| """
|
| FormScout typed agent contracts.
|
| Every agent accepts and returns frozen dataclasses defined here.
|
| Validate at every boundary — never accept raw dicts across agent boundaries.
|
| """
|
| from __future__ import annotations
|
|
|
| from dataclasses import dataclass, field
|
|
|
|
|
| @dataclass(frozen=True)
|
| class IngestResult:
|
| """Output of IngestAgent — decoded video frames + metadata."""
|
| frames: list
|
| fps: float
|
| duration: float
|
| n_people: int
|
| width: int
|
| height: int
|
| confidence: float = 1.0
|
| notes: str = ""
|
|
|
|
|
| @dataclass(frozen=True)
|
| class SegmentResult:
|
| """Output of SegmentationAgent — per-frame athlete masks."""
|
| athlete_track_id: int
|
| masks: list
|
| confidence: float = 1.0
|
| notes: str = ""
|
|
|
|
|
| @dataclass(frozen=True)
|
| class Pose2DResult:
|
| """Output of Pose2DAgent — per-frame 2D keypoints (COCO 17-joint)."""
|
| keypoints: list
|
| fps: float
|
| confidence: float = 0.0
|
| notes: str = ""
|
|
|
|
|
| @dataclass(frozen=True)
|
| class Body3DResult:
|
| """Output of Body3DAgent — optional 3D joint positions."""
|
| used: bool
|
| joints_3d: list
|
| confidence: float = 0.0
|
| notes: str = ""
|
|
|
|
|
| @dataclass(frozen=True)
|
| class MovementResult:
|
| """Output of MovementClassifierAgent — which FMS test is being performed."""
|
| test_name: str
|
| side: str
|
| confidence: float = 0.0
|
| notes: str = ""
|
|
|
| def __post_init__(self):
|
| valid_tests = {
|
| "deep_squat", "hurdle_step", "inline_lunge",
|
| "shoulder_mobility", "active_slr",
|
| "trunk_stability_pushup", "rotary_stability", "unknown",
|
| }
|
| if self.test_name not in valid_tests:
|
| raise ValueError(f"test_name must be one of {valid_tests}, got '{self.test_name}'")
|
| valid_sides = {"left", "right", "na"}
|
| if self.side not in valid_sides:
|
| raise ValueError(f"side must be one of {valid_sides}, got '{self.side}'")
|
|
|
|
|
| @dataclass(frozen=True)
|
| class BiomechFeatures:
|
| """Output of BiomechanicsAgent — measured angles, alignments, timing."""
|
| test_name: str
|
| view: str
|
| side: str
|
| angles: dict
|
| alignments: dict
|
| symmetry_delta: float | None
|
| timing: dict
|
| confidence: float = 0.0
|
| notes: str = ""
|
|
|
| def __post_init__(self):
|
| if self.view not in ("2d", "3d"):
|
| raise ValueError(f"view must be '2d' or '3d', got '{self.view}'")
|
|
|
|
|
| @dataclass(frozen=True)
|
| class ScoreResult:
|
| """Output of ScoringAgent (ST-GCN head) — provisional numeric score."""
|
| score: int
|
| rationale: str
|
| confidence: float
|
| needs_human: bool = False
|
| notes: str = ""
|
|
|
| def __post_init__(self):
|
| if not self.needs_human and not (0 <= self.score <= 3):
|
| raise ValueError(f"score must be 0–3, got {self.score}")
|
|
|
|
|
| @dataclass(frozen=True)
|
| class RetrievalResult:
|
| """Output of RetrievalAgent — similar scored exemplars from the index."""
|
| exemplars: list
|
| confidence: float = 1.0
|
| notes: str = ""
|
|
|
|
|
| @dataclass(frozen=True)
|
| class JudgeResult:
|
| """Output of JudgeAgent — final VLM-scored result with rationale."""
|
| score: int | None
|
| rationale: str
|
| compensation_tags: list
|
| corrective_hint: str
|
| confidence: float
|
| needs_human: bool = False
|
| notes: str = ""
|
|
|
| def __post_init__(self):
|
| if not self.needs_human and self.score is not None:
|
| if not (0 <= self.score <= 3):
|
| raise ValueError(f"score must be 0–3 when needs_human=False, got {self.score}")
|
| if self.needs_human and self.score is not None:
|
| raise ValueError("score must be None when needs_human=True")
|
|
|
|
|
| @dataclass(frozen=True)
|
| class ReportResult:
|
| """Output of ReportAgent — assembled scorecard."""
|
| per_test: list
|
| composite: int | None
|
| asymmetries: list
|
| overlay_video_path: str | None
|
| pdf_path: str | None
|
| low_confidence_flags: list
|
| disagreement_flags: list
|
| scoresheet_path: str | None = None
|
| notes: str = ""
|
|
|
|
|
| @dataclass(frozen=True)
|
| class SessionEntry:
|
| """One accumulated analysis in a screening session.
|
|
|
| Display fields (test_name…keyframe_path) feed the PDF/JSON/MD artifacts;
|
| the trailing typed objects (movement…judge) feed ReportAgent.run().
|
| """
|
| test_name: str
|
| side: str
|
| score: int | None
|
| needs_human: bool
|
| rationale: str
|
| compensation_tags: list
|
| corrective_hint: str
|
| measurements: dict
|
| confidence: float
|
| view: str
|
| keyframe_path: str | None
|
| movement: MovementResult
|
| features: BiomechFeatures
|
| rubric_score: ScoreResult
|
| judge: JudgeResult | None
|
| laban: dict | None = None
|
| flexion: dict | None = None
|
| chart_paths: dict | None = None
|
|
|
|
|
| @dataclass
|
| class PipelineState:
|
| """Mutable state threaded through the Director."""
|
| video_path: str
|
| ingest: IngestResult | None = None
|
| segment: SegmentResult | None = None
|
| pose2d: Pose2DResult | None = None
|
| body3d: Body3DResult | None = None
|
| movement: MovementResult | None = None
|
| features: BiomechFeatures | None = None
|
| stgcn_score: ScoreResult | None = None
|
| retrieval: RetrievalResult | None = None
|
| judge: JudgeResult | None = None
|
| report: ReportResult | None = None
|
| errors: list = field(default_factory=list)
|
| warnings: list = field(default_factory=list)
|
|
|