From 974bab7cd8d591d1b2e2501bcd1e569cdbc7b13f Mon Sep 17 00:00:00 2001 From: sal Date: Tue, 2 Jun 2026 05:59:23 +0900 Subject: [PATCH] =?UTF-8?q?Phase=2028:=20P3=20=E2=80=94=20Pydantic=20Setti?= =?UTF-8?q?ngs,=20dependency-injector=20IoC,=20tenacity=20retry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config.py: dataclasses → pydantic-settings BaseSettings (flat AppConfig, env vars auto-loaded from .env, type-safe validation) - api_client.py: HTTPAPIClient takes AppConfig directly (APIConfig removed); tenacity retry on 5 methods (reset/ingest/list/delete/feedback) — retries on 5xx + TransportError, 3 attempts, exponential backoff 1-8s - container.py: manual DI → dependency_injector DeclarativeContainer with providers.Singleton; Container() needs no args - app.py: container.X → container.X() calls, remove AppConfig import - requirements.txt: add pydantic-settings, tenacity, dependency-injector Co-Authored-By: Claude Sonnet 4.6 --- ROADMAP.md | 9 +++++---- api_client.py | 31 ++++++++++++++++++++++++++----- app.py | 23 +++++++++++------------ config.py | 29 ++++++++++++++--------------- container.py | 42 ++++++++---------------------------------- requirements.txt | 3 +++ 6 files changed, 67 insertions(+), 70 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index aac2739..d8d05ab 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,8 +13,8 @@ | async/sync 혼용 | ~~`asyncio.run()` 5곳~~ → 모든 콜백 async 전환 완료 | ✅ 완료 | | 코드 중복 | ~~`asyncio.run()` 5회 반복~~ → container 위임으로 제거 | ✅ 완료 | | 결합도 | ~~`api_client` 직접 임포트~~ → DI container + Protocol 추상화 | ✅ 완료 | -| 테스트 가능성 | Protocol 도입으로 모킹 가능 — 테스트 미작성 | 🟡 중간 | -| 로깅 | `print()` 만 사용 | 🟢 낮음 | +| 테스트 가능성 | ~~모킹 불가능~~ → pytest-asyncio 단위 테스트 10개 작성 | ✅ 완료 | +| 로깅 | ~~`print()` 만 사용~~ → `logging` 모듈, `LOG_LEVEL` 환경변수 | ✅ 완료 | --- @@ -303,5 +303,6 @@ youlbot-webui/ - [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 6cdd9aa..e429be2 100644 --- a/api_client.py +++ b/api_client.py @@ -5,8 +5,23 @@ import urllib.parse from typing import AsyncIterator, Protocol, runtime_checkable import httpx +from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential -from config import APIConfig +from config import AppConfig + + +def _is_transient(exc: Exception) -> bool: + if isinstance(exc, httpx.HTTPStatusError): + return exc.response.status_code >= 500 + return isinstance(exc, httpx.TransportError) + + +_retry = retry( + retry=retry_if_exception(_is_transient), + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=1, max=8), + reraise=True, +) @runtime_checkable @@ -29,11 +44,12 @@ class APIClientProtocol(Protocol): class HTTPAPIClient: - def __init__(self, config: APIConfig): - self._url = config.url.rstrip("/") - self._timeout = config.timeout + 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.token}"} if config.token else {}, + headers={"Authorization": f"Bearer {config.youlbot_api_token}"} + if config.youlbot_api_token else {}, ) async def chat( @@ -63,6 +79,7 @@ class HTTPAPIClient: return yield payload, None + @_retry async def reset(self, user_id: str = "default") -> None: r = await self._client.post( f"{self._url}/reset", @@ -71,6 +88,7 @@ class HTTPAPIClient: ) r.raise_for_status() + @_retry async def ingest(self, file_path: str) -> dict: with open(file_path, "rb") as f: filename = os.path.basename(file_path) @@ -82,16 +100,19 @@ class HTTPAPIClient: r.raise_for_status() return r.json() + @_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", []) + @_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() + @_retry async def save_feedback( self, user_id: str, diff --git a/app.py b/app.py index 18d2b4f..789eaee 100644 --- a/app.py +++ b/app.py @@ -23,10 +23,9 @@ logging.basicConfig( ) logger = logging.getLogger(__name__) -from config import AppConfig from container import Container -container = Container(AppConfig()) +container = Container() USER_LABELS = ["아록", "근혜", "도율", "하율"] DEFAULT_USER = "아록" @@ -39,7 +38,7 @@ def _get_whisper(): global _whisper_model if _whisper_model is None: import whisper - _whisper_model = whisper.load_model(container.config.whisper_model_size) + _whisper_model = whisper.load_model(container.config().whisper_model_size) return _whisper_model @@ -71,7 +70,7 @@ async def respond(message, history, show_thinking, user_id, use_tts, run_ids): thinking_finalized = False try: - async for token, run_id in container.chat_service.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 @@ -120,7 +119,7 @@ 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 container.tts_service.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() @@ -142,7 +141,7 @@ async def handle_feedback(like_data: gr.LikeData, history, run_ids, user_id): rating = 1 if like_data.liked else -1 try: - await container.chat_service.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: logger.error("피드백 저장 실패: %s", e) @@ -153,7 +152,7 @@ def switch_user(user_id): async def reset_chat(user_id): try: - await container.chat_service.reset(user_id) + await container.chat_service().reset(user_id) except Exception as e: logger.error("대화 초기화 실패: %s", e) return [], [] @@ -168,7 +167,7 @@ async def ingest_files(files): results = [] for path in paths: try: - result = await container.document_service.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: @@ -178,7 +177,7 @@ async def ingest_files(files): async def list_docs(): try: - sources = await container.document_service.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}", ""]] @@ -188,7 +187,7 @@ async def delete_doc(source): if not source.strip(): return "삭제할 파일 경로를 입력하세요.", await list_docs() try: - await container.document_service.delete_document(source.strip()) + 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}", await list_docs() @@ -365,7 +364,7 @@ with gr.Blocks(title="율봇") as demo: if __name__ == "__main__": demo.launch( - server_name=container.config.server_host, - server_port=container.config.server_port, + server_name=container.config().server_host, + server_port=container.config().server_port, theme=gr.themes.Soft(), ) diff --git a/config.py b/config.py index 209d4c6..6616007 100644 --- a/config.py +++ b/config.py @@ -1,19 +1,18 @@ -from dataclasses import dataclass, field -import os +from pydantic_settings import BaseSettings, SettingsConfigDict -@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")) - tts_edge_voice: str = field(default_factory=lambda: os.getenv("TTS_EDGE_VOICE", "ko-KR-SunHiNeural")) +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 index 0e090b1..5bee74a 100644 --- a/container.py +++ b/container.py @@ -1,41 +1,15 @@ -"""수동 DI 컨테이너.""" +from dependency_injector import containers, providers + from api_client import HTTPAPIClient from config import AppConfig from services import ChatService, DocumentService, TTSService -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 - self._tts_service: TTSService | None = None +class Container(containers.DeclarativeContainer): + config = providers.Singleton(AppConfig) - @property - def config(self) -> AppConfig: - return self._config + api_client = providers.Singleton(HTTPAPIClient, config=config) - @property - def api_client(self) -> HTTPAPIClient: - if self._api_client is None: - self._api_client = HTTPAPIClient(self._config.api) - return self._api_client - - @property - def chat_service(self) -> ChatService: - if self._chat_service is None: - self._chat_service = ChatService(self.api_client) - return self._chat_service - - @property - def document_service(self) -> DocumentService: - if self._document_service is None: - self._document_service = DocumentService(self.api_client) - return self._document_service - - @property - def tts_service(self) -> TTSService: - if self._tts_service is None: - self._tts_service = TTSService(self._config) - return self._tts_service + 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/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