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:
@@ -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(),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user