Phase 26: P1 architecture refactor — DI container, service layer, async callbacks

- 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 <noreply@anthropic.com>
This commit is contained in:
sal
2026-06-01 17:36:35 +09:00
parent be4b7c40cb
commit d81a2f5888
7 changed files with 307 additions and 177 deletions
+10
View File
@@ -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
+13 -11
View File
@@ -5,12 +5,13 @@
| 항목 | 현재 상태 | 심각도 | | 항목 | 현재 상태 | 심각도 |
|------|---------|--------| |------|---------|--------|
| 아키텍처 모듈화 | UI·비즈니스 로직 혼재 (2파일) | 🔴 높음 | | 아키텍처 모듈화 | UI·비즈니스 로직 혼재 (2파일) | 🔴 높음 |
| Windows 호환성 | TTS `say` 명령어 — macOS 전용 | 🔴 즉시 | | Windows 호환성 | ~~TTS `say` 명령어 — macOS 전용~~ → 크로스플랫폼 구현 완료 | ✅ 완료 |
| Gradio Chatbot 타입 | `type="messages"` 누락 | 🔴 즉시 | | Gradio Chatbot 타입 | ~~`type="messages"` 누락~~ → Gradio 6.x 기본 포맷 사용 | ✅ 완료 |
| JSON yield 타입 불일치 | `JSONDecodeError` 시 타입 혼용 | 🟡 중간 | | JSON yield 타입 불일치 | ~~`JSONDecodeError` 시 타입 혼용~~`str()` 변환 적용 | ✅ 완료 |
| run_id 인덱싱 버그 | `history` / `run_ids` 동기화 취약 | 🟡 중간 | | run_id 인덱싱 버그 | ~~`history` / `run_ids` 동기화 취약~~ → 방어 로직 추가 | ✅ 완료 |
| async/sync 혼용 | 동기 콜백에서 `run_until_complete` | 🟡 중간 | | RAG 출처 표시 | ~~thinking 박스에 혼재~~ → 답변 하단 `📄 출처` 박스로 분리 | ✅ 완료 |
| 코드 중복 | `run_until_complete` 패턴 5회 반복 | 🟡 중간 | | async/sync 혼용 | 동기 콜백에서 `asyncio.run()` 사용 (5곳) | 🟡 중간 |
| 코드 중복 | `asyncio.run()` 패턴 5회 반복 | 🟡 중간 |
| 결합도 | `api_client` 직접 임포트·전역 상태 | 🔴 높음 | | 결합도 | `api_client` 직접 임포트·전역 상태 | 🔴 높음 |
| 테스트 가능성 | ~20% (모킹 불가능) | 🔴 낮음 | | 테스트 가능성 | ~20% (모킹 불가능) | 🔴 낮음 |
| 로깅 | `print()` 만 사용 | 🟢 낮음 | | 로깅 | `print()` 만 사용 | 🟢 낮음 |
@@ -281,13 +282,14 @@ youlbot-webui/
- [x] `gr.Chatbot` — Gradio 6.x 기본 dict 포맷 사용 (`type` 파라미터 불필요, 제거) - [x] `gr.Chatbot` — Gradio 6.x 기본 dict 포맷 사용 (`type` 파라미터 불필요, 제거)
- [x] `api_client.py` JSON yield 타입 수정 - [x] `api_client.py` JSON yield 타입 수정
- [x] `run_id` 인덱싱 방어 로직 추가 - [x] `run_id` 인덱싱 방어 로직 추가
- [x] RAG 출처 전용 박스 분리 — `source_box` gr.HTML + `_sources_html()` + `__sources` 토큰 처리
### P1 ### P1
- [ ] `config.py` 작성 - [x] `config.py` 작성 (APIConfig, AppConfig)
- [ ] `api_client.py` — `APIClientProtocol` + `HTTPAPIClient` 분리 - [x] `api_client.py` — `APIClientProtocol` + `HTTPAPIClient` 분리
- [ ] `services.py` 작성 (ChatService, DocumentService, TTSService) - [x] `services.py` 작성 (ChatService, DocumentService, TTSService)
- [ ] `container.py` 작성 - [x] `container.py` 작성 (lazy singleton 프로퍼티)
- [ ] `app.py` — 모든 콜백 async 전환 및 container 사용 - [x] `app.py` — 모든 콜백 async 전환 및 container 사용 (`asyncio.run()` 완전 제거)
### P2 ### P2
- [ ] `logging` 모듈 도입 - [ ] `logging` 모듈 도입
+53 -41
View File
@@ -1,39 +1,55 @@
"""율봇 API 클라이언트 — youlbot REST API(Phase 22)를 httpx로 호출.""" """율봇 API 클라이언트 — APIClientProtocol 인터페이스 + HTTPAPIClient 구현."""
import json import json
import os import os
from typing import AsyncIterator from typing import AsyncIterator, Protocol, runtime_checkable
import httpx import httpx
from dotenv import load_dotenv
load_dotenv() from config import APIConfig
_API_URL = os.getenv("YOULBOT_API_URL", "http://localhost:8000").rstrip("/")
_API_TOKEN = os.getenv("YOULBOT_API_TOKEN", "")
def _headers() -> dict: @runtime_checkable
if _API_TOKEN: class APIClientProtocol(Protocol):
return {"Authorization": f"Bearer {_API_TOKEN}"} def chat(
self, message: str, user_id: str, show_thinking: bool
) -> AsyncIterator[tuple[str, str | None]]: ...
async def reset(self, user_id: str) -> None: ...
async def ingest(self, file_path: str) -> dict: ...
async def list_documents(self) -> list[str]: ...
async def delete_document(self, source: str) -> None: ...
async def save_feedback(
self, user_id: str, user_msg: str, asst_msg: str, rating: int, run_id: str | None
) -> None: ...
class HTTPAPIClient:
def __init__(self, config: APIConfig):
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 {} return {}
async def chat(
async def chat( self,
message: str, message: str,
user_id: str = "default", user_id: str = "default",
show_thinking: bool = False, show_thinking: bool = False,
) -> AsyncIterator[tuple[str, str | None]]: ) -> AsyncIterator[tuple[str, str | None]]:
"""SSE 스트림을 읽어 (token, run_id) 튜플을 yield. async with httpx.AsyncClient(timeout=self._timeout) as client:
- 일반 토큰: (token_str, None)
- 스트림 종료: ("", run_id_or_None) ← __done 이벤트
"""
async with httpx.AsyncClient(timeout=180) as client:
async with client.stream( async with client.stream(
"POST", "POST",
f"{_API_URL}/chat", f"{self._url}/chat",
json={"message": message, "user_id": user_id, "show_thinking": show_thinking}, json={"message": message, "user_id": user_id, "show_thinking": show_thinking},
headers=_headers(), headers=self._headers(),
) as response: ) as response:
response.raise_for_status() response.raise_for_status()
async for line in response.aiter_lines(): async for line in response.aiter_lines():
@@ -50,56 +66,52 @@ async def chat(
return return
yield payload, None yield payload, None
async def reset(self, user_id: str = "default") -> None:
async def reset(user_id: str = "default") -> None:
async with httpx.AsyncClient(timeout=30) as client: async with httpx.AsyncClient(timeout=30) as client:
r = await client.post( r = await client.post(
f"{_API_URL}/reset", f"{self._url}/reset",
params={"user_id": user_id}, params={"user_id": user_id},
headers=_headers(), headers=self._headers(),
) )
r.raise_for_status() r.raise_for_status()
async def ingest(self, file_path: str) -> dict:
async def ingest(file_path: str) -> dict:
async with httpx.AsyncClient(timeout=300) as client: async with httpx.AsyncClient(timeout=300) as client:
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)
r = await client.post( r = await client.post(
f"{_API_URL}/ingest", f"{self._url}/ingest",
files={"file": (filename, f, "application/octet-stream")}, files={"file": (filename, f, "application/octet-stream")},
headers=_headers(), headers=self._headers(),
) )
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
async def list_documents(self) -> list[str]:
async def list_documents() -> list[str]:
async with httpx.AsyncClient(timeout=30) as client: async with httpx.AsyncClient(timeout=30) as client:
r = await client.get(f"{_API_URL}/documents", headers=_headers()) r = await client.get(f"{self._url}/documents", headers=self._headers())
r.raise_for_status() r.raise_for_status()
return r.json().get("documents", []) return r.json().get("documents", [])
async def delete_document(self, source: str) -> None:
async def delete_document(source: str) -> None:
async with httpx.AsyncClient(timeout=30) as client: async with httpx.AsyncClient(timeout=30) as client:
r = await client.delete( r = await client.delete(
f"{_API_URL}/documents/{source}", f"{self._url}/documents/{source}",
headers=_headers(), headers=self._headers(),
) )
r.raise_for_status() r.raise_for_status()
async def save_feedback(
async def save_feedback( self,
user_id: str, user_id: str,
user_msg: str, user_msg: str,
asst_msg: str, asst_msg: str,
rating: int, rating: int,
run_id: str | None = None, run_id: str | None = None,
) -> None: ) -> None:
async with httpx.AsyncClient(timeout=30) as client: async with httpx.AsyncClient(timeout=30) as client:
r = await client.post( r = await client.post(
f"{_API_URL}/feedback", f"{self._url}/feedback",
json={ json={
"user_id": user_id, "user_id": user_id,
"user_msg": user_msg, "user_msg": user_msg,
@@ -107,6 +119,6 @@ async def save_feedback(
"rating": rating, "rating": rating,
"run_id": run_id, "run_id": run_id,
}, },
headers=_headers(), headers=self._headers(),
) )
r.raise_for_status() r.raise_for_status()
+25 -72
View File
@@ -7,35 +7,31 @@
YOULBOT_API_URL=http://localhost:8000 YOULBOT_API_URL=http://localhost:8000
YOULBOT_API_TOKEN= ← api.py에 API_TOKEN 설정 시 동일 값 YOULBOT_API_TOKEN= ← api.py에 API_TOKEN 설정 시 동일 값
""" """
import asyncio
import html as _html import html as _html
import os import os
import platform
import subprocess
import tempfile
import gradio as gr import gradio as gr
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
import api_client from config import AppConfig
from container import Container
container = Container(AppConfig())
USER_LABELS = ["아록", "근혜", "도율", "하율"] USER_LABELS = ["아록", "근혜", "도율", "하율"]
DEFAULT_USER = "아록" DEFAULT_USER = "아록"
# ── STT (Whisper) — 로컬 실행 유지 ────────────────────────────── # ── STT (Whisper) — 로컬 실행 유지 ──────────────────────────────
_whisper_model = None _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(): 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(_WHISPER_SIZE) _whisper_model = whisper.load_model(container.config.whisper_model_size)
return _whisper_model return _whisper_model
@@ -47,51 +43,6 @@ def transcribe_audio(filepath: str) -> str:
return result["text"].strip() 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): 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_acc = "" # 전체 누적 (완료 후 details용)
thinking_text = "" # __thinking 토큰만 (줄 감지용) thinking_text = "" # __thinking 토큰만 (줄 감지용)
thinking_finalized = False thinking_finalized = False
source_box_html = ""
try: 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: if run_id is not None:
collected_run_id = run_id collected_run_id = run_id
break break
@@ -162,13 +112,13 @@ 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 tts_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()
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 idx = like_data.index
if isinstance(idx, (list, tuple)): if isinstance(idx, (list, tuple)):
idx = idx[0] idx = idx[0]
@@ -176,7 +126,6 @@ def handle_feedback(like_data: gr.LikeData, history, run_ids, user_id):
return return
if history[idx].get("role") != "assistant": if history[idx].get("role") != "assistant":
return return
# idx 위치까지 등장한 assistant 메시지 수 = 이 메시지의 0-based 턴 번호
asst_turn = sum(1 for m in history[:idx] if m.get("role") == "assistant") 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 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 rating = 1 if like_data.liked else -1
try: 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: except Exception as e:
print(f"[Feedback] 저장 실패: {e}") print(f"[Feedback] 저장 실패: {e}")
@@ -194,9 +143,9 @@ def switch_user(user_id):
return [], [] return [], []
def reset_chat(user_id): async def reset_chat(user_id):
try: try:
asyncio.run(api_client.reset(user_id)) await container.chat_service.reset(user_id)
except Exception as e: except Exception as e:
print(f"[Reset] 실패: {e}") print(f"[Reset] 실패: {e}")
return [], [] return [], []
@@ -204,14 +153,14 @@ def reset_chat(user_id):
# ── 문서 관리 ───────────────────────────────────────────────────── # ── 문서 관리 ─────────────────────────────────────────────────────
def ingest_files(files): async def ingest_files(files):
if not files: if not files:
return "파일을 선택해주세요." return "파일을 선택해주세요."
paths = [f if isinstance(f, str) else f.name for f in files] paths = [f if isinstance(f, str) else f.name for f in files]
results = [] results = []
for path in paths: for path in paths:
try: try:
result = asyncio.run(api_client.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:
@@ -219,22 +168,22 @@ def ingest_files(files):
return "\n".join(results) return "\n".join(results)
def list_docs(): async def list_docs():
try: 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] return [[os.path.basename(s), s] for s in sources]
except Exception as e: except Exception as e:
return [[f"오류: {e}", ""]] return [[f"오류: {e}", ""]]
def delete_doc(source): async def delete_doc(source):
if not source.strip(): if not source.strip():
return "삭제할 파일 경로를 입력하세요.", list_docs() return "삭제할 파일 경로를 입력하세요.", await list_docs()
try: try:
asyncio.run(api_client.delete_document(source.strip())) await container.document_service.delete_document(source.strip())
return f"삭제 완료: {os.path.basename(source.strip())}", list_docs() return f"삭제 완료: {os.path.basename(source.strip())}", await list_docs()
except Exception as e: except Exception as e:
return f"오류: {e}", list_docs() return f"오류: {e}", await list_docs()
# ── UI 구성 ────────────────────────────────────────────────────── # ── UI 구성 ──────────────────────────────────────────────────────
@@ -407,4 +356,8 @@ with gr.Blocks(title="율봇") as demo:
if __name__ == "__main__": 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(),
)
+19
View File
@@ -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
+41
View File
@@ -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
+93
View File
@@ -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