tthhanh commited on
Commit
5969249
·
1 Parent(s): 19dfbdf

feat: build Pozify-native coach summary SFT datasets

Browse files
data/sft/coach_summary_eval.jsonl ADDED
The diff for this file is too large to render. See raw diff
 
data/sft/coach_summary_train.jsonl ADDED
The diff for this file is too large to render. See raw diff
 
scripts/build_coach_summary_sft_dataset.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+ import sys
6
+
7
+
8
+ ROOT = Path(__file__).resolve().parents[1]
9
+ sys.path.insert(0, str(ROOT / "src"))
10
+
11
+ from pozify.coach_summary_sft_dataset import ( # noqa: E402
12
+ build_sft_row_from_run_dir,
13
+ collect_run_dirs,
14
+ split_sft_rows,
15
+ write_jsonl,
16
+ )
17
+
18
+
19
+ def build_arg_parser() -> argparse.ArgumentParser:
20
+ parser = argparse.ArgumentParser(
21
+ description="Build Pozify-native SFT train/eval JSONL files from run artifacts."
22
+ )
23
+ parser.add_argument(
24
+ "--runs-dir",
25
+ default=str(ROOT / "runs"),
26
+ help="Directory containing Pozify run artifact folders.",
27
+ )
28
+ parser.add_argument(
29
+ "--train-output",
30
+ default=str(ROOT / "data/sft/coach_summary_train.jsonl"),
31
+ help="Destination for the train JSONL file.",
32
+ )
33
+ parser.add_argument(
34
+ "--eval-output",
35
+ default=str(ROOT / "data/sft/coach_summary_eval.jsonl"),
36
+ help="Destination for the eval JSONL file.",
37
+ )
38
+ parser.add_argument(
39
+ "--eval-count",
40
+ type=int,
41
+ default=10,
42
+ help="Number of examples to reserve for eval.",
43
+ )
44
+ parser.add_argument(
45
+ "--seed",
46
+ type=int,
47
+ default=7,
48
+ help="Shuffle seed for train/eval split.",
49
+ )
50
+ return parser
51
+
52
+
53
+ def main(argv: list[str] | None = None) -> int:
54
+ parser = build_arg_parser()
55
+ args = parser.parse_args(argv)
56
+
57
+ run_dirs = collect_run_dirs(Path(args.runs_dir))
58
+ rows = [build_sft_row_from_run_dir(run_dir) for run_dir in run_dirs]
59
+ train_rows, eval_rows = split_sft_rows(
60
+ rows,
61
+ eval_count=args.eval_count,
62
+ seed=args.seed,
63
+ )
64
+ write_jsonl(Path(args.train_output), train_rows)
65
+ write_jsonl(Path(args.eval_output), eval_rows)
66
+ print(
67
+ {
68
+ "runs_dir": args.runs_dir,
69
+ "row_count": len(rows),
70
+ "train_count": len(train_rows),
71
+ "eval_count": len(eval_rows),
72
+ "train_output": args.train_output,
73
+ "eval_output": args.eval_output,
74
+ }
75
+ )
76
+ return 0
77
+
78
+
79
+ if __name__ == "__main__":
80
+ raise SystemExit(main())
src/pozify/coach_summary_sft_dataset.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict
4
+ import json
5
+ from pathlib import Path
6
+ import random
7
+ from typing import Any
8
+
9
+ from pozify.contracts import (
10
+ CoachSummary,
11
+ ExerciseClassification,
12
+ IssueMarker,
13
+ IssueMarkers,
14
+ Rep,
15
+ RepAnalysis,
16
+ RepAnalysisItem,
17
+ Reps,
18
+ UserProfile,
19
+ Variation,
20
+ )
21
+ from pozify.knowledge_cards import retrieve_cards
22
+ from pozify.slm.prompting import build_summary_evidence
23
+
24
+
25
+ SYSTEM_PROMPT = (
26
+ "You are Pozify's grounded coach-summary model. "
27
+ "Use only the provided structured evidence and knowledge cards. "
28
+ "Return coach_summary.json as JSON only."
29
+ )
30
+
31
+
32
+ def _load_json(path: Path) -> dict[str, Any]:
33
+ payload = json.loads(path.read_text(encoding="utf-8"))
34
+ if not isinstance(payload, dict):
35
+ raise ValueError(f"{path} must contain a JSON object")
36
+ return payload
37
+
38
+
39
+ def _profile_from_dict(payload: dict[str, Any]) -> UserProfile:
40
+ return UserProfile(
41
+ goal=str(payload["goal"]),
42
+ experience_level=str(payload["experience_level"]),
43
+ intended_exercise=str(payload.get("intended_exercise", "auto")),
44
+ intended_variation=payload.get("intended_variation"),
45
+ known_limitations=[str(item) for item in payload.get("known_limitations", [])],
46
+ equipment=str(payload.get("equipment", "unknown")),
47
+ )
48
+
49
+
50
+ def _classification_from_dict(payload: dict[str, Any]) -> ExerciseClassification:
51
+ return ExerciseClassification(
52
+ exercise=str(payload["exercise"]),
53
+ confidence=float(payload["confidence"]),
54
+ window_predictions=list(payload.get("window_predictions", [])),
55
+ fallback_required=bool(payload.get("fallback_required", False)),
56
+ )
57
+
58
+
59
+ def _reps_from_dict(payload: dict[str, Any]) -> Reps:
60
+ return Reps(
61
+ exercise=str(payload["exercise"]),
62
+ reps=[
63
+ Rep(
64
+ rep_id=int(item["rep_id"]),
65
+ start_frame=int(item["start_frame"]),
66
+ mid_frame=int(item["mid_frame"]),
67
+ end_frame=int(item["end_frame"]),
68
+ start_sec=float(item["start_sec"]),
69
+ mid_sec=float(item["mid_sec"]),
70
+ end_sec=float(item["end_sec"]),
71
+ )
72
+ for item in payload.get("reps", [])
73
+ ],
74
+ partial_reps=list(payload.get("partial_reps", [])),
75
+ )
76
+
77
+
78
+ def _analysis_from_dict(payload: dict[str, Any]) -> RepAnalysis:
79
+ return RepAnalysis(
80
+ exercise=str(payload["exercise"]),
81
+ items=[
82
+ RepAnalysisItem(
83
+ rep_id=int(item["rep_id"]),
84
+ duration_sec=float(item["duration_sec"]),
85
+ range_of_motion_score=float(item["range_of_motion_score"]),
86
+ stability_score=float(item["stability_score"]),
87
+ symmetry_score=float(item["symmetry_score"]),
88
+ metrics=dict(item.get("metrics", {})),
89
+ variation_hints=[str(value) for value in item.get("variation_hints", [])],
90
+ )
91
+ for item in payload.get("items", [])
92
+ ],
93
+ aggregate_metrics=dict(payload.get("aggregate_metrics", {})),
94
+ )
95
+
96
+
97
+ def _variation_from_dict(payload: dict[str, Any]) -> Variation:
98
+ return Variation(
99
+ exercise=str(payload["exercise"]),
100
+ detected_variation=str(payload["detected_variation"]),
101
+ variation_confidence=float(payload["variation_confidence"]),
102
+ not_issues=[str(item) for item in payload.get("not_issues", [])],
103
+ )
104
+
105
+
106
+ def _issues_from_dict(payload: dict[str, Any]) -> IssueMarkers:
107
+ return IssueMarkers(
108
+ issues=[
109
+ IssueMarker(
110
+ rep_id=int(item["rep_id"]),
111
+ issue=str(item["issue"]),
112
+ severity=float(item["severity"]),
113
+ start_frame=int(item["start_frame"]),
114
+ end_frame=int(item["end_frame"]),
115
+ start_sec=float(item["start_sec"]),
116
+ end_sec=float(item["end_sec"]),
117
+ affected_joints=[str(value) for value in item.get("affected_joints", [])],
118
+ evidence=dict(item.get("evidence", {})),
119
+ )
120
+ for item in payload.get("issues", [])
121
+ ]
122
+ )
123
+
124
+
125
+ def _summary_from_dict(payload: dict[str, Any]) -> CoachSummary:
126
+ return CoachSummary(
127
+ summary=str(payload["summary"]),
128
+ what_you_did=[str(item) for item in payload.get("what_you_did", [])],
129
+ what_looked_good=[str(item) for item in payload.get("what_looked_good", [])],
130
+ what_changed_across_reps=[
131
+ str(item) for item in payload.get("what_changed_across_reps", [])
132
+ ],
133
+ valid_variation_vs_issue=[
134
+ str(item) for item in payload.get("valid_variation_vs_issue", [])
135
+ ],
136
+ top_fixes=[str(item) for item in payload.get("top_fixes", [])],
137
+ next_session_plan=[str(item) for item in payload.get("next_session_plan", [])],
138
+ confidence_notes=[str(item) for item in payload.get("confidence_notes", [])],
139
+ )
140
+
141
+
142
+ def build_sft_row_from_run_dir(run_dir: Path) -> dict[str, Any]:
143
+ profile = _profile_from_dict(_load_json(run_dir / "user_profile.json"))
144
+ classification = _classification_from_dict(
145
+ _load_json(run_dir / "exercise_classification.json")
146
+ )
147
+ reps = _reps_from_dict(_load_json(run_dir / "reps.json"))
148
+ analysis = _analysis_from_dict(_load_json(run_dir / "rep_analysis.json"))
149
+ variation = _variation_from_dict(_load_json(run_dir / "variation.json"))
150
+ issues = _issues_from_dict(_load_json(run_dir / "issue_markers.json"))
151
+ summary = _summary_from_dict(_load_json(run_dir / "coach_summary.json"))
152
+
153
+ cards = retrieve_cards(
154
+ profile=profile,
155
+ classification=classification,
156
+ variation=variation,
157
+ issues=issues,
158
+ )
159
+ evidence = build_summary_evidence(
160
+ profile=profile,
161
+ classification=classification,
162
+ reps=reps,
163
+ analysis=analysis,
164
+ variation=variation,
165
+ issues=issues,
166
+ cards=cards,
167
+ )
168
+ return {
169
+ "messages": [
170
+ {"role": "system", "content": SYSTEM_PROMPT},
171
+ {
172
+ "role": "user",
173
+ "content": json.dumps(evidence, ensure_ascii=False, indent=2),
174
+ },
175
+ {
176
+ "role": "assistant",
177
+ "content": json.dumps(asdict(summary), ensure_ascii=False, indent=2),
178
+ },
179
+ ],
180
+ "metadata": {
181
+ "run_dir": str(run_dir),
182
+ "exercise": classification.exercise,
183
+ "goal": profile.goal,
184
+ "equipment": profile.equipment,
185
+ "issue_count": len(issues.issues),
186
+ "variation": variation.detected_variation,
187
+ },
188
+ }
189
+
190
+
191
+ def collect_run_dirs(runs_dir: Path) -> list[Path]:
192
+ run_dirs = []
193
+ for child in sorted(runs_dir.iterdir()):
194
+ if not child.is_dir():
195
+ continue
196
+ required = [
197
+ "user_profile.json",
198
+ "exercise_classification.json",
199
+ "reps.json",
200
+ "rep_analysis.json",
201
+ "variation.json",
202
+ "issue_markers.json",
203
+ "coach_summary.json",
204
+ ]
205
+ if all((child / filename).is_file() for filename in required):
206
+ run_dirs.append(child)
207
+ return run_dirs
208
+
209
+
210
+ def split_sft_rows(
211
+ rows: list[dict[str, Any]],
212
+ *,
213
+ eval_count: int,
214
+ seed: int = 7,
215
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
216
+ ordered = list(rows)
217
+ rng = random.Random(seed)
218
+ rng.shuffle(ordered)
219
+ eval_count = max(0, min(eval_count, len(ordered)))
220
+ eval_rows = ordered[:eval_count]
221
+ train_rows = ordered[eval_count:]
222
+ return train_rows, eval_rows
223
+
224
+
225
+ def write_jsonl(path: Path, rows: list[dict[str, Any]]) -> None:
226
+ path.parent.mkdir(parents=True, exist_ok=True)
227
+ with path.open("w", encoding="utf-8") as handle:
228
+ for row in rows:
229
+ handle.write(json.dumps(row, ensure_ascii=False))
230
+ handle.write("\n")
231
+
tests/test_coach_summary_sft_dataset.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ import sys
6
+ import tempfile
7
+ import unittest
8
+
9
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
10
+
11
+ from pozify.coach_summary_sft_dataset import ( # noqa: E402
12
+ build_sft_row_from_run_dir,
13
+ collect_run_dirs,
14
+ split_sft_rows,
15
+ write_jsonl,
16
+ )
17
+
18
+
19
+ def _write_json(path: Path, payload: dict) -> None:
20
+ path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
21
+
22
+
23
+ class CoachSummarySftDatasetTests(unittest.TestCase):
24
+ def test_build_sft_row_from_run_dir(self) -> None:
25
+ with tempfile.TemporaryDirectory() as temp_dir:
26
+ run_dir = Path(temp_dir) / "run-001"
27
+ run_dir.mkdir()
28
+ _write_json(
29
+ run_dir / "user_profile.json",
30
+ {
31
+ "goal": "beginner_practice",
32
+ "experience_level": "beginner",
33
+ "intended_exercise": "push_up",
34
+ "intended_variation": None,
35
+ "known_limitations": [],
36
+ "equipment": "bodyweight",
37
+ },
38
+ )
39
+ _write_json(
40
+ run_dir / "exercise_classification.json",
41
+ {
42
+ "exercise": "push_up",
43
+ "confidence": 0.8,
44
+ "window_predictions": [],
45
+ "fallback_required": False,
46
+ },
47
+ )
48
+ _write_json(
49
+ run_dir / "reps.json",
50
+ {
51
+ "exercise": "push_up",
52
+ "reps": [
53
+ {
54
+ "rep_id": 1,
55
+ "start_frame": 0,
56
+ "mid_frame": 10,
57
+ "end_frame": 20,
58
+ "start_sec": 0.0,
59
+ "mid_sec": 0.3,
60
+ "end_sec": 0.7,
61
+ }
62
+ ],
63
+ "partial_reps": [],
64
+ },
65
+ )
66
+ _write_json(
67
+ run_dir / "rep_analysis.json",
68
+ {
69
+ "exercise": "push_up",
70
+ "items": [
71
+ {
72
+ "rep_id": 1,
73
+ "duration_sec": 0.7,
74
+ "range_of_motion_score": 0.8,
75
+ "stability_score": 0.82,
76
+ "symmetry_score": 0.84,
77
+ "metrics": {"body_line_score": 0.79},
78
+ "variation_hints": ["wide_grip_push_up"],
79
+ }
80
+ ],
81
+ "aggregate_metrics": {"pose_valid_ratio": 0.93},
82
+ },
83
+ )
84
+ _write_json(
85
+ run_dir / "variation.json",
86
+ {
87
+ "exercise": "push_up",
88
+ "detected_variation": "wide_grip_push_up",
89
+ "variation_confidence": 0.77,
90
+ "not_issues": ["wide_hand_placement"],
91
+ },
92
+ )
93
+ _write_json(
94
+ run_dir / "issue_markers.json",
95
+ {
96
+ "issues": [
97
+ {
98
+ "rep_id": 1,
99
+ "issue": "hip_sag",
100
+ "severity": 0.8,
101
+ "start_frame": 10,
102
+ "end_frame": 16,
103
+ "start_sec": 0.3,
104
+ "end_sec": 0.53,
105
+ "affected_joints": ["left_hip", "right_hip"],
106
+ "evidence": {"body_line_score": 0.61},
107
+ }
108
+ ]
109
+ },
110
+ )
111
+ _write_json(
112
+ run_dir / "coach_summary.json",
113
+ {
114
+ "summary": "Example grounded summary.",
115
+ "what_you_did": ["You completed 1 `push_up` rep."],
116
+ "what_looked_good": ["The setup looked organized."],
117
+ "what_changed_across_reps": ["Not enough reps for a strong trend."],
118
+ "valid_variation_vs_issue": [
119
+ "The detected variation was `wide_grip_push_up` and `wide_hand_placement` stayed context only."
120
+ ],
121
+ "top_fixes": ["Keep shoulders, hips, and ankles moving as one line."],
122
+ "next_session_plan": ["Repeat the set with the same setup."],
123
+ "confidence_notes": ["Confidence is moderate."],
124
+ },
125
+ )
126
+
127
+ row = build_sft_row_from_run_dir(run_dir)
128
+
129
+ self.assertEqual(len(row["messages"]), 3)
130
+ self.assertEqual(row["messages"][0]["role"], "system")
131
+ self.assertIn("knowledge_cards", row["messages"][1]["content"])
132
+ self.assertIn("Example grounded summary.", row["messages"][2]["content"])
133
+ self.assertEqual(row["metadata"]["exercise"], "push_up")
134
+
135
+ def test_collect_split_and_write_jsonl(self) -> None:
136
+ with tempfile.TemporaryDirectory() as temp_dir:
137
+ runs_dir = Path(temp_dir) / "runs"
138
+ runs_dir.mkdir()
139
+ for index in range(2):
140
+ run_dir = runs_dir / f"run-{index:03d}"
141
+ run_dir.mkdir()
142
+ for filename in [
143
+ "user_profile.json",
144
+ "exercise_classification.json",
145
+ "reps.json",
146
+ "rep_analysis.json",
147
+ "variation.json",
148
+ "issue_markers.json",
149
+ "coach_summary.json",
150
+ ]:
151
+ _write_json(run_dir / filename, {})
152
+ collected = collect_run_dirs(runs_dir)
153
+ train_rows, eval_rows = split_sft_rows(
154
+ [{"id": 1}, {"id": 2}, {"id": 3}],
155
+ eval_count=1,
156
+ seed=5,
157
+ )
158
+ output_path = Path(temp_dir) / "dataset.jsonl"
159
+ write_jsonl(output_path, train_rows)
160
+
161
+ written_lines = output_path.read_text(encoding="utf-8").splitlines()
162
+
163
+ self.assertEqual(len(collected), 2)
164
+ self.assertEqual(len(eval_rows), 1)
165
+ self.assertEqual(len(train_rows), 2)
166
+ self.assertEqual(len(written_lines), 2)
167
+
168
+
169
+ if __name__ == "__main__":
170
+ unittest.main()