BladeSzaSza's picture
docs: document judge backend selection (llama_cpp local / transformers ZeroGPU on Space) (#5)
f3bbb01
Raw
History Blame Contribute Delete
4.69 kB
"""
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)
# Build context for the judge
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": [], # Phase 3: populated by RetrievalAgent
}
prompt = f"{self._system_prompt}\n\n{json.dumps(context, indent=2)}"
# Optionally include keyframes
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"):
# transformers backend couldn't load/run — use the deterministic rubric
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",
)