File size: 6,578 Bytes
92d53f7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14a0030
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92d53f7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14a0030
 
 
92d53f7
 
 
 
 
 
 
 
 
 
 
14a0030
 
 
 
 
 
10b5225
 
 
 
 
 
 
 
14a0030
 
10b5225
14a0030
92d53f7
 
 
25e9850
 
 
 
 
 
 
 
 
 
 
 
 
 
14a0030
 
 
 
 
 
 
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
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
"""Hugging Face Space entry point: the scoring service, wrapped for the Gradio SDK.

## Why this adapter exists

The service is a FastAPI app (`lighthouse/serve/app.py`) and wants nothing to do with
Gradio. But Docker Spaces on free `cpu-basic` now answer 402:

    Static Spaces are free for everyone, but hosting Gradio and Docker Spaces on free
    cpu-basic requires a PRO subscription.

A Gradio Space on ZeroGPU hardware does not. Verified by probe on 2026-08-13: a free
account created one and it reached RUNNING on `zero-a10g`. So the free route onto Hugging
Face runs through the Gradio SDK, and this file is the adapter. It contains no triage
logic and must never grow any.

## Why Gradio owns the server and we graft routes onto it

The obvious shape, `gr.mount_gradio_app(api, demo, ...)` plus `uvicorn.run`, does not work
on a Space and fails twice over. Observed on the first deploy:

    ERROR: [Errno 98] error while attempting to bind on address ('0.0.0.0', 7860):
           address already in use
    RUNTIME_ERROR: No @spaces.GPU function detected during startup

The Gradio SDK runner has already bound 7860 by the time `__main__` runs, and ZeroGPU's
startup scan hooks `Blocks.launch()`, so bypassing launch means the platform never sees the
decorated function. Both problems have the same fix: let Gradio be the server, and attach
the two service routes to the FastAPI instance it builds.

The routes keep their exact paths, so `POST /score` and `GET /health` are what
`web/src/lib/classifier.ts` already calls, with no change at the other end. The Gradio page
stays at `/`, because a Space that renders a stack trace is a bad look on a submission page.

## Why the model still runs on CPU

ZeroGPU hands a GPU to functions decorated with `@spaces.GPU`, and only for the duration of
that call. `TurnScorer` loads its checkpoint lazily and then holds it across requests,
which is the opposite shape: the fork-and-initialise dance the decorator performs would be
a failure mode added for no gain. A DistilBERT scores a conversation in about 30ms on CPU.

`gpu_selftest` exists so the Space still declares a GPU entry point and so a judge can
confirm the hardware is real. Nothing on the scoring path calls it.
"""

import os
from pathlib import Path

# MUST precede the lighthouse import. `config.py` resolves ARTIFACTS_DIR at import time,
# and its default is REPO_ROOT/data, which on a Space points one level ABOVE this file.
# Set it wrong and the service starts happily and 503s on every request.
os.environ.setdefault("LIGHTHOUSE_DATA_DIR", str(Path(__file__).parent / "data"))

import gradio as gr  # noqa: E402
import spaces  # noqa: E402

from lighthouse.serve.app import app as api  # noqa: E402


@spaces.GPU(duration=15)
def gpu_selftest() -> str:
    """Off the scoring path. Proves the ZeroGPU allocation is real, nothing more."""
    import torch

    if not torch.cuda.is_available():
        return "no CUDA device in this allocation"
    return f"{torch.cuda.get_device_name(0)}, torch {torch.__version__}"


with gr.Blocks(title="Lighthouse scoring service") as demo:
    gr.Markdown(
        """
        # Lighthouse scoring service

        The classifier half of Lighthouse, behind one HTTP call. A fine-tuned turn
        classifier, a logistic conversation head with isotonic calibration, and a
        deterministic safety gate, returning a finished escalation card.

        **Every conversation this service was trained and demonstrated on is synthetic.**
        No real student ever spoke to it. This is a listening and routing tool: it does
        not diagnose, treat, or offer therapy.

        This page is not the product. The API is:

        | Route | Purpose |
        |---|---|
        | `GET /health` | liveness, and whether the checkpoint is present and loaded |
        | `POST /score` | one conversation in, one escalation card out |

        The first `/score` after a cold start takes about ten seconds while the checkpoint
        loads. Every one after it is about 30ms.

        This service cannot decide whether a student sees crisis resources. That already
        happened, in the browser, before this service was contacted, from a safety gate
        that runs in about 123µs with no network. Nothing returned here can revoke it.
        """
    )
    with gr.Row():
        selftest = gr.Button("GPU self-test", variant="secondary")
        selftest_out = gr.Textbox(label="allocation", interactive=False)
    selftest.click(gpu_selftest, outputs=selftest_out)


SERVICE_ROUTES = ("/health", "/score")


def graft(target, source, paths):
    """Move `source`'s routes onto `target`, preserving their paths.

    Starlette's router is a plain list consulted per request, so this can happen after
    `launch()` is already serving. It must **prepend**: Gradio registers a catch-all
    `/{path:path}` for its single-page frontend, and first match wins. Appending produced a
    Space that looked healthy and answered `GET /health` with a page of HTML, and `POST
    /score` with a 405.

    Asserting the result afterwards, because a silent no-op here is indistinguishable from
    a working deploy until something tries to score.
    """
    grafted = [r for r in source.routes if getattr(r, "path", None) in paths]
    target.router.routes[:0] = grafted
    return [r.path for r in grafted]


if __name__ == "__main__":
    # ssr_mode=False is not cosmetic. Gradio 6 defaults to server-side rendering, which
    # puts a Node proxy on 7860 in front of Python on 7861:
    #
    #     * Running on local URL: http://0.0.0.0:7860, with SSR (Node proxy -> Python :7861)
    #
    # That proxy answers paths it does not recognise with the single-page app, so grafted
    # routes on the Python side are never reached: `GET /health` returned a page of HTML
    # and `POST /score` a 405, with the graft reporting success in the same logs. Turning
    # SSR off gives Python port 7860 directly. We render one static markdown page; there is
    # nothing here for SSR to accelerate.
    #
    # prevent_thread_lock so control returns for the graft, then we block by hand. Host and
    # port are left to Gradio, which reads what the Space sets.
    demo.launch(prevent_thread_lock=True, ssr_mode=False)

    added = graft(demo.app, api, SERVICE_ROUTES)
    if sorted(added) != sorted(SERVICE_ROUTES):
        raise RuntimeError(f"expected {SERVICE_ROUTES} on the service app, grafted {added}")
    print(f"[lighthouse] serving {', '.join(added)} alongside the Gradio page", flush=True)

    demo.block_thread()