""" GCAS Excel Search Engine – FastAPI Application ================================================ Endpoints --------- GET /health – liveness + index status GET /schema – table names, column lists, row counts POST /search – natural-language search → top-N JSON rows POST /reindex – rebuild FAISS index from the Excel folder GET /docs – Swagger UI (auto-generated) Pipeline -------- Tier 1: Structured in-memory lookup (~50–200 ms, confidence=high) QueryPlan is built from the query (college, district, university, program, gender, category, intent, etc.) and matched directly against the data store via O(n) row scan. Tier 2: FAISS semantic fallback (triggered only when Tier 1 returns []) Query is embedded and compared against dense FAISS vectors (~50k rows). LLM reranking: opt-in only (use_llm_rerank=true), not on by default. Startup behaviour ----------------- On first boot the server tries to load a persisted cache from ./index_cache/. If none exists it indexes the Excel files immediately (this takes ~1-3 min for ~50 k rows with the local embedding model). Usage example (curl) -------------------- curl -X POST http://localhost:8000/search \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ -d '{"query": "GLS College ki fees kitni hai", "top_k": 5}' """ from __future__ import annotations import logging import os import time from contextlib import asynccontextmanager import uvicorn from fastapi import Depends, FastAPI, HTTPException, Query, Security from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from config import settings from models import ( HealthResponse, ReindexResponse, SchemaResponse, SearchRequest, SearchResponse, TableSchema, ) import indexer import search_engine # --------------------------------------------------------------------------- # Auth # --------------------------------------------------------------------------- _bearer = HTTPBearer(auto_error=False) def _require_token(credentials: HTTPAuthorizationCredentials = Security(_bearer)): expected = os.getenv("API_SECRET_TOKEN", "") if not expected: return # no token configured → open (dev/local mode) if not credentials or credentials.credentials != expected: raise HTTPException(status_code=401, detail="Invalid or missing token") # --------------------------------------------------------------------------- # Logging # --------------------------------------------------------------------------- logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Lifespan: auto-index on startup # --------------------------------------------------------------------------- @asynccontextmanager async def lifespan(app: FastAPI): logger.info("=" * 60) logger.info(" GCAS Search Engine starting up") logger.info(" Excel folder : %s", settings.excel_folder) logger.info(" Embeddings : %s", settings.embedding_provider) logger.info(" LLM provider : %s", settings.llm_provider) logger.info("=" * 60) # Try cache first; fall back to fresh indexing if not indexer.load_cache(): logger.info("Building index from Excel files (first boot)…") try: stats = indexer.load_and_index(settings.excel_folder) logger.info("Index ready: %s", stats) except Exception: logger.exception( "Indexing failed on startup. " "The server is running but /search will return 503 until " "POST /reindex succeeds." ) else: logger.info("Index loaded from cache ✓") yield # ---------- server is running ---------- logger.info("GCAS Search Engine shutting down.") # --------------------------------------------------------------------------- # App # --------------------------------------------------------------------------- app = FastAPI( title="GCAS Excel Search Engine", description=( "Natural-language search API over Gujarat College Admissions System (GCAS) Excel data. " "Two-tier pipeline: structured in-memory lookup (primary, ~50–200ms) + " "FAISS semantic fallback. Supports English, Hindi (Hinglish), and Gujarati queries. " "LLM reranking is opt-in only." ), version="2.0.0", lifespan=lifespan, docs_url="/docs", redoc_url="/redoc", ) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- @app.get( "/health", response_model=HealthResponse, summary="Health check & index status", tags=["System"], ) def health() -> HealthResponse: """Returns server health and index readiness.""" return HealthResponse( status="ready" if indexer.is_ready() else "not_indexed", indexed_tables=indexer.get_indexed_tables(), total_indexed_rows=indexer.get_total_rows(), embedding_provider=settings.embedding_provider, llm_provider=settings.llm_provider, ) @app.get( "/schema", response_model=SchemaResponse, summary="Indexed table schemas", tags=["System"], ) def schema() -> SchemaResponse: """Returns column names and row counts for every indexed table.""" if not indexer.is_ready(): raise HTTPException(status_code=503, detail="Index not ready. POST /reindex first.") raw = indexer.get_schema() tables = { name: TableSchema( columns=info["columns"], row_count=info["row_count"], file=info["file"], ) for name, info in raw.items() } return SchemaResponse(tables=tables) @app.post( "/search", response_model=SearchResponse, summary="Natural-language search", tags=["Search"], dependencies=[Depends(_require_token)], ) def search(request: SearchRequest) -> SearchResponse: """ **Main endpoint** – accepts a natural-language query and returns the most relevant rows from the indexed Excel tables. ### Request body | Field | Type | Default | Description | |---|---|---|---| | `query` | string | *required* | Natural language query | | `top_k` | int | 10 | Max results to return (1–100) | | `tables` | list[str] | null | Restrict to specific table names | | `use_llm_rerank` | bool | false | Opt-in LLM reranking (adds 3–10s latency) | | `llm_provider` | string | *(server default)* | `"openai"` or `"anthropic"` | | `llm_model` | string | *(server default)* | Model name | | `api_key` | string | *(server default)* | API key override | ### Example queries - `"engineering colleges in Surat with girls hostel"` - `"NAAC A grade colleges under GTU"` - `"B.Com program fees less than 20000 in Ahmedabad"` - `"cutoff for SC category in computer science Ahmedabad colleges"` """ if not indexer.is_ready(): raise HTTPException( status_code=503, detail="Search index is not ready. POST /reindex to build it.", ) try: return search_engine.search(request) except Exception as exc: logger.exception("Search error for query: %s", request.query) raise HTTPException(status_code=500, detail=f"Search failed: {exc}") from exc @app.post( "/reindex", response_model=ReindexResponse, summary="Rebuild search index", tags=["System"], dependencies=[Depends(_require_token)], ) def reindex( excel_folder: str = Query( default=None, description=( "Path to the folder containing .xlsx files. " "Defaults to the server-configured excel_folder." ), ) ) -> ReindexResponse: """ Scans the Excel folder, re-embeds all rows, and rebuilds the FAISS index. The new index is saved to disk and replaces the in-memory index atomically. Use this endpoint when Excel files are added, updated, or removed. """ t0 = time.perf_counter() try: folder = excel_folder or settings.excel_folder stats = indexer.load_and_index(folder) elapsed_ms = (time.perf_counter() - t0) * 1000 return ReindexResponse( status="success", tables_indexed=list(stats.keys()), total_rows_indexed=sum(stats.values()), time_taken_ms=round(elapsed_ms, 2), ) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except Exception as exc: logger.exception("Reindex failed") raise HTTPException(status_code=500, detail=f"Reindex failed: {exc}") from exc # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- if __name__ == "__main__": uvicorn.run( "main:app", host=settings.api_host, port=settings.api_port, reload=False, workers=1, # >1 workers would each hold their own FAISS index in RAM log_level="info", )