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>
This commit is contained in:
249
generator/models.py
Normal file
249
generator/models.py
Normal file
@@ -0,0 +1,249 @@
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from django.db import models
|
||||
from registered_user.models import RegisteredUser
|
||||
from workout.models import Workout
|
||||
from exercise.models import Exercise
|
||||
from equipment.models import Equipment
|
||||
from muscle.models import Muscle
|
||||
|
||||
|
||||
INTENSITY_CHOICES = (
|
||||
('low', 'Low'),
|
||||
('medium', 'Medium'),
|
||||
('high', 'High'),
|
||||
)
|
||||
|
||||
GOAL_CHOICES = (
|
||||
('strength', 'Strength'),
|
||||
('hypertrophy', 'Hypertrophy'),
|
||||
('endurance', 'Endurance'),
|
||||
('weight_loss', 'Weight Loss'),
|
||||
('general_fitness', 'General Fitness'),
|
||||
)
|
||||
|
||||
FITNESS_LEVEL_CHOICES = (
|
||||
(1, 'Beginner'),
|
||||
(2, 'Intermediate'),
|
||||
(3, 'Advanced'),
|
||||
(4, 'Elite'),
|
||||
)
|
||||
|
||||
PLAN_STATUS_CHOICES = (
|
||||
('pending', 'Pending'),
|
||||
('completed', 'Completed'),
|
||||
('failed', 'Failed'),
|
||||
)
|
||||
|
||||
WORKOUT_STATUS_CHOICES = (
|
||||
('pending', 'Pending'),
|
||||
('accepted', 'Accepted'),
|
||||
('rejected', 'Rejected'),
|
||||
('completed', 'Completed'),
|
||||
)
|
||||
|
||||
SPLIT_TYPE_CHOICES = (
|
||||
('push', 'Push'),
|
||||
('pull', 'Pull'),
|
||||
('legs', 'Legs'),
|
||||
('upper', 'Upper'),
|
||||
('lower', 'Lower'),
|
||||
('full_body', 'Full Body'),
|
||||
('core', 'Core'),
|
||||
('cardio', 'Cardio'),
|
||||
)
|
||||
|
||||
SECTION_TYPE_CHOICES = (
|
||||
('warm_up', 'Warm Up'),
|
||||
('working', 'Working'),
|
||||
('cool_down', 'Cool Down'),
|
||||
)
|
||||
|
||||
POSITION_CHOICES = (
|
||||
('early', 'Early'),
|
||||
('middle', 'Middle'),
|
||||
('late', 'Late'),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Reference / Config Models
|
||||
# ============================================================
|
||||
|
||||
class WorkoutType(models.Model):
|
||||
name = models.CharField(max_length=100, unique=True)
|
||||
display_name = models.CharField(max_length=100, blank=True, default='')
|
||||
description = models.TextField(blank=True, default='')
|
||||
typical_rest_between_sets = models.IntegerField(default=60, help_text='Seconds')
|
||||
typical_intensity = models.CharField(max_length=10, choices=INTENSITY_CHOICES, default='medium')
|
||||
rep_range_min = models.IntegerField(default=8)
|
||||
rep_range_max = models.IntegerField(default=12)
|
||||
round_range_min = models.IntegerField(default=3)
|
||||
round_range_max = models.IntegerField(default=4)
|
||||
duration_bias = models.FloatField(default=0.5, help_text='0.0=all rep-based, 1.0=all duration-based')
|
||||
superset_size_min = models.IntegerField(default=2)
|
||||
superset_size_max = models.IntegerField(default=4)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
# ============================================================
|
||||
# User Preference Model
|
||||
# ============================================================
|
||||
|
||||
class UserPreference(models.Model):
|
||||
registered_user = models.OneToOneField(
|
||||
RegisteredUser,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='generator_preference'
|
||||
)
|
||||
available_equipment = models.ManyToManyField(Equipment, blank=True, related_name='user_preferences')
|
||||
target_muscle_groups = models.ManyToManyField(Muscle, blank=True, related_name='user_preferences')
|
||||
preferred_workout_types = models.ManyToManyField(WorkoutType, blank=True, related_name='user_preferences')
|
||||
fitness_level = models.IntegerField(choices=FITNESS_LEVEL_CHOICES, default=2)
|
||||
primary_goal = models.CharField(max_length=20, choices=GOAL_CHOICES, default='general_fitness')
|
||||
secondary_goal = models.CharField(max_length=20, choices=GOAL_CHOICES, blank=True, default='')
|
||||
days_per_week = models.IntegerField(default=4)
|
||||
preferred_workout_duration = models.IntegerField(
|
||||
default=45, help_text='Minutes',
|
||||
validators=[MinValueValidator(15), MaxValueValidator(120)],
|
||||
)
|
||||
preferred_days = models.JSONField(default=list, blank=True, help_text='List of weekday ints (0=Mon, 6=Sun)')
|
||||
injuries_limitations = models.TextField(blank=True, default='')
|
||||
injury_types = models.JSONField(
|
||||
default=list, blank=True,
|
||||
help_text='Structured injury types: knee, lower_back, upper_back, shoulder, hip, wrist, ankle, neck',
|
||||
)
|
||||
excluded_exercises = models.ManyToManyField(Exercise, blank=True, related_name='excluded_by_users')
|
||||
|
||||
def __str__(self):
|
||||
return f"Preferences for {self.registered_user.first_name}"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Generated Plan / Workout Models
|
||||
# ============================================================
|
||||
|
||||
class GeneratedWeeklyPlan(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
registered_user = models.ForeignKey(
|
||||
RegisteredUser,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='generated_plans'
|
||||
)
|
||||
week_start_date = models.DateField()
|
||||
week_end_date = models.DateField()
|
||||
status = models.CharField(max_length=10, choices=PLAN_STATUS_CHOICES, default='pending')
|
||||
preferences_snapshot = models.JSONField(default=dict, blank=True)
|
||||
generation_time_ms = models.IntegerField(null=True, blank=True)
|
||||
|
||||
# Periodization fields
|
||||
week_number = models.IntegerField(default=1, help_text='Position in training cycle (1-based)')
|
||||
is_deload = models.BooleanField(default=False, help_text='Whether this is a recovery/deload week')
|
||||
cycle_id = models.CharField(max_length=64, null=True, blank=True, help_text='Groups weeks into training cycles')
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"Plan {self.id} for {self.registered_user.first_name} ({self.week_start_date})"
|
||||
|
||||
|
||||
class GeneratedWorkout(models.Model):
|
||||
plan = models.ForeignKey(
|
||||
GeneratedWeeklyPlan,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='generated_workouts'
|
||||
)
|
||||
workout = models.OneToOneField(
|
||||
Workout,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='generated_from'
|
||||
)
|
||||
workout_type = models.ForeignKey(
|
||||
WorkoutType,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='generated_workouts'
|
||||
)
|
||||
scheduled_date = models.DateField()
|
||||
day_of_week = models.IntegerField(help_text='0=Monday, 6=Sunday')
|
||||
is_rest_day = models.BooleanField(default=False)
|
||||
status = models.CharField(max_length=10, choices=WORKOUT_STATUS_CHOICES, default='pending')
|
||||
focus_area = models.CharField(max_length=255, blank=True, default='')
|
||||
target_muscles = models.JSONField(default=list, blank=True)
|
||||
user_rating = models.IntegerField(null=True, blank=True)
|
||||
user_feedback = models.TextField(blank=True, default='')
|
||||
|
||||
class Meta:
|
||||
ordering = ['scheduled_date']
|
||||
|
||||
def __str__(self):
|
||||
if self.is_rest_day:
|
||||
return f"Rest Day - {self.scheduled_date}"
|
||||
return f"{self.focus_area} - {self.scheduled_date}"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ML Pattern Models (populated by analyze_workouts command)
|
||||
# ============================================================
|
||||
|
||||
class MuscleGroupSplit(models.Model):
|
||||
muscle_names = models.JSONField(default=list, help_text='List of muscle group names')
|
||||
frequency = models.IntegerField(default=0, help_text='How often this combo appeared')
|
||||
label = models.CharField(max_length=100, blank=True, default='')
|
||||
typical_exercise_count = models.IntegerField(default=6)
|
||||
split_type = models.CharField(max_length=20, choices=SPLIT_TYPE_CHOICES, default='full_body')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.label} ({self.split_type}) - freq: {self.frequency}"
|
||||
|
||||
|
||||
class WeeklySplitPattern(models.Model):
|
||||
days_per_week = models.IntegerField()
|
||||
pattern = models.JSONField(default=list, help_text='Ordered list of MuscleGroupSplit IDs')
|
||||
pattern_labels = models.JSONField(default=list, help_text='Ordered list of split labels')
|
||||
frequency = models.IntegerField(default=0)
|
||||
rest_day_positions = models.JSONField(default=list, help_text='Day indices that are rest days')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.days_per_week}-day split (freq: {self.frequency}): {self.pattern_labels}"
|
||||
|
||||
|
||||
class WorkoutStructureRule(models.Model):
|
||||
workout_type = models.ForeignKey(
|
||||
WorkoutType,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='structure_rules'
|
||||
)
|
||||
section_type = models.CharField(max_length=10, choices=SECTION_TYPE_CHOICES)
|
||||
movement_patterns = models.JSONField(default=list)
|
||||
typical_rounds = models.IntegerField(default=3)
|
||||
typical_exercises_per_superset = models.IntegerField(default=3)
|
||||
typical_rep_range_min = models.IntegerField(default=8)
|
||||
typical_rep_range_max = models.IntegerField(default=12)
|
||||
typical_duration_range_min = models.IntegerField(default=30, help_text='Seconds')
|
||||
typical_duration_range_max = models.IntegerField(default=45, help_text='Seconds')
|
||||
goal_type = models.CharField(max_length=20, choices=GOAL_CHOICES, default='general_fitness')
|
||||
|
||||
def __str__(self):
|
||||
wt = self.workout_type.name if self.workout_type else 'Any'
|
||||
return f"{wt} - {self.section_type} ({self.goal_type})"
|
||||
|
||||
|
||||
class MovementPatternOrder(models.Model):
|
||||
position = models.CharField(max_length=10, choices=POSITION_CHOICES)
|
||||
movement_pattern = models.CharField(max_length=100)
|
||||
frequency = models.IntegerField(default=0)
|
||||
section_type = models.CharField(max_length=10, choices=SECTION_TYPE_CHOICES, default='working')
|
||||
|
||||
class Meta:
|
||||
ordering = ['position', '-frequency']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.movement_pattern} @ {self.position} (freq: {self.frequency})"
|
||||
Reference in New Issue
Block a user