0b50444e43
- IDEA-2 스마트 알림: td_reminders 테이블, set_reminder/list_reminders 도구,
SchedulerService(asyncio 60초 루프, D-7/D-1/D-0 Telegram push),
FastAPI lifespan 연동, GET /reminders/{user_id} 엔드포인트
- IDEA-1 대화 기반 RAG: IngestionService.store_text() 추가,
AgentService._maybe_index_conversation() — 응답 후 LLM 판단 → Qdrant 저장
(CONV_RAG_ENABLED=true 활성화, background task로 응답 속도 무관)
- IDEA-5 CRAG: AgentState에 crag_fallback_used 플래그 추가,
crag_check LangGraph 노드 — search_documents 결과 없으면 web_search 자동 주입,
route_after_crag으로 fallback 1회 루프 제어 (CRAG_ENABLED=true 활성화)
- IDEA-7 RAG Auto-Eval: eval/auto_tune.py — API 서버 없이 파라미터 조합별
context_precision/recall 비교, 최적 설정 추천
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
209 lines
7.3 KiB
Python
209 lines
7.3 KiB
Python
"""율봇 REST API — Phase 22.
|
|
|
|
실행:
|
|
uvicorn api:app --host 0.0.0.0 --port 8000
|
|
|
|
클라이언트 예시:
|
|
import httpx, json
|
|
headers = {"Authorization": "Bearer YOUR_TOKEN"}
|
|
with httpx.Client() as c:
|
|
with c.stream("POST", "http://localhost:8000/chat",
|
|
json={"message": "안녕", "user_id": "홍길동"},
|
|
headers=headers, timeout=120) as r:
|
|
for line in r.iter_lines():
|
|
if not line.startswith("data: "):
|
|
continue
|
|
payload = json.loads(line[6:])
|
|
if isinstance(payload, dict) and payload.get("__done"):
|
|
break # run_id = payload["run_id"]
|
|
print(payload, end="", flush=True)
|
|
"""
|
|
import json
|
|
import os
|
|
import tempfile
|
|
from contextlib import asynccontextmanager
|
|
|
|
from dotenv import load_dotenv
|
|
load_dotenv()
|
|
|
|
from fastapi import Depends, FastAPI, File, Header, HTTPException, UploadFile
|
|
from fastapi.responses import StreamingResponse
|
|
from pydantic import BaseModel
|
|
|
|
from container import Container
|
|
from services.agent.agent_service import AgentService
|
|
|
|
_container = Container()
|
|
_container.db_service().connect()
|
|
_container.db_service().init_schema()
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
scheduler = _container.scheduler_service()
|
|
scheduler.start()
|
|
yield
|
|
scheduler.shutdown()
|
|
|
|
|
|
app = FastAPI(title="율봇 API", version="1.0", lifespan=lifespan)
|
|
|
|
_cfg = _container.config()
|
|
_agent_cache: dict[str, AgentService] = {}
|
|
|
|
# Vision 모델 — VISION_ENABLED=true 시 lazy 초기화
|
|
_vision_model = _container.vision_model() if _cfg.vision_enabled else None
|
|
|
|
|
|
def _get_agent(user_id: str) -> AgentService:
|
|
if user_id not in _agent_cache:
|
|
_agent_cache[user_id] = AgentService(
|
|
chat_model=_container.chat_model(),
|
|
retriever_service=_container.retriever_service(),
|
|
system_prompt=_cfg.system_prompt,
|
|
rag_verbose=_cfg.rag_verbose,
|
|
rag_show_sources=_cfg.rag_show_sources,
|
|
langgraph_verbose=_cfg.langgraph_verbose,
|
|
think_verbose=_cfg.think_verbose,
|
|
query_rewrite_enabled=_cfg.query_rewrite_enabled,
|
|
user_profile_repository=_container.user_profile_repository(),
|
|
conversation_repository=_container.conversation_repository(),
|
|
reminder_repository=_container.reminder_repository(),
|
|
ingestion_service=_container.ingestion_service() if _cfg.conv_rag_enabled else None,
|
|
crag_enabled=_cfg.crag_enabled,
|
|
conv_rag_enabled=_cfg.conv_rag_enabled,
|
|
user_id=user_id,
|
|
)
|
|
if _vision_model:
|
|
_agent_cache[user_id].set_vision_model(_vision_model)
|
|
return _agent_cache[user_id]
|
|
|
|
|
|
def _auth(authorization: str = Header(default="")):
|
|
"""API_TOKEN 설정 시 Bearer 토큰 검증. 미설정 시 인증 스킵(개발 모드)."""
|
|
token = _cfg.api_token
|
|
if token and authorization != f"Bearer {token}":
|
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
|
|
|
|
# ── 요청/응답 모델 ────────────────────────────────────────────
|
|
|
|
|
|
class ChatRequest(BaseModel):
|
|
message: str
|
|
user_id: str = "default"
|
|
show_thinking: bool = False
|
|
image_base64: str | None = None # base64 인코딩된 이미지 (선택)
|
|
|
|
|
|
class FeedbackRequest(BaseModel):
|
|
user_id: str = "default"
|
|
user_msg: str
|
|
asst_msg: str
|
|
rating: int
|
|
run_id: str | None = None
|
|
|
|
|
|
# ── 엔드포인트 ────────────────────────────────────────────────
|
|
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.post("/chat")
|
|
async def chat(req: ChatRequest, _=Depends(_auth)):
|
|
"""SSE 스트리밍 응답. 각 라인: `data: <JSON 토큰>\n\n`, 종료: `data: [DONE]\n\n`"""
|
|
agent = _get_agent(req.user_id)
|
|
|
|
# 이미지 base64 → 임시 파일 저장
|
|
image_path: str | None = None
|
|
tmp_path: str | None = None
|
|
if req.image_base64 and _vision_model:
|
|
import base64
|
|
img_bytes = base64.b64decode(req.image_base64)
|
|
suffix = ".jpg"
|
|
if img_bytes[:4] == b"\x89PNG":
|
|
suffix = ".png"
|
|
elif img_bytes[:4] == b"GIF8":
|
|
suffix = ".gif"
|
|
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False, dir="/tmp", prefix="youlbot_img_")
|
|
tmp.write(img_bytes)
|
|
tmp.close()
|
|
image_path = tmp.name
|
|
tmp_path = tmp.name
|
|
|
|
async def generate():
|
|
try:
|
|
async for token in agent.stream_response(
|
|
req.message, show_thinking=req.show_thinking, image_path=image_path
|
|
):
|
|
yield f"data: {json.dumps(token, ensure_ascii=False)}\n\n"
|
|
yield f"data: {json.dumps({'__done': True, 'run_id': agent.last_run_id}, ensure_ascii=False)}\n\n"
|
|
finally:
|
|
if tmp_path and os.path.exists(tmp_path):
|
|
os.unlink(tmp_path)
|
|
|
|
return StreamingResponse(generate(), media_type="text/event-stream")
|
|
|
|
|
|
@app.post("/feedback")
|
|
async def save_feedback(req: FeedbackRequest, _=Depends(_auth)):
|
|
"""👍/👎 피드백 저장. LangSmith 트레이싱 활성화 시 자동 연동."""
|
|
_container.feedback_repository().save_feedback(
|
|
req.user_id, req.user_msg, req.asst_msg, req.rating, req.run_id
|
|
)
|
|
if req.run_id and os.getenv("LANGCHAIN_TRACING_V2") == "true":
|
|
try:
|
|
from langsmith import Client
|
|
Client().create_feedback(run_id=req.run_id, key="user_feedback", score=req.rating)
|
|
except Exception:
|
|
pass
|
|
return {"saved": True}
|
|
|
|
|
|
@app.post("/reset")
|
|
async def reset(user_id: str = "default", _=Depends(_auth)):
|
|
"""대화 이력 초기화."""
|
|
if user_id in _agent_cache:
|
|
_agent_cache[user_id].reset()
|
|
return {"reset": True, "user_id": user_id}
|
|
|
|
|
|
@app.post("/ingest")
|
|
async def ingest(file: UploadFile = File(...), _=Depends(_auth)):
|
|
"""PDF 또는 TXT 파일을 업로드해 벡터DB에 수집."""
|
|
suffix = os.path.splitext(file.filename or "")[1] or ".bin"
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as f:
|
|
f.write(await file.read())
|
|
tmp_path = f.name
|
|
try:
|
|
count = _container.ingestion_service().ingest([tmp_path])
|
|
return {"chunks": count, "filename": file.filename}
|
|
finally:
|
|
os.unlink(tmp_path)
|
|
|
|
|
|
@app.get("/documents")
|
|
async def list_documents(_=Depends(_auth)):
|
|
"""등록된 문서 경로 목록 반환."""
|
|
return {"documents": _container.retriever_service().list_documents()}
|
|
|
|
|
|
@app.delete("/documents/{source:path}")
|
|
async def delete_document(source: str, _=Depends(_auth)):
|
|
"""source 경로에 해당하는 모든 청크 삭제."""
|
|
_container.retriever_service().delete_document(source)
|
|
return {"deleted": source}
|
|
|
|
|
|
@app.get("/reminders/{user_id}")
|
|
async def list_reminders(user_id: str, days_ahead: int = 30, _=Depends(_auth)):
|
|
"""user_id의 예정 알림 목록 반환 (기본 30일 이내)."""
|
|
items = _container.reminder_repository().get_upcoming(user_id, days_ahead=days_ahead)
|
|
return {"reminders": [
|
|
{"id": r["id"], "remind_date": str(r["remind_date"]), "message": r["message"]}
|
|
for r in items
|
|
]}
|