docs: mark full FMS session + PDF export complete in build phases

#5
CLAUDE.md CHANGED
@@ -172,7 +172,7 @@ The UI uses **Gradio `gr.Blocks`** with custom CSS/theme (`formscout/ui/theme.py
172
  2. **Phase 1 — Spine:** ✅ Complete. Deep Squat end-to-end.
173
  3. **Phase 2 — All 7 tests:** ✅ Complete. Classifier, Judge, Report agents; all rubric scorers; Gradio UI.
174
  4. **Phase 3 — Learned scoring + retrieval:** ST-GCN fine-tune on physio clips, publish to Hub. RetrievalAgent with embedding index.
175
- 5. **Phase 4 — Polish + ship:** Custom Svelte UI components, PDF export, agent trace to Hub, blog post. (Overlay video already done via `PoseVisualizer`.)
176
 
177
  ## Known issues
178
 
 
172
  2. **Phase 1 — Spine:** ✅ Complete. Deep Squat end-to-end.
173
  3. **Phase 2 — All 7 tests:** ✅ Complete. Classifier, Judge, Report agents; all rubric scorers; Gradio UI.
174
  4. **Phase 3 — Learned scoring + retrieval:** ST-GCN fine-tune on physio clips, publish to Hub. RetrievalAgent with embedding index.
175
+ 5. **Phase 4 — Polish + ship:** Custom Svelte UI components, agent trace to Hub, blog post. (Overlay video done via `PoseVisualizer`; full 7-test session + PDF export done via `formscout/session.py` + `PdfReportAgent`.)
176
 
177
  ## Known issues
178
 
app.py CHANGED
@@ -8,6 +8,7 @@ rubric breakdown, and persistent safety banner.
8
  """
9
  from __future__ import annotations
10
 
 
11
  import tempfile
12
 
13
  import gradio as gr
@@ -16,6 +17,7 @@ from formscout.pipeline import Director
16
  from formscout.rubric import score_test
17
  from formscout.ui.theme import formscout_theme, FORMSCOUT_CSS
18
  from formscout import config
 
19
  from formscout.startup import ensure_checkpoints
20
 
21
  ensure_checkpoints()
@@ -48,22 +50,22 @@ SCORE_DESCRIPTIONS = {
48
 
49
  # ─── Processing ──────────────────────────────────────────────────────────────
50
 
51
- def process_video(video_path: str, test_name: str, side: str, model_key: str, layers: list[str]):
52
- """Process an uploaded video through the FormScout pipeline."""
 
53
  if not video_path:
54
  return (
55
- _render_empty_state(),
56
- "Upload a video to begin analysis.",
57
- "",
58
- "",
59
- None,
60
- "",
61
  )
62
 
 
 
 
63
  director = Director()
64
  state = director.run(video_path, test_name=test_name, side=side, model_key=model_key)
65
 
66
- # ─── Score card ───
67
  score_html = _render_empty_state()
68
  score_details = ""
69
 
@@ -80,13 +82,21 @@ def process_video(video_path: str, test_name: str, side: str, model_key: str, la
80
  score_html = _render_score_card(result.score, result.confidence, result.needs_human)
81
  score_details = _render_score_details(result, state.features)
82
 
83
- # ─── Pipeline info ───
84
- pipeline_md = _render_pipeline_status(state)
 
 
 
 
 
 
 
 
 
85
 
86
- # ─── Warnings/errors ───
87
  alerts = _render_alerts(state)
88
 
89
- # ─── Overlay video ───
90
  overlay_path = None
91
  vel_summary = ""
92
  layer_set = {lbl.lower().replace(" ", "_") for lbl in (layers or [])}
@@ -102,7 +112,12 @@ def process_video(video_path: str, test_name: str, side: str, model_key: str, la
102
  except Exception as e:
103
  alerts = (alerts or "") + f"\n⚠️ Visualizer error: {e}"
104
 
105
- return score_html, pipeline_md, score_details, alerts, overlay_path, vel_summary
 
 
 
 
 
106
 
107
 
108
  def _render_score_card(score: int, confidence: float, needs_human: bool) -> str:
@@ -242,12 +257,64 @@ def _render_alerts(state) -> str:
242
  return "\n\n".join(parts)
243
 
244
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  # ─── App Builder ─────────────────────────────────────────────────────────────
246
 
247
  def build_app() -> gr.Blocks:
248
  """Build the FormScout Gradio app with custom scout/trail theme."""
249
  with gr.Blocks(title="FormScout — FMS Screening Aid") as app:
250
 
 
 
251
  # Header
252
  gr.HTML("""
253
  <div class="formscout-header">
@@ -304,6 +371,10 @@ def build_app() -> gr.Blocks:
304
  variant="primary",
305
  size="lg",
306
  )
 
 
 
 
307
 
308
  gr.Markdown(
309
  "*Tip: Film from the side at hip height for best accuracy. "
@@ -333,6 +404,12 @@ def build_app() -> gr.Blocks:
333
  overlay_video = gr.Video(label="Annotated Movement")
334
  velocity_md = gr.Markdown("")
335
 
 
 
 
 
 
 
336
  # Footer safety banner
337
  gr.HTML(f'<div class="safety-banner" style="margin-top: 20px;">{DISCLAIMER}</div>')
338
 
@@ -345,17 +422,35 @@ def build_app() -> gr.Blocks:
345
 
346
  # ─── Event wiring ────────────────────────────────────────────────────
347
 
348
- def _map_inputs(video, test_display_name, side_display, pose_model_key, overlay_layers):
349
- """Map UI display values to internal values."""
350
  test_map = {name: val for name, val in FMS_TESTS}
351
  test_name = test_map.get(test_display_name, "deep_squat")
352
  side = {"N/A": "na", "Left": "left", "Right": "right"}.get(side_display, "na")
353
- return process_video(video, test_name, side, pose_model_key, overlay_layers)
354
 
355
  submit_btn.click(
356
  fn=_map_inputs,
357
- inputs=[video_input, test_dropdown, side_dropdown, pose_model_dropdown, overlay_layers],
358
- outputs=[score_html, pipeline_md, score_details, alerts_md, overlay_video, velocity_md],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  )
360
 
361
  return app
 
8
  """
9
  from __future__ import annotations
10
 
11
+ import os
12
  import tempfile
13
 
14
  import gradio as gr
 
17
  from formscout.rubric import score_test
18
  from formscout.ui.theme import formscout_theme, FORMSCOUT_CSS
19
  from formscout import config
20
+ from formscout import session as session_mod
21
  from formscout.startup import ensure_checkpoints
22
 
23
  ensure_checkpoints()
 
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.",
59
+ "", "", None, "", _render_session_table(session_state),
60
+ gr.update(visible=False), gr.update(visible=False),
 
 
 
61
  )
62
 
63
+ if session_state is None:
64
+ session_state = session_mod.new_session()
65
+
66
  director = Director()
67
  state = director.run(video_path, test_name=test_name, side=side, model_key=model_key)
68
 
 
69
  score_html = _render_empty_state()
70
  score_details = ""
71
 
 
82
  score_html = _render_score_card(result.score, result.confidence, result.needs_human)
83
  score_details = _render_score_details(result, state.features)
84
 
85
+ # Accumulate into the session (only when we have a real analysis)
86
+ if state.ingest and state.pose2d and state.judge:
87
+ draw_trails = "trails" in {lbl.lower().replace(" ", "_") for lbl in (layers or [])}
88
+ try:
89
+ session_mod.add_analysis(
90
+ session_state, ingest=state.ingest, pose2d=state.pose2d,
91
+ features=state.features, judge=state.judge,
92
+ test_name=test_name, side=side, draw_trails=draw_trails,
93
+ )
94
+ except Exception as e:
95
+ state.warnings.append(f"session accumulation failed: {e}")
96
 
97
+ pipeline_md = _render_pipeline_status(state)
98
  alerts = _render_alerts(state)
99
 
 
100
  overlay_path = None
101
  vel_summary = ""
102
  layer_set = {lbl.lower().replace(" ", "_") for lbl in (layers or [])}
 
112
  except Exception as e:
113
  alerts = (alerts or "") + f"\n⚠️ Visualizer error: {e}"
114
 
115
+ has_entries = bool(session_state and session_state.entries)
116
+ return (
117
+ session_state, score_html, pipeline_md, score_details, alerts,
118
+ overlay_path, vel_summary, _render_session_table(session_state),
119
+ gr.update(visible=has_entries), gr.update(visible=has_entries),
120
+ )
121
 
122
 
123
  def _render_score_card(score: int, confidence: float, needs_human: bool) -> str:
 
257
  return "\n\n".join(parts)
258
 
259
 
260
+ def _render_session_table(session_state) -> str:
261
+ """Render the accumulated 'Session so far' table as markdown."""
262
+ if not session_state or not session_state.entries:
263
+ return "*No clips analysed yet.*"
264
+ lines = ["| Test | Side | Score | Status |", "|---|---|---|---|"]
265
+ for e in session_state.entries:
266
+ test = e.test_name.replace("_", " ").title()
267
+ side = e.side if e.side in ("left", "right") else "—"
268
+ if e.needs_human:
269
+ score, status = "—", "⚠️ Clinician review"
270
+ else:
271
+ score, status = f"{e.score}/3", "✓ scored"
272
+ lines.append(f"| {test} | {side} | {score} | {status} |")
273
+ return "\n".join(lines)
274
+
275
+
276
+ 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"]
288
+ else:
289
+ n = len(session_state.entries)
290
+ summary = [f"## Composite: Incomplete — {n}/7 tests scored",
291
+ "*(One or more tests need clinician review or were unscored.)*"]
292
+
293
+ if report.asymmetries:
294
+ summary.append("\n### Asymmetries")
295
+ for a in report.asymmetries:
296
+ test = a["test"].replace("_", " ").title()
297
+ summary.append(f"- **{test}:** L={a['left_score']} R={a['right_score']} (Δ {a['delta']})")
298
+
299
+ flags = list(report.low_confidence_flags) + list(report.disagreement_flags)
300
+ if flags:
301
+ summary.append("\n### Flags")
302
+ for fl in flags:
303
+ summary.append(f"- {fl}")
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 ─────────────────────────────────────────────────────────────
311
 
312
  def build_app() -> gr.Blocks:
313
  """Build the FormScout Gradio app with custom scout/trail theme."""
314
  with gr.Blocks(title="FormScout — FMS Screening Aid") as app:
315
 
316
+ session_state = gr.State(None)
317
+
318
  # Header
319
  gr.HTML("""
320
  <div class="formscout-header">
 
371
  variant="primary",
372
  size="lg",
373
  )
374
+ with gr.Row():
375
+ new_clip_btn = gr.Button("➕ Analyse new clip", visible=False)
376
+ finish_btn = gr.Button("✅ Finish & generate PDF",
377
+ variant="primary", visible=False)
378
 
379
  gr.Markdown(
380
  "*Tip: Film from the side at hip height for best accuracy. "
 
404
  overlay_video = gr.Video(label="Annotated Movement")
405
  velocity_md = gr.Markdown("")
406
 
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
414
  gr.HTML(f'<div class="safety-banner" style="margin-top: 20px;">{DISCLAIMER}</div>')
415
 
 
422
 
423
  # ─── Event wiring ────────────────────────────────────────────────────
424
 
425
+ def _map_inputs(video, test_display_name, side_display, pose_model_key, overlay_layers, sess):
426
+ """Map UI display values to internal values and accumulate into the session."""
427
  test_map = {name: val for name, val in FMS_TESTS}
428
  test_name = test_map.get(test_display_name, "deep_squat")
429
  side = {"N/A": "na", "Left": "left", "Right": "right"}.get(side_display, "na")
430
+ return process_video(video, test_name, side, pose_model_key, overlay_layers, sess)
431
 
432
  submit_btn.click(
433
  fn=_map_inputs,
434
+ inputs=[video_input, test_dropdown, side_dropdown, pose_model_dropdown,
435
+ overlay_layers, session_state],
436
+ outputs=[session_state, score_html, pipeline_md, score_details, alerts_md,
437
+ overlay_video, velocity_md, session_table, new_clip_btn, finish_btn],
438
+ )
439
+
440
+ def _new_clip():
441
+ """Clear inputs for the next clip; keep the session intact."""
442
+ return None, _render_empty_state(), ""
443
+
444
+ new_clip_btn.click(
445
+ fn=_new_clip,
446
+ inputs=[],
447
+ outputs=[video_input, score_html, score_details],
448
+ )
449
+
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
docs/superpowers/plans/2026-06-13-full-fms-session-pdf.md ADDED
@@ -0,0 +1,1209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Full FMS Session + PDF Report — Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Turn FormScout's one-clip scorer into a screening session that accumulates analyzed clips into a composite 0–21 report and exports a branded PDF with annotated worst-moment key-frame stills.
6
+
7
+ **Architecture:** A new `formscout/session.py` accumulates typed `SessionEntry` objects (one per analyzed clip), persisting each to a temp session dir. `PoseVisualizer.render_frame()` captures the governing frame (already computed by `BiomechanicsAgent` and stored in `features.timing`) as an annotated PNG. On "Finish", the existing `ReportAgent` computes composite + asymmetries, and a new `PdfReportAgent` renders a ReportLab PDF. The UI (`app.py`) gains `gr.State` session accumulation with "Analyse new clip" / "Finish & generate PDF" buttons.
8
+
9
+ **Tech Stack:** Python 3.13, ReportLab (new dep), OpenCV (existing), Gradio 5, pytest. No model downloads in tests.
10
+
11
+ ---
12
+
13
+ ## File Structure
14
+
15
+ - `requirements.txt` — add `reportlab`.
16
+ - `formscout/types.py` — add `SessionEntry` frozen dataclass.
17
+ - `formscout/agents/biomechanics.py` — add `max_sag_frame` to `trunk_stability_pushup` timing (rotary already has `peak_extension_frame`).
18
+ - `formscout/agents/visualizer.py` — add `PoseVisualizer.render_frame()`.
19
+ - `formscout/session.py` — **new**: session accumulator (new/add/finish + persistence + key-frame helpers).
20
+ - `formscout/agents/pdf_report.py` — **new**: `PdfReportAgent` (ReportLab).
21
+ - `app.py` — wire `gr.State`, two buttons, "Session so far" table, finish handler.
22
+ - `tests/test_session.py`, `tests/test_keyframe.py`, `tests/test_pdf_report.py` — **new**.
23
+
24
+ ---
25
+
26
+ ## Task 1: Add ReportLab dependency
27
+
28
+ **Files:**
29
+ - Modify: `requirements.txt`
30
+
31
+ - [ ] **Step 1: Add the dependency**
32
+
33
+ Add this line to `requirements.txt` (after `pillow>=10.3`):
34
+
35
+ ```
36
+ reportlab>=4.0
37
+ ```
38
+
39
+ - [ ] **Step 2: Install it**
40
+
41
+ Run: `pip install 'reportlab>=4.0'`
42
+ Expected: `Successfully installed reportlab-4.x.x`
43
+
44
+ - [ ] **Step 3: Verify import**
45
+
46
+ Run: `python3 -c "import reportlab; print(reportlab.Version)"`
47
+ Expected: prints a version like `4.x.x`
48
+
49
+ - [ ] **Step 4: Commit**
50
+
51
+ ```bash
52
+ git add requirements.txt
53
+ git commit -m "build: add reportlab for PDF report generation"
54
+ ```
55
+
56
+ ---
57
+
58
+ ## Task 2: Add `SessionEntry` dataclass
59
+
60
+ **Files:**
61
+ - Modify: `formscout/types.py` (after `ReportResult`, before `PipelineState`)
62
+ - Test: `tests/test_session.py`
63
+
64
+ - [ ] **Step 1: Write the failing test**
65
+
66
+ Create `tests/test_session.py` with:
67
+
68
+ ```python
69
+ """Tests for the FMS session accumulator — no GPU, no model downloads."""
70
+ import numpy as np
71
+
72
+ from formscout.types import (
73
+ IngestResult, Pose2DResult, BiomechFeatures, ScoreResult, JudgeResult,
74
+ MovementResult, SessionEntry,
75
+ )
76
+
77
+
78
+ def test_session_entry_holds_typed_objects():
79
+ movement = MovementResult(test_name="deep_squat", side="na", confidence=1.0)
80
+ features = BiomechFeatures(
81
+ test_name="deep_squat", view="2d", side="na",
82
+ angles={"left_knee_flexion_deg": 95.0}, alignments={"knees_tracking_over_feet": True},
83
+ symmetry_delta=None, timing={"deepest_frame": 2}, confidence=0.9,
84
+ )
85
+ rubric = ScoreResult(score=2, rationale="ok", confidence=0.8)
86
+ judge = JudgeResult(score=2, rationale="ok", compensation_tags=["heels elevated"],
87
+ corrective_hint="ankle mobility", confidence=0.85)
88
+ entry = SessionEntry(
89
+ test_name="deep_squat", side="na", score=2, needs_human=False,
90
+ rationale="ok", compensation_tags=["heels elevated"], corrective_hint="ankle mobility",
91
+ measurements={"left_knee_flexion_deg": 95.0}, confidence=0.85, view="2d",
92
+ keyframe_path=None, movement=movement, features=features,
93
+ rubric_score=rubric, judge=judge,
94
+ )
95
+ assert entry.score == 2
96
+ assert entry.movement.test_name == "deep_squat"
97
+ assert entry.rubric_score.score == 2
98
+ assert entry.judge.compensation_tags == ["heels elevated"]
99
+ ```
100
+
101
+ - [ ] **Step 2: Run test to verify it fails**
102
+
103
+ Run: `pytest tests/test_session.py::test_session_entry_holds_typed_objects -v`
104
+ Expected: FAIL with `ImportError: cannot import name 'SessionEntry'`
105
+
106
+ - [ ] **Step 3: Add the dataclass**
107
+
108
+ In `formscout/types.py`, insert after the `ReportResult` class (line ~142) and before `PipelineState`:
109
+
110
+ ```python
111
+ @dataclass(frozen=True)
112
+ class SessionEntry:
113
+ """One accumulated analysis in a screening session.
114
+
115
+ Display fields (test_name…keyframe_path) feed the PDF/JSON/MD artifacts;
116
+ the trailing typed objects (movement…judge) feed ReportAgent.run().
117
+ """
118
+ test_name: str
119
+ side: str
120
+ score: int | None
121
+ needs_human: bool
122
+ rationale: str
123
+ compensation_tags: list
124
+ corrective_hint: str
125
+ measurements: dict
126
+ confidence: float
127
+ view: str
128
+ keyframe_path: str | None
129
+ movement: MovementResult
130
+ features: BiomechFeatures
131
+ rubric_score: ScoreResult
132
+ judge: JudgeResult | None
133
+ ```
134
+
135
+ - [ ] **Step 4: Run test to verify it passes**
136
+
137
+ Run: `pytest tests/test_session.py::test_session_entry_holds_typed_objects -v`
138
+ Expected: PASS
139
+
140
+ - [ ] **Step 5: Commit**
141
+
142
+ ```bash
143
+ git add formscout/types.py tests/test_session.py
144
+ git commit -m "feat: add SessionEntry typed contract for screening sessions"
145
+ ```
146
+
147
+ ---
148
+
149
+ ## Task 3: Add governing-frame index to push-up biomechanics
150
+
151
+ **Files:**
152
+ - Modify: `formscout/agents/biomechanics.py:468-529` (`_trunk_stability_pushup`)
153
+ - Test: `tests/test_biomechanics.py` (append a test)
154
+
155
+ The other six tests already store a governing frame index in `features.timing`
156
+ (`deepest_frame`, `peak_step_frame`, `deepest_lunge_frame`, `measure_frame`,
157
+ `peak_raise_frame`, `peak_extension_frame`). Only `trunk_stability_pushup` is missing one.
158
+
159
+ - [ ] **Step 1: Write the failing test**
160
+
161
+ Append to `tests/test_biomechanics.py`:
162
+
163
+ ```python
164
+ def test_pushup_timing_has_max_sag_frame():
165
+ from formscout.agents.biomechanics import BiomechanicsAgent
166
+ from formscout.types import Pose2DResult, Body3DResult, MovementResult
167
+
168
+ # 4 frames; frame 2 has the largest hip sag (hip far below shoulder/ankle midline)
169
+ def kps(hip_y):
170
+ base = {
171
+ 5: {"x": 200, "y": 200, "conf": 0.9}, # L shoulder
172
+ 6: {"x": 220, "y": 200, "conf": 0.9}, # R shoulder
173
+ 11: {"x": 300, "y": hip_y, "conf": 0.9}, # L hip
174
+ 12: {"x": 320, "y": hip_y, "conf": 0.9}, # R hip
175
+ 15: {"x": 400, "y": 200, "conf": 0.9}, # L ankle
176
+ 16: {"x": 420, "y": 200, "conf": 0.9}, # R ankle
177
+ }
178
+ return base
179
+
180
+ frames = [kps(200), kps(210), kps(260), kps(205)]
181
+ pose = Pose2DResult(keypoints=frames, fps=30.0, confidence=0.9)
182
+ body3d = Body3DResult(used=False, joints_3d=[])
183
+ movement = MovementResult(test_name="trunk_stability_pushup", side="na", confidence=1.0)
184
+
185
+ feats = BiomechanicsAgent().run(pose, body3d, movement)
186
+ assert "max_sag_frame" in feats.timing
187
+ assert feats.timing["max_sag_frame"] == 2
188
+ ```
189
+
190
+ - [ ] **Step 2: Run test to verify it fails**
191
+
192
+ Run: `pytest tests/test_biomechanics.py::test_pushup_timing_has_max_sag_frame -v`
193
+ Expected: FAIL with `assert 'max_sag_frame' in {...}` (KeyError-style assertion failure)
194
+
195
+ - [ ] **Step 3: Track the max-sag frame index**
196
+
197
+ In `formscout/agents/biomechanics.py`, replace the body of `_trunk_stability_pushup` from the
198
+ `trunk_angles_over_time = []` loop through the `if trunk_angles_over_time:` block. Replace:
199
+
200
+ ```python
201
+ # Analyze multiple frames to detect sag/lag
202
+ trunk_angles_over_time = []
203
+ for i, kps in enumerate(pose2d.keypoints):
204
+ ```
205
+
206
+ …down to and including the `alignments["no_sag"] = max_sag < 30` line, with:
207
+
208
+ ```python
209
+ # Analyze multiple frames to detect sag/lag
210
+ trunk_sags: list[tuple[int, float]] = [] # (frame_idx, sag_px)
211
+ for i, kps in enumerate(pose2d.keypoints):
212
+ l_sh = _get_joint(kps, L_SHOULDER)
213
+ r_sh = _get_joint(kps, R_SHOULDER)
214
+ l_hip = _get_joint(kps, L_HIP)
215
+ r_hip = _get_joint(kps, R_HIP)
216
+ l_ankle = _get_joint(kps, L_ANKLE)
217
+ r_ankle = _get_joint(kps, R_ANKLE)
218
+
219
+ if l_sh and r_sh and l_hip and r_hip and l_ankle and r_ankle:
220
+ sh_y = (l_sh[1] + r_sh[1]) / 2
221
+ hip_y = (l_hip[1] + r_hip[1]) / 2
222
+ ankle_y = (l_ankle[1] + r_ankle[1]) / 2
223
+ expected_hip_y = (sh_y + ankle_y) / 2
224
+ sag_px = hip_y - expected_hip_y
225
+ trunk_sags.append((i, sag_px))
226
+
227
+ max_sag_frame = 0
228
+ if trunk_sags:
229
+ sags = [s for _, s in trunk_sags]
230
+ max_sag_frame = max(trunk_sags, key=lambda t: t[1])[0]
231
+ mean = sum(sags) / len(sags)
232
+ variance = (sum((x - mean) ** 2 for x in sags) / len(sags)) ** 0.5
233
+ max_sag = max(sags)
234
+ angles["max_sag_px"] = max_sag
235
+ angles["trunk_variance_px"] = variance
236
+ alignments["body_rigid"] = max_sag < 30 and variance < 15
237
+ alignments["no_sag"] = max_sag < 30
238
+ else:
239
+ notes_parts.append("insufficient landmarks for trunk analysis")
240
+ ```
241
+
242
+ Then update the `return BiomechFeatures(...)` `timing=` argument at the end of the method from:
243
+
244
+ ```python
245
+ timing={"n_frames_analyzed": len(trunk_angles_over_time)},
246
+ ```
247
+
248
+ to:
249
+
250
+ ```python
251
+ timing={"n_frames_analyzed": len(trunk_sags), "max_sag_frame": max_sag_frame},
252
+ ```
253
+
254
+ - [ ] **Step 4: Run test to verify it passes**
255
+
256
+ Run: `pytest tests/test_biomechanics.py::test_pushup_timing_has_max_sag_frame -v`
257
+ Expected: PASS
258
+
259
+ - [ ] **Step 5: Run the full biomechanics suite (no regressions)**
260
+
261
+ Run: `pytest tests/test_biomechanics.py -v`
262
+ Expected: all previously-passing tests still pass (the pre-existing `test_unimplemented_test_returns_low_confidence` known-failure may remain failing — that is unrelated and documented in CLAUDE.md).
263
+
264
+ - [ ] **Step 6: Commit**
265
+
266
+ ```bash
267
+ git add formscout/agents/biomechanics.py tests/test_biomechanics.py
268
+ git commit -m "feat: track max-sag frame index in push-up biomechanics for key-frame capture"
269
+ ```
270
+
271
+ ---
272
+
273
+ ## Task 4: Add `PoseVisualizer.render_frame()`
274
+
275
+ **Files:**
276
+ - Modify: `formscout/agents/visualizer.py` (add method to `PoseVisualizer`, after `render_video`)
277
+ - Test: `tests/test_keyframe.py`
278
+
279
+ - [ ] **Step 1: Write the failing test**
280
+
281
+ Create `tests/test_keyframe.py`:
282
+
283
+ ```python
284
+ """Tests for PoseVisualizer.render_frame — single annotated still."""
285
+ import os
286
+ import numpy as np
287
+
288
+ from formscout.types import IngestResult, Pose2DResult
289
+
290
+
291
+ def _ingest(n=5, h=480, w=640):
292
+ frames = [np.zeros((h, w, 3), dtype=np.uint8) for _ in range(n)]
293
+ return IngestResult(frames=frames, fps=30.0, duration=n / 30.0, n_people=1, width=w, height=h)
294
+
295
+
296
+ def _pose(n=5):
297
+ kps = []
298
+ for i in range(n):
299
+ kps.append({j: {"x": float(50 + j * 25), "y": float(80 + j * 18), "conf": 0.9}
300
+ for j in range(17)})
301
+ return Pose2DResult(keypoints=kps, fps=30.0, confidence=0.9)
302
+
303
+
304
+ def test_render_frame_writes_png(tmp_path):
305
+ from formscout.agents.visualizer import PoseVisualizer
306
+ out = str(tmp_path / "key.png")
307
+ path = PoseVisualizer().render_frame(_ingest(), _pose(), frame_idx=2,
308
+ layers={"skeleton"}, caption="Deep Squat — heels elevated",
309
+ out_png=out)
310
+ assert path == out
311
+ assert os.path.exists(out)
312
+ assert os.path.getsize(out) > 0
313
+
314
+
315
+ def test_render_frame_bad_index_returns_none(tmp_path):
316
+ from formscout.agents.visualizer import PoseVisualizer
317
+ out = str(tmp_path / "key.png")
318
+ path = PoseVisualizer().render_frame(_ingest(n=3), _pose(n=3), frame_idx=99,
319
+ layers={"skeleton"}, caption="", out_png=out)
320
+ assert path is None
321
+ ```
322
+
323
+ - [ ] **Step 2: Run test to verify it fails**
324
+
325
+ Run: `pytest tests/test_keyframe.py -v`
326
+ Expected: FAIL with `AttributeError: 'PoseVisualizer' object has no attribute 'render_frame'`
327
+
328
+ - [ ] **Step 3: Add the method**
329
+
330
+ In `formscout/agents/visualizer.py`, inside the `PoseVisualizer` class, add this method
331
+ immediately after `render_video` (before the closing of the class / the module-level
332
+ `build_velocity_summary`):
333
+
334
+ ```python
335
+ def render_frame(
336
+ self,
337
+ ingest,
338
+ pose2d,
339
+ frame_idx: int,
340
+ layers: set[str],
341
+ caption: str = "",
342
+ out_png: str | None = None,
343
+ ) -> str | None:
344
+ """Render a single annotated still (skeleton + optional trails + caption).
345
+
346
+ frame_idx is typically the governing frame from BiomechFeatures.timing.
347
+ Returns the PNG path on success, None on any failure. Never raises.
348
+ """
349
+ try:
350
+ if not (0 <= frame_idx < len(ingest.frames)) or frame_idx >= len(pose2d.keypoints):
351
+ return None
352
+
353
+ frame = ingest.frames[frame_idx].copy()
354
+ kps = pose2d.keypoints[frame_idx]
355
+
356
+ if "trails" in layers:
357
+ trail: dict[int, deque] = {j: deque(maxlen=TRAIL_LENGTH) for j in range(17)}
358
+ start = max(0, frame_idx - TRAIL_LENGTH)
359
+ for fi in range(start, frame_idx + 1):
360
+ for j, kp in pose2d.keypoints[fi].items():
361
+ if kp.get("conf", 0.0) >= CONF_THRESHOLD:
362
+ trail[j].append((kp["x"], kp["y"]))
363
+ frame = self._draw_trails(frame, trail)
364
+
365
+ if "skeleton" in layers:
366
+ frame = self._draw_skeleton(frame, kps)
367
+
368
+ if caption:
369
+ cv2.rectangle(frame, (0, 0), (frame.shape[1], 28), (0, 0, 0), -1)
370
+ cv2.putText(frame, caption[:80], (8, 20), cv2.FONT_HERSHEY_SIMPLEX,
371
+ 0.55, (255, 255, 255), 1, cv2.LINE_AA)
372
+
373
+ if out_png is None:
374
+ out_png = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
375
+
376
+ ok = cv2.imwrite(out_png, frame)
377
+ return out_png if ok else None
378
+ except Exception as e:
379
+ logger.warning("render_frame failed: %s", e)
380
+ return None
381
+ ```
382
+
383
+ (`deque`, `cv2`, `tempfile`, `logger`, `TRAIL_LENGTH`, `CONF_THRESHOLD` are all already imported at the top of this file.)
384
+
385
+ - [ ] **Step 4: Run test to verify it passes**
386
+
387
+ Run: `pytest tests/test_keyframe.py -v`
388
+ Expected: both tests PASS
389
+
390
+ - [ ] **Step 5: Commit**
391
+
392
+ ```bash
393
+ git add formscout/agents/visualizer.py tests/test_keyframe.py
394
+ git commit -m "feat: add PoseVisualizer.render_frame for annotated key-frame stills"
395
+ ```
396
+
397
+ ---
398
+
399
+ ## Task 5: Create the session accumulator
400
+
401
+ **Files:**
402
+ - Create: `formscout/session.py`
403
+ - Test: `tests/test_session.py` (append tests)
404
+
405
+ - [ ] **Step 1: Write the failing tests**
406
+
407
+ Append to `tests/test_session.py`:
408
+
409
+ ```python
410
+ def _ingest(n=5, h=480, w=640):
411
+ frames = [np.zeros((h, w, 3), dtype=np.uint8) for _ in range(n)]
412
+ return IngestResult(frames=frames, fps=30.0, duration=n / 30.0, n_people=1, width=w, height=h)
413
+
414
+
415
+ def _pose(n=5):
416
+ kps = []
417
+ for i in range(n):
418
+ kps.append({j: {"x": float(50 + j * 25), "y": float(80 + j * 18), "conf": 0.9}
419
+ for j in range(17)})
420
+ return Pose2DResult(keypoints=kps, fps=30.0, confidence=0.9)
421
+
422
+
423
+ def _features(test_name="deep_squat", side="na", frame_key="deepest_frame"):
424
+ return BiomechFeatures(
425
+ test_name=test_name, view="2d", side=side,
426
+ angles={"left_knee_flexion_deg": 95.0},
427
+ alignments={"knees_tracking_over_feet": False},
428
+ symmetry_delta=None, timing={frame_key: 2}, confidence=0.9,
429
+ )
430
+
431
+
432
+ def _judge(score=2, needs_human=False):
433
+ return JudgeResult(
434
+ score=None if needs_human else score, rationale="r",
435
+ compensation_tags=["heels elevated"], corrective_hint="ankle mobility",
436
+ confidence=0.85, needs_human=needs_human,
437
+ )
438
+
439
+
440
+ def test_add_analysis_appends_entry_and_writes_files():
441
+ import os
442
+ from formscout import session as S
443
+ sess = S.new_session()
444
+ entry = S.add_analysis(sess, ingest=_ingest(), pose2d=_pose(),
445
+ features=_features(), judge=_judge(), test_name="deep_squat", side="na")
446
+ assert len(sess.entries) == 1
447
+ assert entry.score == 2
448
+ assert os.path.exists(os.path.join(sess.session_dir, "session.json"))
449
+ assert os.path.exists(os.path.join(sess.session_dir, "analysis.md"))
450
+ # key-frame still written (deepest_frame=2 is valid)
451
+ assert entry.keyframe_path and os.path.exists(entry.keyframe_path)
452
+
453
+
454
+ def test_finish_composite_null_when_needs_human():
455
+ from formscout import session as S
456
+ sess = S.new_session()
457
+ S.add_analysis(sess, ingest=_ingest(), pose2d=_pose(), features=_features(),
458
+ judge=_judge(score=3), test_name="deep_squat", side="na")
459
+ S.add_analysis(sess, ingest=_ingest(), pose2d=_pose(),
460
+ features=_features("trunk_stability_pushup", frame_key="max_sag_frame"),
461
+ judge=_judge(needs_human=True), test_name="trunk_stability_pushup", side="na")
462
+ report, pdf_path = S.finish_session(sess)
463
+ assert report is not None
464
+ assert report.composite is None # one test needs_human
465
+
466
+
467
+ def test_finish_empty_session_returns_none():
468
+ from formscout import session as S
469
+ sess = S.new_session()
470
+ report, pdf_path = S.finish_session(sess)
471
+ assert report is None and pdf_path is None
472
+ ```
473
+
474
+ - [ ] **Step 2: Run tests to verify they fail**
475
+
476
+ Run: `pytest tests/test_session.py -v`
477
+ Expected: the three new tests FAIL with `ModuleNotFoundError: No module named 'formscout.session'`
478
+
479
+ - [ ] **Step 3: Create the module**
480
+
481
+ Create `formscout/session.py`:
482
+
483
+ ```python
484
+ """
485
+ Screening-session accumulator.
486
+
487
+ Accumulates one SessionEntry per analyzed clip, persists each to a temp session
488
+ dir (session.json + analysis.md + key-frame PNGs), and on finish builds a
489
+ ReportResult (via ReportAgent) + a PDF (via PdfReportAgent).
490
+
491
+ Pure orchestration — no Gradio imports. Disk writes tolerate failure with a
492
+ logged warning and never block scoring.
493
+ """
494
+ from __future__ import annotations
495
+
496
+ import json
497
+ import logging
498
+ import os
499
+ import tempfile
500
+ import uuid
501
+ from dataclasses import dataclass, replace
502
+
503
+ from formscout.rubric import score_test
504
+ from formscout.types import MovementResult, ReportResult, SessionEntry
505
+
506
+ logger = logging.getLogger(__name__)
507
+
508
+ # Maps each test to the BiomechFeatures.timing key holding its governing frame.
509
+ TIMING_KEY = {
510
+ "deep_squat": "deepest_frame",
511
+ "hurdle_step": "peak_step_frame",
512
+ "inline_lunge": "deepest_lunge_frame",
513
+ "shoulder_mobility": "measure_frame",
514
+ "active_slr": "peak_raise_frame",
515
+ "trunk_stability_pushup": "max_sag_frame",
516
+ "rotary_stability": "peak_extension_frame",
517
+ }
518
+
519
+
520
+ @dataclass
521
+ class Session:
522
+ """Mutable session: an id, its temp dir, and accumulated entries."""
523
+ session_id: str
524
+ session_dir: str
525
+ entries: list # list[SessionEntry]
526
+
527
+
528
+ def new_session() -> Session:
529
+ sid = uuid.uuid4().hex[:12]
530
+ base = os.path.join(tempfile.gettempdir(), "formscout_sessions", sid)
531
+ try:
532
+ os.makedirs(os.path.join(base, "keyframes"), exist_ok=True)
533
+ except Exception as e:
534
+ logger.warning("session dir create failed: %s", e)
535
+ return Session(session_id=sid, session_dir=base, entries=[])
536
+
537
+
538
+ def governing_frame_index(features) -> int | None:
539
+ """Return the governing frame index for this test, or None."""
540
+ key = TIMING_KEY.get(features.test_name)
541
+ if key is None:
542
+ return None
543
+ idx = features.timing.get(key)
544
+ return int(idx) if isinstance(idx, (int, float)) else None
545
+
546
+
547
+ def worst_compensation_caption(judge, features) -> str:
548
+ """Short caption naming the worst compensation for the key-frame still."""
549
+ if judge and getattr(judge, "compensation_tags", None):
550
+ return ", ".join(judge.compensation_tags)
551
+ failed = [k.replace("_", " ") for k, v in features.alignments.items() if v is False]
552
+ return ("compensation: " + ", ".join(failed)) if failed else "key position"
553
+
554
+
555
+ def add_analysis(session, *, ingest, pose2d, features, judge, test_name, side,
556
+ draw_trails: bool = False) -> SessionEntry:
557
+ """Build a SessionEntry from a completed analysis, render its key-frame,
558
+ persist the session, append, and return the entry."""
559
+ movement = MovementResult(test_name=test_name, side=side, confidence=1.0)
560
+ rubric = score_test(features)
561
+
562
+ needs_human = bool((judge and judge.needs_human) or rubric.needs_human)
563
+ if needs_human:
564
+ score = None
565
+ elif judge and judge.score is not None:
566
+ score = judge.score
567
+ else:
568
+ score = rubric.score
569
+
570
+ keyframe_path = None
571
+ idx = governing_frame_index(features)
572
+ if idx is not None and 0 <= idx < len(pose2d.keypoints):
573
+ from formscout.agents.visualizer import PoseVisualizer
574
+ caption = (f"{test_name.replace('_', ' ').title()} "
575
+ f"({side}) — {worst_compensation_caption(judge, features)}")
576
+ layers = {"skeleton", "trails"} if draw_trails else {"skeleton"}
577
+ out_png = os.path.join(session.session_dir, "keyframes", f"{test_name}_{side}.png")
578
+ try:
579
+ keyframe_path = PoseVisualizer().render_frame(ingest, pose2d, idx, layers, caption, out_png)
580
+ except Exception as e:
581
+ logger.warning("keyframe render failed: %s", e)
582
+
583
+ measurements = {}
584
+ measurements.update(features.angles)
585
+ measurements.update(features.alignments)
586
+
587
+ entry = SessionEntry(
588
+ test_name=test_name, side=side, score=score, needs_human=needs_human,
589
+ rationale=(judge.rationale if judge else rubric.rationale),
590
+ compensation_tags=list(judge.compensation_tags) if judge else [],
591
+ corrective_hint=(judge.corrective_hint if judge else ""),
592
+ measurements=measurements,
593
+ confidence=(judge.confidence if judge else rubric.confidence),
594
+ view=features.view,
595
+ keyframe_path=keyframe_path,
596
+ movement=movement, features=features, rubric_score=rubric, judge=judge,
597
+ )
598
+ session.entries.append(entry)
599
+ _persist(session)
600
+ return entry
601
+
602
+
603
+ def finish_session(session) -> tuple[ReportResult | None, str | None]:
604
+ """Build the composite report + PDF. Returns (report, pdf_path).
605
+ Returns (None, None) for an empty session."""
606
+ if not session.entries:
607
+ return None, None
608
+
609
+ from formscout.agents.report import ReportAgent
610
+ report_inputs = [{
611
+ "movement": e.movement, "features": e.features,
612
+ "rubric_score": e.rubric_score, "judge": e.judge, "side": e.side,
613
+ } for e in session.entries]
614
+ report = ReportAgent().run(report_inputs)
615
+
616
+ pdf_path = None
617
+ try:
618
+ from formscout.agents.pdf_report import PdfReportAgent
619
+ pdf_path = PdfReportAgent().run(report, session.entries, session.session_dir)
620
+ except Exception as e:
621
+ logger.warning("pdf generation failed: %s", e)
622
+
623
+ report = replace(report, pdf_path=pdf_path)
624
+ return report, pdf_path
625
+
626
+
627
+ # ── Persistence ───────────────────────────────────────────────────────────────
628
+
629
+ def _jsonable(d: dict) -> dict:
630
+ out = {}
631
+ for k, v in d.items():
632
+ if isinstance(v, float):
633
+ out[k] = round(v, 2)
634
+ elif isinstance(v, (int, str, bool)) or v is None:
635
+ out[k] = v
636
+ else:
637
+ out[k] = str(v)
638
+ return out
639
+
640
+
641
+ def _entry_display(e: SessionEntry) -> dict:
642
+ return {
643
+ "test_name": e.test_name, "side": e.side, "score": e.score,
644
+ "needs_human": e.needs_human, "rationale": e.rationale,
645
+ "compensation_tags": list(e.compensation_tags), "corrective_hint": e.corrective_hint,
646
+ "measurements": _jsonable(e.measurements), "confidence": round(e.confidence, 2),
647
+ "view": e.view, "keyframe_path": e.keyframe_path,
648
+ }
649
+
650
+
651
+ def _render_markdown(session: Session) -> str:
652
+ lines = ["# FormScout — Session Log", ""]
653
+ for e in session.entries:
654
+ title = e.test_name.replace("_", " ").title()
655
+ if e.side in ("left", "right"):
656
+ title += f" ({e.side})"
657
+ score = "Clinician review required" if e.needs_human else f"{e.score}/3"
658
+ lines.append(f"## {title} — {score}")
659
+ lines.append(e.rationale or "")
660
+ if e.compensation_tags:
661
+ lines.append(f"- Compensations: {', '.join(e.compensation_tags)}")
662
+ if e.corrective_hint:
663
+ lines.append(f"- Corrective: {e.corrective_hint}")
664
+ if e.keyframe_path:
665
+ lines.append(f"- Key frame: `{e.keyframe_path}`")
666
+ lines.append("")
667
+ return "\n".join(lines)
668
+
669
+
670
+ def _persist(session: Session) -> None:
671
+ try:
672
+ with open(os.path.join(session.session_dir, "session.json"), "w") as f:
673
+ json.dump([_entry_display(e) for e in session.entries], f, indent=2)
674
+ with open(os.path.join(session.session_dir, "analysis.md"), "w") as f:
675
+ f.write(_render_markdown(session))
676
+ except Exception as e:
677
+ logger.warning("session persist failed: %s", e)
678
+ ```
679
+
680
+ - [ ] **Step 4: Run tests to verify they pass**
681
+
682
+ Run: `pytest tests/test_session.py -v`
683
+ Expected: all session tests PASS (Task 6 provides `PdfReportAgent`; `finish_session` tolerates its
684
+ absence via the try/except, so these pass now — `pdf_path` may be `None` until Task 6).
685
+
686
+ - [ ] **Step 5: Commit**
687
+
688
+ ```bash
689
+ git add formscout/session.py tests/test_session.py
690
+ git commit -m "feat: add screening-session accumulator with key-frame capture and persistence"
691
+ ```
692
+
693
+ ---
694
+
695
+ ## Task 6: Create `PdfReportAgent`
696
+
697
+ **Files:**
698
+ - Create: `formscout/agents/pdf_report.py`
699
+ - Test: `tests/test_pdf_report.py`
700
+
701
+ - [ ] **Step 1: Write the failing test**
702
+
703
+ Create `tests/test_pdf_report.py`:
704
+
705
+ ```python
706
+ """Tests for PdfReportAgent — no GPU, no model downloads."""
707
+ import os
708
+
709
+ from formscout.types import (
710
+ ReportResult, SessionEntry, MovementResult, BiomechFeatures, ScoreResult, JudgeResult,
711
+ )
712
+
713
+
714
+ def _entry(test_name="deep_squat", score=2, needs_human=False):
715
+ movement = MovementResult(test_name=test_name, side="na", confidence=1.0)
716
+ features = BiomechFeatures(
717
+ test_name=test_name, view="2d", side="na",
718
+ angles={"left_knee_flexion_deg": 95.0}, alignments={"knees_tracking_over_feet": False},
719
+ symmetry_delta=None, timing={"deepest_frame": 1}, confidence=0.9,
720
+ )
721
+ rubric = ScoreResult(score=2, rationale="rubric ok", confidence=0.8)
722
+ judge = JudgeResult(score=None if needs_human else score, rationale="judge rationale",
723
+ compensation_tags=["heels elevated"], corrective_hint="ankle mobility",
724
+ confidence=0.85, needs_human=needs_human)
725
+ return SessionEntry(
726
+ test_name=test_name, side="na", score=None if needs_human else score,
727
+ needs_human=needs_human, rationale="judge rationale",
728
+ compensation_tags=["heels elevated"], corrective_hint="ankle mobility",
729
+ measurements={"left_knee_flexion_deg": 95.0, "knees_tracking_over_feet": False},
730
+ confidence=0.85, view="2d", keyframe_path=None,
731
+ movement=movement, features=features, rubric_score=rubric, judge=judge,
732
+ )
733
+
734
+
735
+ def _report(composite=2):
736
+ return ReportResult(
737
+ per_test=[], composite=composite, asymmetries=[],
738
+ overlay_video_path=None, pdf_path=None,
739
+ low_confidence_flags=[], disagreement_flags=[],
740
+ )
741
+
742
+
743
+ def test_pdf_is_created(tmp_path):
744
+ from formscout.agents.pdf_report import PdfReportAgent
745
+ path = PdfReportAgent().run(_report(2), [_entry()], str(tmp_path))
746
+ assert path is not None
747
+ assert os.path.exists(path)
748
+ assert os.path.getsize(path) > 1000 # a real PDF, not an empty file
749
+ with open(path, "rb") as f:
750
+ assert f.read(5) == b"%PDF-"
751
+
752
+
753
+ def test_pdf_handles_incomplete_composite(tmp_path):
754
+ from formscout.agents.pdf_report import PdfReportAgent
755
+ path = PdfReportAgent().run(_report(None), [_entry(needs_human=True)], str(tmp_path))
756
+ assert path is not None and os.path.exists(path)
757
+ ```
758
+
759
+ - [ ] **Step 2: Run test to verify it fails**
760
+
761
+ Run: `pytest tests/test_pdf_report.py -v`
762
+ Expected: FAIL with `ModuleNotFoundError: No module named 'formscout.agents.pdf_report'`
763
+
764
+ - [ ] **Step 3: Create the agent**
765
+
766
+ Create `formscout/agents/pdf_report.py`:
767
+
768
+ ```python
769
+ """
770
+ PdfReportAgent — renders a ReportResult + session entries to a branded PDF.
771
+
772
+ Input: ReportResult, list[SessionEntry], session_dir (str)
773
+ Output: path to the written PDF (str), or None on failure.
774
+ Failure: returns None, never raises.
775
+ Params: 0 (pure rendering — no model).
776
+ License: n/a.
777
+ Gated: no.
778
+ """
779
+ from __future__ import annotations
780
+
781
+ import logging
782
+ import os
783
+
784
+ from formscout.types import ReportResult
785
+
786
+ logger = logging.getLogger(__name__)
787
+
788
+ DISCLAIMER = "Screening aid — not a diagnosis. Pain or clearing tests require a clinician."
789
+
790
+
791
+ class PdfReportAgent:
792
+ """Assembles the screening-session PDF via ReportLab."""
793
+
794
+ def run(self, report: ReportResult, entries: list, session_dir: str) -> str | None:
795
+ try:
796
+ from reportlab.lib import colors
797
+ from reportlab.lib.pagesizes import LETTER
798
+ from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
799
+ from reportlab.lib.units import inch
800
+ from reportlab.platypus import (
801
+ Image, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle,
802
+ )
803
+ except Exception as e:
804
+ logger.warning("reportlab unavailable: %s", e)
805
+ return None
806
+
807
+ out_path = os.path.join(session_dir, "formscout_report.pdf")
808
+ try:
809
+ styles = getSampleStyleSheet()
810
+ banner = ParagraphStyle(
811
+ "banner", parent=styles["Normal"], fontSize=9, textColor=colors.white,
812
+ backColor=colors.HexColor("#b45309"), alignment=1, borderPadding=6, spaceAfter=12,
813
+ )
814
+ story = []
815
+ story.append(Paragraph(f"<b>&#9888; {DISCLAIMER}</b>", banner))
816
+ story.append(Paragraph("FormScout — FMS Screening Report", styles["Title"]))
817
+
818
+ if report.composite is not None:
819
+ comp = f"Composite: <b>{report.composite} / 21</b>"
820
+ else:
821
+ comp = f"Composite: <b>Incomplete</b> — {len(entries)}/7 tests scored"
822
+ story.append(Paragraph(comp, styles["Heading2"]))
823
+ story.append(Spacer(1, 0.2 * inch))
824
+
825
+ for e in entries:
826
+ title = e.test_name.replace("_", " ").title()
827
+ if e.side in ("left", "right"):
828
+ title += f" ({e.side})"
829
+ score_txt = "Clinician review required" if e.needs_human else f"Score: {e.score}/3"
830
+ story.append(Paragraph(f"<b>{title}</b> — {score_txt}", styles["Heading3"]))
831
+ if e.rationale:
832
+ story.append(Paragraph(e.rationale, styles["Normal"]))
833
+ if e.compensation_tags:
834
+ story.append(Paragraph("Compensations: " + ", ".join(e.compensation_tags),
835
+ styles["Normal"]))
836
+ if e.corrective_hint:
837
+ story.append(Paragraph("Corrective: " + e.corrective_hint, styles["Normal"]))
838
+
839
+ items = list(e.measurements.items())[:6]
840
+ if items:
841
+ rows = [[k.replace("_", " "),
842
+ (f"{v:.1f}" if isinstance(v, float) else str(v))] for k, v in items]
843
+ tbl = Table(rows, colWidths=[3 * inch, 1.5 * inch])
844
+ tbl.setStyle(TableStyle([
845
+ ("FONTSIZE", (0, 0), (-1, -1), 8),
846
+ ("TEXTCOLOR", (0, 0), (-1, -1), colors.HexColor("#334155")),
847
+ ]))
848
+ story.append(tbl)
849
+
850
+ if e.keyframe_path and os.path.exists(e.keyframe_path):
851
+ try:
852
+ story.append(Image(e.keyframe_path, width=3.0 * inch, height=2.25 * inch))
853
+ except Exception:
854
+ story.append(Paragraph("<i>(key-frame image unavailable)</i>", styles["Normal"]))
855
+ else:
856
+ story.append(Paragraph("<i>(key-frame image unavailable)</i>", styles["Normal"]))
857
+
858
+ story.append(Spacer(1, 0.2 * inch))
859
+
860
+ if report.asymmetries:
861
+ story.append(Paragraph("Asymmetries", styles["Heading2"]))
862
+ for a in report.asymmetries:
863
+ story.append(Paragraph(
864
+ f"{a['test'].replace('_', ' ').title()}: "
865
+ f"L={a['left_score']} R={a['right_score']} (&#916; {a['delta']})",
866
+ styles["Normal"]))
867
+
868
+ flags = list(report.low_confidence_flags) + list(report.disagreement_flags)
869
+ if flags:
870
+ story.append(Paragraph("Flags", styles["Heading2"]))
871
+ for fl in flags:
872
+ story.append(Paragraph(fl, styles["Normal"]))
873
+
874
+ story.append(Spacer(1, 0.3 * inch))
875
+ story.append(Paragraph(f"<b>&#9888; {DISCLAIMER}</b>", banner))
876
+
877
+ doc = SimpleDocTemplate(out_path, pagesize=LETTER,
878
+ topMargin=0.6 * inch, bottomMargin=0.6 * inch)
879
+ doc.build(story)
880
+ return out_path
881
+ except Exception as e:
882
+ logger.warning("pdf build failed: %s", e)
883
+ return None
884
+ ```
885
+
886
+ - [ ] **Step 4: Run test to verify it passes**
887
+
888
+ Run: `pytest tests/test_pdf_report.py -v`
889
+ Expected: both tests PASS
890
+
891
+ - [ ] **Step 5: Re-run the session suite (pdf_path now populated)**
892
+
893
+ Run: `pytest tests/test_session.py -v`
894
+ Expected: all PASS (now `finish_session` returns a real `pdf_path`).
895
+
896
+ - [ ] **Step 6: Commit**
897
+
898
+ ```bash
899
+ git add formscout/agents/pdf_report.py tests/test_pdf_report.py
900
+ git commit -m "feat: add PdfReportAgent — branded ReportLab session PDF"
901
+ ```
902
+
903
+ ---
904
+
905
+ ## Task 7: Wire the session UI in `app.py`
906
+
907
+ **Files:**
908
+ - Modify: `app.py` (`process_video`, `build_app`, event wiring)
909
+
910
+ This task is verified by running the app (Gradio event wiring is not unit-tested; the
911
+ orchestration it calls is already covered by `tests/test_session.py`).
912
+
913
+ - [ ] **Step 1: Import the session module**
914
+
915
+ In `app.py`, add to the imports block (after `from formscout.startup import ensure_checkpoints`):
916
+
917
+ ```python
918
+ from formscout import session as session_mod
919
+ ```
920
+
921
+ - [ ] **Step 2: Refactor `process_video` to accumulate into a session**
922
+
923
+ Replace the entire `process_video` function (lines ~51-105) with a version that takes and
924
+ returns the session, appends an entry on success, and builds the "Session so far" table.
925
+ Replace from `def process_video(` through its final `return ...` with:
926
+
927
+ ```python
928
+ def process_video(video_path: str, test_name: str, side: str, model_key: str,
929
+ layers: list[str], session_state):
930
+ """Analyse one clip and accumulate it into the screening session."""
931
+ if not video_path:
932
+ return (
933
+ session_state, _render_empty_state(), "Upload a video to begin analysis.",
934
+ "", "", None, "", _render_session_table(session_state),
935
+ gr.update(visible=False), gr.update(visible=False),
936
+ )
937
+
938
+ if session_state is None:
939
+ session_state = session_mod.new_session()
940
+
941
+ director = Director()
942
+ state = director.run(video_path, test_name=test_name, side=side, model_key=model_key)
943
+
944
+ score_html = _render_empty_state()
945
+ score_details = ""
946
+
947
+ if state.features:
948
+ result = score_test(state.features)
949
+ judge = state.judge
950
+ if judge and judge.score is not None:
951
+ score_html = _render_score_card(judge.score, judge.confidence, judge.needs_human)
952
+ score_details = _render_score_details_judge(judge, result, state.features)
953
+ elif judge and judge.needs_human:
954
+ score_html = _render_score_card(0, 0, True)
955
+ score_details = f"### Needs Clinician Review\n{judge.rationale}"
956
+ else:
957
+ score_html = _render_score_card(result.score, result.confidence, result.needs_human)
958
+ score_details = _render_score_details(result, state.features)
959
+
960
+ # Accumulate into the session (only when we have a real analysis)
961
+ if state.ingest and state.pose2d and state.judge:
962
+ draw_trails = "trails" in {lbl.lower().replace(" ", "_") for lbl in (layers or [])}
963
+ try:
964
+ session_mod.add_analysis(
965
+ session_state, ingest=state.ingest, pose2d=state.pose2d,
966
+ features=state.features, judge=state.judge,
967
+ test_name=test_name, side=side, draw_trails=draw_trails,
968
+ )
969
+ except Exception as e:
970
+ state.warnings.append(f"session accumulation failed: {e}")
971
+
972
+ pipeline_md = _render_pipeline_status(state)
973
+ alerts = _render_alerts(state)
974
+
975
+ overlay_path = None
976
+ vel_summary = ""
977
+ layer_set = {lbl.lower().replace(" ", "_") for lbl in (layers or [])}
978
+ if layer_set and state.ingest and state.pose2d:
979
+ try:
980
+ from formscout.agents.visualizer import PoseVisualizer, build_velocity_summary
981
+ vis = PoseVisualizer()
982
+ with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
983
+ out_path = f.name
984
+ overlay_path = vis.render_video(state.ingest, state.pose2d, layer_set, out_path)
985
+ if overlay_path:
986
+ vel_summary = build_velocity_summary(state.pose2d.keypoints, vis.last_velocities)
987
+ except Exception as e:
988
+ alerts = (alerts or "") + f"\n⚠️ Visualizer error: {e}"
989
+
990
+ has_entries = bool(session_state and session_state.entries)
991
+ return (
992
+ session_state, score_html, pipeline_md, score_details, alerts,
993
+ overlay_path, vel_summary, _render_session_table(session_state),
994
+ gr.update(visible=has_entries), gr.update(visible=has_entries),
995
+ )
996
+ ```
997
+
998
+ - [ ] **Step 3: Add the session-table renderer and finish handler**
999
+
1000
+ In `app.py`, add these two functions just before `def build_app()`:
1001
+
1002
+ ```python
1003
+ def _render_session_table(session_state) -> str:
1004
+ """Render the accumulated 'Session so far' table as markdown."""
1005
+ if not session_state or not session_state.entries:
1006
+ return "*No clips analysed yet.*"
1007
+ lines = ["| Test | Side | Score | Status |", "|---|---|---|---|"]
1008
+ for e in session_state.entries:
1009
+ test = e.test_name.replace("_", " ").title()
1010
+ side = e.side if e.side in ("left", "right") else "—"
1011
+ if e.needs_human:
1012
+ score, status = "—", "⚠️ Clinician review"
1013
+ else:
1014
+ score, status = f"{e.score}/3", "✓ scored"
1015
+ lines.append(f"| {test} | {side} | {score} | {status} |")
1016
+ return "\n".join(lines)
1017
+
1018
+
1019
+ def _finish_session(session_state):
1020
+ """Build the composite report + PDF for the whole session."""
1021
+ if not session_state or not session_state.entries:
1022
+ return ("⚠️ No clips analysed yet — analyse at least one clip first.",
1023
+ None, None)
1024
+
1025
+ report, pdf_path = session_mod.finish_session(session_state)
1026
+ if report is None:
1027
+ return ("⚠️ Nothing to report.", None, None)
1028
+
1029
+ if report.composite is not None:
1030
+ summary = [f"## Composite: {report.composite} / 21"]
1031
+ else:
1032
+ n = len(session_state.entries)
1033
+ summary = [f"## Composite: Incomplete — {n}/7 tests scored",
1034
+ "*(One or more tests need clinician review or were unscored.)*"]
1035
+
1036
+ if report.asymmetries:
1037
+ summary.append("\n### Asymmetries")
1038
+ for a in report.asymmetries:
1039
+ test = a["test"].replace("_", " ").title()
1040
+ summary.append(f"- **{test}:** L={a['left_score']} R={a['right_score']} (Δ {a['delta']})")
1041
+
1042
+ flags = list(report.low_confidence_flags) + list(report.disagreement_flags)
1043
+ if flags:
1044
+ summary.append("\n### Flags")
1045
+ for fl in flags:
1046
+ summary.append(f"- {fl}")
1047
+
1048
+ md_path = os.path.join(session_state.session_dir, "analysis.md")
1049
+ md_out = md_path if os.path.exists(md_path) else None
1050
+ return "\n".join(summary), pdf_path, md_out
1051
+ ```
1052
+
1053
+ Also add `import os` to the top of `app.py` if not already present (it currently imports only
1054
+ `tempfile` and `gradio`). Add after `import tempfile`:
1055
+
1056
+ ```python
1057
+ import os
1058
+ ```
1059
+
1060
+ - [ ] **Step 4: Add the session state, buttons, and outputs to `build_app`**
1061
+
1062
+ In `build_app`, inside the `with gr.Blocks(...) as app:` block, immediately after the line
1063
+ `with gr.Blocks(title="FormScout — FMS Screening Aid") as app:` add:
1064
+
1065
+ ```python
1066
+ session_state = gr.State(None)
1067
+ ```
1068
+
1069
+ Then, in the left input column, replace the single submit button block:
1070
+
1071
+ ```python
1072
+ submit_btn = gr.Button(
1073
+ "🎯 Score Movement",
1074
+ variant="primary",
1075
+ size="lg",
1076
+ )
1077
+ ```
1078
+
1079
+ with:
1080
+
1081
+ ```python
1082
+ submit_btn = gr.Button(
1083
+ "🎯 Score Movement",
1084
+ variant="primary",
1085
+ size="lg",
1086
+ )
1087
+ with gr.Row():
1088
+ new_clip_btn = gr.Button("➕ Analyse new clip", visible=False)
1089
+ finish_btn = gr.Button("✅ Finish & generate PDF",
1090
+ variant="primary", visible=False)
1091
+ ```
1092
+
1093
+ In the right results column, add a "Session" tab and a finish-output area. Inside `with gr.Tabs():`
1094
+ add a new tab after the "🎬 Overlay Video" tab:
1095
+
1096
+ ```python
1097
+ with gr.TabItem("🗂️ Session"):
1098
+ session_table = gr.Markdown("*No clips analysed yet.*")
1099
+ finish_summary = gr.Markdown("")
1100
+ pdf_file = gr.File(label="Screening Report (PDF)", visible=True)
1101
+ md_file = gr.File(label="Analysis Log (Markdown)", visible=True)
1102
+ ```
1103
+
1104
+ - [ ] **Step 5: Update event wiring**
1105
+
1106
+ Replace the `_map_inputs` function and `submit_btn.click(...)` block at the bottom of `build_app`
1107
+ with:
1108
+
1109
+ ```python
1110
+ def _map_inputs(video, test_display_name, side_display, pose_model_key, overlay_layers, sess):
1111
+ """Map UI display values to internal values and accumulate into the session."""
1112
+ test_map = {name: val for name, val in FMS_TESTS}
1113
+ test_name = test_map.get(test_display_name, "deep_squat")
1114
+ side = {"N/A": "na", "Left": "left", "Right": "right"}.get(side_display, "na")
1115
+ return process_video(video, test_name, side, pose_model_key, overlay_layers, sess)
1116
+
1117
+ submit_btn.click(
1118
+ fn=_map_inputs,
1119
+ inputs=[video_input, test_dropdown, side_dropdown, pose_model_dropdown,
1120
+ overlay_layers, session_state],
1121
+ outputs=[session_state, score_html, pipeline_md, score_details, alerts_md,
1122
+ overlay_video, velocity_md, session_table, new_clip_btn, finish_btn],
1123
+ )
1124
+
1125
+ def _new_clip():
1126
+ """Clear inputs for the next clip; keep the session intact."""
1127
+ return None, _render_empty_state(), ""
1128
+
1129
+ new_clip_btn.click(
1130
+ fn=_new_clip,
1131
+ inputs=[],
1132
+ outputs=[video_input, score_html, score_details],
1133
+ )
1134
+
1135
+ finish_btn.click(
1136
+ fn=_finish_session,
1137
+ inputs=[session_state],
1138
+ outputs=[finish_summary, pdf_file, md_file],
1139
+ )
1140
+ ```
1141
+
1142
+ - [ ] **Step 6: Verify the full test suite still passes**
1143
+
1144
+ Run: `pytest tests/ -q`
1145
+ Expected: all tests pass except the single pre-existing known failure documented in CLAUDE.md
1146
+ (`test_unimplemented_test_returns_low_confidence`). No new failures.
1147
+
1148
+ - [ ] **Step 7: Manually verify the app**
1149
+
1150
+ Run: `python3 app.py`
1151
+ Then in the browser:
1152
+ 1. Upload a clip, pick a test, click **Score Movement** → score card appears; the **Session** tab
1153
+ shows one row; the two new buttons appear.
1154
+ 2. Click **➕ Analyse new clip** → the video input clears, the session row persists.
1155
+ 3. Analyse a second test → a second row appears.
1156
+ 4. Click **✅ Finish & generate PDF** → the Session tab shows the composite summary and a
1157
+ downloadable PDF (open it: disclaimer top + bottom, per-test blocks with key-frame images,
1158
+ composite or "Incomplete"). The Markdown log is also downloadable.
1159
+
1160
+ Expected: all four steps work; PDF opens and contains the disclaimer, composite, and per-test sections.
1161
+
1162
+ - [ ] **Step 8: Commit**
1163
+
1164
+ ```bash
1165
+ git add app.py
1166
+ git commit -m "feat: accumulate FMS clips into a session with composite report + PDF export"
1167
+ ```
1168
+
1169
+ ---
1170
+
1171
+ ## Task 8: Update docs
1172
+
1173
+ **Files:**
1174
+ - Modify: `CLAUDE.md` (Build phases / status)
1175
+ - Modify: `MODEL_BUDGET.md` (no param change — note PDF agent adds 0 params, for completeness)
1176
+
1177
+ - [ ] **Step 1: Update the Phase 4 line in CLAUDE.md**
1178
+
1179
+ In `CLAUDE.md`, in the "Build phases" section, update the Phase 4 line from:
1180
+
1181
+ ```
1182
+ 4. **Phase 4 — Polish + ship:** Custom Svelte UI components, PDF export, agent trace to Hub, blog post. (Overlay video already done via `PoseVisualizer`.)
1183
+ ```
1184
+
1185
+ to:
1186
+
1187
+ ```
1188
+ 4. **Phase 4 — Polish + ship:** Custom Svelte UI components, agent trace to Hub, blog post. (Overlay video done via `PoseVisualizer`; full 7-test session + PDF export done via `formscout/session.py` + `PdfReportAgent`.)
1189
+ ```
1190
+
1191
+ - [ ] **Step 2: Note the PDF agent in the architecture section**
1192
+
1193
+ In `CLAUDE.md`, under "### Rubric scorers" or near the ReportAgent description, this is optional
1194
+ context; no required change. Skip if no natural home.
1195
+
1196
+ - [ ] **Step 3: Commit**
1197
+
1198
+ ```bash
1199
+ git add CLAUDE.md
1200
+ git commit -m "docs: mark full FMS session + PDF export complete in build phases"
1201
+ ```
1202
+
1203
+ ---
1204
+
1205
+ ## Self-Review Notes (already applied)
1206
+
1207
+ - **Spec coverage:** session accumulation (Task 5), two-button UX (Task 7), on-disk MD/JSON/keyframes (Task 5), key-frame from `features.timing` (Tasks 3–5), ReportLab PDF top/bottom disclaimer + composite + per-test + asymmetry + flags (Task 6), `SessionEntry` type (Task 2), `ReportAgent` reuse (Task 5 `finish_session`), composite-null-on-needs-human (Task 5 test), error tolerance / never-raise (Tasks 4–6). All covered.
1208
+ - **Type consistency:** `SessionEntry` field names are identical across Tasks 2, 5, 6, 7. `finish_session` returns `(ReportResult | None, str | None)` and is consumed that way in Task 7. `render_frame(ingest, pose2d, frame_idx, layers, caption, out_png)` signature matches its callers.
1209
+ - **No placeholders:** every code step shows complete code; every run step states the exact command + expected outcome.
docs/superpowers/specs/2026-06-13-full-fms-session-pdf-design.md ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Full FMS Session + PDF Report — Design
2
+
3
+ **Date:** 2026-06-13
4
+ **Status:** Approved (brainstorming) — pending implementation plan
5
+ **Owner:** FormScout
6
+ **Related:** `formscout/agents/report.py`, `formscout/agents/visualizer.py`, `formscout/agents/biomechanics.py`, `app.py`, `formscout/types.py`
7
+
8
+ ## Problem
9
+
10
+ FormScout today scores **one** FMS test per upload. A real Functional Movement Screen is **all 7 tests** producing a single **composite 0–21** with asymmetry flags. `ReportAgent` and `ReportResult.composite` already support a multi-test report, but the UI never accumulates more than one test, and `ReportResult.pdf_path` is a hardcoded `None` stub.
11
+
12
+ This feature turns the one-clip scorer into a **screening session**: each analyzed clip accumulates; a "Finish" action produces the composite report plus a downloadable, brand-consistent **PDF**. Each clip's worst-moment frame is captured as an annotated still embedded in both an on-disk log and the PDF.
13
+
14
+ ## Goals
15
+
16
+ - Accumulate multiple analyzed clips into one session, then emit a composite 0–21 report.
17
+ - Generate a clinician/client-facing **PDF** handout (ReportLab) with scores, rationale, asymmetries, key-frame images, and the safety disclaimer.
18
+ - Capture and annotate the **worst-moment frame** per test (the governing/peak frame already computed by `BiomechanicsAgent`).
19
+ - Persist each analysis incrementally to disk (`session.json`, `analysis.md`, key-frame PNGs) until "Finish" is clicked.
20
+
21
+ ## Non-goals (YAGNI)
22
+
23
+ - No cross-restart session reload — the session lives in `gr.State` + a temp dir for the browser session.
24
+ - No PDF styling beyond a clean branded layout (no HTML/CSS engine; ReportLab only).
25
+ - No RAG / exemplar-clip citations (separate future spec).
26
+ - No changes to the scoring pipeline, rubric functions, or Director flow.
27
+
28
+ ## UX
29
+
30
+ The current one-clip-at-a-time flow is preserved. Two new buttons appear after an analysis completes:
31
+
32
+ - **➕ Analyse new clip** — clears the video/test inputs for the next upload; **keeps** the session.
33
+ - **✅ Finish & generate PDF** — runs the report + PDF over everything accumulated so far.
34
+
35
+ After each analysis, a **"Session so far"** table updates: `test · side · score · status`. Finish renders an on-screen composite scorecard + asymmetry summary and exposes the PDF (and `analysis.md`) via `gr.File` for download.
36
+
37
+ Guard: Finish with zero analyses → warning, no PDF.
38
+
39
+ ## Components
40
+
41
+ ### 1. Session state + on-disk store
42
+
43
+ A per-session temp directory `<tmpdir>/formscout_sessions/<session_id>/`:
44
+
45
+ - `session.json` — structured list of entries; **source of truth** for the PDF.
46
+ - `analysis.md` — human-readable log, appended after each clip.
47
+ - `keyframes/<test_name>_<side>.png` — annotated worst-frame stills.
48
+
49
+ Session identity lives in a `gr.State`. Each entry carries:
50
+
51
+ - `test_name`, `side`, `score` (judge score, else rubric), `needs_human`
52
+ - `rationale`, `compensation_tags`, `corrective_hint`
53
+ - key measurements (selected `angles` / `alignments`)
54
+ - `confidence`, `view` (`"2d"`/`"3d"`)
55
+ - `keyframe_path`
56
+ - the `movement` / `features` / `rubric_score` / `judge` objects that `ReportAgent.run()` consumes
57
+
58
+ Persistence lasts until Finish; files are kept afterward for download. Cross-restart cleanup is best-effort and out of scope.
59
+
60
+ ### 2. Key-frame capture
61
+
62
+ New method on `PoseVisualizer`:
63
+
64
+ ```python
65
+ def render_frame(self, ingest, pose2d, frame_idx: int,
66
+ layers: set[str], caption: str, out_png: str) -> str | None
67
+ ```
68
+
69
+ - `frame_idx` comes from `features.timing`, which already stores the governing frame per test:
70
+ `deep_squat → deepest_frame`, `hurdle_step → peak_step_frame`,
71
+ `inline_lunge → deepest_lunge_frame`, `shoulder_mobility → measure_frame`,
72
+ `active_slr → peak_raise_frame`.
73
+ - `trunk_stability_pushup` and `rotary_stability` currently store only counts in `timing`. Add the worst-sag-frame and peak-extension-frame index to their `timing` dicts (one-line change in each `BiomechanicsAgent` method).
74
+ - Reuses `_draw_skeleton` (+ optional `_draw_trails`) on the single frame, overlays a caption naming the worst compensation, writes a PNG.
75
+ - Returns `None` on any failure — never raises, never blocks the entry.
76
+
77
+ The "worst compensation" caption is derived from `judge.compensation_tags` (preferred) or the failed `alignments` (fallback).
78
+
79
+ ### 3. PDF generator
80
+
81
+ New module `formscout/agents/pdf_report.py`:
82
+
83
+ ```python
84
+ class PdfReportAgent:
85
+ def run(self, report_result: ReportResult,
86
+ entries: list[SessionEntry], session_dir: str) -> str | None
87
+ ```
88
+
89
+ Uses **ReportLab** (pure-Python, no system deps — safe on HF Spaces/ZeroGPU). Layout:
90
+
91
+ - Safety disclaimer banner at **top and bottom** (mirrors the UI invariant).
92
+ - Title/brand header + date.
93
+ - Composite **0–21** badge, or "Incomplete — N/7 tests scored" when `composite is None`.
94
+ - Per-test block: score, rationale, key measurements, compensation tags, corrective hint, the annotated key-frame image, asymmetry delta (bilateral).
95
+ - Flags section: low-confidence, rubric↔judge disagreement, needs-human.
96
+ - Populates `ReportResult.pdf_path`.
97
+
98
+ Returns the PDF path, or `None` on failure (UI surfaces the error and keeps the session for retry). Image embedding tolerates a missing/`None` `keyframe_path` with a placeholder line.
99
+
100
+ ### 4. ReportAgent reuse
101
+
102
+ At Finish, build the entry list and call the existing `ReportAgent.run()` for composite + asymmetries + flags. The bilateral lower-score + asymmetry-delta logic and the null-composite rule already exist and are not rewritten. A small adapter converts `SessionEntry` objects to the dict schema `ReportAgent.run()` expects (or `ReportAgent` gains overload tolerance — implementer's choice, keep it minimal).
103
+
104
+ ### 5. Types
105
+
106
+ Add a `SessionEntry` frozen dataclass to `formscout/types.py` (consistent with the "every agent I/O is a typed dataclass" standard), including `keyframe_path: str | None`. Populate the existing `ReportResult.pdf_path` (and optionally `overlay_video_path`). No other type changes.
107
+
108
+ ### 6. UI (`app.py`)
109
+
110
+ - Add a `gr.State` holding the session (id + entries).
111
+ - After each analysis: render the scorecard as today, append the entry, write `session.json`/`analysis.md`/keyframe PNG, refresh the "Session so far" table, and reveal the two buttons.
112
+ - **Analyse new clip**: reset the video/test/side inputs; keep session state.
113
+ - **Finish & generate PDF**: `ReportAgent.run` → `PdfReportAgent.run` → display composite + asymmetry summary + `gr.File` downloads (PDF + `analysis.md`).
114
+ - Guard: Finish with zero analyses → warning.
115
+
116
+ ## Data flow
117
+
118
+ ```
119
+ upload → Director.run → score
120
+ → build SessionEntry (+ render_frame keyframe png)
121
+ → append to gr.State + write session.json / analysis.md / keyframe png
122
+ → refresh "Session so far" table
123
+
124
+ Finish → ReportAgent.run(entries) → composite / asymmetries / flags
125
+ → PdfReportAgent.run(...) → pdf_path
126
+ → on-screen composite + gr.File (PDF, analysis.md)
127
+ ```
128
+
129
+ ## Error handling
130
+
131
+ - Key-frame render fails → entry still saved; PDF shows an image placeholder.
132
+ - PDF generation fails → surface the error, keep the session intact for retry.
133
+ - `needs_human` entry → no numeric score; PDF shows "Clinician review required"; composite null.
134
+ - Composite is `None` whenever any test is unscored or needs human review (existing rule — never show a partial 0–21 as complete).
135
+ - All disk writes tolerate failure with a logged warning; a write failure degrades the artifact but never blocks scoring.
136
+
137
+ ## Testing (must run without model downloads)
138
+
139
+ - `tests/test_pdf_report.py` — synthetic `ReportResult` + entries → PDF file created, non-zero size, contains the disclaimer text and composite line.
140
+ - `tests/test_session.py` — accumulation; composite math; bilateral lower-score + asymmetry delta; null composite when one entry `needs_human`.
141
+ - `tests/test_keyframe.py` — `render_frame` returns a real PNG path (file exists) for a synthetic frame; returns `None` gracefully on bad input.
142
+
143
+ ## Invariants preserved
144
+
145
+ - Pipeline stays headless — no Gradio imports in agent files (`PdfReportAgent` is a pure agent; key-frame capture stays in `visualizer.py`, the existing UI-layer component).
146
+ - Safety disclaimer present top and bottom of the PDF, mirroring the UI.
147
+ - Pain / clearing / needs-human is never auto-scored; composite null when any test unscored.
148
+ - New code follows the engineering standards: one public entrypoint per agent, typed dataclass I/O, `confidence`/`notes` where applicable, module docstring stating purpose/inputs/outputs/failure/params/license/gated.
149
+
150
+ ## Open implementation choices (left to the plan)
151
+
152
+ - Exact `SessionEntry` → `ReportAgent` dict adapter shape.
153
+ - Which measurements to surface per test in the PDF (a curated subset, not the full `angles` dump).
154
+ - PDF assertion strategy in tests (text extraction vs. size/smoke).
formscout/agents/biomechanics.py CHANGED
@@ -472,7 +472,7 @@ class BiomechanicsAgent:
472
  notes_parts = []
473
 
474
  # Analyze multiple frames to detect sag/lag
475
- trunk_angles_over_time = []
476
  for i, kps in enumerate(pose2d.keypoints):
477
  l_sh = _get_joint(kps, L_SHOULDER)
478
  r_sh = _get_joint(kps, R_SHOULDER)
@@ -482,9 +482,6 @@ class BiomechanicsAgent:
482
  r_ankle = _get_joint(kps, R_ANKLE)
483
 
484
  if l_sh and r_sh and l_hip and r_hip and l_ankle and r_ankle:
485
- mid_sh = ((l_sh[1] + r_sh[1]) / 2,)
486
- mid_hip = ((l_hip[1] + r_hip[1]) / 2,)
487
- mid_ankle = ((l_ankle[1] + r_ankle[1]) / 2,)
488
  # Sag = hip drops below shoulder-ankle line
489
  sh_y = (l_sh[1] + r_sh[1]) / 2
490
  hip_y = (l_hip[1] + r_hip[1]) / 2
@@ -492,12 +489,15 @@ class BiomechanicsAgent:
492
  # In image coords: sag = hip_y > midpoint of shoulder-ankle Y
493
  expected_hip_y = (sh_y + ankle_y) / 2
494
  sag_px = hip_y - expected_hip_y
495
- trunk_angles_over_time.append(sag_px)
496
-
497
- if trunk_angles_over_time:
498
- max_sag = max(trunk_angles_over_time)
499
- variance = (sum((x - sum(trunk_angles_over_time) / len(trunk_angles_over_time))**2
500
- for x in trunk_angles_over_time) / len(trunk_angles_over_time)) ** 0.5
 
 
 
501
  angles["max_sag_px"] = max_sag
502
  angles["trunk_variance_px"] = variance
503
  alignments["body_rigid"] = max_sag < 30 and variance < 15
@@ -523,7 +523,7 @@ class BiomechanicsAgent:
523
  test_name="trunk_stability_pushup", view=view, side="na",
524
  angles=angles, alignments=alignments,
525
  symmetry_delta=None,
526
- timing={"n_frames_analyzed": len(trunk_angles_over_time)},
527
  confidence=confidence,
528
  notes="; ".join(notes_parts) if notes_parts else "",
529
  )
 
472
  notes_parts = []
473
 
474
  # Analyze multiple frames to detect sag/lag
475
+ trunk_sags: list[tuple[int, float]] = [] # (frame_idx, sag_px)
476
  for i, kps in enumerate(pose2d.keypoints):
477
  l_sh = _get_joint(kps, L_SHOULDER)
478
  r_sh = _get_joint(kps, R_SHOULDER)
 
482
  r_ankle = _get_joint(kps, R_ANKLE)
483
 
484
  if l_sh and r_sh and l_hip and r_hip and l_ankle and r_ankle:
 
 
 
485
  # Sag = hip drops below shoulder-ankle line
486
  sh_y = (l_sh[1] + r_sh[1]) / 2
487
  hip_y = (l_hip[1] + r_hip[1]) / 2
 
489
  # In image coords: sag = hip_y > midpoint of shoulder-ankle Y
490
  expected_hip_y = (sh_y + ankle_y) / 2
491
  sag_px = hip_y - expected_hip_y
492
+ trunk_sags.append((i, sag_px))
493
+
494
+ max_sag_frame = 0
495
+ if trunk_sags:
496
+ sags = [s for _, s in trunk_sags]
497
+ max_sag_frame = max(trunk_sags, key=lambda t: t[1])[0]
498
+ mean = sum(sags) / len(sags)
499
+ variance = (sum((x - mean) ** 2 for x in sags) / len(sags)) ** 0.5
500
+ max_sag = max(sags)
501
  angles["max_sag_px"] = max_sag
502
  angles["trunk_variance_px"] = variance
503
  alignments["body_rigid"] = max_sag < 30 and variance < 15
 
523
  test_name="trunk_stability_pushup", view=view, side="na",
524
  angles=angles, alignments=alignments,
525
  symmetry_delta=None,
526
+ timing={"n_frames_analyzed": len(trunk_sags), "max_sag_frame": max_sag_frame},
527
  confidence=confidence,
528
  notes="; ".join(notes_parts) if notes_parts else "",
529
  )
formscout/agents/pdf_report.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PdfReportAgent — renders a ReportResult + session entries to a branded PDF.
3
+
4
+ Input: ReportResult, list[SessionEntry], session_dir (str)
5
+ Output: path to the written PDF (str), or None on failure.
6
+ Failure: returns None, never raises.
7
+ Params: 0 (pure rendering — no model).
8
+ License: n/a.
9
+ Gated: no.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import os
15
+
16
+ from formscout.types import ReportResult
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ DISCLAIMER = "Screening aid — not a diagnosis. Pain or clearing tests require a clinician."
21
+
22
+
23
+ class PdfReportAgent:
24
+ """Assembles the screening-session PDF via ReportLab."""
25
+
26
+ def run(self, report: ReportResult, entries: list, session_dir: str) -> str | None:
27
+ try:
28
+ from reportlab.lib import colors
29
+ from reportlab.lib.pagesizes import LETTER
30
+ from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
31
+ from reportlab.lib.units import inch
32
+ from reportlab.platypus import (
33
+ Image, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle,
34
+ )
35
+ except Exception as e:
36
+ logger.warning("reportlab unavailable: %s", e)
37
+ return None
38
+
39
+ out_path = os.path.join(session_dir, "formscout_report.pdf")
40
+ try:
41
+ styles = getSampleStyleSheet()
42
+ banner = ParagraphStyle(
43
+ "banner", parent=styles["Normal"], fontSize=9, textColor=colors.white,
44
+ backColor=colors.HexColor("#b45309"), alignment=1, borderPadding=6, spaceAfter=12,
45
+ )
46
+ story = []
47
+ story.append(Paragraph(f"<b>&#9888; {DISCLAIMER}</b>", banner))
48
+ story.append(Paragraph("FormScout — FMS Screening Report", styles["Title"]))
49
+
50
+ if report.composite is not None:
51
+ comp = f"Composite: <b>{report.composite} / 21</b>"
52
+ else:
53
+ comp = f"Composite: <b>Incomplete</b> — {len(entries)}/7 tests scored"
54
+ story.append(Paragraph(comp, styles["Heading2"]))
55
+ story.append(Spacer(1, 0.2 * inch))
56
+
57
+ for e in entries:
58
+ title = e.test_name.replace("_", " ").title()
59
+ if e.side in ("left", "right"):
60
+ title += f" ({e.side})"
61
+ score_txt = "Clinician review required" if e.needs_human else f"Score: {e.score}/3"
62
+ story.append(Paragraph(f"<b>{title}</b> — {score_txt}", styles["Heading3"]))
63
+ if e.rationale:
64
+ story.append(Paragraph(e.rationale, styles["Normal"]))
65
+ if e.compensation_tags:
66
+ story.append(Paragraph("Compensations: " + ", ".join(e.compensation_tags),
67
+ styles["Normal"]))
68
+ if e.corrective_hint:
69
+ story.append(Paragraph("Corrective: " + e.corrective_hint, styles["Normal"]))
70
+
71
+ items = list(e.measurements.items())[:6]
72
+ if items:
73
+ rows = [[k.replace("_", " "),
74
+ (f"{v:.1f}" if isinstance(v, float) else str(v))] for k, v in items]
75
+ tbl = Table(rows, colWidths=[3 * inch, 1.5 * inch])
76
+ tbl.setStyle(TableStyle([
77
+ ("FONTSIZE", (0, 0), (-1, -1), 8),
78
+ ("TEXTCOLOR", (0, 0), (-1, -1), colors.HexColor("#334155")),
79
+ ]))
80
+ story.append(tbl)
81
+
82
+ if e.keyframe_path and os.path.exists(e.keyframe_path):
83
+ try:
84
+ story.append(Image(e.keyframe_path, width=3.0 * inch, height=2.25 * inch))
85
+ except Exception:
86
+ story.append(Paragraph("<i>(key-frame image unavailable)</i>", styles["Normal"]))
87
+ else:
88
+ story.append(Paragraph("<i>(key-frame image unavailable)</i>", styles["Normal"]))
89
+
90
+ story.append(Spacer(1, 0.2 * inch))
91
+
92
+ if report.asymmetries:
93
+ story.append(Paragraph("Asymmetries", styles["Heading2"]))
94
+ for a in report.asymmetries:
95
+ story.append(Paragraph(
96
+ f"{a['test'].replace('_', ' ').title()}: "
97
+ f"L={a['left_score']} R={a['right_score']} (&#916; {a['delta']})",
98
+ styles["Normal"]))
99
+
100
+ flags = list(report.low_confidence_flags) + list(report.disagreement_flags)
101
+ if flags:
102
+ story.append(Paragraph("Flags", styles["Heading2"]))
103
+ for fl in flags:
104
+ story.append(Paragraph(fl, styles["Normal"]))
105
+
106
+ story.append(Spacer(1, 0.3 * inch))
107
+ story.append(Paragraph(f"<b>&#9888; {DISCLAIMER}</b>", banner))
108
+
109
+ doc = SimpleDocTemplate(out_path, pagesize=LETTER,
110
+ topMargin=0.6 * inch, bottomMargin=0.6 * inch)
111
+ doc.build(story)
112
+ return out_path
113
+ except Exception as e:
114
+ logger.warning("pdf build failed: %s", e)
115
+ return None
formscout/agents/visualizer.py CHANGED
@@ -329,6 +329,53 @@ class PoseVisualizer:
329
  logger.warning("render_video failed: %s", e)
330
  return None
331
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
 
333
  # ── Velocity summary ──────────────────────────────────────────────────────────
334
 
 
329
  logger.warning("render_video failed: %s", e)
330
  return None
331
 
332
+ def render_frame(
333
+ self,
334
+ ingest,
335
+ pose2d,
336
+ frame_idx: int,
337
+ layers: set[str],
338
+ caption: str = "",
339
+ out_png: str | None = None,
340
+ ) -> str | None:
341
+ """Render a single annotated still (skeleton + optional trails + caption).
342
+
343
+ frame_idx is typically the governing frame from BiomechFeatures.timing.
344
+ Returns the PNG path on success, None on any failure. Never raises.
345
+ """
346
+ try:
347
+ if not (0 <= frame_idx < len(ingest.frames)) or frame_idx >= len(pose2d.keypoints):
348
+ return None
349
+
350
+ frame = ingest.frames[frame_idx].copy()
351
+ kps = pose2d.keypoints[frame_idx]
352
+
353
+ if "trails" in layers:
354
+ trail: dict[int, deque] = {j: deque(maxlen=TRAIL_LENGTH) for j in range(17)}
355
+ start = max(0, frame_idx - TRAIL_LENGTH)
356
+ for fi in range(start, frame_idx + 1):
357
+ for j, kp in pose2d.keypoints[fi].items():
358
+ if kp.get("conf", 0.0) >= CONF_THRESHOLD:
359
+ trail[j].append((kp["x"], kp["y"]))
360
+ frame = self._draw_trails(frame, trail)
361
+
362
+ if "skeleton" in layers:
363
+ frame = self._draw_skeleton(frame, kps)
364
+
365
+ if caption:
366
+ cv2.rectangle(frame, (0, 0), (frame.shape[1], 28), (0, 0, 0), -1)
367
+ cv2.putText(frame, caption[:80], (8, 20), cv2.FONT_HERSHEY_SIMPLEX,
368
+ 0.55, (255, 255, 255), 1, cv2.LINE_AA)
369
+
370
+ if out_png is None:
371
+ out_png = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name
372
+
373
+ ok = cv2.imwrite(out_png, frame)
374
+ return out_png if ok else None
375
+ except Exception as e:
376
+ logger.warning("render_frame failed: %s", e)
377
+ return None
378
+
379
 
380
  # ── Velocity summary ──────────────────────────────────────────────────────────
381
 
formscout/session.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Screening-session accumulator.
3
+
4
+ Accumulates one SessionEntry per analyzed clip, persists each to a temp session
5
+ dir (session.json + analysis.md + key-frame PNGs), and on finish builds a
6
+ ReportResult (via ReportAgent) + a PDF (via PdfReportAgent).
7
+
8
+ Pure orchestration — no Gradio imports. Disk writes tolerate failure with a
9
+ logged warning and never block scoring.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import os
16
+ import tempfile
17
+ import uuid
18
+ from dataclasses import dataclass, replace
19
+
20
+ from formscout.rubric import score_test
21
+ from formscout.types import MovementResult, ReportResult, SessionEntry
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Maps each test to the BiomechFeatures.timing key holding its governing frame.
26
+ TIMING_KEY = {
27
+ "deep_squat": "deepest_frame",
28
+ "hurdle_step": "peak_step_frame",
29
+ "inline_lunge": "deepest_lunge_frame",
30
+ "shoulder_mobility": "measure_frame",
31
+ "active_slr": "peak_raise_frame",
32
+ "trunk_stability_pushup": "max_sag_frame",
33
+ "rotary_stability": "peak_extension_frame",
34
+ }
35
+
36
+
37
+ @dataclass
38
+ class Session:
39
+ """Mutable session: an id, its temp dir, and accumulated entries."""
40
+ session_id: str
41
+ session_dir: str
42
+ entries: list # list[SessionEntry]
43
+
44
+
45
+ def new_session() -> Session:
46
+ sid = uuid.uuid4().hex[:12]
47
+ base = os.path.join(tempfile.gettempdir(), "formscout_sessions", sid)
48
+ try:
49
+ os.makedirs(os.path.join(base, "keyframes"), exist_ok=True)
50
+ except Exception as e:
51
+ logger.warning("session dir create failed: %s", e)
52
+ return Session(session_id=sid, session_dir=base, entries=[])
53
+
54
+
55
+ def governing_frame_index(features) -> int | None:
56
+ """Return the governing frame index for this test, or None."""
57
+ key = TIMING_KEY.get(features.test_name)
58
+ if key is None:
59
+ return None
60
+ idx = features.timing.get(key)
61
+ return int(idx) if isinstance(idx, (int, float)) else None
62
+
63
+
64
+ def worst_compensation_caption(judge, features) -> str:
65
+ """Short caption naming the worst compensation for the key-frame still."""
66
+ if judge and getattr(judge, "compensation_tags", None):
67
+ return ", ".join(judge.compensation_tags)
68
+ failed = [k.replace("_", " ") for k, v in features.alignments.items() if v is False]
69
+ return ("compensation: " + ", ".join(failed)) if failed else "key position"
70
+
71
+
72
+ def add_analysis(session, *, ingest, pose2d, features, judge, test_name, side,
73
+ draw_trails: bool = False) -> SessionEntry:
74
+ """Build a SessionEntry from a completed analysis, render its key-frame,
75
+ persist the session, append, and return the entry."""
76
+ movement = MovementResult(test_name=test_name, side=side, confidence=1.0)
77
+ rubric = score_test(features)
78
+
79
+ needs_human = bool((judge and judge.needs_human) or rubric.needs_human)
80
+ if needs_human:
81
+ score = None
82
+ elif judge and judge.score is not None:
83
+ score = judge.score
84
+ else:
85
+ score = rubric.score
86
+
87
+ keyframe_path = None
88
+ idx = governing_frame_index(features)
89
+ if idx is not None and 0 <= idx < len(pose2d.keypoints):
90
+ from formscout.agents.visualizer import PoseVisualizer
91
+ caption = (f"{test_name.replace('_', ' ').title()} "
92
+ f"({side}) — {worst_compensation_caption(judge, features)}")
93
+ layers = {"skeleton", "trails"} if draw_trails else {"skeleton"}
94
+ out_png = os.path.join(session.session_dir, "keyframes", f"{test_name}_{side}.png")
95
+ try:
96
+ keyframe_path = PoseVisualizer().render_frame(ingest, pose2d, idx, layers, caption, out_png)
97
+ except Exception as e:
98
+ logger.warning("keyframe render failed: %s", e)
99
+
100
+ measurements = {}
101
+ measurements.update(features.angles)
102
+ measurements.update(features.alignments)
103
+
104
+ entry = SessionEntry(
105
+ test_name=test_name, side=side, score=score, needs_human=needs_human,
106
+ rationale=(judge.rationale if judge else rubric.rationale),
107
+ compensation_tags=list(judge.compensation_tags) if judge else [],
108
+ corrective_hint=(judge.corrective_hint if judge else ""),
109
+ measurements=measurements,
110
+ confidence=(judge.confidence if judge else rubric.confidence),
111
+ view=features.view,
112
+ keyframe_path=keyframe_path,
113
+ movement=movement, features=features, rubric_score=rubric, judge=judge,
114
+ )
115
+ session.entries.append(entry)
116
+ _persist(session)
117
+ return entry
118
+
119
+
120
+ def finish_session(session) -> tuple[ReportResult | None, str | None]:
121
+ """Build the composite report + PDF. Returns (report, pdf_path).
122
+ Returns (None, None) for an empty session."""
123
+ if not session.entries:
124
+ return None, None
125
+
126
+ from formscout.agents.report import ReportAgent
127
+ report_inputs = [{
128
+ "movement": e.movement, "features": e.features,
129
+ "rubric_score": e.rubric_score, "judge": e.judge, "side": e.side,
130
+ } for e in session.entries]
131
+ report = ReportAgent().run(report_inputs)
132
+
133
+ pdf_path = None
134
+ try:
135
+ from formscout.agents.pdf_report import PdfReportAgent
136
+ pdf_path = PdfReportAgent().run(report, session.entries, session.session_dir)
137
+ except Exception as e:
138
+ logger.warning("pdf generation failed: %s", e)
139
+
140
+ report = replace(report, pdf_path=pdf_path)
141
+ return report, pdf_path
142
+
143
+
144
+ # ── Persistence ───────────────────────────────────────────────────────────────
145
+
146
+ def _jsonable(d: dict) -> dict:
147
+ out = {}
148
+ for k, v in d.items():
149
+ if isinstance(v, float):
150
+ out[k] = round(v, 2)
151
+ elif isinstance(v, (int, str, bool)) or v is None:
152
+ out[k] = v
153
+ else:
154
+ out[k] = str(v)
155
+ return out
156
+
157
+
158
+ def _entry_display(e: SessionEntry) -> dict:
159
+ return {
160
+ "test_name": e.test_name, "side": e.side, "score": e.score,
161
+ "needs_human": e.needs_human, "rationale": e.rationale,
162
+ "compensation_tags": list(e.compensation_tags), "corrective_hint": e.corrective_hint,
163
+ "measurements": _jsonable(e.measurements), "confidence": round(e.confidence, 2),
164
+ "view": e.view, "keyframe_path": e.keyframe_path,
165
+ }
166
+
167
+
168
+ def _render_markdown(session: Session) -> str:
169
+ lines = ["# FormScout — Session Log", ""]
170
+ for e in session.entries:
171
+ title = e.test_name.replace("_", " ").title()
172
+ if e.side in ("left", "right"):
173
+ title += f" ({e.side})"
174
+ score = "Clinician review required" if e.needs_human else f"{e.score}/3"
175
+ lines.append(f"## {title} — {score}")
176
+ lines.append(e.rationale or "")
177
+ if e.compensation_tags:
178
+ lines.append(f"- Compensations: {', '.join(e.compensation_tags)}")
179
+ if e.corrective_hint:
180
+ lines.append(f"- Corrective: {e.corrective_hint}")
181
+ if e.keyframe_path:
182
+ lines.append(f"- Key frame: `{e.keyframe_path}`")
183
+ lines.append("")
184
+ return "\n".join(lines)
185
+
186
+
187
+ def _persist(session: Session) -> None:
188
+ try:
189
+ with open(os.path.join(session.session_dir, "session.json"), "w") as f:
190
+ json.dump([_entry_display(e) for e in session.entries], f, indent=2)
191
+ with open(os.path.join(session.session_dir, "analysis.md"), "w") as f:
192
+ f.write(_render_markdown(session))
193
+ except Exception as e:
194
+ logger.warning("session persist failed: %s", e)
formscout/types.py CHANGED
@@ -142,6 +142,30 @@ class ReportResult:
142
  notes: str = ""
143
 
144
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  @dataclass
146
  class PipelineState:
147
  """Mutable state threaded through the Director."""
 
142
  notes: str = ""
143
 
144
 
145
+ @dataclass(frozen=True)
146
+ class SessionEntry:
147
+ """One accumulated analysis in a screening session.
148
+
149
+ Display fields (test_name…keyframe_path) feed the PDF/JSON/MD artifacts;
150
+ the trailing typed objects (movement…judge) feed ReportAgent.run().
151
+ """
152
+ test_name: str
153
+ side: str
154
+ score: int | None
155
+ needs_human: bool
156
+ rationale: str
157
+ compensation_tags: list
158
+ corrective_hint: str
159
+ measurements: dict
160
+ confidence: float
161
+ view: str
162
+ keyframe_path: str | None
163
+ movement: MovementResult
164
+ features: BiomechFeatures
165
+ rubric_score: ScoreResult
166
+ judge: JudgeResult | None
167
+
168
+
169
  @dataclass
170
  class PipelineState:
171
  """Mutable state threaded through the Director."""
requirements.txt CHANGED
@@ -5,6 +5,7 @@ opencv-python>=4.10
5
  numpy>=1.26
6
  scipy>=1.13
7
  pillow>=10.3
 
8
  requests>=2.31
9
  pytest>=8.2
10
  ruff>=0.4
 
5
  numpy>=1.26
6
  scipy>=1.13
7
  pillow>=10.3
8
+ reportlab>=4.0
9
  requests>=2.31
10
  pytest>=8.2
11
  ruff>=0.4
tests/test_biomechanics.py CHANGED
@@ -96,3 +96,29 @@ class TestBiomechanicsAgent:
96
  result = agent.run(pose, body3d, movement)
97
  assert result.confidence < 0.5
98
  assert "not yet implemented" in result.notes
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  result = agent.run(pose, body3d, movement)
97
  assert result.confidence < 0.5
98
  assert "not yet implemented" in result.notes
99
+
100
+
101
+ def test_pushup_timing_has_max_sag_frame():
102
+ from formscout.agents.biomechanics import BiomechanicsAgent
103
+ from formscout.types import Pose2DResult, Body3DResult, MovementResult
104
+
105
+ # 4 frames; frame 2 has the largest hip sag (hip far below shoulder/ankle midline)
106
+ def kps(hip_y):
107
+ base = {
108
+ 5: {"x": 200, "y": 200, "conf": 0.9}, # L shoulder
109
+ 6: {"x": 220, "y": 200, "conf": 0.9}, # R shoulder
110
+ 11: {"x": 300, "y": hip_y, "conf": 0.9}, # L hip
111
+ 12: {"x": 320, "y": hip_y, "conf": 0.9}, # R hip
112
+ 15: {"x": 400, "y": 200, "conf": 0.9}, # L ankle
113
+ 16: {"x": 420, "y": 200, "conf": 0.9}, # R ankle
114
+ }
115
+ return base
116
+
117
+ frames = [kps(200), kps(210), kps(260), kps(205)]
118
+ pose = Pose2DResult(keypoints=frames, fps=30.0, confidence=0.9)
119
+ body3d = Body3DResult(used=False, joints_3d=[])
120
+ movement = MovementResult(test_name="trunk_stability_pushup", side="na", confidence=1.0)
121
+
122
+ feats = BiomechanicsAgent().run(pose, body3d, movement)
123
+ assert "max_sag_frame" in feats.timing
124
+ assert feats.timing["max_sag_frame"] == 2
tests/test_keyframe.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for PoseVisualizer.render_frame — single annotated still."""
2
+ import os
3
+ import numpy as np
4
+
5
+ from formscout.types import IngestResult, Pose2DResult
6
+
7
+
8
+ def _ingest(n=5, h=480, w=640):
9
+ frames = [np.zeros((h, w, 3), dtype=np.uint8) for _ in range(n)]
10
+ return IngestResult(frames=frames, fps=30.0, duration=n / 30.0, n_people=1, width=w, height=h)
11
+
12
+
13
+ def _pose(n=5):
14
+ kps = []
15
+ for i in range(n):
16
+ kps.append({j: {"x": float(50 + j * 25), "y": float(80 + j * 18), "conf": 0.9}
17
+ for j in range(17)})
18
+ return Pose2DResult(keypoints=kps, fps=30.0, confidence=0.9)
19
+
20
+
21
+ def test_render_frame_writes_png(tmp_path):
22
+ from formscout.agents.visualizer import PoseVisualizer
23
+ out = str(tmp_path / "key.png")
24
+ path = PoseVisualizer().render_frame(_ingest(), _pose(), frame_idx=2,
25
+ layers={"skeleton"}, caption="Deep Squat — heels elevated",
26
+ out_png=out)
27
+ assert path == out
28
+ assert os.path.exists(out)
29
+ assert os.path.getsize(out) > 0
30
+
31
+
32
+ def test_render_frame_bad_index_returns_none(tmp_path):
33
+ from formscout.agents.visualizer import PoseVisualizer
34
+ out = str(tmp_path / "key.png")
35
+ path = PoseVisualizer().render_frame(_ingest(n=3), _pose(n=3), frame_idx=99,
36
+ layers={"skeleton"}, caption="", out_png=out)
37
+ assert path is None
tests/test_pdf_report.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for PdfReportAgent — no GPU, no model downloads."""
2
+ import os
3
+
4
+ from formscout.types import (
5
+ ReportResult, SessionEntry, MovementResult, BiomechFeatures, ScoreResult, JudgeResult,
6
+ )
7
+
8
+
9
+ def _entry(test_name="deep_squat", score=2, needs_human=False):
10
+ movement = MovementResult(test_name=test_name, side="na", confidence=1.0)
11
+ features = BiomechFeatures(
12
+ test_name=test_name, view="2d", side="na",
13
+ angles={"left_knee_flexion_deg": 95.0}, alignments={"knees_tracking_over_feet": False},
14
+ symmetry_delta=None, timing={"deepest_frame": 1}, confidence=0.9,
15
+ )
16
+ rubric = ScoreResult(score=2, rationale="rubric ok", confidence=0.8)
17
+ judge = JudgeResult(score=None if needs_human else score, rationale="judge rationale",
18
+ compensation_tags=["heels elevated"], corrective_hint="ankle mobility",
19
+ confidence=0.85, needs_human=needs_human)
20
+ return SessionEntry(
21
+ test_name=test_name, side="na", score=None if needs_human else score,
22
+ needs_human=needs_human, rationale="judge rationale",
23
+ compensation_tags=["heels elevated"], corrective_hint="ankle mobility",
24
+ measurements={"left_knee_flexion_deg": 95.0, "knees_tracking_over_feet": False},
25
+ confidence=0.85, view="2d", keyframe_path=None,
26
+ movement=movement, features=features, rubric_score=rubric, judge=judge,
27
+ )
28
+
29
+
30
+ def _report(composite=2):
31
+ return ReportResult(
32
+ per_test=[], composite=composite, asymmetries=[],
33
+ overlay_video_path=None, pdf_path=None,
34
+ low_confidence_flags=[], disagreement_flags=[],
35
+ )
36
+
37
+
38
+ def test_pdf_is_created(tmp_path):
39
+ from formscout.agents.pdf_report import PdfReportAgent
40
+ path = PdfReportAgent().run(_report(2), [_entry()], str(tmp_path))
41
+ assert path is not None
42
+ assert os.path.exists(path)
43
+ assert os.path.getsize(path) > 1000 # a real PDF, not an empty file
44
+ with open(path, "rb") as f:
45
+ assert f.read(5) == b"%PDF-"
46
+
47
+
48
+ def test_pdf_handles_incomplete_composite(tmp_path):
49
+ from formscout.agents.pdf_report import PdfReportAgent
50
+ path = PdfReportAgent().run(_report(None), [_entry(needs_human=True)], str(tmp_path))
51
+ assert path is not None and os.path.exists(path)
tests/test_session.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the FMS session accumulator — no GPU, no model downloads."""
2
+ import numpy as np
3
+
4
+ from formscout.types import (
5
+ IngestResult, Pose2DResult, BiomechFeatures, ScoreResult, JudgeResult,
6
+ MovementResult, SessionEntry,
7
+ )
8
+
9
+
10
+ def test_session_entry_holds_typed_objects():
11
+ movement = MovementResult(test_name="deep_squat", side="na", confidence=1.0)
12
+ features = BiomechFeatures(
13
+ test_name="deep_squat", view="2d", side="na",
14
+ angles={"left_knee_flexion_deg": 95.0}, alignments={"knees_tracking_over_feet": True},
15
+ symmetry_delta=None, timing={"deepest_frame": 2}, confidence=0.9,
16
+ )
17
+ rubric = ScoreResult(score=2, rationale="ok", confidence=0.8)
18
+ judge = JudgeResult(score=2, rationale="ok", compensation_tags=["heels elevated"],
19
+ corrective_hint="ankle mobility", confidence=0.85)
20
+ entry = SessionEntry(
21
+ test_name="deep_squat", side="na", score=2, needs_human=False,
22
+ rationale="ok", compensation_tags=["heels elevated"], corrective_hint="ankle mobility",
23
+ measurements={"left_knee_flexion_deg": 95.0}, confidence=0.85, view="2d",
24
+ keyframe_path=None, movement=movement, features=features,
25
+ rubric_score=rubric, judge=judge,
26
+ )
27
+ assert entry.score == 2
28
+ assert entry.movement.test_name == "deep_squat"
29
+ assert entry.rubric_score.score == 2
30
+ assert entry.judge.compensation_tags == ["heels elevated"]
31
+
32
+
33
+ def _ingest(n=5, h=480, w=640):
34
+ frames = [np.zeros((h, w, 3), dtype=np.uint8) for _ in range(n)]
35
+ return IngestResult(frames=frames, fps=30.0, duration=n / 30.0, n_people=1, width=w, height=h)
36
+
37
+
38
+ def _pose(n=5):
39
+ kps = []
40
+ for i in range(n):
41
+ kps.append({j: {"x": float(50 + j * 25), "y": float(80 + j * 18), "conf": 0.9}
42
+ for j in range(17)})
43
+ return Pose2DResult(keypoints=kps, fps=30.0, confidence=0.9)
44
+
45
+
46
+ def _features(test_name="deep_squat", side="na", frame_key="deepest_frame"):
47
+ return BiomechFeatures(
48
+ test_name=test_name, view="2d", side=side,
49
+ angles={"left_knee_flexion_deg": 95.0},
50
+ alignments={"knees_tracking_over_feet": False},
51
+ symmetry_delta=None, timing={frame_key: 2}, confidence=0.9,
52
+ )
53
+
54
+
55
+ def _judge(score=2, needs_human=False):
56
+ return JudgeResult(
57
+ score=None if needs_human else score, rationale="r",
58
+ compensation_tags=["heels elevated"], corrective_hint="ankle mobility",
59
+ confidence=0.85, needs_human=needs_human,
60
+ )
61
+
62
+
63
+ def test_add_analysis_appends_entry_and_writes_files():
64
+ import os
65
+ from formscout import session as S
66
+ sess = S.new_session()
67
+ entry = S.add_analysis(sess, ingest=_ingest(), pose2d=_pose(),
68
+ features=_features(), judge=_judge(), test_name="deep_squat", side="na")
69
+ assert len(sess.entries) == 1
70
+ assert entry.score == 2
71
+ assert os.path.exists(os.path.join(sess.session_dir, "session.json"))
72
+ assert os.path.exists(os.path.join(sess.session_dir, "analysis.md"))
73
+ # key-frame still written (deepest_frame=2 is valid)
74
+ assert entry.keyframe_path and os.path.exists(entry.keyframe_path)
75
+
76
+
77
+ def test_finish_composite_null_when_needs_human():
78
+ from formscout import session as S
79
+ sess = S.new_session()
80
+ S.add_analysis(sess, ingest=_ingest(), pose2d=_pose(), features=_features(),
81
+ judge=_judge(score=3), test_name="deep_squat", side="na")
82
+ S.add_analysis(sess, ingest=_ingest(), pose2d=_pose(),
83
+ features=_features("trunk_stability_pushup", frame_key="max_sag_frame"),
84
+ judge=_judge(needs_human=True), test_name="trunk_stability_pushup", side="na")
85
+ report, pdf_path = S.finish_session(sess)
86
+ assert report is not None
87
+ assert report.composite is None # one test needs_human
88
+
89
+
90
+ def test_finish_empty_session_returns_none():
91
+ from formscout import session as S
92
+ sess = S.new_session()
93
+ report, pdf_path = S.finish_session(sess)
94
+ assert report is None and pdf_path is None