Files
WerkoutAPI/generator/management/commands/fix_exercise_flags.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

223 lines
7.7 KiB
Python

"""
Fix exercise flags and assign missing muscle associations.
1. Fix is_weight flags on exercises that are bodyweight but incorrectly marked
is_weight=True (wall sits, agility ladder, planks, bodyweight exercises, etc.)
2. Assign muscle groups to exercises that have no ExerciseMuscle rows, using
name keyword matching.
Known false positives: wall sits, agility ladder, planks, body weight exercises,
and similar movements that use no external resistance.
Usage:
python manage.py fix_exercise_flags
python manage.py fix_exercise_flags --dry-run
"""
import re
from django.core.management.base import BaseCommand
from exercise.models import Exercise
from muscle.models import Muscle, ExerciseMuscle
try:
from equipment.models import WorkoutEquipment
except ImportError:
WorkoutEquipment = None
# Patterns that indicate bodyweight exercises (no external weight).
# Uses word boundary matching to avoid substring issues (e.g. "l sit" in "wall sit").
BODYWEIGHT_PATTERNS = [
r'\bwall sit\b',
r'\bplank\b',
r'\bmountain climber\b',
r'\bburpee\b',
r'\bpush ?up\b',
r'\bpushup\b',
r'\bpull ?up\b',
r'\bpullup\b',
r'\bchin ?up\b',
r'\bchinup\b',
r'\bdips?\b',
r'\bpike\b',
r'\bhandstand\b',
r'\bl sit\b',
r'\bv sit\b',
r'\bhollow\b',
r'\bsuperman\b',
r'\bbird dog\b',
r'\bdead bug\b',
r'\bbear crawl\b',
r'\bcrab walk\b',
r'\binchworm\b',
r'\bjumping jack\b',
r'\bhigh knee\b',
r'\bbutt kick\b',
r'\bskater\b',
r'\blunge jump\b',
r'\bjump lunge\b',
r'\bsquat jump\b',
r'\bjump squat\b',
r'\bbox jump\b',
r'\btuck jump\b',
r'\bbroad jump\b',
r'\bsprinter\b',
r'\bagility ladder\b',
r'\bbody ?weight\b',
r'\bbodyweight\b',
r'\bcalisthenics?\b',
r'\bflutter kick\b',
r'\bleg raise\b',
r'\bsit ?up\b',
r'\bcrunch\b',
r'\bstretch\b',
r'\byoga\b',
r'\bfoam roll\b',
r'\bjump rope\b',
r'\bspider crawl\b',
]
# Keywords for assigning muscles to exercises with no ExerciseMuscle rows.
# Each muscle name maps to a list of name keywords to match against exercise name.
EXERCISE_MUSCLE_KEYWORDS = {
'chest': ['chest', 'pec', 'bench press', 'push up', 'fly'],
'back': ['back', 'lat', 'row', 'pull up', 'pulldown'],
'shoulders': ['shoulder', 'delt', 'press', 'raise', 'shrug'],
'quads': ['quad', 'squat', 'leg press', 'lunge', 'extension'],
'hamstrings': ['hamstring', 'curl', 'deadlift', 'rdl'],
'glutes': ['glute', 'hip thrust', 'bridge'],
'biceps': ['bicep', 'curl'],
'triceps': ['tricep', 'pushdown', 'extension', 'dip'],
'core': ['core', 'ab', 'crunch', 'plank', 'sit up'],
'calves': ['calf', 'calves', 'calf raise'],
}
class Command(BaseCommand):
help = 'Fix is_weight flags and assign missing muscle associations'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would change without writing to DB',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
self.stdout.write(self.style.MIGRATE_HEADING('Step 1: Fix is_weight false positives'))
weight_fixed = self._fix_is_weight_false_positives(dry_run)
self.stdout.write(self.style.MIGRATE_HEADING('\nStep 2: Assign missing muscles'))
muscle_assigned = self._assign_missing_muscles(dry_run)
prefix = '[DRY RUN] ' if dry_run else ''
self.stdout.write(self.style.SUCCESS(
f'\n{prefix}Summary: Fixed {weight_fixed} is_weight flags, '
f'assigned muscles to {muscle_assigned} exercises'
))
def _fix_is_weight_false_positives(self, dry_run):
"""Fix exercises that are bodyweight but incorrectly marked is_weight=True."""
# Get exercises that have is_weight=True
weighted_exercises = Exercise.objects.filter(is_weight=True)
# Get exercises that have equipment assigned (if WorkoutEquipment exists)
exercises_with_equipment = set()
if WorkoutEquipment is not None:
exercises_with_equipment = set(
WorkoutEquipment.objects.values_list('exercise_id', flat=True).distinct()
)
fixed = 0
for ex in weighted_exercises:
if not ex.name:
continue
name_lower = ex.name.lower()
# Check if name matches any bodyweight pattern
is_bodyweight_name = any(
re.search(pat, name_lower) for pat in BODYWEIGHT_PATTERNS
)
# Also check if the exercise has no equipment assigned
has_no_equipment = ex.pk not in exercises_with_equipment
if is_bodyweight_name and has_no_equipment:
if dry_run:
self.stdout.write(f' Would fix: {ex.name} (id={ex.pk})')
else:
ex.is_weight = False
ex.save(update_fields=['is_weight'])
self.stdout.write(f' Fixed: {ex.name} (id={ex.pk})')
fixed += 1
prefix = '[DRY RUN] ' if dry_run else ''
self.stdout.write(self.style.SUCCESS(
f'{prefix}Fixed {fixed} exercises from is_weight=True to is_weight=False'
))
return fixed
def _assign_missing_muscles(self, dry_run):
"""Assign muscle groups to exercises that have no ExerciseMuscle rows."""
# Find exercises with no muscle associations
exercises_with_muscles = set(
ExerciseMuscle.objects.values_list('exercise_id', flat=True).distinct()
)
orphan_exercises = Exercise.objects.exclude(pk__in=exercises_with_muscles)
if not orphan_exercises.exists():
self.stdout.write(' No exercises without muscle assignments found.')
return 0
self.stdout.write(f' Found {orphan_exercises.count()} exercises without muscle assignments')
# Build a cache of muscle objects by name (case-insensitive)
muscle_cache = {}
for muscle in Muscle.objects.all():
muscle_cache[muscle.name.lower()] = muscle
assigned_count = 0
for ex in orphan_exercises:
if not ex.name:
continue
name_lower = ex.name.lower()
matched_muscles = []
for muscle_name, keywords in EXERCISE_MUSCLE_KEYWORDS.items():
for keyword in keywords:
if keyword in name_lower:
# Find the muscle in the cache
muscle_obj = muscle_cache.get(muscle_name)
if muscle_obj and muscle_obj not in matched_muscles:
matched_muscles.append(muscle_obj)
break # One keyword match per muscle group is enough
if matched_muscles:
if dry_run:
muscle_names = ', '.join(m.name for m in matched_muscles)
self.stdout.write(
f' Would assign: {ex.name} (id={ex.pk}) -> [{muscle_names}]'
)
else:
for muscle_obj in matched_muscles:
ExerciseMuscle.objects.get_or_create(
exercise=ex,
muscle=muscle_obj,
)
muscle_names = ', '.join(m.name for m in matched_muscles)
self.stdout.write(
f' Assigned: {ex.name} (id={ex.pk}) -> [{muscle_names}]'
)
assigned_count += 1
prefix = '[DRY RUN] ' if dry_run else ''
self.stdout.write(self.style.SUCCESS(
f'{prefix}Assigned muscles to {assigned_count} exercises'
))
return assigned_count