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
+60 -67
View File
@@ -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()