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

203 lines
6.6 KiB
Python

"""
Comprehensive audit of exercise data quality.
Checks for:
1. Null estimated_rep_duration on rep-based exercises
2. is_weight false positives (bodyweight exercises marked as weighted)
3. Exercises with no muscle assignments
4. "horizonal" typo in movement_patterns
5. Null metadata fields summary (difficulty_level, exercise_tier, etc.)
Exits with code 1 if any CRITICAL issues are found.
Usage:
python manage.py audit_exercise_data
"""
import re
import sys
from django.core.management.base import BaseCommand
from exercise.models import Exercise
from muscle.models import ExerciseMuscle
# Same bodyweight patterns as fix_exercise_flags for consistency
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',
]
class Command(BaseCommand):
help = 'Audit exercise data quality -- exits 1 if critical issues found'
def handle(self, *args, **options):
issues = []
# 1. Null estimated_rep_duration (excluding duration-only exercises)
null_duration = Exercise.objects.filter(
estimated_rep_duration__isnull=True,
is_reps=True,
).exclude(
is_duration=True, is_reps=False
).count()
if null_duration > 0:
issues.append(
f"CRITICAL: {null_duration} rep-based exercises have null estimated_rep_duration"
)
else:
self.stdout.write(self.style.SUCCESS(
'OK: All rep-based exercises have estimated_rep_duration'
))
# 2. is_weight false positives -- bodyweight exercises marked as weighted
weight_false_positives = 0
weighted_exercises = Exercise.objects.filter(is_weight=True)
for ex in weighted_exercises:
if not ex.name:
continue
name_lower = ex.name.lower()
if any(re.search(pat, name_lower) for pat in BODYWEIGHT_PATTERNS):
weight_false_positives += 1
if weight_false_positives > 0:
issues.append(
f"WARNING: {weight_false_positives} bodyweight exercises still have is_weight=True"
)
else:
self.stdout.write(self.style.SUCCESS(
'OK: No bodyweight exercises incorrectly marked as weighted'
))
# 3. Exercises with no muscles
exercises_with_muscles = set(
ExerciseMuscle.objects.values_list('exercise_id', flat=True).distinct()
)
exercises_no_muscles = Exercise.objects.exclude(
pk__in=exercises_with_muscles
).count()
if exercises_no_muscles > 0:
issues.append(
f"CRITICAL: {exercises_no_muscles} exercises have no muscle assignments"
)
else:
self.stdout.write(self.style.SUCCESS(
'OK: All exercises have muscle assignments'
))
# 4. "horizonal" typo
typo_count = Exercise.objects.filter(
movement_patterns__icontains='horizonal'
).count()
if typo_count > 0:
issues.append(
f'WARNING: {typo_count} exercises have "horizonal" typo in movement_patterns'
)
else:
self.stdout.write(self.style.SUCCESS(
'OK: No "horizonal" typos in movement_patterns'
))
# 5. Null metadata fields summary
total = Exercise.objects.count()
if total > 0:
# Base field always present
metadata_fields = {
'movement_patterns': Exercise.objects.filter(
movement_patterns__isnull=True
).count() + Exercise.objects.filter(movement_patterns='').count(),
}
# Optional fields that may not exist in all environments
optional_fields = ['difficulty_level', 'exercise_tier']
for field_name in optional_fields:
if hasattr(Exercise, field_name):
try:
null_count = Exercise.objects.filter(
**{f'{field_name}__isnull': True}
).count() + Exercise.objects.filter(
**{field_name: ''}
).count()
metadata_fields[field_name] = null_count
except Exception:
pass # Field doesn't exist in DB schema yet
self.stdout.write(f'\nMetadata coverage ({total} total exercises):')
for field, null_count in metadata_fields.items():
filled = total - null_count
pct = (filled / total) * 100
self.stdout.write(f' {field}: {filled}/{total} ({pct:.1f}%)')
if null_count > total * 0.5: # More than 50% missing
issues.append(
f"WARNING: {field} is missing on {null_count}/{total} exercises ({100-pct:.1f}%)"
)
# Report
self.stdout.write('') # blank line
if not issues:
self.stdout.write(self.style.SUCCESS('All exercise data checks passed!'))
else:
for issue in issues:
if issue.startswith('CRITICAL'):
self.stdout.write(self.style.ERROR(issue))
else:
self.stdout.write(self.style.WARNING(issue))
critical = [i for i in issues if i.startswith('CRITICAL')]
if critical:
self.stdout.write(self.style.ERROR(
f'\n{len(critical)} critical issue(s) found. Run fix commands to resolve.'
))
sys.exit(1)
else:
self.stdout.write(self.style.WARNING(
f'\n{len(issues)} non-critical warning(s) found.'
))