148211e236
- 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>
118 lines
3.7 KiB
Python
118 lines
3.7 KiB
Python
"""율봇 API 클라이언트 — APIClientProtocol 인터페이스 + HTTPAPIClient 구현."""
|
|
import json
|
|
import os
|
|
import urllib.parse
|
|
from typing import AsyncIterator, Protocol, runtime_checkable
|
|
|
|
import httpx
|
|
|
|
from config import APIConfig
|
|
|
|
|
|
@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: APIConfig):
|
|
self._url = config.url.rstrip("/")
|
|
self._timeout = config.timeout
|
|
self._client = httpx.AsyncClient(
|
|
headers={"Authorization": f"Bearer {config.token}"} if config.token else {},
|
|
)
|
|
|
|
async def chat(
|
|
self,
|
|
message: str,
|
|
user_id: str = "default",
|
|
show_thinking: bool = False,
|
|
) -> AsyncIterator[tuple[str, str | 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:
|
|
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:
|
|
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]:
|
|
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:
|
|
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,
|
|
user_id: str,
|
|
user_msg: str,
|
|
asst_msg: str,
|
|
rating: int,
|
|
run_id: str | None = None,
|
|
) -> None:
|
|
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()
|