nvti commited on
Commit
c97f76b
·
2 Parent(s): b7e61eadf07925

Merge pull request #11 from tihado/issue-1-harden-pipeline-foundation

Browse files
README.md CHANGED
@@ -18,6 +18,16 @@ uv run gradio app.py
18
 
19
  The app runs at `http://127.0.0.1:7860` by default.
20
 
 
 
 
 
 
 
 
 
 
 
21
  ## Pipeline
22
 
23
  ```text
@@ -64,6 +74,7 @@ docs/
64
 
65
  Each analysis run creates a folder under `runs/<run_id>/`:
66
 
 
67
  - `user_profile.json`
68
  - `video_manifest.json`
69
  - `pose_sequence.json`
@@ -76,6 +87,10 @@ Each analysis run creates a folder under `runs/<run_id>/`:
76
  - `verification.json`
77
  - `final_report.json`
78
 
 
 
 
 
79
  `annotated_video.mp4` is not implemented yet. The mocked renderer currently returns the original input video path and writes `annotated_video_placeholder.json`.
80
 
81
  ## Replacing Mock Steps
@@ -95,6 +110,10 @@ Recommended replacement order:
95
  ## Development Checks
96
 
97
  ```bash
 
98
  python3 -m py_compile app.py src/pozify/*.py src/pozify/steps/*.py
99
  uv run python -c "import app; from pozify.pipeline import run_pipeline; print('ok')"
100
  ```
 
 
 
 
18
 
19
  The app runs at `http://127.0.0.1:7860` by default.
20
 
21
+ The pipeline runs in mock mode by default. To be explicit in scripts, call
22
+ `run_pipeline(..., mock=True)` or set:
23
+
24
+ ```bash
25
+ POZIFY_MOCK_MODE=1 uv run python app.py
26
+ ```
27
+
28
+ Setting `POZIFY_MOCK_MODE=0` currently raises `NotImplementedError` until real model-backed
29
+ steps are added.
30
+
31
  ## Pipeline
32
 
33
  ```text
 
74
 
75
  Each analysis run creates a folder under `runs/<run_id>/`:
76
 
77
+ - `manifest.json`
78
  - `user_profile.json`
79
  - `video_manifest.json`
80
  - `pose_sequence.json`
 
87
  - `verification.json`
88
  - `final_report.json`
89
 
90
+ `manifest.json` indexes the generated artifacts in pipeline order and records whether the run used
91
+ mock mode. JSON artifacts are validated before they are written, including required fields, supported
92
+ enum values, score ranges, frame/timestamp ordering, and final report shape.
93
+
94
  `annotated_video.mp4` is not implemented yet. The mocked renderer currently returns the original input video path and writes `annotated_video_placeholder.json`.
95
 
96
  ## Replacing Mock Steps
 
110
  ## Development Checks
111
 
112
  ```bash
113
+ uv run python -m unittest discover -s tests
114
  python3 -m py_compile app.py src/pozify/*.py src/pozify/steps/*.py
115
  uv run python -c "import app; from pozify.pipeline import run_pipeline; print('ok')"
116
  ```
117
+
118
+ The unit tests run the full mocked pipeline with no video input and with a small fixture path, then
119
+ assert deterministic top-level keys for each JSON artifact.
src/pozify/artifacts.py CHANGED
@@ -4,12 +4,13 @@ import json
4
  from pathlib import Path
5
  from typing import Any
6
 
7
- from pozify.contracts import to_dict
8
 
9
 
10
- def write_json(run_dir: Path, filename: str, payload: Any) -> Path:
11
  run_dir.mkdir(parents=True, exist_ok=True)
 
 
12
  path = run_dir / filename
13
  path.write_text(json.dumps(to_dict(payload), ensure_ascii=False, indent=2), encoding="utf-8")
14
  return path
15
-
 
4
  from pathlib import Path
5
  from typing import Any
6
 
7
+ from pozify.contracts import to_dict, validate_contract
8
 
9
 
10
+ def write_json(run_dir: Path, filename: str, payload: Any, *, validate: bool = True) -> Path:
11
  run_dir.mkdir(parents=True, exist_ok=True)
12
+ if validate:
13
+ validate_contract(filename, payload)
14
  path = run_dir / filename
15
  path.write_text(json.dumps(to_dict(payload), ensure_ascii=False, indent=2), encoding="utf-8")
16
  return path
 
src/pozify/contracts.py CHANGED
@@ -6,6 +6,16 @@ from typing import Any, Literal
6
 
7
  Exercise = Literal["squat", "push_up", "shoulder_press", "unknown"]
8
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  @dataclass(frozen=True)
11
  class UserProfile:
@@ -142,3 +152,377 @@ def to_dict(value: Any) -> Any:
142
  return {key: to_dict(item) for key, item in value.items()}
143
  return value
144
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  Exercise = Literal["squat", "push_up", "shoulder_press", "unknown"]
8
 
9
+ EXERCISES = {"squat", "push_up", "shoulder_press", "unknown"}
10
+ INTENDED_EXERCISES = EXERCISES | {"auto"}
11
+ GOALS = {"strength", "hypertrophy", "endurance", "mobility", "beginner_practice"}
12
+ EXPERIENCE_LEVELS = {"beginner", "intermediate"}
13
+ EQUIPMENT = {"bodyweight", "dumbbell", "barbell", "unknown"}
14
+
15
+
16
+ class ContractValidationError(ValueError):
17
+ """Raised when a pipeline artifact does not match its JSON contract."""
18
+
19
 
20
  @dataclass(frozen=True)
21
  class UserProfile:
 
152
  return {key: to_dict(item) for key, item in value.items()}
153
  return value
154
 
155
+
156
+ def validate_contract(name: str, value: Any) -> None:
157
+ payload = to_dict(value)
158
+ validators = {
159
+ "user_profile.json": _validate_user_profile,
160
+ "video_manifest.json": _validate_video_manifest,
161
+ "pose_sequence.json": _validate_pose_sequence,
162
+ "exercise_classification.json": _validate_exercise_classification,
163
+ "reps.json": _validate_reps,
164
+ "rep_analysis.json": _validate_rep_analysis,
165
+ "variation.json": _validate_variation,
166
+ "issue_markers.json": _validate_issue_markers,
167
+ "coach_summary.json": _validate_coach_summary,
168
+ "verification.json": _validate_verification,
169
+ "final_report.json": _validate_final_report,
170
+ "manifest.json": _validate_run_manifest,
171
+ }
172
+ try:
173
+ validator = validators[name]
174
+ except KeyError as exc:
175
+ raise ContractValidationError(f"Unknown contract: {name}") from exc
176
+ validator(payload, name)
177
+
178
+
179
+ def _require_mapping(value: Any, path: str) -> dict[str, Any]:
180
+ if not isinstance(value, dict):
181
+ raise ContractValidationError(f"{path} must be an object")
182
+ return value
183
+
184
+
185
+ def _require_fields(payload: dict[str, Any], required: set[str], path: str) -> None:
186
+ missing = sorted(required - payload.keys())
187
+ if missing:
188
+ raise ContractValidationError(f"{path} missing required field(s): {', '.join(missing)}")
189
+
190
+
191
+ def _require_type(value: Any, expected_type: type | tuple[type, ...], path: str) -> None:
192
+ if not isinstance(value, expected_type):
193
+ raise ContractValidationError(f"{path} has invalid type")
194
+
195
+
196
+ def _require_bool(value: Any, path: str) -> None:
197
+ if not isinstance(value, bool):
198
+ raise ContractValidationError(f"{path} must be a boolean")
199
+
200
+
201
+ def _require_number(value: Any, path: str, *, minimum: float | None = None) -> None:
202
+ if isinstance(value, bool) or not isinstance(value, int | float):
203
+ raise ContractValidationError(f"{path} must be a number")
204
+ if minimum is not None and value < minimum:
205
+ raise ContractValidationError(f"{path} must be >= {minimum}")
206
+
207
+
208
+ def _require_int(value: Any, path: str, *, minimum: int | None = None) -> None:
209
+ if isinstance(value, bool) or not isinstance(value, int):
210
+ raise ContractValidationError(f"{path} must be an integer")
211
+ if minimum is not None and value < minimum:
212
+ raise ContractValidationError(f"{path} must be >= {minimum}")
213
+
214
+
215
+ def _require_score(value: Any, path: str) -> None:
216
+ _require_number(value, path)
217
+ if value < 0 or value > 1:
218
+ raise ContractValidationError(f"{path} must be between 0 and 1")
219
+
220
+
221
+ def _require_enum(value: Any, allowed: set[str], path: str) -> None:
222
+ if value not in allowed:
223
+ raise ContractValidationError(f"{path} has invalid enum value: {value!r}")
224
+
225
+
226
+ def _require_string_list(value: Any, path: str) -> None:
227
+ _require_type(value, list, path)
228
+ for index, item in enumerate(value):
229
+ _require_type(item, str, f"{path}[{index}]")
230
+
231
+
232
+ def _require_time_range(start_frame: Any, end_frame: Any, start_sec: Any, end_sec: Any, path: str) -> None:
233
+ _require_int(start_frame, f"{path}.start_frame", minimum=0)
234
+ _require_int(end_frame, f"{path}.end_frame", minimum=0)
235
+ _require_number(start_sec, f"{path}.start_sec", minimum=0)
236
+ _require_number(end_sec, f"{path}.end_sec", minimum=0)
237
+ if start_frame > end_frame:
238
+ raise ContractValidationError(f"{path} frame range must be ordered")
239
+ if start_sec > end_sec:
240
+ raise ContractValidationError(f"{path} timestamp range must be ordered")
241
+
242
+
243
+ def _validate_user_profile(value: Any, path: str) -> None:
244
+ payload = _require_mapping(value, path)
245
+ _require_fields(
246
+ payload,
247
+ {
248
+ "goal",
249
+ "experience_level",
250
+ "intended_exercise",
251
+ "intended_variation",
252
+ "known_limitations",
253
+ "equipment",
254
+ },
255
+ path,
256
+ )
257
+ _require_enum(payload["goal"], GOALS, f"{path}.goal")
258
+ _require_enum(payload["experience_level"], EXPERIENCE_LEVELS, f"{path}.experience_level")
259
+ _require_enum(payload["intended_exercise"], INTENDED_EXERCISES, f"{path}.intended_exercise")
260
+ if payload["intended_variation"] is not None:
261
+ _require_type(payload["intended_variation"], str, f"{path}.intended_variation")
262
+ _require_string_list(payload["known_limitations"], f"{path}.known_limitations")
263
+ _require_enum(payload["equipment"], EQUIPMENT, f"{path}.equipment")
264
+
265
+
266
+ def _validate_video_manifest(value: Any, path: str) -> None:
267
+ payload = _require_mapping(value, path)
268
+ _require_fields(
269
+ payload,
270
+ {
271
+ "video_path",
272
+ "fps",
273
+ "duration_sec",
274
+ "total_frames",
275
+ "sampled_frames",
276
+ "quality_warnings",
277
+ "analysis_allowed",
278
+ },
279
+ path,
280
+ )
281
+ if payload["video_path"] is not None:
282
+ _require_type(payload["video_path"], str, f"{path}.video_path")
283
+ _require_number(payload["fps"], f"{path}.fps", minimum=0)
284
+ _require_number(payload["duration_sec"], f"{path}.duration_sec", minimum=0)
285
+ _require_int(payload["total_frames"], f"{path}.total_frames", minimum=0)
286
+ _require_int(payload["sampled_frames"], f"{path}.sampled_frames", minimum=0)
287
+ if payload["sampled_frames"] > payload["total_frames"]:
288
+ raise ContractValidationError(f"{path}.sampled_frames must be <= total_frames")
289
+ _require_string_list(payload["quality_warnings"], f"{path}.quality_warnings")
290
+ _require_bool(payload["analysis_allowed"], f"{path}.analysis_allowed")
291
+
292
+
293
+ def _validate_pose_sequence(value: Any, path: str) -> None:
294
+ payload = _require_mapping(value, path)
295
+ _require_fields(payload, {"frames", "normalized", "smoothing_method", "pose_valid_ratio"}, path)
296
+ _require_type(payload["frames"], list, f"{path}.frames")
297
+ previous_frame = -1
298
+ previous_timestamp = -1.0
299
+ for index, frame_value in enumerate(payload["frames"]):
300
+ frame_path = f"{path}.frames[{index}]"
301
+ frame = _require_mapping(frame_value, frame_path)
302
+ _require_fields(
303
+ frame,
304
+ {"frame_index", "timestamp_sec", "landmarks", "world_landmarks", "pose_quality"},
305
+ frame_path,
306
+ )
307
+ _require_int(frame["frame_index"], f"{frame_path}.frame_index", minimum=0)
308
+ _require_number(frame["timestamp_sec"], f"{frame_path}.timestamp_sec", minimum=0)
309
+ if frame["frame_index"] < previous_frame:
310
+ raise ContractValidationError(f"{frame_path}.frame_index must be ordered")
311
+ if frame["timestamp_sec"] < previous_timestamp:
312
+ raise ContractValidationError(f"{frame_path}.timestamp_sec must be ordered")
313
+ previous_frame = frame["frame_index"]
314
+ previous_timestamp = frame["timestamp_sec"]
315
+ _require_mapping(frame["landmarks"], f"{frame_path}.landmarks")
316
+ _require_mapping(frame["world_landmarks"], f"{frame_path}.world_landmarks")
317
+ _require_mapping(frame["pose_quality"], f"{frame_path}.pose_quality")
318
+ _require_bool(payload["normalized"], f"{path}.normalized")
319
+ _require_type(payload["smoothing_method"], str, f"{path}.smoothing_method")
320
+ _require_score(payload["pose_valid_ratio"], f"{path}.pose_valid_ratio")
321
+
322
+
323
+ def _validate_exercise_classification(value: Any, path: str) -> None:
324
+ payload = _require_mapping(value, path)
325
+ _require_fields(payload, {"exercise", "confidence", "window_predictions", "fallback_required"}, path)
326
+ _require_enum(payload["exercise"], EXERCISES, f"{path}.exercise")
327
+ _require_score(payload["confidence"], f"{path}.confidence")
328
+ _require_type(payload["window_predictions"], list, f"{path}.window_predictions")
329
+ for index, prediction_value in enumerate(payload["window_predictions"]):
330
+ prediction_path = f"{path}.window_predictions[{index}]"
331
+ prediction = _require_mapping(prediction_value, prediction_path)
332
+ _require_fields(prediction, {"start_sec", "end_sec", "label", "confidence"}, prediction_path)
333
+ _require_number(prediction["start_sec"], f"{prediction_path}.start_sec", minimum=0)
334
+ _require_number(prediction["end_sec"], f"{prediction_path}.end_sec", minimum=0)
335
+ if prediction["start_sec"] > prediction["end_sec"]:
336
+ raise ContractValidationError(f"{prediction_path} timestamps must be ordered")
337
+ _require_enum(prediction["label"], EXERCISES, f"{prediction_path}.label")
338
+ _require_score(prediction["confidence"], f"{prediction_path}.confidence")
339
+ _require_bool(payload["fallback_required"], f"{path}.fallback_required")
340
+
341
+
342
+ def _validate_rep(rep_value: Any, path: str) -> None:
343
+ rep = _require_mapping(rep_value, path)
344
+ _require_fields(
345
+ rep,
346
+ {"rep_id", "start_frame", "mid_frame", "end_frame", "start_sec", "mid_sec", "end_sec"},
347
+ path,
348
+ )
349
+ _require_int(rep["rep_id"], f"{path}.rep_id", minimum=1)
350
+ _require_time_range(rep["start_frame"], rep["end_frame"], rep["start_sec"], rep["end_sec"], path)
351
+ _require_int(rep["mid_frame"], f"{path}.mid_frame", minimum=0)
352
+ _require_number(rep["mid_sec"], f"{path}.mid_sec", minimum=0)
353
+ if not rep["start_frame"] <= rep["mid_frame"] <= rep["end_frame"]:
354
+ raise ContractValidationError(f"{path}.mid_frame must be inside rep frame range")
355
+ if not rep["start_sec"] <= rep["mid_sec"] <= rep["end_sec"]:
356
+ raise ContractValidationError(f"{path}.mid_sec must be inside rep timestamp range")
357
+
358
+
359
+ def _validate_reps(value: Any, path: str) -> None:
360
+ payload = _require_mapping(value, path)
361
+ _require_fields(payload, {"exercise", "reps", "partial_reps"}, path)
362
+ _require_enum(payload["exercise"], EXERCISES, f"{path}.exercise")
363
+ _require_type(payload["reps"], list, f"{path}.reps")
364
+ for index, rep in enumerate(payload["reps"]):
365
+ _validate_rep(rep, f"{path}.reps[{index}]")
366
+ _require_type(payload["partial_reps"], list, f"{path}.partial_reps")
367
+
368
+
369
+ def _validate_rep_analysis(value: Any, path: str) -> None:
370
+ payload = _require_mapping(value, path)
371
+ _require_fields(payload, {"exercise", "items", "aggregate_metrics"}, path)
372
+ _require_enum(payload["exercise"], EXERCISES, f"{path}.exercise")
373
+ _require_type(payload["items"], list, f"{path}.items")
374
+ for index, item_value in enumerate(payload["items"]):
375
+ item_path = f"{path}.items[{index}]"
376
+ item = _require_mapping(item_value, item_path)
377
+ _require_fields(
378
+ item,
379
+ {
380
+ "rep_id",
381
+ "duration_sec",
382
+ "range_of_motion_score",
383
+ "stability_score",
384
+ "symmetry_score",
385
+ "metrics",
386
+ "variation_hints",
387
+ },
388
+ item_path,
389
+ )
390
+ _require_int(item["rep_id"], f"{item_path}.rep_id", minimum=1)
391
+ _require_number(item["duration_sec"], f"{item_path}.duration_sec", minimum=0)
392
+ _require_score(item["range_of_motion_score"], f"{item_path}.range_of_motion_score")
393
+ _require_score(item["stability_score"], f"{item_path}.stability_score")
394
+ _require_score(item["symmetry_score"], f"{item_path}.symmetry_score")
395
+ _require_mapping(item["metrics"], f"{item_path}.metrics")
396
+ _require_string_list(item["variation_hints"], f"{item_path}.variation_hints")
397
+ _require_mapping(payload["aggregate_metrics"], f"{path}.aggregate_metrics")
398
+
399
+
400
+ def _validate_variation(value: Any, path: str) -> None:
401
+ payload = _require_mapping(value, path)
402
+ _require_fields(payload, {"exercise", "detected_variation", "variation_confidence", "not_issues"}, path)
403
+ _require_enum(payload["exercise"], EXERCISES, f"{path}.exercise")
404
+ _require_type(payload["detected_variation"], str, f"{path}.detected_variation")
405
+ _require_score(payload["variation_confidence"], f"{path}.variation_confidence")
406
+ _require_string_list(payload["not_issues"], f"{path}.not_issues")
407
+
408
+
409
+ def _validate_issue_markers(value: Any, path: str) -> None:
410
+ payload = _require_mapping(value, path)
411
+ _require_fields(payload, {"issues"}, path)
412
+ _require_type(payload["issues"], list, f"{path}.issues")
413
+ for index, issue_value in enumerate(payload["issues"]):
414
+ issue_path = f"{path}.issues[{index}]"
415
+ issue = _require_mapping(issue_value, issue_path)
416
+ _require_fields(
417
+ issue,
418
+ {
419
+ "rep_id",
420
+ "issue",
421
+ "severity",
422
+ "start_frame",
423
+ "end_frame",
424
+ "start_sec",
425
+ "end_sec",
426
+ "affected_joints",
427
+ "evidence",
428
+ },
429
+ issue_path,
430
+ )
431
+ _require_int(issue["rep_id"], f"{issue_path}.rep_id", minimum=1)
432
+ _require_type(issue["issue"], str, f"{issue_path}.issue")
433
+ _require_score(issue["severity"], f"{issue_path}.severity")
434
+ _require_time_range(
435
+ issue["start_frame"],
436
+ issue["end_frame"],
437
+ issue["start_sec"],
438
+ issue["end_sec"],
439
+ issue_path,
440
+ )
441
+ _require_string_list(issue["affected_joints"], f"{issue_path}.affected_joints")
442
+ _require_mapping(issue["evidence"], f"{issue_path}.evidence")
443
+
444
+
445
+ def _validate_coach_summary(value: Any, path: str) -> None:
446
+ payload = _require_mapping(value, path)
447
+ _require_fields(
448
+ payload,
449
+ {
450
+ "summary",
451
+ "what_went_well",
452
+ "main_findings",
453
+ "variation_explanation",
454
+ "top_fixes",
455
+ "next_session_plan",
456
+ "confidence_notes",
457
+ },
458
+ path,
459
+ )
460
+ _require_type(payload["summary"], str, f"{path}.summary")
461
+ _require_string_list(payload["what_went_well"], f"{path}.what_went_well")
462
+ _require_string_list(payload["main_findings"], f"{path}.main_findings")
463
+ _require_type(payload["variation_explanation"], str, f"{path}.variation_explanation")
464
+ _require_string_list(payload["top_fixes"], f"{path}.top_fixes")
465
+ _require_string_list(payload["next_session_plan"], f"{path}.next_session_plan")
466
+ _require_string_list(payload["confidence_notes"], f"{path}.confidence_notes")
467
+
468
+
469
+ def _validate_verification(value: Any, path: str) -> None:
470
+ payload = _require_mapping(value, path)
471
+ _require_fields(payload, {"passed", "checks", "notes"}, path)
472
+ _require_bool(payload["passed"], f"{path}.passed")
473
+ checks = _require_mapping(payload["checks"], f"{path}.checks")
474
+ for key, value in checks.items():
475
+ _require_type(key, str, f"{path}.checks key")
476
+ _require_bool(value, f"{path}.checks.{key}")
477
+ _require_string_list(payload["notes"], f"{path}.notes")
478
+
479
+
480
+ def _validate_final_report(value: Any, path: str) -> None:
481
+ payload = _require_mapping(value, path)
482
+ _require_fields(
483
+ payload,
484
+ {
485
+ "run_id",
486
+ "profile",
487
+ "video_manifest",
488
+ "exercise",
489
+ "reps",
490
+ "rep_analysis",
491
+ "variation",
492
+ "issue_markers",
493
+ "coach_summary",
494
+ "verification",
495
+ "artifacts",
496
+ },
497
+ path,
498
+ )
499
+ _require_type(payload["run_id"], str, f"{path}.run_id")
500
+ _validate_user_profile(payload["profile"], f"{path}.profile")
501
+ _validate_video_manifest(payload["video_manifest"], f"{path}.video_manifest")
502
+ _validate_exercise_classification(payload["exercise"], f"{path}.exercise")
503
+ _validate_reps(payload["reps"], f"{path}.reps")
504
+ _validate_rep_analysis(payload["rep_analysis"], f"{path}.rep_analysis")
505
+ _validate_variation(payload["variation"], f"{path}.variation")
506
+ _validate_issue_markers(payload["issue_markers"], f"{path}.issue_markers")
507
+ _validate_coach_summary(payload["coach_summary"], f"{path}.coach_summary")
508
+ _validate_verification(payload["verification"], f"{path}.verification")
509
+ artifacts = _require_mapping(payload["artifacts"], f"{path}.artifacts")
510
+ _require_fields(artifacts, {"run_dir", "annotated_video_path"}, f"{path}.artifacts")
511
+ _require_type(artifacts["run_dir"], str, f"{path}.artifacts.run_dir")
512
+ if artifacts["annotated_video_path"] is not None:
513
+ _require_type(artifacts["annotated_video_path"], str, f"{path}.artifacts.annotated_video_path")
514
+
515
+
516
+ def _validate_run_manifest(value: Any, path: str) -> None:
517
+ payload = _require_mapping(value, path)
518
+ _require_fields(payload, {"run_id", "mock_mode", "artifacts"}, path)
519
+ _require_type(payload["run_id"], str, f"{path}.run_id")
520
+ _require_bool(payload["mock_mode"], f"{path}.mock_mode")
521
+ _require_type(payload["artifacts"], list, f"{path}.artifacts")
522
+ for index, artifact_value in enumerate(payload["artifacts"]):
523
+ artifact_path = f"{path}.artifacts[{index}]"
524
+ artifact = _require_mapping(artifact_value, artifact_path)
525
+ _require_fields(artifact, {"name", "path", "contract"}, artifact_path)
526
+ _require_type(artifact["name"], str, f"{artifact_path}.name")
527
+ _require_type(artifact["path"], str, f"{artifact_path}.path")
528
+ _require_type(artifact["contract"], str, f"{artifact_path}.contract")
src/pozify/pipeline.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  from datetime import datetime, timezone
 
4
  from pathlib import Path
5
  from typing import Any
6
  from uuid import uuid4
@@ -25,9 +26,34 @@ from pozify.steps import (
25
  RUNS_DIR = Path("runs")
26
 
27
 
28
- def run_pipeline(video_path: str | None, profile_input: dict[str, Any]) -> dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  run_id = f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{uuid4().hex[:8]}"
30
  run_dir = RUNS_DIR / run_id
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  profile = UserProfile(
33
  goal=profile_input["goal"],
@@ -37,37 +63,44 @@ def run_pipeline(video_path: str | None, profile_input: dict[str, Any]) -> dict[
37
  known_limitations=profile_input.get("known_limitations", []),
38
  equipment=profile_input.get("equipment", "unknown"),
39
  )
40
- write_json(run_dir, "user_profile.json", profile)
41
 
42
  manifest = video_qc.run(video_path)
43
- write_json(run_dir, "video_manifest.json", manifest)
44
 
45
  pose_sequence = pose_landmarker.run(manifest)
46
  cleaned_pose_sequence = pose_cleaning.run(pose_sequence)
47
- write_json(run_dir, "pose_sequence.json", cleaned_pose_sequence)
48
 
49
  classification = exercise_classifier.run(cleaned_pose_sequence, profile)
50
- write_json(run_dir, "exercise_classification.json", classification)
51
 
52
  reps = rep_counter.run(classification, cleaned_pose_sequence)
53
- write_json(run_dir, "reps.json", reps)
54
 
55
  analysis = rep_analysis.run(classification, reps, cleaned_pose_sequence)
56
- write_json(run_dir, "rep_analysis.json", analysis)
57
 
58
  variation = variation_detector.run(classification, analysis, profile)
59
- write_json(run_dir, "variation.json", variation)
60
 
61
  issues = issue_marker.run(classification, reps, analysis, variation)
62
- write_json(run_dir, "issue_markers.json", issues)
63
 
64
  annotated_video_path = annotated_renderer.run(manifest, issues, run_dir)
 
 
 
 
 
 
 
65
 
66
  summary = coach_summary.run(profile, classification, reps, analysis, variation, issues)
67
- write_json(run_dir, "coach_summary.json", summary)
68
 
69
  verification = verifier.run(summary, issues, variation)
70
- write_json(run_dir, "verification.json", verification)
71
 
72
  final_report = {
73
  "run_id": run_id,
@@ -85,12 +118,19 @@ def run_pipeline(video_path: str | None, profile_input: dict[str, Any]) -> dict[
85
  "annotated_video_path": annotated_video_path,
86
  },
87
  }
88
- write_json(run_dir, "final_report.json", final_report)
 
 
 
 
 
 
 
89
 
90
  return {
91
  "run_id": run_id,
92
  "run_dir": str(run_dir),
93
  "annotated_video_path": annotated_video_path,
 
94
  "final_report": final_report,
95
  }
96
-
 
1
  from __future__ import annotations
2
 
3
  from datetime import datetime, timezone
4
+ import os
5
  from pathlib import Path
6
  from typing import Any
7
  from uuid import uuid4
 
26
  RUNS_DIR = Path("runs")
27
 
28
 
29
+ def _env_mock_mode() -> bool:
30
+ value = os.getenv("POZIFY_MOCK_MODE", "1").strip().lower()
31
+ return value not in {"0", "false", "no", "off"}
32
+
33
+
34
+ def run_pipeline(
35
+ video_path: str | None,
36
+ profile_input: dict[str, Any],
37
+ *,
38
+ mock: bool | None = None,
39
+ ) -> dict[str, Any]:
40
+ mock_mode = _env_mock_mode() if mock is None else mock
41
+ if not mock_mode:
42
+ raise NotImplementedError("Only mock pipeline implementations are available right now.")
43
+
44
  run_id = f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{uuid4().hex[:8]}"
45
  run_dir = RUNS_DIR / run_id
46
+ artifact_index: list[dict[str, str]] = []
47
+
48
+ def write_artifact(filename: str, payload: Any) -> None:
49
+ path = write_json(run_dir, filename, payload)
50
+ artifact_index.append(
51
+ {
52
+ "name": filename,
53
+ "path": str(path),
54
+ "contract": filename,
55
+ }
56
+ )
57
 
58
  profile = UserProfile(
59
  goal=profile_input["goal"],
 
63
  known_limitations=profile_input.get("known_limitations", []),
64
  equipment=profile_input.get("equipment", "unknown"),
65
  )
66
+ write_artifact("user_profile.json", profile)
67
 
68
  manifest = video_qc.run(video_path)
69
+ write_artifact("video_manifest.json", manifest)
70
 
71
  pose_sequence = pose_landmarker.run(manifest)
72
  cleaned_pose_sequence = pose_cleaning.run(pose_sequence)
73
+ write_artifact("pose_sequence.json", cleaned_pose_sequence)
74
 
75
  classification = exercise_classifier.run(cleaned_pose_sequence, profile)
76
+ write_artifact("exercise_classification.json", classification)
77
 
78
  reps = rep_counter.run(classification, cleaned_pose_sequence)
79
+ write_artifact("reps.json", reps)
80
 
81
  analysis = rep_analysis.run(classification, reps, cleaned_pose_sequence)
82
+ write_artifact("rep_analysis.json", analysis)
83
 
84
  variation = variation_detector.run(classification, analysis, profile)
85
+ write_artifact("variation.json", variation)
86
 
87
  issues = issue_marker.run(classification, reps, analysis, variation)
88
+ write_artifact("issue_markers.json", issues)
89
 
90
  annotated_video_path = annotated_renderer.run(manifest, issues, run_dir)
91
+ artifact_index.append(
92
+ {
93
+ "name": "annotated_video_placeholder.json",
94
+ "path": str(run_dir / "annotated_video_placeholder.json"),
95
+ "contract": "renderer_placeholder",
96
+ }
97
+ )
98
 
99
  summary = coach_summary.run(profile, classification, reps, analysis, variation, issues)
100
+ write_artifact("coach_summary.json", summary)
101
 
102
  verification = verifier.run(summary, issues, variation)
103
+ write_artifact("verification.json", verification)
104
 
105
  final_report = {
106
  "run_id": run_id,
 
118
  "annotated_video_path": annotated_video_path,
119
  },
120
  }
121
+ write_artifact("final_report.json", final_report)
122
+
123
+ run_manifest = {
124
+ "run_id": run_id,
125
+ "mock_mode": mock_mode,
126
+ "artifacts": artifact_index,
127
+ }
128
+ write_json(run_dir, "manifest.json", run_manifest)
129
 
130
  return {
131
  "run_id": run_id,
132
  "run_dir": str(run_dir),
133
  "annotated_video_path": annotated_video_path,
134
+ "manifest_path": str(run_dir / "manifest.json"),
135
  "final_report": final_report,
136
  }
 
tests/test_pipeline_contracts.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ import tempfile
6
+ import unittest
7
+ from pathlib import Path
8
+
9
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
10
+
11
+ from pozify import pipeline
12
+ from pozify.contracts import ContractValidationError, UserProfile, validate_contract
13
+
14
+
15
+ PROFILE_INPUT = {
16
+ "goal": "beginner_practice",
17
+ "experience_level": "beginner",
18
+ "intended_exercise": "auto",
19
+ "intended_variation": None,
20
+ "known_limitations": [],
21
+ "equipment": "bodyweight",
22
+ }
23
+
24
+ EXPECTED_ARTIFACT_KEYS = {
25
+ "user_profile.json": [
26
+ "equipment",
27
+ "experience_level",
28
+ "goal",
29
+ "intended_exercise",
30
+ "intended_variation",
31
+ "known_limitations",
32
+ ],
33
+ "video_manifest.json": [
34
+ "analysis_allowed",
35
+ "duration_sec",
36
+ "fps",
37
+ "quality_warnings",
38
+ "sampled_frames",
39
+ "total_frames",
40
+ "video_path",
41
+ ],
42
+ "pose_sequence.json": ["frames", "normalized", "pose_valid_ratio", "smoothing_method"],
43
+ "exercise_classification.json": [
44
+ "confidence",
45
+ "exercise",
46
+ "fallback_required",
47
+ "window_predictions",
48
+ ],
49
+ "reps.json": ["exercise", "partial_reps", "reps"],
50
+ "rep_analysis.json": ["aggregate_metrics", "exercise", "items"],
51
+ "variation.json": ["detected_variation", "exercise", "not_issues", "variation_confidence"],
52
+ "issue_markers.json": ["issues"],
53
+ "coach_summary.json": [
54
+ "confidence_notes",
55
+ "main_findings",
56
+ "next_session_plan",
57
+ "summary",
58
+ "top_fixes",
59
+ "variation_explanation",
60
+ "what_went_well",
61
+ ],
62
+ "verification.json": ["checks", "notes", "passed"],
63
+ "final_report.json": [
64
+ "artifacts",
65
+ "coach_summary",
66
+ "exercise",
67
+ "issue_markers",
68
+ "profile",
69
+ "rep_analysis",
70
+ "reps",
71
+ "run_id",
72
+ "variation",
73
+ "verification",
74
+ "video_manifest",
75
+ ],
76
+ "manifest.json": ["artifacts", "mock_mode", "run_id"],
77
+ }
78
+
79
+
80
+ class PipelineContractTests(unittest.TestCase):
81
+ def setUp(self) -> None:
82
+ self.temp_dir = tempfile.TemporaryDirectory()
83
+ self.original_runs_dir = pipeline.RUNS_DIR
84
+ pipeline.RUNS_DIR = Path(self.temp_dir.name) / "runs"
85
+
86
+ def tearDown(self) -> None:
87
+ pipeline.RUNS_DIR = self.original_runs_dir
88
+ self.temp_dir.cleanup()
89
+
90
+ def _assert_pipeline_artifacts(self, result: dict[str, object]) -> None:
91
+ run_dir = Path(str(result["run_dir"]))
92
+ manifest_path = run_dir / "manifest.json"
93
+ self.assertTrue(manifest_path.exists())
94
+
95
+ for artifact_name, keys in EXPECTED_ARTIFACT_KEYS.items():
96
+ artifact_path = run_dir / artifact_name
97
+ self.assertTrue(artifact_path.exists(), artifact_name)
98
+ payload = json.loads(artifact_path.read_text(encoding="utf-8"))
99
+ self.assertEqual(sorted(payload.keys()), keys, artifact_name)
100
+
101
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
102
+ self.assertTrue(manifest["mock_mode"])
103
+ self.assertEqual(
104
+ [artifact["name"] for artifact in manifest["artifacts"]],
105
+ [
106
+ "user_profile.json",
107
+ "video_manifest.json",
108
+ "pose_sequence.json",
109
+ "exercise_classification.json",
110
+ "reps.json",
111
+ "rep_analysis.json",
112
+ "variation.json",
113
+ "issue_markers.json",
114
+ "annotated_video_placeholder.json",
115
+ "coach_summary.json",
116
+ "verification.json",
117
+ "final_report.json",
118
+ ],
119
+ )
120
+
121
+ def test_pipeline_runs_end_to_end_without_video(self) -> None:
122
+ result = pipeline.run_pipeline(video_path=None, profile_input=PROFILE_INPUT, mock=True)
123
+
124
+ self._assert_pipeline_artifacts(result)
125
+ report = result["final_report"]
126
+ self.assertEqual(report["exercise"]["exercise"], "push_up")
127
+ self.assertEqual(report["video_manifest"]["quality_warnings"], ["no_video_uploaded_mock_mode"])
128
+
129
+ def test_pipeline_runs_end_to_end_with_fixture_video_path(self) -> None:
130
+ fixture = Path(__file__).parent / "fixtures" / "sample.mp4"
131
+ result = pipeline.run_pipeline(video_path=str(fixture), profile_input=PROFILE_INPUT, mock=True)
132
+
133
+ self._assert_pipeline_artifacts(result)
134
+ report = result["final_report"]
135
+ self.assertEqual(report["video_manifest"]["video_path"], str(fixture))
136
+ self.assertEqual(report["video_manifest"]["quality_warnings"], [])
137
+
138
+ def test_contract_validation_rejects_missing_required_field(self) -> None:
139
+ payload = {
140
+ "goal": "beginner_practice",
141
+ "experience_level": "beginner",
142
+ "intended_exercise": "auto",
143
+ "intended_variation": None,
144
+ "known_limitations": [],
145
+ }
146
+
147
+ with self.assertRaisesRegex(ContractValidationError, "missing required"):
148
+ validate_contract("user_profile.json", payload)
149
+
150
+ def test_contract_validation_rejects_invalid_enum_value(self) -> None:
151
+ profile = UserProfile(
152
+ goal="beginner_practice",
153
+ experience_level="expert",
154
+ intended_exercise="auto",
155
+ intended_variation=None,
156
+ known_limitations=[],
157
+ equipment="bodyweight",
158
+ )
159
+
160
+ with self.assertRaisesRegex(ContractValidationError, "invalid enum"):
161
+ validate_contract("user_profile.json", profile)
162
+
163
+
164
+ if __name__ == "__main__":
165
+ unittest.main()