Files
WerkoutAPI/generator/models.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

250 lines
9.2 KiB
Python

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})"