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:
Trey t
2026-02-22 20:07:40 -06:00
parent 2a16b75c4b
commit 1c61b80731
111 changed files with 28108 additions and 30 deletions

View File

@@ -0,0 +1,130 @@
"""
Management command to normalize muscle names in the database.
Fixes casing duplicates (e.g. "Quads" vs "quads") and updates
ExerciseMuscle records to point to the canonical muscle entries.
Usage:
python manage.py normalize_muscles # apply changes
python manage.py normalize_muscles --dry-run # preview only
"""
from django.core.management.base import BaseCommand
from muscle.models import Muscle, ExerciseMuscle
from generator.services.muscle_normalizer import normalize_muscle_name
class Command(BaseCommand):
help = 'Normalize muscle names (fix casing duplicates) and consolidate ExerciseMuscle records.'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Preview changes without modifying the database.',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
if dry_run:
self.stdout.write(self.style.WARNING('DRY RUN - no changes will be made.\n'))
muscles = Muscle.objects.all().order_by('name')
self.stdout.write(f'Total muscles in DB: {muscles.count()}\n')
# Build a mapping: canonical_name -> list of Muscle objects with that canonical name
canonical_map = {}
for m in muscles:
canonical = normalize_muscle_name(m.name)
if canonical is None:
canonical = m.name.strip().lower()
canonical_map.setdefault(canonical, []).append(m)
# Identify duplicates (canonical names with > 1 Muscle record)
duplicates = {k: v for k, v in canonical_map.items() if len(v) > 1}
if not duplicates:
self.stdout.write(self.style.SUCCESS('No duplicate muscles found. Nothing to normalize.'))
return
self.stdout.write(f'Found {len(duplicates)} canonical names with duplicates:\n')
merged_count = 0
reassigned_count = 0
for canonical, muscle_list in sorted(duplicates.items()):
names = [m.name for m in muscle_list]
self.stdout.write(f'\n "{canonical}" <- {names}')
# Keep the first one (or the one whose name already matches canonical)
keep = None
for m in muscle_list:
if m.name == canonical:
keep = m
break
if keep is None:
keep = muscle_list[0]
to_merge = [m for m in muscle_list if m.pk != keep.pk]
for old_muscle in to_merge:
# Reassign ExerciseMuscle records from old_muscle to keep
em_records = ExerciseMuscle.objects.filter(muscle=old_muscle)
count = em_records.count()
if count > 0:
self.stdout.write(f' Reassigning {count} ExerciseMuscle records: '
f'"{old_muscle.name}" (id={old_muscle.pk}) -> '
f'"{keep.name}" (id={keep.pk})')
if not dry_run:
# Check for conflicts (same exercise already linked to keep)
for em in em_records:
existing = ExerciseMuscle.objects.filter(
exercise=em.exercise, muscle=keep
).exists()
if existing:
em.delete()
else:
em.muscle = keep
em.save()
reassigned_count += count
# Rename keep to canonical if needed
if keep.name != canonical and not dry_run:
keep.name = canonical
keep.save()
# Delete the duplicate
self.stdout.write(f' Deleting duplicate: "{old_muscle.name}" (id={old_muscle.pk})')
if not dry_run:
old_muscle.delete()
merged_count += 1
# Also fix names that aren't duplicates but have wrong casing
rename_count = 0
for canonical, muscle_list in canonical_map.items():
if len(muscle_list) == 1:
m = muscle_list[0]
if m.name != canonical:
self.stdout.write(f'\n Renaming: "{m.name}" -> "{canonical}"')
if not dry_run:
m.name = canonical
m.save()
rename_count += 1
self.stdout.write('\n')
if dry_run:
self.stdout.write(self.style.WARNING(
f'DRY RUN complete. Would merge {merged_count} duplicates, '
f'reassign {reassigned_count} ExerciseMuscle records, '
f'rename {rename_count} muscles.'
))
else:
remaining = Muscle.objects.count()
self.stdout.write(self.style.SUCCESS(
f'Done. Merged {merged_count} duplicates, '
f'reassigned {reassigned_count} ExerciseMuscle records, '
f'renamed {rename_count} muscles. '
f'{remaining} muscles remaining.'
))