From d81a2f58887e9b1bdbc1f7a8eefbc2c383f04922 Mon Sep 17 00:00:00 2001 From: sal Date: Mon, 1 Jun 2026 17:36:35 +0900 Subject: [PATCH 1/7] =?UTF-8?q?Phase=2026:=20P1=20architecture=20refactor?= =?UTF-8?q?=20=E2=80=94=20DI=20container,=20service=20layer,=20async=20cal?= =?UTF-8?q?lbacks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config.py: APIConfig + AppConfig dataclasses, env vars centralized - api_client.py: APIClientProtocol (Protocol) + HTTPAPIClient class, remove module-level globals - services.py: ChatService, DocumentService, TTSService (TTS moved from app.py) - container.py: manual DI container with lazy singleton properties - app.py: all callbacks converted to async, asyncio.run() fully removed, container wired in - .env.example: add TTS_EDGE_VOICE entry - ROADMAP.md: P0/P1 checklist updated to reflect completed work Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 10 +++ ROADMAP.md | 24 +++--- api_client.py | 200 ++++++++++++++++++++++++++------------------------ app.py | 97 +++++++----------------- config.py | 19 +++++ container.py | 41 +++++++++++ services.py | 93 +++++++++++++++++++++++ 7 files changed, 307 insertions(+), 177 deletions(-) create mode 100644 .env.example create mode 100644 config.py create mode 100644 container.py create mode 100644 services.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..99856d3 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# 율봇 API 서버 주소 +YOULBOT_API_URL=http://localhost:8000 + +# API 토큰 (youlbot/.env의 API_TOKEN과 동일하게 설정. 빈 값이면 개발 모드) +YOULBOT_API_TOKEN=youlbot-ai-token!!@@1234 + +# 음성 설정 (로컬 실행) +WHISPER_MODEL_SIZE=small +TTS_VOICE=Yuna +TTS_EDGE_VOICE=ko-KR-SunHiNeural diff --git a/ROADMAP.md b/ROADMAP.md index 7767a40..e1e564a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,12 +5,13 @@ | 항목 | 현재 상태 | 심각도 | |------|---------|--------| | 아키텍처 모듈화 | 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회 반복 | 🟡 중간 | +| 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곳) | 🟡 중간 | +| 코드 중복 | `asyncio.run()` 패턴 5회 반복 | 🟡 중간 | | 결합도 | `api_client` 직접 임포트·전역 상태 | 🔴 높음 | | 테스트 가능성 | ~20% (모킹 불가능) | 🔴 낮음 | | 로깅 | `print()` 만 사용 | 🟢 낮음 | @@ -281,13 +282,14 @@ 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.py` 작성 (ChatService, DocumentService, TTSService) +- [x] `container.py` 작성 (lazy singleton 프로퍼티) +- [x] `app.py` — 모든 콜백 async 전환 및 container 사용 (`asyncio.run()` 완전 제거) ### P2 - [ ] `logging` 모듈 도입 diff --git a/api_client.py b/api_client.py index 37c61ed..26d5eba 100644 --- a/api_client.py +++ b/api_client.py @@ -1,112 +1,124 @@ -"""율봇 API 클라이언트 — youlbot REST API(Phase 22)를 httpx로 호출.""" +"""율봇 API 클라이언트 — APIClientProtocol 인터페이스 + HTTPAPIClient 구현.""" import json import os -from typing import AsyncIterator +from typing import AsyncIterator, Protocol, runtime_checkable import httpx -from dotenv import load_dotenv -load_dotenv() - -_API_URL = os.getenv("YOULBOT_API_URL", "http://localhost:8000").rstrip("/") -_API_TOKEN = os.getenv("YOULBOT_API_TOKEN", "") +from config import APIConfig -def _headers() -> dict: - if _API_TOKEN: - return {"Authorization": f"Bearer {_API_TOKEN}"} - return {} +@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: ... -async def chat( - message: str, - user_id: str = "default", - show_thinking: bool = False, -) -> AsyncIterator[tuple[str, str | None]]: - """SSE 스트림을 읽어 (token, run_id) 튜플을 yield. +class HTTPAPIClient: + def __init__(self, config: APIConfig): + self._url = config.url.rstrip("/") + self._token = config.token + self._timeout = config.timeout - - 일반 토큰: (token_str, None) - - 스트림 종료: ("", run_id_or_None) ← __done 이벤트 - """ - async with httpx.AsyncClient(timeout=180) as client: - async with client.stream( - "POST", - f"{_API_URL}/chat", - json={"message": message, "user_id": user_id, "show_thinking": show_thinking}, - headers=_headers(), - ) as response: - response.raise_for_status() - async for line in response.aiter_lines(): - if not line.startswith("data: "): - continue - raw = line[6:] - try: - payload = json.loads(raw) - except json.JSONDecodeError: - yield str(raw), None - continue - if isinstance(payload, dict) and payload.get("__done"): - yield "", payload.get("run_id") - return - yield payload, None + def _headers(self) -> dict: + if self._token: + return {"Authorization": f"Bearer {self._token}"} + return {} + async def chat( + self, + message: str, + user_id: str = "default", + show_thinking: bool = False, + ) -> AsyncIterator[tuple[str, str | None]]: + async with httpx.AsyncClient(timeout=self._timeout) as client: + async with client.stream( + "POST", + f"{self._url}/chat", + json={"message": message, "user_id": user_id, "show_thinking": show_thinking}, + headers=self._headers(), + ) as response: + response.raise_for_status() + async for line in response.aiter_lines(): + if not line.startswith("data: "): + continue + raw = line[6:] + try: + payload = json.loads(raw) + except json.JSONDecodeError: + yield str(raw), None + continue + if isinstance(payload, dict) and payload.get("__done"): + yield "", payload.get("run_id") + 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", - params={"user_id": user_id}, - headers=_headers(), - ) - r.raise_for_status() - - -async def ingest(file_path: str) -> dict: - async with httpx.AsyncClient(timeout=300) as client: - with open(file_path, "rb") as f: - filename = os.path.basename(file_path) + async def reset(self, user_id: str = "default") -> None: + async with httpx.AsyncClient(timeout=30) as client: r = await client.post( - f"{_API_URL}/ingest", - files={"file": (filename, f, "application/octet-stream")}, - headers=_headers(), + f"{self._url}/reset", + params={"user_id": user_id}, + headers=self._headers(), ) - r.raise_for_status() - return r.json() + r.raise_for_status() + async def ingest(self, file_path: str) -> dict: + async with httpx.AsyncClient(timeout=300) as client: + with open(file_path, "rb") as f: + filename = os.path.basename(file_path) + r = await client.post( + f"{self._url}/ingest", + files={"file": (filename, f, "application/octet-stream")}, + headers=self._headers(), + ) + 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()) - r.raise_for_status() - return r.json().get("documents", []) + async def list_documents(self) -> list[str]: + async with httpx.AsyncClient(timeout=30) as client: + r = await client.get(f"{self._url}/documents", headers=self._headers()) + r.raise_for_status() + return r.json().get("documents", []) + async def delete_document(self, source: str) -> None: + async with httpx.AsyncClient(timeout=30) as client: + r = await client.delete( + f"{self._url}/documents/{source}", + headers=self._headers(), + ) + r.raise_for_status() -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(), - ) - 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", - json={ - "user_id": user_id, - "user_msg": user_msg, - "asst_msg": asst_msg, - "rating": rating, - "run_id": run_id, - }, - headers=_headers(), - ) - r.raise_for_status() + async def save_feedback( + self, + 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"{self._url}/feedback", + json={ + "user_id": user_id, + "user_msg": user_msg, + "asst_msg": asst_msg, + "rating": rating, + "run_id": run_id, + }, + headers=self._headers(), + ) + r.raise_for_status() diff --git a/app.py b/app.py index e9e04fb..b7ca498 100644 --- a/app.py +++ b/app.py @@ -7,35 +7,31 @@ YOULBOT_API_URL=http://localhost:8000 YOULBOT_API_TOKEN= ← api.py에 API_TOKEN 설정 시 동일 값 """ -import asyncio import html as _html import os -import platform -import subprocess -import tempfile import gradio as gr from dotenv import load_dotenv load_dotenv() -import api_client +from config import AppConfig +from container import Container + +container = Container(AppConfig()) 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 +43,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 +61,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 +112,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 +126,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,7 +134,7 @@ 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}") @@ -194,9 +143,9 @@ 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}") return [], [] @@ -204,14 +153,14 @@ def reset_chat(user_id): # ── 문서 관리 ───────────────────────────────────────────────────── -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 +168,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 +356,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..209d4c6 --- /dev/null +++ b/config.py @@ -0,0 +1,19 @@ +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")) + tts_edge_voice: str = field(default_factory=lambda: os.getenv("TTS_EDGE_VOICE", "ko-KR-SunHiNeural")) + server_host: str = "0.0.0.0" + server_port: int = 7860 diff --git a/container.py b/container.py new file mode 100644 index 0000000..0e090b1 --- /dev/null +++ b/container.py @@ -0,0 +1,41 @@ +"""수동 DI 컨테이너.""" +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 + + @property + def config(self) -> AppConfig: + return self._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 diff --git a/services.py b/services.py new file mode 100644 index 0000000..2c64341 --- /dev/null +++ b/services.py @@ -0,0 +1,93 @@ +"""ChatService, DocumentService, TTSService.""" +import asyncio +import platform +import subprocess +import tempfile +from typing import AsyncIterator + +from api_client import APIClientProtocol +from config import AppConfig + + +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) + + +class DocumentService: + def __init__(self, api_client: APIClientProtocol): + self._api = api_client + + async def ingest(self, file_path: str) -> dict: + 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) + + +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 From 1e93def9093c980040220ebb4f9b689f6e7e0d89 Mon Sep 17 00:00:00 2001 From: sal Date: Mon, 1 Jun 2026 17:39:52 +0900 Subject: [PATCH 2/7] Refactor: split services.py into services/ package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatService → services/chat.py DocumentService → services/document.py TTSService → services/tts.py services/__init__.py re-exports all three for backward-compatible imports Co-Authored-By: Claude Sonnet 4.6 --- .idea/.gitignore | 10 +++++ .idea/misc.xml | 9 ++++ .idea/modules.xml | 8 ++++ .idea/vcs.xml | 6 +++ .idea/youlbot-webui.iml | 9 ++++ __pycache__/api_client.cpython-312.pyc | Bin 0 -> 9005 bytes __pycache__/app.cpython-312.pyc | Bin 0 -> 18332 bytes __pycache__/config.cpython-312.pyc | Bin 0 -> 2092 bytes __pycache__/container.cpython-312.pyc | Bin 0 -> 2379 bytes __pycache__/services.cpython-312.pyc | Bin 0 -> 5711 bytes services/__init__.py | 5 +++ services/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 349 bytes services/__pycache__/chat.cpython-312.pyc | Bin 0 -> 1696 bytes services/__pycache__/document.cpython-312.pyc | Bin 0 -> 1475 bytes services/__pycache__/tts.cpython-312.pyc | Bin 0 -> 2929 bytes services/chat.py | 26 ++++++++++++ services/document.py | 15 +++++++ services.py => services/tts.py | 40 ------------------ 18 files changed, 88 insertions(+), 40 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 .idea/youlbot-webui.iml create mode 100644 __pycache__/api_client.cpython-312.pyc create mode 100644 __pycache__/app.cpython-312.pyc create mode 100644 __pycache__/config.cpython-312.pyc create mode 100644 __pycache__/container.cpython-312.pyc create mode 100644 __pycache__/services.cpython-312.pyc create mode 100644 services/__init__.py create mode 100644 services/__pycache__/__init__.cpython-312.pyc create mode 100644 services/__pycache__/chat.cpython-312.pyc create mode 100644 services/__pycache__/document.cpython-312.pyc create mode 100644 services/__pycache__/tts.cpython-312.pyc create mode 100644 services/chat.py create mode 100644 services/document.py rename services.py => services/tts.py (58%) diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..5c38e4b --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..fbf2f7e --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/youlbot-webui.iml b/.idea/youlbot-webui.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/youlbot-webui.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/__pycache__/api_client.cpython-312.pyc b/__pycache__/api_client.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4b7ab73774471771064227507fe906c4d9164645 GIT binary patch literal 9005 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 literal 0 HcmV?d00001 diff --git a/__pycache__/app.cpython-312.pyc b/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e8250133be37e557c257a26d8eaf6a432dd7d57b GIT binary patch literal 18332 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 RKE3SjDk;LrC-<$F{(pFA1pfd4 literal 0 HcmV?d00001 diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a38387fca1f4d465120941887de8452bebba3f7c GIT binary patch literal 2092 zcmb_dO-vg{6rTO@&+_L&3N(;Vsw!=*25dEr8X_905Eopi2{xp~t+ZNu2iUOox-)B# zIr)%E9CM?`9*aPVDj_wID&-of7bEWhwuh?fDK{z(m3r!XyS9ass#4U9rO)%`n|<@% z?EBvS(%s!bU?`K9@6|;@{y?KS0*%SpmoV8SI?)A_6ojl$5VN9ywrEOOi3%c_A-Z&* z=<+`Jz_IWDk*wTc1HcAfU;`B~8hVC%qoS+mnyuwcjdM4!YEV;8~ex}2Wh?q9d< z(#=@RwDOv{ZgKnOjd(o1C%U4=-QXIvX>oIpoT1CVuf~=+Wjw|;Gqz>14PMgnG*+?7 zX3nz5wrH+w#PVLqK|iAp-DkIfP-I zzXkv~u!ohsN^N5PY0il)de6E6+uESTXNR!C4cSJ4T4g&G?Qp}YTGR?uRoxC%Em(Tl z#CeyhepA*=pJDixSsy+rahtgjRn>|`%hqheDsok21IWMO@kY^pKHwm-2&f!>72sR) zd*t<_D>)~UuTJk=sLT12-hrcc7oFa8HL-K8E~k$#3{+=!GLAgpXO54z-8L2-jRegZUP|0Ge>Oqm`*bof^E4NrsZTc#+P?nZ5>fMFp zbi$Qa%0=y^V#j;sm_U^re(=-8j}wo+disuYtx^AqZF6&OtdXK+roG(tV+(3tzxYIXtkkB`wWT%Z#=3v~J(vX5*QlS#b|^mX{oGFI zgq!eMuw@#DBT!(?m6F9k=RNEqxMo8L!w8H{xD^X5^MC|-al!U=k0&pgQdFq*69uX8S Ooe}_#_)~0D%b=#V2z5&fRv_4=NuefS^&$d;qFuLg^sBQ{ zgM<%0xJ89PPQ~h>(4&P9X`#?V|Aby7oM3lxXrZSN2UP1&=&A3`u6|T)QhLiC-n{p{ zH#_rvGyC?ZV9-zCxx5_Se9KM9Zy59*$#J^Z!PzA`NfBMp#hj2AQ=)*rE9c5fDT(`1 z&Ykz9Jb7=*o0n6vKt%E$(cL>l_w0-In^L~2HzrpuKy*@XJiY&wx_U!B`El>rXZKFN z{{HDseenqv#sn*PJ(;|D?dFY@T-GR*0q|Wbl~#&{jqFD;m!+(HoGz~$?9*(#eWyI-;iwmC z+(W3$Q6He5A=J-N1yJu08sKOUP1@GZmM6mrW*oKn`sm}vyYGY9 zC0oK4p*(>`ClCp}kVpNwJnB)lD|_cjs-!8b#oU&c+ACTLO_#DX!^O6OGz0Qd)6w?~ z)g6KRP=J=pW-p3~mSh^a4TcxV#PwD6&v^Vpm~Y0-bS{3k$nKb>bjFBRix-?HJjKof@V71C@_Y+5aGtk??YVOQ^k9?yvx6zl zv+3!1gK^)XI($oBOi^NEFrDFAoUG-7tH3ToIKkpM0+7|5V=}keHfTN3QZh_BJEJ5B}ZPM&j!5jx%y20mp=br z3m3x^WY7l*n?f@xLtyyJsJ$`}Vi>;!GtJ9j94W$WCEQY?#~MmOoAf|Spa?Tu1g#y? zTC(<1i7<}T@pApU2;-yupf))WVl)>S5kqS$T1$x?N0+`${y@K@2hn#?j9D&5v>l4J zCF)Cp_n2RaPF(Gdxz+sbrHnLAcr+gp0D++03T-$n_-TmrQn zQd_dhC0L7vurC(Ud4p2RPielWS90hFDE*|8&e<7;y@8zrW632}EE%j^kMzu>|=!_B1SVt7x(+%G#fuZZ_ z0UZzbTyC}T!6|{kNrBh#JE_^m9Zb#kQ?vcl{5Nq-&G%FD{gl>7VM^nv-$n539pgUN z;3w*(_>-d4zOcNP;opcWW692+%(OpjE&xAJPIVsZutjtFmb`3>e3iuj!)|KMfa$md dLHL=3ACtMqWcnA9Xp_Vro>@UVC1CjczX0%5-;V$Q literal 0 HcmV?d00001 diff --git a/__pycache__/services.cpython-312.pyc b/__pycache__/services.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eaf719635cb10f816548a3979138a9632bb84e79 GIT binary patch literal 5711 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 literal 0 HcmV?d00001 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/__pycache__/__init__.cpython-312.pyc b/services/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eeb58097208c6d3d16393700fb79a4bc5e28b2b8 GIT binary patch literal 349 zcmX@j%ge<81g@^KSzCbgV-N=hn4pZ$20+Gih7^Vr#vF!R#wbQc5SuB7DVI5l8O&zR zVaa8UVr67VXGmdL#2Cd^$*Rfr5~N*|@fNppMq){DYEfBcaw?F^?~T4#Sb>C}Ci^YkVufb6Ib0JuC@;m zP|8|cFB*R$EYc`@MYQYAwUBW;3^;rky>k89Ro`Rv@H!8|zzKW@O^1>hfL<5CHesYp zT~bzD%G8SDDjQl^b5*9h8Z4a|uE7$_WICJ@odh8jndVP#m_%?^ECw92?Su*$R(#e9^$x@w2ZjNkAa7NzWlXE`!)XTVgZidco!I|XW+ zbZM7Z@&Z+x+@p&?TC>2rs6?7zeuYDxM4;cq#N!L4cLn33By7Lb3i!Hc*bXbTgQmX{ zgmW#n()3DTfGLQQ1NqE1+L38lUfl~Vt5Z0Z;{5QdjGu(~x5&f7i=S$rCwp)pQKH6# zu&XReG$EwrxlvZE1}!UG_3GxbcW4GHyxHFco_f?By0HfDOp2{rvyr& zbk+UA%`)MbQc>YJbCHHziW*PDf#*?;pc(}w@Yg3mI1U3Zfa;7K=ZUTuL_7!T7Ksfq zQrK#5wzqC>-rT9~w!Y6E<`?dp3p@w+Cz^%1+=kVA57aw^J|mxf3T_~`;R$>MvA$Hc z)lYOC;YND{0%u7R84mj1r#1#3I-|#_Nqa1ZcplUroE?$nmI z>1_p@!A|usH+^JIKQN~s3oOpZ=Zo|%ozcEBV8~3_)tabArY%HhAu{0E^*h_FL&F^d zoQ8s9B8%}pfM+iTHn*#F^?e)=kpEqjtgvq|tHPMOVms?a<4JM&IK<&+P~j8i=RgTe z8K+@54_KJa+2ch!5f7iCm<1(^7mtwqX)NDSZt5U6wOif$@P{)$Ui|6eVQ%TjTzX(G z4GK9qN57%3Xx|!hw3x*QR<~=+vZ9n_)dIKaBhFaX2Tj}W-*B8#o&^=DBIJ>_5(GZS z0gLpo+3=YhT#hY{RIeU#%=;dZGuiJx662_FiLXIzkXWJm`TLWv^$6^-q3Ulw96uMU zxKGXY_t)Q$`=jL8o#xK+?&O|!_vIbyV0`W%Ip5R4GEPx_X7@r2A!hbuq?y<-^=0~K zv=HkE;KiICixU!>2$K?~2pFU#%mBROevyi^@|Yv3+~0YG{roZG#iAY=VY>n2C27mQ vwSHA3vzNf;3ERlL+^^#TpN0#3NCLYZtCZ4T$;9tu_8+4_m+4;wwRHRsn1FM~ literal 0 HcmV?d00001 diff --git a/services/__pycache__/document.cpython-312.pyc b/services/__pycache__/document.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..da327766f93495c1a56de66d4ed0213c974911cc GIT binary patch literal 1475 zcmb7E&1(};5PxsK+@vy(g>D0G(AW?DypsS`#R^~o$PQA!kYjxgmBVd|QaSe2BTX6c_%uxPaB?4|Q#zQ-%! zB@u*y8~AQwmeB3U7eKBPPD+%KlENrgrxl2+l{BVuooU>d)|d|RF5peB9|U`_FhG-H zyU>D}V37hjT{Eq8lpPP;>MWdlg^RnM%cFF8&U4&26}O#goCESIe62npb+Skoi4$)i zsgYGW2{1GVye%ctB=@I<0Xq`?1_ytfw694nq%F(7Js*hMa<1%hyB1Xa8$mcS&u>&c z8w@Z7X|r}t$L4C0={R1+3ms?SKv$~AIy*X^lLB&|yx%wQM0=iUfk2|vwC8hYt{mQq z48g;ysO&(5Xdc4Xx(ehXp@@pi0N=f4(6VbS(luy5u7}X*dtmWOsjH85om?R&$usSZ zI!}cO+m<3QwaD-)H@OT!!~qf+Ann?S9#rfBvar7!V&a5x*+CEe`R|cr(5y zL#NY8Y0((B#B>G|#Cw5V80bd4(+x`_^FVeYAKA!_Z077{)^3<~BWt&D4;1JU9nzNd z0*wqARE5hY|J?HH5b}Qu@b{KexB$>}{kNuP8TWa}x69D!{yv12+>E3a`$4gg@2*9c zcRVu#+r`NAz3`rT%}iklwlFdoZD!=?!Am%v0o?EP qB*`y6t{>FDQi#mc38FIsi-Qv;@sZk6DWxCC@HeA}7QYZ6vCmHjMn8Z6 literal 0 HcmV?d00001 diff --git a/services/__pycache__/tts.cpython-312.pyc b/services/__pycache__/tts.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..db088546c45dc30919a6aaebbaa2fc7397ea4cff GIT binary patch literal 2929 zcmb7GZ)_Vy7N6NajyJYrd!4$u{ObnypIhQIS8*T&Em7Q1Kutd+9FQ-|*1IltZLe>3 zoy58H)H@1Isz_~7?*alrDv(H(BH$Bz-~(6XawpvvOpEMp@&%5QKv2oLR0mXi;LY0G zln@mdY2N$Io0&KB-kW(d|MvMB5tRM|9cj&l(0^#dDO??_#sSPC6{$FbMrs_7V7twX zFg9mLSl~>C%km>UMhtowsq9IlatlmN%ZRIpqx_e&F&M>`(3j8m>$zm=Xrk6%qkO%w z8UrwkG(?wyC9uXO8I?K7jc_Wf@hS(EpJ+fR$*C@ox{7?X!D<{H9vaffXeyyuu0&0H zeR*3i#Vpj-gSPfboVLY3CX-0Ujs!+HPH;rPVFcA;mm<1|2cw)Nn5H3*>h{WY-b6*3 zdQ_{oJ-0QYjKvvRCP~2O$e{a@q?^C(dEC&5(PJo?o-v&qGxAD8>nZAm%n{w}9@CB# zQavz$nrHNY@>srTxn(((OPR7f5n3}o<_KwQjSe6uP$k@64sS1ow@-01o=RI+xouCW zZO;_m>BAk_`)Hao2~K8=sMR6!LkH7?Xo#pU>Yq}$eZ=%8r~mbE*rHSTyey=6`2ppAFFP>=5~xAa~# z^IQa(VMp&)L|BZTLNk9EL*wjIXbi)Mu8l_IIA=uNTY=Nu&v?uC>%29^P2nj%$*N5! z-N)HGpPotfN47)6Yf-)z-6_r+*;T}jU2m~tIO;Dv4!*hm=SyE*c;)NUZ(Kh=^VO>h z*Ux+)!6lAKStW6J=zhsiil06I0?i=ZaN7aqi@?YCN2#TP{`0Xr^Ydpn8Jo9)+q8LqDH*MoS#Cni5JLZoek$1v6hT z3BAn|Tr8X43v^5wO*rwSoF4xW)YU%$`72A4LHbrA08za-7xg%#%`{w-^<|owW;aT* z&N0Pw9whqp#nFpV5fL}^LK5a^DzvDU>wHkR%ITm{$qbHP%u`gy1ppTV7 z$+wVaeR>YBd^# zBYC3317TP!*i7gPKOoN^q7ePrshc7ILYCVrqn@Pk5HVVj=;I zx`+>$?Htlg(Or~Clym_xX!4exmZXOss(Z`!%|u|mx2P)(70$-ZFQ|t;^@l2<&7XTh zXZBC;zv5}1bARmFS!F!FO)KHfa=5n??tOprzcyVBA3n=g0-;&qoN(T=67DF6ca*|A zR+>9!2hR;wHnqMOpWFK8?hCu;&3A_0d-B~U-#0&Mj#pYcE0G;H1b(yl4dR1h)h1rl z(l$GOZv3pj>TVE%%igvXv9&CAm&EROe*fWttKy?m{S~kO%p=o}%sf`1UE53I_BnHY zxZM3$@9GsJY{R z2>Ui&3p7_*;5VyH$Tx_;``q6Gav^xl8vwZwyjgX5gqCG*>mBy-e}?sKz2Qb7*iNJ* zM&=G&5qFinyGq_&x7Pi3rKY&$T4*y>-15EgI(YlJwgCxW=-VA{Mwh)fE^?QBeu~>M z$S-&HMdI7gBF6Cux5)S@4$_WA5yPOxW+5)IiyaJ=Y{3*uEX5H_M{X11z3gJl?%jha z?zMaG>1!MaqJQki0}Qu>0)UqSROwPs80cV^TIqx(SRZf)odDl8_`A@{Da%%)EN6AK zkfGQs%YP^+nOYB_8H4zNK+ZA=*#qbzvM-K(<_P^t*o6KOjBP+pqAJ7iy)PD55wNOz z2j4QsRuQmyc3!>Mc!R>420A&~K;T<~NQ9DIlu#x1zpS>ny}~_IMz3q_q`M!eZFe8} k9mr_bGdh7l1TbDkP0Pr?jDkS_i`u_&-HjpkK 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..d4c5661 --- /dev/null +++ b/services/document.py @@ -0,0 +1,15 @@ +from api_client import APIClientProtocol + + +class DocumentService: + def __init__(self, api_client: APIClientProtocol): + self._api = api_client + + async def ingest(self, file_path: str) -> dict: + 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.py b/services/tts.py similarity index 58% rename from services.py rename to services/tts.py index 2c64341..c5425d7 100644 --- a/services.py +++ b/services/tts.py @@ -1,51 +1,11 @@ -"""ChatService, DocumentService, TTSService.""" import asyncio import platform import subprocess import tempfile -from typing import AsyncIterator -from api_client import APIClientProtocol from config import AppConfig -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) - - -class DocumentService: - def __init__(self, api_client: APIClientProtocol): - self._api = api_client - - async def ingest(self, file_path: str) -> dict: - 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) - - class TTSService: def __init__(self, config: AppConfig): self._voice = config.tts_voice From 7eed70d7f77f3cc905f08533e6f4c6d80090ae94 Mon Sep 17 00:00:00 2001 From: sal Date: Mon, 1 Jun 2026 17:40:08 +0900 Subject: [PATCH 3/7] chore: add .gitignore rules for IDE and cache files, remove tracked artifacts Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 4 ++++ .idea/.gitignore | 10 ---------- .idea/misc.xml | 9 --------- .idea/modules.xml | 8 -------- .idea/vcs.xml | 6 ------ .idea/youlbot-webui.iml | 9 --------- __pycache__/api_client.cpython-312.pyc | Bin 9005 -> 0 bytes __pycache__/app.cpython-312.pyc | Bin 18332 -> 0 bytes __pycache__/config.cpython-312.pyc | Bin 2092 -> 0 bytes __pycache__/container.cpython-312.pyc | Bin 2379 -> 0 bytes __pycache__/services.cpython-312.pyc | Bin 5711 -> 0 bytes services/__pycache__/__init__.cpython-312.pyc | Bin 349 -> 0 bytes services/__pycache__/chat.cpython-312.pyc | Bin 1696 -> 0 bytes services/__pycache__/document.cpython-312.pyc | Bin 1475 -> 0 bytes services/__pycache__/tts.cpython-312.pyc | Bin 2929 -> 0 bytes 15 files changed, 4 insertions(+), 42 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/vcs.xml delete mode 100644 .idea/youlbot-webui.iml delete mode 100644 __pycache__/api_client.cpython-312.pyc delete mode 100644 __pycache__/app.cpython-312.pyc delete mode 100644 __pycache__/config.cpython-312.pyc delete mode 100644 __pycache__/container.cpython-312.pyc delete mode 100644 __pycache__/services.cpython-312.pyc delete mode 100644 services/__pycache__/__init__.cpython-312.pyc delete mode 100644 services/__pycache__/chat.cpython-312.pyc delete mode 100644 services/__pycache__/document.cpython-312.pyc delete mode 100644 services/__pycache__/tts.cpython-312.pyc diff --git a/.gitignore b/.gitignore index f10862a..70958dc 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ /.env +.idea/ +__pycache__/ +*.pyc +*.pyo diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 30cf57e..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Ignored default folder with query files -/queries/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 5c38e4b..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index fbf2f7e..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/youlbot-webui.iml b/.idea/youlbot-webui.iml deleted file mode 100644 index d6ebd48..0000000 --- a/.idea/youlbot-webui.iml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/__pycache__/api_client.cpython-312.pyc b/__pycache__/api_client.cpython-312.pyc deleted file mode 100644 index 4b7ab73774471771064227507fe906c4d9164645..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9005 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 diff --git a/__pycache__/app.cpython-312.pyc b/__pycache__/app.cpython-312.pyc deleted file mode 100644 index e8250133be37e557c257a26d8eaf6a432dd7d57b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18332 zcmcJ03v?6LwdjmABaJ?mO0qppA3OUFDN^OINb)l_Ik&RPI{z^VWKe<+t3BE*jdk?%QWZ zBiY6@Y47TQ&+Iv8pL6!vXP4+*Pj^*D&qNu9{SJKyk1`BAoCln<7>%`K zG){}mc)N&ui`&KAn`kHDEjCMfrR`E26Jd<>&2Uv5)ga7NudH3h!K7K27e-5dh_@?gX?qMUGOB257;9J4GDbto8O;%bRtyvEv9uC$3B#}=!EuEIB1s7t2vxKLhb0A&Ju9eW8vPKqoo2Q%u)oWRVazR86^`#XVWp9g#D9m|KNzheK=i7KT!D`E0*=&uL(BifSy(Rz9ny@p;pksldTDc9Z+0VDdR3+O@^ebJj@KCgYwc#J8q+jSi`lic)9~&ZVZ{lcJ4sRp9X#rlvQ)#WB zD<`TV?Nu&I>v9~~Os|C=sw4caVk&yR7Nt{w=x2s8rlJdHs=DxrX9a8=W7aN*%_K5w z0k?=*-6e`*_Y4ZHMc;G{w2N?am$bW~=j%}{0b*jAa=g0G8I6F z1mIGpidoGRFvVSxC{-|q(w>t-579UCDkXcXqTz3!=Uw~a2G*dCF) zdJb=H)zH+j$`V1;=w8Xda;(YxtAz=OR& zb900TTc65<7NN!VMh<5=&bJCUf5-l?93S`5`$2mhh)}idsZ<@L+ZJik?aT6Uxjq)p z)efd{*}2j(TVbBsg*gSu-o7ke3bPI7bO&HB&;@{<({oPXcl6CX(dtsFmwq$5=tD>I zFpS*?EnbMWBtVS3MCXOMd*%)LAnI(ugJmGgyidFa%ox$lis$aV1aAGdW| z`gF$r{=)v_a{1gj_b2cE+w9e$kkhTpD`fhf*YkL7yZ7y^-Q8+{jtu*ncW&;s+4?Jr zip-Wyqq*B+wN-2^E-vO;T!`7adq+dlX4Fsk`RzAd(3c7N3Y~Q!stp{9E;M{87y5aA!o;-~&mP7$oN5Fy!t46^J<36X6<^ zh96!=^B9}&psad< zr#2)Q+~ZKt5+r{DFKqGQ0<0%NfTC|bB}ILm0E!1? z4r}b97M=IuVuJ*WZ%X!5BvjoBQMl z91)kp-%am-e*wKEH1HGXEwH6UxTK(E3+HlB6`|-$&8kBcomUq=`mBRu*Er5ckF=fVLJ!O4IWT2&y5Y2`7|F%g3Jl1_1_s0w|Bb-Fq zArZy~x`AL1ELh7rAZy9fld~iPJ{?k_g_nd?EaF%3pazY~p>dF3j@~bawNOL;)3_Ga zz#A}^Xtc2$8@-r=+0ahQJu5MG*P!~z8Kq+!>K|aV>Vg=}`9FPT#Yii)0nZ23FQ|`) z-vjsn#v7>{u+dv@i3f1Kx`{;!E=btH%Fv5~1Zy!fK`Am+m_A!jX0%#OR-3WUwk1dz z3;@~e)}Y*AuyvdIj+*+qf)azFm$4axGAOm!*-pmV`iK99ht)x|cBJ0r@?1 zK~1N{Y-T!b3~k`Ef)wflnJhu2z(j+wvojbI`Qi%WBA-W0eMU3bg>;a#S&lM&V2^Q) zBNjf-1l2s-4IP$a{CIHS1ZTt;ko9Iodz^0R!<#))u!Z_9!2WH3Qw)DrJ?yjp7yI)N z7Er{U-aN8-^qGJv(Z#ygd6LJo$2+{LVn0K)p8UjPDx;#*B8o7`K* z2Ym4xriqP0#*WF_aE3hARqC?24|qDpv%T_sKau|+XSJ6|0LyBqF%X|Pv@?*9G_)&_ zlIbOq=7|DQeLp9ECTEi`XVXkhwJ)c7YT%C4yZ5WpIfh5rYS~tNtiZL(ofJ?fdevEh z9KBbg3u+QSlVF-u_o_Q_yz_pN<4pvmwgdK|v2bw15HS4=>Y?jb#9pRUZO zDRU_UNf~a|vu-^3+M4U_cVhgT_xno^Ot&4HY3uN{b@WA2 zeqHU;6hTwT>F$GLU!O_N_a*21lM7wMz4+uP={+|R{N-Cd68)#_SF#Tk-qaR)xa$zozM)M*BzLSGsRyd0-VV zFguD_cq83k+BChVd1g5bcRSGi?gg^@}mC*Js+X=3GXq^kRA zD`(P*eQCurY307O^2uZVv}b1$pLNQ4!K?5LTob(~dq;LvalQE$ZSS}J{E%1GI88M^ z5ae8EP%Q>wqrOQDg+^BOFv27hergeCK0P4O-a zzgd>GV-xc`pY2m z;dV#_i8~CIH+Rlo<{V2$PE;T%;IOpfqgqy?Q1_XpGp@*#=AnE?ig zsIWMQ_gY~#VHe}KEm>6;)5jd^XRA+ScN_a?Gh;ZyFm#8p^Jrle=i#ze7e;VSLQSg< z5^i7f#_u{e=+2tN-y`24Cy!i>xsm2C-g&0x+~DU{B;hZnaZ^484A7j1~|bk&5e#%==AC!Y3ieyV{AUE3!??6gU#L#_LSHJwv^b+ z^s!ASS8TQSLSkcsBwE?L`NOV3MKM+*m@vX{*!5f^{VdeiD_F#Jb`wfAqofiQicRz} z&P=lzkduZ5@dn=wXOQ$-yMh!La5e*iB&^W}1{=E-5UgmyEU*c9zN(inlYI`bjw5s} zObTxn$m35JjT8;l-IvC?a?TUy8Q0*nbX7nV>&hBA;uPOkB)U4!mrW~Hc*@3Of4E`1 z^IFSP^3|_=Tv!8ELqMu>T2DG=q{%*MvRmSpuJBZTEG>kR`RpAU( z$C9N>fE}`*zNUrT_th5oIr2|j`AyU1aFqrXNo_NsOnS1 zA0&{F|3M0gax%pLFIs0v+QAaT9)el|A5j_ zYAAtn&{GuXsfaHO^i&#ntB{|Qd*CF9FQcdM7jU5k(BJU2L|ciP?8wJidlq#Ol^HNNA~? zag&?RBp8;UosnPG0ek0Zyi?SLb&3w_khYF`Y8|*OGS88nUF5*~6yOacpv5K6m}oSc zzy?KwFf)VTr>n;f_aj^3AM*4O!cM+aat2A85JZIaM7()XQhFkuyJ(&x(i-ga;+}}F zX90!;PT*h>^dv^}1c(;XgfM=T*1MeP7V6=4kU0N7H3-A_)zd<--gS2;mv`2P& zkoL`k(&cITpNtdelsKfYZo~o=;r(6uf^_sFSTXQkvSO$vJ2DRDMn~b&CUjvKZgM^;+mh*F(coq3@q5v?Il!`|UTstAlHbZhPNRYY>0Nj#SYnljn7zci);jIblb_ zri-5I<@y+Y+dBaHL88yt%kYPK4Q=VP_ku4Wa;}!ZF2umuUu3Zc3GRY{>M&Xvu8JON zb9*Ge^ASB3Q=gs+lISi1s5lZNxic-CuX!&%3$h2Q1SMvaaGM+2$A!&}SCC4+O9R&o zI{@Y9;m`UI5^izdQ)mL}!h03Z_?6GTziM*tnKkacm&DU?D@XQ@XT6`O>PUTK#FYGl{j%E%)OS&u$poFcY7H zHm7=jyxv(4C#KU)BTa7Fz2CEbJn8M!E2)!>Q=NW&tv9dkMuj)6>8^73ybOy?f`gJ$ zb9&RrCf5PKBEw5$eEw-X7E^=IQ&>_R{y93NrohGo1cci>-*K_)T-Quyp)a#=+~&_* z=gn9@**5hrQ=MKQNLlAo)}ajxD*V;D1IGO#RWHL|URAq^cv}Y0``LAv=w=#TTP?Yn zUL%K(Te(`uzg3L`d>fP1OUT=@vU;4nT}49v2RI2iA4tRicS3WK2dQ{Y31P6+{NtO- zo8bnZPz-QkvOt;TaLO#Qtt@+;hxWlGPxOf5z`I1C(pC}N(rrOYw=BAlVG&2v5G>-j zL5N>;h|oqwi@HQzpi(&(o9G4DoME}ad&zPWH~k4oqp(gmTT(COcc)$`2&#}93~oyK zHDYBgkjV@6zaR+Ne|m9H8g{J(aVsz4V#o&(58J~+8&-A@@)7H-NXEna76JQ~Hfu&( z>eH6aXls1h8o#z~Ca%sYy;r))udaA!_4wX1ac7gql4oMGe6d-cEPw1ukMUzo-sEbZ zy249U%!{%3di-%vUGe9zJ(L@WPjlC}%UqeBIIm`kwTd%{U_09feZzKEjSpwl z=&(2NniS&46hQwguO^MSnIr=6rWTdoOu-SHMxs=Kbw)3Vk6ljh_3T7;pF#iN*^;KL~$~@FxS2M!BCl3V9&(!K35%A{~lj zc>O$ziU_^-jaknd=+-~HA0Za-_`1-!;n^RJR_IuCF44<4Uot243)YkxF5np(I84H; zYS~343QG7zwTvMctZmzrHZ))Sn^jZQX(&|sEfy6$7@$een1|PRl z@Y)>7tyBWRnIgcsm4zcXhf7z<>NMh8g|T%C@vUd@IuUgnBLTim;*ft^g4(>TAQ7w) z1AL+r9lyI+i@lGo$T_kJ{j2P-ur$LKn3*HCin9J=I;+KOqIEeP9UVdu3VE?xE6V$i zRrVWc8n)?*jeuCP4l;U;Y?rC80AglrmR=6!gfX0Fv-EQr5IkXH3b>GnihhPm6yLRwMXB5*#>eK*_vK4gM_fS<&1b zU<=YIsOKe*PcYfY+sA-Rz_r0*MOQqtFEB=>WeA+qR6xEa>eTvKdJ)o^H0TC%myn`i)RvRq{L4MNEXcr+EckX*#iLl zzyOb)T9JhxJ<-6I*oJlr?paQkJ0ho7z$Gq7QIv_D2K3O&mmmmh_Qw;mlixKEB8V@uA&Sx;z?g!%SwYZuae|(2EnH?_c38>Dg0n z&b{X5+IZ=tE_C*rp$nt>!pF)gRL5#NZe})T+e@I^?CL6T3|acRs^>3Wf|h?a92&lW z`Y$XjtSSl@Ev)relwHl8f_1UKA^HZ-+{ zze)bKNWxetS=_vsBqdM!9sb#~w*g4y_X*wH58#C384kUBO*i}E zHJA+U$m1FNWNNZ#?mO2*@4EqlZAUjZb{>M+3Lk5#g!vOD1$@}tq}Y2w`5YJKs?*L| zL8d3X#G z!B=z`v^Y?OMZH7I`2gWQhPxjU#=#$l95_Vb$dIkb?_QiX6~p}pc>;Q%B`6L1{nvrt zU&{IYsS`w%*;LKP8X^Y(atL%WVFv^R&2nyrx$n3`7p}pc#SP;GSycpen!t}2opBMn z8`@_bkSx3^VE01C3EiUm|7ETS{vWD#022Phgpmo7KT=p+in9hRFok;5e%dkOfa6Zj zxm|ws8n0@tm&y;UEt%YVrP8_2RpQz|w#l{D-R4uQo~r#LoUxz9AIM|>M1t_Z8M$#1vmKAS@ zm4JQqH%+AMYP7=aX0(fhbrGyE7JB2C89Lno@6lR-`wkn^YhA=~BvdR)Lhxs8g#@et zrRwz7k*$A>F!rGgSo{zpVM^z0Ga# z(T*=wU|eu8A7>5sMWoGSGc$H%h~((_TzTmH>kwUh>%C8=oT2kSMLWUl#FVaP8&W~D zr(jQjAmi^(!cNVH9FGpoojb#c9KUmiUUPBxKzafqV!AC5m0}H&&l}Blh%Gf6J0Q%L zv_d>J!yoH$_WRJc=`&E*z@Hxg9F*|3rX4Aq&}Aod&Mnj>=bOSpU0ws)M$krtcB^l` zuKxovT3VrFMZXyWe!F2$0pC7qltr#9xbXpN^#~krtUMRET0tq?6ro)T>^JH2M%Dyz zJ3P9)*VM^c`XO|jv8NznE{ukT-wB<+&UMLA23i>Q!apEU)f4>m61L+I(H!a2o))Re z*)((C_%?4OfiVP}bA#SRwAm97+KUsPT=nQaxq2PNiSv3s%oUDJc3q^cFcq_>ehSU# zLKprG$k64rwzlZEaCXfsH!Zr*H769n1O-LbUYKtJnfP{eqs$Kmj4;#y$STKr3NZ+H zcN{nYF+?2@goE}`;XzuP)7)rkAa*=bwyeg(CL;Dd7xfJzy#n=MKt=Ehk~Ju17-+(u zU$E%}sVcLvw}UoTvu^=bE_7i<ejxXTdp%MDL_4<`5eN=j9fN!qxnTu3zh z%pr7n4A}oeAe)o?Pu`sbArlsyf-}dAoi>P!T3GLsYeQhk+rJ96_REu$+ML zZ$lOe1?+{Na2O0gxdH|3LHHZDeG4D`7S;wDxtr-_^esWD0r_GKh9H4r_V{hvg4&UE zald)tSCNGOy$FjWokfF(TNg+aC_&mVsMy!i&}`UQQ`@k!C8(@#*iy4^XR85avgk;` z{Q!VP^2|l=b3tuEYA0N<*F)qC`y48uQPPEy0hAzjDd*zhx3*L06Kx>;*$3I#oYBbL z<#U@_P*Mv425=!l&`hr-NbNGRM`_DIUyy78TN(oIxQlmWS3}rgI0lf5SA-aVR%1s{ z+-w;LQuUyUxmqziI=HJkA+9h;G`8;AxgabMLi&QzI=GJn9|+okct9Qv_zOx~A&`(m zgDZX%0tf`!!C%rBq~Jz{HgwqfSY&qw6?{Y>r)7ew1wVHHj38Cl&2%1x+H3_Hauuo| z<8MPzu^dG#@_Gqf2*K|z(2p*5`(QC4yfW)Ivv<233cop;g$-~z# zsIizZ%%44iGBh_wSY-Oikc`-jps9ljmN^3bcO&|PF=2NcKkT49{JE%zk4q)cZ*7op zB3T4+lbDV6zRvE5h@u}pvHyxjC*edBrm@9x#Kx&nPCxSgbgBw|Dq=S?)$BarBIRrS z7z7Y*PjLK?A_^xS#$kBkuQA#0u=xMNGJk_9hGYH@mi}MZir**`$4svrg*dn&*>6?l zLyCYpc}NL|=D4IG)u$2?Po3A0c(&U*kHO0W7f|r}G+_&l*WvTcqBy+R(|q~R>xUj< z0Dig#$KyVwNJtWtc;=^>B%b@JMv9lrr{d%q@X6zfc{xs&AxJ&16ywFNmU#?b?v{t> zHLsN75|jlmlm)N(1O&%(S)TZCmW0oO7s`Uyd|V7_mAxV_Q62Li!OLt|TT{E`EvvIO+I&Py=8c0lcHJ?2&cED3Q zotO)ugA3YE(z!a$_Kfue(su@uw9t4UH51xTRynuMD=}>rG<|&RxJNs==|;_T@)l>~ zBQ@$Z4pSvWXiUv^XJ5=amlsG`wS$R*H?8)$`r`AlRgCHr?pJ5_} z9Bvt9$$SIejDpFpP4?dDy3^*R(x<5d4=4@l6RsTt326|_+dJ8QBl%8&H~t{%qswz* za`TOR@1AyV!XeaI@@J?sAYb+mR*O5W9#OmH${tA2!9v+Nwlk2Z^Avd#N&|5# z1KJEAAW`m=0RhQKfK6j2kIJ7^at z5+Ep~Faik~GYKnw2`gt3ihT*iOFvbb)20#A>AsOZzbX@^lhphP0}l)cCz1MSD^dv~ z2OmF53cq2Ua^J#xGt9Y9mh$)!n)fW9O!xRvUKj;Uh8JaUba~0l$JUQQx8Ks&$oA!n zZas`5axp8xAzVy!ex|Q(p zQA#S*{-~@bZ=Zzxk2Li0AM0yY?9-9ngthzPrQT<1(%|FcR0>L_NwQU~n3hveaXOA{ zO;=1ObMOkXRj-&{$-((#Yq?^&go8Jc`^2*8YE<{Ggxsf*-BoaKJh?AJaaT)0%Xf9; RKE3SjDk;LrC-<$F{(pFA1pfd4 diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc deleted file mode 100644 index a38387fca1f4d465120941887de8452bebba3f7c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2092 zcmb_dO-vg{6rTO@&+_L&3N(;Vsw!=*25dEr8X_905Eopi2{xp~t+ZNu2iUOox-)B# zIr)%E9CM?`9*aPVDj_wID&-of7bEWhwuh?fDK{z(m3r!XyS9ass#4U9rO)%`n|<@% z?EBvS(%s!bU?`K9@6|;@{y?KS0*%SpmoV8SI?)A_6ojl$5VN9ywrEOOi3%c_A-Z&* z=<+`Jz_IWDk*wTc1HcAfU;`B~8hVC%qoS+mnyuwcjdM4!YEV;8~ex}2Wh?q9d< z(#=@RwDOv{ZgKnOjd(o1C%U4=-QXIvX>oIpoT1CVuf~=+Wjw|;Gqz>14PMgnG*+?7 zX3nz5wrH+w#PVLqK|iAp-DkIfP-I zzXkv~u!ohsN^N5PY0il)de6E6+uESTXNR!C4cSJ4T4g&G?Qp}YTGR?uRoxC%Em(Tl z#CeyhepA*=pJDixSsy+rahtgjRn>|`%hqheDsok21IWMO@kY^pKHwm-2&f!>72sR) zd*t<_D>)~UuTJk=sLT12-hrcc7oFa8HL-K8E~k$#3{+=!GLAgpXO54z-8L2-jRegZUP|0Ge>Oqm`*bof^E4NrsZTc#+P?nZ5>fMFp zbi$Qa%0=y^V#j;sm_U^re(=-8j}wo+disuYtx^AqZF6&OtdXK+roG(tV+(3tzxYIXtkkB`wWT%Z#=3v~J(vX5*QlS#b|^mX{oGFI zgq!eMuw@#DBT!(?m6F9k=RNEqxMo8L!w8H{xD^X5^MC|-al!U=k0&pgQdFq*69uX8S Ooe}_#_)~0D%b=#V2z5&fRv_4=NuefS^&$d;qFuLg^sBQ{ zgM<%0xJ89PPQ~h>(4&P9X`#?V|Aby7oM3lxXrZSN2UP1&=&A3`u6|T)QhLiC-n{p{ zH#_rvGyC?ZV9-zCxx5_Se9KM9Zy59*$#J^Z!PzA`NfBMp#hj2AQ=)*rE9c5fDT(`1 z&Ykz9Jb7=*o0n6vKt%E$(cL>l_w0-In^L~2HzrpuKy*@XJiY&wx_U!B`El>rXZKFN z{{HDseenqv#sn*PJ(;|D?dFY@T-GR*0q|Wbl~#&{jqFD;m!+(HoGz~$?9*(#eWyI-;iwmC z+(W3$Q6He5A=J-N1yJu08sKOUP1@GZmM6mrW*oKn`sm}vyYGY9 zC0oK4p*(>`ClCp}kVpNwJnB)lD|_cjs-!8b#oU&c+ACTLO_#DX!^O6OGz0Qd)6w?~ z)g6KRP=J=pW-p3~mSh^a4TcxV#PwD6&v^Vpm~Y0-bS{3k$nKb>bjFBRix-?HJjKof@V71C@_Y+5aGtk??YVOQ^k9?yvx6zl zv+3!1gK^)XI($oBOi^NEFrDFAoUG-7tH3ToIKkpM0+7|5V=}keHfTN3QZh_BJEJ5B}ZPM&j!5jx%y20mp=br z3m3x^WY7l*n?f@xLtyyJsJ$`}Vi>;!GtJ9j94W$WCEQY?#~MmOoAf|Spa?Tu1g#y? zTC(<1i7<}T@pApU2;-yupf))WVl)>S5kqS$T1$x?N0+`${y@K@2hn#?j9D&5v>l4J zCF)Cp_n2RaPF(Gdxz+sbrHnLAcr+gp0D++03T-$n_-TmrQn zQd_dhC0L7vurC(Ud4p2RPielWS90hFDE*|8&e<7;y@8zrW632}EE%j^kMzu>|=!_B1SVt7x(+%G#fuZZ_ z0UZzbTyC}T!6|{kNrBh#JE_^m9Zb#kQ?vcl{5Nq-&G%FD{gl>7VM^nv-$n539pgUN z;3w*(_>-d4zOcNP;opcWW692+%(OpjE&xAJPIVsZutjtFmb`3>e3iuj!)|KMfa$md dLHL=3ACtMqWcnA9Xp_Vro>@UVC1CjczX0%5-;V$Q diff --git a/__pycache__/services.cpython-312.pyc b/__pycache__/services.cpython-312.pyc deleted file mode 100644 index eaf719635cb10f816548a3979138a9632bb84e79..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5711 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 diff --git a/services/__pycache__/__init__.cpython-312.pyc b/services/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index eeb58097208c6d3d16393700fb79a4bc5e28b2b8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 349 zcmX@j%ge<81g@^KSzCbgV-N=hn4pZ$20+Gih7^Vr#vF!R#wbQc5SuB7DVI5l8O&zR zVaa8UVr67VXGmdL#2Cd^$*Rfr5~N*|@fNppMq){DYEfBcaw?F^?~T4#Sb>C}Ci^YkVufb6Ib0JuC@;m zP|8|cFB*R$EYc`@MYQYAwUBW;3^;rky>k89Ro`Rv@H!8|zzKW@O^1>hfL<5CHesYp zT~bzD%G8SDDjQl^b5*9h8Z4a|uE7$_WICJ@odh8jndVP#m_%?^ECw92?Su*$R(#e9^$x@w2ZjNkAa7NzWlXE`!)XTVgZidco!I|XW+ zbZM7Z@&Z+x+@p&?TC>2rs6?7zeuYDxM4;cq#N!L4cLn33By7Lb3i!Hc*bXbTgQmX{ zgmW#n()3DTfGLQQ1NqE1+L38lUfl~Vt5Z0Z;{5QdjGu(~x5&f7i=S$rCwp)pQKH6# zu&XReG$EwrxlvZE1}!UG_3GxbcW4GHyxHFco_f?By0HfDOp2{rvyr& zbk+UA%`)MbQc>YJbCHHziW*PDf#*?;pc(}w@Yg3mI1U3Zfa;7K=ZUTuL_7!T7Ksfq zQrK#5wzqC>-rT9~w!Y6E<`?dp3p@w+Cz^%1+=kVA57aw^J|mxf3T_~`;R$>MvA$Hc z)lYOC;YND{0%u7R84mj1r#1#3I-|#_Nqa1ZcplUroE?$nmI z>1_p@!A|usH+^JIKQN~s3oOpZ=Zo|%ozcEBV8~3_)tabArY%HhAu{0E^*h_FL&F^d zoQ8s9B8%}pfM+iTHn*#F^?e)=kpEqjtgvq|tHPMOVms?a<4JM&IK<&+P~j8i=RgTe z8K+@54_KJa+2ch!5f7iCm<1(^7mtwqX)NDSZt5U6wOif$@P{)$Ui|6eVQ%TjTzX(G z4GK9qN57%3Xx|!hw3x*QR<~=+vZ9n_)dIKaBhFaX2Tj}W-*B8#o&^=DBIJ>_5(GZS z0gLpo+3=YhT#hY{RIeU#%=;dZGuiJx662_FiLXIzkXWJm`TLWv^$6^-q3Ulw96uMU zxKGXY_t)Q$`=jL8o#xK+?&O|!_vIbyV0`W%Ip5R4GEPx_X7@r2A!hbuq?y<-^=0~K zv=HkE;KiICixU!>2$K?~2pFU#%mBROevyi^@|Yv3+~0YG{roZG#iAY=VY>n2C27mQ vwSHA3vzNf;3ERlL+^^#TpN0#3NCLYZtCZ4T$;9tu_8+4_m+4;wwRHRsn1FM~ diff --git a/services/__pycache__/document.cpython-312.pyc b/services/__pycache__/document.cpython-312.pyc deleted file mode 100644 index da327766f93495c1a56de66d4ed0213c974911cc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1475 zcmb7E&1(};5PxsK+@vy(g>D0G(AW?DypsS`#R^~o$PQA!kYjxgmBVd|QaSe2BTX6c_%uxPaB?4|Q#zQ-%! zB@u*y8~AQwmeB3U7eKBPPD+%KlENrgrxl2+l{BVuooU>d)|d|RF5peB9|U`_FhG-H zyU>D}V37hjT{Eq8lpPP;>MWdlg^RnM%cFF8&U4&26}O#goCESIe62npb+Skoi4$)i zsgYGW2{1GVye%ctB=@I<0Xq`?1_ytfw694nq%F(7Js*hMa<1%hyB1Xa8$mcS&u>&c z8w@Z7X|r}t$L4C0={R1+3ms?SKv$~AIy*X^lLB&|yx%wQM0=iUfk2|vwC8hYt{mQq z48g;ysO&(5Xdc4Xx(ehXp@@pi0N=f4(6VbS(luy5u7}X*dtmWOsjH85om?R&$usSZ zI!}cO+m<3QwaD-)H@OT!!~qf+Ann?S9#rfBvar7!V&a5x*+CEe`R|cr(5y zL#NY8Y0((B#B>G|#Cw5V80bd4(+x`_^FVeYAKA!_Z077{)^3<~BWt&D4;1JU9nzNd z0*wqARE5hY|J?HH5b}Qu@b{KexB$>}{kNuP8TWa}x69D!{yv12+>E3a`$4gg@2*9c zcRVu#+r`NAz3`rT%}iklwlFdoZD!=?!Am%v0o?EP qB*`y6t{>FDQi#mc38FIsi-Qv;@sZk6DWxCC@HeA}7QYZ6vCmHjMn8Z6 diff --git a/services/__pycache__/tts.cpython-312.pyc b/services/__pycache__/tts.cpython-312.pyc deleted file mode 100644 index db088546c45dc30919a6aaebbaa2fc7397ea4cff..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2929 zcmb7GZ)_Vy7N6NajyJYrd!4$u{ObnypIhQIS8*T&Em7Q1Kutd+9FQ-|*1IltZLe>3 zoy58H)H@1Isz_~7?*alrDv(H(BH$Bz-~(6XawpvvOpEMp@&%5QKv2oLR0mXi;LY0G zln@mdY2N$Io0&KB-kW(d|MvMB5tRM|9cj&l(0^#dDO??_#sSPC6{$FbMrs_7V7twX zFg9mLSl~>C%km>UMhtowsq9IlatlmN%ZRIpqx_e&F&M>`(3j8m>$zm=Xrk6%qkO%w z8UrwkG(?wyC9uXO8I?K7jc_Wf@hS(EpJ+fR$*C@ox{7?X!D<{H9vaffXeyyuu0&0H zeR*3i#Vpj-gSPfboVLY3CX-0Ujs!+HPH;rPVFcA;mm<1|2cw)Nn5H3*>h{WY-b6*3 zdQ_{oJ-0QYjKvvRCP~2O$e{a@q?^C(dEC&5(PJo?o-v&qGxAD8>nZAm%n{w}9@CB# zQavz$nrHNY@>srTxn(((OPR7f5n3}o<_KwQjSe6uP$k@64sS1ow@-01o=RI+xouCW zZO;_m>BAk_`)Hao2~K8=sMR6!LkH7?Xo#pU>Yq}$eZ=%8r~mbE*rHSTyey=6`2ppAFFP>=5~xAa~# z^IQa(VMp&)L|BZTLNk9EL*wjIXbi)Mu8l_IIA=uNTY=Nu&v?uC>%29^P2nj%$*N5! z-N)HGpPotfN47)6Yf-)z-6_r+*;T}jU2m~tIO;Dv4!*hm=SyE*c;)NUZ(Kh=^VO>h z*Ux+)!6lAKStW6J=zhsiil06I0?i=ZaN7aqi@?YCN2#TP{`0Xr^Ydpn8Jo9)+q8LqDH*MoS#Cni5JLZoek$1v6hT z3BAn|Tr8X43v^5wO*rwSoF4xW)YU%$`72A4LHbrA08za-7xg%#%`{w-^<|owW;aT* z&N0Pw9whqp#nFpV5fL}^LK5a^DzvDU>wHkR%ITm{$qbHP%u`gy1ppTV7 z$+wVaeR>YBd^# zBYC3317TP!*i7gPKOoN^q7ePrshc7ILYCVrqn@Pk5HVVj=;I zx`+>$?Htlg(Or~Clym_xX!4exmZXOss(Z`!%|u|mx2P)(70$-ZFQ|t;^@l2<&7XTh zXZBC;zv5}1bARmFS!F!FO)KHfa=5n??tOprzcyVBA3n=g0-;&qoN(T=67DF6ca*|A zR+>9!2hR;wHnqMOpWFK8?hCu;&3A_0d-B~U-#0&Mj#pYcE0G;H1b(yl4dR1h)h1rl z(l$GOZv3pj>TVE%%igvXv9&CAm&EROe*fWttKy?m{S~kO%p=o}%sf`1UE53I_BnHY zxZM3$@9GsJY{R z2>Ui&3p7_*;5VyH$Tx_;``q6Gav^xl8vwZwyjgX5gqCG*>mBy-e}?sKz2Qb7*iNJ* zM&=G&5qFinyGq_&x7Pi3rKY&$T4*y>-15EgI(YlJwgCxW=-VA{Mwh)fE^?QBeu~>M z$S-&HMdI7gBF6Cux5)S@4$_WA5yPOxW+5)IiyaJ=Y{3*uEX5H_M{X11z3gJl?%jha z?zMaG>1!MaqJQki0}Qu>0)UqSROwPs80cV^TIqx(SRZf)odDl8_`A@{Da%%)EN6AK zkfGQs%YP^+nOYB_8H4zNK+ZA=*#qbzvM-K(<_P^t*o6KOjBP+pqAJ7iy)PD55wNOz z2j4QsRuQmyc3!>Mc!R>420A&~K;T<~NQ9DIlu#x1zpS>ny}~_IMz3q_q`M!eZFe8} k9mr_bGdh7l1TbDkP0Pr?jDkS_i`u_&-HjpkK Date: Mon, 1 Jun 2026 17:41:17 +0900 Subject: [PATCH 4/7] chore: exclude .claude/ from git tracking Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 70958dc..9eab973 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /.env .idea/ +.claude/ __pycache__/ *.pyc *.pyo From 511c87b2903628f26b581abdb0cae6ee3e69fcbc Mon Sep 17 00:00:00 2001 From: sal Date: Mon, 1 Jun 2026 17:46:53 +0900 Subject: [PATCH 5/7] docs: update ROADMAP to reflect P0/P1 completion and services/ package structure Co-Authored-By: Claude Sonnet 4.6 --- ROADMAP.md | 39 ++++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e1e564a..725c16a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,16 +4,16 @@ | 항목 | 현재 상태 | 심각도 | |------|---------|--------| -| 아키텍처 모듈화 | UI·비즈니스 로직 혼재 (2파일) | 🔴 높음 | +| 아키텍처 모듈화 | ~~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곳) | 🟡 중간 | -| 코드 중복 | `asyncio.run()` 패턴 5회 반복 | 🟡 중간 | -| 결합도 | `api_client` 직접 임포트·전역 상태 | 🔴 높음 | -| 테스트 가능성 | ~20% (모킹 불가능) | 🔴 낮음 | +| async/sync 혼용 | ~~`asyncio.run()` 5곳~~ → 모든 콜백 async 전환 완료 | ✅ 완료 | +| 코드 중복 | ~~`asyncio.run()` 5회 반복~~ → container 위임으로 제거 | ✅ 완료 | +| 결합도 | ~~`api_client` 직접 임포트~~ → DI container + Protocol 추상화 | ✅ 완료 | +| 테스트 가능성 | Protocol 도입으로 모킹 가능 — 테스트 미작성 | 🟡 중간 | | 로깅 | `print()` 만 사용 | 🟢 낮음 | --- @@ -43,8 +43,8 @@ └──────────────┬───────────────────────────┘ │ 생성 ┌──────────────▼───────────────────────────┐ -│ services.py │ -│ ChatService / DocumentService / TTSService │ +│ services/ │ +│ chat.py / document.py / tts.py │ └──────────────┬───────────────────────────┘ │ 사용 ┌──────────────▼───────────────────────────┐ @@ -194,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` 신규 생성 @@ -259,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 @@ -287,7 +292,7 @@ youlbot-webui/ ### P1 - [x] `config.py` 작성 (APIConfig, AppConfig) - [x] `api_client.py` — `APIClientProtocol` + `HTTPAPIClient` 분리 -- [x] `services.py` 작성 (ChatService, DocumentService, TTSService) +- [x] `services/` 패키지 작성 (chat.py, document.py, tts.py + __init__.py 재익스포트) - [x] `container.py` 작성 (lazy singleton 프로퍼티) - [x] `app.py` — 모든 콜백 async 전환 및 container 사용 (`asyncio.run()` 완전 제거) From 148211e236a7e62e9ab97a909b8d3f6991d67f5e Mon Sep 17 00:00:00 2001 From: sal Date: Mon, 1 Jun 2026 17:52:43 +0900 Subject: [PATCH 6/7] =?UTF-8?q?Phase=2027:=20P2=20quality=20improvements?= =?UTF-8?q?=20=E2=80=94=20logging,=20httpx=20pooling,=20validation,=20test?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 1 + ROADMAP.md | 8 +-- api_client.py | 127 ++++++++++++++++----------------- app.py | 12 +++- pyproject.toml | 2 + requirements-dev.txt | 2 + services/document.py | 9 +++ tests/__init__.py | 0 tests/test_chat_service.py | 40 +++++++++++ tests/test_document_service.py | 56 +++++++++++++++ 10 files changed, 184 insertions(+), 73 deletions(-) create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 tests/__init__.py create mode 100644 tests/test_chat_service.py create mode 100644 tests/test_document_service.py diff --git a/.env.example b/.env.example index 99856d3..8da45c1 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,4 @@ YOULBOT_API_TOKEN=youlbot-ai-token!!@@1234 WHISPER_MODEL_SIZE=small TTS_VOICE=Yuna TTS_EDGE_VOICE=ko-KR-SunHiNeural +LOG_LEVEL=INFO diff --git a/ROADMAP.md b/ROADMAP.md index 725c16a..aac2739 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -297,10 +297,10 @@ youlbot-webui/ - [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 - [ ] 재시도 로직 diff --git a/api_client.py b/api_client.py index 26d5eba..6cdd9aa 100644 --- a/api_client.py +++ b/api_client.py @@ -1,6 +1,7 @@ """율봇 API 클라이언트 — APIClientProtocol 인터페이스 + HTTPAPIClient 구현.""" import json import os +import urllib.parse from typing import AsyncIterator, Protocol, runtime_checkable import httpx @@ -30,13 +31,10 @@ class APIClientProtocol(Protocol): class HTTPAPIClient: def __init__(self, config: APIConfig): self._url = config.url.rstrip("/") - self._token = config.token self._timeout = config.timeout - - def _headers(self) -> dict: - if self._token: - return {"Authorization": f"Bearer {self._token}"} - return {} + self._client = httpx.AsyncClient( + headers={"Authorization": f"Bearer {config.token}"} if config.token else {}, + ) async def chat( self, @@ -44,62 +42,55 @@ class HTTPAPIClient: user_id: str = "default", show_thinking: bool = False, ) -> AsyncIterator[tuple[str, str | None]]: - async with httpx.AsyncClient(timeout=self._timeout) as client: - async with client.stream( - "POST", - f"{self._url}/chat", - json={"message": message, "user_id": user_id, "show_thinking": show_thinking}, - headers=self._headers(), - ) as response: - response.raise_for_status() - async for line in response.aiter_lines(): - if not line.startswith("data: "): - continue - raw = line[6:] - try: - payload = json.loads(raw) - except json.JSONDecodeError: - yield str(raw), None - continue - if isinstance(payload, dict) and payload.get("__done"): - yield "", payload.get("run_id") - return - yield payload, None + async with self._client.stream( + "POST", + f"{self._url}/chat", + json={"message": message, "user_id": user_id, "show_thinking": show_thinking}, + timeout=self._timeout, + ) as response: + response.raise_for_status() + async for line in response.aiter_lines(): + if not line.startswith("data: "): + continue + raw = line[6:] + try: + payload = json.loads(raw) + except json.JSONDecodeError: + yield str(raw), None + continue + if isinstance(payload, dict) and payload.get("__done"): + yield "", payload.get("run_id") + return + yield payload, None async def reset(self, user_id: str = "default") -> None: - async with httpx.AsyncClient(timeout=30) as client: - r = await client.post( - f"{self._url}/reset", - params={"user_id": user_id}, - headers=self._headers(), - ) - r.raise_for_status() + r = await self._client.post( + f"{self._url}/reset", + params={"user_id": user_id}, + timeout=30, + ) + r.raise_for_status() async def ingest(self, file_path: str) -> dict: - async with httpx.AsyncClient(timeout=300) as client: - with open(file_path, "rb") as f: - filename = os.path.basename(file_path) - r = await client.post( - f"{self._url}/ingest", - files={"file": (filename, f, "application/octet-stream")}, - headers=self._headers(), - ) - r.raise_for_status() - return r.json() + with open(file_path, "rb") as f: + filename = os.path.basename(file_path) + r = await self._client.post( + f"{self._url}/ingest", + files={"file": (filename, f, "application/octet-stream")}, + timeout=300, + ) + r.raise_for_status() + return r.json() async def list_documents(self) -> list[str]: - async with httpx.AsyncClient(timeout=30) as client: - r = await client.get(f"{self._url}/documents", headers=self._headers()) - r.raise_for_status() - return r.json().get("documents", []) + r = await self._client.get(f"{self._url}/documents", timeout=30) + r.raise_for_status() + return r.json().get("documents", []) async def delete_document(self, source: str) -> None: - async with httpx.AsyncClient(timeout=30) as client: - r = await client.delete( - f"{self._url}/documents/{source}", - headers=self._headers(), - ) - r.raise_for_status() + 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( self, @@ -109,16 +100,18 @@ class HTTPAPIClient: rating: int, run_id: str | None = None, ) -> None: - async with httpx.AsyncClient(timeout=30) as client: - r = await client.post( - f"{self._url}/feedback", - json={ - "user_id": user_id, - "user_msg": user_msg, - "asst_msg": asst_msg, - "rating": rating, - "run_id": run_id, - }, - headers=self._headers(), - ) - r.raise_for_status() + r = await self._client.post( + f"{self._url}/feedback", + json={ + "user_id": user_id, + "user_msg": user_msg, + "asst_msg": asst_msg, + "rating": rating, + "run_id": run_id, + }, + timeout=30, + ) + r.raise_for_status() + + async def aclose(self) -> None: + await self._client.aclose() diff --git a/app.py b/app.py index b7ca498..18d2b4f 100644 --- a/app.py +++ b/app.py @@ -8,6 +8,7 @@ YOULBOT_API_TOKEN= ← api.py에 API_TOKEN 설정 시 동일 값 """ import html as _html +import logging import os import gradio as gr @@ -15,6 +16,13 @@ from dotenv import load_dotenv load_dotenv() +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 config import AppConfig from container import Container @@ -136,7 +144,7 @@ async def handle_feedback(like_data: gr.LikeData, history, run_ids, user_id): try: 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): @@ -147,7 +155,7 @@ async def reset_chat(user_id): try: await container.chat_service.reset(user_id) except Exception as e: - print(f"[Reset] 실패: {e}") + logger.error("대화 초기화 실패: %s", e) return [], [] 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/services/document.py b/services/document.py index d4c5661..b4ed2c3 100644 --- a/services/document.py +++ b/services/document.py @@ -1,11 +1,20 @@ +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]: 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") From 974bab7cd8d591d1b2e2501bcd1e569cdbc7b13f Mon Sep 17 00:00:00 2001 From: sal Date: Tue, 2 Jun 2026 05:59:23 +0900 Subject: [PATCH 7/7] =?UTF-8?q?Phase=2028:=20P3=20=E2=80=94=20Pydantic=20S?= =?UTF-8?q?ettings,=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