tthhanh commited on
Commit
e7a202c
·
1 Parent(s): a2af43d

feat: add exercise dataset to card-pack transformer

Browse files
src/pozify/knowledge_card_dataset_transformer.py ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from collections.abc import Iterable
5
+ from datetime import datetime, timezone
6
+ import json
7
+ from pathlib import Path
8
+ import re
9
+ from typing import Any
10
+
11
+
12
+ FIELD_CANDIDATES = {
13
+ "name": ("name", "exercise", "title", "Exercise_Name"),
14
+ "aliases": ("aliases", "alias", "alternative_names", "Alternative Names"),
15
+ "category": ("category", "Category", "exercise_type", "type"),
16
+ "equipment": ("equipment", "Equipment", "equipments"),
17
+ "primary_muscles": (
18
+ "primary_muscles",
19
+ "primaryMuscles",
20
+ "target_muscle",
21
+ "Target Muscle",
22
+ "muscles",
23
+ ),
24
+ "secondary_muscles": (
25
+ "secondary_muscles",
26
+ "secondaryMuscles",
27
+ "synergist_muscles",
28
+ "Secondary Muscles",
29
+ ),
30
+ "instructions": ("instructions", "Instructions", "steps", "how_to"),
31
+ "description": ("description", "Description", "summary", "overview"),
32
+ }
33
+
34
+ KNOWN_EXERCISE_LABELS = {
35
+ "push up": "push_up",
36
+ "push-up": "push_up",
37
+ "pushup": "push_up",
38
+ "shoulder press": "shoulder_press",
39
+ "overhead press": "shoulder_press",
40
+ "bodyweight squat": "squat",
41
+ "air squat": "squat",
42
+ }
43
+
44
+
45
+ def _first_present(row: dict[str, Any], field_names: tuple[str, ...]) -> Any:
46
+ for field_name in field_names:
47
+ if field_name in row and row[field_name] not in (None, ""):
48
+ return row[field_name]
49
+ return None
50
+
51
+
52
+ def _as_string_list(value: Any) -> list[str]:
53
+ if value is None:
54
+ return []
55
+ if isinstance(value, str):
56
+ raw_parts = re.split(r"[;\n]|(?:,\s*)", value)
57
+ return [part.strip() for part in raw_parts if part.strip()]
58
+ if isinstance(value, list):
59
+ values: list[str] = []
60
+ for item in value:
61
+ if isinstance(item, str) and item.strip():
62
+ values.append(item.strip())
63
+ return values
64
+ return []
65
+
66
+
67
+ def _as_instruction_list(value: Any) -> list[str]:
68
+ if value is None:
69
+ return []
70
+ if isinstance(value, list):
71
+ return [str(item).strip() for item in value if str(item).strip()]
72
+ if isinstance(value, str):
73
+ lines = [line.strip("-• ").strip() for line in value.splitlines() if line.strip()]
74
+ if len(lines) > 1:
75
+ return lines
76
+ sentence_parts = [part.strip() for part in value.split(".") if part.strip()]
77
+ return [part if part.endswith(".") else f"{part}." for part in sentence_parts]
78
+ return []
79
+
80
+
81
+ def _slugify(value: str) -> str:
82
+ lowered = value.strip().lower()
83
+ lowered = KNOWN_EXERCISE_LABELS.get(lowered, lowered)
84
+ lowered = re.sub(r"[^a-z0-9]+", "_", lowered)
85
+ return lowered.strip("_")
86
+
87
+
88
+ def _display_name(value: str) -> str:
89
+ words = re.split(r"[_\s-]+", value.strip())
90
+ return " ".join(word.capitalize() for word in words if word)
91
+
92
+
93
+ def _dedupe_keep_order(values: Iterable[str]) -> list[str]:
94
+ seen: set[str] = set()
95
+ ordered: list[str] = []
96
+ for value in values:
97
+ normalized = value.strip()
98
+ if not normalized or normalized in seen:
99
+ continue
100
+ seen.add(normalized)
101
+ ordered.append(normalized)
102
+ return ordered
103
+
104
+
105
+ def _normalize_exercise_label(name: str, aliases: list[str]) -> str:
106
+ alias_candidates = [name, *aliases]
107
+ for candidate in alias_candidates:
108
+ normalized = KNOWN_EXERCISE_LABELS.get(candidate.strip().lower())
109
+ if normalized:
110
+ return normalized
111
+ return _slugify(name)
112
+
113
+
114
+ def _build_summary(
115
+ *,
116
+ exercise_name: str,
117
+ category: str | None,
118
+ equipment: list[str],
119
+ primary_muscles: list[str],
120
+ description: str | None,
121
+ ) -> str:
122
+ parts = [f"A {exercise_name} summary should stay grounded in structured rep evidence."]
123
+ if category:
124
+ parts.append(f"This movement is commonly categorized as {category.lower()}.")
125
+ if equipment:
126
+ parts.append(f"Typical equipment context: {', '.join(equipment[:3])}.")
127
+ if primary_muscles:
128
+ parts.append(
129
+ f"Common target-muscle context includes {', '.join(primary_muscles[:3])}."
130
+ )
131
+ if description:
132
+ parts.append(description.strip())
133
+ return " ".join(parts)
134
+
135
+
136
+ def transform_dataset_rows(
137
+ rows: list[dict[str, Any]],
138
+ *,
139
+ source_dataset: str,
140
+ ) -> dict[str, Any]:
141
+ cards: list[dict[str, Any]] = []
142
+ for row in rows:
143
+ name_value = _first_present(row, FIELD_CANDIDATES["name"])
144
+ if not isinstance(name_value, str) or not name_value.strip():
145
+ continue
146
+
147
+ aliases = _dedupe_keep_order(
148
+ _as_string_list(_first_present(row, FIELD_CANDIDATES["aliases"]))
149
+ )
150
+ exercise_label = _normalize_exercise_label(name_value, aliases)
151
+ title = _display_name(name_value)
152
+ category = _first_present(row, FIELD_CANDIDATES["category"])
153
+ equipment = _dedupe_keep_order(
154
+ _as_string_list(_first_present(row, FIELD_CANDIDATES["equipment"]))
155
+ )
156
+ primary_muscles = _dedupe_keep_order(
157
+ _as_string_list(_first_present(row, FIELD_CANDIDATES["primary_muscles"]))
158
+ )
159
+ secondary_muscles = _dedupe_keep_order(
160
+ _as_string_list(_first_present(row, FIELD_CANDIDATES["secondary_muscles"]))
161
+ )
162
+ instructions = _dedupe_keep_order(
163
+ _as_instruction_list(_first_present(row, FIELD_CANDIDATES["instructions"]))
164
+ )
165
+ description_value = _first_present(row, FIELD_CANDIDATES["description"])
166
+ description = description_value.strip() if isinstance(description_value, str) else None
167
+
168
+ labels = _dedupe_keep_order(
169
+ [exercise_label, *(_slugify(alias) for alias in aliases if alias.strip())]
170
+ )
171
+
172
+ evidence_rules = [
173
+ "Use structured rep analysis and issue markers instead of inferring directly from raw video.",
174
+ "Treat valid detected variations as context unless issue markers show a separate problem.",
175
+ ]
176
+ if equipment:
177
+ evidence_rules.append(
178
+ f"Use {', '.join(equipment[:3])} only as exercise context, not as a claim about what the user must change."
179
+ )
180
+
181
+ coaching_points = instructions[:3]
182
+ if not coaching_points:
183
+ coaching_points = [
184
+ "Keep the summary focused on repeatable setup and execution cues supported by the evidence."
185
+ ]
186
+
187
+ allowed_interpretations = []
188
+ if primary_muscles:
189
+ muscle_line = f"Common target muscles: {', '.join(primary_muscles[:3])}."
190
+ if secondary_muscles:
191
+ muscle_line += f" Secondary support may include {', '.join(secondary_muscles[:3])}."
192
+ allowed_interpretations.append(muscle_line)
193
+ if equipment:
194
+ allowed_interpretations.append(
195
+ f"Common equipment context: {', '.join(equipment[:3])}."
196
+ )
197
+
198
+ cards.append(
199
+ {
200
+ "card_id": f"exercise:{exercise_label}",
201
+ "card_type": "exercise",
202
+ "labels": labels,
203
+ "title": title,
204
+ "summary": _build_summary(
205
+ exercise_name=title,
206
+ category=category if isinstance(category, str) else None,
207
+ equipment=equipment,
208
+ primary_muscles=primary_muscles,
209
+ description=description,
210
+ ),
211
+ "evidence_rules": evidence_rules,
212
+ "coaching_points": coaching_points,
213
+ "allowed_interpretations": allowed_interpretations,
214
+ "related_cards": [],
215
+ }
216
+ )
217
+
218
+ cards.sort(key=lambda card: card["card_id"])
219
+ return {
220
+ "source_dataset": source_dataset,
221
+ "generated_at": datetime.now(timezone.utc).isoformat(),
222
+ "card_count": len(cards),
223
+ "cards": cards,
224
+ }
225
+
226
+
227
+ def load_dataset_rows(path: Path) -> list[dict[str, Any]]:
228
+ suffix = path.suffix.lower()
229
+ text = path.read_text(encoding="utf-8")
230
+ if suffix == ".jsonl":
231
+ rows: list[dict[str, Any]] = []
232
+ for line_number, line in enumerate(text.splitlines(), start=1):
233
+ stripped = line.strip()
234
+ if not stripped:
235
+ continue
236
+ payload = json.loads(stripped)
237
+ if not isinstance(payload, dict):
238
+ raise ValueError(f"{path}:{line_number} must be a JSON object")
239
+ rows.append(payload)
240
+ return rows
241
+
242
+ payload = json.loads(text)
243
+ if isinstance(payload, list):
244
+ rows = payload
245
+ elif isinstance(payload, dict):
246
+ rows_value = payload.get("rows") or payload.get("data") or payload.get("train")
247
+ if not isinstance(rows_value, list):
248
+ raise ValueError(
249
+ f"{path} must contain a top-level list or a `rows`/`data`/`train` list"
250
+ )
251
+ rows = rows_value
252
+ else:
253
+ raise ValueError(f"{path} must be a JSON array or object")
254
+
255
+ for index, row in enumerate(rows):
256
+ if not isinstance(row, dict):
257
+ raise ValueError(f"{path}: row {index} must be a JSON object")
258
+ return rows
259
+
260
+
261
+ def write_card_pack(
262
+ *,
263
+ input_path: Path,
264
+ output_path: Path,
265
+ source_dataset: str,
266
+ ) -> dict[str, Any]:
267
+ rows = load_dataset_rows(input_path)
268
+ pack = transform_dataset_rows(rows, source_dataset=source_dataset)
269
+ output_path.parent.mkdir(parents=True, exist_ok=True)
270
+ output_path.write_text(json.dumps(pack, ensure_ascii=False, indent=2), encoding="utf-8")
271
+ return pack
272
+
273
+
274
+ def build_arg_parser() -> argparse.ArgumentParser:
275
+ parser = argparse.ArgumentParser(
276
+ description="Convert an exercise dataset export into a Pozify knowledge-card pack."
277
+ )
278
+ parser.add_argument("--input", required=True, help="Path to a JSON or JSONL dataset export.")
279
+ parser.add_argument(
280
+ "--output",
281
+ required=True,
282
+ help="Destination path for the generated Pozify card-pack JSON.",
283
+ )
284
+ parser.add_argument(
285
+ "--source-dataset",
286
+ default="unknown_dataset",
287
+ help="Dataset identifier to record in the generated pack metadata.",
288
+ )
289
+ return parser
290
+
291
+
292
+ def main(argv: list[str] | None = None) -> int:
293
+ parser = build_arg_parser()
294
+ args = parser.parse_args(argv)
295
+ pack = write_card_pack(
296
+ input_path=Path(args.input),
297
+ output_path=Path(args.output),
298
+ source_dataset=args.source_dataset,
299
+ )
300
+ print(
301
+ f"Wrote {pack['card_count']} cards from {args.source_dataset} to {args.output}"
302
+ )
303
+ return 0
304
+
305
+
306
+ if __name__ == "__main__":
307
+ raise SystemExit(main())
tests/test_knowledge_card_dataset_transformer.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.knowledge_card_dataset_transformer import (
12
+ load_dataset_rows,
13
+ transform_dataset_rows,
14
+ write_card_pack,
15
+ )
16
+
17
+
18
+ class KnowledgeCardDatasetTransformerTests(unittest.TestCase):
19
+ def test_transform_dataset_rows_maps_common_fields(self) -> None:
20
+ pack = transform_dataset_rows(
21
+ [
22
+ {
23
+ "name": "Push-Up",
24
+ "aliases": ["Push Up", "Pushup"],
25
+ "category": "Strength",
26
+ "equipment": ["Bodyweight"],
27
+ "primary_muscles": ["Chest", "Triceps"],
28
+ "secondary_muscles": ["Shoulders"],
29
+ "instructions": [
30
+ "Keep a straight line from shoulders through ankles.",
31
+ "Lower until the bottom position is controlled.",
32
+ ],
33
+ "description": "A classic upper-body bodyweight exercise.",
34
+ }
35
+ ],
36
+ source_dataset="demo/exercises",
37
+ )
38
+
39
+ self.assertEqual(pack["source_dataset"], "demo/exercises")
40
+ self.assertEqual(pack["card_count"], 1)
41
+ card = pack["cards"][0]
42
+ self.assertEqual(card["card_id"], "exercise:push_up")
43
+ self.assertEqual(card["labels"], ["push_up"])
44
+ self.assertIn("Bodyweight", card["summary"])
45
+ self.assertIn("Chest", " ".join(card["allowed_interpretations"]))
46
+ self.assertEqual(
47
+ card["coaching_points"][0],
48
+ "Keep a straight line from shoulders through ankles.",
49
+ )
50
+
51
+ def test_load_dataset_rows_supports_jsonl(self) -> None:
52
+ with tempfile.TemporaryDirectory() as temp_dir:
53
+ dataset_path = Path(temp_dir) / "dataset.jsonl"
54
+ dataset_path.write_text(
55
+ "\n".join(
56
+ [
57
+ json.dumps({"name": "Squat", "equipment": ["Bodyweight"]}),
58
+ json.dumps({"name": "Shoulder Press", "equipment": ["Dumbbell"]}),
59
+ ]
60
+ ),
61
+ encoding="utf-8",
62
+ )
63
+
64
+ rows = load_dataset_rows(dataset_path)
65
+
66
+ self.assertEqual(len(rows), 2)
67
+ self.assertEqual(rows[0]["name"], "Squat")
68
+
69
+ def test_write_card_pack_writes_transform_output(self) -> None:
70
+ with tempfile.TemporaryDirectory() as temp_dir:
71
+ input_path = Path(temp_dir) / "dataset.json"
72
+ output_path = Path(temp_dir) / "pack.json"
73
+ input_path.write_text(
74
+ json.dumps(
75
+ [
76
+ {
77
+ "Exercise_Name": "Shoulder Press",
78
+ "Equipment": ["Dumbbell"],
79
+ "Target Muscle": ["Shoulders"],
80
+ "Instructions": "Press the weights overhead.\nLower with control.",
81
+ }
82
+ ]
83
+ ),
84
+ encoding="utf-8",
85
+ )
86
+
87
+ pack = write_card_pack(
88
+ input_path=input_path,
89
+ output_path=output_path,
90
+ source_dataset="demo/export",
91
+ )
92
+
93
+ written = json.loads(output_path.read_text(encoding="utf-8"))
94
+
95
+ self.assertEqual(pack["card_count"], 1)
96
+ self.assertEqual(written["source_dataset"], "demo/export")
97
+ self.assertEqual(written["cards"][0]["card_id"], "exercise:shoulder_press")
98
+ self.assertEqual(
99
+ written["cards"][0]["coaching_points"],
100
+ ["Press the weights overhead.", "Lower with control."],
101
+ )
102
+
103
+
104
+ if __name__ == "__main__":
105
+ unittest.main()