Phase 26: P1 architecture refactor — DI container, service layer, async callbacks
- config.py: APIConfig + AppConfig dataclasses, env vars centralized - api_client.py: APIClientProtocol (Protocol) + HTTPAPIClient class, remove module-level globals - services.py: ChatService, DocumentService, TTSService (TTS moved from app.py) - container.py: manual DI container with lazy singleton properties - app.py: all callbacks converted to async, asyncio.run() fully removed, container wired in - .env.example: add TTS_EDGE_VOICE entry - ROADMAP.md: P0/P1 checklist updated to reflect completed work Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+106
-94
@@ -1,112 +1,124 @@
|
||||
"""율봇 API 클라이언트 — youlbot REST API(Phase 22)를 httpx로 호출."""
|
||||
"""율봇 API 클라이언트 — APIClientProtocol 인터페이스 + HTTPAPIClient 구현."""
|
||||
import json
|
||||
import os
|
||||
from typing import AsyncIterator
|
||||
from typing import AsyncIterator, Protocol, runtime_checkable
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
_API_URL = os.getenv("YOULBOT_API_URL", "http://localhost:8000").rstrip("/")
|
||||
_API_TOKEN = os.getenv("YOULBOT_API_TOKEN", "")
|
||||
from config import APIConfig
|
||||
|
||||
|
||||
def _headers() -> dict:
|
||||
if _API_TOKEN:
|
||||
return {"Authorization": f"Bearer {_API_TOKEN}"}
|
||||
return {}
|
||||
@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: ...
|
||||
|
||||
|
||||
async def chat(
|
||||
message: str,
|
||||
user_id: str = "default",
|
||||
show_thinking: bool = False,
|
||||
) -> AsyncIterator[tuple[str, str | None]]:
|
||||
"""SSE 스트림을 읽어 (token, run_id) 튜플을 yield.
|
||||
class HTTPAPIClient:
|
||||
def __init__(self, config: APIConfig):
|
||||
self._url = config.url.rstrip("/")
|
||||
self._token = config.token
|
||||
self._timeout = config.timeout
|
||||
|
||||
- 일반 토큰: (token_str, None)
|
||||
- 스트림 종료: ("", run_id_or_None) ← __done 이벤트
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=180) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{_API_URL}/chat",
|
||||
json={"message": message, "user_id": user_id, "show_thinking": show_thinking},
|
||||
headers=_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
|
||||
def _headers(self) -> dict:
|
||||
if self._token:
|
||||
return {"Authorization": f"Bearer {self._token}"}
|
||||
return {}
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
message: str,
|
||||
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 def reset(user_id: str = "default") -> None:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.post(
|
||||
f"{_API_URL}/reset",
|
||||
params={"user_id": user_id},
|
||||
headers=_headers(),
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
|
||||
async def ingest(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)
|
||||
async def reset(self, user_id: str = "default") -> None:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.post(
|
||||
f"{_API_URL}/ingest",
|
||||
files={"file": (filename, f, "application/octet-stream")},
|
||||
headers=_headers(),
|
||||
f"{self._url}/reset",
|
||||
params={"user_id": user_id},
|
||||
headers=self._headers(),
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
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()
|
||||
|
||||
async def list_documents() -> list[str]:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.get(f"{_API_URL}/documents", headers=_headers())
|
||||
r.raise_for_status()
|
||||
return r.json().get("documents", [])
|
||||
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", [])
|
||||
|
||||
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()
|
||||
|
||||
async def delete_document(source: str) -> None:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.delete(
|
||||
f"{_API_URL}/documents/{source}",
|
||||
headers=_headers(),
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
|
||||
async def save_feedback(
|
||||
user_id: str,
|
||||
user_msg: str,
|
||||
asst_msg: str,
|
||||
rating: int,
|
||||
run_id: str | None = None,
|
||||
) -> None:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.post(
|
||||
f"{_API_URL}/feedback",
|
||||
json={
|
||||
"user_id": user_id,
|
||||
"user_msg": user_msg,
|
||||
"asst_msg": asst_msg,
|
||||
"rating": rating,
|
||||
"run_id": run_id,
|
||||
},
|
||||
headers=_headers(),
|
||||
)
|
||||
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:
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user