gsearch-api / main.py
tanmayapna's picture
add bearer token auth to /search and /reindex
2aa1884
Raw
History Blame
9.12 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)
Startup behaviour
-----------------
On first boot the server tries to load a persisted FAISS 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 'Content-Type: application/json' \
-d '{
"query": "engineering colleges in Ahmedabad with hostel and NAAC A grade",
"top_k": 5,
"use_llm_rerank": true,
"llm_provider": "openai",
"api_key": "sk-..."
}'
"""
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. Hybrid pipeline: dense retrieval (FAISS) + LLM reranking."
),
version="1.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 | true | Enable LLM reranking pass |
| `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",
)