nvti commited on
Commit
fbdb586
·
1 Parent(s): 7d8fed6

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 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[:6]]
84
  return {
85
- "user_profile": to_dict(profile),
 
 
 
 
 
86
  "exercise_classification": {
87
  "exercise": classification.exercise,
88
  "confidence": classification.confidence,
89
  "fallback_required": classification.fallback_required,
90
  },
91
- "variation": to_dict(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[:5]],
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[:5]],
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
- start = text.find("{")
48
- end = text.rfind("}")
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
- try:
55
- payload = json.loads(text[start : end + 1])
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`.",