AI_Menu_Search / api /main.py
Juhaha
HF Spaces 데모 배포 (Streamlit + Qdrant 임베디드, 색인 빌드타임 생성)
fbd1091
Raw
History Blame Contribute Delete
12 kB
"""
영웅문 S# AI 메뉴 검색 — REST API 서버
FastAPI + Swagger UI
실행:
.venv/Scripts/uvicorn.exe api.main:app --host 0.0.0.0 --port 8000
또는
.venv/Scripts/python.exe -m uvicorn api.main:app --host 0.0.0.0 --port 8000
Swagger UI: http://localhost:8000/docs
ReDoc: http://localhost:8000/redoc
"""
import sys
import json
import logging
from datetime import datetime
from pathlib import Path
from typing import Optional
sys.path.insert(0, str(Path(__file__).parent.parent))
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
# ── 로거 설정 ─────────────────────────────────────────────────────────────────
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)
# ── 로그 파일 경로 ─────────────────────────────────────────────────────────────
LOG_DIR = Path(__file__).parent.parent / "logs"
LOG_DIR.mkdir(exist_ok=True)
QUERY_LOG_PATH = LOG_DIR / "api_queries.jsonl"
# ── FastAPI 앱 ────────────────────────────────────────────────────────────────
app = FastAPI(
title="영웅문 S# AI 메뉴 검색 API",
description="""
자연어로 영웅문 S# 앱 메뉴를 검색합니다.
## 주요 기능
- 구어체 자연어 질문 → 관련 메뉴 Top-N 추천
- HyDE 기반 어휘 갭 해소
- 품질 미달 시 자동 쿼리 재작성 (최대 2회)
- 카테고리 자동 감지 및 필터
## 사용 예시
```
POST /api/search
{ "query": "내 주식 얼마야" }
```
""",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc",
)
# CORS (내부망 웹 클라이언트 허용)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# ── 카테고리 목록 ─────────────────────────────────────────────────────────────
CATEGORIES = [
"국내주식", "해외주식", "파생상품",
"자산/뱅킹", "금융상품", "주식더모으기",
"랭킹/영웅전", "대출/카드/보험", "환전", "생활/혜택",
]
# ── Pydantic 모델 ─────────────────────────────────────────────────────────────
class SearchRequest(BaseModel):
query: str = Field(
...,
description="자연어 검색어",
examples=["내 주식 얼마야", "주문 취소하고 싶어", "달러 환전하고 싶어"],
)
top_n: int = Field(5, ge=1, le=20, description="반환할 결과 수 (기본 5, 최대 20)")
category_filter: Optional[str] = Field(
None,
description=f"카테고리 필터. 전체 검색 시 null. 가능한 값: {CATEGORIES}",
examples=["국내주식", "해외주식"],
)
use_reranker: bool = Field(False, description="Cross-Encoder 리랭킹 활성화 (기본 false)")
use_hyde: bool = Field(True, description="HyDE 활성화 — 어휘 갭 해소 (권장, 기본 true)")
class MenuResult(BaseModel):
rank: int = Field(..., description="검색 순위")
menu_id: str = Field(..., description="메뉴 ID (예: SCR_3032)")
menu_name: str = Field(..., description="메뉴명 (예: 국내잔고)")
menu_path: str = Field(..., description="메뉴 경로 (예: 국내주식 > 계좌 > 국내잔고)")
category: Optional[str] = Field(None, description="카테고리")
similarity: float = Field(..., description="유사도 점수 (0.0 ~ 1.0)")
similarity_pct: str = Field(..., description="유사도 퍼센트 (예: 87.3%)")
class SearchResponse(BaseModel):
query: str = Field(..., description="원본 검색어")
refined_query: str = Field(..., description="최종 검색 쿼리 (재작성된 경우 변경됨)")
detected_category: Optional[str] = Field(None, description="자동 감지된 카테고리")
intent: str = Field(..., description="감지된 의도 (주문/잔고조회/시세/차트/기타)")
results: list[MenuResult] = Field(..., description="검색 결과 목록")
result_count: int = Field(..., description="반환된 결과 수")
quality_score: float = Field(..., description="검색 품질 점수 (0.0 ~ 1.0)")
is_satisfactory: bool = Field(..., description="품질 만족 여부 (0.55 이상 시 true)")
retry_count: int = Field(..., description="쿼리 재작성 횟수 (0 ~ 2)")
rewrite_history: list[str] = Field(default_factory=list, description="쿼리 재작성 이력")
elapsed_ms: int = Field(..., description="처리 시간 (밀리초)")
class HealthResponse(BaseModel):
status: str = Field(..., description="서버 상태 (ok / error)")
menu_count: int = Field(..., description="인덱싱된 메뉴 수")
version: str = Field(..., description="API 버전")
timestamp: str = Field(..., description="현재 서버 시각")
class CategoriesResponse(BaseModel):
categories: list[str] = Field(..., description="사용 가능한 카테고리 목록")
# ── 헬퍼: 로깅 ───────────────────────────────────────────────────────────────
def _log_query(req: SearchRequest, resp: SearchResponse) -> None:
"""파인튜닝 데이터 수집용 쿼리 로그 기록"""
entry = {
"timestamp": datetime.now().isoformat(),
"query": req.query,
"refined_query": resp.refined_query,
"category_filter": req.category_filter,
"detected_category": resp.detected_category,
"results": [
{
"rank": r.rank,
"menu_id": r.menu_id,
"menu_name": r.menu_name,
"similarity": r.similarity,
}
for r in resp.results
],
"quality_score": resp.quality_score,
"retry_count": resp.retry_count,
"elapsed_ms": resp.elapsed_ms,
}
with open(QUERY_LOG_PATH, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
# ── 엔진 초기화 ───────────────────────────────────────────────────────────────
_engine = None
def _get_engine():
global _engine
if _engine is None:
from core.search_engine import MenuSearchEngine
_engine = MenuSearchEngine.get_instance()
return _engine
@app.on_event("startup")
async def startup_event():
"""서버 시작 시 검색 엔진 미리 로드"""
logger.info("검색 엔진 초기화 중...")
_get_engine()
logger.info("검색 엔진 초기화 완료")
# ── 엔드포인트 ────────────────────────────────────────────────────────────────
@app.post(
"/api/search",
response_model=SearchResponse,
summary="메뉴 검색",
description="자연어 쿼리로 영웅문 S# 메뉴를 검색합니다. LangGraph 에이전트가 품질 미달 시 자동 재시도합니다.",
tags=["검색"],
)
async def search_menu(req: SearchRequest):
from core.agent import run_agent
start = datetime.now()
# 카테고리 필터 검증
if req.category_filter and req.category_filter not in CATEGORIES:
raise HTTPException(
status_code=400,
detail=f"유효하지 않은 카테고리: '{req.category_filter}'. 가능한 값: {CATEGORIES}",
)
try:
result = run_agent(
query=req.query,
top_n=req.top_n,
threshold=0.3,
category_filter=req.category_filter,
use_reranker=req.use_reranker,
use_hyde=req.use_hyde,
)
except Exception as e:
logger.error(f"검색 오류: {e}")
raise HTTPException(status_code=500, detail=f"검색 중 오류 발생: {str(e)}")
elapsed_ms = int((datetime.now() - start).total_seconds() * 1000)
# 결과 변환
menu_results = []
for i, r in enumerate(result.get("search_results", []), 1):
menu_results.append(MenuResult(
rank=i,
menu_id=r.get("menu_id", ""),
menu_name=r.get("menu_name", r.get("menu_id", "")),
menu_path=r.get("menu_path", ""),
category=r.get("category"),
similarity=round(r.get("similarity", 0.0), 4),
similarity_pct=r.get("similarity_pct", f"{r.get('similarity', 0):.1%}"),
))
response = SearchResponse(
query=req.query,
refined_query=result.get("refined_query", req.query),
detected_category=result.get("detected_category"),
intent=result.get("intent", "기타"),
results=menu_results,
result_count=len(menu_results),
quality_score=round(result.get("quality_score", 0.0), 4),
is_satisfactory=result.get("is_satisfactory", False),
retry_count=result.get("retry_count", 0),
rewrite_history=result.get("rewrite_history", []),
elapsed_ms=elapsed_ms,
)
# 로그 기록
try:
_log_query(req, response)
except Exception as e:
logger.warning(f"로그 기록 실패: {e}")
logger.info(
f"검색 완료 | query='{req.query[:30]}' | "
f"결과={len(menu_results)}개 | 품질={response.quality_score:.2f} | "
f"재시도={response.retry_count} | {elapsed_ms}ms"
)
return response
@app.get(
"/api/health",
response_model=HealthResponse,
summary="헬스체크",
description="서버 상태 및 기본 정보를 확인합니다.",
tags=["시스템"],
)
async def health():
try:
engine = _get_engine()
stats = engine.vectorstore.get_stats()
return HealthResponse(
status="ok",
menu_count=stats.get("total_menus", 0),
version="1.0.0",
timestamp=datetime.now().isoformat(),
)
except Exception as e:
raise HTTPException(status_code=503, detail=f"서버 오류: {str(e)}")
@app.get(
"/api/categories",
response_model=CategoriesResponse,
summary="카테고리 목록",
description="검색 필터로 사용 가능한 카테고리 목록을 반환합니다.",
tags=["메타"],
)
async def get_categories():
return CategoriesResponse(categories=CATEGORIES)
@app.get(
"/api/stats",
summary="검색 통계",
description="누적 검색 쿼리 수 등 운영 통계를 반환합니다.",
tags=["시스템"],
)
async def get_stats():
total_queries = 0
if QUERY_LOG_PATH.exists():
with open(QUERY_LOG_PATH, "r", encoding="utf-8") as f:
total_queries = sum(1 for _ in f)
return {
"total_queries": total_queries,
"log_path": str(QUERY_LOG_PATH),
"timestamp": datetime.now().isoformat(),
}
# ── 직접 실행 ─────────────────────────────────────────────────────────────────
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"api.main:app",
host="0.0.0.0",
port=8000,
reload=False,
log_level="info",
)