"""Single-file FastAPI entrypoint for the 9XAIPal Hugging Face Space. The Space uses the Docker SDK, so we serve everything from one plain FastAPI app on port 7860: * ``/api/v1/*`` -> the real 9XAIPal backend router * ``/*`` -> the compiled React build (SPA, with client-side routing) The React build (``dist/index.html``) references its bundles with absolute root paths (``/assets/index-*.js``), so the SPA MUST be served from the root. Mounting it under a sub-path (e.g. ``/ui``) makes those asset requests 404 and the page renders blank. The API router is registered before the static mount, so ``/api/v1`` and ``/api/docs`` still win over the catch-all. We deliberately do NOT use Gradio as a wrapper: as of Gradio 6.0 the FastAPI instance exposed as ``demo.app`` is no longer the app that ``launch()`` serves, so routers/static mounts added to it silently 404. Running uvicorn on our own FastAPI app removes that ambiguity. All config is read from environment variables so the container works cleanly with Hugging Face Space secrets. """ import asyncio import logging import os from contextlib import asynccontextmanager from pathlib import Path from typing import AsyncIterator import uvicorn from fastapi import FastAPI from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from sqlalchemy import text from starlette.responses import FileResponse logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") logger = logging.getLogger("9xaipal.space") # ----------------------------------------------------------------------------- # Environment variables only — never read a local .env file. # ----------------------------------------------------------------------------- PORT = int(os.getenv("PORT", os.getenv("GRADIO_SERVER_PORT", "7860"))) HOST = os.getenv("HOST", "0.0.0.0") UI_DIR = Path(os.getenv("UI_DIR", "/code/dist")).resolve() # Reuse the real backend's startup lifecycle so the database schema is created # on boot. The in-container Postgres starts empty, and app/main.py applies # schema.sql + vector setup inside its `lifespan` hook — without attaching it # here, the tables (documents, chunks, ...) never exist and every query fails # with `relation "documents" does not exist`. Degrade gracefully if the backend # package can't be imported (static-UI-only mode). try: from app.core.lifecycle import lifespan as _backend_lifespan # type: ignore except Exception as _exc: # pragma: no cover logger.warning("Backend lifespan not importable (%s); skipping DB migrations", _exc) _backend_lifespan = None # Import backend pieces for health checks and the watchdog. If the backend is # not importable we degrade to a static-UI-only health response. try: from app.core.celery_app import celery_app as _celery_app # type: ignore from app.core.config import settings as _settings # type: ignore from app.database.connection import engine as _engine # type: ignore except Exception as _exc: # pragma: no cover logger.warning("Backend services not importable (%s); health checks degraded", _exc) _celery_app = None _settings = None _engine = None @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: """Run the backend lifecycle, then start the ingestion watchdog. The watchdog is Space-specific: it detects stuck ingestion jobs and force-terminates + recreates them so uploads can't hang forever. """ if _backend_lifespan is not None: async with _backend_lifespan(app): try: from app.core.watchdog import start_watchdog # type: ignore start_watchdog() except Exception as exc: logger.warning("Could not start ingestion watchdog: %s", exc) yield else: yield app = FastAPI( title="9XAIPal", docs_url="/api/docs", openapi_url="/api/openapi.json", lifespan=lifespan, ) # ----------------------------------------------------------------------------- # Mount the real backend API router so /api/v1/... works. If the backend package # is not importable, degrade to serving the static React UI only. # ----------------------------------------------------------------------------- try: from app.api.v1.router import api_router # type: ignore from app.api.errors import register_exception_handlers # type: ignore from app.core.config import settings # type: ignore from app.core.paths import ( # type: ignore images_dir, extracted_dir, assets_dir, research_images_dir, ) from app.core.security import ( # type: ignore RateLimitMiddleware, SecurityHeadersMiddleware, ) from fastapi.middleware.cors import CORSMiddleware # Replicate the middleware stack from app.main so CORS / security / rate # limiting behave the same as the standalone backend. Middleware is applied # inside-out: the last add_middleware call becomes the outermost. app.add_middleware(SecurityHeadersMiddleware) app.add_middleware( RateLimitMiddleware, limit_per_minute=settings.rate_limit_per_minute, ) app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origin_list, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.include_router(api_router, prefix="/api/v1") register_exception_handlers(app) # Mount storage-backed static files (figures, raw PDFs, extracted artifacts, # research-agent images) BEFORE the SPA catch-all so they don't fall back to # index.html. These mounts mirror app/main.py. app.mount( "/static/images", StaticFiles(directory=str(images_dir()), check_dir=False), name="images", ) app.mount( "/static/extracted", StaticFiles(directory=str(extracted_dir()), check_dir=False), name="extracted", ) app.mount( "/static/assets", StaticFiles(directory=str(assets_dir()), check_dir=False), name="assets", ) app.mount( "/static/images/research", StaticFiles(directory=str(research_images_dir()), check_dir=False), name="research-images", ) logger.info("Backend package found; mounted API at /api/v1") except Exception as exc: # pragma: no cover logger.warning("Backend package not importable (%s); serving static UI only", exc) # ----------------------------------------------------------------------------- # Allow embedding in the Hugging Face Space "App" tab. HF renders the Space in # an