# youlbot-webui 개선 로드맵 ## 현황 요약 | 항목 | 현재 상태 | 심각도 | |------|---------|--------| | 아키텍처 모듈화 | ~~2파일 혼재~~ → config / api_client / services/ / container / app 5모듈 분리 | ✅ 완료 | | Windows 호환성 | ~~TTS `say` 명령어 — macOS 전용~~ → 크로스플랫폼 구현 완료 | ✅ 완료 | | Gradio Chatbot 타입 | ~~`type="messages"` 누락~~ → Gradio 6.x 기본 포맷 사용 | ✅ 완료 | | JSON yield 타입 불일치 | ~~`JSONDecodeError` 시 타입 혼용~~ → `str()` 변환 적용 | ✅ 완료 | | run_id 인덱싱 버그 | ~~`history` / `run_ids` 동기화 취약~~ → 방어 로직 추가 | ✅ 완료 | | RAG 출처 표시 | ~~thinking 박스에 혼재~~ → 답변 하단 `📄 출처` 전용 박스로 분리 | ✅ 완료 | | async/sync 혼용 | ~~`asyncio.run()` 5곳~~ → 모든 콜백 async 전환 완료 | ✅ 완료 | | 코드 중복 | ~~`asyncio.run()` 5회 반복~~ → container 위임으로 제거 | ✅ 완료 | | 결합도 | ~~`api_client` 직접 임포트~~ → DI container + Protocol 추상화 | ✅ 완료 | | 테스트 가능성 | ~~모킹 불가능~~ → pytest-asyncio 단위 테스트 10개 작성 | ✅ 완료 | | 로깅 | ~~`print()` 만 사용~~ → `logging` 모듈, `LOG_LEVEL` 환경변수 | ✅ 완료 | --- ## IoC / 의존성 주입 전략 전용 IoC 프레임워크(`dependency-injector` 등)는 현재 규모에 **과도한 복잡도**를 유발합니다. 대신 **수동 DI 패턴**을 적용합니다. - `Protocol` 기반 인터페이스로 `api_client` 추상화 → 테스트 모킹 가능 - `Container` 클래스가 서비스 인스턴스 생성·생명주기 관리 - 향후 규모가 커질 경우 프레임워크로 전환 용이 --- ## 목표 아키텍처 ``` ┌──────────────────────────────────────────┐ │ app.py (Gradio UI) │ │ 콜백 함수는 service 메서드만 호출 │ └──────────────┬───────────────────────────┘ │ 주입받음 ┌──────────────▼───────────────────────────┐ │ container.py (Container) │ │ chat_service, document_service 제공 │ └──────────────┬───────────────────────────┘ │ 생성 ┌──────────────▼───────────────────────────┐ │ services/ │ │ chat.py / document.py / tts.py │ └──────────────┬───────────────────────────┘ │ 사용 ┌──────────────▼───────────────────────────┐ │ api_client.py (APIClientProtocol) │ │ HTTPAPIClient (실구현) │ └──────────────┬───────────────────────────┘ │ 설정 읽음 ┌──────────────▼───────────────────────────┐ │ config.py (AppConfig / APIConfig) │ │ 환경변수 일원화 │ └──────────────────────────────────────────┘ ``` --- ## P0 — 즉시 수정 (버그·호환성) > 현재 Windows 환경에서 실행 불가 또는 런타임 오류 유발 항목 ### 1. 크로스플랫폼 TTS 구현 (`app.py:47-61`) - **문제**: `subprocess.run(["say", ...])` 는 macOS 전용 → Windows에서 동작 불가. - **플랫폼별 우선순위**: - **macOS**: `say`(오프라인, 내장) → `edge-tts`(온라인 폴백) → `pyttsx3`(최종 폴백) - **Windows**: `edge-tts`(온라인) → `pyttsx3`(오프라인 폴백) | 우선순위 | 라이브러리 | 방식 | 품질 | Windows | macOS | |---------|-----------|------|------|---------|-------| | macOS 1순위 | `say` | 오프라인(내장) | ⭐⭐⭐⭐ | ❌ | ✅ | | macOS 2순위 / Windows 1순위 | `edge-tts` | 온라인(MS Edge) | ⭐⭐⭐⭐⭐ | ✅ | ✅ | | 최종 폴백 | `pyttsx3` | 오프라인(SAPI5/NSSpeech) | ⭐⭐⭐ | ✅ | ✅ | ```python async def tts_speak(text: str) -> str | None: """크로스플랫폼 TTS — 플랫폼별 우선순위 적용.""" if not text: return None # macOS: say 우선 (오프라인, 자연스러운 한국어) if platform.system() == "Darwin": try: tmp = tempfile.NamedTemporaryFile(suffix=".aiff", delete=False) tmp.close() await asyncio.to_thread( subprocess.run, ["say", "-v", _TTS_VOICE, "-o", tmp.name, text], check=True, capture_output=True, ) return tmp.name except Exception: pass # Windows 1순위 / macOS say 실패 시: edge-tts (온라인) try: import edge_tts tmp = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) tmp.close() await edge_tts.Communicate(text, _TTS_EDGE_VOICE).save(tmp.name) return tmp.name except Exception: pass # 최종 폴백: pyttsx3 (오프라인) try: import pyttsx3 tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) tmp.close() def _save(): engine = pyttsx3.init() engine.save_to_file(text, tmp.name) engine.runAndWait() await asyncio.to_thread(_save) return tmp.name except Exception: return None ``` - **전역 변수** (`app.py` 상단 추가): ```python _TTS_VOICE = os.getenv("TTS_VOICE", "Yuna") # macOS say _TTS_EDGE_VOICE = os.getenv("TTS_EDGE_VOICE", "ko-KR-SunHiNeural") # edge-tts ``` - **출력 포맷**: macOS say → `.aiff` (기존 유지), edge-tts → `.mp3`, pyttsx3 → `.wav` - **requirements.txt 추가**: `edge-tts>=6.1.9`, `pyttsx3>=2.90` - **주요 edge-tts 한국어 보이스**: `ko-KR-SunHiNeural`(여성), `ko-KR-InJoonNeural`(남성) ### 2. Gradio Chatbot `type="messages"` 누락 (`app.py:186`) - **문제**: Gradio 4.x에서 `{"role": ..., "content": ...}` 딕셔너리 포맷 사용 시 `type="messages"` 를 명시하지 않으면 경고 또는 오류 발생. - **수정**: `gr.Chatbot(type="messages", ...)` 추가. ### 3. JSON yield 타입 불일치 (`api_client.py:45-46`) - **문제**: `JSONDecodeError` 발생 시 `raw`(bytes 또는 str)를 그대로 yield → 반환 타입 `tuple[str, str | None]` 불일치. - **수정**: `yield str(raw), None` 으로 명시적 변환. ### 4. run_id 인덱싱 방어 로직 (`app.py:107-108`) - **문제**: `history` 길이와 `run_ids` 길이가 어긋나면 인덱스 오류 발생 가능. - **수정**: `asst_turn` 계산 후 범위 초과 시 `None` 반환하는 방어 코드 보강. --- ## P1 — 1주일 내 (구조 개선) ### 1. 설정 분리 → `config.py` 신규 생성 ```python # config.py from dataclasses import dataclass, field import os @dataclass class APIConfig: url: str = field(default_factory=lambda: os.getenv("YOULBOT_API_URL", "http://localhost:8000")) token: str = field(default_factory=lambda: os.getenv("YOULBOT_API_TOKEN", "")) timeout: int = 180 @dataclass class AppConfig: api: APIConfig = field(default_factory=APIConfig) whisper_model_size: str = field(default_factory=lambda: os.getenv("WHISPER_MODEL_SIZE", "small")) tts_voice: str = field(default_factory=lambda: os.getenv("TTS_VOICE", "Yuna")) server_host: str = "0.0.0.0" server_port: int = 7860 ``` ### 2. Protocol 인터페이스 + HTTPAPIClient 분리 (`api_client.py` 리팩터링) ```python # api_client.py from typing import Protocol, AsyncIterator, runtime_checkable @runtime_checkable class APIClientProtocol(Protocol): async def chat(self, message: str, user_id: str, show_thinking: bool) -> AsyncIterator[tuple[str, str | None]]: ... async def reset(self, user_id: str) -> None: ... async def ingest(self, file_path: str) -> dict: ... async def list_documents(self) -> list[str]: ... async def delete_document(self, source: str) -> None: ... async def save_feedback(self, user_id: str, user_msg: str, asst_msg: str, rating: int, run_id: str | None) -> None: ... class HTTPAPIClient: def __init__(self, config: APIConfig): ... # 기존 함수들을 메서드로 이전 ``` ### 3. 서비스 레이어 분리 → `services/` 패키지 신규 생성 ``` services/ ├── __init__.py — ChatService, DocumentService, TTSService 재익스포트 ├── chat.py — ChatService: chat, reset, save_feedback ├── document.py — DocumentService: ingest, list_documents, delete_document └── tts.py — TTSService: speak (플랫폼 분기) ``` ### 4. 수동 DI 컨테이너 → `container.py` 신규 생성 ```python # container.py class Container: def __init__(self, config: AppConfig): self._config = config self._api_client: HTTPAPIClient | None = None self._chat_service: ChatService | None = None self._document_service: DocumentService | None = None @property def api_client(self) -> HTTPAPIClient: ... @property def chat_service(self) -> ChatService: ... @property def document_service(self) -> DocumentService: ... ``` ### 5. Async 콜백 통일 (`app.py`) - `handle_feedback`, `reset_chat`, `ingest_files`, `list_docs`, `delete_doc` → 모두 `async def` 로 전환 - `asyncio.get_event_loop().run_until_complete()` 패턴 완전 제거 - Gradio 4.x async 콜백 지원 활용 --- ## P2 — 2주일 내 (품질 개선) | # | 항목 | 설명 | |---|------|------| | 1 | 로깅 시스템 | `print()` → `logging` 모듈, 구조적 로그 포맷 | | 2 | httpx 연결 풀 공유 | `HTTPAPIClient`에서 `AsyncClient` 인스턴스 재사용 | | 3 | 입력 검증 | 파일 경로 sanitize, URL path parameter 검증 | | 4 | 단위 테스트 | `tests/` 폴더 생성, `ChatService` / `DocumentService` Mock 테스트 | --- ## P3 — 선택 사항 (장기) | # | 항목 | 설명 | |---|------|------| | 1 | 재시도 로직 | `tenacity` 라이브러리 또는 수동 exponential backoff | | 2 | Pydantic Settings | `pydantic-settings` 로 타입 안전 환경변수 관리 | | 3 | IoC 프레임워크 전환 | 규모 확장 시 `dependency-injector` 도입 검토 | --- ## 최종 파일 구조 (목표) ``` youlbot-webui/ ├── app.py # Gradio UI 전용 — 콜백만 존재, 비즈니스 로직 없음 ├── container.py # 수동 DI 컨테이너 ├── services/ │ ├── __init__.py # 재익스포트 │ ├── chat.py # ChatService │ ├── document.py # DocumentService │ └── tts.py # TTSService ├── api_client.py # APIClientProtocol + HTTPAPIClient ├── config.py # AppConfig, APIConfig dataclass ├── tests/ │ ├── test_chat_service.py │ └── test_document_service.py ├── requirements.txt ├── .env.example └── ROADMAP.md ``` --- ## UI/UX 디자인 개선 > 실제 UI 스크린샷 분석(2026-06-02) 기반 — 우선순위 순 정렬 ### D0 — 즉시 수정 (노출 결함) | # | 위치 | 문제 | 개선 방안 | |---|------|------|-----------| | 1 | 대화 탭 · 입력창 | `"Textbox"` 레이블이 입력창 위에 그대로 노출 | `gr.Textbox(label="", show_label=False, ...)` 또는 `label=None` | | 2 | 대화 탭 · 음성 영역 | `"마이크를 찾을 수 ..."` 오류 메시지가 항상 노출 | 마이크 미사용 시 해당 텍스트 숨김 처리, 또는 `visible=False` 기본값 | | 3 | 전체 탭 · 푸터 | `"Gradio로 제작됨 🎉"` Gradio 브랜딩 노출 | `css="footer { display: none; }"` 추가 또는 `gr.Blocks(show_footer=False)` | | 4 | 문서 등록 탭 · 결과 박스 | 업로드 전에도 빈 `결과` 박스가 항상 노출 | 초기 `visible=False`, 수집 완료 시에만 `visible=True` 반환 | ### D1 — 1주일 내 (레이아웃 개선) | # | 위치 | 문제 | 개선 방안 | |---|------|------|-----------| | 5 | 대화 탭 · 채팅 영역 | 초기 화면이 빈 흰 공간으로 시작 — 어색한 첫 인상 | 웰컴 메시지 또는 예시 질문 버블(`gr.Examples`) 추가 | | 6 | 대화 탭 · 이미지 첨부 | 이미지 업로드 영역이 항상 전체 크기로 펼쳐짐 | `gr.Accordion("이미지 첨부 (선택)", open=False)` 로 접이식 변경 | | 7 | 대화 탭 · 하단 컨트롤 | `사고 과정 표시`, `TTS`, `대화 초기화` 배치가 불규칙 | `gr.Row`로 균등 3분할, `대화 초기화`는 오른쪽 정렬 | | 8 | 대화 탭 · 입력 영역 | 텍스트 입력창과 `전송` 버튼이 시각적으로 분리됨 | 입력창 높이를 `lines=2`로 통일, 버튼 높이 CSS로 맞춤 | | 9 | 문서 관리 탭 · 삭제 UX | 경로를 테이블에서 복사해 입력 필드에 붙여넣기 해야 함 | 테이블 행 클릭 → 입력 필드 자동 채움 (`select` 이벤트 활용) | | 10 | 문서 등록 탭 · 버튼 | `문서 수집` 버튼이 전체 너비를 차지해 무게감 과도 | `scale=0` 또는 `min_width=200` 으로 적정 크기 조절 | ### D2 — 2주일 내 (시각 품질) | # | 항목 | 설명 | |---|------|------| | 11 | 헤더 브랜딩 | 텍스트 전용 헤더 → 아이콘/로고 이미지 + 서브타이틀 레이아웃으로 개선 | | 12 | 커스텀 테마 | Gradio 기본 보라색 → `gr.themes.Soft()` 또는 커스텀 `primary_hue` 색상 지정 | | 13 | 사용자 선택 위치 | `사용자` 드롭다운이 채팅 위에 있어 흐름 방해 → 헤더 우측 또는 사이드바로 이동 | | 14 | 채팅 버블 스타일 | Gradio 기본 스타일 → CSS로 사용자/봇 버블 배경색·radius 차별화 | | 15 | 응답 로딩 표시 | 스트리밍 중 시각적 피드백 없음 → 입력 비활성화 + 스피너 CSS 추가 | | 16 | 반응형 레이아웃 | 좁은 뷰포트에서 요소 겹침 → `gr.Column`/`gr.Row` 비율 재조정 | ### D3 — 선택 사항 (장기) | # | 항목 | 설명 | |---|------|------| | 17 | 다크 모드 | `gr.themes.Base()` + CSS 변수로 다크/라이트 토글 지원 | | 18 | 채팅 내보내기 | 대화 내용을 `.txt`/`.md`로 다운로드하는 버튼 추가 | | 19 | 접근성 | `aria-label` 속성 추가, 키보드 포커스 표시 개선 | | 20 | 온보딩 투어 | 첫 방문 사용자 대상 단계별 기능 안내 (JS 오버레이) | ### D 체크리스트 #### D0 - [x] `gr.Textbox(show_label=False)` — `"Textbox"` 레이블 제거 - [x] 음성 오류 메시지 기본 숨김 처리 — `gr.Accordion("🎤 음성으로 질문하기", open=False)` 로 기본 접힘 - [x] Gradio 푸터 CSS 숨김 — `footer { display: none !important; }` 추가 - [x] `결과` 박스 초기 `visible=False` — `ingest_files`에서 `gr.update(visible=True)` 반환 #### D1 - [x] 웰컴 메시지 또는 예시 질문 버블 추가 — `gr.Examples` 3개 예시 질문 - [x] 이미지 첨부 영역 `gr.Accordion`으로 접이식 변경 — `open=False` 기본 접힘 - [x] 하단 컨트롤 `gr.Row` 균등 배치 — 체크박스 좌측 `Column(scale=3)`, 초기화 버튼 우측 `Column(scale=1)` - [x] 입력창 `lines=2` + 전송 버튼 높이 CSS 맞춤 — `.send-btn { min-height: 80px }` - [x] 문서 관리 탭 — 테이블 행 클릭 → 삭제 경로 자동 채움 — `doc_table.select` + `select_doc_row` - [x] `문서 수집` 버튼 크기 적정화 — `scale=0, min_width=200` #### D2 - [x] 헤더 아이콘/로고 추가 — `gr.HTML` 🤖 아이콘 + 타이틀/서브타이틀 레이아웃 - [x] 커스텀 테마 적용 — `gr.themes.Soft(primary_hue="blue", secondary_hue="indigo", neutral_hue="slate")` - [x] 사용자 드롭다운 위치 이동 — 탭 내부 → 헤더 Row 우측 (`scale=0, min_width=160`) - [x] 채팅 버블 커스텀 CSS — `.message-wrap .user` 파란 배경, `.message-wrap .bot` 회색 배경 - [x] 스트리밍 로딩 표시 — `_live_html`에 `streaming-indicator` blink 애니메이션 - [x] 반응형 레이아웃 — `@media (max-width: 768px)` 모바일 대응 CSS #### D3 - [x] 다크 모드 토글 — 헤더 🌙/☀️ 버튼 + `localStorage` 기억 + 시스템 설정 자동 감지 - [x] 채팅 내보내기 — `export_chat` 함수, `.md` 파일 다운로드, `gr.File` 출력 - [x] 접근성 개선 — `aria-label`/`role`/`aria-live` JS 주입, `:focus-visible` 파란 테두리 - [x] 온보딩 투어 — 첫 방문 모달 (`localStorage` 체크, "시작하기" 버튼으로 닫기) --- ### D4 — 시안 기반 UI 리뉴얼 (Gemini 스타일) > 참고 시안: Google Gemini 스타일 — 좌측 사이드바 + 중앙 웰컴 화면 + 글로우 그라디언트 #### 시안 vs 현재 비교 | 항목 | 현재 율봇 | 시안 (Gemini 스타일) | |------|----------|---------------------| | 네비게이션 | 상단 수평 탭 3개 | 좌측 접이식 사이드바 | | 빈 화면 | 빈 흰 공간 + 예시 질문 하단 | 중앙 개인화 인사 + 글로우 배경 | | 입력창 위치 | 항상 하단 고정 | 빈 상태: 중앙 / 대화 중: 하단 | | 입력창 모양 | 직사각형 | Pill 형태(완전 둥근 테두리) | | 예시 질문 | 레이블 있는 리스트 | 레이블 없는 수평 chip 카드 | | 컨트롤 위치 | 항상 노출(체크박스·버튼 3개) | 아이콘 버튼으로 숨김 처리 | | 사용자 표시 | 드롭다운(헤더 우측) | 사이드바 하단 프로필 영역 | | 대화 이력 | 없음 | 사이드바에 최근 대화 목록 | #### D4-1 — 좌측 사이드바 레이아웃 > 현재 탭 구조를 사이드바로 전환하는 핵심 레이아웃 변경 - **구조**: `gr.Blocks` 내에 `gr.Column(scale=1)` 사이드바 + `gr.Column(scale=5)` 메인 영역 - **사이드바 구성**: - 상단: 율봇 로고 + 사이드바 접기 버튼(☰) - 중간: 네비게이션 (`💬 대화`, `📄 문서 등록`, `🗂 문서 관리`) - 하단: 사용자 프로필(아이콘 + 이름) - **사이드바 접기**: JS로 `sidebar-collapsed` CSS 클래스 토글 → `max-width: 0` 전환 - **현재 탭** → `gr.Tab` 제거, 메인 영역을 `gr.Column(visible=...)` 3개로 교체 - **CSS**: `.sidebar { width: 260px; transition: width .25s; }` + `.sidebar-collapsed { width: 64px; }` #### D4-2 — 빈 화면 웰컴 뷰 (중앙 인사 + 글로우) > 대화가 없을 때 Gemini 스타일 중앙 화면 표시 - **개인화 인사**: `"{user_name}님, 시작해 볼까요?"` — 사용자 선택값 반영 - **글로우 그라디언트**: 화면 중앙에 파란/하늘색 방사형 그라디언트 광원 효과 ```css .welcome-glow { background: radial-gradient(ellipse 60% 50% at 50% 40%, rgba(99,179,237,.25) 0%, rgba(147,197,253,.12) 40%, transparent 70%); } ``` - **전환 조건**: `chatbot` 히스토리가 비어있으면 웰컴 뷰 표시, 첫 메시지 전송 시 채팅 뷰로 전환 - **구현**: `gr.HTML(elem_id="welcome-view")` + JS로 `chatbot` 내 메시지 수 감지 → visibility 토글 #### D4-3 — Pill 형태 입력창 + 위치 전환 > 입력창을 완전 둥근 pill 스타일로 변경하고 빈 상태에서는 화면 중앙에 배치 - **Pill CSS**: ```css .pill-input textarea { border-radius: 999px !important; padding: 14px 24px !important; } .pill-input { border-radius: 999px !important; } ``` - **중앙 배치 (빈 상태)**: 웰컴 뷰 안에 별도 입력 컨테이너 배치 - `position: relative; max-width: 680px; margin: 0 auto;` - **아이콘 통합**: 입력창 좌측에 `+` (첨부), 우측에 전송 화살표 아이콘 - **전환**: 첫 전송 후 하단 고정 입력창으로 자연스럽게 전환 (CSS transition) #### D4-4 — 예시 질문 Chip 카드 스타일 > 현재 리스트형 예시 질문을 시안의 수평 카드 chip으로 교체 - **현재**: `gr.Examples` 컴포넌트 (세로 리스트, 레이블 있음) - **변경**: `gr.HTML`로 직접 렌더링한 수평 chip 카드 ```html