Spaces:
Runtime error
Runtime error
| """FastAPI application factory and entry point.""" | |
| import logging | |
| import os | |
| import uuid | |
| from collections.abc import AsyncGenerator | |
| from contextlib import asynccontextmanager | |
| from datetime import UTC | |
| from pathlib import Path | |
| from fastapi import FastAPI | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import FileResponse, JSONResponse, Response | |
| from fastapi.staticfiles import StaticFiles | |
| from sqlalchemy import text | |
| from app.api import ( | |
| agentic, | |
| auth, | |
| canonical_rollout, | |
| catalog, | |
| content_similarity, | |
| generate, | |
| photos, | |
| rag_runtime, | |
| status, | |
| style_library, | |
| survey_level, | |
| upload, | |
| ) | |
| from app.api import export as export_api | |
| from app.api.middleware import TenantAuthMiddleware | |
| from app.config import settings | |
| from app.db.database import get_session_factory, init_db | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s %(levelname)s %(name)s — %(message)s", | |
| ) | |
| logging.getLogger("aiosqlite").setLevel(logging.WARNING) | |
| logging.getLogger("sqlalchemy.engine").setLevel( | |
| logging.INFO if settings.dev_mode else logging.WARNING | |
| ) | |
| logger = logging.getLogger(__name__) | |
| # Sentinel values for `tenant_secret_key` that must never be allowed to ship | |
| # to a real tenant. We surface a loud WARNING (not a hard fail) because the | |
| # setting is currently inert — declared in `Settings` but not consumed by any | |
| # signing/verification path. The day someone wires it into tenant-token | |
| # signing, this guard becomes a fail-fast and they get the right behaviour | |
| # for free. Until then, a noisy warning is the correct severity. | |
| _DEFAULT_TENANT_SECRETS: frozenset[str] = frozenset( | |
| { | |
| "", | |
| "dev-secret-change-me", | |
| "change-me-in-production", | |
| } | |
| ) | |
| def _validate_production_settings() -> None: | |
| """Refuse to boot a production deployment with sentinel/empty secrets. | |
| Runs only when ``DEV_MODE=false`` (the Dockerfile's default). In dev mode | |
| the conftest sets ``DEV_MODE=true`` and ``OPENAI_API_KEY=""`` on purpose, | |
| so this guard is a no-op for the test suite. | |
| Raises: | |
| RuntimeError: if a hard requirement is missing in production. The | |
| error message is explicit about which env var to set, so the | |
| operator sees a fixable cause in the deploy logs rather than a | |
| silent degradation hours later. | |
| """ | |
| if settings.dev_mode: | |
| return | |
| api_key = (settings.openai_api_key or "").strip() | |
| if not api_key and os.environ.get("SPACE_ID"): | |
| api_key = (os.environ.get("OPENAI_API_KEY") or "").strip() | |
| if api_key: | |
| settings.openai_api_key = api_key | |
| if not api_key: | |
| hf_hint = ( | |
| " Hugging Face Space: Settings → Variables and secrets → Secrets → " | |
| "New secret, name exactly OPENAI_API_KEY, value sk-…, Save, then " | |
| "Restart Space (secrets load on boot only)." | |
| ) | |
| on_hf = bool(os.environ.get("SPACE_ID")) | |
| if on_hf or os.environ.get("ALLOW_MISSING_OPENAI_API_KEY", "").lower() in ( | |
| "1", | |
| "true", | |
| "yes", | |
| ): | |
| logger.error( | |
| "OPENAI_API_KEY is not set — the Space will start but AI generation, " | |
| "inspector, and vision are disabled until you add the secret and restart.%s", | |
| hf_hint if on_hf else "", | |
| ) | |
| return | |
| raise RuntimeError( | |
| "OPENAI_API_KEY is required when DEV_MODE=false. " | |
| "Without it the agentic inspector silently falls back to keyword " | |
| "heuristics and reports become low quality. " | |
| "Set OPENAI_API_KEY in your hosting platform's secrets" | |
| f"{hf_hint if on_hf else ''} " | |
| "or set DEV_MODE=true to suppress this check (development only)." | |
| ) | |
| if settings.tenant_secret_key.strip() in _DEFAULT_TENANT_SECRETS: | |
| logger.warning( | |
| "TENANT_SECRET_KEY is unset or using a default sentinel value. " | |
| "It is currently inert (no signing path reads it) but you should " | |
| "set a non-default value before any signing logic is added." | |
| ) | |
| if not settings.knowledge_base_enabled: | |
| logger.warning( | |
| "KNOWLEDGE_BASE_ENABLED=false in production: the agentic inspector " | |
| "loop's KB-grounding tool returns nothing, so generated reports " | |
| "rely solely on tenant uploads. This is the expected default for " | |
| "the HF Spaces free-tier deploy (KB source PDFs are gitignored)." | |
| ) | |
| async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: | |
| """Initialise resources on startup; release them on shutdown.""" | |
| _validate_production_settings() | |
| logger.info("Initialising database…") | |
| await init_db() | |
| settings.upload_dir.mkdir(parents=True, exist_ok=True) | |
| settings.cache_dir.mkdir(parents=True, exist_ok=True) | |
| logger.info("Pre-warming vector store…") | |
| from app.vectorstore.factory import get_vectorstore | |
| get_vectorstore() | |
| logger.info("Pre-warming embedding model…") | |
| from app.embeddings.factory import get_embedding_client | |
| get_embedding_client() | |
| import asyncio | |
| # Optional: ingest local standards/exemplar corpus (Behrang + RAW Context) into a reserved tenant. | |
| # Run KB + index repair in the background so the API becomes reachable immediately. | |
| try: | |
| import anyio | |
| from app.services.knowledge_base import upsert_knowledge_base | |
| async def _post_startup_index_maintenance() -> None: | |
| if settings.knowledge_base_enabled: | |
| try: | |
| await anyio.to_thread.run_sync(upsert_knowledge_base) | |
| except Exception: # noqa: BLE001 | |
| logger.exception("Knowledge base ingest failed") | |
| elif not getattr(settings, "index_repair_after_kb", True): | |
| await asyncio.sleep(3) | |
| if not getattr(settings, "index_repair_on_startup", True): | |
| return | |
| try: | |
| from app.ingest.repair import repair_documents_missing_from_index | |
| n = await repair_documents_missing_from_index() | |
| if n: | |
| logger.info( | |
| "Startup index repair: re-queued %d complete doc(s) missing from FAISS", | |
| n, | |
| ) | |
| except Exception: | |
| logger.exception("Startup index repair failed") | |
| if settings.knowledge_base_enabled or getattr(settings, "index_repair_on_startup", True): | |
| asyncio.create_task(_post_startup_index_maintenance()) | |
| except Exception: | |
| logger.exception("Startup index maintenance scheduling failed") | |
| from app.services.generation_stale import generation_stale_sweeper_loop | |
| if int(getattr(settings, "generation_stale_sweep_seconds", 120)) > 0: | |
| asyncio.create_task(generation_stale_sweeper_loop()) | |
| # Resume any pending/processing ingests after restarts so the UI doesn't | |
| # hang indefinitely on "pending" rows created by earlier batch uploads. | |
| try: | |
| from datetime import datetime | |
| from sqlalchemy import select | |
| from app.db.models import Document as DBDocument | |
| from app.db.models import IngestStatus | |
| from app.ingest.schedule import schedule_ingest | |
| now = datetime.now(UTC) | |
| stale_s = int(settings.ingest_timeout_seconds) | |
| async with get_session_factory()() as db: | |
| result = await db.execute( | |
| select(DBDocument).where(DBDocument.status.in_([IngestStatus.pending, IngestStatus.processing])) | |
| ) | |
| rows = result.scalars().all() | |
| resumed = 0 | |
| marked_failed = 0 | |
| for d in rows: | |
| if d.status == IngestStatus.pending: | |
| schedule_ingest(doc_id=d.id, file_path=Path(d.file_path)) | |
| resumed += 1 | |
| elif d.status == IngestStatus.processing: | |
| updated = d.updated_at | |
| if updated is not None and updated.tzinfo is None: | |
| updated = updated.replace(tzinfo=UTC) | |
| age_s = (now - updated).total_seconds() if updated else 0 | |
| if age_s > stale_s: | |
| d.status = IngestStatus.failed | |
| d.error_message = ( | |
| f"Ingestion timed out after {stale_s}s during processing. " | |
| "Restarted server detected a stale ingest." | |
| ) | |
| marked_failed += 1 | |
| if marked_failed: | |
| await db.commit() | |
| if resumed or marked_failed: | |
| logger.info("Startup ingest resume: queued=%d stale_failed=%d", resumed, marked_failed) | |
| except Exception: | |
| logger.exception("Startup ingest resume failed") | |
| try: | |
| from app.agentic.runtime_status import inspector_public_status | |
| ins = inspector_public_status() | |
| logger.info( | |
| "RICS inspector runtime: mode=%s key=%s flag=%s — %s", | |
| ins["effective_mode"], | |
| ins["openai_api_key_configured"], | |
| ins["inspector_tool_agent"], | |
| ins["summary"], | |
| ) | |
| except Exception: # noqa: BLE001 | |
| logger.exception("Could not log RICS inspector status") | |
| # Optional: build reference style profile from the KB corpus. | |
| # Non-blocking: if the KB hasn't been ingested yet (or no key is set), this is a no-op. | |
| try: | |
| import asyncio | |
| from app.services.reference_style import seed_reference_style_profile | |
| asyncio.create_task(seed_reference_style_profile()) | |
| except Exception: # noqa: BLE001 | |
| logger.exception("Reference style seeding failed") | |
| from app.redis_client import redis_configured | |
| if redis_configured(): | |
| try: | |
| from app.redis_client import get_redis | |
| await get_redis() | |
| logger.info("Redis available for rate limits / job queue") | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("Redis configured but not reachable at startup: %s", exc) | |
| if os.environ.get("SPACE_ID"): | |
| logger.info( | |
| "HF Space ingest policy: rag_upload_sanitisation=%s max_concurrent_ingests=%d " | |
| "(set ENABLE_RAG_UPLOAD_SANITISATION=true to opt into LLM scrub per PDF page)", | |
| settings.enable_rag_upload_sanitisation, | |
| int(settings.max_concurrent_ingests), | |
| ) | |
| logger.info("Startup complete.") | |
| yield | |
| from app.redis_client import close_redis, redis_configured as _redis_cfg | |
| if _redis_cfg(): | |
| await close_redis() | |
| logger.info("Shutdown complete.") | |
| def create_app() -> FastAPI: | |
| """Build and return the configured FastAPI application. | |
| Returns: | |
| A fully configured FastAPI instance with routers and middleware. | |
| Example:: | |
| app = create_app() | |
| """ | |
| app = FastAPI( | |
| title="Report Genius AI", | |
| description="RICS-style RAG report generation API", | |
| version="0.1.0", | |
| lifespan=lifespan, | |
| ) | |
| async def _unhandled_exception_handler(request, exc): # type: ignore[no-untyped-def] | |
| error_id = str(uuid.uuid4()) | |
| logger.exception("Unhandled error_id=%s path=%s", error_id, getattr(request, "url", "unknown")) | |
| return JSONResponse( | |
| status_code=500, | |
| content={ | |
| "detail": "Internal Server Error", | |
| "error_id": error_id, | |
| }, | |
| ) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=settings.allowed_origins, | |
| allow_credentials=False, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| app.add_middleware(TenantAuthMiddleware) | |
| app.include_router(auth.router, tags=["auth"]) | |
| app.include_router(upload.router, tags=["upload"]) | |
| app.include_router(survey_level.router, tags=["upload"]) | |
| app.include_router(style_library.router, tags=["style-library"]) | |
| app.include_router(catalog.router, tags=["templates"]) | |
| app.include_router(content_similarity.router, tags=["content"]) | |
| app.include_router(canonical_rollout.router, tags=["content"]) | |
| app.include_router(generate.router, tags=["generate"]) | |
| app.include_router(agentic.router, tags=["agentic"]) | |
| app.include_router(photos.router, tags=["photos"]) | |
| app.include_router(rag_runtime.router, tags=["rag"]) | |
| app.include_router(status.router, tags=["status"]) | |
| app.include_router(export_api.router, tags=["export"]) | |
| _frontend = Path(__file__).parent.parent / "frontend" | |
| _favicon = _frontend / "favicon.ico" | |
| if _frontend.exists(): | |
| app.mount("/static", StaticFiles(directory=str(_frontend)), name="static") | |
| async def favicon() -> FileResponse | Response: | |
| """Serve site icon at root path (browsers request /favicon.ico by default).""" | |
| if _favicon.is_file(): | |
| return FileResponse(str(_favicon), media_type="image/x-icon") | |
| return Response(status_code=204) | |
| async def health_check() -> JSONResponse: | |
| """Return system health status with component-level detail. | |
| Returns HTTP 200 when all components are healthy, HTTP 503 when any | |
| component is degraded. Safe to call without authentication. | |
| Component errors are logged server-side but are not exposed in detail. | |
| """ | |
| from app.agentic.runtime_status import inspector_public_status | |
| from app.vectorstore.factory import VECTORSTORE_BACKEND_LABEL | |
| components: dict[str, str] = {} | |
| overall_ok = True | |
| faiss_vector_count: int | None = None | |
| try: | |
| async with get_session_factory()() as db: | |
| await db.execute(text("SELECT 1")) | |
| components["database"] = "ok" | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("Health check: database unreachable — %s", exc) | |
| components["database"] = "error" | |
| overall_ok = False | |
| try: | |
| from app.vectorstore.factory import get_vectorstore | |
| vs = get_vectorstore() | |
| vs.count(tenant_id="__healthcheck__") | |
| faiss_vector_count = int(getattr(vs, "total_vectors", lambda: 0)()) | |
| components["vectorstore"] = "ok" | |
| if faiss_vector_count == 0: | |
| components["vectorstore"] = "empty" | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("Health check: vectorstore unreachable — %s", exc) | |
| components["vectorstore"] = "error" | |
| overall_ok = False | |
| if settings.enable_temporal_workflow: | |
| try: | |
| from temporalio.client import Client | |
| client = await Client.connect( | |
| settings.temporal_host, | |
| namespace=settings.temporal_namespace, | |
| ) | |
| await client.service_client.check_health() | |
| components["temporal"] = "ok" | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("Health check: Temporal unreachable — %s", exc) | |
| components["temporal"] = "error" | |
| overall_ok = False | |
| else: | |
| components["temporal"] = "disabled" | |
| backend = (settings.vectorstore_backend or "faiss").strip().lower() | |
| if backend == "qdrant": | |
| try: | |
| from qdrant_client import QdrantClient | |
| qclient = QdrantClient( | |
| url=settings.qdrant_url, | |
| api_key=settings.qdrant_api_key or None, | |
| ) | |
| qclient.get_collections() | |
| components["qdrant"] = "ok" | |
| except Exception as exc: # noqa: BLE001 | |
| logger.warning("Health check: Qdrant unreachable — %s", exc) | |
| components["qdrant"] = "error" | |
| overall_ok = False | |
| from app.api.rate_limit import rate_limit_backend_label | |
| from app.deployment_baseline import ( | |
| BASELINE_DESCRIPTION, | |
| BASELINE_GIT_SHA, | |
| BASELINE_LABEL, | |
| ) | |
| from app.optimization.health_warnings import ( | |
| collect_optimization_hints, | |
| collect_optimization_warnings, | |
| ) | |
| from app.redis_client import ( | |
| generation_queue_depth, | |
| job_queue_enabled, | |
| redis_configured, | |
| redis_health_ok, | |
| ) | |
| opt_warnings = collect_optimization_warnings() | |
| opt_hints = collect_optimization_hints() | |
| queue_depth: int | None = None | |
| if redis_configured(): | |
| components["redis"] = "ok" if await redis_health_ok() else "error" | |
| if components["redis"] == "error": | |
| overall_ok = False | |
| queue_depth = await generation_queue_depth() | |
| if ( | |
| job_queue_enabled() | |
| and queue_depth is not None | |
| and queue_depth > 0 | |
| ): | |
| opt_warnings.append( | |
| f"Generation job queue has {queue_depth} pending job(s); " | |
| "ensure jobs_worker is running." | |
| ) | |
| else: | |
| components["redis"] = "disabled" | |
| components["job_queue"] = "active" if job_queue_enabled() else "disabled" | |
| components["rate_limit_backend"] = rate_limit_backend_label() | |
| status_code = 200 if overall_ok else 503 | |
| return JSONResponse( | |
| status_code=status_code, | |
| content={ | |
| "status": "ok" if overall_ok else "degraded", | |
| "components": components, | |
| "vectorstore_backend": VECTORSTORE_BACKEND_LABEL, | |
| "faiss_index_path": str(settings.faiss_index_path), | |
| "faiss_vector_count": faiss_vector_count, | |
| "enable_async_pipeline": bool(settings.enable_async_pipeline), | |
| "enable_speculative_executor": bool(settings.enable_speculative_executor), | |
| "enable_prompt_caching": bool(settings.enable_prompt_caching), | |
| "enable_temporal_workflow": bool(settings.enable_temporal_workflow), | |
| "semantic_cache_enabled": bool(settings.semantic_cache_enabled), | |
| "enable_hybrid_retrieval": bool(settings.enable_hybrid_retrieval), | |
| "notes_only_generation": bool(settings.notes_only_generation), | |
| "primary_generate_pipeline": ( | |
| settings.primary_generate_pipeline or "standard" | |
| ).strip().lower(), | |
| "agentic_inspector_when_notes_only": bool( | |
| settings.agentic_inspector_when_notes_only | |
| ), | |
| "codebase_baseline": { | |
| "git_sha": BASELINE_GIT_SHA, | |
| "label": BASELINE_LABEL, | |
| "description": BASELINE_DESCRIPTION, | |
| }, | |
| "qdrant_url": settings.qdrant_url if backend == "qdrant" else None, | |
| "rics_inspector": inspector_public_status(), | |
| "optimization_warnings": opt_warnings, | |
| "optimization_hints": opt_hints, | |
| "redis_url_configured": redis_configured(), | |
| "enable_job_queue": bool(settings.enable_job_queue), | |
| "generation_queue_depth": queue_depth, | |
| }, | |
| ) | |
| async def serve_ui() -> FileResponse: | |
| return FileResponse( | |
| str(_frontend / "index.html"), | |
| headers={"Cache-Control": "no-cache, no-store, must-revalidate"}, | |
| ) | |
| return app | |
| app = create_app() | |