tthhanh commited on
Commit
13dc750
·
1 Parent(s): 903d1e1

feat: load external coach knowledge packs

Browse files
data/knowledge_cards/grounded_exercise_expansion.json ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cards": [
3
+ {
4
+ "card_id": "exercise:squat",
5
+ "card_type": "exercise",
6
+ "labels": ["squat"],
7
+ "title": "Squat",
8
+ "summary": "A squat summary should stay grounded in rep evidence while using standardized execution cues for depth, balance, torso position, and stance context.",
9
+ "evidence_rules": [
10
+ "Use rep analysis and issue markers instead of inferring directly from the video.",
11
+ "Describe squat depth, torso control, and balance only when supported by structured evidence.",
12
+ "Treat stance-width context as a valid variation unless issue markers show a separate problem."
13
+ ],
14
+ "coaching_points": [
15
+ "Reference depth, torso control, and rep-to-rep consistency using the measured evidence.",
16
+ "Keep top fixes practical, such as controlling the descent or sitting slightly deeper when `shallow_depth` is present.",
17
+ "Keep language descriptive rather than diagnostic."
18
+ ],
19
+ "allowed_interpretations": [
20
+ "Target muscles commonly include quads and glutes, but coaching should remain focused on the observed movement evidence."
21
+ ],
22
+ "related_cards": ["issue:shallow_depth", "issue:excessive_torso_lean"]
23
+ },
24
+ {
25
+ "card_id": "exercise:push_up",
26
+ "card_type": "exercise",
27
+ "labels": ["push_up"],
28
+ "title": "Push-up",
29
+ "summary": "A push-up summary should explain body-line control, bottom depth, and rep consistency from structured evidence, while recognizing valid regressions and grip variations.",
30
+ "evidence_rules": [
31
+ "Treat hand placement or knee support as variation context when the variation detector marks them as not-issues.",
32
+ "Do not infer pain, fatigue causes, or joint pathology from the movement.",
33
+ "Use only the detected issue labels for correction language."
34
+ ],
35
+ "coaching_points": [
36
+ "Explain whether the body line stayed organized across the set before suggesting fixes.",
37
+ "If `hip_sag` or `incomplete_depth` appears, keep corrections specific to those labels.",
38
+ "Keep next-session advice simple and repeatable for the current goal."
39
+ ],
40
+ "allowed_interpretations": [
41
+ "Primary effort often involves chest, shoulders, and triceps, but the summary should still stay grounded in the measured rep evidence."
42
+ ],
43
+ "related_cards": ["variation:wide_grip_push_up", "variation:knee_push_up", "issue:hip_sag", "issue:incomplete_depth"]
44
+ },
45
+ {
46
+ "card_id": "exercise:shoulder_press",
47
+ "card_type": "exercise",
48
+ "labels": ["shoulder_press"],
49
+ "title": "Shoulder Press",
50
+ "summary": "A shoulder press summary should focus on top position, left-right symmetry, and rep consistency using structured evidence and standardized overhead pressing cues.",
51
+ "evidence_rules": [
52
+ "Use rep analysis and issue markers instead of diagnosing shoulder limitations.",
53
+ "Partial range can be valid variation context and should not be overcorrected by default.",
54
+ "Only describe asymmetry or incomplete lockout when those labels are present in JSON."
55
+ ],
56
+ "coaching_points": [
57
+ "Separate partial-range context from `incomplete_lockout` issue language.",
58
+ "Call out rep-to-rep symmetry drift only when the evidence shows it.",
59
+ "Keep the next-session plan limited to one or two repeatable priorities."
60
+ ],
61
+ "allowed_interpretations": [
62
+ "Primary effort often involves shoulders and triceps, but the coach summary must stay focused on observed rep evidence."
63
+ ],
64
+ "related_cards": ["issue:incomplete_lockout", "issue:asymmetry"]
65
+ }
66
+ ]
67
+ }
src/pozify/knowledge_cards.py CHANGED
@@ -1,6 +1,10 @@
1
  from __future__ import annotations
2
 
3
  from dataclasses import dataclass
 
 
 
 
4
 
5
  from pozify.contracts import ExerciseClassification, GOALS, IssueMarkers, UserProfile, Variation
6
 
@@ -26,6 +30,24 @@ class KnowledgeCard:
26
  allowed_interpretations: tuple[str, ...] = ()
27
  forbidden_claims: tuple[str, ...] = ()
28
  related_cards: tuple[str, ...] = ()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
 
31
  def _card(
@@ -40,6 +62,8 @@ def _card(
40
  allowed_interpretations: tuple[str, ...] = (),
41
  forbidden_claims: tuple[str, ...] = (),
42
  related_cards: tuple[str, ...] = (),
 
 
43
  ) -> KnowledgeCard:
44
  return KnowledgeCard(
45
  card_id=card_id,
@@ -52,6 +76,8 @@ def _card(
52
  allowed_interpretations=allowed_interpretations,
53
  forbidden_claims=forbidden_claims,
54
  related_cards=related_cards,
 
 
55
  )
56
 
57
 
@@ -315,22 +341,202 @@ CARD_REGISTRY: tuple[KnowledgeCard, ...] = (
315
  )
316
 
317
 
318
- CARDS_BY_ID = {card.card_id: card for card in CARD_REGISTRY}
319
- CARDS_BY_LABEL = {
320
- label: card
321
- for card in CARD_REGISTRY
322
- for label in card.labels
323
- }
324
- KNOWN_ISSUE_LABELS = frozenset(
325
- label for card in CARD_REGISTRY if card.card_type == "issue" for label in card.labels
326
- )
327
- KNOWN_VARIATION_LABELS = frozenset(
328
- label for card in CARD_REGISTRY if card.card_type == "variation" for label in card.labels
329
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
 
331
 
332
  def get_card_by_label(label: str) -> KnowledgeCard | None:
333
- return CARDS_BY_LABEL.get(label)
334
 
335
 
336
  def cards_for_labels(labels: list[str] | tuple[str, ...]) -> list[KnowledgeCard]:
@@ -346,6 +552,32 @@ def cards_for_labels(labels: list[str] | tuple[str, ...]) -> list[KnowledgeCard]
346
  )
347
 
348
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
  def retrieve_cards(
350
  *,
351
  profile: UserProfile,
 
1
  from __future__ import annotations
2
 
3
  from dataclasses import dataclass
4
+ import json
5
+ import os
6
+ from functools import lru_cache
7
+ from pathlib import Path
8
 
9
  from pozify.contracts import ExerciseClassification, GOALS, IssueMarkers, UserProfile, Variation
10
 
 
30
  allowed_interpretations: tuple[str, ...] = ()
31
  forbidden_claims: tuple[str, ...] = ()
32
  related_cards: tuple[str, ...] = ()
33
+ source_kind: str = "builtin"
34
+ source_path: str | None = None
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class KnowledgeCatalog:
39
+ cards: tuple[KnowledgeCard, ...]
40
+ cards_by_id: dict[str, KnowledgeCard]
41
+ cards_by_label: dict[str, KnowledgeCard]
42
+ loaded_pack_paths: tuple[str, ...]
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class KnowledgeRetrieval:
47
+ cards: list[KnowledgeCard]
48
+ loaded_pack_paths: tuple[str, ...]
49
+ external_cards_loaded: int
50
+ external_cards_retrieved: int
51
 
52
 
53
  def _card(
 
62
  allowed_interpretations: tuple[str, ...] = (),
63
  forbidden_claims: tuple[str, ...] = (),
64
  related_cards: tuple[str, ...] = (),
65
+ source_kind: str = "builtin",
66
+ source_path: str | None = None,
67
  ) -> KnowledgeCard:
68
  return KnowledgeCard(
69
  card_id=card_id,
 
76
  allowed_interpretations=allowed_interpretations,
77
  forbidden_claims=forbidden_claims,
78
  related_cards=related_cards,
79
+ source_kind=source_kind,
80
+ source_path=source_path,
81
  )
82
 
83
 
 
341
  )
342
 
343
 
344
+ DEFAULT_CARD_PACK_PATHS = (
345
+ Path(__file__).resolve().parents[2] / "data/knowledge_cards/grounded_exercise_expansion.json",
 
 
 
 
 
 
 
 
 
346
  )
347
+ CARD_PACKS_ENV = "POZIFY_KNOWLEDGE_CARD_PACKS"
348
+
349
+
350
+ def _candidate_card_pack_paths() -> tuple[str, ...]:
351
+ candidates: list[str] = []
352
+ for path in DEFAULT_CARD_PACK_PATHS:
353
+ if path.is_file():
354
+ candidates.append(str(path.resolve()))
355
+
356
+ configured = os.getenv(CARD_PACKS_ENV, "").strip()
357
+ if not configured:
358
+ return tuple(candidates)
359
+
360
+ for raw_path in configured.split(os.pathsep):
361
+ cleaned = raw_path.strip()
362
+ if not cleaned:
363
+ continue
364
+ resolved = str(Path(cleaned).expanduser().resolve())
365
+ if resolved not in candidates:
366
+ candidates.append(resolved)
367
+ return tuple(candidates)
368
+
369
+
370
+ def _normalize_string_tuple(values: object, field_name: str, source_path: str) -> tuple[str, ...]:
371
+ if not isinstance(values, list):
372
+ raise ValueError(f"{source_path}: {field_name} must be a list of strings")
373
+ normalized: list[str] = []
374
+ for index, value in enumerate(values):
375
+ if not isinstance(value, str) or not value.strip():
376
+ raise ValueError(
377
+ f"{source_path}: {field_name}[{index}] must be a non-empty string"
378
+ )
379
+ normalized.append(value.strip())
380
+ return tuple(normalized)
381
+
382
+
383
+ def _load_pack_card(payload: object, source_path: str) -> KnowledgeCard:
384
+ if not isinstance(payload, dict):
385
+ raise ValueError(f"{source_path}: each card must be an object")
386
+
387
+ required = {
388
+ "card_id",
389
+ "card_type",
390
+ "labels",
391
+ "title",
392
+ "summary",
393
+ "evidence_rules",
394
+ "coaching_points",
395
+ }
396
+ missing = sorted(required - payload.keys())
397
+ if missing:
398
+ raise ValueError(
399
+ f"{source_path}: card missing required field(s): {', '.join(missing)}"
400
+ )
401
+
402
+ card_id = payload["card_id"]
403
+ card_type = payload["card_type"]
404
+ title = payload["title"]
405
+ summary = payload["summary"]
406
+ if not isinstance(card_id, str) or not card_id.strip():
407
+ raise ValueError(f"{source_path}: card_id must be a non-empty string")
408
+ if not isinstance(card_type, str) or not card_type.strip():
409
+ raise ValueError(f"{source_path}: card_type must be a non-empty string")
410
+ if not isinstance(title, str) or not title.strip():
411
+ raise ValueError(f"{source_path}: title must be a non-empty string")
412
+ if not isinstance(summary, str) or not summary.strip():
413
+ raise ValueError(f"{source_path}: summary must be a non-empty string")
414
+
415
+ return _card(
416
+ card_id=card_id.strip(),
417
+ card_type=card_type.strip(),
418
+ labels=_normalize_string_tuple(payload["labels"], "labels", source_path),
419
+ title=title.strip(),
420
+ summary=summary.strip(),
421
+ evidence_rules=_normalize_string_tuple(
422
+ payload["evidence_rules"], "evidence_rules", source_path
423
+ ),
424
+ coaching_points=_normalize_string_tuple(
425
+ payload["coaching_points"], "coaching_points", source_path
426
+ ),
427
+ allowed_interpretations=_normalize_string_tuple(
428
+ payload.get("allowed_interpretations", []),
429
+ "allowed_interpretations",
430
+ source_path,
431
+ ),
432
+ forbidden_claims=_normalize_string_tuple(
433
+ payload.get("forbidden_claims", []),
434
+ "forbidden_claims",
435
+ source_path,
436
+ ),
437
+ related_cards=_normalize_string_tuple(
438
+ payload.get("related_cards", []),
439
+ "related_cards",
440
+ source_path,
441
+ ),
442
+ source_kind="external",
443
+ source_path=source_path,
444
+ )
445
+
446
+
447
+ def _load_external_cards(path: str) -> tuple[KnowledgeCard, ...]:
448
+ resolved = Path(path).expanduser().resolve()
449
+ if not resolved.is_file():
450
+ raise FileNotFoundError(f"Knowledge card pack not found: {resolved}")
451
+
452
+ payload = json.loads(resolved.read_text(encoding="utf-8"))
453
+ if not isinstance(payload, dict):
454
+ raise ValueError(f"{resolved}: card pack must be a JSON object")
455
+ cards_payload = payload.get("cards")
456
+ if not isinstance(cards_payload, list):
457
+ raise ValueError(f"{resolved}: `cards` must be a list")
458
+
459
+ cards = [_load_pack_card(card_payload, str(resolved)) for card_payload in cards_payload]
460
+ seen_ids: set[str] = set()
461
+ for card in cards:
462
+ if card.card_id in seen_ids:
463
+ raise ValueError(f"{resolved}: duplicate card_id {card.card_id!r}")
464
+ seen_ids.add(card.card_id)
465
+ return tuple(cards)
466
+
467
+
468
+ def _build_cards_by_label(cards: tuple[KnowledgeCard, ...]) -> dict[str, KnowledgeCard]:
469
+ cards_by_label: dict[str, KnowledgeCard] = {}
470
+ for card in cards:
471
+ for label in card.labels:
472
+ existing = cards_by_label.get(label)
473
+ if existing is not None and existing.card_id != card.card_id:
474
+ raise ValueError(
475
+ f"Knowledge label conflict for {label!r}: "
476
+ f"{existing.card_id!r} vs {card.card_id!r}"
477
+ )
478
+ cards_by_label[label] = card
479
+ return cards_by_label
480
+
481
+
482
+ @lru_cache(maxsize=8)
483
+ def _catalog_for_paths(pack_paths: tuple[str, ...]) -> KnowledgeCatalog:
484
+ cards_by_id = {card.card_id: card for card in CARD_REGISTRY}
485
+ for path in pack_paths:
486
+ for card in _load_external_cards(path):
487
+ cards_by_id[card.card_id] = card
488
+
489
+ cards = tuple(
490
+ sorted(
491
+ cards_by_id.values(),
492
+ key=lambda card: (CARD_TYPE_ORDER.get(card.card_type, 99), card.card_id),
493
+ )
494
+ )
495
+ return KnowledgeCatalog(
496
+ cards=cards,
497
+ cards_by_id={card.card_id: card for card in cards},
498
+ cards_by_label=_build_cards_by_label(cards),
499
+ loaded_pack_paths=pack_paths,
500
+ )
501
+
502
+
503
+ def get_catalog(pack_paths: tuple[str, ...] | None = None) -> KnowledgeCatalog:
504
+ selected_paths = _candidate_card_pack_paths() if pack_paths is None else pack_paths
505
+ return _catalog_for_paths(tuple(selected_paths))
506
+
507
+
508
+ def configured_card_pack_paths() -> tuple[str, ...]:
509
+ return get_catalog().loaded_pack_paths
510
+
511
+
512
+ def clear_catalog_cache() -> None:
513
+ _catalog_for_paths.cache_clear()
514
+
515
+
516
+ def _labels_for_card_type(card_type: str, *, pack_paths: tuple[str, ...] | None = None) -> frozenset[str]:
517
+ catalog = get_catalog(pack_paths)
518
+ return frozenset(
519
+ label
520
+ for card in catalog.cards
521
+ if card.card_type == card_type
522
+ for label in card.labels
523
+ )
524
+
525
+
526
+ KNOWN_ISSUE_LABELS = _labels_for_card_type("issue")
527
+ KNOWN_VARIATION_LABELS = _labels_for_card_type("variation")
528
+
529
+
530
+ def known_issue_labels() -> frozenset[str]:
531
+ return _labels_for_card_type("issue")
532
+
533
+
534
+ def known_variation_labels() -> frozenset[str]:
535
+ return _labels_for_card_type("variation")
536
 
537
 
538
  def get_card_by_label(label: str) -> KnowledgeCard | None:
539
+ return get_catalog().cards_by_label.get(label)
540
 
541
 
542
  def cards_for_labels(labels: list[str] | tuple[str, ...]) -> list[KnowledgeCard]:
 
552
  )
553
 
554
 
555
+ def retrieve_cards_with_metadata(
556
+ *,
557
+ profile: UserProfile,
558
+ classification: ExerciseClassification,
559
+ variation: Variation,
560
+ issues: IssueMarkers,
561
+ ) -> KnowledgeRetrieval:
562
+ cards = retrieve_cards(
563
+ profile=profile,
564
+ classification=classification,
565
+ variation=variation,
566
+ issues=issues,
567
+ )
568
+ catalog = get_catalog()
569
+ return KnowledgeRetrieval(
570
+ cards=cards,
571
+ loaded_pack_paths=catalog.loaded_pack_paths,
572
+ external_cards_loaded=sum(
573
+ 1 for card in catalog.cards if card.source_kind == "external"
574
+ ),
575
+ external_cards_retrieved=sum(
576
+ 1 for card in cards if card.source_kind == "external"
577
+ ),
578
+ )
579
+
580
+
581
  def retrieve_cards(
582
  *,
583
  profile: UserProfile,
src/pozify/pipeline.py CHANGED
@@ -11,7 +11,7 @@ from pozify.artifacts import write_json
11
  from pozify.contracts import UserProfile, to_dict
12
  from pozify.env import env_truthy, load_local_env
13
  from pozify.exercises import create_exercise_strategy
14
- from pozify.knowledge_cards import retrieve_cards
15
  from pozify.steps import (
16
  annotated_renderer,
17
  coach_summary,
@@ -232,12 +232,13 @@ def run_pipeline(
232
  if mock_mode:
233
  mock_steps.insert(0, "exercise_classifier")
234
 
235
- summary_cards = retrieve_cards(
236
  profile=profile,
237
  classification=classification,
238
  variation=variation,
239
  issues=issues,
240
  )
 
241
  coach_result = coach_summary.run_with_metadata(
242
  profile,
243
  classification,
@@ -319,6 +320,9 @@ def run_pipeline(
319
  "coach_summary_model": coach_summary_model,
320
  "coach_summary_verifier_bypassed": coach_summary_verifier_bypassed,
321
  "coach_summary_verifier_bypass_requested": bypass_verifier_enabled,
 
 
 
322
  },
323
  }
324
  write_artifact("final_report.json", final_report)
 
11
  from pozify.contracts import UserProfile, to_dict
12
  from pozify.env import env_truthy, load_local_env
13
  from pozify.exercises import create_exercise_strategy
14
+ from pozify.knowledge_cards import retrieve_cards_with_metadata
15
  from pozify.steps import (
16
  annotated_renderer,
17
  coach_summary,
 
232
  if mock_mode:
233
  mock_steps.insert(0, "exercise_classifier")
234
 
235
+ knowledge_retrieval = retrieve_cards_with_metadata(
236
  profile=profile,
237
  classification=classification,
238
  variation=variation,
239
  issues=issues,
240
  )
241
+ summary_cards = knowledge_retrieval.cards
242
  coach_result = coach_summary.run_with_metadata(
243
  profile,
244
  classification,
 
320
  "coach_summary_model": coach_summary_model,
321
  "coach_summary_verifier_bypassed": coach_summary_verifier_bypassed,
322
  "coach_summary_verifier_bypass_requested": bypass_verifier_enabled,
323
+ "knowledge_card_pack_paths": list(knowledge_retrieval.loaded_pack_paths),
324
+ "knowledge_external_cards_loaded": knowledge_retrieval.external_cards_loaded,
325
+ "knowledge_external_cards_retrieved": knowledge_retrieval.external_cards_retrieved,
326
  },
327
  }
328
  write_artifact("final_report.json", final_report)
src/pozify/steps/verifier.py CHANGED
@@ -11,7 +11,7 @@ from pozify.contracts import (
11
  Variation,
12
  Verification,
13
  )
14
- from pozify.knowledge_cards import KNOWN_ISSUE_LABELS
15
 
16
 
17
  DIAGNOSIS_PATTERNS = (
@@ -80,7 +80,7 @@ def _mentioned_labels(summary: CoachSummary) -> set[str]:
80
  text = " ".join(_summary_sections(summary))
81
  labels = set(re.findall(r"`([a-z0-9_]+)`", text))
82
  lowered = text.lower()
83
- for label in KNOWN_ISSUE_LABELS:
84
  if label in lowered:
85
  labels.add(label)
86
  return labels
@@ -117,7 +117,7 @@ def run(
117
  ) -> Verification:
118
  allowed_issues = {issue.issue for issue in issues.issues}
119
  mentioned_labels = _mentioned_labels(summary)
120
- mentioned_issues = mentioned_labels & KNOWN_ISSUE_LABELS
121
 
122
  no_issue_outside_json = mentioned_issues <= allowed_issues
123
 
 
11
  Variation,
12
  Verification,
13
  )
14
+ from pozify.knowledge_cards import known_issue_labels
15
 
16
 
17
  DIAGNOSIS_PATTERNS = (
 
80
  text = " ".join(_summary_sections(summary))
81
  labels = set(re.findall(r"`([a-z0-9_]+)`", text))
82
  lowered = text.lower()
83
+ for label in known_issue_labels():
84
  if label in lowered:
85
  labels.add(label)
86
  return labels
 
117
  ) -> Verification:
118
  allowed_issues = {issue.issue for issue in issues.issues}
119
  mentioned_labels = _mentioned_labels(summary)
120
+ mentioned_issues = mentioned_labels & known_issue_labels()
121
 
122
  no_issue_outside_json = mentioned_issues <= allowed_issues
123