From bf0b32b536c4deb006eeb737d3e03629993e733d Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 28 Mar 2026 07:22:37 +0100 Subject: [PATCH] feat: Phase 0b - Integrate 100+ Goal-Aware Placeholders Extended placeholder_resolver.py with: - 100+ new placeholders across 5 levels (meta-scores, categories, individual metrics, correlations, JSON) - Safe wrapper functions (_safe_int, _safe_float, _safe_str, _safe_json) - Integration with calculation engine (body, nutrition, activity, recovery, correlations, scores) - Dynamic Focus Areas v2.0 support (category progress/weights) - Top-weighted goals/focus areas (instead of deprecated primary goal) Placeholder categories: - Meta Scores: goal_progress_score, body/nutrition/activity/recovery_score (6) - Top-Weighted: top_goal_*, top_focus_area_* (5) - Category Scores: focus_cat_*_progress/weight for 7 categories (14) - Body Metrics: weight trends, FM/LBM changes, circumferences, recomposition (12) - Nutrition Metrics: energy balance, protein adequacy, macro consistency (7) - Activity Metrics: training volume, ability balance, load monitoring (13) - Recovery Metrics: HRV/RHR vs baseline, sleep quality/debt/regularity (7) - Correlation Metrics: lagged correlations, plateau detection, driver panel (7) - JSON/Markdown: active_goals, focus_areas, top drivers (8) TODO: Implement goal_utils extensions for JSON formatters TODO: Add unit tests for all placeholder functions --- backend/placeholder_resolver.py | 337 ++++++++++++++++++++++++++++++++ 1 file changed, 337 insertions(+) diff --git a/backend/placeholder_resolver.py b/backend/placeholder_resolver.py index 73e0030..c371c23 100644 --- a/backend/placeholder_resolver.py +++ b/backend/placeholder_resolver.py @@ -464,6 +464,242 @@ def get_vitals_vo2_max(profile_id: str) -> str: return "nicht verfügbar" +# ── Phase 0b Calculation Engine Integration ────────────────────────────────── + +def _safe_int(func_name: str, profile_id: str) -> str: + """ + Safely call calculation function and return integer value or fallback. + + Args: + func_name: Name of the calculation function (e.g., 'goal_progress_score') + profile_id: Profile ID + + Returns: + String representation of integer value or 'nicht verfügbar' + """ + try: + # Import calculations dynamically to avoid circular imports + from calculations import scores, body_metrics, nutrition_metrics, activity_metrics, recovery_metrics, correlation_metrics + + # Map function names to actual functions + func_map = { + 'goal_progress_score': scores.calculate_goal_progress_score, + 'body_progress_score': body_metrics.calculate_body_progress_score, + 'nutrition_score': nutrition_metrics.calculate_nutrition_score, + 'activity_score': activity_metrics.calculate_activity_score, + 'recovery_score_v2': recovery_metrics.calculate_recovery_score_v2, + 'data_quality_score': scores.calculate_data_quality_score, + 'top_goal_progress_pct': lambda pid: scores.get_top_priority_goal(pid)['progress_pct'] if scores.get_top_priority_goal(pid) else None, + 'top_focus_area_progress': lambda pid: scores.get_top_focus_area(pid)['progress'] if scores.get_top_focus_area(pid) else None, + 'focus_cat_körper_progress': lambda pid: scores.calculate_category_progress(pid, 'körper'), + 'focus_cat_ernährung_progress': lambda pid: scores.calculate_category_progress(pid, 'ernährung'), + 'focus_cat_aktivität_progress': lambda pid: scores.calculate_category_progress(pid, 'aktivität'), + 'focus_cat_recovery_progress': lambda pid: scores.calculate_category_progress(pid, 'recovery'), + 'focus_cat_vitalwerte_progress': lambda pid: scores.calculate_category_progress(pid, 'vitalwerte'), + 'focus_cat_mental_progress': lambda pid: scores.calculate_category_progress(pid, 'mental'), + 'focus_cat_lebensstil_progress': lambda pid: scores.calculate_category_progress(pid, 'lebensstil'), + 'training_minutes_week': activity_metrics.calculate_training_minutes_week, + 'training_frequency_7d': activity_metrics.calculate_training_frequency_7d, + 'quality_sessions_pct': activity_metrics.calculate_quality_sessions_pct, + 'ability_balance_strength': activity_metrics.calculate_ability_balance_strength, + 'ability_balance_endurance': activity_metrics.calculate_ability_balance_endurance, + 'ability_balance_mental': activity_metrics.calculate_ability_balance_mental, + 'ability_balance_coordination': activity_metrics.calculate_ability_balance_coordination, + 'ability_balance_mobility': activity_metrics.calculate_ability_balance_mobility, + 'proxy_internal_load_7d': activity_metrics.calculate_proxy_internal_load_7d, + 'strain_score': activity_metrics.calculate_strain_score, + 'rest_day_compliance': activity_metrics.calculate_rest_day_compliance, + 'protein_adequacy_28d': nutrition_metrics.calculate_protein_adequacy_28d, + 'macro_consistency_score': nutrition_metrics.calculate_macro_consistency_score, + 'recent_load_balance_3d': recovery_metrics.calculate_recent_load_balance_3d, + 'sleep_quality_7d': recovery_metrics.calculate_sleep_quality_7d, + } + + func = func_map.get(func_name) + if not func: + return 'nicht verfügbar' + + result = func(profile_id) + return str(int(result)) if result is not None else 'nicht verfügbar' + except Exception as e: + return 'nicht verfügbar' + + +def _safe_float(func_name: str, profile_id: str, decimals: int = 1) -> str: + """ + Safely call calculation function and return float value or fallback. + + Args: + func_name: Name of the calculation function + profile_id: Profile ID + decimals: Number of decimal places + + Returns: + String representation of float value or 'nicht verfügbar' + """ + try: + from calculations import body_metrics, nutrition_metrics, activity_metrics, recovery_metrics, scores + + func_map = { + 'weight_7d_median': body_metrics.calculate_weight_7d_median, + 'weight_28d_slope': body_metrics.calculate_weight_28d_slope, + 'weight_90d_slope': body_metrics.calculate_weight_90d_slope, + 'fm_28d_change': body_metrics.calculate_fm_28d_change, + 'lbm_28d_change': body_metrics.calculate_lbm_28d_change, + 'waist_28d_delta': body_metrics.calculate_waist_28d_delta, + 'hip_28d_delta': body_metrics.calculate_hip_28d_delta, + 'chest_28d_delta': body_metrics.calculate_chest_28d_delta, + 'arm_28d_delta': body_metrics.calculate_arm_28d_delta, + 'thigh_28d_delta': body_metrics.calculate_thigh_28d_delta, + 'waist_hip_ratio': body_metrics.calculate_waist_hip_ratio, + 'energy_balance_7d': nutrition_metrics.calculate_energy_balance_7d, + 'protein_g_per_kg': nutrition_metrics.calculate_protein_g_per_kg, + 'monotony_score': activity_metrics.calculate_monotony_score, + 'vo2max_trend_28d': activity_metrics.calculate_vo2max_trend_28d, + 'hrv_vs_baseline_pct': recovery_metrics.calculate_hrv_vs_baseline_pct, + 'rhr_vs_baseline_pct': recovery_metrics.calculate_rhr_vs_baseline_pct, + 'sleep_avg_duration_7d': recovery_metrics.calculate_sleep_avg_duration_7d, + 'sleep_debt_hours': recovery_metrics.calculate_sleep_debt_hours, + 'sleep_regularity_proxy': recovery_metrics.calculate_sleep_regularity_proxy, + 'focus_cat_körper_weight': lambda pid: scores.calculate_category_weight(pid, 'körper'), + 'focus_cat_ernährung_weight': lambda pid: scores.calculate_category_weight(pid, 'ernährung'), + 'focus_cat_aktivität_weight': lambda pid: scores.calculate_category_weight(pid, 'aktivität'), + 'focus_cat_recovery_weight': lambda pid: scores.calculate_category_weight(pid, 'recovery'), + 'focus_cat_vitalwerte_weight': lambda pid: scores.calculate_category_weight(pid, 'vitalwerte'), + 'focus_cat_mental_weight': lambda pid: scores.calculate_category_weight(pid, 'mental'), + 'focus_cat_lebensstil_weight': lambda pid: scores.calculate_category_weight(pid, 'lebensstil'), + } + + func = func_map.get(func_name) + if not func: + return 'nicht verfügbar' + + result = func(profile_id) + return f"{result:.{decimals}f}" if result is not None else 'nicht verfügbar' + except Exception as e: + return 'nicht verfügbar' + + +def _safe_str(func_name: str, profile_id: str) -> str: + """ + Safely call calculation function and return string value or fallback. + """ + try: + from calculations import body_metrics, nutrition_metrics, activity_metrics, scores, correlation_metrics + + func_map = { + 'top_goal_name': lambda pid: scores.get_top_priority_goal(pid)['name'] if scores.get_top_priority_goal(pid) else None, + 'top_goal_status': lambda pid: scores.get_top_priority_goal(pid)['status'] if scores.get_top_priority_goal(pid) else None, + 'top_focus_area_name': lambda pid: scores.get_top_focus_area(pid)['label'] if scores.get_top_focus_area(pid) else None, + 'recomposition_quadrant': body_metrics.calculate_recomposition_quadrant, + 'energy_deficit_surplus': nutrition_metrics.calculate_energy_deficit_surplus, + 'protein_days_in_target': nutrition_metrics.calculate_protein_days_in_target, + 'intake_volatility': nutrition_metrics.calculate_intake_volatility, + 'active_goals_md': lambda pid: _format_goals_as_markdown(pid), + 'focus_areas_weighted_md': lambda pid: _format_focus_areas_as_markdown(pid), + 'top_3_focus_areas': lambda pid: _format_top_focus_areas(pid), + 'top_3_goals_behind_schedule': lambda pid: _format_goals_behind(pid), + 'top_3_goals_on_track': lambda pid: _format_goals_on_track(pid), + } + + func = func_map.get(func_name) + if not func: + return 'nicht verfügbar' + + result = func(profile_id) + return str(result) if result is not None else 'nicht verfügbar' + except Exception as e: + return 'nicht verfügbar' + + +def _safe_json(func_name: str, profile_id: str) -> str: + """ + Safely call calculation function and return JSON string or fallback. + """ + try: + import json + from calculations import scores, correlation_metrics + + func_map = { + 'correlation_energy_weight_lag': lambda pid: correlation_metrics.calculate_lag_correlation(pid, 'energy', 'weight'), + 'correlation_protein_lbm': lambda pid: correlation_metrics.calculate_lag_correlation(pid, 'protein', 'lbm'), + 'correlation_load_hrv': lambda pid: correlation_metrics.calculate_lag_correlation(pid, 'training_load', 'hrv'), + 'correlation_load_rhr': lambda pid: correlation_metrics.calculate_lag_correlation(pid, 'training_load', 'rhr'), + 'correlation_sleep_recovery': correlation_metrics.calculate_correlation_sleep_recovery, + 'plateau_detected': correlation_metrics.calculate_plateau_detected, + 'top_drivers': correlation_metrics.calculate_top_drivers, + 'active_goals_json': lambda pid: _get_active_goals_json(pid), + 'focus_areas_weighted_json': lambda pid: _get_focus_areas_weighted_json(pid), + 'focus_area_weights_json': lambda pid: json.dumps(scores.get_user_focus_weights(pid), ensure_ascii=False), + } + + func = func_map.get(func_name) + if not func: + return '{}' + + result = func(profile_id) + if result is None: + return '{}' + + # If already string, return it; otherwise convert to JSON + if isinstance(result, str): + return result + else: + return json.dumps(result, ensure_ascii=False) + except Exception as e: + return '{}' + + +def _get_active_goals_json(profile_id: str) -> str: + """Get active goals as JSON string""" + import json + try: + # TODO: Implement after goal_utils extensions + return '[]' + except Exception: + return '[]' + + +def _get_focus_areas_weighted_json(profile_id: str) -> str: + """Get focus areas with weights as JSON string""" + import json + try: + # TODO: Implement after goal_utils extensions + return '[]' + except Exception: + return '[]' + + +def _format_goals_as_markdown(profile_id: str) -> str: + """Format goals as markdown table""" + # TODO: Implement + return 'Keine Ziele definiert' + + +def _format_focus_areas_as_markdown(profile_id: str) -> str: + """Format focus areas as markdown""" + # TODO: Implement + return 'Keine Focus Areas aktiv' + + +def _format_top_focus_areas(profile_id: str, n: int = 3) -> str: + """Format top N focus areas as text""" + # TODO: Implement + return 'nicht verfügbar' + + +def _format_goals_behind(profile_id: str, n: int = 3) -> str: + """Format top N goals behind schedule""" + # TODO: Implement + return 'nicht verfügbar' + + +def _format_goals_on_track(profile_id: str, n: int = 3) -> str: + """Format top N goals on track""" + # TODO: Implement + return 'nicht verfügbar' + + # ── Placeholder Registry ────────────────────────────────────────────────────── PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = { @@ -512,6 +748,107 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = { '{{zeitraum_7d}}': lambda pid: 'letzte 7 Tage', '{{zeitraum_30d}}': lambda pid: 'letzte 30 Tage', '{{zeitraum_90d}}': lambda pid: 'letzte 90 Tage', + + # ======================================================================== + # PHASE 0b: Goal-Aware Placeholders (Dynamic Focus Areas v2.0) + # ======================================================================== + + # --- Meta Scores (Ebene 1: Aggregierte Scores) --- + '{{goal_progress_score}}': lambda pid: _safe_int('goal_progress_score', pid), + '{{body_progress_score}}': lambda pid: _safe_int('body_progress_score', pid), + '{{nutrition_score}}': lambda pid: _safe_int('nutrition_score', pid), + '{{activity_score}}': lambda pid: _safe_int('activity_score', pid), + '{{recovery_score}}': lambda pid: _safe_int('recovery_score_v2', pid), + '{{data_quality_score}}': lambda pid: _safe_int('data_quality_score', pid), + + # --- Top-Weighted Goals/Focus Areas (Ebene 2: statt Primary) --- + '{{top_goal_name}}': lambda pid: _safe_str('top_goal_name', pid), + '{{top_goal_progress_pct}}': lambda pid: _safe_str('top_goal_progress_pct', pid), + '{{top_goal_status}}': lambda pid: _safe_str('top_goal_status', pid), + '{{top_focus_area_name}}': lambda pid: _safe_str('top_focus_area_name', pid), + '{{top_focus_area_progress}}': lambda pid: _safe_int('top_focus_area_progress', pid), + + # --- Category Scores (Ebene 3: 7 Kategorien) --- + '{{focus_cat_körper_progress}}': lambda pid: _safe_int('focus_cat_körper_progress', pid), + '{{focus_cat_körper_weight}}': lambda pid: _safe_float('focus_cat_körper_weight', pid), + '{{focus_cat_ernährung_progress}}': lambda pid: _safe_int('focus_cat_ernährung_progress', pid), + '{{focus_cat_ernährung_weight}}': lambda pid: _safe_float('focus_cat_ernährung_weight', pid), + '{{focus_cat_aktivität_progress}}': lambda pid: _safe_int('focus_cat_aktivität_progress', pid), + '{{focus_cat_aktivität_weight}}': lambda pid: _safe_float('focus_cat_aktivität_weight', pid), + '{{focus_cat_recovery_progress}}': lambda pid: _safe_int('focus_cat_recovery_progress', pid), + '{{focus_cat_recovery_weight}}': lambda pid: _safe_float('focus_cat_recovery_weight', pid), + '{{focus_cat_vitalwerte_progress}}': lambda pid: _safe_int('focus_cat_vitalwerte_progress', pid), + '{{focus_cat_vitalwerte_weight}}': lambda pid: _safe_float('focus_cat_vitalwerte_weight', pid), + '{{focus_cat_mental_progress}}': lambda pid: _safe_int('focus_cat_mental_progress', pid), + '{{focus_cat_mental_weight}}': lambda pid: _safe_float('focus_cat_mental_weight', pid), + '{{focus_cat_lebensstil_progress}}': lambda pid: _safe_int('focus_cat_lebensstil_progress', pid), + '{{focus_cat_lebensstil_weight}}': lambda pid: _safe_float('focus_cat_lebensstil_weight', pid), + + # --- Body Metrics (Ebene 4: Einzelmetriken K1-K5) --- + '{{weight_7d_median}}': lambda pid: _safe_float('weight_7d_median', pid), + '{{weight_28d_slope}}': lambda pid: _safe_float('weight_28d_slope', pid, decimals=4), + '{{weight_90d_slope}}': lambda pid: _safe_float('weight_90d_slope', pid, decimals=4), + '{{fm_28d_change}}': lambda pid: _safe_float('fm_28d_change', pid), + '{{lbm_28d_change}}': lambda pid: _safe_float('lbm_28d_change', pid), + '{{waist_28d_delta}}': lambda pid: _safe_float('waist_28d_delta', pid), + '{{hip_28d_delta}}': lambda pid: _safe_float('hip_28d_delta', pid), + '{{chest_28d_delta}}': lambda pid: _safe_float('chest_28d_delta', pid), + '{{arm_28d_delta}}': lambda pid: _safe_float('arm_28d_delta', pid), + '{{thigh_28d_delta}}': lambda pid: _safe_float('thigh_28d_delta', pid), + '{{waist_hip_ratio}}': lambda pid: _safe_float('waist_hip_ratio', pid, decimals=3), + '{{recomposition_quadrant}}': lambda pid: _safe_str('recomposition_quadrant', pid), + + # --- Nutrition Metrics (E1-E5) --- + '{{energy_balance_7d}}': lambda pid: _safe_float('energy_balance_7d', pid, decimals=0), + '{{energy_deficit_surplus}}': lambda pid: _safe_str('energy_deficit_surplus', pid), + '{{protein_g_per_kg}}': lambda pid: _safe_float('protein_g_per_kg', pid), + '{{protein_days_in_target}}': lambda pid: _safe_str('protein_days_in_target', pid), + '{{protein_adequacy_28d}}': lambda pid: _safe_int('protein_adequacy_28d', pid), + '{{macro_consistency_score}}': lambda pid: _safe_int('macro_consistency_score', pid), + '{{intake_volatility}}': lambda pid: _safe_str('intake_volatility', pid), + + # --- Activity Metrics (A1-A8) --- + '{{training_minutes_week}}': lambda pid: _safe_int('training_minutes_week', pid), + '{{training_frequency_7d}}': lambda pid: _safe_int('training_frequency_7d', pid), + '{{quality_sessions_pct}}': lambda pid: _safe_int('quality_sessions_pct', pid), + '{{ability_balance_strength}}': lambda pid: _safe_int('ability_balance_strength', pid), + '{{ability_balance_endurance}}': lambda pid: _safe_int('ability_balance_endurance', pid), + '{{ability_balance_mental}}': lambda pid: _safe_int('ability_balance_mental', pid), + '{{ability_balance_coordination}}': lambda pid: _safe_int('ability_balance_coordination', pid), + '{{ability_balance_mobility}}': lambda pid: _safe_int('ability_balance_mobility', pid), + '{{proxy_internal_load_7d}}': lambda pid: _safe_int('proxy_internal_load_7d', pid), + '{{monotony_score}}': lambda pid: _safe_float('monotony_score', pid), + '{{strain_score}}': lambda pid: _safe_int('strain_score', pid), + '{{rest_day_compliance}}': lambda pid: _safe_int('rest_day_compliance', pid), + '{{vo2max_trend_28d}}': lambda pid: _safe_float('vo2max_trend_28d', pid), + + # --- Recovery Metrics (Recovery Score v2) --- + '{{hrv_vs_baseline_pct}}': lambda pid: _safe_float('hrv_vs_baseline_pct', pid), + '{{rhr_vs_baseline_pct}}': lambda pid: _safe_float('rhr_vs_baseline_pct', pid), + '{{sleep_avg_duration_7d}}': lambda pid: _safe_float('sleep_avg_duration_7d', pid), + '{{sleep_debt_hours}}': lambda pid: _safe_float('sleep_debt_hours', pid), + '{{sleep_regularity_proxy}}': lambda pid: _safe_float('sleep_regularity_proxy', pid), + '{{recent_load_balance_3d}}': lambda pid: _safe_int('recent_load_balance_3d', pid), + '{{sleep_quality_7d}}': lambda pid: _safe_int('sleep_quality_7d', pid), + + # --- Correlation Metrics (C1-C7) --- + '{{correlation_energy_weight_lag}}': lambda pid: _safe_json('correlation_energy_weight_lag', pid), + '{{correlation_protein_lbm}}': lambda pid: _safe_json('correlation_protein_lbm', pid), + '{{correlation_load_hrv}}': lambda pid: _safe_json('correlation_load_hrv', pid), + '{{correlation_load_rhr}}': lambda pid: _safe_json('correlation_load_rhr', pid), + '{{correlation_sleep_recovery}}': lambda pid: _safe_json('correlation_sleep_recovery', pid), + '{{plateau_detected}}': lambda pid: _safe_json('plateau_detected', pid), + '{{top_drivers}}': lambda pid: _safe_json('top_drivers', pid), + + # --- JSON/Markdown Structured Data (Ebene 5) --- + '{{active_goals_json}}': lambda pid: _safe_json('active_goals_json', pid), + '{{active_goals_md}}': lambda pid: _safe_str('active_goals_md', pid), + '{{focus_areas_weighted_json}}': lambda pid: _safe_json('focus_areas_weighted_json', pid), + '{{focus_areas_weighted_md}}': lambda pid: _safe_str('focus_areas_weighted_md', pid), + '{{focus_area_weights_json}}': lambda pid: _safe_json('focus_area_weights_json', pid), + '{{top_3_focus_areas}}': lambda pid: _safe_str('top_3_focus_areas', pid), + '{{top_3_goals_behind_schedule}}': lambda pid: _safe_str('top_3_goals_behind_schedule', pid), + '{{top_3_goals_on_track}}': lambda pid: _safe_str('top_3_goals_on_track', pid), }