refactor: add functions for JSON payload extraction and validation in coach summary; streamline summary key handling and improve error reporting
Browse files- src/pozify/slm/prompting.py +32 -8
- src/pozify/steps/coach_summary.py +64 -19
- tests/test_coach_summary.py +18 -0
src/pozify/slm/prompting.py
CHANGED
|
@@ -53,6 +53,20 @@ def _compact_card(card: KnowledgeCard) -> dict[str, Any]:
|
|
| 53 |
}
|
| 54 |
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
def build_summary_evidence(
|
| 57 |
*,
|
| 58 |
profile: UserProfile,
|
|
@@ -80,27 +94,36 @@ def build_summary_evidence(
|
|
| 80 |
)
|
| 81 |
|
| 82 |
sorted_issues = sorted(issues.issues, key=lambda item: item.severity, reverse=True)
|
| 83 |
-
rep_metrics = [_compact_rep_metric(item) for item in analysis.items[:
|
| 84 |
return {
|
| 85 |
-
"user_profile":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
"exercise_classification": {
|
| 87 |
"exercise": classification.exercise,
|
| 88 |
"confidence": classification.confidence,
|
| 89 |
"fallback_required": classification.fallback_required,
|
| 90 |
},
|
| 91 |
-
"variation":
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
"rep_summary": {
|
| 93 |
"rep_count": len(reps.reps),
|
| 94 |
-
"aggregate_metrics": analysis.aggregate_metrics,
|
| 95 |
"rep_metrics": rep_metrics,
|
| 96 |
},
|
| 97 |
"issue_summary": {
|
| 98 |
"issue_counts": issue_counts,
|
| 99 |
-
"issues": [_compact_issue(issue) for issue in sorted_issues[:
|
| 100 |
-
"top_issue_intervals": top_issue_intervals,
|
| 101 |
},
|
| 102 |
-
"priority_cues": prioritized_coaching_points(cards),
|
| 103 |
-
"knowledge_cards": [_compact_card(card) for card in cards[:
|
| 104 |
}
|
| 105 |
|
| 106 |
|
|
@@ -161,5 +184,6 @@ def build_coach_summary_prompt(
|
|
| 161 |
[
|
| 162 |
json.dumps(instructions, **json_dump_kwargs),
|
| 163 |
json.dumps(evidence, **json_dump_kwargs),
|
|
|
|
| 164 |
]
|
| 165 |
)
|
|
|
|
| 53 |
}
|
| 54 |
|
| 55 |
|
| 56 |
+
def _selected_aggregate_metrics(metrics: dict[str, Any]) -> dict[str, Any]:
|
| 57 |
+
return {
|
| 58 |
+
key: metrics[key]
|
| 59 |
+
for key in (
|
| 60 |
+
"avg_rom_score",
|
| 61 |
+
"avg_stability_score",
|
| 62 |
+
"avg_symmetry_score",
|
| 63 |
+
"fatigue_trend_rom_delta",
|
| 64 |
+
"pose_valid_ratio",
|
| 65 |
+
)
|
| 66 |
+
if key in metrics
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
def build_summary_evidence(
|
| 71 |
*,
|
| 72 |
profile: UserProfile,
|
|
|
|
| 94 |
)
|
| 95 |
|
| 96 |
sorted_issues = sorted(issues.issues, key=lambda item: item.severity, reverse=True)
|
| 97 |
+
rep_metrics = [_compact_rep_metric(item) for item in analysis.items[:4]]
|
| 98 |
return {
|
| 99 |
+
"user_profile": {
|
| 100 |
+
"goal": profile.goal,
|
| 101 |
+
"experience_level": profile.experience_level,
|
| 102 |
+
"intended_exercise": profile.intended_exercise,
|
| 103 |
+
"equipment": profile.equipment,
|
| 104 |
+
},
|
| 105 |
"exercise_classification": {
|
| 106 |
"exercise": classification.exercise,
|
| 107 |
"confidence": classification.confidence,
|
| 108 |
"fallback_required": classification.fallback_required,
|
| 109 |
},
|
| 110 |
+
"variation": {
|
| 111 |
+
"detected_variation": variation.detected_variation,
|
| 112 |
+
"variation_confidence": variation.variation_confidence,
|
| 113 |
+
"not_issues": variation.not_issues,
|
| 114 |
+
},
|
| 115 |
"rep_summary": {
|
| 116 |
"rep_count": len(reps.reps),
|
| 117 |
+
"aggregate_metrics": _selected_aggregate_metrics(analysis.aggregate_metrics),
|
| 118 |
"rep_metrics": rep_metrics,
|
| 119 |
},
|
| 120 |
"issue_summary": {
|
| 121 |
"issue_counts": issue_counts,
|
| 122 |
+
"issues": [_compact_issue(issue) for issue in sorted_issues[:3]],
|
| 123 |
+
"top_issue_intervals": top_issue_intervals[:2],
|
| 124 |
},
|
| 125 |
+
"priority_cues": prioritized_coaching_points(cards, limit=4),
|
| 126 |
+
"knowledge_cards": [_compact_card(card) for card in cards[:3]],
|
| 127 |
}
|
| 128 |
|
| 129 |
|
|
|
|
| 184 |
[
|
| 185 |
json.dumps(instructions, **json_dump_kwargs),
|
| 186 |
json.dumps(evidence, **json_dump_kwargs),
|
| 187 |
+
'Output the final coach summary JSON object now. Start with {"summary":',
|
| 188 |
]
|
| 189 |
)
|
src/pozify/steps/coach_summary.py
CHANGED
|
@@ -19,6 +19,17 @@ from pozify.slm.prompting import build_coach_summary_prompt
|
|
| 19 |
from pozify.slm.providers import CoachSummaryModel, get_coach_summary_model
|
| 20 |
from pozify.steps.coach_summary_fallback import build_fallback_summary
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
@dataclass(frozen=True)
|
| 24 |
class CoachSummaryResult:
|
|
@@ -35,6 +46,44 @@ def _text_preview(text: str, *, limit: int = 240) -> str:
|
|
| 35 |
return preview or "<empty>"
|
| 36 |
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
def _extract_json_object(text: str) -> dict[str, Any]:
|
| 39 |
text = text.strip()
|
| 40 |
if text.startswith("```"):
|
|
@@ -44,39 +93,35 @@ def _extract_json_object(text: str) -> dict[str, Any]:
|
|
| 44 |
try:
|
| 45 |
payload = json.loads(text)
|
| 46 |
except json.JSONDecodeError as exc:
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
if start == -1 or end == -1 or end <= start:
|
| 50 |
raise ValueError(
|
| 51 |
"Coach summary model output was not valid JSON. "
|
| 52 |
f"Parser error: {exc}. Output preview: {_text_preview(text)}"
|
| 53 |
) from exc
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
except json.JSONDecodeError as nested_exc:
|
| 57 |
-
raise ValueError(
|
| 58 |
-
"Coach summary model output contained a JSON-like object that could not be "
|
| 59 |
-
f"parsed. Parser error: {nested_exc}. Output preview: {_text_preview(text)}"
|
| 60 |
-
) from nested_exc
|
| 61 |
-
|
| 62 |
-
if isinstance(payload, list) and payload and isinstance(payload[0], dict):
|
| 63 |
-
payload = payload[0]
|
| 64 |
-
if isinstance(payload, dict):
|
| 65 |
-
for key in ("coach_summary", "summary_json", "output"):
|
| 66 |
-
nested_payload = payload.get(key)
|
| 67 |
-
if isinstance(nested_payload, dict):
|
| 68 |
-
payload = nested_payload
|
| 69 |
-
break
|
| 70 |
|
| 71 |
if not isinstance(payload, dict):
|
| 72 |
raise ValueError(
|
| 73 |
"Coach summary model output must be a JSON object. "
|
| 74 |
f"Got {type(payload).__name__}. Output preview: {_text_preview(text)}"
|
| 75 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
return payload
|
| 77 |
|
| 78 |
|
| 79 |
def _summary_from_payload(payload: dict[str, Any]) -> CoachSummary:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
summary = CoachSummary(
|
| 81 |
summary=str(payload["summary"]),
|
| 82 |
what_you_did=[str(item) for item in payload["what_you_did"]],
|
|
|
|
| 19 |
from pozify.slm.providers import CoachSummaryModel, get_coach_summary_model
|
| 20 |
from pozify.steps.coach_summary_fallback import build_fallback_summary
|
| 21 |
|
| 22 |
+
_SUMMARY_KEYS = {
|
| 23 |
+
"summary",
|
| 24 |
+
"what_you_did",
|
| 25 |
+
"what_looked_good",
|
| 26 |
+
"what_changed_across_reps",
|
| 27 |
+
"valid_variation_vs_issue",
|
| 28 |
+
"top_fixes",
|
| 29 |
+
"next_session_plan",
|
| 30 |
+
"confidence_notes",
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
|
| 34 |
@dataclass(frozen=True)
|
| 35 |
class CoachSummaryResult:
|
|
|
|
| 46 |
return preview or "<empty>"
|
| 47 |
|
| 48 |
|
| 49 |
+
def _unwrap_json_payload(payload: Any) -> Any:
|
| 50 |
+
if isinstance(payload, list) and payload and isinstance(payload[0], dict):
|
| 51 |
+
payload = payload[0]
|
| 52 |
+
if isinstance(payload, dict):
|
| 53 |
+
for key in ("coach_summary", "summary_json", "output"):
|
| 54 |
+
nested_payload = payload.get(key)
|
| 55 |
+
if isinstance(nested_payload, dict):
|
| 56 |
+
return nested_payload
|
| 57 |
+
return payload
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _summary_key_count(payload: Any) -> int:
|
| 61 |
+
if not isinstance(payload, dict):
|
| 62 |
+
return 0
|
| 63 |
+
return len(_SUMMARY_KEYS.intersection(payload))
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _best_json_object_candidate(text: str) -> dict[str, Any] | None:
|
| 67 |
+
decoder = json.JSONDecoder()
|
| 68 |
+
best_payload: dict[str, Any] | None = None
|
| 69 |
+
best_score = 0
|
| 70 |
+
for index, character in enumerate(text):
|
| 71 |
+
if character != "{":
|
| 72 |
+
continue
|
| 73 |
+
try:
|
| 74 |
+
payload, _end = decoder.raw_decode(text[index:])
|
| 75 |
+
except json.JSONDecodeError:
|
| 76 |
+
continue
|
| 77 |
+
payload = _unwrap_json_payload(payload)
|
| 78 |
+
score = _summary_key_count(payload)
|
| 79 |
+
if isinstance(payload, dict) and score > best_score:
|
| 80 |
+
best_payload = payload
|
| 81 |
+
best_score = score
|
| 82 |
+
if score == len(_SUMMARY_KEYS):
|
| 83 |
+
break
|
| 84 |
+
return best_payload
|
| 85 |
+
|
| 86 |
+
|
| 87 |
def _extract_json_object(text: str) -> dict[str, Any]:
|
| 88 |
text = text.strip()
|
| 89 |
if text.startswith("```"):
|
|
|
|
| 93 |
try:
|
| 94 |
payload = json.loads(text)
|
| 95 |
except json.JSONDecodeError as exc:
|
| 96 |
+
payload = _best_json_object_candidate(text)
|
| 97 |
+
if payload is None:
|
|
|
|
| 98 |
raise ValueError(
|
| 99 |
"Coach summary model output was not valid JSON. "
|
| 100 |
f"Parser error: {exc}. Output preview: {_text_preview(text)}"
|
| 101 |
) from exc
|
| 102 |
+
else:
|
| 103 |
+
payload = _unwrap_json_payload(payload)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
if not isinstance(payload, dict):
|
| 106 |
raise ValueError(
|
| 107 |
"Coach summary model output must be a JSON object. "
|
| 108 |
f"Got {type(payload).__name__}. Output preview: {_text_preview(text)}"
|
| 109 |
)
|
| 110 |
+
if _summary_key_count(payload) == 0:
|
| 111 |
+
raise ValueError(
|
| 112 |
+
"Coach summary model output did not contain a coach summary JSON object. "
|
| 113 |
+
f"Output preview: {_text_preview(text)}"
|
| 114 |
+
)
|
| 115 |
return payload
|
| 116 |
|
| 117 |
|
| 118 |
def _summary_from_payload(payload: dict[str, Any]) -> CoachSummary:
|
| 119 |
+
missing_fields = sorted(_SUMMARY_KEYS.difference(payload))
|
| 120 |
+
if missing_fields:
|
| 121 |
+
raise ValueError(
|
| 122 |
+
"Coach summary model output is missing required field(s): "
|
| 123 |
+
f"{', '.join(missing_fields)}"
|
| 124 |
+
)
|
| 125 |
summary = CoachSummary(
|
| 126 |
summary=str(payload["summary"]),
|
| 127 |
what_you_did=[str(item) for item in payload["what_you_did"]],
|
tests/test_coach_summary.py
CHANGED
|
@@ -435,6 +435,24 @@ class CoachSummaryTests(unittest.TestCase):
|
|
| 435 |
payload,
|
| 436 |
)
|
| 437 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
def test_verifier_rejects_issue_not_in_json(self) -> None:
|
| 439 |
summary = CoachSummary(
|
| 440 |
summary="The strongest issue was `incomplete_depth`.",
|
|
|
|
| 435 |
payload,
|
| 436 |
)
|
| 437 |
|
| 438 |
+
def test_extract_json_object_ignores_evidence_echo_before_summary(self) -> None:
|
| 439 |
+
payload = {
|
| 440 |
+
"summary": "ok",
|
| 441 |
+
"what_you_did": [],
|
| 442 |
+
"what_looked_good": [],
|
| 443 |
+
"what_changed_across_reps": [],
|
| 444 |
+
"valid_variation_vs_issue": [],
|
| 445 |
+
"top_fixes": [],
|
| 446 |
+
"next_session_plan": [],
|
| 447 |
+
"confidence_notes": [],
|
| 448 |
+
}
|
| 449 |
+
output = (
|
| 450 |
+
'{"rep_id":5,"issue":"shallow_depth","severity":1.0}'
|
| 451 |
+
f"\n{json.dumps(payload)}"
|
| 452 |
+
)
|
| 453 |
+
|
| 454 |
+
self.assertEqual(coach_summary._extract_json_object(output), payload)
|
| 455 |
+
|
| 456 |
def test_verifier_rejects_issue_not_in_json(self) -> None:
|
| 457 |
summary = CoachSummary(
|
| 458 |
summary="The strongest issue was `incomplete_depth`.",
|