LibBee / app.py
nikeshn's picture
Upload 5 files
a585654 verified
Raw
History Blame Contribute Delete
11.8 kB
"""
app.py β€” LibBee v3.5
FastAPI entry point: CORS, middleware, lifespan, router mounting.
Changes over v3.1:
- Maintenance mode middleware now reads from JsonRuntimeStore (admin-editable)
instead of settings.maintenance_mode (env var, static at startup).
- CORS origins updated: added ku-library.github.io.
- Section headers added throughout.
"""
# ── Imports ────────────────────────────────────────────────────────────────────
import asyncio
import logging
import time
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
try:
from cachetools import TTLCache
_ttl_cache_available = True
except ImportError:
_ttl_cache_available = False
from src.config import get_settings, LIBBEE_VERSION
from src.services.cache_service import CacheService
from src.services.metrics_service import MetricsService
from src.services.rag_service import RAGService
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# ── Service Instances ──────────────────────────────────────────────────────────
rag_service = RAGService()
cache_service = CacheService()
metrics_service = MetricsService(get_settings().metrics_path)
# ── Rate-limit Request Log ─────────────────────────────────────────────────────
# TTLCache auto-evicts entries older than ttl seconds.
# Falls back to a plain deque-based dict if cachetools is not installed.
if _ttl_cache_available:
_request_log = TTLCache(maxsize=10_000, ttl=120)
_request_log_lock = asyncio.Lock()
else:
from collections import defaultdict, deque
_request_log = defaultdict(deque) # type: ignore[assignment]
_request_log_lock = asyncio.Lock()
# ── Public Accessors ───────────────────────────────────────────────────────────
# Routers import these instead of importing module-level globals directly,
# which avoids circular-import issues at import time.
def get_rag_service() -> RAGService:
return rag_service
def get_metrics_service() -> MetricsService:
return metrics_service
# ── Background Tasks ───────────────────────────────────────────────────────────
async def _metrics_flush_loop(interval: int = 30) -> None:
"""Flush in-memory metric counters to disk every `interval` seconds."""
while True:
await asyncio.sleep(interval)
try:
await metrics_service.flush()
except Exception as exc:
logger.warning("Metrics flush error: %s", exc)
# ── Lifespan ───────────────────────────────────────────────────────────────────
@asynccontextmanager
async def lifespan(app: FastAPI):
from src.services.staff_service import build_staff_index
settings = get_settings()
# Build staff index
try:
build_staff_index()
logger.info("Staff index built")
except Exception as exc:
logger.error("Staff index build failed: %s", exc)
# Initialise RAG service
if settings.openai_api_key:
try:
await rag_service.initialize(openai_api_key=settings.openai_api_key)
logger.info("RAG service ready β€” chunks: %d", len(rag_service.bm25_corpus))
except Exception as exc:
logger.error("RAG initialization failed: %s", exc, exc_info=True)
else:
logger.warning("OPENAI_API_KEY not set β€” RAG disabled")
# Start background metrics flush
flush_task = asyncio.create_task(_metrics_flush_loop(interval=30))
logger.info("LibBee startup complete")
yield
# Graceful shutdown β€” flush remaining metrics
flush_task.cancel()
try:
await asyncio.wait_for(metrics_service.flush(), timeout=5.0)
except Exception:
pass
logger.info("LibBee shutting down")
# ── FastAPI App ────────────────────────────────────────────────────────────────
app = FastAPI(
title="LibBee - KU Library AI",
description="Khalifa University Library AI Assistant",
version=LIBBEE_VERSION,
lifespan=lifespan,
)
# ── CORS Middleware ────────────────────────────────────────────────────────────
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://nikeshn.github.io",
"https://ku-library.github.io", # added v3.5
"http://localhost:8080",
"http://localhost:3000",
"http://127.0.0.1:5500",
],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"],
max_age=86400,
)
# ── Operational Guardrails Middleware ──────────────────────────────────────────
@app.middleware("http")
async def security_headers(request: Request, call_next):
"""
Add security headers to every response.
X-Content-Type-Options β€” prevents MIME sniffing
X-Frame-Options β€” prevents clickjacking
Referrer-Policy β€” limits referrer leakage
X-XSS-Protection β€” legacy XSS filter (belt-and-braces)
Permissions-Policy β€” disables unused browser features
"""
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "SAMEORIGIN"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Permissions-Policy"] = (
"geolocation=(), microphone=(), camera=(), payment=()"
)
return response
@app.middleware("http")
async def operational_guardrails(request: Request, call_next):
settings = get_settings()
# ── Maintenance mode ───────────────────────────────────────────────────────
# FIX v3.5: read from JsonRuntimeStore (admin-editable) rather than
# settings.maintenance_mode (env var, static at startup). This means
# toggling maintenance via the admin dashboard actually takes effect.
_maintenance_on = False
try:
from src.services.runtime_store import JsonRuntimeStore
_store = JsonRuntimeStore(settings.config_path, default={"maintenance_mode": False})
_maintenance_on = bool(_store.load().get("maintenance_mode", False))
except Exception:
# If store is unreadable fall back to env-var setting
_maintenance_on = settings.maintenance_mode
_exempt_paths = {"/", "/config", "/admin", "/admin/login", "/admin/auth"}
if _maintenance_on and request.url.path not in _exempt_paths:
return JSONResponse(
status_code=503,
content={"detail": "LibBee is in maintenance mode. Please try again shortly."},
)
# ── Rate limiting ──────────────────────────────────────────────────────────
client_ip = request.client.host if request.client else "unknown"
now = time.time()
async with _request_log_lock:
if _ttl_cache_available:
timestamps = _request_log.get(client_ip, [])
timestamps = [t for t in timestamps if now - t < 60]
if len(timestamps) >= settings.rate_limit_per_minute:
metrics_service.incr_bucket("errors", "rate_limit")
return JSONResponse(
status_code=429,
content={"detail": "Too many requests. Please slow down and try again."},
)
timestamps.append(now)
_request_log[client_ip] = timestamps
else:
from collections import deque
bucket = _request_log[client_ip] # type: ignore[index]
while bucket and now - bucket[0] > 60:
bucket.popleft()
if len(bucket) >= settings.rate_limit_per_minute:
metrics_service.incr_bucket("errors", "rate_limit")
return JSONResponse(
status_code=429,
content={"detail": "Too many requests. Please slow down and try again."},
)
bucket.append(now)
# ── Request execution ──────────────────────────────────────────────────────
try:
response = await call_next(request)
return response
except Exception as exc:
logger.exception("Unhandled application error: %s", exc)
metrics_service.incr_bucket("errors", "unhandled_exception")
return JSONResponse(status_code=500, content={"detail": "Unexpected server error."})
# ── Router Mounting ────────────────────────────────────────────────────────────
from src.api import admin, agent, feedback, search # noqa: E402
app.include_router(agent.router, prefix="/agent", tags=["Agent"])
app.include_router(search.router, prefix="/search", tags=["Search"])
app.include_router(admin.router, prefix="/admin", tags=["Admin"])
app.include_router(feedback.router, prefix="/feedback", tags=["Feedback"])
# ── Health & Public Endpoints ──────────────────────────────────────────────────
@app.get("/favicon.ico", include_in_schema=False)
async def favicon():
"""
Return 204 No Content for favicon requests.
Prevents 404 errors in browser console β€” the backend has no favicon,
the frontend (GitHub Pages) serves its own at assets/libbee-mascot.png.
"""
from fastapi.responses import Response
return Response(status_code=204)
@app.get("/")
def health_check():
settings = get_settings()
return {
"status": "ok",
"version": LIBBEE_VERSION,
"service": "LibBee KU Library AI",
"rag_ready": rag_service.is_ready(),
"maintenance_mode": settings.maintenance_mode,
"endpoints": ["/agent", "/search", "/admin", "/feedback"],
}
@app.get("/config")
def public_config():
"""Legacy public config endpoint β€” frontend uses /admin/public-config instead."""
settings = get_settings()
return {
"welcome_message": "Hi! I'm LibBee, the Khalifa University Library AI Assistant.",
"max_results": settings.max_results,
"maintenance_mode": settings.maintenance_mode,
}
@app.get("/year")
def get_year():
from datetime import datetime
now = datetime.utcnow()
return {"year": now.year, "month": now.month, "date": now.strftime("%Y-%m-%d")}