gsearch-api / main.py
tanmay-bm's picture
docs: update README and main.py to reflect two-tier architecture (v2.0.0)
71cd020
Raw
History Blame Contribute Delete
9.64 kB
"""
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
# ---------------------------------------------------------------------------
@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",
)