""" 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