File size: 4,039 Bytes
414dc55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
"""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