Phase 27: P2 quality improvements — logging, httpx pooling, validation, tests

- 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 <noreply@anthropic.com>
This commit is contained in:
sal
2026-06-01 17:52:43 +09:00
parent 511c87b290
commit 148211e236
10 changed files with 184 additions and 73 deletions
+1
View File
@@ -8,3 +8,4 @@ YOULBOT_API_TOKEN=youlbot-ai-token!!@@1234
WHISPER_MODEL_SIZE=small WHISPER_MODEL_SIZE=small
TTS_VOICE=Yuna TTS_VOICE=Yuna
TTS_EDGE_VOICE=ko-KR-SunHiNeural TTS_EDGE_VOICE=ko-KR-SunHiNeural
LOG_LEVEL=INFO
+4 -4
View File
@@ -297,10 +297,10 @@ youlbot-webui/
- [x] `app.py` — 모든 콜백 async 전환 및 container 사용 (`asyncio.run()` 완전 제거) - [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
- [ ] 재시도 로직 - [ ] 재시도 로직
+60 -67
View File
@@ -1,6 +1,7 @@
"""율봇 API 클라이언트 — APIClientProtocol 인터페이스 + HTTPAPIClient 구현.""" """율봇 API 클라이언트 — APIClientProtocol 인터페이스 + HTTPAPIClient 구현."""
import json import json
import os import os
import urllib.parse
from typing import AsyncIterator, Protocol, runtime_checkable from typing import AsyncIterator, Protocol, runtime_checkable
import httpx import httpx
@@ -30,13 +31,10 @@ class APIClientProtocol(Protocol):
class HTTPAPIClient: class HTTPAPIClient:
def __init__(self, config: APIConfig): def __init__(self, config: APIConfig):
self._url = config.url.rstrip("/") self._url = config.url.rstrip("/")
self._token = config.token
self._timeout = config.timeout self._timeout = config.timeout
self._client = httpx.AsyncClient(
def _headers(self) -> dict: headers={"Authorization": f"Bearer {config.token}"} if config.token else {},
if self._token: )
return {"Authorization": f"Bearer {self._token}"}
return {}
async def chat( async def chat(
self, self,
@@ -44,62 +42,55 @@ class HTTPAPIClient:
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]]:
async with httpx.AsyncClient(timeout=self._timeout) as client: async with self._client.stream(
async with client.stream( "POST",
"POST", f"{self._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}, timeout=self._timeout,
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(): if not line.startswith("data: "):
if not line.startswith("data: "): continue
continue raw = line[6:]
raw = line[6:] try:
try: payload = json.loads(raw)
payload = json.loads(raw) except json.JSONDecodeError:
except json.JSONDecodeError: yield str(raw), None
yield str(raw), None continue
continue if isinstance(payload, dict) and payload.get("__done"):
if isinstance(payload, dict) and payload.get("__done"): yield "", payload.get("run_id")
yield "", payload.get("run_id") return
return yield payload, None
yield payload, None
async def reset(self, 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"{self._url}/reset", params={"user_id": user_id},
params={"user_id": user_id}, timeout=30,
headers=self._headers(), )
) r.raise_for_status()
r.raise_for_status()
async def ingest(self, 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 self._client.post(
r = await client.post( f"{self._url}/ingest",
f"{self._url}/ingest", files={"file": (filename, f, "application/octet-stream")},
files={"file": (filename, f, "application/octet-stream")}, timeout=300,
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(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"{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(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"{self._url}/documents/{source}", r.raise_for_status()
headers=self._headers(),
)
r.raise_for_status()
async def save_feedback( async def save_feedback(
self, self,
@@ -109,16 +100,18 @@ class HTTPAPIClient:
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"{self._url}/feedback", json={
json={ "user_id": user_id,
"user_id": user_id, "user_msg": user_msg,
"user_msg": user_msg, "asst_msg": asst_msg,
"asst_msg": asst_msg, "rating": rating,
"rating": rating, "run_id": run_id,
"run_id": run_id, },
}, timeout=30,
headers=self._headers(), )
) r.raise_for_status()
r.raise_for_status()
async def aclose(self) -> None:
await self._client.aclose()
+10 -2
View File
@@ -8,6 +8,7 @@
YOULBOT_API_TOKEN= ← api.py에 API_TOKEN 설정 시 동일 값 YOULBOT_API_TOKEN= ← api.py에 API_TOKEN 설정 시 동일 값
""" """
import html as _html import html as _html
import logging
import os import os
import gradio as gr import gradio as gr
@@ -15,6 +16,13 @@ from dotenv import load_dotenv
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 config import AppConfig
from container import Container from container import Container
@@ -136,7 +144,7 @@ async def handle_feedback(like_data: gr.LikeData, history, run_ids, user_id):
try: try:
await container.chat_service.save_feedback(user_id, user_msg, asst_msg, rating, run_id) await container.chat_service.save_feedback(user_id, user_msg, asst_msg, rating, run_id)
except Exception as e: except Exception as e:
print(f"[Feedback] 저장 실패: {e}") logger.error("피드백 저장 실패: %s", e)
def switch_user(user_id): def switch_user(user_id):
@@ -147,7 +155,7 @@ async def reset_chat(user_id):
try: try:
await container.chat_service.reset(user_id) await container.chat_service.reset(user_id)
except Exception as e: except Exception as e:
print(f"[Reset] 실패: {e}") logger.error("대화 초기화 실패: %s", e)
return [], [] return [], []
+2
View File
@@ -0,0 +1,2 @@
[tool.pytest.ini_options]
asyncio_mode = "auto"
+2
View File
@@ -0,0 +1,2 @@
pytest>=8.0.0
pytest-asyncio>=0.23.0
+9
View File
@@ -1,11 +1,20 @@
from pathlib import Path
from api_client import APIClientProtocol from api_client import APIClientProtocol
_ALLOWED_EXTENSIONS = {".pdf", ".txt"}
class DocumentService: class DocumentService:
def __init__(self, api_client: APIClientProtocol): def __init__(self, api_client: APIClientProtocol):
self._api = api_client self._api = api_client
async def ingest(self, file_path: str) -> dict: 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) return await self._api.ingest(file_path)
async def list_documents(self) -> list[str]: async def list_documents(self) -> list[str]:
View File
+40
View File
@@ -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)
+56
View File
@@ -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")