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