| """ |
| JudgeAgent — VLM-based final scorer with rationale, compensation tags, pain detection. |
| |
| Input: BiomechFeatures, ScoreResult (rubric candidate), MovementResult, keyframes |
| Output: JudgeResult(score, rationale, compensation_tags, corrective_hint, needs_human) |
| Failure: returns JudgeResult(needs_human=True, score=None) when uncertain. |
| Model: Qwen3-VL-8B-Instruct via llama.cpp (8B params, Apache-2.0). |
| Gated: No. |
| |
| Safety: NEVER auto-scores pain. If any indication of pain/clearing test, |
| sets needs_human=True and score=None. |
| """ |
| from __future__ import annotations |
|
|
| import json |
| import logging |
| from pathlib import Path |
|
|
| from formscout import config |
| from formscout.types import ( |
| BiomechFeatures, ScoreResult, MovementResult, |
| IngestResult, JudgeResult, |
| ) |
| from formscout.serving import get_vlm_client |
|
|
| logger = logging.getLogger(__name__) |
|
|
| _PROMPT_PATH = Path(__file__).parent / "prompts" / "c2_judge.md" |
|
|
|
|
| class JudgeAgent: |
| """VLM judge that produces the final FMS score with rationale.""" |
|
|
| def __init__(self): |
| self._client = get_vlm_client() |
| self._system_prompt = _PROMPT_PATH.read_text(encoding="utf-8") |
|
|
| def run( |
| self, |
| features: BiomechFeatures, |
| rubric_score: ScoreResult, |
| movement: MovementResult, |
| ingest: IngestResult | None = None, |
| ) -> JudgeResult: |
| """ |
| Produce final score. Falls back to rubric score if VLM unavailable. |
| """ |
| if not config.ENABLE_JUDGE: |
| return self._fallback_from_rubric(rubric_score, features) |
|
|
| if not self._client.available: |
| logger.warning("JudgeAgent: VLM unavailable, using rubric score as final") |
| return self._fallback_from_rubric(rubric_score, features) |
|
|
| |
| context = { |
| "test": features.test_name, |
| "side": features.side, |
| "view": features.view, |
| "features": {"angles": features.angles, "alignments": features.alignments}, |
| "candidate_score": rubric_score.score, |
| "candidate_confidence": rubric_score.confidence, |
| "exemplars": [], |
| } |
|
|
| prompt = f"{self._system_prompt}\n\n{json.dumps(context, indent=2)}" |
|
|
| |
| images = None |
| if ingest and ingest.frames: |
| images = self._encode_keyframes(ingest.frames) |
|
|
| result = self._client.complete(prompt, images=images, max_tokens=512, temperature=0.1) |
| if result.get("fallback"): |
| |
| return self._fallback_from_rubric(rubric_score, features) |
| return self._parse_response(result) |
|
|
| def _encode_keyframes(self, frames: list) -> list[str]: |
| """Encode 3 keyframes for VLM context.""" |
| import cv2 |
| import base64 |
|
|
| n = len(frames) |
| indices = [0, n // 2, n - 1] if n >= 3 else list(range(n)) |
| encoded = [] |
| for idx in indices: |
| _, buf = cv2.imencode(".jpg", frames[idx], [cv2.IMWRITE_JPEG_QUALITY, 70]) |
| encoded.append(base64.b64encode(buf.tobytes()).decode()) |
| return encoded |
|
|
| def _parse_response(self, result: dict) -> JudgeResult: |
| """Parse VLM JSON response into JudgeResult.""" |
| if "error" in result: |
| return JudgeResult( |
| score=None, rationale=f"VLM error: {result['error']}", |
| compensation_tags=[], corrective_hint="", |
| confidence=0.0, needs_human=True, |
| ) |
|
|
| needs_human = result.get("needs_human", False) |
| score = result.get("score") if not needs_human else None |
| if score is not None: |
| score = max(0, min(3, int(score))) |
|
|
| return JudgeResult( |
| score=score, |
| rationale=result.get("rationale", ""), |
| compensation_tags=result.get("compensation_tags", []), |
| corrective_hint=result.get("corrective_hint", ""), |
| confidence=float(result.get("confidence", 0.5)), |
| needs_human=needs_human, |
| ) |
|
|
| def _fallback_from_rubric(self, rubric: ScoreResult, features: BiomechFeatures) -> JudgeResult: |
| """When VLM is unavailable, promote the rubric score as the final score.""" |
| return JudgeResult( |
| score=rubric.score, |
| rationale=f"[rubric-only] {rubric.rationale}", |
| compensation_tags=[], |
| corrective_hint="", |
| confidence=rubric.confidence * 0.8, |
| needs_human=rubric.needs_human, |
| notes="VLM unavailable — rubric score used as final", |
| ) |
|
|