# -*- coding: utf-8 -*- """ API demo NamBoQA — chạy trên Hugging Face Spaces (Docker, cổng 7860). GET /health trạng thái từng thành phần (BM25, bi-encoder, reader...) GET /stats thống kê bộ dữ liệu + mô hình đang dùng GET /samples câu hỏi mẫu (mặc định lấy từ tập test) POST /ask hỏi: truy hồi top-k rồi đọc hiểu Chạy local: pip install -r requirements.txt uvicorn app:app --reload --port 7860 """ from __future__ import annotations import logging import os import threading import time from collections import OrderedDict, defaultdict, deque from typing import Literal, Optional from fastapi import Depends, FastAPI, Header, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field from engine import Engine logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%H:%M:%S") log = logging.getLogger("namboqa") engine = Engine(os.environ.get("DATA_DIR", "data")) app = FastAPI(title="NamBoQA demo API", version="1.0", description="Hỏi–đáp du lịch và di sản văn hoá Nam Bộ (RAG hai giai đoạn)") origins = [o.strip() for o in os.environ.get("ALLOWED_ORIGINS", "*").split(",") if o.strip()] app.add_middleware(CORSMiddleware, allow_origins=origins, allow_methods=["GET", "POST"], allow_headers=["*"]) @app.on_event("startup") def _startup(): engine.load_fast() # dữ liệu + BM25: vài giây threading.Thread(target=engine.load_heavy, daemon=True).start() # mô hình: chạy nền # ------------------------------------------------------------------ bảo vệ API_KEY = os.environ.get("DEMO_API_KEY") # đặt thì mọi request phải gửi X-API-Key RATE = int(os.environ.get("RATE_LIMIT_PER_MIN", "20")) _hits: dict[str, deque] = defaultdict(deque) def guard(request: Request, x_api_key: Optional[str] = Header(default=None)): if API_KEY and x_api_key != API_KEY: raise HTTPException(401, "Sai hoặc thiếu X-API-Key") # proxy trên Vercel chuyển IP người dùng qua x-client-ip; gọi thẳng thì dùng x-forwarded-for ip = (request.headers.get("x-client-ip") or request.headers.get("x-forwarded-for") or (request.client.host if request.client else "?")) ip = ip.split(",")[0].strip() now, q = time.time(), _hits[ip] while q and now - q[0] > 60: q.popleft() if len(q) >= RATE: raise HTTPException(429, f"Quá {RATE} câu hỏi/phút, thử lại sau ít giây") q.append(now) # ------------------------------------------------------------------ cache _cache: OrderedDict = OrderedDict() CACHE_SIZE = 256 class AskIn(BaseModel): question: str = Field(..., min_length=3, max_length=500) retriever: Literal["bm25", "dense", "hybrid"] = "hybrid" reader: Literal["extractive", "generative", "none"] = "generative" top_k: int = Field(5, ge=1, le=10) alpha: Optional[float] = Field(None, ge=0.0, le=1.0) @app.get("/") def root(): return {"name": "NamBoQA demo API", "docs": "/docs", "health": "/health"} @app.get("/health") def health(): return {"status": engine.status, "errors": engine.errors, "ready": engine.status["bm25"] == "ready"} @app.get("/stats") def stats(): return engine.stats() @app.get("/samples") def samples(n: int = 6, type: Optional[str] = None, split: str = "test", seed: Optional[int] = None): return engine.samples(min(n, 30), type, split, seed) @app.post("/ask", dependencies=[Depends(guard)]) def ask(body: AskIn): key = (body.question.strip(), body.retriever, body.reader, body.top_k, body.alpha, engine.status["dense"], engine.status["extractive"]) if key in _cache: _cache.move_to_end(key) return {**_cache[key], "cached": True} try: res = engine.ask(body.question.strip(), body.retriever, body.reader, body.top_k, body.alpha) except RuntimeError as e: raise HTTPException(503, str(e)) except Exception as e: log.exception("lỗi khi trả lời") raise HTTPException(500, f"Lỗi back-end: {str(e)[:200]}") _cache[key] = res if len(_cache) > CACHE_SIZE: _cache.popitem(last=False) return res