contra / app.py
jonroby
no cache
25d011a
"""
Contra — FastAPI backend serving the React frontend.
Stage 1: minimal shell. Health endpoint + static-file mount for the built
React app. Pipeline endpoints (`/api/query`) come in Stage 2.
Run locally:
# build the frontend once
cd frontend && npm install && npm run build && cd ..
# run the backend
uv run uvicorn app:app --host 0.0.0.0 --port 8000
For development with hot-reload, run the Vite dev server (`npm run dev`) on
port 5173 — it proxies /api to localhost:8000.
Deploy: HuggingFace Docker Space. The Dockerfile builds the frontend and
runs uvicorn.
"""
import os
import time
from pathlib import Path
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from starlette.types import Scope
load_dotenv()
from contra.db import resolve_url
from contra.pipeline import run_query
from contra.retrieval import Retriever
DEFAULT_TARGET = os.getenv("CONTRA_TARGET", "railway")
EXAMPLES = [
"Does lithium slow cognitive decline in Alzheimer's?",
"Do anti-amyloid antibodies improve clinical outcomes in Alzheimer's?",
"Is the Mediterranean diet protective against Alzheimer's?",
"Do statins reduce Alzheimer's risk?",
"Is there a causal link between herpes simplex virus and Alzheimer's?",
]
print(f"[startup] Loading Retriever (target={DEFAULT_TARGET})...")
_t0 = time.time()
RETRIEVER = Retriever(resolve_url(DEFAULT_TARGET))
print(
f"[startup] Retriever ready: {len(RETRIEVER.papers):,} papers in "
f"{time.time() - _t0:.1f}s"
)
app = FastAPI(title="Contra")
class QueryRequest(BaseModel):
question: str = Field(..., min_length=1, max_length=500)
@app.get("/api/health")
def health() -> dict:
return {"status": "ok", "papers": len(RETRIEVER.papers)}
@app.get("/api/examples")
def examples() -> dict:
return {"examples": EXAMPLES}
@app.post("/api/query")
def query(req: QueryRequest) -> dict:
question = req.question.strip()
if not question:
raise HTTPException(status_code=400, detail="Question is required.")
try:
result = run_query(question, target=DEFAULT_TARGET, retriever=RETRIEVER)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return result.as_dict()
# Mount the built React app last so /api/* routes take precedence.
#
# Cache strategy:
# - Hashed assets under /assets/* are content-addressed (Vite puts a hash in
# the filename), so they can be cached forever.
# - index.html is the bootstrap that points at the hashed assets. It must
# never be cached, or browsers will keep loading old JS/CSS bundles after
# a deploy.
FRONTEND_DIST = Path(__file__).parent / "frontend" / "dist"
class CachedStatic(StaticFiles):
async def get_response(self, path: str, scope: Scope) -> Response:
response = await super().get_response(path, scope)
if path.endswith(".html") or path in ("", "/"):
response.headers["Cache-Control"] = "no-cache"
else:
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
if FRONTEND_DIST.exists():
app.mount("/", CachedStatic(directory=FRONTEND_DIST, html=True), name="frontend")
else:
print(
f"[startup] WARNING: {FRONTEND_DIST} not found. "
"Run `cd frontend && npm run build` to build the React app."
)