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")