Spaces:
Sleeping
Sleeping
| """ | |
| 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 <token>' \ | |
| -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 | |
| # --------------------------------------------------------------------------- | |
| 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 | |
| # --------------------------------------------------------------------------- | |
| 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, | |
| ) | |
| 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) | |
| 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 | |
| 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", | |
| ) | |