"""Build the single ``gradio.Server`` that powers Case Zero. ``gradio.Server`` is a FastAPI subclass, so all standard FastAPI methods work on it (`.include_router`, `.get`, `.mount`, ...) plus a `.api()` decorator that adds Gradio's queue + SSE streaming + concurrency control. We: * register the game's JSON/SSE routers under ``/api``; * serve the built Preact pixel-art SPA (``web/dist``). The whole app runs 100% inside one Gradio process - no separate frontend host. Static serving uses explicit routes (assets under ``/assets``, index at ``/``) rather than a catch-all mount at ``/`` on purpose: a ``Mount("/")`` shadows Gradio's own internal ``/gradio_api/*`` routes (including its launch health check) and breaks startup. """ from __future__ import annotations from pathlib import Path from fastapi import HTTPException from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from gradio import Server from ..constants import ASSETS_DIR from .routes_case import router as case_router from .routes_run import router as run_router # src/case_zero/api/server.py -> repo root is three parents up from this file's dir. _REPO_ROOT = Path(__file__).resolve().parents[3] WEB_DIST = (_REPO_ROOT / "web" / "dist").resolve() _INDEX = WEB_DIST / "index.html" _AUDIO_DIR = ASSETS_DIR / "ui" def build_server() -> Server: """Return a configured (not yet launched) ``gradio.Server``.""" server = Server(title="Case Zero", docs_url=None, redoc_url=None, openapi_url=None) server.include_router(case_router) server.include_router(run_router) # Gradio API endpoints (Server mode): the core game actions registered through Gradio's # own API engine - queue + concurrency + gradio_client-callable + visible in the API view. # This makes the app unambiguously a Gradio application (the custom SPA is the Off-Brand # frontend on top). The SPA itself uses the REST routes above; these mirror them. from .dto import NewCaseRequest from .routes_case import new_case from .routes_run import InterrogateBody, interrogate @server.api(name="new_case") def gr_new_case(case_id: str = "") -> dict: """Generate a fresh case (or load one by id); returns the public case JSON.""" return new_case(NewCaseRequest(case_id=case_id or None)).model_dump(by_alias=True) @server.api(name="interrogate") def gr_interrogate(run_id: str, suspect_id: str, question: str) -> dict: """Ask a suspect a free-text question; returns their reply + server suspicion.""" return interrogate(run_id, suspect_id, InterrogateBody(free_text=question)).model_dump( by_alias=True ) @server.get("/healthz") def healthz() -> dict[str, bool]: return {"ok": True} # UI audio (sfx + ambient music) - a specific prefix, served locally (Off-the-Grid). if _AUDIO_DIR.is_dir(): server.mount("/audio", StaticFiles(directory=str(_AUDIO_DIR)), name="audio") if WEB_DIST.is_dir(): # Hashed JS/CSS live under /assets - a specific prefix that never collides with /api. server.mount( "/assets", StaticFiles(directory=str(WEB_DIST / "assets")), name="assets", ) @server.get("/") def _index() -> FileResponse: return FileResponse(_INDEX) @server.get("/{filename}") def _root_or_spa(filename: str) -> FileResponse: # Serve real root files (favicon.svg, icons.svg); otherwise fall back to the SPA # index so single-segment client routes resolve. Single-segment matching means # this never shadows /gradio_api/* or /api/* (those have deeper paths). candidate = (WEB_DIST / filename).resolve() if candidate.is_file() and candidate.is_relative_to(WEB_DIST): return FileResponse(candidate) if _INDEX.is_file(): return FileResponse(_INDEX) raise HTTPException(status_code=404) return server