Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 974bab7cd8 | |||
| 148211e236 | |||
| 511c87b290 | |||
| 79f2abe7cf | |||
| 7eed70d7f7 | |||
| 1e93def909 | |||
| d81a2f5888 |
@@ -0,0 +1,11 @@
|
|||||||
|
# 율봇 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
|
||||||
|
LOG_LEVEL=INFO
|
||||||
@@ -1 +1,6 @@
|
|||||||
/.env
|
/.env
|
||||||
|
.idea/
|
||||||
|
.claude/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
|||||||
+40
-32
@@ -4,16 +4,17 @@
|
|||||||
|
|
||||||
| 항목 | 현재 상태 | 심각도 |
|
| 항목 | 현재 상태 | 심각도 |
|
||||||
|------|---------|--------|
|
|------|---------|--------|
|
||||||
| 아키텍처 모듈화 | UI·비즈니스 로직 혼재 (2파일) | 🔴 높음 |
|
| 아키텍처 모듈화 | ~~2파일 혼재~~ → config / api_client / services/ / container / app 5모듈 분리 | ✅ 완료 |
|
||||||
| 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곳~~ → 모든 콜백 async 전환 완료 | ✅ 완료 |
|
||||||
| 결합도 | `api_client` 직접 임포트·전역 상태 | 🔴 높음 |
|
| 코드 중복 | ~~`asyncio.run()` 5회 반복~~ → container 위임으로 제거 | ✅ 완료 |
|
||||||
| 테스트 가능성 | ~20% (모킹 불가능) | 🔴 낮음 |
|
| 결합도 | ~~`api_client` 직접 임포트~~ → DI container + Protocol 추상화 | ✅ 완료 |
|
||||||
| 로깅 | `print()` 만 사용 | 🟢 낮음 |
|
| 테스트 가능성 | ~~모킹 불가능~~ → pytest-asyncio 단위 테스트 10개 작성 | ✅ 완료 |
|
||||||
|
| 로깅 | ~~`print()` 만 사용~~ → `logging` 모듈, `LOG_LEVEL` 환경변수 | ✅ 완료 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -42,8 +43,8 @@
|
|||||||
└──────────────┬───────────────────────────┘
|
└──────────────┬───────────────────────────┘
|
||||||
│ 생성
|
│ 생성
|
||||||
┌──────────────▼───────────────────────────┐
|
┌──────────────▼───────────────────────────┐
|
||||||
│ services.py │
|
│ services/ │
|
||||||
│ ChatService / DocumentService / TTSService │
|
│ chat.py / document.py / tts.py │
|
||||||
└──────────────┬───────────────────────────┘
|
└──────────────┬───────────────────────────┘
|
||||||
│ 사용
|
│ 사용
|
||||||
┌──────────────▼───────────────────────────┐
|
┌──────────────▼───────────────────────────┐
|
||||||
@@ -193,13 +194,14 @@ class HTTPAPIClient:
|
|||||||
# 기존 함수들을 메서드로 이전
|
# 기존 함수들을 메서드로 이전
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 서비스 레이어 분리 → `services.py` 신규 생성
|
### 3. 서비스 레이어 분리 → `services/` 패키지 신규 생성
|
||||||
|
|
||||||
```
|
```
|
||||||
services.py
|
services/
|
||||||
├── ChatService(api_client) — chat, reset, save_feedback
|
├── __init__.py — ChatService, DocumentService, TTSService 재익스포트
|
||||||
├── DocumentService(api_client) — ingest, list_documents, delete_document
|
├── chat.py — ChatService: chat, reset, save_feedback
|
||||||
└── TTSService() — tts_speak (플랫폼 분기)
|
├── document.py — DocumentService: ingest, list_documents, delete_document
|
||||||
|
└── tts.py — TTSService: speak (플랫폼 분기)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. 수동 DI 컨테이너 → `container.py` 신규 생성
|
### 4. 수동 DI 컨테이너 → `container.py` 신규 생성
|
||||||
@@ -258,10 +260,14 @@ class Container:
|
|||||||
```
|
```
|
||||||
youlbot-webui/
|
youlbot-webui/
|
||||||
├── app.py # Gradio UI 전용 — 콜백만 존재, 비즈니스 로직 없음
|
├── app.py # Gradio UI 전용 — 콜백만 존재, 비즈니스 로직 없음
|
||||||
├── container.py # 수동 DI 컨테이너 (신규)
|
├── container.py # 수동 DI 컨테이너
|
||||||
├── services.py # ChatService, DocumentService, TTSService (신규)
|
├── services/
|
||||||
├── api_client.py # APIClientProtocol + HTTPAPIClient (리팩터링)
|
│ ├── __init__.py # 재익스포트
|
||||||
├── config.py # AppConfig, APIConfig dataclass (신규)
|
│ ├── chat.py # ChatService
|
||||||
|
│ ├── document.py # DocumentService
|
||||||
|
│ └── tts.py # TTSService
|
||||||
|
├── api_client.py # APIClientProtocol + HTTPAPIClient
|
||||||
|
├── config.py # AppConfig, APIConfig dataclass
|
||||||
├── tests/
|
├── tests/
|
||||||
│ ├── test_chat_service.py
|
│ ├── test_chat_service.py
|
||||||
│ └── test_document_service.py
|
│ └── test_document_service.py
|
||||||
@@ -281,20 +287,22 @@ 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/` 패키지 작성 (chat.py, document.py, tts.py + __init__.py 재익스포트)
|
||||||
- [ ] `container.py` 작성
|
- [x] `container.py` 작성 (lazy singleton 프로퍼티)
|
||||||
- [ ] `app.py` — 모든 콜백 async 전환 및 container 사용
|
- [x] `app.py` — 모든 콜백 async 전환 및 container 사용 (`asyncio.run()` 완전 제거)
|
||||||
|
|
||||||
### P2
|
### P2
|
||||||
- [ ] `logging` 모듈 도입
|
- [x] `logging` 모듈 도입 — `basicConfig` + `LOG_LEVEL` 환경변수, `print()` 제거
|
||||||
- [ ] httpx `AsyncClient` 재사용
|
- [x] httpx `AsyncClient` 재사용 — `HTTPAPIClient.__init__`에서 공유 클라이언트 생성, `aclose()` 추가
|
||||||
- [ ] 입력 검증 추가
|
- [x] 입력 검증 추가 — `DocumentService.ingest` 파일 존재·확장자 검증, `delete_document` URL 인코딩
|
||||||
- [ ] `tests/` 단위 테스트 작성
|
- [x] `tests/` 단위 테스트 작성 — `pytest-asyncio`, `ChatService` 4개 / `DocumentService` 6개 (10/10 통과)
|
||||||
|
|
||||||
### P3
|
### P3
|
||||||
- [ ] 재시도 로직
|
- [x] 재시도 로직 — `tenacity` / 5xx·TransportError에만 최대 3회, 지수 백오프(1→8s) / `chat` 제외 5개 메서드 적용
|
||||||
- [ ] Pydantic Settings
|
- [x] Pydantic Settings — `config.py` dataclass → `BaseSettings` (flat `AppConfig`), `.env` 자동 로드
|
||||||
|
- [x] IoC 프레임워크 전환 — 수동 DI → `dependency-injector` `DeclarativeContainer` + `providers.Singleton`
|
||||||
|
|||||||
+74
-48
@@ -1,39 +1,68 @@
|
|||||||
"""율봇 API 클라이언트 — youlbot REST API(Phase 22)를 httpx로 호출."""
|
"""율봇 API 클라이언트 — APIClientProtocol 인터페이스 + HTTPAPIClient 구현."""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from typing import AsyncIterator
|
import urllib.parse
|
||||||
|
from typing import AsyncIterator, Protocol, runtime_checkable
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from dotenv import load_dotenv
|
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
|
||||||
|
|
||||||
load_dotenv()
|
from config import AppConfig
|
||||||
|
|
||||||
_API_URL = os.getenv("YOULBOT_API_URL", "http://localhost:8000").rstrip("/")
|
|
||||||
_API_TOKEN = os.getenv("YOULBOT_API_TOKEN", "")
|
|
||||||
|
|
||||||
|
|
||||||
def _headers() -> dict:
|
def _is_transient(exc: Exception) -> bool:
|
||||||
if _API_TOKEN:
|
if isinstance(exc, httpx.HTTPStatusError):
|
||||||
return {"Authorization": f"Bearer {_API_TOKEN}"}
|
return exc.response.status_code >= 500
|
||||||
return {}
|
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
|
||||||
|
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: ...
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPAPIClient:
|
||||||
|
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.youlbot_api_token}"}
|
||||||
|
if config.youlbot_api_token else {},
|
||||||
|
)
|
||||||
|
|
||||||
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 self._client.stream(
|
||||||
|
|
||||||
- 일반 토큰: (token_str, None)
|
|
||||||
- 스트림 종료: ("", run_id_or_None) ← __done 이벤트
|
|
||||||
"""
|
|
||||||
async with httpx.AsyncClient(timeout=180) as client:
|
|
||||||
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(),
|
timeout=self._timeout,
|
||||||
) 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 +79,50 @@ async def chat(
|
|||||||
return
|
return
|
||||||
yield payload, None
|
yield payload, None
|
||||||
|
|
||||||
|
@_retry
|
||||||
async def reset(user_id: str = "default") -> None:
|
async def reset(self, user_id: str = "default") -> None:
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
r = await self._client.post(
|
||||||
r = await client.post(
|
f"{self._url}/reset",
|
||||||
f"{_API_URL}/reset",
|
|
||||||
params={"user_id": user_id},
|
params={"user_id": user_id},
|
||||||
headers=_headers(),
|
timeout=30,
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
|
@_retry
|
||||||
async def ingest(file_path: str) -> dict:
|
async def ingest(self, file_path: str) -> dict:
|
||||||
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 self._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(),
|
timeout=300,
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
@_retry
|
||||||
async def list_documents() -> list[str]:
|
async def list_documents(self) -> list[str]:
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
r = await self._client.get(f"{self._url}/documents", timeout=30)
|
||||||
r = await client.get(f"{_API_URL}/documents", headers=_headers())
|
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json().get("documents", [])
|
return r.json().get("documents", [])
|
||||||
|
|
||||||
|
@_retry
|
||||||
async def delete_document(source: str) -> None:
|
async def delete_document(self, source: str) -> None:
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
encoded = urllib.parse.quote(source, safe="")
|
||||||
r = await client.delete(
|
r = await self._client.delete(f"{self._url}/documents/{encoded}", timeout=30)
|
||||||
f"{_API_URL}/documents/{source}",
|
|
||||||
headers=_headers(),
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
|
@_retry
|
||||||
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:
|
r = await self._client.post(
|
||||||
r = await client.post(
|
f"{self._url}/feedback",
|
||||||
f"{_API_URL}/feedback",
|
|
||||||
json={
|
json={
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"user_msg": user_msg,
|
"user_msg": user_msg,
|
||||||
@@ -107,6 +130,9 @@ async def save_feedback(
|
|||||||
"rating": rating,
|
"rating": rating,
|
||||||
"run_id": run_id,
|
"run_id": run_id,
|
||||||
},
|
},
|
||||||
headers=_headers(),
|
timeout=30,
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
await self._client.aclose()
|
||||||
|
|||||||
@@ -7,35 +7,38 @@
|
|||||||
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 logging
|
||||||
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
|
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 container import Container
|
||||||
|
|
||||||
|
container = Container()
|
||||||
|
|
||||||
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 +50,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 +68,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 +119,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 +133,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,33 +141,33 @@ 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}")
|
logger.error("피드백 저장 실패: %s", e)
|
||||||
|
|
||||||
|
|
||||||
def switch_user(user_id):
|
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}")
|
logger.error("대화 초기화 실패: %s", e)
|
||||||
return [], []
|
return [], []
|
||||||
|
|
||||||
|
|
||||||
# ── 문서 관리 ─────────────────────────────────────────────────────
|
# ── 문서 관리 ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
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 +175,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 +363,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(),
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from dependency_injector import containers, providers
|
||||||
|
|
||||||
|
from api_client import HTTPAPIClient
|
||||||
|
from config import AppConfig
|
||||||
|
from services import ChatService, DocumentService, TTSService
|
||||||
|
|
||||||
|
|
||||||
|
class Container(containers.DeclarativeContainer):
|
||||||
|
config = providers.Singleton(AppConfig)
|
||||||
|
|
||||||
|
api_client = providers.Singleton(HTTPAPIClient, config=config)
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pytest>=8.0.0
|
||||||
|
pytest-asyncio>=0.23.0
|
||||||
@@ -4,3 +4,6 @@ python-dotenv>=1.0.0
|
|||||||
openai-whisper>=20231117
|
openai-whisper>=20231117
|
||||||
edge-tts>=6.1.9
|
edge-tts>=6.1.9
|
||||||
pyttsx3>=2.90
|
pyttsx3>=2.90
|
||||||
|
pydantic-settings>=2.0.0
|
||||||
|
tenacity>=8.0.0
|
||||||
|
dependency-injector>=4.41.0
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from services.chat import ChatService
|
||||||
|
from services.document import DocumentService
|
||||||
|
from services.tts import TTSService
|
||||||
|
|
||||||
|
__all__ = ["ChatService", "DocumentService", "TTSService"]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from typing import AsyncIterator
|
||||||
|
|
||||||
|
from api_client import APIClientProtocol
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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]:
|
||||||
|
return await self._api.list_documents()
|
||||||
|
|
||||||
|
async def delete_document(self, source: str) -> None:
|
||||||
|
await self._api.delete_document(source)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import asyncio
|
||||||
|
import platform
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from config import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user