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:
Trey t
2026-02-23 10:25:45 -06:00
parent 1c61b80731
commit 03681c532d
21 changed files with 2366 additions and 138 deletions

View File

@@ -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',