"""
ScoreSheetAgent — renders a one-page FMS scoring sheet from a ReportResult.
A FormScout-styled scorecard (NOT a reproduction of the copyrighted FMS chart):
all seven screens in their canonical order, each with its 0–3 score, L/R columns
for the bilateral tests, the composite out of 21, and the standard FMS risk
interpretation (>=14 reduced injury risk; <=13 increased risk). It is the
single-page companion download to the detailed PdfReportAgent report.
Input: ReportResult, session_dir (str), optional meta dict (name/date/etc.)
Output: path to the written PDF (str), or None on failure (e.g. reportlab missing).
Failure: returns None, never raises.
Params: 0 (pure rendering — no model).
License: n/a.
Gated: no.
"""
from __future__ import annotations
import datetime as _dt
import logging
import os
from formscout.types import ReportResult
logger = logging.getLogger(__name__)
DISCLAIMER = "Screening aid — not a diagnosis. Pain or clearing tests require a clinician."
# Canonical FMS order; (key, display label, is_bilateral). Post-MVP additions
# (clearing tests, adjunct measures) slot in here without touching the renderer.
CANONICAL_TESTS = [
("deep_squat", "1. Deep Squat", False),
("hurdle_step", "2. Hurdle Step", True),
("inline_lunge", "3. In-Line Lunge", True),
("shoulder_mobility", "4. Shoulder Mobility", True),
("active_slr", "5. Active Straight-Leg Raise", True),
("trunk_stability_pushup", "6. Trunk Stability Push-Up", False),
("rotary_stability", "7. Rotary Stability", False),
]
def _cell(score, needs_human: bool) -> str:
"""Render one score cell: a number, a review flag, or a blank placeholder."""
if needs_human:
return "Review"
if score is None:
return "—"
return str(score)
class ScoreSheetAgent:
"""Assembles the single-page FMS scoring sheet via ReportLab."""
def run(self, report: ReportResult, session_dir: str,
meta: dict | None = None) -> str | None:
try:
from reportlab.lib import colors
from reportlab.lib.pagesizes import LETTER
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import inch
from reportlab.platypus import (
Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle,
)
except Exception as e:
logger.warning("reportlab unavailable: %s", e)
return None
out_path = os.path.join(session_dir, "formscout_fms_scoresheet.pdf")
try:
styles = getSampleStyleSheet()
ink = colors.HexColor("#243a34")
banner = ParagraphStyle(
"banner", parent=styles["Normal"], fontSize=9, textColor=colors.white,
backColor=colors.HexColor("#cf922a"), alignment=1, borderPadding=6,
spaceAfter=12,
)
small = ParagraphStyle("small", parent=styles["Normal"], fontSize=8,
textColor=ink)
# Index report data by test name.
by_test = {t["test_name"]: t for t in (report.per_test or [])}
asym = {a["test"]: a for a in (report.asymmetries or [])}
story = []
story.append(Paragraph(f"⚠ {DISCLAIMER}", banner))
story.append(Paragraph("FormScout — FMS Scoring Sheet", styles["Title"]))
# Optional athlete/meta header (auto-filled date by default).
meta = dict(meta or {})
meta.setdefault("date", _dt.date.today().isoformat())
meta_bits = [f"{k.title()}: {v}" for k, v in meta.items() if v]
if meta_bits:
story.append(Paragraph(" ".join(meta_bits), small))
story.append(Spacer(1, 0.15 * inch))
# Scores table: # / Test, Left, Right, Score, Status.
header = ["Test", "Left", "Right", "Score", "Status"]
rows = [header]
for key, label, bilateral in CANONICAL_TESTS:
t = by_test.get(key)
needs_human = bool(t and t.get("needs_human"))
score = t.get("score") if t else None
if not t:
status = "Not screened"
elif needs_human:
status = "Clinician review"
elif score is None:
status = "Unscored"
else:
status = "Scored"
if bilateral and key in asym:
left = _cell(asym[key]["left_score"], needs_human)
right = _cell(asym[key]["right_score"], needs_human)
elif bilateral:
# Only one side (or none) was screened — no asymmetry pair.
left = right = _cell(score, needs_human) if t else "—"
else:
left = right = "" # n/a for non-bilateral tests
rows.append([label, left, right, _cell(score, needs_human), status])
comp = (f"{report.composite}" if report.composite is not None else "—")
rows.append(["Total Score (Tests 1–7)", "", "", f"{comp} / 21",
"Complete" if report.composite is not None else "Incomplete"])
tbl = Table(rows, colWidths=[3.0 * inch, 0.8 * inch, 0.8 * inch,
0.9 * inch, 1.4 * inch])
tbl.setStyle(TableStyle([
("FONTSIZE", (0, 0), (-1, -1), 9),
("TEXTCOLOR", (0, 0), (-1, -1), ink),
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1f6e6e")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("BACKGROUND", (0, -1), (-1, -1), colors.HexColor("#f7eedd")),
("FONTNAME", (0, -1), (-1, -1), "Helvetica-Bold"),
("ALIGN", (1, 0), (-1, -1), "CENTER"),
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#c9d6cf")),
("ROWBACKGROUNDS", (0, 1), (-1, -2),
[colors.white, colors.HexColor("#f3f7f5")]),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
]))
story.append(tbl)
story.append(Spacer(1, 0.18 * inch))
# Risk interpretation — only meaningful when the screen is complete.
if report.composite is not None:
if report.composite >= 14:
risk = (f"Composite {report.composite}/21 ≥ 14 — associated in the "
"FMS literature with reduced injury risk during physical "
"activity.")
else:
risk = (f"Composite {report.composite}/21 ≤ 13 — associated in the "
"FMS literature with increased injury risk during physical "
"activity.")
else:
risk = ("Composite is incomplete — all seven screens must be scored before the "
"14-point risk threshold applies.")
story.append(Paragraph(risk, small))
if report.asymmetries:
story.append(Spacer(1, 0.1 * inch))
bits = ", ".join(
f"{a['test'].replace('_', ' ').title()} (L{a['left_score']}/R{a['right_score']},"
f" Δ{a['delta']})" for a in report.asymmetries)
story.append(Paragraph(f"Left/right asymmetries: {bits}", small))
story.append(Spacer(1, 0.25 * inch))
story.append(Paragraph(f"⚠ {DISCLAIMER}", banner))
doc = SimpleDocTemplate(out_path, pagesize=LETTER,
topMargin=0.6 * inch, bottomMargin=0.6 * inch)
doc.build(story)
return out_path
except Exception as e:
logger.warning("scoresheet build failed: %s", e)
return None