CoolFace
Apppublic

Kelvin-programmer/rag-chatbot

sourceHugging Faceupdated 5mo agoView on Hugging Face
0likes
main.py196 linesDownload Raw Back to src
1"""FastAPI application for the RAG Chatbot API."""2 3import logging4import os5import tempfile6import time7from collections import defaultdict8from contextlib import asynccontextmanager9 10from fastapi import FastAPI, File, HTTPException, Request, UploadFile11from fastapi.middleware.cors import CORSMiddleware12from fastapi.responses import FileResponse13from fastapi.staticfiles import StaticFiles14from starlette.middleware.base import BaseHTTPMiddleware15from starlette.responses import JSONResponse16 17from .config import Settings18from .rag_engine import RAGEngine19from .schemas import (20    DocumentsResponse,21    HealthResponse,22    IngestResponse,23    QueryRequest,24    QueryResponse,25)26 27settings = Settings()28 29logging.basicConfig(30    level=logging.DEBUG if settings.debug else logging.INFO,31    format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",32)33logger = logging.getLogger(__name__)34 35 36# ---------------------------------------------------------------------------37# Rate-limiting middleware38# ---------------------------------------------------------------------------39 40class RateLimitMiddleware(BaseHTTPMiddleware):41    """Simple sliding-window rate limiter keyed by client IP."""42 43    def __init__(self, app, max_requests: int = 10, window_seconds: int = 60):44        super().__init__(app)45        self.max_requests = max_requests46        self.window_seconds = window_seconds47        self._hits: dict[str, list[float]] = defaultdict(list)48 49    async def dispatch(self, request: Request, call_next):50        client_ip = request.client.host if request.client else "unknown"51        now = time.time()52 53        # Prune timestamps outside the current window54        self._hits[client_ip] = [55            t for t in self._hits[client_ip] if now - t < self.window_seconds56        ]57 58        if len(self._hits[client_ip]) >= self.max_requests:59            return JSONResponse(60                status_code=429,61                content={"detail": "Rate limit exceeded. Try again later."},62            )63 64        self._hits[client_ip].append(now)65        return await call_next(request)66 67 68# ---------------------------------------------------------------------------69# Application lifecycle70# ---------------------------------------------------------------------------71 72engine: RAGEngine | None = None73 74 75@asynccontextmanager76async def lifespan(app: FastAPI):77    global engine78    logger.info("Starting %s ...", settings.app_name)79    engine = RAGEngine(settings)80    engine.vector_store.load()81    logger.info("Ready — %d documents loaded", engine.vector_store.count)82    yield83    logger.info("Shutting down")84 85 86# ---------------------------------------------------------------------------87# FastAPI app88# ---------------------------------------------------------------------------89 90app = FastAPI(91    title=settings.app_name,92    description="Production-grade Retrieval-Augmented Generation chatbot API",93    version="1.0.0",94    lifespan=lifespan,95)96 97# Serve the frontend98_static_dir = os.path.join(os.path.dirname(__file__), "static")99app.mount("/static", StaticFiles(directory=_static_dir), name="static")100 101 102@app.get("/", include_in_schema=False)103async def root():104    return FileResponse(os.path.join(_static_dir, "index.html"))105 106app.add_middleware(107    CORSMiddleware,108    allow_origins=settings.cors_origins,109    allow_credentials=True,110    allow_methods=["*"],111    allow_headers=["*"],112)113app.add_middleware(114    RateLimitMiddleware,115    max_requests=settings.rate_limit_requests,116    window_seconds=settings.rate_limit_window,117)118 119 120# ---------------------------------------------------------------------------121# Routes122# ---------------------------------------------------------------------------123 124 125@app.get("/api/v1/health", response_model=HealthResponse, tags=["System"])126async def health_check():127    """Return service health and basic stats."""128    return HealthResponse(129        status="healthy",130        version="1.0.0",131        documents_loaded=engine.vector_store.count if engine else 0,132    )133 134 135@app.post("/api/v1/query", response_model=QueryResponse, tags=["Chat"])136async def query_documents(body: QueryRequest):137    """Ask a question about the loaded documents."""138    if not engine or engine.vector_store.count == 0:139        raise HTTPException(140            status_code=400, detail="No documents loaded. Upload a PDF first."141        )142 143    result = engine.query(body.question)144    return result145 146 147@app.post(148    "/api/v1/documents/upload", response_model=IngestResponse, tags=["Documents"]149)150async def upload_document(file: UploadFile = File(...)):151    """Upload a PDF and ingest it into the vector store."""152    if not file.filename or not file.filename.lower().endswith(".pdf"):153        raise HTTPException(status_code=400, detail="Only PDF files are supported.")154 155    size = 0156    tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".pdf")157    try:158        while chunk := await file.read(8192):159            size += len(chunk)160            if size > settings.max_upload_size_mb * 1024 * 1024:161                tmp.close()162                os.unlink(tmp.name)163                raise HTTPException(164                    status_code=413,165                    detail=f"File exceeds {settings.max_upload_size_mb} MB limit.",166                )167            tmp.write(chunk)168        tmp.close()169 170        count = engine.ingest_pdf(tmp.name)171        return IngestResponse(172            message=f"Processed {file.filename}",173            chunks_added=count,174            total_documents=engine.vector_store.count,175        )176    finally:177        if os.path.exists(tmp.name):178            os.unlink(tmp.name)179 180 181@app.get("/api/v1/documents", response_model=DocumentsResponse, tags=["Documents"])182async def get_document_stats():183    """Return the number of indexed document chunks."""184    return DocumentsResponse(185        total_documents=engine.vector_store.count if engine else 0,186        persist_dir=settings.vector_store_path,187    )188 189 190@app.delete("/api/v1/documents", tags=["Documents"])191async def clear_documents():192    """Remove all documents from the vector store."""193    if engine:194        engine.vector_store.clear()195    return {"message": "All documents cleared."}196