feat: ZeroGPU startup fix + FMS scoring sheet download + judge prompt alignment

#14
CLAUDE.md CHANGED
@@ -170,7 +170,7 @@ Track the running sum in `MODEL_BUDGET.md`. The two Qwen3-VL-8B models share a b
170
 
171
  The UI uses **Gradio `gr.Blocks`** with custom CSS/theme (`formscout/ui/theme.py`). Custom Svelte components for score dial, asymmetry bars, rubric drawer are planned for Phase 4. Use `gradio-svelte-expert` agent for Svelte component work.
172
 
173
- - ZeroGPU: wrap heavy inference (`Pose2DAgent.run`, `Body3DAgent.run`) in `@spaces.GPU` before deploying to Spaces.
174
  - Verify Gradio APIs against current docs before use — pin exact versions in `requirements.txt`.
175
 
176
  ## Build phases
 
170
 
171
  The UI uses **Gradio `gr.Blocks`** with custom CSS/theme (`formscout/ui/theme.py`). Custom Svelte components for score dial, asymmetry bars, rubric drawer are planned for Phase 4. Use `gradio-svelte-expert` agent for Svelte component work.
172
 
173
+ - ZeroGPU: `app.py`'s `process_video` (the Start Analysis handler) is decorated with `@spaces.GPU` (via the `gpu_task` shim, no-op off-Space) so one GPU window wraps the whole pipeline — pose, optional 3D, and the judge. **ZeroGPU aborts startup with "No @spaces.GPU function detected" unless a decorated function exists at import time**, so the decorator must stay at module level on a top-level function, not buried behind a lazy import. Window length is `config.ZEROGPU_DURATION` (default 120s, `FORMSCOUT_ZEROGPU_DURATION`).
174
  - Verify Gradio APIs against current docs before use — pin exact versions in `requirements.txt`.
175
 
176
  ## Build phases
app.py CHANGED
@@ -20,6 +20,23 @@ from formscout import config
20
  from formscout import session as session_mod
21
  from formscout.startup import ensure_checkpoints
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  ensure_checkpoints()
24
 
25
 
@@ -50,9 +67,14 @@ SCORE_DESCRIPTIONS = {
50
 
51
  # ─── Processing ──────────────────────────────────────────────────────────────
52
 
 
53
  def process_video(video_path: str, test_name: str, side: str, model_key: str,
54
  layers: list[str], session_state):
55
- """Analyse one clip and accumulate it into the screening session."""
 
 
 
 
56
  if not video_path:
57
  return (
58
  session_state, _render_empty_state(), "Upload a video to begin analysis.",
@@ -277,11 +299,11 @@ def _finish_session(session_state):
277
  """Build the composite report + PDF for the whole session."""
278
  if not session_state or not session_state.entries:
279
  return ("⚠️ No clips analysed yet — analyse at least one clip first.",
280
- None, None)
281
 
282
  report, pdf_path = session_mod.finish_session(session_state)
283
  if report is None:
284
- return ("⚠️ Nothing to report.", None, None)
285
 
286
  if report.composite is not None:
287
  summary = [f"## Composite: {report.composite} / 21"]
@@ -304,7 +326,7 @@ def _finish_session(session_state):
304
 
305
  md_path = os.path.join(session_state.session_dir, "analysis.md")
306
  md_out = md_path if os.path.exists(md_path) else None
307
- return "\n".join(summary), pdf_path, md_out
308
 
309
 
310
  # ─── App Builder ─────────────────────────────────────────────────────────────
@@ -407,7 +429,8 @@ def build_app() -> gr.Blocks:
407
  with gr.TabItem("🗂️ Session"):
408
  session_table = gr.Markdown("*No clips analysed yet.*")
409
  finish_summary = gr.Markdown("")
410
- pdf_file = gr.File(label="Screening Report (PDF)", visible=True)
 
411
  md_file = gr.File(label="Analysis Log (Markdown)", visible=True)
412
 
413
  # Footer safety banner
@@ -450,7 +473,7 @@ def build_app() -> gr.Blocks:
450
  finish_btn.click(
451
  fn=_finish_session,
452
  inputs=[session_state],
453
- outputs=[finish_summary, pdf_file, md_file],
454
  )
455
 
456
  return app
 
20
  from formscout import session as session_mod
21
  from formscout.startup import ensure_checkpoints
22
 
23
+ # ─── ZeroGPU ──────────────────────────────────────────────────────────────────
24
+ # On an HF Spaces ZeroGPU runtime the heavy analysis MUST run inside an
25
+ # @spaces.GPU function, and that function must already exist at import time:
26
+ # ZeroGPU scans for one during startup and aborts the Space with
27
+ # "No @spaces.GPU function detected during startup" if none is registered.
28
+ # We decorate process_video (the Start Analysis handler) so a single GPU window
29
+ # covers the whole pipeline — pose, optional 3D, and the Qwen3-VL judge. Off a
30
+ # ZeroGPU Space the `spaces` package is absent (or its decorator is effect-free),
31
+ # so local runs and CPU Spaces are unaffected.
32
+ try:
33
+ import spaces
34
+
35
+ gpu_task = spaces.GPU(duration=config.ZEROGPU_DURATION)
36
+ except Exception: # local dev / non-ZeroGPU — decorate as a no-op
37
+ def gpu_task(fn):
38
+ return fn
39
+
40
  ensure_checkpoints()
41
 
42
 
 
67
 
68
  # ─── Processing ──────────────────────────────────────────────────────────────
69
 
70
+ @gpu_task
71
  def process_video(video_path: str, test_name: str, side: str, model_key: str,
72
  layers: list[str], session_state):
73
+ """Analyse one clip and accumulate it into the screening session.
74
+
75
+ Decorated with @spaces.GPU on ZeroGPU: the whole pipeline (pose, optional 3D,
76
+ Qwen3-VL judge) runs inside one GPU window. The decorator is a no-op off-Space.
77
+ """
78
  if not video_path:
79
  return (
80
  session_state, _render_empty_state(), "Upload a video to begin analysis.",
 
299
  """Build the composite report + PDF for the whole session."""
300
  if not session_state or not session_state.entries:
301
  return ("⚠️ No clips analysed yet — analyse at least one clip first.",
302
+ None, None, None)
303
 
304
  report, pdf_path = session_mod.finish_session(session_state)
305
  if report is None:
306
+ return ("⚠️ Nothing to report.", None, None, None)
307
 
308
  if report.composite is not None:
309
  summary = [f"## Composite: {report.composite} / 21"]
 
326
 
327
  md_path = os.path.join(session_state.session_dir, "analysis.md")
328
  md_out = md_path if os.path.exists(md_path) else None
329
+ return "\n".join(summary), pdf_path, md_out, report.scoresheet_path
330
 
331
 
332
  # ─── App Builder ─────────────────────────────────────────────────────────────
 
429
  with gr.TabItem("🗂️ Session"):
430
  session_table = gr.Markdown("*No clips analysed yet.*")
431
  finish_summary = gr.Markdown("")
432
+ scoresheet_file = gr.File(label="FMS Scoring Sheet (PDF)", visible=True)
433
+ pdf_file = gr.File(label="Detailed Screening Report (PDF)", visible=True)
434
  md_file = gr.File(label="Analysis Log (Markdown)", visible=True)
435
 
436
  # Footer safety banner
 
473
  finish_btn.click(
474
  fn=_finish_session,
475
  inputs=[session_state],
476
+ outputs=[finish_summary, pdf_file, md_file, scoresheet_file],
477
  )
478
 
479
  return app
formscout/agents/prompts/c2_judge.md CHANGED
@@ -14,13 +14,14 @@ FMS scoring scale (apply per side; the test score is the LOWER side):
14
  - 1: the person cannot perform the movement pattern even with the allowed regression.
15
  - 0: PAIN. You CANNOT see pain. Never assign 0 yourself.
16
 
17
- Per-test criteria to weigh (use the features as primary evidence):
18
- - deep_squat (3): femur below horizontal, torso roughly parallel to the tibia, knees tracking over the feet, dowel staying aligned over the feet, heels flat. (2): the same achieved only with heels elevated. (1): criteria unmet even with heels elevated.
19
- - hurdle_step / inline_lunge: minimal sway/loss of balance, knee/hip/ankle alignment maintained, no contact with the hurdle, dowel/posture stable. Compensation -> 2; failure to complete -> 1. Report L/R asymmetry.
20
- - shoulder_mobility: judge by the normalized inter-fist distance bands (per side). Report asymmetry.
21
- - active_slr: judge the raised-leg hip-flexion angle relative to the standard band; the down leg stays flat.
22
- - trunk_stability_pushup: the body must move as one rigid unit (low segment-angle variance through the press); sag/lag or needing the easier hand position -> 2.
23
- - rotary_stability: smooth contralateral (or the allowed unilateral) coordination with a stable trunk; loss of coordination/balance -> lower.
 
24
 
25
  Hard safety rules:
26
  - If there is any clearing-test context, visible pain, grimacing, or an aborted rep, set needs_human=true and score=null. Do not score it.
 
14
  - 1: the person cannot perform the movement pattern even with the allowed regression.
15
  - 0: PAIN. You CANNOT see pain. Never assign 0 yourself.
16
 
17
+ Per-test criteria to weigh (official FMS grading; use the features as primary evidence):
18
+ - deep_squat. (3): upper torso parallel with the tibia or toward vertical, femur below horizontal, knees aligned over the feet, dowel aligned over the feet, heels flat. (2): all of the above achieved only with the heels elevated (on a board). (1): tibia and torso not parallel, femur not below horizontal, knees not aligned over the feet, or lumbar flexion noted.
19
+ - hurdle_step (bilateral, score each side). (3): hips/knees/ankles stay aligned in the sagittal plane, minimal lumbar-spine movement, dowel and hurdle remain parallel. (2): alignment lost between hips/knees/ankles, lumbar movement, or dowel and hurdle not parallel. (1): contact between foot and hurdle (cord not cleared) or any loss of balance. Report L/R asymmetry.
20
+ - inline_lunge (bilateral, score each side). (3): minimal-to-no torso movement, feet remain in the sagittal plane of the board, dowel stays vertical and in contact, rear knee touches behind the heel of the front foot. (2): torso movement, feet/dowel leave the sagittal plane, or rear knee does not touch behind the front heel. (1): loss of balance (steps off the board) or inability to complete the pattern. Report L/R asymmetry.
21
+ - shoulder_mobility (bilateral, score each side). (3): fists within one hand length. (2): fists within one-and-a-half hand lengths. (1): fists greater than one-and-a-half hand lengths apart. Judge by the normalized inter-fist distance bands; report asymmetry.
22
+ - active_slr (bilateral, score each side). (3): the raised-ankle malleolus reaches between mid-thigh and the ASIS of the down leg. (2): the malleolus reaches between mid-thigh and the joint line/mid-patella. (1): the malleolus stays below the joint line. The non-moving (down) limb stays flat in neutral. Judge by the raised-leg hip-flexion angle relative to the standard band.
23
+ - trunk_stability_pushup. The body must lift as one rigid unit with no lag/sag in the spine (low segment-angle variance through the press). (3): one clean rep from the hardest start hand position (men: thumbs at forehead; women: thumbs at chin). (2): one clean rep only from the easier position (men: chin; women: clavicle). (1): unable to perform a rep even from the easier position. If sex is unknown, judge primarily on the rigid-unit press and note the assumption.
24
+ - rotary_stability (score each side). (3): a correct UNILATERAL (same-side) rep — same-side elbow and knee touch over the board without touching down, trunk parallel to the board. (2): a correct DIAGONAL (contralateral, opposite elbow/knee) rep over the board. (1): inability to perform a diagonal repetition. Note: a clean unilateral rep outranks a diagonal one.
25
 
26
  Hard safety rules:
27
  - If there is any clearing-test context, visible pain, grimacing, or an aborted rep, set needs_human=true and score=null. Do not score it.
formscout/agents/scoresheet.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ScoreSheetAgent — renders a one-page FMS scoring sheet from a ReportResult.
3
+
4
+ A FormScout-styled scorecard (NOT a reproduction of the copyrighted FMS chart):
5
+ all seven screens in their canonical order, each with its 0–3 score, L/R columns
6
+ for the bilateral tests, the composite out of 21, and the standard FMS risk
7
+ interpretation (>=14 reduced injury risk; <=13 increased risk). It is the
8
+ single-page companion download to the detailed PdfReportAgent report.
9
+
10
+ Input: ReportResult, session_dir (str), optional meta dict (name/date/etc.)
11
+ Output: path to the written PDF (str), or None on failure (e.g. reportlab missing).
12
+ Failure: returns None, never raises.
13
+ Params: 0 (pure rendering — no model).
14
+ License: n/a.
15
+ Gated: no.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import datetime as _dt
20
+ import logging
21
+ import os
22
+
23
+ from formscout.types import ReportResult
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ DISCLAIMER = "Screening aid — not a diagnosis. Pain or clearing tests require a clinician."
28
+
29
+ # Canonical FMS order; (key, display label, is_bilateral). Post-MVP additions
30
+ # (clearing tests, adjunct measures) slot in here without touching the renderer.
31
+ CANONICAL_TESTS = [
32
+ ("deep_squat", "1. Deep Squat", False),
33
+ ("hurdle_step", "2. Hurdle Step", True),
34
+ ("inline_lunge", "3. In-Line Lunge", True),
35
+ ("shoulder_mobility", "4. Shoulder Mobility", True),
36
+ ("active_slr", "5. Active Straight-Leg Raise", True),
37
+ ("trunk_stability_pushup", "6. Trunk Stability Push-Up", False),
38
+ ("rotary_stability", "7. Rotary Stability", False),
39
+ ]
40
+
41
+
42
+ def _cell(score, needs_human: bool) -> str:
43
+ """Render one score cell: a number, a review flag, or a blank placeholder."""
44
+ if needs_human:
45
+ return "Review"
46
+ if score is None:
47
+ return "—"
48
+ return str(score)
49
+
50
+
51
+ class ScoreSheetAgent:
52
+ """Assembles the single-page FMS scoring sheet via ReportLab."""
53
+
54
+ def run(self, report: ReportResult, session_dir: str,
55
+ meta: dict | None = None) -> str | None:
56
+ try:
57
+ from reportlab.lib import colors
58
+ from reportlab.lib.pagesizes import LETTER
59
+ from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
60
+ from reportlab.lib.units import inch
61
+ from reportlab.platypus import (
62
+ Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle,
63
+ )
64
+ except Exception as e:
65
+ logger.warning("reportlab unavailable: %s", e)
66
+ return None
67
+
68
+ out_path = os.path.join(session_dir, "formscout_fms_scoresheet.pdf")
69
+ try:
70
+ styles = getSampleStyleSheet()
71
+ ink = colors.HexColor("#243a34")
72
+ banner = ParagraphStyle(
73
+ "banner", parent=styles["Normal"], fontSize=9, textColor=colors.white,
74
+ backColor=colors.HexColor("#cf922a"), alignment=1, borderPadding=6,
75
+ spaceAfter=12,
76
+ )
77
+ small = ParagraphStyle("small", parent=styles["Normal"], fontSize=8,
78
+ textColor=ink)
79
+
80
+ # Index report data by test name.
81
+ by_test = {t["test_name"]: t for t in (report.per_test or [])}
82
+ asym = {a["test"]: a for a in (report.asymmetries or [])}
83
+
84
+ story = []
85
+ story.append(Paragraph(f"<b>&#9888; {DISCLAIMER}</b>", banner))
86
+ story.append(Paragraph("FormScout — FMS Scoring Sheet", styles["Title"]))
87
+
88
+ # Optional athlete/meta header (auto-filled date by default).
89
+ meta = dict(meta or {})
90
+ meta.setdefault("date", _dt.date.today().isoformat())
91
+ meta_bits = [f"<b>{k.title()}:</b> {v}" for k, v in meta.items() if v]
92
+ if meta_bits:
93
+ story.append(Paragraph(" &nbsp;&nbsp; ".join(meta_bits), small))
94
+ story.append(Spacer(1, 0.15 * inch))
95
+
96
+ # Scores table: # / Test, Left, Right, Score, Status.
97
+ header = ["Test", "Left", "Right", "Score", "Status"]
98
+ rows = [header]
99
+ for key, label, bilateral in CANONICAL_TESTS:
100
+ t = by_test.get(key)
101
+ needs_human = bool(t and t.get("needs_human"))
102
+ score = t.get("score") if t else None
103
+ if not t:
104
+ status = "Not screened"
105
+ elif needs_human:
106
+ status = "Clinician review"
107
+ elif score is None:
108
+ status = "Unscored"
109
+ else:
110
+ status = "Scored"
111
+
112
+ if bilateral and key in asym:
113
+ left = _cell(asym[key]["left_score"], needs_human)
114
+ right = _cell(asym[key]["right_score"], needs_human)
115
+ elif bilateral:
116
+ # Only one side (or none) was screened — no asymmetry pair.
117
+ left = right = _cell(score, needs_human) if t else "—"
118
+ else:
119
+ left = right = "" # n/a for non-bilateral tests
120
+ rows.append([label, left, right, _cell(score, needs_human), status])
121
+
122
+ comp = (f"{report.composite}" if report.composite is not None else "—")
123
+ rows.append(["Total Score (Tests 1–7)", "", "", f"{comp} / 21",
124
+ "Complete" if report.composite is not None else "Incomplete"])
125
+
126
+ tbl = Table(rows, colWidths=[3.0 * inch, 0.8 * inch, 0.8 * inch,
127
+ 0.9 * inch, 1.4 * inch])
128
+ tbl.setStyle(TableStyle([
129
+ ("FONTSIZE", (0, 0), (-1, -1), 9),
130
+ ("TEXTCOLOR", (0, 0), (-1, -1), ink),
131
+ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1f6e6e")),
132
+ ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
133
+ ("BACKGROUND", (0, -1), (-1, -1), colors.HexColor("#f7eedd")),
134
+ ("FONTNAME", (0, -1), (-1, -1), "Helvetica-Bold"),
135
+ ("ALIGN", (1, 0), (-1, -1), "CENTER"),
136
+ ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#c9d6cf")),
137
+ ("ROWBACKGROUNDS", (0, 1), (-1, -2),
138
+ [colors.white, colors.HexColor("#f3f7f5")]),
139
+ ("TOPPADDING", (0, 0), (-1, -1), 5),
140
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
141
+ ]))
142
+ story.append(tbl)
143
+ story.append(Spacer(1, 0.18 * inch))
144
+
145
+ # Risk interpretation — only meaningful when the screen is complete.
146
+ if report.composite is not None:
147
+ if report.composite >= 14:
148
+ risk = (f"Composite {report.composite}/21 &#8805; 14 — associated in the "
149
+ "FMS literature with <b>reduced</b> injury risk during physical "
150
+ "activity.")
151
+ else:
152
+ risk = (f"Composite {report.composite}/21 &#8804; 13 — associated in the "
153
+ "FMS literature with <b>increased</b> injury risk during physical "
154
+ "activity.")
155
+ else:
156
+ risk = ("Composite is incomplete — all seven screens must be scored before the "
157
+ "14-point risk threshold applies.")
158
+ story.append(Paragraph(risk, small))
159
+
160
+ if report.asymmetries:
161
+ story.append(Spacer(1, 0.1 * inch))
162
+ bits = ", ".join(
163
+ f"{a['test'].replace('_', ' ').title()} (L{a['left_score']}/R{a['right_score']},"
164
+ f" &#916;{a['delta']})" for a in report.asymmetries)
165
+ story.append(Paragraph(f"<b>Left/right asymmetries:</b> {bits}", small))
166
+
167
+ story.append(Spacer(1, 0.25 * inch))
168
+ story.append(Paragraph(f"<b>&#9888; {DISCLAIMER}</b>", banner))
169
+
170
+ doc = SimpleDocTemplate(out_path, pagesize=LETTER,
171
+ topMargin=0.6 * inch, bottomMargin=0.6 * inch)
172
+ doc.build(story)
173
+ return out_path
174
+ except Exception as e:
175
+ logger.warning("scoresheet build failed: %s", e)
176
+ return None
formscout/config.py CHANGED
@@ -147,14 +147,40 @@ LLAMA_CPP_PORT_EMBED = 8081
147
  # ─── Judge backend selection ────────────────────────────────────────────────
148
  # "llama_cpp" — local llama-server (default for local dev; works perfectly)
149
  # "transformers"— in-process Qwen3-VL via transformers, GPU on HF Spaces (ZeroGPU)
150
- # "auto" — transformers on a Space (SPACE_ID set), llama_cpp locally
151
  JUDGE_BACKEND = os.environ.get("FORMSCOUT_JUDGE_BACKEND", "auto")
152
  JUDGE_HF_MODEL = os.environ.get("FORMSCOUT_JUDGE_HF_MODEL", "Qwen/Qwen3-VL-8B-Instruct")
153
  ON_HF_SPACE = bool(os.environ.get("SPACE_ID"))
154
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
 
156
  def resolve_judge_backend() -> str:
157
- """Resolve the effective judge backend from JUDGE_BACKEND + environment."""
 
 
 
 
 
 
158
  if JUDGE_BACKEND in ("llama_cpp", "transformers"):
159
  return JUDGE_BACKEND
160
- return "transformers" if ON_HF_SPACE else "llama_cpp"
 
147
  # ─── Judge backend selection ────────────────────────────────────────────────
148
  # "llama_cpp" — local llama-server (default for local dev; works perfectly)
149
  # "transformers"— in-process Qwen3-VL via transformers, GPU on HF Spaces (ZeroGPU)
150
+ # "auto" — transformers ONLY on a GPU/ZeroGPU Space, else llama_cpp
151
  JUDGE_BACKEND = os.environ.get("FORMSCOUT_JUDGE_BACKEND", "auto")
152
  JUDGE_HF_MODEL = os.environ.get("FORMSCOUT_JUDGE_HF_MODEL", "Qwen/Qwen3-VL-8B-Instruct")
153
  ON_HF_SPACE = bool(os.environ.get("SPACE_ID"))
154
 
155
+ # Seconds the ZeroGPU window stays allocated per analysis. One window wraps the
156
+ # whole pipeline (pose, optional 3D, Qwen3-VL judge), so size it for the slowest
157
+ # clip; raise via env for long videos. Only effective on a ZeroGPU Space.
158
+ ZEROGPU_DURATION = int(os.environ.get("FORMSCOUT_ZEROGPU_DURATION", "120"))
159
+
160
+
161
+ def has_gpu() -> bool:
162
+ """True on a ZeroGPU Space (env flag) or when CUDA is actually present.
163
+
164
+ ZeroGPU exposes no CUDA outside @spaces.GPU, so it is detected via the
165
+ SPACES_ZERO_GPU env flag; ordinary GPU Spaces report via torch.cuda.
166
+ """
167
+ if os.environ.get("SPACES_ZERO_GPU") or os.environ.get("ZERO_GPU"):
168
+ return True
169
+ try:
170
+ import torch
171
+ return bool(torch.cuda.is_available())
172
+ except Exception:
173
+ return False
174
+
175
 
176
  def resolve_judge_backend() -> str:
177
+ """Resolve the effective judge backend from JUDGE_BACKEND + environment.
178
+
179
+ `auto` only engages the heavy in-process transformers model when a GPU is
180
+ actually available — a CPU-only Space stays on llama_cpp (which is then
181
+ unreachable, so the Judge falls back to the fast rubric instead of trying to
182
+ run a 17 GB model on CPU).
183
+ """
184
  if JUDGE_BACKEND in ("llama_cpp", "transformers"):
185
  return JUDGE_BACKEND
186
+ return "transformers" if (ON_HF_SPACE and has_gpu()) else "llama_cpp"
formscout/serving/transformers_vlm.py CHANGED
@@ -39,19 +39,27 @@ except Exception: # pragma: no cover
39
  return fn
40
 
41
 
42
- @_gpu
43
- def _generate(model_id: str, prompt: str, pil_images: list, max_tokens: int,
44
- temperature: float) -> str: # pragma: no cover - needs GPU + model
45
- """Load (cached) and run Qwen3-VL; returns the raw decoded string."""
46
- import torch
47
- from transformers import AutoModelForImageTextToText, AutoProcessor
48
-
49
  if "model" not in _CACHE:
 
 
50
  _CACHE["processor"] = AutoProcessor.from_pretrained(model_id)
51
  _CACHE["model"] = AutoModelForImageTextToText.from_pretrained(
52
- model_id, torch_dtype="auto", device_map="auto",
53
  )
 
 
 
 
 
 
 
 
 
54
  processor, model = _CACHE["processor"], _CACHE["model"]
 
55
 
56
  content = [{"type": "image", "image": im} for im in pil_images]
57
  content.append({"type": "text", "text": prompt})
@@ -60,7 +68,7 @@ def _generate(model_id: str, prompt: str, pil_images: list, max_tokens: int,
60
  inputs = processor.apply_chat_template(
61
  messages, tokenize=True, add_generation_prompt=True,
62
  return_tensors="pt", return_dict=True,
63
- ).to(model.device)
64
 
65
  with torch.no_grad():
66
  out = model.generate(
@@ -90,7 +98,8 @@ class TransformersVLMClient:
90
  stop: list[str] | None = None) -> dict:
91
  try:
92
  pil_images = self._decode_images(images)
93
- text = _generate(self.model_id, prompt, pil_images, max_tokens, temperature)
 
94
  return LlamaCppClient._parse_json_reply(text)
95
  except Exception as e: # pragma: no cover - needs GPU + model
96
  logger.warning("transformers VLM failed (%s) — falling back to rubric", e)
 
39
  return fn
40
 
41
 
42
+ def _ensure_loaded(model_id: str): # pragma: no cover - downloads ~16 GB
43
+ """Load processor + model to CPU once (cached). Kept OUT of the GPU window so
44
+ the 17 GB download/load does not eat ZeroGPU time."""
 
 
 
 
45
  if "model" not in _CACHE:
46
+ import torch
47
+ from transformers import AutoModelForImageTextToText, AutoProcessor
48
  _CACHE["processor"] = AutoProcessor.from_pretrained(model_id)
49
  _CACHE["model"] = AutoModelForImageTextToText.from_pretrained(
50
+ model_id, torch_dtype=torch.bfloat16,
51
  )
52
+ return _CACHE["processor"], _CACHE["model"]
53
+
54
+
55
+ @_gpu
56
+ def _generate(prompt: str, pil_images: list, max_tokens: int,
57
+ temperature: float) -> str: # pragma: no cover - needs GPU + model
58
+ """Move the cached model to CUDA and run Qwen3-VL (ZeroGPU window)."""
59
+ import torch
60
+
61
  processor, model = _CACHE["processor"], _CACHE["model"]
62
+ model.to("cuda")
63
 
64
  content = [{"type": "image", "image": im} for im in pil_images]
65
  content.append({"type": "text", "text": prompt})
 
68
  inputs = processor.apply_chat_template(
69
  messages, tokenize=True, add_generation_prompt=True,
70
  return_tensors="pt", return_dict=True,
71
+ ).to("cuda")
72
 
73
  with torch.no_grad():
74
  out = model.generate(
 
98
  stop: list[str] | None = None) -> dict:
99
  try:
100
  pil_images = self._decode_images(images)
101
+ _ensure_loaded(self.model_id) # CPU load (no GPU time)
102
+ text = _generate(prompt, pil_images, max_tokens, temperature)
103
  return LlamaCppClient._parse_json_reply(text)
104
  except Exception as e: # pragma: no cover - needs GPU + model
105
  logger.warning("transformers VLM failed (%s) — falling back to rubric", e)
formscout/session.py CHANGED
@@ -179,7 +179,14 @@ def finish_session(session) -> tuple[ReportResult | None, str | None]:
179
  except Exception as e:
180
  logger.warning("pdf generation failed: %s", e)
181
 
182
- report = replace(report, pdf_path=pdf_path)
 
 
 
 
 
 
 
183
  return report, pdf_path
184
 
185
 
 
179
  except Exception as e:
180
  logger.warning("pdf generation failed: %s", e)
181
 
182
+ scoresheet_path = None
183
+ try:
184
+ from formscout.agents.scoresheet import ScoreSheetAgent
185
+ scoresheet_path = ScoreSheetAgent().run(report, session.session_dir)
186
+ except Exception as e:
187
+ logger.warning("scoresheet generation failed: %s", e)
188
+
189
+ report = replace(report, pdf_path=pdf_path, scoresheet_path=scoresheet_path)
190
  return report, pdf_path
191
 
192
 
formscout/types.py CHANGED
@@ -6,7 +6,6 @@ Validate at every boundary — never accept raw dicts across agent boundaries.
6
  from __future__ import annotations
7
 
8
  from dataclasses import dataclass, field
9
- from typing import Any
10
 
11
 
12
  @dataclass(frozen=True)
@@ -139,6 +138,7 @@ class ReportResult:
139
  pdf_path: str | None
140
  low_confidence_flags: list
141
  disagreement_flags: list
 
142
  notes: str = ""
143
 
144
 
 
6
  from __future__ import annotations
7
 
8
  from dataclasses import dataclass, field
 
9
 
10
 
11
  @dataclass(frozen=True)
 
138
  pdf_path: str | None
139
  low_confidence_flags: list
140
  disagreement_flags: list
141
+ scoresheet_path: str | None = None # one-page FMS scoring sheet (separate download)
142
  notes: str = ""
143
 
144
 
formscout/ui/theme.py CHANGED
@@ -59,15 +59,20 @@ def formscout_theme() -> gr.Theme:
59
  button_secondary_background_fill="rgba(156, 188, 173, 0.55)",
60
  button_secondary_text_color=INK,
61
  # Inputs
62
- input_background_fill="rgba(255, 255, 255, 0.85)",
63
- input_background_fill_dark="rgba(255, 255, 255, 0.85)",
 
64
  input_border_color="rgba(43, 138, 138, 0.30)",
65
  input_border_color_focus="rgba(43, 138, 138, 0.75)",
 
 
 
 
 
66
  # Text
67
  body_text_color=INK,
68
  body_text_color_dark=INK,
69
  block_title_text_color=TEAL_DEEP,
70
- block_label_text_color=INK_MUTED,
71
  # Spacing
72
  block_padding="16px",
73
  layout_gap="16px",
 
59
  button_secondary_background_fill="rgba(156, 188, 173, 0.55)",
60
  button_secondary_text_color=INK,
61
  # Inputs
62
+ input_background_fill="rgba(255, 255, 255, 0.92)",
63
+ input_background_fill_dark="rgba(255, 255, 255, 0.92)",
64
+ input_background_fill_focus="rgba(255, 255, 255, 1.0)",
65
  input_border_color="rgba(43, 138, 138, 0.30)",
66
  input_border_color_focus="rgba(43, 138, 138, 0.75)",
67
+ # Labels — pin light in both modes so no dark dropdown header appears
68
+ block_label_background_fill="rgba(188, 211, 200, 0.55)",
69
+ block_label_background_fill_dark="rgba(188, 211, 200, 0.55)",
70
+ block_label_text_color=INK,
71
+ block_label_text_color_dark=INK,
72
  # Text
73
  body_text_color=INK,
74
  body_text_color_dark=INK,
75
  block_title_text_color=TEAL_DEEP,
 
76
  # Spacing
77
  block_padding="16px",
78
  layout_gap="16px",
scripts/hf_upload.sh CHANGED
@@ -21,8 +21,11 @@ set -euo pipefail
21
 
22
  cd "$(dirname "$0")/.."
23
 
24
- MODEL_REPO="silas-therapy/small-functional-movement-screening"
25
- SPACE_REPO="spaces/silas-therapy/small-functional-movement-screening"
 
 
 
26
  MSG="${1:-$(git log -1 --pretty=%s)}"
27
  LARGE_THRESHOLD="${FORMSCOUT_HF_LARGE_THRESHOLD:-500}"
28
 
@@ -76,22 +79,62 @@ if (( N_FILES == 0 )); then
76
  exit 1
77
  fi
78
 
 
 
 
79
  upload_repo() {
80
  local repo="$1"
 
81
  if (( N_FILES > LARGE_THRESHOLD )); then
82
  echo "── $repo: $N_FILES files > $LARGE_THRESHOLD, using upload-large-folder"
83
  echo " (resumable; commits directly to main — no PR, no custom message)"
84
  hf upload-large-folder "$repo" . "${EXCLUDES[@]}"
 
 
 
85
  else
86
- echo "── uploading to: $repo"
87
- hf upload "$repo" . . \
88
- "${EXCLUDES[@]}" \
89
- --create-pr \
90
- --commit-message="$MSG"
91
  fi
92
  }
93
 
94
- upload_repo "$MODEL_REPO"
95
- upload_repo "$SPACE_REPO"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
 
97
- echo "✓ done"
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
  cd "$(dirname "$0")/.."
23
 
24
+ REPO_NAME="small-functional-movement-screening"
25
+ BLADE_OWNER="${FORMSCOUT_HF_BLADE_OWNER:-BladeSzaSza}"
26
+ MODEL_REPO="silas-therapy/$REPO_NAME"
27
+ SPACE_REPO="spaces/silas-therapy/$REPO_NAME"
28
+ SPACE_BLADESZASZA_REPO="spaces/$BLADE_OWNER/$REPO_NAME"
29
  MSG="${1:-$(git log -1 --pretty=%s)}"
30
  LARGE_THRESHOLD="${FORMSCOUT_HF_LARGE_THRESHOLD:-500}"
31
 
 
79
  exit 1
80
  fi
81
 
82
+ # upload_repo <repo> [pr|direct]
83
+ # pr — open a PR (shared org repos; review before merge)
84
+ # direct — commit straight to main (repos you own; deploys immediately)
85
  upload_repo() {
86
  local repo="$1"
87
+ local mode="${2:-pr}"
88
  if (( N_FILES > LARGE_THRESHOLD )); then
89
  echo "── $repo: $N_FILES files > $LARGE_THRESHOLD, using upload-large-folder"
90
  echo " (resumable; commits directly to main — no PR, no custom message)"
91
  hf upload-large-folder "$repo" . "${EXCLUDES[@]}"
92
+ elif [[ "$mode" == "direct" ]]; then
93
+ echo "── uploading (direct → main) to: $repo"
94
+ hf upload "$repo" . . "${EXCLUDES[@]}" --commit-message="$MSG"
95
  else
96
+ echo "── uploading (PR) to: $repo"
97
+ hf upload "$repo" . . "${EXCLUDES[@]}" --create-pr --commit-message="$MSG"
 
 
 
98
  fi
99
  }
100
 
101
+ # Ensure the personal ZeroGPU Space exists. Tries zero-a10g (needs Pro/ZeroGPU);
102
+ # falls back to cpu-basic so the upload still has a target (set ZeroGPU in
103
+ # Settings afterward). Idempotent via --exist-ok.
104
+ ensure_blade_space() {
105
+ local id="$BLADE_OWNER/$REPO_NAME"
106
+ if hf repos create "$id" --type space --space-sdk gradio --flavor zero-a10g --exist-ok 2>/dev/null; then
107
+ echo "── Space ready (ZeroGPU / zero-a10g): $id"; return 0
108
+ fi
109
+ if hf repos create "$id" --type space --space-sdk gradio --exist-ok 2>/dev/null; then
110
+ echo "── Space created cpu-basic (set ZeroGPU in Settings → Hardware): $id"; return 0
111
+ fi
112
+ return 1
113
+ }
114
+
115
+ blade_help() {
116
+ cat >&2 <<EOF
117
+ ── ⚠ Could not create/deploy to $SPACE_BLADESZASZA_REPO
118
+ Your active HF token can push to silas-therapy but not create repos under
119
+ "$BLADE_OWNER". To deploy your own ZeroGPU Space:
120
+ 1) In the HF UI create a Space: $BLADE_OWNER/$REPO_NAME
121
+ SDK = Gradio, Hardware = ZeroGPU (Nvidia A10G).
122
+ 2) Re-auth with a token that can write there:
123
+ hf auth login (token with 'Write' role, or fine-grained with
124
+ write access to $BLADE_OWNER)
125
+ 3) Re-run ./scripts/hf_upload.sh
126
+ EOF
127
+ }
128
 
129
+ # Shared org repos → PRs; personal ZeroGPU Space → created + direct deploy.
130
+ upload_repo "$MODEL_REPO" pr
131
+ upload_repo "$SPACE_REPO" pr
132
+
133
+ set +e
134
+ if ensure_blade_space; then
135
+ upload_repo "$SPACE_BLADESZASZA_REPO" direct || blade_help
136
+ else
137
+ blade_help
138
+ fi
139
+ set -e
140
+ echo "✓ done (silas-therapy PRs created; see any notes above for the personal Space)"
tests/test_judge_backend.py CHANGED
@@ -21,9 +21,19 @@ def test_resolve_backend_default_local(monkeypatch):
21
  assert cfg.resolve_judge_backend() == "llama_cpp"
22
 
23
 
24
- def test_resolve_backend_auto_on_space(monkeypatch):
25
- cfg = _reload_config(monkeypatch, FORMSCOUT_JUDGE_BACKEND="auto", SPACE_ID="me/space")
 
26
  assert cfg.resolve_judge_backend() == "transformers"
 
 
 
 
 
 
 
 
 
27
 
28
 
29
  def test_resolve_backend_explicit(monkeypatch):
 
21
  assert cfg.resolve_judge_backend() == "llama_cpp"
22
 
23
 
24
+ def test_resolve_backend_auto_on_zero_gpu_space(monkeypatch):
25
+ cfg = _reload_config(monkeypatch, FORMSCOUT_JUDGE_BACKEND="auto",
26
+ SPACE_ID="me/space", SPACES_ZERO_GPU="true")
27
  assert cfg.resolve_judge_backend() == "transformers"
28
+ importlib.reload(config)
29
+
30
+
31
+ def test_resolve_backend_auto_on_cpu_space_stays_llama(monkeypatch):
32
+ # A CPU-only Space must NOT load the 17 GB transformers model.
33
+ cfg = _reload_config(monkeypatch, FORMSCOUT_JUDGE_BACKEND="auto",
34
+ SPACE_ID="me/space", SPACES_ZERO_GPU=None, ZERO_GPU=None)
35
+ assert cfg.resolve_judge_backend() == "llama_cpp"
36
+ importlib.reload(config)
37
 
38
 
39
  def test_resolve_backend_explicit(monkeypatch):
tests/test_scoresheet.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for ScoreSheetAgent — the one-page FMS scoring sheet. No GPU/model downloads."""
2
+ import os
3
+
4
+ from formscout.types import ReportResult
5
+
6
+
7
+ def _report(composite=14, per_test=None, asymmetries=None):
8
+ return ReportResult(
9
+ per_test=per_test if per_test is not None else [],
10
+ composite=composite,
11
+ asymmetries=asymmetries or [],
12
+ overlay_video_path=None, pdf_path=None,
13
+ low_confidence_flags=[], disagreement_flags=[],
14
+ )
15
+
16
+
17
+ def _pt(test_name, score, needs_human=False):
18
+ return {"test_name": test_name, "score": score, "judge": None,
19
+ "features": None, "needs_human": needs_human}
20
+
21
+
22
+ def test_scoresheet_is_created(tmp_path):
23
+ from formscout.agents.scoresheet import ScoreSheetAgent
24
+ report = _report(
25
+ composite=15,
26
+ per_test=[
27
+ _pt("deep_squat", 2),
28
+ _pt("hurdle_step", 2),
29
+ _pt("trunk_stability_pushup", 3),
30
+ ],
31
+ asymmetries=[{"test": "hurdle_step", "left_score": 2, "right_score": 3, "delta": 1}],
32
+ )
33
+ path = ScoreSheetAgent().run(report, str(tmp_path))
34
+ assert path is not None
35
+ assert os.path.exists(path)
36
+ assert os.path.getsize(path) > 1000
37
+ with open(path, "rb") as f:
38
+ assert f.read(5) == b"%PDF-"
39
+
40
+
41
+ def test_scoresheet_incomplete_composite(tmp_path):
42
+ """A session with unscored/needs-human tests must still render."""
43
+ from formscout.agents.scoresheet import ScoreSheetAgent
44
+ report = _report(
45
+ composite=None,
46
+ per_test=[_pt("deep_squat", None, needs_human=True)],
47
+ )
48
+ path = ScoreSheetAgent().run(report, str(tmp_path))
49
+ assert path is not None and os.path.exists(path)
50
+
51
+
52
+ def test_scoresheet_accepts_meta(tmp_path):
53
+ """Optional athlete/meta fields are accepted without breaking rendering."""
54
+ from formscout.agents.scoresheet import ScoreSheetAgent
55
+ path = ScoreSheetAgent().run(
56
+ _report(14, per_test=[_pt("deep_squat", 2)]),
57
+ str(tmp_path),
58
+ meta={"name": "Test Athlete", "date": "2026-06-14"},
59
+ )
60
+ assert path is not None and os.path.exists(path)