Spaces:
Sleeping
Sleeping
| """FastAPI application for the template-agnostic v2 backend.""" | |
| from __future__ import annotations | |
| import json | |
| import logging | |
| from contextlib import asynccontextmanager | |
| from pathlib import Path | |
| from fastapi import FastAPI, Request | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import FileResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from backend.config import settings | |
| from backend.domain import template_discoverer | |
| from backend.pii import scrubber as pii_scrubber | |
| from backend.rag.store import get_rag_store | |
| from backend.rag.types import TIER_MASTER, TIER_REFERENCE | |
| from backend.startup import run_startup_ingest | |
| from backend.utils.runtime_paths import ensure_data_drive_runtime_dirs | |
| ensure_data_drive_runtime_dirs() | |
| logging.basicConfig(level=logging.INFO) | |
| logger = logging.getLogger(__name__) | |
| _FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend" | |
| _STARTUP_SUMMARY: dict = {} | |
| _PII_SCAN_PATHS = {"/api/report/generate", "/api/report/preview"} | |
| async def lifespan(app: FastAPI): | |
| global _STARTUP_SUMMARY | |
| # Warm jina-reranker-v3 FIRST, before startup ingest touches the embedder, so | |
| # the host-RAM weight load happens before the embedder + ingest stack up their | |
| # own allocations. Off by default (reference_cross_encoder_warmup); the lazy | |
| # path loads it on the first reference mapping otherwise. | |
| # Non-fatal: degrades to multi-signal on failure or under the RAM guard. | |
| try: | |
| from backend.llm.reranker import warmup_cross_encoder | |
| if warmup_cross_encoder(): | |
| logger.info("jina-reranker-v3 warmed at startup.") | |
| except Exception: # noqa: BLE001 - warmup must never block startup | |
| logger.warning("jina-reranker-v3 warmup skipped (non-fatal).", exc_info=True) | |
| _STARTUP_SUMMARY = run_startup_ingest() | |
| from backend.storage import document_library | |
| recovered = document_library.recover_all_tenants_stale_processing() | |
| if recovered: | |
| logger.info("Startup recovered %d stale processing document(s)", recovered) | |
| yield | |
| app = FastAPI( | |
| title="RICS Template-Agnostic Backend (v2)", version="2.0.0", lifespan=lifespan | |
| ) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| async def pii_request_middleware(request: Request, call_next): | |
| """Log PII in inbound notes for awareness (notes are not scrubbed; RAG docs are).""" | |
| if request.method == "POST" and request.url.path in _PII_SCAN_PATHS: | |
| body = await request.body() | |
| async def _receive(): | |
| return {"type": "http.request", "body": body, "more_body": False} | |
| request._receive = _receive # re-inject consumed body | |
| try: | |
| raw_notes = json.loads(body or b"{}").get("raw_notes", "") | |
| audit = pii_scrubber.detect_pii(raw_notes) | |
| flagged = {k: v for k, v in audit.items() if k in ("EMAIL", "POSTCODE")} | |
| if flagged: | |
| logger.warning( | |
| "Inbound notes contain PII (notes kept verbatim; scrub RAG uploads only): %s", | |
| flagged, | |
| ) | |
| except Exception: # noqa: BLE001 | |
| pass | |
| response = await call_next(request) | |
| return response | |
| # Routers | |
| from backend.api.routes import ( # noqa: E402 | |
| admin, | |
| auth, | |
| legacy_api_routes, | |
| photos, | |
| report, | |
| schema, | |
| upload, | |
| ) | |
| app.include_router(auth.router) | |
| app.include_router(legacy_api_routes.router) | |
| app.include_router(schema.router) | |
| app.include_router(report.router) | |
| app.include_router(photos.router) | |
| app.include_router(upload.router) | |
| if settings.master_template_upload_enabled: | |
| app.include_router(admin.router) | |
| def metrics_summary(limit: int = 50) -> dict: | |
| """Aggregate recent per-report telemetry from the local metrics sink. | |
| Reads the JSONL records written under the metrics dir (no live state), so it | |
| is cheap and safe to poll. Returns token/cost totals, average grounding pass | |
| rate + retrieval confidence, status/violation breakdowns and per-label LLM | |
| call latency over the most-recent reports. | |
| """ | |
| from backend.observability import tracing as observability | |
| return observability.recent_summary(limit=max(1, min(limit, 500))) | |
| def health() -> dict: | |
| tenant = settings.default_tenant_id | |
| loaded = template_discoverer.load_schema(tenant) is not None | |
| store = get_rag_store() | |
| master_faiss_count = store.count(tenant, TIER_MASTER) | |
| reference_faiss_count = store.count(tenant, TIER_REFERENCE) | |
| # Reference-only model: readiness depends on the (canonical) schema being | |
| # installed, not on a shared master being seeded. When the operator master is | |
| # explicitly enabled, its chunks must be present for an "ok" status. | |
| master_ready = (not settings.master_template_auto_ingest) or master_faiss_count > 0 | |
| from backend.llm.embeddings import get_embedder | |
| try: | |
| emb = get_embedder() | |
| embedder_name = type(emb).__name__ | |
| embed_dim = int(getattr(emb, "embed_dim", 0)) | |
| embedder_ok = True | |
| embedder_error = None | |
| except Exception as exc: # noqa: BLE001 - surface load failure in health | |
| embedder_name = None | |
| embed_dim = 0 | |
| embedder_ok = False | |
| embedder_error = str(exc) | |
| return { | |
| "status": "ok" if loaded and master_ready and embedder_ok else "degraded", | |
| "embedder": embedder_name, | |
| "embed_dim": embed_dim, | |
| "embedder_ok": embedder_ok, | |
| "embedder_error": embedder_error, | |
| "master_loaded": loaded, | |
| "master_faiss_count": master_faiss_count, | |
| "reference_faiss_count": reference_faiss_count, | |
| "reference_ready": reference_faiss_count > 0, | |
| "startup": _STARTUP_SUMMARY, | |
| "admin_override_enabled": settings.master_template_upload_enabled, | |
| "ui_path": "/", | |
| } | |
| if _FRONTEND_DIR.is_dir(): | |
| app.mount("/static", StaticFiles(directory=str(_FRONTEND_DIR)), name="static") | |
| def root_ui() -> FileResponse: | |
| index = _FRONTEND_DIR / "index.html" | |
| if index.is_file(): | |
| return FileResponse(index) | |
| return FileResponse(_FRONTEND_DIR / "v2.html") | |
| def v2_ui() -> FileResponse: | |
| return FileResponse(_FRONTEND_DIR / "v2.html") | |
| def legacy_index() -> FileResponse: | |
| return FileResponse(_FRONTEND_DIR / "index.html") | |
| def favicon() -> FileResponse: | |
| path = _FRONTEND_DIR / "favicon.ico" | |
| if path.is_file(): | |
| return FileResponse(path) | |
| return FileResponse(_FRONTEND_DIR / "v2.html") | |