case0 / src /case_zero /api /server.py
HusseinEid's picture
Case Zero - initial public release (fully local: Qwen2.5-1.5B via llama.cpp + Supertonic, custom pixel-noir SPA via gradio.Server)
414dc55
"""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