diff --git a/.env.example b/.env.example index 8e11c80..8da45c1 100644 --- a/.env.example +++ b/.env.example @@ -2,9 +2,10 @@ YOULBOT_API_URL=http://localhost:8000 # API 토큰 (youlbot/.env의 API_TOKEN과 동일하게 설정. 빈 값이면 개발 모드) -YOULBOT_API_TOKEN= +YOULBOT_API_TOKEN=youlbot-ai-token!!@@1234 # 음성 설정 (로컬 실행) WHISPER_MODEL_SIZE=small -TTS_VOICE=Yuna # macOS say 보이스 이름 -TTS_EDGE_VOICE=ko-KR-SunHiNeural # edge-tts 보이스 (여성). 남성: ko-KR-InJoonNeural +TTS_VOICE=Yuna +TTS_EDGE_VOICE=ko-KR-SunHiNeural +LOG_LEVEL=INFO diff --git a/.gitignore b/.gitignore index f10862a..9eab973 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ /.env +.idea/ +.claude/ +__pycache__/ +*.pyc +*.pyo diff --git a/ROADMAP.md b/ROADMAP.md index 7767a40..d8d05ab 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,16 +4,17 @@ | 항목 | 현재 상태 | 심각도 | |------|---------|--------| -| 아키텍처 모듈화 | 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()` 만 사용 | 🟢 낮음 | +| 아키텍처 모듈화 | ~~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` 환경변수 | ✅ 완료 | --- @@ -42,8 +43,8 @@ └──────────────┬───────────────────────────┘ │ 생성 ┌──────────────▼───────────────────────────┐ -│ services.py │ -│ ChatService / DocumentService / TTSService │ +│ services/ │ +│ chat.py / document.py / tts.py │ └──────────────┬───────────────────────────┘ │ 사용 ┌──────────────▼───────────────────────────┐ @@ -193,13 +194,14 @@ class HTTPAPIClient: # 기존 함수들을 메서드로 이전 ``` -### 3. 서비스 레이어 분리 → `services.py` 신규 생성 +### 3. 서비스 레이어 분리 → `services/` 패키지 신규 생성 ``` -services.py -├── ChatService(api_client) — chat, reset, save_feedback -├── DocumentService(api_client) — ingest, list_documents, delete_document -└── TTSService() — tts_speak (플랫폼 분기) +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` 신규 생성 @@ -258,10 +260,14 @@ class Container: ``` youlbot-webui/ ├── app.py # Gradio UI 전용 — 콜백만 존재, 비즈니스 로직 없음 -├── container.py # 수동 DI 컨테이너 (신규) -├── services.py # ChatService, DocumentService, TTSService (신규) -├── api_client.py # APIClientProtocol + HTTPAPIClient (리팩터링) -├── config.py # AppConfig, APIConfig dataclass (신규) +├── 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 @@ -281,20 +287,22 @@ youlbot-webui/ - [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 -- [ ] `config.py` 작성 -- [ ] `api_client.py` — `APIClientProtocol` + `HTTPAPIClient` 분리 -- [ ] `services.py` 작성 (ChatService, DocumentService, TTSService) -- [ ] `container.py` 작성 -- [ ] `app.py` — 모든 콜백 async 전환 및 container 사용 +- [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 -- [ ] `logging` 모듈 도입 -- [ ] httpx `AsyncClient` 재사용 -- [ ] 입력 검증 추가 -- [ ] `tests/` 단위 테스트 작성 +- [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 +- [x] 재시도 로직 — `tenacity` / 5xx·TransportError에만 최대 3회, 지수 백오프(1→8s) / `chat` 제외 5개 메서드 적용 +- [x] Pydantic Settings — `config.py` dataclass → `BaseSettings` (flat `AppConfig`), `.env` 자동 로드 +- [x] IoC 프레임워크 전환 — 수동 DI → `dependency-injector` `DeclarativeContainer` + `providers.Singleton` diff --git a/api_client.py b/api_client.py index 37c61ed..e429be2 100644 --- a/api_client.py +++ b/api_client.py @@ -1,39 +1,68 @@ -"""율봇 API 클라이언트 — youlbot REST API(Phase 22)를 httpx로 호출.""" +"""율봇 API 클라이언트 — APIClientProtocol 인터페이스 + HTTPAPIClient 구현.""" import json import os -from typing import AsyncIterator +import urllib.parse +from typing import AsyncIterator, Protocol, runtime_checkable import httpx -from dotenv import load_dotenv +from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential -load_dotenv() - -_API_URL = os.getenv("YOULBOT_API_URL", "http://localhost:8000").rstrip("/") -_API_TOKEN = os.getenv("YOULBOT_API_TOKEN", "") +from config import AppConfig -def _headers() -> dict: - if _API_TOKEN: - return {"Authorization": f"Bearer {_API_TOKEN}"} - return {} +def _is_transient(exc: Exception) -> bool: + if isinstance(exc, httpx.HTTPStatusError): + return exc.response.status_code >= 500 + return isinstance(exc, httpx.TransportError) -async def chat( - message: str, - user_id: str = "default", - show_thinking: bool = False, -) -> AsyncIterator[tuple[str, str | None]]: - """SSE 스트림을 읽어 (token, run_id) 튜플을 yield. +_retry = retry( + retry=retry_if_exception(_is_transient), + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=1, max=8), + reraise=True, +) - - 일반 토큰: (token_str, None) - - 스트림 종료: ("", run_id_or_None) ← __done 이벤트 - """ - async with httpx.AsyncClient(timeout=180) as client: - async with client.stream( + +@runtime_checkable +class APIClientProtocol(Protocol): + 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: AppConfig): + self._url = config.youlbot_api_url.rstrip("/") + self._timeout = config.youlbot_api_timeout + self._client = httpx.AsyncClient( + headers={"Authorization": f"Bearer {config.youlbot_api_token}"} + if config.youlbot_api_token else {}, + ) + + async def chat( + self, + message: str, + user_id: str = "default", + show_thinking: bool = False, + ) -> AsyncIterator[tuple[str, str | None]]: + async with self._client.stream( "POST", - f"{_API_URL}/chat", + f"{self._url}/chat", json={"message": message, "user_id": user_id, "show_thinking": show_thinking}, - headers=_headers(), + timeout=self._timeout, ) as response: response.raise_for_status() async for line in response.aiter_lines(): @@ -50,56 +79,50 @@ async def chat( return yield payload, None - -async def reset(user_id: str = "default") -> None: - async with httpx.AsyncClient(timeout=30) as client: - r = await client.post( - f"{_API_URL}/reset", + @_retry + async def reset(self, user_id: str = "default") -> None: + r = await self._client.post( + f"{self._url}/reset", params={"user_id": user_id}, - headers=_headers(), + timeout=30, ) r.raise_for_status() - -async def ingest(file_path: str) -> dict: - async with httpx.AsyncClient(timeout=300) as client: + @_retry + async def ingest(self, file_path: str) -> dict: with open(file_path, "rb") as f: filename = os.path.basename(file_path) - r = await client.post( - f"{_API_URL}/ingest", + r = await self._client.post( + f"{self._url}/ingest", files={"file": (filename, f, "application/octet-stream")}, - headers=_headers(), + timeout=300, ) r.raise_for_status() return r.json() - -async def list_documents() -> list[str]: - async with httpx.AsyncClient(timeout=30) as client: - r = await client.get(f"{_API_URL}/documents", headers=_headers()) + @_retry + async def list_documents(self) -> list[str]: + r = await self._client.get(f"{self._url}/documents", timeout=30) r.raise_for_status() return r.json().get("documents", []) - -async def delete_document(source: str) -> None: - async with httpx.AsyncClient(timeout=30) as client: - r = await client.delete( - f"{_API_URL}/documents/{source}", - headers=_headers(), - ) + @_retry + async def delete_document(self, source: str) -> None: + encoded = urllib.parse.quote(source, safe="") + r = await self._client.delete(f"{self._url}/documents/{encoded}", timeout=30) r.raise_for_status() - -async def save_feedback( - user_id: str, - user_msg: str, - asst_msg: str, - rating: int, - run_id: str | None = None, -) -> None: - async with httpx.AsyncClient(timeout=30) as client: - r = await client.post( - f"{_API_URL}/feedback", + @_retry + async def save_feedback( + self, + user_id: str, + user_msg: str, + asst_msg: str, + rating: int, + run_id: str | None = None, + ) -> None: + r = await self._client.post( + f"{self._url}/feedback", json={ "user_id": user_id, "user_msg": user_msg, @@ -107,6 +130,9 @@ async def save_feedback( "rating": rating, "run_id": run_id, }, - headers=_headers(), + timeout=30, ) r.raise_for_status() + + async def aclose(self) -> None: + await self._client.aclose() diff --git a/app.py b/app.py index e9e04fb..789eaee 100644 --- a/app.py +++ b/app.py @@ -7,35 +7,38 @@ YOULBOT_API_URL=http://localhost:8000 YOULBOT_API_TOKEN= ← api.py에 API_TOKEN 설정 시 동일 값 """ -import asyncio import html as _html +import logging import os -import platform -import subprocess -import tempfile import gradio as gr from dotenv import load_dotenv load_dotenv() -import api_client +logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO").upper(), + format="%(asctime)s %(levelname)-8s %(name)s — %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) +logger = logging.getLogger(__name__) + +from container import Container + +container = Container() USER_LABELS = ["아록", "근혜", "도율", "하율"] DEFAULT_USER = "아록" # ── STT (Whisper) — 로컬 실행 유지 ────────────────────────────── _whisper_model = None -_WHISPER_SIZE = os.getenv("WHISPER_MODEL_SIZE", "small") -_TTS_VOICE = os.getenv("TTS_VOICE", "Yuna") # macOS say 보이스 -_TTS_EDGE_VOICE = os.getenv("TTS_EDGE_VOICE", "ko-KR-SunHiNeural") # edge-tts 보이스 def _get_whisper(): global _whisper_model if _whisper_model is None: import whisper - _whisper_model = whisper.load_model(_WHISPER_SIZE) + _whisper_model = whisper.load_model(container.config().whisper_model_size) return _whisper_model @@ -47,51 +50,6 @@ def transcribe_audio(filepath: str) -> str: return result["text"].strip() -async def tts_speak(text: str) -> str | None: - """크로스플랫폼 TTS. macOS: say→edge-tts→pyttsx3 / Windows: edge-tts→pyttsx3""" - 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 - - # ── 채팅 ───────────────────────────────────────────────────────── async def respond(message, history, show_thinking, user_id, use_tts, run_ids): @@ -110,10 +68,9 @@ async def respond(message, history, show_thinking, user_id, use_tts, run_ids): thinking_acc = "" # 전체 누적 (완료 후 details용) thinking_text = "" # __thinking 토큰만 (줄 감지용) thinking_finalized = False - source_box_html = "" try: - async for token, run_id in api_client.chat(message, user_id, show_thinking): + async for token, run_id in container.chat_service().chat(message, user_id, show_thinking): if run_id is not None: collected_run_id = run_id break @@ -162,13 +119,13 @@ async def respond(message, history, show_thinking, user_id, use_tts, run_ids): run_ids.append(collected_run_id) if use_tts: - audio_path = await tts_speak(tts_text) + audio_path = await container.tts_service().speak(tts_text) yield history, "", audio_path, run_ids, gr.update(), gr.update() else: yield history, "", None, run_ids, gr.update(), gr.update() -def handle_feedback(like_data: gr.LikeData, history, run_ids, user_id): +async def handle_feedback(like_data: gr.LikeData, history, run_ids, user_id): idx = like_data.index if isinstance(idx, (list, tuple)): idx = idx[0] @@ -176,7 +133,6 @@ def handle_feedback(like_data: gr.LikeData, history, run_ids, user_id): return if history[idx].get("role") != "assistant": return - # idx 위치까지 등장한 assistant 메시지 수 = 이 메시지의 0-based 턴 번호 asst_turn = sum(1 for m in history[:idx] if m.get("role") == "assistant") run_id = run_ids[asst_turn] if run_ids and asst_turn < len(run_ids) else None @@ -185,33 +141,33 @@ def handle_feedback(like_data: gr.LikeData, history, run_ids, user_id): rating = 1 if like_data.liked else -1 try: - asyncio.run(api_client.save_feedback(user_id, user_msg, asst_msg, rating, run_id)) + await container.chat_service().save_feedback(user_id, user_msg, asst_msg, rating, run_id) except Exception as e: - print(f"[Feedback] 저장 실패: {e}") + logger.error("피드백 저장 실패: %s", e) def switch_user(user_id): return [], [] -def reset_chat(user_id): +async def reset_chat(user_id): try: - asyncio.run(api_client.reset(user_id)) + await container.chat_service().reset(user_id) except Exception as e: - print(f"[Reset] 실패: {e}") + logger.error("대화 초기화 실패: %s", e) return [], [] # ── 문서 관리 ───────────────────────────────────────────────────── -def ingest_files(files): +async def ingest_files(files): if not files: return "파일을 선택해주세요." paths = [f if isinstance(f, str) else f.name for f in files] results = [] for path in paths: try: - result = asyncio.run(api_client.ingest(path)) + result = await container.document_service().ingest(path) name = os.path.basename(path) results.append(f"{name} → {result.get('chunks', '?')}개 청크") except Exception as e: @@ -219,22 +175,22 @@ def ingest_files(files): return "\n".join(results) -def list_docs(): +async def list_docs(): try: - sources = asyncio.run(api_client.list_documents()) + sources = await container.document_service().list_documents() return [[os.path.basename(s), s] for s in sources] except Exception as e: return [[f"오류: {e}", ""]] -def delete_doc(source): +async def delete_doc(source): if not source.strip(): - return "삭제할 파일 경로를 입력하세요.", list_docs() + return "삭제할 파일 경로를 입력하세요.", await list_docs() try: - asyncio.run(api_client.delete_document(source.strip())) - return f"삭제 완료: {os.path.basename(source.strip())}", list_docs() + await container.document_service().delete_document(source.strip()) + return f"삭제 완료: {os.path.basename(source.strip())}", await list_docs() except Exception as e: - return f"오류: {e}", list_docs() + return f"오류: {e}", await list_docs() # ── UI 구성 ────────────────────────────────────────────────────── @@ -407,4 +363,8 @@ with gr.Blocks(title="율봇") as demo: if __name__ == "__main__": - demo.launch(server_name="0.0.0.0", server_port=7860, theme=gr.themes.Soft()) + demo.launch( + server_name=container.config().server_host, + server_port=container.config().server_port, + theme=gr.themes.Soft(), + ) diff --git a/config.py b/config.py new file mode 100644 index 0000000..6616007 --- /dev/null +++ b/config.py @@ -0,0 +1,18 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class AppConfig(BaseSettings): + # API (env: YOULBOT_API_URL, YOULBOT_API_TOKEN, YOULBOT_API_TIMEOUT) + youlbot_api_url: str = "http://localhost:8000" + youlbot_api_token: str = "" + youlbot_api_timeout: int = 180 + # STT/TTS (env: WHISPER_MODEL_SIZE, TTS_VOICE, TTS_EDGE_VOICE) + whisper_model_size: str = "small" + tts_voice: str = "Yuna" + tts_edge_voice: str = "ko-KR-SunHiNeural" + # 서버 / 로깅 + log_level: str = "INFO" + server_host: str = "0.0.0.0" + server_port: int = 7860 + + model_config = SettingsConfigDict(env_file=".env", extra="ignore") diff --git a/container.py b/container.py new file mode 100644 index 0000000..5bee74a --- /dev/null +++ b/container.py @@ -0,0 +1,15 @@ +from dependency_injector import containers, providers + +from api_client import HTTPAPIClient +from config import AppConfig +from services import ChatService, DocumentService, TTSService + + +class Container(containers.DeclarativeContainer): + config = providers.Singleton(AppConfig) + + api_client = providers.Singleton(HTTPAPIClient, config=config) + + chat_service = providers.Singleton(ChatService, api_client=api_client) + document_service = providers.Singleton(DocumentService, api_client=api_client) + tts_service = providers.Singleton(TTSService, config=config) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6eb3df5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,2 @@ +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..df39e2a --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +pytest>=8.0.0 +pytest-asyncio>=0.23.0 diff --git a/requirements.txt b/requirements.txt index 5168873..74d827e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,6 @@ python-dotenv>=1.0.0 openai-whisper>=20231117 edge-tts>=6.1.9 pyttsx3>=2.90 +pydantic-settings>=2.0.0 +tenacity>=8.0.0 +dependency-injector>=4.41.0 diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..ec02651 --- /dev/null +++ b/services/__init__.py @@ -0,0 +1,5 @@ +from services.chat import ChatService +from services.document import DocumentService +from services.tts import TTSService + +__all__ = ["ChatService", "DocumentService", "TTSService"] diff --git a/services/chat.py b/services/chat.py new file mode 100644 index 0000000..2e58ae8 --- /dev/null +++ b/services/chat.py @@ -0,0 +1,26 @@ +from typing import AsyncIterator + +from api_client import APIClientProtocol + + +class ChatService: + def __init__(self, api_client: APIClientProtocol): + self._api = api_client + + def chat( + self, message: str, user_id: str, show_thinking: bool + ) -> AsyncIterator[tuple[str, str | None]]: + return self._api.chat(message, user_id, show_thinking) + + async def reset(self, user_id: str) -> None: + await self._api.reset(user_id) + + async def save_feedback( + self, + user_id: str, + user_msg: str, + asst_msg: str, + rating: int, + run_id: str | None, + ) -> None: + await self._api.save_feedback(user_id, user_msg, asst_msg, rating, run_id) diff --git a/services/document.py b/services/document.py new file mode 100644 index 0000000..b4ed2c3 --- /dev/null +++ b/services/document.py @@ -0,0 +1,24 @@ +from pathlib import Path + +from api_client import APIClientProtocol + +_ALLOWED_EXTENSIONS = {".pdf", ".txt"} + + +class DocumentService: + def __init__(self, api_client: APIClientProtocol): + self._api = api_client + + async def ingest(self, file_path: str) -> dict: + path = Path(file_path) + if not path.is_file(): + raise ValueError(f"파일을 찾을 수 없습니다: {file_path}") + if path.suffix.lower() not in _ALLOWED_EXTENSIONS: + raise ValueError(f"지원하지 않는 파일 형식입니다: {path.suffix} (허용: pdf, txt)") + return await self._api.ingest(file_path) + + async def list_documents(self) -> list[str]: + return await self._api.list_documents() + + async def delete_document(self, source: str) -> None: + await self._api.delete_document(source) diff --git a/services/tts.py b/services/tts.py new file mode 100644 index 0000000..c5425d7 --- /dev/null +++ b/services/tts.py @@ -0,0 +1,53 @@ +import asyncio +import platform +import subprocess +import tempfile + +from config import AppConfig + + +class TTSService: + def __init__(self, config: AppConfig): + self._voice = config.tts_voice + self._edge_voice = config.tts_edge_voice + + async def speak(self, text: str) -> str | None: + """크로스플랫폼 TTS. macOS: say→edge-tts→pyttsx3 / Windows: edge-tts→pyttsx3""" + if not text: + return None + + if platform.system() == "Darwin": + try: + tmp = tempfile.NamedTemporaryFile(suffix=".aiff", delete=False) + tmp.close() + await asyncio.to_thread( + subprocess.run, + ["say", "-v", self._voice, "-o", tmp.name, text], + check=True, + capture_output=True, + ) + return tmp.name + except Exception: + pass + + try: + import edge_tts + tmp = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) + tmp.close() + await edge_tts.Communicate(text, self._edge_voice).save(tmp.name) + return tmp.name + except Exception: + pass + + 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 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_chat_service.py b/tests/test_chat_service.py new file mode 100644 index 0000000..8acf98d --- /dev/null +++ b/tests/test_chat_service.py @@ -0,0 +1,40 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock + +from services.chat import ChatService + + +@pytest.fixture +def mock_api(): + api = MagicMock() + api.reset = AsyncMock() + api.save_feedback = AsyncMock() + return api + + +@pytest.fixture +def service(mock_api): + return ChatService(mock_api) + + +async def test_reset_delegates_to_api(service, mock_api): + await service.reset("user1") + mock_api.reset.assert_awaited_once_with("user1") + + +async def test_save_feedback_delegates_to_api(service, mock_api): + await service.save_feedback("user1", "질문", "답변", 1, "run-123") + mock_api.save_feedback.assert_awaited_once_with("user1", "질문", "답변", 1, "run-123") + + +async def test_save_feedback_with_no_run_id(service, mock_api): + await service.save_feedback("user1", "질문", "답변", -1, None) + mock_api.save_feedback.assert_awaited_once_with("user1", "질문", "답변", -1, None) + + +def test_chat_returns_api_iterator(service, mock_api): + sentinel = object() + mock_api.chat = MagicMock(return_value=sentinel) + result = service.chat("안녕", "user1", False) + assert result is sentinel + mock_api.chat.assert_called_once_with("안녕", "user1", False) diff --git a/tests/test_document_service.py b/tests/test_document_service.py new file mode 100644 index 0000000..b911072 --- /dev/null +++ b/tests/test_document_service.py @@ -0,0 +1,56 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock + +from services.document import DocumentService + + +@pytest.fixture +def mock_api(): + api = MagicMock() + api.ingest = AsyncMock(return_value={"chunks": 5}) + api.list_documents = AsyncMock(return_value=["docs/report.pdf"]) + api.delete_document = AsyncMock() + return api + + +@pytest.fixture +def service(mock_api): + return DocumentService(mock_api) + + +async def test_ingest_valid_pdf(service, mock_api, tmp_path): + pdf = tmp_path / "report.pdf" + pdf.write_bytes(b"%PDF-1.4") + result = await service.ingest(str(pdf)) + assert result == {"chunks": 5} + mock_api.ingest.assert_awaited_once_with(str(pdf)) + + +async def test_ingest_valid_txt(service, mock_api, tmp_path): + txt = tmp_path / "notes.txt" + txt.write_text("내용") + result = await service.ingest(str(txt)) + assert result == {"chunks": 5} + + +async def test_ingest_nonexistent_file_raises(service): + with pytest.raises(ValueError, match="파일을 찾을 수 없습니다"): + await service.ingest("/nonexistent/file.pdf") + + +async def test_ingest_unsupported_extension_raises(service, tmp_path): + docx = tmp_path / "doc.docx" + docx.write_bytes(b"data") + with pytest.raises(ValueError, match="지원하지 않는 파일 형식"): + await service.ingest(str(docx)) + + +async def test_list_documents_delegates(service, mock_api): + result = await service.list_documents() + assert result == ["docs/report.pdf"] + mock_api.list_documents.assert_awaited_once() + + +async def test_delete_document_delegates(service, mock_api): + await service.delete_document("/path/to/doc.pdf") + mock_api.delete_document.assert_awaited_once_with("/path/to/doc.pdf")