- 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>
799 lines
26 KiB
Python
799 lines
26 KiB
Python
"""
|
|
Classifies all Exercise records by difficulty_level and complexity_rating
|
|
using name-based keyword matching and movement_patterns fallback rules.
|
|
|
|
difficulty_level: 'beginner', 'intermediate', 'advanced'
|
|
complexity_rating: 1-5 integer scale
|
|
|
|
Classification strategy (applied in order, first match wins):
|
|
|
|
1. **Name-based keyword rules** -- regex patterns matched against exercise.name
|
|
- ADVANCED_NAME_PATTERNS -> 'advanced'
|
|
- BEGINNER_NAME_PATTERNS -> 'beginner'
|
|
- Unmatched -> 'intermediate' (default)
|
|
|
|
2. **Name-based complexity rules** -- regex patterns matched against exercise.name
|
|
- COMPLEXITY_5_PATTERNS -> 5 (Olympic lifts, advanced gymnastics)
|
|
- COMPLEXITY_4_PATTERNS -> 4 (complex multi-joint, unilateral loaded)
|
|
- COMPLEXITY_1_PATTERNS -> 1 (single-joint isolation, simple stretches)
|
|
- COMPLEXITY_2_PATTERNS -> 2 (basic compound or standard bodyweight)
|
|
- Unmatched -> movement_patterns fallback -> default 3
|
|
|
|
3. **Movement-pattern fallback** for exercises not caught by name rules,
|
|
using the exercise's movement_patterns CharField.
|
|
|
|
Usage:
|
|
python manage.py classify_exercises
|
|
python manage.py classify_exercises --dry-run
|
|
python manage.py classify_exercises --dry-run --verbose
|
|
"""
|
|
|
|
import re
|
|
from django.core.management.base import BaseCommand
|
|
from exercise.models import Exercise
|
|
|
|
|
|
# ============================================================================
|
|
# DIFFICULTY LEVEL RULES (name-based)
|
|
# ============================================================================
|
|
# Each entry: (compiled_regex, difficulty_level)
|
|
# Matched against exercise.name.lower(). First match wins.
|
|
# Patterns use word boundaries (\b) where appropriate to avoid false positives.
|
|
|
|
ADVANCED_NAME_PATTERNS = [
|
|
# --- Olympic lifts & derivatives ---
|
|
r'\bsnatch\b',
|
|
r'\bclean and jerk\b',
|
|
r'\bclean & jerk\b',
|
|
r'\bpower clean\b',
|
|
r'\bhang clean\b',
|
|
r'\bsquat clean\b',
|
|
r'\bclean pull\b',
|
|
r'\bcluster\b.*\bclean\b',
|
|
r'\bclean\b.*\bto\b.*\bpress\b', # clean to press / clean to push press
|
|
r'\bclean\b.*\bto\b.*\bjerk\b',
|
|
r'\bpush jerk\b',
|
|
r'\bsplit jerk\b',
|
|
r'\bjerk\b(?!.*chicken)', # jerk but not "chicken jerk" type food
|
|
r'\bthruster\b',
|
|
r'\bwall ball\b', # high coordination + explosive
|
|
|
|
# --- Advanced gymnastics / calisthenics ---
|
|
r'\bpistol\b.*\bsquat\b',
|
|
r'\bpistol squat\b',
|
|
r'\bmuscle.?up\b',
|
|
r'\bhandstand\b',
|
|
r'\bhand\s*stand\b',
|
|
r'\bdragon flag\b',
|
|
r'\bplanche\b',
|
|
r'\bl.?sit\b',
|
|
r'\bhuman flag\b',
|
|
r'\bfront lever\b',
|
|
r'\bback lever\b',
|
|
r'\biron cross\b',
|
|
r'\bmaltese\b',
|
|
r'\bstrict press.*handstand\b',
|
|
r'\bskin the cat\b',
|
|
r'\bwindshield wiper\b(?!.*stretch)', # weighted windshield wipers, not stretch
|
|
|
|
# --- Advanced barbell lifts ---
|
|
r'\bturkish get.?up\b',
|
|
r'\bturkish getup\b',
|
|
r'\btgu\b',
|
|
r'\bzercher\b', # zercher squat/carry
|
|
r'\bdeficit deadlift\b',
|
|
r'\bsnatch.?grip deadlift\b',
|
|
r'\bsumo deadlift\b', # wider stance = more mobility demand
|
|
r'\bhack squat\b.*\bbarbell\b', # barbell hack squat (not machine)
|
|
r'\boverhead squat\b',
|
|
r'\bsingle.?leg deadlift\b.*\bbarbell\b',
|
|
r'\bbarbell\b.*\bsingle.?leg deadlift\b',
|
|
r'\bscorpion\b', # scorpion press
|
|
|
|
# --- Plyometric / explosive ---
|
|
r'\bbox jump\b',
|
|
r'\bdepth jump\b',
|
|
r'\btuck jump\b',
|
|
r'\bbroad jump\b',
|
|
r'\bclap push.?up\b',
|
|
r'\bclapping push.?up\b',
|
|
r'\bplyometric push.?up\b',
|
|
r'\bplyo push.?up\b',
|
|
r'\bexplosive\b',
|
|
r'\bkipping\b',
|
|
|
|
# --- Advanced core ---
|
|
r'\bab.?wheel\b',
|
|
r'\bab roller\b',
|
|
r'\btoes.?to.?bar\b',
|
|
r'\bknees.?to.?elbow\b',
|
|
r'\bhanging.?leg.?raise\b',
|
|
r'\bhanging.?knee.?raise\b',
|
|
]
|
|
|
|
BEGINNER_NAME_PATTERNS = [
|
|
# --- Simple machine isolation ---
|
|
r'\bleg press\b',
|
|
r'\bleg extension\b',
|
|
r'\bleg curl\b',
|
|
r'\bhamstring curl\b.*\bmachine\b',
|
|
r'\bmachine\b.*\bhamstring curl\b',
|
|
r'\bcalf raise\b.*\bmachine\b',
|
|
r'\bmachine\b.*\bcalf raise\b',
|
|
r'\bseated calf raise\b',
|
|
r'\bchest fly\b.*\bmachine\b',
|
|
r'\bmachine\b.*\bchest fly\b',
|
|
r'\bpec.?deck\b',
|
|
r'\bpec fly\b.*\bmachine\b',
|
|
r'\bcable\b.*\bcurl\b',
|
|
r'\bcable\b.*\btricep\b',
|
|
r'\bcable\b.*\bpushdown\b',
|
|
r'\btricep.?pushdown\b',
|
|
r'\blat pulldown\b',
|
|
r'\bseated row\b.*\bmachine\b',
|
|
r'\bmachine\b.*\brow\b',
|
|
r'\bsmith machine\b',
|
|
|
|
# --- Basic bodyweight ---
|
|
r'\bwall sit\b',
|
|
r'\bwall push.?up\b',
|
|
r'\bincline push.?up\b',
|
|
r'\bdead hang\b',
|
|
r'\bplank\b(?!.*\bjack\b)(?!.*\bup\b.*\bdown\b)', # plank but not plank jacks or up-down planks
|
|
r'\bside plank\b',
|
|
r'\bglute bridge\b',
|
|
r'\bhip bridge\b',
|
|
r'\bbird.?dog\b',
|
|
r'\bsuperman\b(?!.*\bpush.?up\b)',
|
|
r'\bcrunches?\b',
|
|
r'\bsit.?up\b',
|
|
r'\bbicycle\b.*\bcrunch\b',
|
|
r'\bflutter kick\b',
|
|
r'\bleg raise\b(?!.*\bhanging\b)', # lying leg raise (not hanging)
|
|
r'\blying\b.*\bleg raise\b',
|
|
r'\bcalf raise\b(?!.*\bbarbell\b)(?!.*\bsingle\b)', # basic standing calf raise
|
|
r'\bstanding calf raise\b',
|
|
|
|
# --- Stretches and foam rolling ---
|
|
r'\bstretch\b',
|
|
r'\bstretching\b',
|
|
r'\bfoam roll\b',
|
|
r'\bfoam roller\b',
|
|
r'\blacrosse ball\b',
|
|
r'\bmyofascial\b',
|
|
r'\bself.?massage\b',
|
|
|
|
# --- Breathing ---
|
|
r'\bbreathing\b',
|
|
r'\bbreathe\b',
|
|
r'\bdiaphragmatic\b',
|
|
r'\bbox breathing\b',
|
|
r'\bbreath\b',
|
|
|
|
# --- Basic mobility ---
|
|
r'\bneck\b.*\bcircle\b',
|
|
r'\barm\b.*\bcircle\b',
|
|
r'\bshoulder\b.*\bcircle\b',
|
|
r'\bankle\b.*\bcircle\b',
|
|
r'\bhip\b.*\bcircle\b',
|
|
r'\bwrist\b.*\bcircle\b',
|
|
r'\bcat.?cow\b',
|
|
r'\bchild.?s?\s*pose\b',
|
|
|
|
# --- Simple cardio ---
|
|
r'\bwalking\b(?!.*\blunge\b)', # walking but not walking lunges
|
|
r'\bwalk\b(?!.*\bout\b)(?!.*\blunge\b)', # walk but not walkouts or walk lunges
|
|
r'\bjogging\b',
|
|
r'\bjog\b',
|
|
r'\bstepping\b',
|
|
r'\bstep.?up\b(?!.*\bweighted\b)(?!.*\bbarbell\b)(?!.*\bdumbbell\b)',
|
|
r'\bjumping jack\b',
|
|
r'\bhigh knee\b',
|
|
r'\bbutt kick\b',
|
|
r'\bbutt kicker\b',
|
|
r'\bmountain climber\b',
|
|
|
|
# --- Simple yoga poses ---
|
|
r'\bdownward.?dog\b',
|
|
r'\bupward.?dog\b',
|
|
r'\bwarrior\b.*\bpose\b',
|
|
r'\btree\b.*\bpose\b',
|
|
r'\bcorpse\b.*\bpose\b',
|
|
r'\bsavasana\b',
|
|
r'\bchild.?s?\s*pose\b',
|
|
]
|
|
|
|
# Compile for performance
|
|
_ADVANCED_NAME_RE = [(re.compile(p, re.IGNORECASE), 'advanced') for p in ADVANCED_NAME_PATTERNS]
|
|
_BEGINNER_NAME_RE = [(re.compile(p, re.IGNORECASE), 'beginner') for p in BEGINNER_NAME_PATTERNS]
|
|
|
|
|
|
# ============================================================================
|
|
# COMPLEXITY RATING RULES (name-based, 1-5 scale)
|
|
# ============================================================================
|
|
# 1 = Single-joint, simple movement (curls, calf raises, stretches)
|
|
# 2 = Basic compound or standard bodyweight
|
|
# 3 = Standard compound with moderate coordination (bench press, squat, row)
|
|
# 4 = Complex multi-joint, unilateral loaded, high coordination demand
|
|
# 5 = Highly technical (Olympic lifts, advanced gymnastics)
|
|
|
|
COMPLEXITY_5_PATTERNS = [
|
|
# --- Olympic lifts ---
|
|
r'\bsnatch\b',
|
|
r'\bclean and jerk\b',
|
|
r'\bclean & jerk\b',
|
|
r'\bpower clean\b',
|
|
r'\bhang clean\b',
|
|
r'\bsquat clean\b',
|
|
r'\bclean pull\b',
|
|
r'\bclean\b.*\bto\b.*\bpress\b',
|
|
r'\bclean\b.*\bto\b.*\bjerk\b',
|
|
r'\bpush jerk\b',
|
|
r'\bsplit jerk\b',
|
|
r'\bjerk\b(?!.*chicken)',
|
|
|
|
# --- Advanced gymnastics ---
|
|
r'\bmuscle.?up\b',
|
|
r'\bhandstand\b.*\bpush.?up\b',
|
|
r'\bplanche\b',
|
|
r'\bhuman flag\b',
|
|
r'\bfront lever\b',
|
|
r'\bback lever\b',
|
|
r'\biron cross\b',
|
|
r'\bmaltese\b',
|
|
r'\bskin the cat\b',
|
|
|
|
# --- Complex loaded movements ---
|
|
r'\bturkish get.?up\b',
|
|
r'\bturkish getup\b',
|
|
r'\btgu\b',
|
|
r'\boverhead squat\b',
|
|
]
|
|
|
|
COMPLEXITY_4_PATTERNS = [
|
|
# --- Complex compound ---
|
|
r'\bthruster\b',
|
|
r'\bwall ball\b',
|
|
r'\bzercher\b',
|
|
r'\bdeficit deadlift\b',
|
|
r'\bsumo deadlift\b',
|
|
r'\bsnatch.?grip deadlift\b',
|
|
r'\bpistol\b.*\bsquat\b',
|
|
r'\bpistol squat\b',
|
|
r'\bdragon flag\b',
|
|
r'\bl.?sit\b',
|
|
r'\bhandstand\b(?!.*\bpush.?up\b)', # handstand hold (not HSPU, that's 5)
|
|
r'\bwindshield wiper\b',
|
|
r'\btoes.?to.?bar\b',
|
|
r'\bknees.?to.?elbow\b',
|
|
r'\bkipping\b',
|
|
|
|
# --- Single-leg loaded (barbell/dumbbell) ---
|
|
r'\bsingle.?leg deadlift\b',
|
|
r'\bsingle.?leg rdl\b',
|
|
r'\bsingle.?leg squat\b(?!.*\bpistol\b)',
|
|
r'\bbulgarian split squat\b',
|
|
r'\brear.?foot.?elevated\b.*\bsplit\b',
|
|
|
|
# --- Explosive / plyometric ---
|
|
r'\bbox jump\b',
|
|
r'\bdepth jump\b',
|
|
r'\btuck jump\b',
|
|
r'\bbroad jump\b',
|
|
r'\bclap push.?up\b',
|
|
r'\bclapping push.?up\b',
|
|
r'\bplyometric push.?up\b',
|
|
r'\bplyo push.?up\b',
|
|
r'\bexplosive\b',
|
|
|
|
# --- Advanced core ---
|
|
r'\bab.?wheel\b',
|
|
r'\bab roller\b',
|
|
r'\bhanging.?leg.?raise\b',
|
|
r'\bhanging.?knee.?raise\b',
|
|
|
|
# --- Complex upper body ---
|
|
r'\barcher\b.*\bpush.?up\b',
|
|
r'\bdiamond push.?up\b',
|
|
r'\bpike push.?up\b',
|
|
r'\bmilitary press\b',
|
|
r'\bstrict press\b',
|
|
|
|
# --- Carries (unilateral loaded / coordination) ---
|
|
r'\bfarmer.?s?\s*carry\b',
|
|
r'\bfarmer.?s?\s*walk\b',
|
|
r'\bsuitcase carry\b',
|
|
r'\boverhead carry\b',
|
|
r'\brack carry\b',
|
|
r'\bwaiter.?s?\s*carry\b',
|
|
r'\bwaiter.?s?\s*walk\b',
|
|
r'\bcross.?body carry\b',
|
|
]
|
|
|
|
COMPLEXITY_1_PATTERNS = [
|
|
# --- Single-joint isolation ---
|
|
r'\bbicep curl\b',
|
|
r'\bcurl\b(?!.*\bleg\b)(?!.*\bhamstring\b)(?!.*\bnordic\b)',
|
|
r'\btricep extension\b',
|
|
r'\btricep kickback\b',
|
|
r'\btricep.?pushdown\b',
|
|
r'\bskull.?crusher\b',
|
|
r'\bcable\b.*\bfly\b',
|
|
r'\bcable\b.*\bpushdown\b',
|
|
r'\bcable\b.*\bcurl\b',
|
|
r'\bleg extension\b',
|
|
r'\bleg curl\b',
|
|
r'\bhamstring curl\b',
|
|
r'\bcalf raise\b',
|
|
r'\blateral raise\b',
|
|
r'\bfront raise\b',
|
|
r'\brear delt fly\b',
|
|
r'\breverse fly\b',
|
|
r'\bpec.?deck\b',
|
|
r'\bchest fly\b.*\bmachine\b',
|
|
r'\bmachine\b.*\bchest fly\b',
|
|
r'\bshrug\b',
|
|
r'\bwrist curl\b',
|
|
r'\bforearm curl\b',
|
|
r'\bconcentration curl\b',
|
|
r'\bhammer curl\b',
|
|
r'\bpreacher curl\b',
|
|
r'\bincline curl\b',
|
|
|
|
# --- Stretches / foam rolling ---
|
|
r'\bstretch\b',
|
|
r'\bstretching\b',
|
|
r'\bfoam roll\b',
|
|
r'\bfoam roller\b',
|
|
r'\blacrosse ball\b',
|
|
r'\bmyofascial\b',
|
|
r'\bself.?massage\b',
|
|
|
|
# --- Breathing ---
|
|
r'\bbreathing\b',
|
|
r'\bbreathe\b',
|
|
r'\bdiaphragmatic\b',
|
|
r'\bbox breathing\b',
|
|
r'\bbreath\b',
|
|
|
|
# --- Simple isolation machines ---
|
|
r'\bpec fly\b',
|
|
r'\bseated calf raise\b',
|
|
|
|
# --- Simple mobility ---
|
|
r'\bneck\b.*\bcircle\b',
|
|
r'\barm\b.*\bcircle\b',
|
|
r'\bshoulder\b.*\bcircle\b',
|
|
r'\bankle\b.*\bcircle\b',
|
|
r'\bhip\b.*\bcircle\b',
|
|
r'\bwrist\b.*\bcircle\b',
|
|
r'\bcat.?cow\b',
|
|
r'\bchild.?s?\s*pose\b',
|
|
r'\bcorpse\b.*\bpose\b',
|
|
r'\bsavasana\b',
|
|
]
|
|
|
|
COMPLEXITY_2_PATTERNS = [
|
|
# --- Basic bodyweight compound ---
|
|
r'\bpush.?up\b(?!.*\bclap\b)(?!.*\bplyometric\b)(?!.*\bplyo\b)(?!.*\bpike\b)(?!.*\bdiamond\b)(?!.*\barcher\b)(?!.*\bexplosive\b)',
|
|
r'\bsit.?up\b',
|
|
r'\bcrunches?\b',
|
|
r'\bbicycle\b.*\bcrunch\b',
|
|
r'\bflutter kick\b',
|
|
r'\bplank\b',
|
|
r'\bside plank\b',
|
|
r'\bglute bridge\b',
|
|
r'\bhip bridge\b',
|
|
r'\bbird.?dog\b',
|
|
r'\bsuperman\b',
|
|
r'\bwall sit\b',
|
|
r'\bdead hang\b',
|
|
r'\bbodyweight squat\b',
|
|
r'\bair squat\b',
|
|
r'\blying\b.*\bleg raise\b',
|
|
r'\bleg raise\b(?!.*\bhanging\b)',
|
|
r'\bjumping jack\b',
|
|
r'\bhigh knee\b',
|
|
r'\bbutt kick\b',
|
|
r'\bbutt kicker\b',
|
|
r'\bmountain climber\b',
|
|
r'\bstep.?up\b(?!.*\bweighted\b)(?!.*\bbarbell\b)',
|
|
|
|
# --- Basic machine compound ---
|
|
r'\bleg press\b',
|
|
r'\blat pulldown\b',
|
|
r'\bseated row\b.*\bmachine\b',
|
|
r'\bmachine\b.*\brow\b',
|
|
r'\bchest press\b.*\bmachine\b',
|
|
r'\bmachine\b.*\bchest press\b',
|
|
r'\bsmith machine\b',
|
|
|
|
# --- Cardio / locomotion ---
|
|
r'\bwalking\b',
|
|
r'\bwalk\b(?!.*\bout\b)',
|
|
r'\bjogging\b',
|
|
r'\bjog\b',
|
|
r'\brunning\b',
|
|
r'\bsprinting\b',
|
|
r'\browing\b.*\bmachine\b',
|
|
r'\bassault bike\b',
|
|
r'\bstationary bike\b',
|
|
r'\belliptical\b',
|
|
r'\bjump rope\b',
|
|
r'\bskipping\b',
|
|
|
|
# --- Simple yoga poses ---
|
|
r'\bdownward.?dog\b',
|
|
r'\bupward.?dog\b',
|
|
r'\bwarrior\b.*\bpose\b',
|
|
r'\btree\b.*\bpose\b',
|
|
|
|
# --- Basic combat ---
|
|
r'\bjab\b',
|
|
r'\bcross\b(?!.*\bbody\b.*\bcarry\b)',
|
|
r'\bshadow\s*box\b',
|
|
|
|
# --- Basic resistance band ---
|
|
r'\bband\b.*\bpull.?apart\b',
|
|
r'\bband\b.*\bface pull\b',
|
|
]
|
|
|
|
# Compile for performance
|
|
_COMPLEXITY_5_RE = [(re.compile(p, re.IGNORECASE), 5) for p in COMPLEXITY_5_PATTERNS]
|
|
_COMPLEXITY_4_RE = [(re.compile(p, re.IGNORECASE), 4) for p in COMPLEXITY_4_PATTERNS]
|
|
_COMPLEXITY_1_RE = [(re.compile(p, re.IGNORECASE), 1) for p in COMPLEXITY_1_PATTERNS]
|
|
_COMPLEXITY_2_RE = [(re.compile(p, re.IGNORECASE), 2) for p in COMPLEXITY_2_PATTERNS]
|
|
|
|
|
|
# ============================================================================
|
|
# MOVEMENT PATTERN -> DIFFICULTY FALLBACK
|
|
# ============================================================================
|
|
# When name-based rules don't match, use movement_patterns field.
|
|
# Keys are substring matches against movement_patterns (lowercased).
|
|
# Order matters: first match wins.
|
|
|
|
MOVEMENT_PATTERN_DIFFICULTY = [
|
|
# --- Advanced patterns ---
|
|
('plyometric', 'advanced'),
|
|
('olympic', 'advanced'),
|
|
|
|
# --- Beginner patterns ---
|
|
('massage', 'beginner'),
|
|
('breathing', 'beginner'),
|
|
('mobility - static', 'beginner'),
|
|
('yoga', 'beginner'),
|
|
('stretch', 'beginner'),
|
|
|
|
# --- Intermediate (default for all loaded / compound patterns) ---
|
|
('upper push - vertical', 'intermediate'),
|
|
('upper push - horizontal', 'intermediate'),
|
|
('upper pull - vertical', 'intermediate'),
|
|
('upper pull - horizonal', 'intermediate'), # note: typo matches DB
|
|
('upper pull - horizontal', 'intermediate'),
|
|
('upper push', 'intermediate'),
|
|
('upper pull', 'intermediate'),
|
|
('lower push - squat', 'intermediate'),
|
|
('lower push - lunge', 'intermediate'),
|
|
('lower pull - hip hinge', 'intermediate'),
|
|
('lower push', 'intermediate'),
|
|
('lower pull', 'intermediate'),
|
|
('core - anti-extension', 'intermediate'),
|
|
('core - rotational', 'intermediate'),
|
|
('core - anti-rotation', 'intermediate'),
|
|
('core - carry', 'intermediate'),
|
|
('core', 'intermediate'),
|
|
('arms', 'intermediate'),
|
|
('machine', 'intermediate'),
|
|
('balance', 'intermediate'),
|
|
('mobility - dynamic', 'intermediate'),
|
|
('mobility', 'intermediate'),
|
|
('combat', 'intermediate'),
|
|
('cardio/locomotion', 'intermediate'),
|
|
('cardio', 'intermediate'),
|
|
]
|
|
|
|
|
|
# ============================================================================
|
|
# MOVEMENT PATTERN -> COMPLEXITY FALLBACK
|
|
# ============================================================================
|
|
# When name-based rules don't match, use movement_patterns field.
|
|
# Order matters: first match wins.
|
|
|
|
MOVEMENT_PATTERN_COMPLEXITY = [
|
|
# --- Complexity 5 ---
|
|
('olympic', 5),
|
|
|
|
# --- Complexity 4 ---
|
|
('plyometric', 4),
|
|
('core - carry', 4),
|
|
|
|
# --- Complexity 3 (standard compound) ---
|
|
('upper push - vertical', 3),
|
|
('upper push - horizontal', 3),
|
|
('upper pull - vertical', 3),
|
|
('upper pull - horizonal', 3), # typo matches DB
|
|
('upper pull - horizontal', 3),
|
|
('upper push', 3),
|
|
('upper pull', 3),
|
|
('lower push - squat', 3),
|
|
('lower push - lunge', 3),
|
|
('lower pull - hip hinge', 3),
|
|
('lower push', 3),
|
|
('lower pull', 3),
|
|
('core - anti-extension', 3),
|
|
('core - rotational', 3),
|
|
('core - anti-rotation', 3),
|
|
('balance', 3),
|
|
('combat', 3),
|
|
|
|
# --- Complexity 2 ---
|
|
('core', 2),
|
|
('machine', 2),
|
|
('arms', 2),
|
|
('mobility - dynamic', 2),
|
|
('cardio/locomotion', 2),
|
|
('cardio', 2),
|
|
('yoga', 2),
|
|
|
|
# --- Complexity 1 ---
|
|
('mobility - static', 1),
|
|
('massage', 1),
|
|
('stretch', 1),
|
|
('breathing', 1),
|
|
('mobility', 1), # generic mobility fallback
|
|
]
|
|
|
|
|
|
# ============================================================================
|
|
# EQUIPMENT-BASED ADJUSTMENTS
|
|
# ============================================================================
|
|
# Some exercises can be bumped up or down based on equipment context.
|
|
# These are applied AFTER name + movement_pattern rules as modifiers.
|
|
|
|
def _apply_equipment_adjustments(exercise, difficulty, complexity):
|
|
"""
|
|
Apply equipment-based adjustments to difficulty and complexity.
|
|
|
|
- Barbell compound lifts: ensure at least intermediate / 3
|
|
- Kettlebell: bump complexity +1 for most movements (unstable load)
|
|
- Stability ball: bump complexity +1 (balance demand)
|
|
- Suspension trainer (TRX): bump complexity +1 (instability)
|
|
- Machine: cap complexity at 2 (guided path, low coordination)
|
|
- Resistance band: no change
|
|
"""
|
|
name_lower = (exercise.name or '').lower()
|
|
equip = (exercise.equipment_required or '').lower()
|
|
patterns = (exercise.movement_patterns or '').lower()
|
|
|
|
# --- Machine cap: complexity should not exceed 2 ---
|
|
is_machine = (
|
|
'machine' in equip
|
|
or 'machine' in name_lower
|
|
or 'smith' in name_lower
|
|
or 'machine' in patterns
|
|
)
|
|
# But only if it's truly a guided-path machine, not cable
|
|
is_cable = 'cable' in equip or 'cable' in name_lower
|
|
if is_machine and not is_cable:
|
|
complexity = min(complexity, 2)
|
|
|
|
# --- Kettlebell bump: +1 complexity (unstable center of mass) ---
|
|
is_kettlebell = 'kettlebell' in equip or 'kettlebell' in name_lower
|
|
if is_kettlebell and complexity < 5:
|
|
# Only bump for compound movements, not simple swings etc.
|
|
if any(kw in patterns for kw in ['upper push', 'upper pull', 'lower push', 'lower pull', 'core - carry']):
|
|
complexity = min(complexity + 1, 5)
|
|
|
|
# --- Stability ball bump: +1 complexity ---
|
|
is_stability_ball = 'stability ball' in equip or 'stability ball' in name_lower
|
|
if is_stability_ball and complexity < 5:
|
|
complexity = min(complexity + 1, 5)
|
|
|
|
# --- Suspension trainer (TRX) bump: +1 complexity ---
|
|
is_suspension = (
|
|
'suspension' in equip or 'trx' in name_lower
|
|
or 'suspension' in name_lower
|
|
)
|
|
if is_suspension and complexity < 5:
|
|
complexity = min(complexity + 1, 5)
|
|
|
|
# --- Barbell floor: ensure at least intermediate / 3 for big lifts ---
|
|
is_barbell = 'barbell' in equip or 'barbell' in name_lower
|
|
if is_barbell:
|
|
for lift in ['squat', 'deadlift', 'bench', 'press', 'row', 'lunge']:
|
|
if lift in name_lower:
|
|
if difficulty == 'beginner':
|
|
difficulty = 'intermediate'
|
|
complexity = max(complexity, 3)
|
|
break
|
|
|
|
return difficulty, complexity
|
|
|
|
|
|
# ============================================================================
|
|
# CLASSIFICATION FUNCTIONS
|
|
# ============================================================================
|
|
|
|
def classify_difficulty(exercise):
|
|
"""Return difficulty_level for an exercise. First match wins."""
|
|
name = (exercise.name or '').lower()
|
|
|
|
# 1. Check advanced name patterns
|
|
for regex, level in _ADVANCED_NAME_RE:
|
|
if regex.search(name):
|
|
return level
|
|
|
|
# 2. Check beginner name patterns
|
|
for regex, level in _BEGINNER_NAME_RE:
|
|
if regex.search(name):
|
|
return level
|
|
|
|
# 3. Movement pattern fallback
|
|
patterns = (exercise.movement_patterns or '').lower()
|
|
if patterns:
|
|
for keyword, level in MOVEMENT_PATTERN_DIFFICULTY:
|
|
if keyword in patterns:
|
|
return level
|
|
|
|
# 4. Default: intermediate
|
|
return 'intermediate'
|
|
|
|
|
|
def classify_complexity(exercise):
|
|
"""Return complexity_rating (1-5) for an exercise. First match wins."""
|
|
name = (exercise.name or '').lower()
|
|
|
|
# 1. Complexity 5
|
|
for regex, rating in _COMPLEXITY_5_RE:
|
|
if regex.search(name):
|
|
return rating
|
|
|
|
# 2. Complexity 4
|
|
for regex, rating in _COMPLEXITY_4_RE:
|
|
if regex.search(name):
|
|
return rating
|
|
|
|
# 3. Complexity 1 (check before 2 since some patterns overlap)
|
|
for regex, rating in _COMPLEXITY_1_RE:
|
|
if regex.search(name):
|
|
return rating
|
|
|
|
# 4. Complexity 2
|
|
for regex, rating in _COMPLEXITY_2_RE:
|
|
if regex.search(name):
|
|
return rating
|
|
|
|
# 5. Movement pattern fallback
|
|
patterns = (exercise.movement_patterns or '').lower()
|
|
if patterns:
|
|
for keyword, rating in MOVEMENT_PATTERN_COMPLEXITY:
|
|
if keyword in patterns:
|
|
return rating
|
|
|
|
# 6. Default: 3 (moderate)
|
|
return 3
|
|
|
|
|
|
def classify_exercise(exercise):
|
|
"""
|
|
Classify a single exercise and return (difficulty_level, complexity_rating).
|
|
"""
|
|
difficulty = classify_difficulty(exercise)
|
|
complexity = classify_complexity(exercise)
|
|
|
|
# Apply equipment-based adjustments
|
|
difficulty, complexity = _apply_equipment_adjustments(
|
|
exercise, difficulty, complexity
|
|
)
|
|
|
|
return difficulty, complexity
|
|
|
|
|
|
# ============================================================================
|
|
# MANAGEMENT COMMAND
|
|
# ============================================================================
|
|
|
|
class Command(BaseCommand):
|
|
help = (
|
|
'Classify all exercises by difficulty_level and complexity_rating '
|
|
'using name-based keyword rules and movement_patterns fallback.'
|
|
)
|
|
|
|
def add_arguments(self, parser):
|
|
parser.add_argument(
|
|
'--dry-run',
|
|
action='store_true',
|
|
help='Show what would change without saving.',
|
|
)
|
|
parser.add_argument(
|
|
'--verbose',
|
|
action='store_true',
|
|
help='Print each exercise classification.',
|
|
)
|
|
parser.add_argument(
|
|
'--only-unset',
|
|
action='store_true',
|
|
help='Only classify exercises that have NULL difficulty/complexity.',
|
|
)
|
|
|
|
def handle(self, *args, **options):
|
|
import warnings
|
|
warnings.warn(
|
|
"classify_exercises is deprecated. Use 'populate_exercise_fields' instead, "
|
|
"which populates all 8 exercise fields including difficulty and complexity.",
|
|
DeprecationWarning,
|
|
stacklevel=2,
|
|
)
|
|
self.stderr.write(self.style.WARNING(
|
|
"DEPRECATED: Use 'python manage.py populate_exercise_fields' instead. "
|
|
"This command only sets difficulty_level and complexity_rating, while "
|
|
"populate_exercise_fields sets all 8 fields."
|
|
))
|
|
|
|
dry_run = options['dry_run']
|
|
verbose = options['verbose']
|
|
only_unset = options['only_unset']
|
|
|
|
exercises = Exercise.objects.all().order_by('name')
|
|
if only_unset:
|
|
exercises = exercises.filter(
|
|
difficulty_level__isnull=True
|
|
) | exercises.filter(
|
|
complexity_rating__isnull=True
|
|
)
|
|
exercises = exercises.distinct().order_by('name')
|
|
|
|
total = exercises.count()
|
|
updated = 0
|
|
unchanged = 0
|
|
|
|
# Counters for summary
|
|
difficulty_counts = {'beginner': 0, 'intermediate': 0, 'advanced': 0}
|
|
complexity_counts = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0}
|
|
|
|
for ex in exercises:
|
|
difficulty, complexity = classify_exercise(ex)
|
|
|
|
difficulty_counts[difficulty] += 1
|
|
complexity_counts[complexity] += 1
|
|
|
|
changed = (
|
|
ex.difficulty_level != difficulty
|
|
or ex.complexity_rating != complexity
|
|
)
|
|
|
|
if verbose:
|
|
marker = '*' if changed else ' '
|
|
self.stdout.write(
|
|
f' {marker} {ex.name:<55} '
|
|
f'difficulty={difficulty:<14} '
|
|
f'complexity={complexity} '
|
|
f'patterns="{ex.movement_patterns or ""}"'
|
|
)
|
|
|
|
if changed:
|
|
updated += 1
|
|
if not dry_run:
|
|
ex.difficulty_level = difficulty
|
|
ex.complexity_rating = complexity
|
|
ex.save(update_fields=['difficulty_level', 'complexity_rating'])
|
|
else:
|
|
unchanged += 1
|
|
|
|
# Summary
|
|
prefix = '[DRY RUN] ' if dry_run else ''
|
|
self.stdout.write('')
|
|
self.stdout.write(f'{prefix}Processed {total} exercises:')
|
|
self.stdout.write(f' {updated} updated, {unchanged} unchanged')
|
|
self.stdout.write('')
|
|
self.stdout.write('Difficulty distribution:')
|
|
for level, count in difficulty_counts.items():
|
|
pct = (count / total * 100) if total else 0
|
|
self.stdout.write(f' {level:<14} {count:>5} ({pct:.1f}%)')
|
|
self.stdout.write('')
|
|
self.stdout.write('Complexity distribution:')
|
|
for rating in sorted(complexity_counts.keys()):
|
|
count = complexity_counts[rating]
|
|
pct = (count / total * 100) if total else 0
|
|
self.stdout.write(f' {rating} {count:>5} ({pct:.1f}%)')
|