""" 영웅문 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 hashlib import logging import os 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 from config import SIMILARITY_THRESHOLD from core.dataset_manifest import resolve_app_version # ── 로거 설정 ───────────────────────────────────────────────────────────────── 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 기반 어휘 갭 해소 - 선택적으로 품질 미달 쿼리 재작성 (기본 비활성) - 카테고리 자동 감지 및 필터 ## 사용 예시 ``` POST /api/search { "query": "내 주식 얼마야" } ``` """, version="1.0.0", docs_url="/docs", redoc_url="/redoc", ) # CORS. 내부망 배포에서는 API_CORS_ALLOW_ORIGINS에 허용 origin을 쉼표로 지정한다. _cors_origins = [ value.strip() for value in os.getenv("API_CORS_ALLOW_ORIGINS", "*").split(",") if value.strip() ] app.add_middleware( CORSMiddleware, allow_origins=_cors_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)") allow_query_rewrite: bool = Field( False, description="품질 미달 시 LLM 쿼리 재작성 허용 (실험용, 기본 false)", ) app_version: Optional[str] = Field( None, description="사용자 앱 버전. 생략 시 현재 운영 데이터 버전", examples=["1.5.5"], ) 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="검색 순위 점수 퍼센트") confidence: float = Field(..., description="Dense/BM25/Q↔Q 검색 근거 신뢰도") 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="처리 시간 (밀리초)") app_version: str = Field(..., description="검색에 사용된 앱 버전") dataset_version: str = Field(..., description="메뉴 데이터셋 버전") index_version: str = Field(..., description="검색 인덱스 버전") applicable: bool = 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: """검색 품질 로그. 사용자 원문은 명시적으로 허용한 경우에만 저장한다.""" if os.getenv("API_QUERY_LOG_ENABLED", "1") not in ("1", "true", "True"): return entry = { "timestamp": datetime.now().isoformat(), "query_hash": hashlib.sha256(req.query.encode("utf-8")).hexdigest(), "category_filter": req.category_filter, "detected_category": resp.detected_category, "app_version": resp.app_version, "dataset_version": resp.dataset_version, "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, } if os.getenv("API_LOG_QUERY_TEXT", "0") in ("1", "true", "True"): entry["query"] = req.query entry["refined_query"] = resp.refined_query with open(QUERY_LOG_PATH, "a", encoding="utf-8") as f: f.write(json.dumps(entry, ensure_ascii=False) + "\n") # ── 엔진 초기화 ─────────────────────────────────────────────────────────────── _engines: dict[str, object] = {} def _get_engine(app_version: str): if app_version not in _engines: from core.search_engine import MenuSearchEngine _engines[app_version] = MenuSearchEngine.get_instance(app_version) return _engines[app_version] @app.on_event("startup") async def startup_event(): """서버 시작 시 검색 엔진 미리 로드""" logger.info("검색 엔진 초기화 중...") app_version, _ = resolve_app_version(None) _get_engine(app_version) logger.info("검색 엔진 초기화 완료") # ── 엔드포인트 ──────────────────────────────────────────────────────────────── @app.post( "/api/search", response_model=SearchResponse, summary="메뉴 검색", description="자연어 쿼리로 영웅문 S# 메뉴를 검색합니다.", 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: selected_app_version, manifest = resolve_app_version(req.app_version) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) try: result = run_agent( query=req.query, top_n=req.top_n, threshold=SIMILARITY_THRESHOLD, category_filter=req.category_filter, use_reranker=req.use_reranker, use_hyde=req.use_hyde, allow_rewrite=req.allow_query_rewrite, app_version=selected_app_version, ) 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%}"), confidence=round(r.get("confidence", 0.0), 4), )) 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, app_version=selected_app_version, dataset_version=str(manifest.get("dataset_version", "")), index_version=str(manifest.get("index_version", "")), applicable=bool(menu_results), ) # 로그 기록 try: _log_query(req, response) except Exception as e: logger.warning(f"로그 기록 실패: {e}") logger.info( f"검색 완료 | query_hash={hashlib.sha256(req.query.encode('utf-8')).hexdigest()[:12]} | " 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(app_version: Optional[str] = None): try: selected_app_version, manifest = resolve_app_version(app_version) engine = _get_engine(selected_app_version) stats = engine.vectorstore.get_stats() return HealthResponse( status="ok", menu_count=stats.get("total_menus", 0), version=str(manifest.get("index_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", )