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:
130
generator/management/commands/normalize_muscles.py
Normal file
130
generator/management/commands/normalize_muscles.py
Normal 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.'
|
||||
))
|
||||
Reference in New Issue
Block a user