""" PdfReportAgent — renders a ReportResult + session entries to a branded PDF. Input: ReportResult, list[SessionEntry], session_dir (str) Output: path to the written PDF (str), or None on failure. Failure: returns None, never raises. Params: 0 (pure rendering — no model). License: n/a. Gated: no. """ from __future__ import annotations 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." class PdfReportAgent: """Assembles the screening-session PDF via ReportLab.""" def run(self, report: ReportResult, entries: list, session_dir: str) -> 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 ( Image, PageBreak, 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_report.pdf") try: styles = getSampleStyleSheet() banner = ParagraphStyle( "banner", parent=styles["Normal"], fontSize=9, textColor=colors.white, backColor=colors.HexColor("#cf922a"), alignment=1, borderPadding=6, spaceAfter=12, ) ink = colors.HexColor("#243a34") def _meas_table(pairs, col0=3.0, col1=1.6): rows = [[str(k).replace("_", " "), (f"{v:.2f}" if isinstance(v, float) else str(v))] for k, v in pairs] tbl = Table(rows, colWidths=[col0 * inch, col1 * inch]) tbl.setStyle(TableStyle([ ("FONTSIZE", (0, 0), (-1, -1), 8), ("TEXTCOLOR", (0, 0), (-1, -1), ink), ("ROWBACKGROUNDS", (0, 0), (-1, -1), [colors.HexColor("#f7eedd"), colors.white]), ])) return tbl def _img(path, w=3.0, h=2.25): if path and os.path.exists(path): try: return Image(path, width=w * inch, height=h * inch) except Exception: return None return None story = [] story.append(Paragraph(f"⚠ {DISCLAIMER}", banner)) story.append(Paragraph("FormScout — FMS Screening Report", styles["Title"])) if report.composite is not None: comp = f"Composite: {report.composite} / 21" else: comp = f"Composite: Incomplete — {len(entries)}/7 tests scored" story.append(Paragraph(comp, styles["Heading2"])) story.append(Spacer(1, 0.2 * inch)) for ei, e in enumerate(entries): if ei > 0: story.append(PageBreak()) title = e.test_name.replace("_", " ").title() if e.side in ("left", "right"): title += f" ({e.side})" score_txt = "Clinician review required" if e.needs_human else f"Score: {e.score}/3" story.append(Paragraph(f"{title} — {score_txt}", styles["Heading3"])) story.append(Paragraph(f"view: {e.view} · confidence: " f"{e.confidence:.0%}", styles["Normal"])) if e.rationale: story.append(Paragraph(e.rationale, styles["Normal"])) if e.compensation_tags: story.append(Paragraph("Compensations: " + ", ".join(e.compensation_tags), styles["Normal"])) if e.corrective_hint: story.append(Paragraph("Corrective: " + e.corrective_hint, styles["Normal"])) # Key frame + flexion chart side by side kf, fb = _img(e.keyframe_path), _img((e.chart_paths or {}).get("flexion"), w=3.2, h=2.0) if kf or fb: cells = [c for c in (kf, fb) if c] or [Paragraph("(images unavailable)", styles["Normal"])] story.append(Table([cells], hAlign="LEFT")) # Relevant-joint flexion table if e.flexion: story.append(Paragraph("Relevant joint flexion (key frame)", styles["Normal"])) story.append(_meas_table( [(n, f"{v['deg']:.1f}° — {v['openness']}") for n, v in e.flexion.items()], col0=2.6, col1=2.6)) # Laban Effort + radar if e.laban: eff, lab = e.laban.get("effort", {}), e.laban.get("labels", {}) story.append(Spacer(1, 0.08 * inch)) story.append(Paragraph("Laban Effort (kinematic estimate)", styles["Normal"])) laban_tbl = _meas_table( [(k.title(), f"{eff.get(k, 0):.2f} — {lab.get(k, '')}") for k in ("space", "weight", "time", "flow")], col0=2.6, col1=2.6) radar = _img((e.chart_paths or {}).get("radar"), w=2.6, h=2.6) if radar: story.append(Table([[laban_tbl, radar]], hAlign="LEFT")) else: story.append(laban_tbl) if e.laban.get("body_emphasis"): emph = ", ".join(f"{n}" for n, _ in e.laban["body_emphasis"]) story.append(Paragraph(f"Body emphasis: {emph} · " f"{e.laban.get('notes', '')}", styles["Normal"])) # Angle + velocity charts for kind in ("angle", "velocity"): chart = _img((e.chart_paths or {}).get(kind), w=5.0, h=2.5) if chart: story.append(chart) # Full measurement dump if e.measurements: story.append(Paragraph("All measurements", styles["Normal"])) story.append(_meas_table(list(e.measurements.items()))) story.append(Spacer(1, 0.15 * inch)) if report.asymmetries: story.append(PageBreak()) story.append(Paragraph("Asymmetries", styles["Heading2"])) for a in report.asymmetries: story.append(Paragraph( f"{a['test'].replace('_', ' ').title()}: " f"L={a['left_score']} R={a['right_score']} (Δ {a['delta']})", styles["Normal"])) try: from formscout.analysis.charts import symmetry_bars os.makedirs(os.path.join(session_dir, "charts"), exist_ok=True) sym_png = symmetry_bars(report.asymmetries, os.path.join(session_dir, "charts", "symmetry.png")) sym_img = _img(sym_png, w=5.5, h=2.75) if sym_img: story.append(sym_img) except Exception: pass flags = list(report.low_confidence_flags) + list(report.disagreement_flags) if flags: story.append(Paragraph("Flags", styles["Heading2"])) for fl in flags: story.append(Paragraph(fl, styles["Normal"])) story.append(Spacer(1, 0.3 * 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("pdf build failed: %s", e) return None