Files
WerkoutAPI/generator/services/plan_builder.py
Trey t 1c61b80731 workout generator audit: rules engine, structure rules, split patterns, injury UX, metadata cleanup
- Add rules_engine.py with quantitative rules for all 8 workout types
- Add quality gate retry loop in generate_single_workout()
- Expand calibrate_structure_rules to all 120 combinations (8 types × 5 goals × 3 sections)
- Wire WeeklySplitPattern DB records into _pick_weekly_split()
- Enforce movement patterns from WorkoutStructureRule in exercise selection
- Add straight-set strength support (single main lift, 4-6 rounds)
- Add modality consistency check for duration-dominant workout types
- Add InjuryStep component to onboarding and preferences
- Add sibling exercise exclusion in regenerate and preview_day endpoints
- Display generator warnings on dashboard
- Expand fix_rep_durations, fix_exercise_flags, fix_movement_pattern_typo
- Add audit_exercise_data and check_rules_drift management commands
- Add Next.js frontend with dashboard, onboarding, preferences, history pages
- Add generator app with ML-powered workout generation pipeline
- 96 new tests across 7 test modules

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:07:40 -06:00

150 lines
5.3 KiB
Python

import logging
from workout.models import Workout
from superset.models import Superset, SupersetExercise
logger = logging.getLogger(__name__)
class PlanBuilder:
"""
Creates Django ORM objects (Workout, Superset, SupersetExercise) from
a workout specification dict. Follows the exact same creation pattern
used by the existing ``add_workout`` view.
"""
def __init__(self, registered_user):
self.registered_user = registered_user
def create_workout_from_spec(self, workout_spec):
"""
Create a full Workout with Supersets and SupersetExercises.
Parameters
----------
workout_spec : dict
Expected shape::
{
'name': 'Upper Push + Core',
'description': 'Generated workout targeting chest ...',
'supersets': [
{
'name': 'Warm Up',
'rounds': 1,
'exercises': [
{
'exercise': <Exercise instance>,
'duration': 30,
'order': 1,
},
{
'exercise': <Exercise instance>,
'reps': 10,
'weight': 50,
'order': 2,
},
],
},
...
],
}
Returns
-------
Workout
The fully-persisted Workout instance with all child objects.
"""
# ---- 1. Create the Workout ----
workout = Workout.objects.create(
name=workout_spec.get('name', 'Generated Workout'),
description=workout_spec.get('description', ''),
registered_user=self.registered_user,
)
workout.save()
workout_total_time = 0
superset_order = 1
# ---- 2. Create each Superset ----
for ss_spec in workout_spec.get('supersets', []):
ss_name = ss_spec.get('name', f'Set {superset_order}')
rounds = ss_spec.get('rounds', 1)
exercises = ss_spec.get('exercises', [])
superset = Superset.objects.create(
workout=workout,
name=ss_name,
rounds=rounds,
order=superset_order,
rest_between_rounds=ss_spec.get('rest_between_rounds', 45),
)
superset.save()
superset_total_time = 0
# ---- 3. Create each SupersetExercise ----
for ex_spec in exercises:
exercise_obj = ex_spec.get('exercise')
if exercise_obj is None:
logger.warning(
"Skipping exercise entry with no exercise object in "
"superset '%s'", ss_name,
)
continue
order = ex_spec.get('order', 1)
superset_exercise = SupersetExercise.objects.create(
superset=superset,
exercise=exercise_obj,
order=order,
)
# Assign optional fields exactly like add_workout does
if ex_spec.get('weight') is not None:
superset_exercise.weight = ex_spec['weight']
if ex_spec.get('reps') is not None:
superset_exercise.reps = ex_spec['reps']
rep_duration = exercise_obj.estimated_rep_duration or 3.0
superset_total_time += ex_spec['reps'] * rep_duration
if ex_spec.get('duration') is not None:
superset_exercise.duration = ex_spec['duration']
superset_total_time += ex_spec['duration']
superset_exercise.save()
# ---- 4. Update superset estimated_time ----
# Store total time including all rounds and rest between rounds
rest_between_rounds = ss_spec.get('rest_between_rounds', 45)
rest_time = rest_between_rounds * max(0, rounds - 1)
superset.estimated_time = (superset_total_time * rounds) + rest_time
superset.save()
# Accumulate into workout total (use the already-calculated superset time)
workout_total_time += superset.estimated_time
superset_order += 1
# Add transition time between supersets
# (matches GENERATION_RULES['rest_between_supersets'] in workout_generator)
superset_count = superset_order - 1
if superset_count > 1:
rest_between_supersets = 30
workout_total_time += rest_between_supersets * (superset_count - 1)
# ---- 5. Update workout estimated_time ----
workout.estimated_time = workout_total_time
workout.save()
logger.info(
"Created workout '%s' (id=%s) with %d supersets, est. %ds",
workout.name,
workout.pk,
superset_order - 1,
workout_total_time,
)
return workout