"""FastAPI web app for Antern Bot: serves a chat UI and a /api/chat endpoint.""" from __future__ import annotations import time import uuid from contextlib import asynccontextmanager from fastapi import FastAPI, Header from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from pathlib import Path import config import monitoring from agent import AnternBot STATIC_DIR = Path(__file__).parent / "static" # In-memory conversation store: session_id -> list of API message dicts. # Fine for a single-process app; swap for Redis/DB if you scale out. SESSIONS: dict[str, list[dict]] = {} MAX_HISTORY_MESSAGES = 24 # cap stored turns to bound token growth bot: AnternBot | None = None INIT_ERROR: str | None = None @asynccontextmanager async def lifespan(app: FastAPI): global bot, INIT_ERROR try: bot = AnternBot() # introspects schema + checks API key at startup monitoring.log_event("startup", status="ready", model=bot.model) except Exception as exc: # missing API key, DB unreachable, etc. INIT_ERROR = str(exc) monitoring.log_event("startup", status="failed", error=str(exc)) yield app = FastAPI(title="Antern Bot", lifespan=lifespan) # Allow the widget to call the API from other web pages (configurable origins). app.add_middleware( CORSMiddleware, allow_origins=config.ALLOWED_ORIGINS, allow_methods=["GET", "POST"], allow_headers=["*"], ) class ChatRequest(BaseModel): message: str session_id: str | None = None class ResetRequest(BaseModel): session_id: str @app.get("/") def index() -> FileResponse: return FileResponse(STATIC_DIR / "index.html") @app.get("/metrics") def metrics_dashboard() -> FileResponse: """Human-friendly metrics dashboard (fetches /api/metrics with ?token=...).""" return FileResponse(STATIC_DIR / "metrics.html") @app.get("/api/health") def health() -> JSONResponse: return JSONResponse( {"ready": bot is not None, "error": INIT_ERROR, "model": getattr(bot, "model", None)} ) @app.post("/api/chat") def chat(req: ChatRequest) -> JSONResponse: request_id = uuid.uuid4().hex received = time.time() if bot is None: monitoring.log_event("api_failure", request_id=request_id, reason="bot_not_initialised", detail=INIT_ERROR) return JSONResponse( {"error": f"Bot not initialised: {INIT_ERROR}"}, status_code=503 ) if not req.message.strip(): return JSONResponse({"error": "Empty message."}, status_code=400) session_id = req.session_id or uuid.uuid4().hex history = SESSIONS.get(session_id, []) try: result = bot.chat(history, req.message) except Exception as exc: err = f"{type(exc).__name__}: {exc}" monitoring.log_request( request_id=request_id, session_id=session_id, question=req.message, answer="", latency_ms=(time.time() - received) * 1000, model=getattr(bot, "model", None), llm_calls=0, tokens=None, queries=[], presentation=None, error=err, ) monitoring.log_event("api_failure", request_id=request_id, session_id=session_id, error=err) return JSONResponse({"error": err}, status_code=500) SESSIONS[session_id] = result["messages"][-MAX_HISTORY_MESSAGES:] pres = result.get("presentation") usage = result.get("usage") or {} chat_cost = monitoring.cost(usage.get("prompt", 0), usage.get("completion", 0)) monitoring.log_request( request_id=request_id, session_id=session_id, question=req.message, answer=result["answer"], latency_ms=(time.time() - received) * 1000, model=getattr(bot, "model", None), llm_calls=result.get("llm_calls", 0), tokens=result.get("usage"), queries=result["queries"], presentation=(pres or {}).get("format"), error=None, ) return JSONResponse( { "session_id": session_id, "request_id": request_id, "answer": result["answer"], "queries": result["queries"], "presentation": pres, "tokens": usage.get("total", 0), "cost_usd": chat_cost, } ) @app.get("/api/metrics") def metrics( x_metrics_token: str | None = Header(default=None), token: str | None = None, # query param, for viewing in a browser ) -> JSONResponse: """Aggregate metrics (uptime, requests, errors, tokens, latency, cost, sessions). If METRICS_TOKEN is set, supply it via the X-Metrics-Token header OR a ?token=... query parameter (the query param is handy in a browser).""" supplied = x_metrics_token or token if config.METRICS_TOKEN and supplied != config.METRICS_TOKEN: return JSONResponse({"error": "Unauthorized"}, status_code=401) return JSONResponse(monitoring.get_metrics()) @app.get("/api/metrics/recent") def metrics_recent( limit: int = 20, x_metrics_token: str | None = Header(default=None), token: str | None = None, ) -> JSONResponse: """Recent chats from the durable log (survives restarts). Token-protected.""" supplied = x_metrics_token or token if config.METRICS_TOKEN and supplied != config.METRICS_TOKEN: return JSONResponse({"error": "Unauthorized"}, status_code=401) return JSONResponse({"recent": monitoring.recent_chats(min(max(limit, 1), 100))}) @app.post("/api/reset") def reset(req: ResetRequest) -> JSONResponse: SESSIONS.pop(req.session_id, None) return JSONResponse({"ok": True}) # Serve any other static assets (none required, but handy for extension). if STATIC_DIR.exists(): app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")