diff --git a/.env.example b/.env.example index 2e61206..07ec30a 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,6 @@ SPARSE_MODEL_ID=Qdrant/bm25 # Query Rewriting (Phase 19) — search_documents 호출 시 구어체 쿼리를 검색 최적화 쿼리로 변환 QUERY_REWRITE_ENABLED=false + +# REST API (Phase 22) — Bearer 토큰 인증. 빈 값이면 인증 없음(개발 모드) +API_TOKEN= diff --git a/api.py b/api.py new file mode 100644 index 0000000..4c0b4ff --- /dev/null +++ b/api.py @@ -0,0 +1,128 @@ +"""율봇 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 line.startswith("data: ") and line != "data: [DONE]": + print(json.loads(line[6:]), end="", flush=True) +""" +import json +import os +import tempfile + +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 + +app = FastAPI(title="율봇 API", version="1.0") + +_container = Container() +_container.db_service().connect() +_container.db_service().init_schema() + +_cfg = _container.config() +_agent_cache: dict[str, AgentService] = {} + + +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(), + user_id=user_id, + ) + 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 + + +# ── 엔드포인트 ──────────────────────────────────────────────── + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +@app.post("/chat") +async def chat(req: ChatRequest, _=Depends(_auth)): + """SSE 스트리밍 응답. 각 라인: `data: \n\n`, 종료: `data: [DONE]\n\n`""" + agent = _get_agent(req.user_id) + + async def generate(): + async for token in agent.stream_response(req.message, show_thinking=req.show_thinking): + yield f"data: {json.dumps(token, ensure_ascii=False)}\n\n" + yield "data: [DONE]\n\n" + + return StreamingResponse(generate(), media_type="text/event-stream") + + +@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} diff --git a/app.py b/app.py index 872d7bc..48dcb39 100644 --- a/app.py +++ b/app.py @@ -71,6 +71,7 @@ def _get_agent(user_id: str) -> AgentService: 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(), user_id=user_id, diff --git a/config.py b/config.py index 0809517..a1d00ed 100644 --- a/config.py +++ b/config.py @@ -48,6 +48,9 @@ class Config(BaseSettings): # Query Rewriting (Phase 19) — 구어체 질문을 검색 최적화 쿼리로 변환 query_rewrite_enabled: bool = False + + # REST API (Phase 22) — 빈 문자열이면 인증 스킵 (개발 모드) + api_token: str = "" rag_verbose: bool = False rag_show_sources: bool = False langgraph_verbose: bool = False diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3286472..6fc3bb6 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -249,40 +249,44 @@ telegram_bot.py --- -## Phase 22 — REST API (FastAPI) ★★☆ +## ✅ Phase 22 — REST API (FastAPI) ★★☆ **배경**: 다른 Python 스크립트나 원격 서버에서 율봇을 호출하려면 HTTP API가 필요하다. -Phase 21 Telegram Bot을 원격 서버에서 실행하거나, 다른 앱에 율봇을 임베딩할 때도 활용 가능. - -**구현 방식**: FastAPI + SSE(Server-Sent Events) 스트리밍. - -``` -api.py (uvicorn api:app) -├── POST /chat — SSE 스트리밍 응답 -├── POST /ingest — 파일 수집 -├── GET /documents — 등록 문서 목록 -└── DELETE /documents/{source} — 문서 삭제 -``` - -**클라이언트 예시**: -```python -import httpx - -with httpx.Client() as client: - with client.stream("POST", "http://localhost:8000/chat", - json={"message": "아이 발달 단계가 궁금해요"}, - headers={"Authorization": "Bearer YOUR_TOKEN"}) as r: - for line in r.iter_lines(): - if line.startswith("data: "): - print(line[6:], end="", flush=True) -``` +Telegram Bot을 별도 프로젝트로 분리해 이 API를 호출하는 구조로 사용 가능. **구현 내용**: - `api.py` — FastAPI 앱, `uvicorn api:app --host 0.0.0.0 --port 8000`으로 실행 -- Bearer Token 인증 (`.env` `API_TOKEN`) -- `user_id` 헤더/파라미터로 멀티유저 지원 -- SSE(`text/event-stream`)로 토큰 단위 스트리밍 -- Gradio(`app.py`)와 동일한 `Container` 공유 가능 +- SSE(`text/event-stream`) 스트리밍: 각 라인 `data: \n\n`, 종료 `data: [DONE]\n\n` +- Bearer Token 인증 (`.env` `API_TOKEN` 설정; 빈 값이면 개발 모드 무인증) +- `user_id` 파라미터로 멀티유저 지원 (기존 DB·메모리 구조 그대로 재사용) + +| 엔드포인트 | 설명 | +|-----------|------| +| `GET /health` | 헬스체크 | +| `POST /chat` | SSE 스트리밍 대화 (`message`, `user_id`, `show_thinking`) | +| `POST /reset` | 대화 이력 초기화 (`user_id`) | +| `POST /ingest` | PDF/TXT 파일 업로드 → 벡터DB 수집 | +| `GET /documents` | 등록 문서 목록 | +| `DELETE /documents/{source}` | 문서 삭제 | + +**클라이언트 예시 (별도 Telegram 봇 프로젝트)**: +```python +import httpx, json + +API_URL = "http://192.168.10.x:8000" +HEADERS = {"Authorization": "Bearer YOUR_TOKEN"} + +async def ask_youlbot(message: str, user_id: str) -> str: + full = "" + async with httpx.AsyncClient(timeout=120) as client: + async with client.stream("POST", f"{API_URL}/chat", + json={"message": message, "user_id": user_id}, + headers=HEADERS) as r: + async for line in r.aiter_lines(): + if line.startswith("data: ") and line != "data: [DONE]": + full += json.loads(line[6:]) + return full +``` **난이도**: 중간 | **임팩트**: 높음 (확장성·외부 연동) @@ -348,8 +352,8 @@ docker-compose.yml ``` 단기 (1~2주) 중기 (1개월) 장기 ──────────────────────── ────────────────────── ────────────────── -Phase 21 Telegram Bot → Phase 22 REST API → Phase 16 (Docker) -Phase 20 RAGAS 평가 → Phase 15 (모델선택) → Phase 17 (멀티모달) +Phase 21 Telegram Bot → Phase 20 RAGAS 평가 → Phase 16 (Docker) + → Phase 15 (모델선택) → Phase 17 (멀티모달) ``` ### 우선순위 매트릭스 @@ -374,8 +378,8 @@ Phase 20 RAGAS 평가 → Phase 15 (모델선택) → Phase 17 (멀 | Phase 13-B Reranker | ✅ 완료 | — | — | — | | Phase 18 Hybrid Search | ✅ 완료 | — | — | — | | Phase 19 Query Rewriting | ✅ 완료 | — | — | — | -| Phase 21 Telegram Bot | 🔲 신규 | 중간 | 높음 | ⭐ 1순위 | -| Phase 22 REST API | 🔲 신규 | 중간 | 높음 | ⭐ 2순위 | +| Phase 21 Telegram Bot | 🔲 신규 | 중간 | 높음 | ⭐ 1순위 (REST API 활용) | +| Phase 22 REST API | ✅ 완료 | — | — | — | | Phase 20 RAGAS 평가 | 🔲 신규 | 중간 | 중간 | 3순위 | | Phase 15 모델 선택 | 🔲 미완 | 중간 | 중간 | 4순위 | | Phase 16 Docker | 🔲 미완 | 높음 | 중간 | 5순위 | diff --git a/requirements.txt b/requirements.txt index 43ebf0c..a8131d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,6 +14,10 @@ qdrant-client>=1.9.0 pdfplumber>=0.11.0 # Phase 18 — Hybrid Search (BM25 sparse vectors) fastembed>=0.3.0 +# Phase 22 — REST API +fastapi>=0.100.0 +uvicorn[standard]>=0.23.0 +python-multipart>=0.0.7 # Phase 3 — Agent orchestration langgraph>=1.0.0 # Phase 4 — Web UI