production-rag / api /server.py
vgsampathkumar's picture
fix: batch ChromaDB upserts to stay under OpenAI 300k token/request limit
557cb66
Raw
History Blame Contribute Delete
24.3 kB
import os
import sys
import json
import time
import asyncio
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional
import jwt as pyjwt
from jwt import PyJWKClient
from collections import Counter
from fastapi import BackgroundTasks, FastAPI, HTTPException, File, UploadFile, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from sse_starlette.sse import EventSourceResponse
from dotenv import load_dotenv
from openai import OpenAI
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from search import HybridSearchIndex
from ingestion import extract_text_from_pdf, adaptive_chunk_text
DATA_DIR = Path(__file__).parent.parent / "data"
CACHE_FILE = Path(__file__).parent.parent / "chroma_store" / "notebook_cache.json"
load_dotenv(Path(__file__).parent.parent / ".env")
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
CROSS_ENCODER_MODEL = os.getenv("CROSS_ENCODER_MODEL", "cross-encoder/ms-marco-TinyBERT-L-2-v2")
DENSE_K = int(os.getenv("DENSE_K", "10"))
SPARSE_K = int(os.getenv("SPARSE_K", "10"))
RERANK_TOP_K = int(os.getenv("RERANK_TOP_K", "5"))
CLERK_JWKS_URL = os.getenv("CLERK_JWKS_URL", "")
_index: Optional[HybridSearchIndex] = None
_openai_client: Optional[OpenAI] = None
_jwks_client: Optional[PyJWKClient] = None
_bg_errors: list = [] # last N background task errors, for /debug/backend
# ---------------------------------------------------------------------------
# Auth
# ---------------------------------------------------------------------------
_security = HTTPBearer(auto_error=False)
def _get_jwks_client() -> PyJWKClient:
global _jwks_client
if _jwks_client is None:
_jwks_client = PyJWKClient(CLERK_JWKS_URL, cache_keys=True)
return _jwks_client
def _verify_token(token: str) -> str:
client = _get_jwks_client()
signing_key = client.get_signing_key_from_jwt(token)
payload = pyjwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
options={"verify_aud": False},
leeway=120, # 2 min leeway — Clerk dev tokens expire in 60s
)
user_id = payload.get("sub", "")
if not user_id:
raise ValueError("Token missing sub claim")
return user_id
async def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_security),
) -> str:
if not CLERK_JWKS_URL:
return "local-dev-user"
if not credentials:
print("[AUTH] FAIL: No Bearer token in request", flush=True)
raise HTTPException(status_code=401, detail="Not authenticated — please sign in")
try:
user_id = _verify_token(credentials.credentials)
return user_id
except Exception as exc:
print(f"[AUTH] FAIL: Token verification error — {type(exc).__name__}: {exc}", flush=True)
raise HTTPException(status_code=401, detail=f"Token invalid: {exc}")
# ---------------------------------------------------------------------------
# App lifecycle
# ---------------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
global _index, _openai_client
print(f"Starting RAG API server (model: {CROSS_ENCODER_MODEL})...")
# Auth diagnostics on startup
if CLERK_JWKS_URL:
print(f"[AUTH] CLERK_JWKS_URL configured: {CLERK_JWKS_URL}", flush=True)
try:
import requests as _req
r = _req.get(CLERK_JWKS_URL, timeout=5)
keys = r.json().get("keys", [])
print(f"[AUTH] JWKS endpoint reachable — {len(keys)} signing key(s) loaded", flush=True)
except Exception as e:
print(f"[AUTH] WARNING: Cannot reach JWKS endpoint: {e}", flush=True)
else:
print("[AUTH] CLERK_JWKS_URL not set — dev bypass active (all requests = 'local-dev-user')", flush=True)
_openai_client = OpenAI(api_key=OPENAI_API_KEY)
_index = HybridSearchIndex(
persist_directory=str(Path(__file__).parent.parent / "chroma_store"),
openai_api_key=OPENAI_API_KEY,
cross_encoder_model=CROSS_ENCODER_MODEL,
rerank_top_k=RERANK_TOP_K,
)
count = _index._collection.count()
if count > 0:
_index.build_bm25_from_collection()
print(f"Warm start complete: {count} chunks ready.")
else:
print("WARNING: No documents indexed. Upload PDFs via the UI.")
yield
print("Shutting down.")
app = FastAPI(title="Production RAG API", version="1.0.0", lifespan=lifespan)
_DEFAULT_ORIGINS = "http://localhost:5173,http://localhost:5174,https://production-rag-beta.vercel.app"
_ALLOWED_ORIGINS = [
o.strip()
for o in os.getenv("ALLOWED_ORIGINS", _DEFAULT_ORIGINS).split(",")
if o.strip()
]
print(f"[CORS] Allowed origins: {_ALLOWED_ORIGINS}", flush=True)
app.add_middleware(
CORSMiddleware,
allow_origins=_ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ---------------------------------------------------------------------------
# Request models
# ---------------------------------------------------------------------------
class QueryRequest(BaseModel):
query: str
dense_k: int = DENSE_K
sparse_k: int = SPARSE_K
enable_llm: bool = True
class SummarizeRequest(BaseModel):
source: str
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _build_context(top_chunks: list) -> str:
return "\n\n".join(
f"[Chunk {i + 1}] Source: {c['source']}, Page: {c['page_num']}\n{c['text']}"
for i, c in enumerate(top_chunks)
)
# ---------------------------------------------------------------------------
# Core endpoints
# ---------------------------------------------------------------------------
@app.get("/health")
def health():
count = _index._collection.count() if _index else 0
return {"status": "ok", "chunks_indexed": count}
@app.get("/debug/auth")
def debug_auth():
"""No-auth diagnostics endpoint — shows JWKS config and connectivity."""
info: dict = {
"clerk_jwks_url_set": bool(CLERK_JWKS_URL),
"dev_bypass_active": not bool(CLERK_JWKS_URL),
}
if CLERK_JWKS_URL:
info["clerk_jwks_url"] = CLERK_JWKS_URL
try:
import requests as _req
r = _req.get(CLERK_JWKS_URL, timeout=5)
keys = r.json().get("keys", [])
info["jwks_reachable"] = True
info["keys_count"] = len(keys)
except Exception as e:
info["jwks_reachable"] = False
info["jwks_error"] = str(e)
return info
@app.get("/debug/backend")
def debug_backend():
"""No-auth full backend health check — shows OpenAI, ChromaDB, and recent BG errors."""
info: dict = {
"openai_api_key_set": bool(OPENAI_API_KEY),
"clerk_jwks_url_set": bool(CLERK_JWKS_URL),
}
# ChromaDB
try:
count = _index._collection.count() if _index else -1
info["chromadb_status"] = "ok"
info["total_chunks_in_db"] = count
except Exception as e:
info["chromadb_status"] = f"error: {e}"
# OpenAI connectivity
if OPENAI_API_KEY:
try:
import requests as _req
r = _req.get(
"https://api.openai.com/v1/models",
headers={"Authorization": f"Bearer {OPENAI_API_KEY}"},
timeout=5,
)
info["openai_status"] = "ok" if r.status_code == 200 else f"http_{r.status_code}"
except Exception as e:
info["openai_status"] = f"error: {e}"
else:
info["openai_status"] = "NOT_CONFIGURED — embeddings will fail"
# Recent background task errors
info["recent_bg_errors"] = list(_bg_errors[-10:])
return info
@app.get("/documents")
def list_documents(user_id: str = Depends(get_current_user)):
if not _index:
return {"documents": [], "total_chunks": 0}
result = _index._collection.get(
where={"user_id": user_id},
include=["metadatas"],
)
counts = Counter(m["source"] for m in result["metadatas"])
return {
"documents": [{"name": k, "chunks": v} for k, v in sorted(counts.items())],
"total_chunks": sum(counts.values()),
}
def _index_file_background(save_path: Path, user_id: str) -> None:
"""Runs OCR + chunking + ChromaDB insert in a background thread."""
print(f"[BG] Starting indexing: {save_path.name}", flush=True)
try:
pages = extract_text_from_pdf(save_path)
if not pages:
print(f"[BG] {save_path.name}: no text found", flush=True)
return
chunks = adaptive_chunk_text(pages)
metadatas = [
{"source": c["source"], "token_count": c["token_count"], "page_num": c["page_num"], "user_id": user_id}
for c in chunks
]
# Batch upserts to stay under OpenAI's 300k-token-per-request embedding limit
batch_size = 150
for i in range(0, len(chunks), batch_size):
batch = chunks[i : i + batch_size]
batch_meta = metadatas[i : i + batch_size]
_index._collection.upsert(
ids=[c["chunk_id"] for c in batch],
documents=[c["text"] for c in batch],
metadatas=batch_meta,
)
print(f"[BG] {save_path.name}: upserted batch {i // batch_size + 1} ({len(batch)} chunks)", flush=True)
_index.build_bm25_from_collection()
print(f"[BG] Done: {save_path.name}{len(chunks)} chunks from {len(pages)} pages", flush=True)
except Exception as exc:
msg = f"{save_path.name}: {type(exc).__name__}: {exc}"
print(f"[BG] Error indexing {msg}", flush=True)
_bg_errors.append(msg)
if len(_bg_errors) > 20:
_bg_errors.pop(0)
@app.post("/upload")
async def upload_documents(
files: list[UploadFile] = File(...),
user_id: str = Depends(get_current_user),
background_tasks: BackgroundTasks = ...,
):
if not _index:
raise HTTPException(status_code=503, detail="Index not initialised")
DATA_DIR.mkdir(parents=True, exist_ok=True)
results = []
for file in files:
name = file.filename or "unknown.pdf"
if not name.lower().endswith(".pdf"):
results.append({"name": name, "status": "skipped", "message": "Only PDF files are supported"})
continue
save_path = DATA_DIR / name
content = await file.read()
save_path.write_bytes(content)
# Return immediately — OCR/indexing runs in background to avoid proxy timeout
background_tasks.add_task(_index_file_background, save_path, user_id)
results.append({
"name": name, "status": "queued",
"message": "Saved — indexing in background (may take 1-2 min for large PDFs)",
"chunks": 0, "pages": 0,
})
return {
"results": results,
"total_chunks": _index._collection.count(),
}
@app.delete("/document/{name:path}")
def delete_document(name: str, user_id: str = Depends(get_current_user)):
if not _index:
raise HTTPException(status_code=503, detail="Index not initialised")
# Fetch IDs first — avoids ChromaDB $and bugs in collection.delete()
result = _index._collection.get(
where={"$and": [{"source": {"$eq": name}}, {"user_id": {"$eq": user_id}}]},
include=[],
)
ids_to_delete = result.get("ids", [])
if ids_to_delete:
_index._collection.delete(ids=ids_to_delete)
_index.build_bm25_from_collection()
cache = _load_cache(user_id)
cache["summaries"].pop(name, None)
_save_cache(user_id, cache)
return {"status": "ok", "deleted": name, "chunks_removed": len(ids_to_delete)}
@app.post("/query")
def query_endpoint(req: QueryRequest, user_id: str = Depends(get_current_user)):
if not _index:
raise HTTPException(status_code=503, detail="Index not initialised")
t0 = time.perf_counter()
candidates = _index.hybrid_search(req.query, dense_k=req.dense_k, sparse_k=req.sparse_k, user_id=user_id)
search_ms = (time.perf_counter() - t0) * 1000
t1 = time.perf_counter()
top_chunks = _index.re_rank(req.query, candidates)
rerank_ms = (time.perf_counter() - t1) * 1000
answer = ""
llm_ms = 0.0
if req.enable_llm and _openai_client and top_chunks:
t2 = time.perf_counter()
response = _openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": (
"You are a document analyst. Answer questions using only the provided context. "
"Cite chunk numbers like (Chunk 1) when referencing information."
),
},
{
"role": "user",
"content": f"Context:\n{_build_context(top_chunks)}\n\nQuestion: {req.query}",
},
],
temperature=0,
)
answer = response.choices[0].message.content or ""
llm_ms = (time.perf_counter() - t2) * 1000
total_ms = (time.perf_counter() - t0) * 1000
return {
"query": req.query,
"top_chunks": top_chunks,
"answer": answer,
"metrics": {
"search_ms": round(search_ms, 1),
"rerank_ms": round(rerank_ms, 1),
"llm_ms": round(llm_ms, 1),
"total_ms": round(total_ms, 1),
},
}
@app.post("/stream")
async def stream_endpoint(req: QueryRequest, user_id: str = Depends(get_current_user)):
if not _index:
raise HTTPException(status_code=503, detail="Index not initialised")
async def event_generator():
loop = asyncio.get_event_loop()
t0 = time.perf_counter()
try:
candidates = await loop.run_in_executor(
None,
lambda: _index.hybrid_search(req.query, dense_k=req.dense_k, sparse_k=req.sparse_k, user_id=user_id),
)
search_ms = (time.perf_counter() - t0) * 1000
t1 = time.perf_counter()
top_chunks = await loop.run_in_executor(
None, lambda: _index.re_rank(req.query, candidates)
)
rerank_ms = (time.perf_counter() - t1) * 1000
for i, chunk in enumerate(top_chunks):
yield {
"event": "chunk",
"data": json.dumps({
"rank": i + 1,
"chunk_id": chunk["chunk_id"],
"text": chunk["text"],
"source": chunk["source"],
"page_num": chunk["page_num"],
"token_count": chunk.get("token_count", 0),
"score": round(chunk.get("score", 0), 4),
"retrieval_type": chunk.get("retrieval_type", "dense"),
"rerank_score": round(chunk.get("rerank_score", 0), 4),
}),
}
llm_ms = 0.0
if req.enable_llm and _openai_client and top_chunks:
t2 = time.perf_counter()
stream = _openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": (
"You are a document analyst. Answer questions using only the provided context. "
"Cite chunk numbers like (Chunk 1) when referencing information."
),
},
{
"role": "user",
"content": f"Context:\n{_build_context(top_chunks)}\n\nQuestion: {req.query}",
},
],
temperature=0,
stream=True,
)
for delta in stream:
content = delta.choices[0].delta.content
if content:
yield {"event": "token", "data": json.dumps({"content": content})}
await asyncio.sleep(0)
llm_ms = (time.perf_counter() - t2) * 1000
total_ms = (time.perf_counter() - t0) * 1000
yield {
"event": "done",
"data": json.dumps({
"metrics": {
"search_ms": round(search_ms, 1),
"rerank_ms": round(rerank_ms, 1),
"llm_ms": round(llm_ms, 1),
"total_ms": round(total_ms, 1),
}
}),
}
except Exception as exc:
yield {"event": "error", "data": json.dumps({"message": str(exc)})}
return EventSourceResponse(event_generator())
# ---------------------------------------------------------------------------
# Per-user notebook cache
# ---------------------------------------------------------------------------
def _load_cache(user_id: str) -> dict:
if CACHE_FILE.exists():
try:
data = json.loads(CACHE_FILE.read_text(encoding="utf-8"))
return data.get(user_id, {"guide": None, "summaries": {}})
except Exception:
pass
return {"guide": None, "summaries": {}}
def _save_cache(user_id: str, user_data: dict) -> None:
CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
all_data: dict = {}
if CACHE_FILE.exists():
try:
all_data = json.loads(CACHE_FILE.read_text(encoding="utf-8"))
except Exception:
pass
all_data[user_id] = user_data
CACHE_FILE.write_text(json.dumps(all_data, indent=2, ensure_ascii=False), encoding="utf-8")
# ---------------------------------------------------------------------------
# Notebook endpoints
# ---------------------------------------------------------------------------
@app.get("/notebook")
def get_notebook(user_id: str = Depends(get_current_user)):
return _load_cache(user_id)
@app.post("/notebook/generate")
async def generate_notebook(user_id: str = Depends(get_current_user)):
if not _index or not _openai_client:
raise HTTPException(status_code=503, detail="Index not initialised")
async def event_generator():
try:
loop = asyncio.get_event_loop()
all_data = await loop.run_in_executor(
None,
lambda: _index._collection.get(
where={"user_id": user_id},
include=["documents", "metadatas"],
),
)
from collections import defaultdict
doc_chunks: dict = defaultdict(list)
for doc, meta in zip(all_data["documents"], all_data["metadatas"]):
src = meta.get("source", "unknown")
if len(doc_chunks[src]) < 2:
doc_chunks[src].append(doc[:600])
if not doc_chunks:
yield {"event": "error", "data": json.dumps({"message": "No documents indexed. Upload PDFs first."})}
return
doc_excerpts = "\n\n".join(
f"=== {src} ===\n" + "\n---\n".join(excerpts)
for src, excerpts in sorted(doc_chunks.items())
)
system_prompt = (
"You are an expert research assistant. Analyse the document excerpts and respond with "
"ONLY a valid JSON object — no markdown fences, no extra text — matching this schema:\n"
'{"overview":"2-3 sentence overview","themes":["theme1","theme2","theme3","theme4"],'
'"doc_onelines":{"filename":"one-line description"},'
'"suggested_questions":["Q1?","Q2?","Q3?","Q4?","Q5?","Q6?"]}'
)
full_text = ""
stream = _openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"Document excerpts:\n\n{doc_excerpts}"},
],
temperature=0.3,
stream=True,
)
for delta in stream:
content = delta.choices[0].delta.content
if content:
full_text += content
yield {"event": "token", "data": json.dumps({"content": content})}
await asyncio.sleep(0)
try:
parsed = json.loads(full_text)
except Exception:
import re
m = re.search(r"\{.*\}", full_text, re.DOTALL)
parsed = json.loads(m.group()) if m else {}
from datetime import datetime
guide = {**parsed, "generated_at": datetime.utcnow().isoformat()}
cache = _load_cache(user_id)
cache["guide"] = guide
_save_cache(user_id, cache)
yield {"event": "done", "data": json.dumps({"guide": guide})}
except Exception as exc:
yield {"event": "error", "data": json.dumps({"message": str(exc)})}
return EventSourceResponse(event_generator())
@app.post("/document/summarize")
async def summarize_document(req: SummarizeRequest, user_id: str = Depends(get_current_user)):
if not _index or not _openai_client:
raise HTTPException(status_code=503, detail="Index not initialised")
async def event_generator():
try:
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
lambda: _index._collection.get(
where={"$and": [{"source": {"$eq": req.source}}, {"user_id": {"$eq": user_id}}]},
include=["documents"],
),
)
docs = result.get("documents") or []
if not docs:
yield {"event": "error", "data": json.dumps({"message": f"No chunks found for {req.source}"})}
return
content = "\n\n---\n\n".join(d[:500] for d in docs[:6])
system_prompt = (
"You are a document analyst. Respond with ONLY a valid JSON object — no markdown, no extra text:\n"
'{"summary":"2-3 sentence summary of the document","topics":["topic1","topic2","topic3","topic4","topic5"]}'
)
full_text = ""
stream = _openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"Document: {req.source}\n\nExcerpts:\n{content}"},
],
temperature=0.2,
stream=True,
)
for delta in stream:
content_tok = delta.choices[0].delta.content
if content_tok:
full_text += content_tok
yield {"event": "token", "data": json.dumps({"content": content_tok})}
await asyncio.sleep(0)
try:
parsed = json.loads(full_text)
except Exception:
import re
m = re.search(r"\{.*\}", full_text, re.DOTALL)
parsed = json.loads(m.group()) if m else {"summary": full_text, "topics": []}
from datetime import datetime
entry = {**parsed, "generated_at": datetime.utcnow().isoformat()}
cache = _load_cache(user_id)
cache["summaries"][req.source] = entry
_save_cache(user_id, cache)
yield {"event": "done", "data": json.dumps({"source": req.source, "entry": entry})}
except Exception as exc:
yield {"event": "error", "data": json.dumps({"message": str(exc)})}
return EventSourceResponse(event_generator())