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:
116
generator/management/commands/normalize_muscle_names.py
Normal file
116
generator/management/commands/normalize_muscle_names.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Normalize muscle names in the database and merge duplicates.
|
||||
|
||||
Uses the MUSCLE_NORMALIZATION_MAP from muscle_normalizer.py to:
|
||||
1. Rename each Muscle record to its canonical lowercase form
|
||||
2. Merge duplicates by updating ExerciseMuscle FKs to point to the canonical Muscle
|
||||
3. Delete orphaned duplicate Muscle records
|
||||
|
||||
Usage:
|
||||
python manage.py normalize_muscle_names --dry-run
|
||||
python manage.py normalize_muscle_names
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from muscle.models import Muscle, ExerciseMuscle
|
||||
from generator.services.muscle_normalizer import normalize_muscle_name
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Normalize muscle names and merge duplicates using MUSCLE_NORMALIZATION_MAP'
|
||||
|
||||
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']
|
||||
|
||||
all_muscles = Muscle.objects.all().order_by('id')
|
||||
self.stdout.write(f'Found {all_muscles.count()} muscle records')
|
||||
|
||||
# Group muscles by their canonical name
|
||||
canonical_groups = defaultdict(list)
|
||||
for muscle in all_muscles:
|
||||
canonical = normalize_muscle_name(muscle.name)
|
||||
if canonical:
|
||||
canonical_groups[canonical].append(muscle)
|
||||
|
||||
renamed = 0
|
||||
merged = 0
|
||||
deleted = 0
|
||||
|
||||
with transaction.atomic():
|
||||
for canonical_name, muscles in canonical_groups.items():
|
||||
# Pick the keeper: prefer the one with the lowest ID (oldest)
|
||||
keeper = muscles[0]
|
||||
|
||||
# Rename keeper if needed
|
||||
if keeper.name != canonical_name:
|
||||
if dry_run:
|
||||
self.stdout.write(f' Rename: "{keeper.name}" -> "{canonical_name}" (id={keeper.pk})')
|
||||
else:
|
||||
keeper.name = canonical_name
|
||||
keeper.save(update_fields=['name'])
|
||||
renamed += 1
|
||||
|
||||
# Merge duplicates into keeper
|
||||
for dup in muscles[1:]:
|
||||
# Count affected ExerciseMuscle rows
|
||||
em_count = ExerciseMuscle.objects.filter(muscle=dup).count()
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
f' Merge: "{dup.name}" (id={dup.pk}) -> "{canonical_name}" '
|
||||
f'(id={keeper.pk}), {em_count} ExerciseMuscle rows'
|
||||
)
|
||||
else:
|
||||
# Update ExerciseMuscle FKs, handling unique_together conflicts
|
||||
for em in ExerciseMuscle.objects.filter(muscle=dup):
|
||||
# Check if keeper already has this exercise
|
||||
existing = ExerciseMuscle.objects.filter(
|
||||
exercise=em.exercise, muscle=keeper
|
||||
).exists()
|
||||
if existing:
|
||||
em.delete()
|
||||
else:
|
||||
em.muscle = keeper
|
||||
em.save(update_fields=['muscle'])
|
||||
|
||||
dup.delete()
|
||||
|
||||
merged += em_count
|
||||
deleted += 1
|
||||
|
||||
if dry_run:
|
||||
# Roll back the transaction for dry run
|
||||
transaction.set_rollback(True)
|
||||
|
||||
prefix = '[DRY RUN] ' if dry_run else ''
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'\n{prefix}Results:'
|
||||
f'\n Renamed: {renamed} muscles'
|
||||
f'\n Merged: {merged} ExerciseMuscle references'
|
||||
f'\n Deleted: {deleted} duplicate Muscle records'
|
||||
))
|
||||
|
||||
# Verify
|
||||
if not dry_run:
|
||||
dupes = (
|
||||
Muscle.objects.values('name')
|
||||
.annotate(c=__import__('django.db.models', fromlist=['Count']).Count('id'))
|
||||
.filter(c__gt=1)
|
||||
)
|
||||
if dupes.exists():
|
||||
self.stdout.write(self.style.WARNING(
|
||||
f' WARNING: {dupes.count()} duplicate names still exist!'
|
||||
))
|
||||
else:
|
||||
self.stdout.write(self.style.SUCCESS(' No duplicate muscle names remain.'))
|
||||
Reference in New Issue
Block a user