""" ReportAgent — assembles per-test scorecard, composite, asymmetries. Input: List of (MovementResult, BiomechFeatures, ScoreResult, JudgeResult) per test Output: ReportResult(per_test, composite, asymmetries, overlay_video_path, pdf_path) Failure: returns ReportResult with composite=None if any test unscored. Params: 0 (pure assembly — no model). License: n/a. Gated: no. """ from __future__ import annotations from formscout.types import ( MovementResult, BiomechFeatures, ScoreResult, JudgeResult, ReportResult, ) from formscout import config # Bilateral tests that need L/R scoring BILATERAL_TESTS = {"hurdle_step", "inline_lunge", "shoulder_mobility", "active_slr"} class ReportAgent: """Assembles the final screening report from all test results.""" def run(self, test_results: list[dict]) -> ReportResult: """ Assemble the report. Args: test_results: list of dicts with keys: - movement: MovementResult - features: BiomechFeatures - rubric_score: ScoreResult - judge: JudgeResult - side: str (for bilateral: "left" or "right") """ per_test = [] asymmetries = [] low_confidence_flags = [] disagreement_flags = [] # Group bilateral tests by test_name bilateral_groups: dict[str, list[dict]] = {} unilateral: list[dict] = [] for entry in test_results: test_name = entry["movement"].test_name if test_name in BILATERAL_TESTS: bilateral_groups.setdefault(test_name, []).append(entry) else: unilateral.append(entry) # Process bilateral tests — take the lower score, emit asymmetry for test_name, entries in bilateral_groups.items(): scores = [] for entry in entries: judge = entry["judge"] side = entry.get("side", entry["movement"].side) score = judge.score if judge.score is not None else None scores.append({"side": side, "score": score, "entry": entry}) # Find best entry per side left = next((s for s in scores if s["side"] == "left"), None) right = next((s for s in scores if s["side"] == "right"), None) left_score = left["score"] if left else None right_score = right["score"] if right else None # Report lower if left_score is not None and right_score is not None: final_score = min(left_score, right_score) delta = abs(left_score - right_score) asymmetries.append({ "test": test_name, "left_score": left_score, "right_score": right_score, "delta": delta, }) elif left_score is not None: final_score = left_score elif right_score is not None: final_score = right_score else: final_score = None # Use the entry with the lower score for details primary = (left["entry"] if left and (right is None or (left_score or 4) <= (right_score or 4)) else right["entry"] if right else entries[0]) per_test.append({ "test_name": test_name, "score": final_score, "judge": primary["judge"], "features": primary["features"], "needs_human": primary["judge"].needs_human, }) self._check_flags(primary, low_confidence_flags, disagreement_flags) # Process unilateral tests for entry in unilateral: judge = entry["judge"] per_test.append({ "test_name": entry["movement"].test_name, "score": judge.score, "judge": judge, "features": entry["features"], "needs_human": judge.needs_human, }) self._check_flags(entry, low_confidence_flags, disagreement_flags) # Composite — null if any test unscored all_scores = [t["score"] for t in per_test] composite = sum(all_scores) if all(s is not None for s in all_scores) else None return ReportResult( per_test=per_test, composite=composite, asymmetries=asymmetries, overlay_video_path=None, # Phase 4 pdf_path=None, # Phase 4 low_confidence_flags=low_confidence_flags, disagreement_flags=disagreement_flags, ) def _check_flags(self, entry: dict, low_conf: list, disagree: list): """Check quality gates and populate flag lists.""" judge = entry["judge"] rubric = entry["rubric_score"] test_name = entry["movement"].test_name if judge.confidence < config.MIN_CONFIDENCE: low_conf.append(f"{test_name}: judge confidence {judge.confidence:.2f}") if (judge.score is not None and rubric.score is not None and abs(judge.score - rubric.score) >= config.SCORE_DISAGREE_THRESH): disagree.append( f"{test_name}: rubric={rubric.score} vs judge={judge.score}" )