| """ |
| 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. |
| """ |
|
|
| |
|
|
| 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__) |
|
|
|
|
| |
|
|
| rag_service = RAGService() |
| cache_service = CacheService() |
| metrics_service = MetricsService(get_settings().metrics_path) |
|
|
|
|
| |
| |
| |
|
|
| 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) |
| _request_log_lock = asyncio.Lock() |
|
|
|
|
| |
| |
| |
|
|
| def get_rag_service() -> RAGService: |
| return rag_service |
|
|
|
|
| def get_metrics_service() -> MetricsService: |
| return metrics_service |
|
|
|
|
| |
|
|
| 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) |
|
|
|
|
| |
|
|
| @asynccontextmanager |
| async def lifespan(app: FastAPI): |
| from src.services.staff_service import build_staff_index |
|
|
| settings = get_settings() |
|
|
| |
| try: |
| build_staff_index() |
| logger.info("Staff index built") |
| except Exception as exc: |
| logger.error("Staff index build failed: %s", exc) |
|
|
| |
| 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") |
|
|
| |
| flush_task = asyncio.create_task(_metrics_flush_loop(interval=30)) |
| logger.info("LibBee startup complete") |
|
|
| yield |
|
|
| |
| flush_task.cancel() |
| try: |
| await asyncio.wait_for(metrics_service.flush(), timeout=5.0) |
| except Exception: |
| pass |
| logger.info("LibBee shutting down") |
|
|
|
|
| |
|
|
| app = FastAPI( |
| title="LibBee - KU Library AI", |
| description="Khalifa University Library AI Assistant", |
| version=LIBBEE_VERSION, |
| lifespan=lifespan, |
| ) |
|
|
|
|
| |
|
|
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=[ |
| "https://nikeshn.github.io", |
| "https://ku-library.github.io", |
| "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, |
| ) |
|
|
|
|
| |
|
|
| @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_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: |
| |
| _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."}, |
| ) |
|
|
| |
| 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] |
| 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) |
|
|
| |
| 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."}) |
|
|
|
|
| |
|
|
| from src.api import admin, agent, feedback, search |
|
|
| 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"]) |
|
|
|
|
| |
|
|
| @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")} |
|
|