Implement Phase 22: REST API (FastAPI + SSE streaming)
- api.py: FastAPI 앱 신규 생성 - GET /health, POST /chat (SSE), POST /reset, POST /ingest, GET/DELETE /documents - SSE 포맷: data: <JSON 토큰>\n\n / data: [DONE]\n\n - Bearer Token 인증 (API_TOKEN 미설정 시 개발 모드) - user_id 파라미터로 멀티유저 지원 (기존 AgentService·DB 구조 재사용) - config.py: api_token 필드 추가 - app.py: _get_agent에 query_rewrite_enabled 누락 수정 - requirements.txt: fastapi, uvicorn[standard], python-multipart 추가 - ROADMAP: Phase 22 ✅, Telegram Bot 클라이언트 예시 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -22,3 +22,6 @@ SPARSE_MODEL_ID=Qdrant/bm25
|
|||||||
|
|
||||||
# Query Rewriting (Phase 19) — search_documents 호출 시 구어체 쿼리를 검색 최적화 쿼리로 변환
|
# Query Rewriting (Phase 19) — search_documents 호출 시 구어체 쿼리를 검색 최적화 쿼리로 변환
|
||||||
QUERY_REWRITE_ENABLED=false
|
QUERY_REWRITE_ENABLED=false
|
||||||
|
|
||||||
|
# REST API (Phase 22) — Bearer 토큰 인증. 빈 값이면 인증 없음(개발 모드)
|
||||||
|
API_TOKEN=
|
||||||
|
|||||||
@@ -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: <JSON 토큰>\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}
|
||||||
@@ -71,6 +71,7 @@ def _get_agent(user_id: str) -> AgentService:
|
|||||||
rag_show_sources=_cfg.rag_show_sources,
|
rag_show_sources=_cfg.rag_show_sources,
|
||||||
langgraph_verbose=_cfg.langgraph_verbose,
|
langgraph_verbose=_cfg.langgraph_verbose,
|
||||||
think_verbose=_cfg.think_verbose,
|
think_verbose=_cfg.think_verbose,
|
||||||
|
query_rewrite_enabled=_cfg.query_rewrite_enabled,
|
||||||
user_profile_repository=container.user_profile_repository(),
|
user_profile_repository=container.user_profile_repository(),
|
||||||
conversation_repository=container.conversation_repository(),
|
conversation_repository=container.conversation_repository(),
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ class Config(BaseSettings):
|
|||||||
|
|
||||||
# Query Rewriting (Phase 19) — 구어체 질문을 검색 최적화 쿼리로 변환
|
# Query Rewriting (Phase 19) — 구어체 질문을 검색 최적화 쿼리로 변환
|
||||||
query_rewrite_enabled: bool = False
|
query_rewrite_enabled: bool = False
|
||||||
|
|
||||||
|
# REST API (Phase 22) — 빈 문자열이면 인증 스킵 (개발 모드)
|
||||||
|
api_token: str = ""
|
||||||
rag_verbose: bool = False
|
rag_verbose: bool = False
|
||||||
rag_show_sources: bool = False
|
rag_show_sources: bool = False
|
||||||
langgraph_verbose: bool = False
|
langgraph_verbose: bool = False
|
||||||
|
|||||||
+37
-33
@@ -249,40 +249,44 @@ telegram_bot.py
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 22 — REST API (FastAPI) ★★☆
|
## ✅ Phase 22 — REST API (FastAPI) ★★☆
|
||||||
|
|
||||||
**배경**: 다른 Python 스크립트나 원격 서버에서 율봇을 호출하려면 HTTP API가 필요하다.
|
**배경**: 다른 Python 스크립트나 원격 서버에서 율봇을 호출하려면 HTTP API가 필요하다.
|
||||||
Phase 21 Telegram Bot을 원격 서버에서 실행하거나, 다른 앱에 율봇을 임베딩할 때도 활용 가능.
|
Telegram Bot을 별도 프로젝트로 분리해 이 API를 호출하는 구조로 사용 가능.
|
||||||
|
|
||||||
**구현 방식**: 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)
|
|
||||||
```
|
|
||||||
|
|
||||||
**구현 내용**:
|
**구현 내용**:
|
||||||
- `api.py` — FastAPI 앱, `uvicorn api:app --host 0.0.0.0 --port 8000`으로 실행
|
- `api.py` — FastAPI 앱, `uvicorn api:app --host 0.0.0.0 --port 8000`으로 실행
|
||||||
- Bearer Token 인증 (`.env` `API_TOKEN`)
|
- SSE(`text/event-stream`) 스트리밍: 각 라인 `data: <JSON 토큰>\n\n`, 종료 `data: [DONE]\n\n`
|
||||||
- `user_id` 헤더/파라미터로 멀티유저 지원
|
- Bearer Token 인증 (`.env` `API_TOKEN` 설정; 빈 값이면 개발 모드 무인증)
|
||||||
- SSE(`text/event-stream`)로 토큰 단위 스트리밍
|
- `user_id` 파라미터로 멀티유저 지원 (기존 DB·메모리 구조 그대로 재사용)
|
||||||
- Gradio(`app.py`)와 동일한 `Container` 공유 가능
|
|
||||||
|
| 엔드포인트 | 설명 |
|
||||||
|
|-----------|------|
|
||||||
|
| `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개월) 장기
|
단기 (1~2주) 중기 (1개월) 장기
|
||||||
──────────────────────── ────────────────────── ──────────────────
|
──────────────────────── ────────────────────── ──────────────────
|
||||||
Phase 21 Telegram Bot → Phase 22 REST API → Phase 16 (Docker)
|
Phase 21 Telegram Bot → Phase 20 RAGAS 평가 → Phase 16 (Docker)
|
||||||
Phase 20 RAGAS 평가 → Phase 15 (모델선택) → Phase 17 (멀티모달)
|
→ Phase 15 (모델선택) → Phase 17 (멀티모달)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 우선순위 매트릭스
|
### 우선순위 매트릭스
|
||||||
@@ -374,8 +378,8 @@ Phase 20 RAGAS 평가 → Phase 15 (모델선택) → Phase 17 (멀
|
|||||||
| Phase 13-B Reranker | ✅ 완료 | — | — | — |
|
| Phase 13-B Reranker | ✅ 완료 | — | — | — |
|
||||||
| Phase 18 Hybrid Search | ✅ 완료 | — | — | — |
|
| Phase 18 Hybrid Search | ✅ 완료 | — | — | — |
|
||||||
| Phase 19 Query Rewriting | ✅ 완료 | — | — | — |
|
| Phase 19 Query Rewriting | ✅ 완료 | — | — | — |
|
||||||
| Phase 21 Telegram Bot | 🔲 신규 | 중간 | 높음 | ⭐ 1순위 |
|
| Phase 21 Telegram Bot | 🔲 신규 | 중간 | 높음 | ⭐ 1순위 (REST API 활용) |
|
||||||
| Phase 22 REST API | 🔲 신규 | 중간 | 높음 | ⭐ 2순위 |
|
| Phase 22 REST API | ✅ 완료 | — | — | — |
|
||||||
| Phase 20 RAGAS 평가 | 🔲 신규 | 중간 | 중간 | 3순위 |
|
| Phase 20 RAGAS 평가 | 🔲 신규 | 중간 | 중간 | 3순위 |
|
||||||
| Phase 15 모델 선택 | 🔲 미완 | 중간 | 중간 | 4순위 |
|
| Phase 15 모델 선택 | 🔲 미완 | 중간 | 중간 | 4순위 |
|
||||||
| Phase 16 Docker | 🔲 미완 | 높음 | 중간 | 5순위 |
|
| Phase 16 Docker | 🔲 미완 | 높음 | 중간 | 5순위 |
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ qdrant-client>=1.9.0
|
|||||||
pdfplumber>=0.11.0
|
pdfplumber>=0.11.0
|
||||||
# Phase 18 — Hybrid Search (BM25 sparse vectors)
|
# Phase 18 — Hybrid Search (BM25 sparse vectors)
|
||||||
fastembed>=0.3.0
|
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
|
# Phase 3 — Agent orchestration
|
||||||
langgraph>=1.0.0
|
langgraph>=1.0.0
|
||||||
# Phase 4 — Web UI
|
# Phase 4 — Web UI
|
||||||
|
|||||||
Reference in New Issue
Block a user