Spaces:
Running on Zero
Running on Zero
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()
|