diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..7767a40 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,300 @@ +# youlbot-webui 개선 로드맵 + +## 현황 요약 + +| 항목 | 현재 상태 | 심각도 | +|------|---------|--------| +| 아키텍처 모듈화 | UI·비즈니스 로직 혼재 (2파일) | 🔴 높음 | +| Windows 호환성 | TTS `say` 명령어 — macOS 전용 | 🔴 즉시 | +| Gradio Chatbot 타입 | `type="messages"` 누락 | 🔴 즉시 | +| JSON yield 타입 불일치 | `JSONDecodeError` 시 타입 혼용 | 🟡 중간 | +| run_id 인덱싱 버그 | `history` / `run_ids` 동기화 취약 | 🟡 중간 | +| async/sync 혼용 | 동기 콜백에서 `run_until_complete` 사용 | 🟡 중간 | +| 코드 중복 | `run_until_complete` 패턴 5회 반복 | 🟡 중간 | +| 결합도 | `api_client` 직접 임포트·전역 상태 | 🔴 높음 | +| 테스트 가능성 | ~20% (모킹 불가능) | 🔴 낮음 | +| 로깅 | `print()` 만 사용 | 🟢 낮음 | + +--- + +## IoC / 의존성 주입 전략 + +전용 IoC 프레임워크(`dependency-injector` 등)는 현재 규모에 **과도한 복잡도**를 유발합니다. +대신 **수동 DI 패턴**을 적용합니다. + +- `Protocol` 기반 인터페이스로 `api_client` 추상화 → 테스트 모킹 가능 +- `Container` 클래스가 서비스 인스턴스 생성·생명주기 관리 +- 향후 규모가 커질 경우 프레임워크로 전환 용이 + +--- + +## 목표 아키텍처 + +``` +┌──────────────────────────────────────────┐ +│ app.py (Gradio UI) │ +│ 콜백 함수는 service 메서드만 호출 │ +└──────────────┬───────────────────────────┘ + │ 주입받음 +┌──────────────▼───────────────────────────┐ +│ container.py (Container) │ +│ chat_service, document_service 제공 │ +└──────────────┬───────────────────────────┘ + │ 생성 +┌──────────────▼───────────────────────────┐ +│ services.py │ +│ ChatService / DocumentService / TTSService │ +└──────────────┬───────────────────────────┘ + │ 사용 +┌──────────────▼───────────────────────────┐ +│ 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.py` 신규 생성 + +``` +services.py +├── ChatService(api_client) — chat, reset, save_feedback +├── DocumentService(api_client) — ingest, list_documents, delete_document +└── TTSService() — tts_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.py # ChatService, DocumentService, 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 +``` + +--- + +## 진행 체크리스트 + +### P0 +- [x] `tts_speak()` 크로스플랫폼 구현 (macOS: say→edge-tts→pyttsx3 / Windows: edge-tts→pyttsx3) +- [x] `requirements.txt` — `edge-tts>=6.1.9`, `pyttsx3>=2.90` 추가 +- [x] `.env.example` — `TTS_EDGE_VOICE=ko-KR-SunHiNeural` 항목 추가 (`TTS_VOICE=Yuna` 유지) +- [x] `gr.Chatbot` — Gradio 6.x 기본 dict 포맷 사용 (`type` 파라미터 불필요, 제거) +- [x] `api_client.py` JSON yield 타입 수정 +- [x] `run_id` 인덱싱 방어 로직 추가 + +### P1 +- [ ] `config.py` 작성 +- [ ] `api_client.py` — `APIClientProtocol` + `HTTPAPIClient` 분리 +- [ ] `services.py` 작성 (ChatService, DocumentService, TTSService) +- [ ] `container.py` 작성 +- [ ] `app.py` — 모든 콜백 async 전환 및 container 사용 + +### P2 +- [ ] `logging` 모듈 도입 +- [ ] httpx `AsyncClient` 재사용 +- [ ] 입력 검증 추가 +- [ ] `tests/` 단위 테스트 작성 + +### P3 +- [ ] 재시도 로직 +- [ ] Pydantic Settings