148211e236
- app.py: replace print() with logging, basicConfig with LOG_LEVEL env var - api_client.py: shared AsyncClient instance (connection pooling), URL-encode delete_document path parameter, aclose() for cleanup - services/document.py: validate file exists and extension before ingest - tests/: ChatService (4) + DocumentService (6) unit tests via pytest-asyncio - pyproject.toml: asyncio_mode = auto - requirements-dev.txt: pytest, pytest-asyncio Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
308 lines
13 KiB
Markdown
308 lines
13 KiB
Markdown
# 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 추상화 | ✅ 완료 |
|
|
| 테스트 가능성 | Protocol 도입으로 모킹 가능 — 테스트 미작성 | 🟡 중간 |
|
|
| 로깅 | `print()` 만 사용 | 🟢 낮음 |
|
|
|
|
---
|
|
|
|
## 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
|
|
```
|
|
|
|
---
|
|
|
|
## 진행 체크리스트
|
|
|
|
### 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` 인덱싱 방어 로직 추가
|
|
- [x] RAG 출처 전용 박스 분리 — `source_box` gr.HTML + `_sources_html()` + `__sources` 토큰 처리
|
|
|
|
### P1
|
|
- [x] `config.py` 작성 (APIConfig, AppConfig)
|
|
- [x] `api_client.py` — `APIClientProtocol` + `HTTPAPIClient` 분리
|
|
- [x] `services/` 패키지 작성 (chat.py, document.py, tts.py + __init__.py 재익스포트)
|
|
- [x] `container.py` 작성 (lazy singleton 프로퍼티)
|
|
- [x] `app.py` — 모든 콜백 async 전환 및 container 사용 (`asyncio.run()` 완전 제거)
|
|
|
|
### P2
|
|
- [x] `logging` 모듈 도입 — `basicConfig` + `LOG_LEVEL` 환경변수, `print()` 제거
|
|
- [x] httpx `AsyncClient` 재사용 — `HTTPAPIClient.__init__`에서 공유 클라이언트 생성, `aclose()` 추가
|
|
- [x] 입력 검증 추가 — `DocumentService.ingest` 파일 존재·확장자 검증, `delete_document` URL 인코딩
|
|
- [x] `tests/` 단위 테스트 작성 — `pytest-asyncio`, `ChatService` 4개 / `DocumentService` 6개 (10/10 통과)
|
|
|
|
### P3
|
|
- [ ] 재시도 로직
|
|
- [ ] Pydantic Settings
|