Spaces:
Running on Zero
Running on Zero
feat: expand coach retrieval with equipment and goal overlays
Browse files- src/pozify/knowledge_cards.py +156 -2
- src/pozify/slm/prompting.py +3 -1
- tests/test_coach_summary.py +42 -1
src/pozify/knowledge_cards.py
CHANGED
|
@@ -13,8 +13,10 @@ CARD_TYPE_ORDER = {
|
|
| 13 |
"exercise": 0,
|
| 14 |
"variation": 1,
|
| 15 |
"issue": 2,
|
| 16 |
-
"
|
| 17 |
-
"
|
|
|
|
|
|
|
| 18 |
}
|
| 19 |
|
| 20 |
|
|
@@ -81,6 +83,45 @@ def _card(
|
|
| 81 |
)
|
| 82 |
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
CARD_REGISTRY: tuple[KnowledgeCard, ...] = (
|
| 85 |
_card(
|
| 86 |
"exercise:squat",
|
|
@@ -300,6 +341,84 @@ CARD_REGISTRY: tuple[KnowledgeCard, ...] = (
|
|
| 300 |
("Limit the number of corrections in a single summary.",),
|
| 301 |
("Pick the top one or two form priorities for next time.",),
|
| 302 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
_card(
|
| 304 |
"safety:no_diagnosis",
|
| 305 |
"safety_rule",
|
|
@@ -552,6 +671,38 @@ def cards_for_labels(labels: list[str] | tuple[str, ...]) -> list[KnowledgeCard]
|
|
| 552 |
)
|
| 553 |
|
| 554 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 555 |
def retrieve_cards_with_metadata(
|
| 556 |
*,
|
| 557 |
profile: UserProfile,
|
|
@@ -590,7 +741,10 @@ def retrieve_cards(
|
|
| 590 |
variation.detected_variation,
|
| 591 |
*variation.not_issues,
|
| 592 |
*[issue.issue for issue in issues.issues],
|
|
|
|
| 593 |
profile.goal,
|
|
|
|
|
|
|
| 594 |
"no_diagnosis",
|
| 595 |
"no_injury_prevention_claim",
|
| 596 |
"grounded_only",
|
|
|
|
| 13 |
"exercise": 0,
|
| 14 |
"variation": 1,
|
| 15 |
"issue": 2,
|
| 16 |
+
"equipment": 3,
|
| 17 |
+
"goal": 4,
|
| 18 |
+
"goal_overlay": 5,
|
| 19 |
+
"safety_rule": 6,
|
| 20 |
}
|
| 21 |
|
| 22 |
|
|
|
|
| 83 |
)
|
| 84 |
|
| 85 |
|
| 86 |
+
def _equipment_card(
|
| 87 |
+
equipment: str,
|
| 88 |
+
title: str,
|
| 89 |
+
summary: str,
|
| 90 |
+
coaching_point: str,
|
| 91 |
+
) -> KnowledgeCard:
|
| 92 |
+
return _card(
|
| 93 |
+
f"equipment:{equipment}",
|
| 94 |
+
"equipment",
|
| 95 |
+
(equipment,),
|
| 96 |
+
title,
|
| 97 |
+
summary,
|
| 98 |
+
(
|
| 99 |
+
"Treat equipment as context for cue wording, not as a new issue.",
|
| 100 |
+
),
|
| 101 |
+
(coaching_point,),
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _goal_overlay_card(
|
| 106 |
+
exercise: str,
|
| 107 |
+
goal: str,
|
| 108 |
+
title: str,
|
| 109 |
+
summary: str,
|
| 110 |
+
coaching_points: tuple[str, ...],
|
| 111 |
+
) -> KnowledgeCard:
|
| 112 |
+
return _card(
|
| 113 |
+
f"goal_overlay:{exercise}:{goal}",
|
| 114 |
+
"goal_overlay",
|
| 115 |
+
(f"{exercise}:{goal}",),
|
| 116 |
+
title,
|
| 117 |
+
summary,
|
| 118 |
+
(
|
| 119 |
+
"Use this overlay only as a prioritization hint for the detected exercise and goal.",
|
| 120 |
+
),
|
| 121 |
+
coaching_points,
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
CARD_REGISTRY: tuple[KnowledgeCard, ...] = (
|
| 126 |
_card(
|
| 127 |
"exercise:squat",
|
|
|
|
| 341 |
("Limit the number of corrections in a single summary.",),
|
| 342 |
("Pick the top one or two form priorities for next time.",),
|
| 343 |
),
|
| 344 |
+
_equipment_card(
|
| 345 |
+
"bodyweight",
|
| 346 |
+
"Bodyweight Context",
|
| 347 |
+
"Bodyweight sessions should keep cues simple and focused on repeatable control.",
|
| 348 |
+
"Prefer control and repeatability cues that do not assume external load adjustments.",
|
| 349 |
+
),
|
| 350 |
+
_equipment_card(
|
| 351 |
+
"dumbbell",
|
| 352 |
+
"Dumbbell Context",
|
| 353 |
+
"Dumbbell sessions can use load-management cues, but only as context rather than a requirement.",
|
| 354 |
+
"If range or symmetry drifts, favor tempo or setup cues before suggesting heavier changes.",
|
| 355 |
+
),
|
| 356 |
+
_equipment_card(
|
| 357 |
+
"barbell",
|
| 358 |
+
"Barbell Context",
|
| 359 |
+
"Barbell sessions may benefit from setup and path-consistency cues, while still staying grounded in the evidence.",
|
| 360 |
+
"Keep the coaching focused on bar path, balance, and repeatable setup when the evidence supports it.",
|
| 361 |
+
),
|
| 362 |
+
_goal_overlay_card(
|
| 363 |
+
"squat",
|
| 364 |
+
"strength",
|
| 365 |
+
"Squat Strength Overlay",
|
| 366 |
+
"For strength-focused squats, prioritize the highest-value rep-quality cue rather than many small changes.",
|
| 367 |
+
(
|
| 368 |
+
"Lead with the one squat fix that most affects repeatable depth or balance.",
|
| 369 |
+
"Keep the next-session plan short and repeatable.",
|
| 370 |
+
),
|
| 371 |
+
),
|
| 372 |
+
_goal_overlay_card(
|
| 373 |
+
"squat",
|
| 374 |
+
"beginner_practice",
|
| 375 |
+
"Squat Beginner Overlay",
|
| 376 |
+
"For beginner-practice squats, keep the tone encouraging and corrections minimal.",
|
| 377 |
+
(
|
| 378 |
+
"Explain what looked stable before introducing the top squat fix.",
|
| 379 |
+
"Use simple depth or balance language rather than stacking many cues.",
|
| 380 |
+
),
|
| 381 |
+
),
|
| 382 |
+
_goal_overlay_card(
|
| 383 |
+
"push_up",
|
| 384 |
+
"beginner_practice",
|
| 385 |
+
"Push-up Beginner Overlay",
|
| 386 |
+
"For beginner-practice push-ups, prioritize body-line control and simple repeatable cues.",
|
| 387 |
+
(
|
| 388 |
+
"Keep push-up corrections to one or two cues that the athlete can repeat next set.",
|
| 389 |
+
"Treat valid regressions or grip variations as context, not failure.",
|
| 390 |
+
),
|
| 391 |
+
),
|
| 392 |
+
_goal_overlay_card(
|
| 393 |
+
"push_up",
|
| 394 |
+
"endurance",
|
| 395 |
+
"Push-up Endurance Overlay",
|
| 396 |
+
"For endurance-oriented push-ups, explain what changed later in the set before suggesting fixes.",
|
| 397 |
+
(
|
| 398 |
+
"Call out late-set body-line or depth drift when the rep evidence shows it.",
|
| 399 |
+
"Use pacing and consistency cues for the next session plan.",
|
| 400 |
+
),
|
| 401 |
+
),
|
| 402 |
+
_goal_overlay_card(
|
| 403 |
+
"shoulder_press",
|
| 404 |
+
"strength",
|
| 405 |
+
"Shoulder Press Strength Overlay",
|
| 406 |
+
"For strength-focused presses, prioritize clean top position and left-right consistency.",
|
| 407 |
+
(
|
| 408 |
+
"Lead with the top-position or symmetry cue that most affected repeatable reps.",
|
| 409 |
+
"Keep the next-session plan focused on one or two overhead pressing priorities.",
|
| 410 |
+
),
|
| 411 |
+
),
|
| 412 |
+
_goal_overlay_card(
|
| 413 |
+
"shoulder_press",
|
| 414 |
+
"mobility",
|
| 415 |
+
"Shoulder Press Mobility Overlay",
|
| 416 |
+
"For mobility-oriented presses, stay descriptive about range consistency without diagnosing restriction.",
|
| 417 |
+
(
|
| 418 |
+
"Describe the observed top-range consistency without speculating about limitations.",
|
| 419 |
+
"Favor smooth controlled reps for the next-session comparison.",
|
| 420 |
+
),
|
| 421 |
+
),
|
| 422 |
_card(
|
| 423 |
"safety:no_diagnosis",
|
| 424 |
"safety_rule",
|
|
|
|
| 671 |
)
|
| 672 |
|
| 673 |
|
| 674 |
+
def prioritized_coaching_points(cards: list[KnowledgeCard], *, limit: int = 6) -> list[str]:
|
| 675 |
+
priority_order = {
|
| 676 |
+
"issue": 0,
|
| 677 |
+
"variation": 1,
|
| 678 |
+
"exercise": 2,
|
| 679 |
+
"equipment": 3,
|
| 680 |
+
"goal_overlay": 4,
|
| 681 |
+
"goal": 5,
|
| 682 |
+
"safety_rule": 6,
|
| 683 |
+
}
|
| 684 |
+
ordered_cards = sorted(
|
| 685 |
+
cards,
|
| 686 |
+
key=lambda card: (
|
| 687 |
+
priority_order.get(card.card_type, 99),
|
| 688 |
+
0 if card.source_kind == "external" else 1,
|
| 689 |
+
card.card_id,
|
| 690 |
+
),
|
| 691 |
+
)
|
| 692 |
+
points: list[str] = []
|
| 693 |
+
seen: set[str] = set()
|
| 694 |
+
for card in ordered_cards:
|
| 695 |
+
for point in card.coaching_points:
|
| 696 |
+
normalized = point.strip()
|
| 697 |
+
if not normalized or normalized in seen:
|
| 698 |
+
continue
|
| 699 |
+
seen.add(normalized)
|
| 700 |
+
points.append(normalized)
|
| 701 |
+
if len(points) >= limit:
|
| 702 |
+
return points
|
| 703 |
+
return points
|
| 704 |
+
|
| 705 |
+
|
| 706 |
def retrieve_cards_with_metadata(
|
| 707 |
*,
|
| 708 |
profile: UserProfile,
|
|
|
|
| 741 |
variation.detected_variation,
|
| 742 |
*variation.not_issues,
|
| 743 |
*[issue.issue for issue in issues.issues],
|
| 744 |
+
profile.equipment,
|
| 745 |
profile.goal,
|
| 746 |
+
f"{classification.exercise}:{profile.goal}",
|
| 747 |
+
f"{classification.exercise}:{profile.equipment}",
|
| 748 |
"no_diagnosis",
|
| 749 |
"no_injury_prevention_claim",
|
| 750 |
"grounded_only",
|
src/pozify/slm/prompting.py
CHANGED
|
@@ -12,7 +12,7 @@ from pozify.contracts import (
|
|
| 12 |
Variation,
|
| 13 |
to_dict,
|
| 14 |
)
|
| 15 |
-
from pozify.knowledge_cards import KnowledgeCard
|
| 16 |
|
| 17 |
|
| 18 |
def build_summary_evidence(
|
|
@@ -59,6 +59,7 @@ def build_summary_evidence(
|
|
| 59 |
"issues": [to_dict(issue) for issue in issues.issues],
|
| 60 |
"top_issue_intervals": top_issue_intervals,
|
| 61 |
},
|
|
|
|
| 62 |
"knowledge_cards": [to_dict(card) for card in cards],
|
| 63 |
}
|
| 64 |
|
|
@@ -101,6 +102,7 @@ def build_coach_summary_prompt(
|
|
| 101 |
"Do not diagnose injuries, pain, mobility deficits, or pathology.",
|
| 102 |
"Do not claim injury prevention.",
|
| 103 |
"Do not treat a valid detected variation or not-issue label as an error.",
|
|
|
|
| 104 |
"When referencing an issue or variation, include its exact label in "
|
| 105 |
"backticks at least once.",
|
| 106 |
"If evidence is limited or confidence is low, say so in confidence_notes.",
|
|
|
|
| 12 |
Variation,
|
| 13 |
to_dict,
|
| 14 |
)
|
| 15 |
+
from pozify.knowledge_cards import KnowledgeCard, prioritized_coaching_points
|
| 16 |
|
| 17 |
|
| 18 |
def build_summary_evidence(
|
|
|
|
| 59 |
"issues": [to_dict(issue) for issue in issues.issues],
|
| 60 |
"top_issue_intervals": top_issue_intervals,
|
| 61 |
},
|
| 62 |
+
"priority_cues": prioritized_coaching_points(cards),
|
| 63 |
"knowledge_cards": [to_dict(card) for card in cards],
|
| 64 |
}
|
| 65 |
|
|
|
|
| 102 |
"Do not diagnose injuries, pain, mobility deficits, or pathology.",
|
| 103 |
"Do not claim injury prevention.",
|
| 104 |
"Do not treat a valid detected variation or not-issue label as an error.",
|
| 105 |
+
"Use priority_cues as the first source for phrasing top fixes and next-session guidance.",
|
| 106 |
"When referencing an issue or variation, include its exact label in "
|
| 107 |
"backticks at least once.",
|
| 108 |
"If evidence is limited or confidence is low, say so in confidence_notes.",
|
tests/test_coach_summary.py
CHANGED
|
@@ -26,9 +26,11 @@ from pozify.env import load_local_env
|
|
| 26 |
import pozify.slm.providers as slm_providers
|
| 27 |
from pozify.knowledge_cards import (
|
| 28 |
clear_catalog_cache,
|
|
|
|
| 29 |
retrieve_cards,
|
| 30 |
retrieve_cards_with_metadata,
|
| 31 |
)
|
|
|
|
| 32 |
from pozify.steps import coach_summary, coach_summary_fallback, verifier
|
| 33 |
|
| 34 |
|
|
@@ -267,15 +269,17 @@ class CoachSummaryTests(unittest.TestCase):
|
|
| 267 |
|
| 268 |
card_ids = [card.card_id for card in cards]
|
| 269 |
self.assertEqual(
|
| 270 |
-
card_ids[:
|
| 271 |
[
|
| 272 |
"exercise:push_up",
|
| 273 |
"variation:wide_grip_push_up",
|
| 274 |
"issue:hip_sag",
|
|
|
|
| 275 |
"goal:beginner_practice",
|
| 276 |
],
|
| 277 |
)
|
| 278 |
self.assertIn("safety:no_diagnosis", card_ids)
|
|
|
|
| 279 |
|
| 280 |
def test_retrieval_metadata_reports_external_card_usage(self) -> None:
|
| 281 |
retrieval = retrieve_cards_with_metadata(
|
|
@@ -332,6 +336,43 @@ class CoachSummaryTests(unittest.TestCase):
|
|
| 332 |
self.assertEqual(push_up_card.source_kind, "external")
|
| 333 |
self.assertEqual(push_up_card.source_path, str(pack_path.resolve()))
|
| 334 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
def test_coach_summary_falls_back_when_model_fails(self) -> None:
|
| 336 |
cards = retrieve_cards(
|
| 337 |
profile=_profile(),
|
|
|
|
| 26 |
import pozify.slm.providers as slm_providers
|
| 27 |
from pozify.knowledge_cards import (
|
| 28 |
clear_catalog_cache,
|
| 29 |
+
prioritized_coaching_points,
|
| 30 |
retrieve_cards,
|
| 31 |
retrieve_cards_with_metadata,
|
| 32 |
)
|
| 33 |
+
from pozify.slm.prompting import build_summary_evidence
|
| 34 |
from pozify.steps import coach_summary, coach_summary_fallback, verifier
|
| 35 |
|
| 36 |
|
|
|
|
| 269 |
|
| 270 |
card_ids = [card.card_id for card in cards]
|
| 271 |
self.assertEqual(
|
| 272 |
+
card_ids[:5],
|
| 273 |
[
|
| 274 |
"exercise:push_up",
|
| 275 |
"variation:wide_grip_push_up",
|
| 276 |
"issue:hip_sag",
|
| 277 |
+
"equipment:bodyweight",
|
| 278 |
"goal:beginner_practice",
|
| 279 |
],
|
| 280 |
)
|
| 281 |
self.assertIn("safety:no_diagnosis", card_ids)
|
| 282 |
+
self.assertIn("goal_overlay:push_up:beginner_practice", card_ids)
|
| 283 |
|
| 284 |
def test_retrieval_metadata_reports_external_card_usage(self) -> None:
|
| 285 |
retrieval = retrieve_cards_with_metadata(
|
|
|
|
| 336 |
self.assertEqual(push_up_card.source_kind, "external")
|
| 337 |
self.assertEqual(push_up_card.source_path, str(pack_path.resolve()))
|
| 338 |
|
| 339 |
+
def test_prompt_evidence_includes_prioritized_cues(self) -> None:
|
| 340 |
+
cards = retrieve_cards(
|
| 341 |
+
profile=_profile(),
|
| 342 |
+
classification=_classification(),
|
| 343 |
+
variation=_variation(),
|
| 344 |
+
issues=_issues(),
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
evidence = build_summary_evidence(
|
| 348 |
+
profile=_profile(),
|
| 349 |
+
classification=_classification(),
|
| 350 |
+
reps=_reps(),
|
| 351 |
+
analysis=_analysis(),
|
| 352 |
+
variation=_variation(),
|
| 353 |
+
issues=_issues(),
|
| 354 |
+
cards=cards,
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
self.assertTrue(evidence["priority_cues"])
|
| 358 |
+
self.assertIn(
|
| 359 |
+
"Keep shoulders, hips, and ankles moving as one line.",
|
| 360 |
+
evidence["priority_cues"],
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
+
def test_prioritized_coaching_points_prefers_issue_and_context_cards(self) -> None:
|
| 364 |
+
cards = retrieve_cards(
|
| 365 |
+
profile=_profile(),
|
| 366 |
+
classification=_classification(),
|
| 367 |
+
variation=_variation(),
|
| 368 |
+
issues=_issues(),
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
points = prioritized_coaching_points(cards, limit=4)
|
| 372 |
+
|
| 373 |
+
self.assertLessEqual(len(points), 4)
|
| 374 |
+
self.assertIn("Keep shoulders, hips, and ankles moving as one line.", points)
|
| 375 |
+
|
| 376 |
def test_coach_summary_falls_back_when_model_fails(self) -> None:
|
| 377 |
cards = retrieve_cards(
|
| 378 |
profile=_profile(),
|