Phase 28: P3 — Pydantic Settings, dependency-injector IoC, tenacity retry
- 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 <noreply@anthropic.com>
This commit is contained in:
+5
-4
@@ -13,8 +13,8 @@
|
|||||||
| async/sync 혼용 | ~~`asyncio.run()` 5곳~~ → 모든 콜백 async 전환 완료 | ✅ 완료 |
|
| async/sync 혼용 | ~~`asyncio.run()` 5곳~~ → 모든 콜백 async 전환 완료 | ✅ 완료 |
|
||||||
| 코드 중복 | ~~`asyncio.run()` 5회 반복~~ → container 위임으로 제거 | ✅ 완료 |
|
| 코드 중복 | ~~`asyncio.run()` 5회 반복~~ → container 위임으로 제거 | ✅ 완료 |
|
||||||
| 결합도 | ~~`api_client` 직접 임포트~~ → DI container + Protocol 추상화 | ✅ 완료 |
|
| 결합도 | ~~`api_client` 직접 임포트~~ → DI container + Protocol 추상화 | ✅ 완료 |
|
||||||
| 테스트 가능성 | Protocol 도입으로 모킹 가능 — 테스트 미작성 | 🟡 중간 |
|
| 테스트 가능성 | ~~모킹 불가능~~ → pytest-asyncio 단위 테스트 10개 작성 | ✅ 완료 |
|
||||||
| 로깅 | `print()` 만 사용 | 🟢 낮음 |
|
| 로깅 | ~~`print()` 만 사용~~ → `logging` 모듈, `LOG_LEVEL` 환경변수 | ✅ 완료 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -303,5 +303,6 @@ youlbot-webui/
|
|||||||
- [x] `tests/` 단위 테스트 작성 — `pytest-asyncio`, `ChatService` 4개 / `DocumentService` 6개 (10/10 통과)
|
- [x] `tests/` 단위 테스트 작성 — `pytest-asyncio`, `ChatService` 4개 / `DocumentService` 6개 (10/10 통과)
|
||||||
|
|
||||||
### P3
|
### P3
|
||||||
- [ ] 재시도 로직
|
- [x] 재시도 로직 — `tenacity` / 5xx·TransportError에만 최대 3회, 지수 백오프(1→8s) / `chat` 제외 5개 메서드 적용
|
||||||
- [ ] Pydantic Settings
|
- [x] Pydantic Settings — `config.py` dataclass → `BaseSettings` (flat `AppConfig`), `.env` 자동 로드
|
||||||
|
- [x] IoC 프레임워크 전환 — 수동 DI → `dependency-injector` `DeclarativeContainer` + `providers.Singleton`
|
||||||
|
|||||||
+26
-5
@@ -5,8 +5,23 @@ import urllib.parse
|
|||||||
from typing import AsyncIterator, Protocol, runtime_checkable
|
from typing import AsyncIterator, Protocol, runtime_checkable
|
||||||
|
|
||||||
import httpx
|
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
|
@runtime_checkable
|
||||||
@@ -29,11 +44,12 @@ class APIClientProtocol(Protocol):
|
|||||||
|
|
||||||
|
|
||||||
class HTTPAPIClient:
|
class HTTPAPIClient:
|
||||||
def __init__(self, config: APIConfig):
|
def __init__(self, config: AppConfig):
|
||||||
self._url = config.url.rstrip("/")
|
self._url = config.youlbot_api_url.rstrip("/")
|
||||||
self._timeout = config.timeout
|
self._timeout = config.youlbot_api_timeout
|
||||||
self._client = httpx.AsyncClient(
|
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(
|
async def chat(
|
||||||
@@ -63,6 +79,7 @@ class HTTPAPIClient:
|
|||||||
return
|
return
|
||||||
yield payload, None
|
yield payload, None
|
||||||
|
|
||||||
|
@_retry
|
||||||
async def reset(self, user_id: str = "default") -> None:
|
async def reset(self, user_id: str = "default") -> None:
|
||||||
r = await self._client.post(
|
r = await self._client.post(
|
||||||
f"{self._url}/reset",
|
f"{self._url}/reset",
|
||||||
@@ -71,6 +88,7 @@ class HTTPAPIClient:
|
|||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
|
@_retry
|
||||||
async def ingest(self, file_path: str) -> dict:
|
async def ingest(self, file_path: str) -> dict:
|
||||||
with open(file_path, "rb") as f:
|
with open(file_path, "rb") as f:
|
||||||
filename = os.path.basename(file_path)
|
filename = os.path.basename(file_path)
|
||||||
@@ -82,16 +100,19 @@ class HTTPAPIClient:
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
@_retry
|
||||||
async def list_documents(self) -> list[str]:
|
async def list_documents(self) -> list[str]:
|
||||||
r = await self._client.get(f"{self._url}/documents", timeout=30)
|
r = await self._client.get(f"{self._url}/documents", timeout=30)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json().get("documents", [])
|
return r.json().get("documents", [])
|
||||||
|
|
||||||
|
@_retry
|
||||||
async def delete_document(self, source: str) -> None:
|
async def delete_document(self, source: str) -> None:
|
||||||
encoded = urllib.parse.quote(source, safe="")
|
encoded = urllib.parse.quote(source, safe="")
|
||||||
r = await self._client.delete(f"{self._url}/documents/{encoded}", timeout=30)
|
r = await self._client.delete(f"{self._url}/documents/{encoded}", timeout=30)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
|
@_retry
|
||||||
async def save_feedback(
|
async def save_feedback(
|
||||||
self,
|
self,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
|
|||||||
@@ -23,10 +23,9 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from config import AppConfig
|
|
||||||
from container import Container
|
from container import Container
|
||||||
|
|
||||||
container = Container(AppConfig())
|
container = Container()
|
||||||
|
|
||||||
USER_LABELS = ["아록", "근혜", "도율", "하율"]
|
USER_LABELS = ["아록", "근혜", "도율", "하율"]
|
||||||
DEFAULT_USER = "아록"
|
DEFAULT_USER = "아록"
|
||||||
@@ -39,7 +38,7 @@ def _get_whisper():
|
|||||||
global _whisper_model
|
global _whisper_model
|
||||||
if _whisper_model is None:
|
if _whisper_model is None:
|
||||||
import whisper
|
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
|
return _whisper_model
|
||||||
|
|
||||||
|
|
||||||
@@ -71,7 +70,7 @@ async def respond(message, history, show_thinking, user_id, use_tts, run_ids):
|
|||||||
thinking_finalized = False
|
thinking_finalized = False
|
||||||
|
|
||||||
try:
|
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:
|
if run_id is not None:
|
||||||
collected_run_id = run_id
|
collected_run_id = run_id
|
||||||
break
|
break
|
||||||
@@ -120,7 +119,7 @@ async def respond(message, history, show_thinking, user_id, use_tts, run_ids):
|
|||||||
run_ids.append(collected_run_id)
|
run_ids.append(collected_run_id)
|
||||||
|
|
||||||
if use_tts:
|
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()
|
yield history, "", audio_path, run_ids, gr.update(), gr.update()
|
||||||
else:
|
else:
|
||||||
yield history, "", None, run_ids, gr.update(), gr.update()
|
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
|
rating = 1 if like_data.liked else -1
|
||||||
|
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
logger.error("피드백 저장 실패: %s", e)
|
logger.error("피드백 저장 실패: %s", e)
|
||||||
|
|
||||||
@@ -153,7 +152,7 @@ def switch_user(user_id):
|
|||||||
|
|
||||||
async def reset_chat(user_id):
|
async def reset_chat(user_id):
|
||||||
try:
|
try:
|
||||||
await container.chat_service.reset(user_id)
|
await container.chat_service().reset(user_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("대화 초기화 실패: %s", e)
|
logger.error("대화 초기화 실패: %s", e)
|
||||||
return [], []
|
return [], []
|
||||||
@@ -168,7 +167,7 @@ async def ingest_files(files):
|
|||||||
results = []
|
results = []
|
||||||
for path in paths:
|
for path in paths:
|
||||||
try:
|
try:
|
||||||
result = await container.document_service.ingest(path)
|
result = await container.document_service().ingest(path)
|
||||||
name = os.path.basename(path)
|
name = os.path.basename(path)
|
||||||
results.append(f"{name} → {result.get('chunks', '?')}개 청크")
|
results.append(f"{name} → {result.get('chunks', '?')}개 청크")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -178,7 +177,7 @@ async def ingest_files(files):
|
|||||||
|
|
||||||
async def list_docs():
|
async def list_docs():
|
||||||
try:
|
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]
|
return [[os.path.basename(s), s] for s in sources]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return [[f"오류: {e}", ""]]
|
return [[f"오류: {e}", ""]]
|
||||||
@@ -188,7 +187,7 @@ async def delete_doc(source):
|
|||||||
if not source.strip():
|
if not source.strip():
|
||||||
return "삭제할 파일 경로를 입력하세요.", await list_docs()
|
return "삭제할 파일 경로를 입력하세요.", await list_docs()
|
||||||
try:
|
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()
|
return f"삭제 완료: {os.path.basename(source.strip())}", await list_docs()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"오류: {e}", await list_docs()
|
return f"오류: {e}", await list_docs()
|
||||||
@@ -365,7 +364,7 @@ with gr.Blocks(title="율봇") as demo:
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
demo.launch(
|
demo.launch(
|
||||||
server_name=container.config.server_host,
|
server_name=container.config().server_host,
|
||||||
server_port=container.config.server_port,
|
server_port=container.config().server_port,
|
||||||
theme=gr.themes.Soft(),
|
theme=gr.themes.Soft(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
from dataclasses import dataclass, field
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
class AppConfig(BaseSettings):
|
||||||
class APIConfig:
|
# API (env: YOULBOT_API_URL, YOULBOT_API_TOKEN, YOULBOT_API_TIMEOUT)
|
||||||
url: str = field(default_factory=lambda: os.getenv("YOULBOT_API_URL", "http://localhost:8000"))
|
youlbot_api_url: str = "http://localhost:8000"
|
||||||
token: str = field(default_factory=lambda: os.getenv("YOULBOT_API_TOKEN", ""))
|
youlbot_api_token: str = ""
|
||||||
timeout: int = 180
|
youlbot_api_timeout: int = 180
|
||||||
|
# STT/TTS (env: WHISPER_MODEL_SIZE, TTS_VOICE, TTS_EDGE_VOICE)
|
||||||
|
whisper_model_size: str = "small"
|
||||||
@dataclass
|
tts_voice: str = "Yuna"
|
||||||
class AppConfig:
|
tts_edge_voice: str = "ko-KR-SunHiNeural"
|
||||||
api: APIConfig = field(default_factory=APIConfig)
|
# 서버 / 로깅
|
||||||
whisper_model_size: str = field(default_factory=lambda: os.getenv("WHISPER_MODEL_SIZE", "small"))
|
log_level: str = "INFO"
|
||||||
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"))
|
|
||||||
server_host: str = "0.0.0.0"
|
server_host: str = "0.0.0.0"
|
||||||
server_port: int = 7860
|
server_port: int = 7860
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|||||||
+8
-34
@@ -1,41 +1,15 @@
|
|||||||
"""수동 DI 컨테이너."""
|
from dependency_injector import containers, providers
|
||||||
|
|
||||||
from api_client import HTTPAPIClient
|
from api_client import HTTPAPIClient
|
||||||
from config import AppConfig
|
from config import AppConfig
|
||||||
from services import ChatService, DocumentService, TTSService
|
from services import ChatService, DocumentService, TTSService
|
||||||
|
|
||||||
|
|
||||||
class Container:
|
class Container(containers.DeclarativeContainer):
|
||||||
def __init__(self, config: AppConfig):
|
config = providers.Singleton(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
|
|
||||||
|
|
||||||
@property
|
api_client = providers.Singleton(HTTPAPIClient, config=config)
|
||||||
def config(self) -> AppConfig:
|
|
||||||
return self._config
|
|
||||||
|
|
||||||
@property
|
chat_service = providers.Singleton(ChatService, api_client=api_client)
|
||||||
def api_client(self) -> HTTPAPIClient:
|
document_service = providers.Singleton(DocumentService, api_client=api_client)
|
||||||
if self._api_client is None:
|
tts_service = providers.Singleton(TTSService, config=config)
|
||||||
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
|
|
||||||
|
|||||||
@@ -4,3 +4,6 @@ python-dotenv>=1.0.0
|
|||||||
openai-whisper>=20231117
|
openai-whisper>=20231117
|
||||||
edge-tts>=6.1.9
|
edge-tts>=6.1.9
|
||||||
pyttsx3>=2.90
|
pyttsx3>=2.90
|
||||||
|
pydantic-settings>=2.0.0
|
||||||
|
tenacity>=8.0.0
|
||||||
|
dependency-injector>=4.41.0
|
||||||
|
|||||||
Reference in New Issue
Block a user