Unraid deployment fixes and generator improvements
- Add Next.js rewrites to proxy API calls through same origin (fixes login/media on werkout.treytartt.com) - Fix mediaUrl() in DayCard and ExerciseRow to use relative paths in production - Add proxyTimeout for long-running workout generation endpoints - Add CSRF trusted origin for treytartt.com - Split docker-compose into production (Unraid) and dev configs - Show display_name and descriptions on workout type cards - Generator: rules engine improvements, movement enforcement, exercise selector updates - Add new test files for rules drift, workout research generation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,8 @@ from typing import List, Optional, Dict, Any, Tuple
|
||||
|
||||
import logging
|
||||
|
||||
from generator.services.exercise_selector import extract_movement_families
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -88,7 +90,7 @@ WORKOUT_TYPE_RULES: Dict[str, Dict[str, Any]] = {
|
||||
# ------------------------------------------------------------------
|
||||
# 3. HIIT
|
||||
# ------------------------------------------------------------------
|
||||
'hiit': {
|
||||
'high_intensity_interval_training': {
|
||||
'rep_ranges': {
|
||||
'primary': (10, 20),
|
||||
'secondary': (10, 20),
|
||||
@@ -275,7 +277,7 @@ UNIVERSAL_RULES: Dict[str, Any] = {
|
||||
# ======================================================================
|
||||
|
||||
DB_CALIBRATION: Dict[str, Dict[str, Any]] = {
|
||||
'Functional Strength Training': {
|
||||
'functional_strength_training': {
|
||||
'duration_bias': 0.15,
|
||||
'typical_rest_between_sets': 60,
|
||||
'typical_intensity': 'medium',
|
||||
@@ -286,7 +288,7 @@ DB_CALIBRATION: Dict[str, Dict[str, Any]] = {
|
||||
'superset_size_min': 2,
|
||||
'superset_size_max': 4,
|
||||
},
|
||||
'Traditional Strength Training': {
|
||||
'traditional_strength_training': {
|
||||
'duration_bias': 0.1,
|
||||
'typical_rest_between_sets': 120,
|
||||
'typical_intensity': 'high',
|
||||
@@ -297,7 +299,7 @@ DB_CALIBRATION: Dict[str, Dict[str, Any]] = {
|
||||
'superset_size_min': 1,
|
||||
'superset_size_max': 3,
|
||||
},
|
||||
'HIIT': {
|
||||
'high_intensity_interval_training': {
|
||||
'duration_bias': 0.7,
|
||||
'typical_rest_between_sets': 30,
|
||||
'typical_intensity': 'high',
|
||||
@@ -308,7 +310,7 @@ DB_CALIBRATION: Dict[str, Dict[str, Any]] = {
|
||||
'superset_size_min': 3,
|
||||
'superset_size_max': 6,
|
||||
},
|
||||
'Cross Training': {
|
||||
'cross_training': {
|
||||
'duration_bias': 0.4,
|
||||
'typical_rest_between_sets': 45,
|
||||
'typical_intensity': 'high',
|
||||
@@ -319,7 +321,7 @@ DB_CALIBRATION: Dict[str, Dict[str, Any]] = {
|
||||
'superset_size_min': 3,
|
||||
'superset_size_max': 5,
|
||||
},
|
||||
'Core Training': {
|
||||
'core_training': {
|
||||
'duration_bias': 0.5,
|
||||
'typical_rest_between_sets': 30,
|
||||
'typical_intensity': 'medium',
|
||||
@@ -330,7 +332,7 @@ DB_CALIBRATION: Dict[str, Dict[str, Any]] = {
|
||||
'superset_size_min': 3,
|
||||
'superset_size_max': 5,
|
||||
},
|
||||
'Flexibility': {
|
||||
'flexibility': {
|
||||
'duration_bias': 0.9,
|
||||
'typical_rest_between_sets': 15,
|
||||
'typical_intensity': 'low',
|
||||
@@ -341,7 +343,7 @@ DB_CALIBRATION: Dict[str, Dict[str, Any]] = {
|
||||
'superset_size_min': 3,
|
||||
'superset_size_max': 6,
|
||||
},
|
||||
'Cardio': {
|
||||
'cardio': {
|
||||
'duration_bias': 1.0,
|
||||
'typical_rest_between_sets': 30,
|
||||
'typical_intensity': 'medium',
|
||||
@@ -352,7 +354,7 @@ DB_CALIBRATION: Dict[str, Dict[str, Any]] = {
|
||||
'superset_size_min': 1,
|
||||
'superset_size_max': 3,
|
||||
},
|
||||
'Hypertrophy': {
|
||||
'hypertrophy': {
|
||||
'duration_bias': 0.2,
|
||||
'typical_rest_between_sets': 90,
|
||||
'typical_intensity': 'high',
|
||||
@@ -366,13 +368,24 @@ DB_CALIBRATION: Dict[str, Dict[str, Any]] = {
|
||||
}
|
||||
|
||||
|
||||
# Canonical key aliases for workout type names. This lets callers pass
|
||||
# legacy/short names while still resolving to DB-style identifiers.
|
||||
WORKOUT_TYPE_KEY_ALIASES: Dict[str, str] = {
|
||||
'hiit': 'high_intensity_interval_training',
|
||||
}
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Validation helpers
|
||||
# ======================================================================
|
||||
|
||||
def _normalize_type_key(name: str) -> str:
|
||||
"""Convert a workout type name to the underscore key used in WORKOUT_TYPE_RULES."""
|
||||
return name.strip().lower().replace(' ', '_')
|
||||
"""Convert a workout type name to the canonical key in WORKOUT_TYPE_RULES."""
|
||||
if not name:
|
||||
return ''
|
||||
normalized = name.strip().lower().replace('-', '_').replace(' ', '_')
|
||||
normalized = '_'.join(part for part in normalized.split('_') if part)
|
||||
return WORKOUT_TYPE_KEY_ALIASES.get(normalized, normalized)
|
||||
|
||||
|
||||
def _classify_rep_weight(reps: int) -> str:
|
||||
@@ -457,6 +470,21 @@ def _check_compound_before_isolation(supersets: list) -> bool:
|
||||
return not compound_after_isolation
|
||||
|
||||
|
||||
def _focus_key_for_entry(entry: dict) -> Optional[str]:
|
||||
"""Derive a coarse focus key from an entry's exercise."""
|
||||
ex = entry.get('exercise')
|
||||
if ex is None:
|
||||
return None
|
||||
families = sorted(extract_movement_families(getattr(ex, 'name', '') or ''))
|
||||
if families:
|
||||
return families[0]
|
||||
patterns = (getattr(ex, 'movement_patterns', '') or '').lower()
|
||||
for token in ('upper pull', 'upper push', 'hip hinge', 'squat', 'lunge', 'core', 'carry'):
|
||||
if token in patterns:
|
||||
return token
|
||||
return None
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Main validation function
|
||||
# ======================================================================
|
||||
@@ -623,7 +651,53 @@ def validate_workout(
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 5. Compound before isolation ordering
|
||||
# 5. Focus spread across working supersets
|
||||
# ------------------------------------------------------------------
|
||||
if working:
|
||||
for ss in working:
|
||||
seen_focus = set()
|
||||
duplicate_focus = set()
|
||||
for entry in ss.get('exercises', []):
|
||||
focus_key = _focus_key_for_entry(entry)
|
||||
if not focus_key:
|
||||
continue
|
||||
if focus_key in seen_focus:
|
||||
duplicate_focus.add(focus_key)
|
||||
seen_focus.add(focus_key)
|
||||
if duplicate_focus:
|
||||
violations.append(RuleViolation(
|
||||
rule_id='superset_focus_repetition',
|
||||
severity='error',
|
||||
message=(
|
||||
f"Superset '{ss.get('name')}' repeats focus area(s): "
|
||||
f"{', '.join(sorted(duplicate_focus))}."
|
||||
),
|
||||
actual_value=sorted(duplicate_focus),
|
||||
))
|
||||
|
||||
# Advisory: same dominant focus in adjacent working supersets.
|
||||
previous_focus = None
|
||||
for ss in working:
|
||||
focus_keys = {
|
||||
_focus_key_for_entry(entry)
|
||||
for entry in ss.get('exercises', [])
|
||||
}
|
||||
focus_keys.discard(None)
|
||||
if previous_focus is not None and focus_keys and focus_keys == previous_focus:
|
||||
violations.append(RuleViolation(
|
||||
rule_id='adjacent_superset_focus_repetition',
|
||||
severity='info',
|
||||
message=(
|
||||
f"Adjacent supersets reuse the same focus profile "
|
||||
f"({', '.join(sorted(focus_keys))}); spread focus when possible."
|
||||
),
|
||||
actual_value=sorted(focus_keys),
|
||||
))
|
||||
if focus_keys:
|
||||
previous_focus = focus_keys
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 6. Compound before isolation ordering
|
||||
# ------------------------------------------------------------------
|
||||
if UNIVERSAL_RULES['compound_before_isolation']:
|
||||
if not _check_compound_before_isolation(supersets):
|
||||
@@ -634,7 +708,7 @@ def validate_workout(
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 6. Warmup check
|
||||
# 7. Warmup check
|
||||
# ------------------------------------------------------------------
|
||||
if UNIVERSAL_RULES['warmup_mandatory']:
|
||||
if not _has_warmup(supersets):
|
||||
@@ -645,7 +719,7 @@ def validate_workout(
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 7. Cooldown check
|
||||
# 8. Cooldown check
|
||||
# ------------------------------------------------------------------
|
||||
if not _has_cooldown(supersets):
|
||||
violations.append(RuleViolation(
|
||||
@@ -655,9 +729,9 @@ def validate_workout(
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 8. HIIT duration cap
|
||||
# 9. HIIT duration cap
|
||||
# ------------------------------------------------------------------
|
||||
if wt_key == 'hiit':
|
||||
if wt_key == 'high_intensity_interval_training':
|
||||
max_hiit_min = UNIVERSAL_RULES.get('max_hiit_duration_min', 30)
|
||||
# Estimate total working time from working supersets
|
||||
total_working_exercises = sum(
|
||||
@@ -683,7 +757,7 @@ def validate_workout(
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 9. Total exercise count cap
|
||||
# 10. Total exercise count cap
|
||||
# ------------------------------------------------------------------
|
||||
max_exercises = wt_rules.get(
|
||||
'max_exercises_per_session',
|
||||
@@ -706,7 +780,7 @@ def validate_workout(
|
||||
))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 10. Workout type match percentage (refactored from _validate_workout_type_match)
|
||||
# 11. Workout type match percentage (refactored from _validate_workout_type_match)
|
||||
# ------------------------------------------------------------------
|
||||
_STRENGTH_TYPES = {
|
||||
'traditional_strength_training', 'functional_strength_training',
|
||||
|
||||
Reference in New Issue
Block a user