Michael Stattelman commited on
Commit
21c5089
·
1 Parent(s): 66ee87e

Add application file

Browse files
Files changed (5) hide show
  1. README.md +5 -5
  2. app.py +13 -4
  3. app/main.py +1 -1
  4. space.py +98 -0
  5. tests/test_space.py +12 -2
README.md CHANGED
@@ -6,7 +6,7 @@ colorTo: gray
6
  sdk: gradio
7
  sdk_version: 6.28.0
8
  python_version: "3.12"
9
- app_file: space.py
10
  pinned: false
11
  ---
12
 
@@ -65,9 +65,9 @@ With Compose, set the same build arg and uncomment the `deploy` block in `docker
65
 
66
  ## Hugging Face Space
67
 
68
- This folder is also a Gradio Space, set up for ZeroGPU hardware (it also runs on a CPU Space). `space.py` serves the same FastAPI app as the container, so the page at `/` and the API at `/api/*` are identical. A Gradio app is mounted at `/gradio`: a small form and a `decide` endpoint for `gradio_client`, running the same decision code.
69
 
70
- On ZeroGPU a GPU is attached for each decision: all models load before the Space starts serving, each model's time is measured on the GPU, and the round trip also includes the GPU hand-off. Each decision uses your ZeroGPU quota, so a full scoreboard run (one decision per demo) uses more of it.
71
 
72
  There is no token on the Space: on a public Space anyone can use the page and the API. Make the Space private on Hugging Face if that is not what you want.
73
 
@@ -80,7 +80,7 @@ cd <your-space>
80
  git lfs install
81
  # copy the contents of this DecisionLab folder into it (not the folder itself), then:
82
  git add .
83
- git commit -m "DecisionLab 2.0.0"
84
  git push
85
  ```
86
 
@@ -226,7 +226,7 @@ DecisionLab/
226
  ├── docker-compose.yml
227
  ├── constraints.txt # every other package, exact versions (from pip freeze)
228
  ├── .env.example # copy to .env for local settings (.env is never in the image)
229
- ├── space.py # Hugging Face Space entry point (Gradio SDK)
230
  ├── requirements.txt # Space dependencies
231
  ├── requirements-container.txt # container dependencies (with constraints.txt)
232
  ├── models/ # your LightDec and Arthur models, one folder each (not in the image)
 
6
  sdk: gradio
7
  sdk_version: 6.28.0
8
  python_version: "3.12"
9
+ app_file: app.py
10
  pinned: false
11
  ---
12
 
 
65
 
66
  ## Hugging Face Space
67
 
68
+ This folder is also a Gradio Space, set up for ZeroGPU hardware (it also runs on a CPU Space). `app.py` serves the same FastAPI app as the container, so the page at `/` and the API at `/api/*` are identical. A Gradio app is mounted at `/gradio`: a small form and a `decide` endpoint for `gradio_client`, running the same decision code.
69
 
70
+ On ZeroGPU a GPU is attached for each decision. The Space starts serving at once and the models load in the background, as in the container. Each model's time is measured on the GPU, and the round trip also includes the GPU hand-off. Each decision uses your ZeroGPU quota, so a full scoreboard run (one decision per demo) uses more of it.
71
 
72
  There is no token on the Space: on a public Space anyone can use the page and the API. Make the Space private on Hugging Face if that is not what you want.
73
 
 
80
  git lfs install
81
  # copy the contents of this DecisionLab folder into it (not the folder itself), then:
82
  git add .
83
+ git commit -m "DecisionLab 2.1.1"
84
  git push
85
  ```
86
 
 
226
  ├── docker-compose.yml
227
  ├── constraints.txt # every other package, exact versions (from pip freeze)
228
  ├── .env.example # copy to .env for local settings (.env is never in the image)
229
+ ├── app.py # Hugging Face Space entry point (Gradio SDK, ZeroGPU)
230
  ├── requirements.txt # Space dependencies
231
  ├── requirements-container.txt # container dependencies (with constraints.txt)
232
  ├── models/ # your LightDec and Arthur models, one folder each (not in the image)
app.py CHANGED
@@ -7,8 +7,12 @@ API endpoint for gradio_client, both running the same decision code (app.main.ru
7
  ZeroGPU (Hugging Face docs, checked 2026-09-28):
8
  - `import spaces` comes first: it patches torch so CUDA looks available everywhere; outside @spaces.GPU a CUDA
9
  emulation mode lets models be moved to "cuda" without a real GPU. A real GPU exists only inside @spaces.GPU calls.
10
- - Decorated calls run apart from the main process, so all models are loaded BEFORE the server starts, and only the
11
- model-running step is decorated. The request checks and the concurrency limit stay in the main process.
 
 
 
 
12
  - Each model times itself inside the GPU call, so model timings stay comparable; the GPU hand-off only adds to the
13
  round trip.
14
  @spaces.GPU does nothing on non-ZeroGPU hardware, so this file also runs on a CPU Space.
@@ -19,6 +23,7 @@ import spaces # noqa: I001 -- must be the first import (patches torch for Zero
19
 
20
  import json
21
  import os
 
22
  from pathlib import Path
23
 
24
  HERE = Path(__file__).resolve().parent
@@ -87,9 +92,13 @@ def build_blocks() -> gr.Blocks:
87
  return demo
88
 
89
 
 
 
 
90
  def main() -> None:
91
- load_all() # every model is loaded before the first request (see the note above)
92
- served = gr.mount_gradio_app(app, build_blocks(), path=GRADIO_PATH)
 
93
  uvicorn.run(served, host=os.getenv("GRADIO_SERVER_NAME") or "0.0.0.0",
94
  port=int(os.getenv("GRADIO_SERVER_PORT") or os.getenv("PORT") or 7860))
95
 
 
7
  ZeroGPU (Hugging Face docs, checked 2026-09-28):
8
  - `import spaces` comes first: it patches torch so CUDA looks available everywhere; outside @spaces.GPU a CUDA
9
  emulation mode lets models be moved to "cuda" without a real GPU. A real GPU exists only inside @spaces.GPU calls.
10
+ - Decorated calls run apart from the main process: only the model-running step is decorated; the request checks and
11
+ the concurrency limit stay in the main process.
12
+ - The server starts serving AT ONCE and the models load in the background (the page shows their progress, as in the
13
+ container). ZeroGPU gives a Space limited time to start: loading ~2.6 GB of models before serving made it report
14
+ "No @spaces.GPU function detected during startup" and take port 7860 for its error page (2.1.0, 2026-09-30).
15
+ - Gradio's server-side rendering is off: its Node proxy would also want port 7860.
16
  - Each model times itself inside the GPU call, so model timings stay comparable; the GPU hand-off only adds to the
17
  round trip.
18
  @spaces.GPU does nothing on non-ZeroGPU hardware, so this file also runs on a CPU Space.
 
23
 
24
  import json
25
  import os
26
+ import threading
27
  from pathlib import Path
28
 
29
  HERE = Path(__file__).resolve().parent
 
92
  return demo
93
 
94
 
95
+ served = gr.mount_gradio_app(app, build_blocks(), path=GRADIO_PATH, ssr_mode=False)
96
+
97
+
98
  def main() -> None:
99
+ # Serve at once; load the models in the background (see the note above). The loader runs only once, even if the
100
+ # lab's own startup hook also starts it.
101
+ threading.Thread(target=load_all, name="model-loader", daemon=True).start()
102
  uvicorn.run(served, host=os.getenv("GRADIO_SERVER_NAME") or "0.0.0.0",
103
  port=int(os.getenv("GRADIO_SERVER_PORT") or os.getenv("PORT") or 7860))
104
 
app/main.py CHANGED
@@ -27,7 +27,7 @@ MAX_BODY_BYTES = int(os.getenv("MAX_BODY_BYTES", str(256 * 1024)))
27
  DECIDE_SLOTS = Gate(int(os.getenv("MAX_PENDING_DECIDES", "4"))) # DL-SA-004
28
 
29
  # DL-SA-007: no /docs, /redoc or /openapi.json
30
- VERSION = "2.1.0"
31
  PAGE = stamp_assets((STATIC / "index.html").read_text(encoding="utf-8"), VERSION)
32
 
33
 
 
27
  DECIDE_SLOTS = Gate(int(os.getenv("MAX_PENDING_DECIDES", "4"))) # DL-SA-004
28
 
29
  # DL-SA-007: no /docs, /redoc or /openapi.json
30
+ VERSION = "2.1.1"
31
  PAGE = stamp_assets((STATIC / "index.html").read_text(encoding="utf-8"), VERSION)
32
 
33
 
space.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DecisionLab on a Hugging Face Space (Gradio SDK, ZeroGPU hardware).
2
+
3
+ The Space runs this file. It serves the SAME FastAPI app as the container, so the lab page at "/" (HTML, CSS and
4
+ JavaScript) and /api/* are the container's. A Gradio app is mounted beside it at /gradio: a small form and a "decide"
5
+ API endpoint for gradio_client, both running the same decision code (app.main.run_decision).
6
+
7
+ ZeroGPU (Hugging Face docs, checked 2026-09-28):
8
+ - `import spaces` comes first: it patches torch so CUDA looks available everywhere; outside @spaces.GPU a CUDA
9
+ emulation mode lets models be moved to "cuda" without a real GPU. A real GPU exists only inside @spaces.GPU calls.
10
+ - Decorated calls run apart from the main process, so all models are loaded BEFORE the server starts, and only the
11
+ model-running step is decorated. The request checks and the concurrency limit stay in the main process.
12
+ - Each model times itself inside the GPU call, so model timings stay comparable; the GPU hand-off only adds to the
13
+ round trip.
14
+ @spaces.GPU does nothing on non-ZeroGPU hardware, so this file also runs on a CPU Space.
15
+
16
+ No token auth anywhere (operator ruling 2026-09-28): on a public Space, anyone can use the page and the API.
17
+ """
18
+ import spaces # noqa: I001 -- must be the first import (patches torch for ZeroGPU)
19
+
20
+ import json
21
+ import os
22
+ from pathlib import Path
23
+
24
+ HERE = Path(__file__).resolve().parent
25
+ os.environ.setdefault("MODELS_DIR", str(HERE / "models")) # the Space's own models/ folder
26
+ os.environ.setdefault("DLAB_WARMUP", "0") # no real GPU outside @spaces.GPU: no warm-up run
27
+
28
+ import gradio as gr # noqa: E402
29
+ import uvicorn # noqa: E402
30
+
31
+ from app import main as lab # noqa: E402
32
+ from app.main import VERSION, Busy, app, run_decision # noqa: E402
33
+ from app.models import load_all # noqa: E402
34
+
35
+ GPU_SECONDS = int(os.getenv("GPU_SECONDS", "60")) # longest a single decision may hold the GPU
36
+
37
+
38
+ @spaces.GPU(duration=GPU_SECONDS)
39
+ def run_models_on_gpu(state, questions, keys):
40
+ return lab.run_models(state, questions, keys)
41
+
42
+
43
+ lab.RUN_MODELS = run_models_on_gpu
44
+
45
+
46
+ GRADIO_PATH = "/gradio"
47
+ EXAMPLE_STATE = "I was charged twice for March. Refund the duplicate or I cancel."
48
+ EXAMPLE_QUESTIONS = json.dumps({
49
+ "dept": {"type": "choice", "instructions": "Which team?",
50
+ "criteria": {"billing": "Payments and refunds", "tech": "Bugs and errors"}},
51
+ "churn": {"type": "noul", "instructions": "The customer threatens to leave"},
52
+ }, indent=2)
53
+
54
+
55
+ def decide(state: str, questions_json: str, models: str = "") -> dict:
56
+ """Run one decision. state: text, or a JSON object. questions_json: the questions as JSON.
57
+ models: comma-separated model keys (see /api/status); empty runs every model."""
58
+ try:
59
+ questions = json.loads(questions_json)
60
+ except ValueError as exc:
61
+ raise gr.Error(f"Questions must be valid JSON: {exc}") from None
62
+ if not isinstance(questions, dict):
63
+ raise gr.Error("Questions must be a JSON object: {name: {type, instructions, criteria}}.")
64
+ text = (state or "").strip()
65
+ try:
66
+ parsed = json.loads(text) if text.startswith("{") else text
67
+ except ValueError:
68
+ parsed = text
69
+ keys = [k.strip() for k in (models or "").split(",") if k.strip()] or None
70
+ try:
71
+ return run_decision(parsed, questions, keys)
72
+ except (ValueError, Busy) as exc:
73
+ raise gr.Error(str(exc)) from None
74
+
75
+
76
+ def build_blocks() -> gr.Blocks:
77
+ with gr.Blocks(title="DecisionLab API") as demo:
78
+ gr.Markdown(f"# DecisionLab {VERSION}: Gradio API\n"
79
+ "The full lab is at the [main page](/). This form and `gradio_client` run the same decision code.")
80
+ with gr.Row():
81
+ state = gr.Textbox(label="State (text or JSON)", lines=8, value=EXAMPLE_STATE)
82
+ questions = gr.Code(label="Questions (JSON)", language="json", value=EXAMPLE_QUESTIONS)
83
+ models = gr.Textbox(label="Models (comma-separated keys from /api/status; empty = every model)", value="")
84
+ run = gr.Button("Run", variant="primary")
85
+ out = gr.JSON(label="Result")
86
+ run.click(decide, [state, questions, models], out, api_name="decide")
87
+ return demo
88
+
89
+
90
+ def main() -> None:
91
+ load_all() # every model is loaded before the first request (see the note above)
92
+ served = gr.mount_gradio_app(app, build_blocks(), path=GRADIO_PATH)
93
+ uvicorn.run(served, host=os.getenv("GRADIO_SERVER_NAME") or "0.0.0.0",
94
+ port=int(os.getenv("GRADIO_SERVER_PORT") or os.getenv("PORT") or 7860))
95
+
96
+
97
+ if __name__ == "__main__":
98
+ main()
tests/test_space.py CHANGED
@@ -32,7 +32,7 @@ def pins(path):
32
  class SpacePackagingTest(unittest.TestCase):
33
  def test_front_matter_declares_a_gradio_space(self):
34
  fm = front_matter()
35
- self.assertEqual((fm["sdk"], fm["app_file"]), ("gradio", "space.py"))
36
  self.assertIn(fm["python_version"], ("3.10", "3.12")) # the Pythons ZeroGPU supports
37
  self.assertTrue((ROOT / fm["app_file"]).is_file())
38
  self.assertEqual(fm["title"], "DecisionLab")
@@ -56,7 +56,7 @@ class SpacePackagingTest(unittest.TestCase):
56
 
57
  def test_spaces_is_imported_before_anything_else(self):
58
  import ast
59
- tree = ast.parse((ROOT / "space.py").read_text(encoding="utf-8"))
60
  imports = [n for n in tree.body if isinstance(n, (ast.Import, ast.ImportFrom)) and
61
  not (isinstance(n, ast.ImportFrom) and n.module == "__future__")]
62
  first = imports[0]
@@ -80,6 +80,16 @@ class SpacePackagingTest(unittest.TestCase):
80
  with self.subTest(pattern=pattern):
81
  self.assertRegex(attrs, rf"(?m)^{re.escape(pattern)} filter=lfs diff=lfs merge=lfs -text$")
82
 
 
 
 
 
 
 
 
 
 
 
83
  def test_models_folder_exists_for_local_models(self):
84
  self.assertTrue((ROOT / "models").is_dir())
85
 
 
32
  class SpacePackagingTest(unittest.TestCase):
33
  def test_front_matter_declares_a_gradio_space(self):
34
  fm = front_matter()
35
+ self.assertEqual((fm["sdk"], fm["app_file"]), ("gradio", "app.py")) # Hugging Face's default name
36
  self.assertIn(fm["python_version"], ("3.10", "3.12")) # the Pythons ZeroGPU supports
37
  self.assertTrue((ROOT / fm["app_file"]).is_file())
38
  self.assertEqual(fm["title"], "DecisionLab")
 
56
 
57
  def test_spaces_is_imported_before_anything_else(self):
58
  import ast
59
+ tree = ast.parse((ROOT / "app.py").read_text(encoding="utf-8"))
60
  imports = [n for n in tree.body if isinstance(n, (ast.Import, ast.ImportFrom)) and
61
  not (isinstance(n, ast.ImportFrom) and n.module == "__future__")]
62
  first = imports[0]
 
80
  with self.subTest(pattern=pattern):
81
  self.assertRegex(attrs, rf"(?m)^{re.escape(pattern)} filter=lfs diff=lfs merge=lfs -text$")
82
 
83
+ def test_no_leftover_space_py(self):
84
+ self.assertFalse((ROOT / "space.py").exists())
85
+
86
+ def test_app_py_imports_the_app_package_not_itself(self):
87
+ import subprocess, sys
88
+ r = subprocess.run([sys.executable, "-c", "import sys; sys.path.insert(0, '.'); import app.validation; print(app.validation.__file__)"],
89
+ cwd=ROOT, capture_output=True, text=True)
90
+ self.assertEqual(r.returncode, 0, r.stderr)
91
+ self.assertIn("app", r.stdout)
92
+
93
  def test_models_folder_exists_for_local_models(self):
94
  self.assertTrue((ROOT / "models").is_dir())
95