- Introduced functions to retrieve profile name, age, height, and gender for better placeholder resolution. - Added functions for displaying current date and time period labels (last 7, 30, and 90 days). - Updated PLACEHOLDER_MAP to utilize new functions for improved readability and maintainability. - Enhanced placeholder registrations in __init__.py to include new modules for sleep, vital metrics, and profile time periods. These changes enhance the flexibility and functionality of the placeholder system, allowing for more dynamic content generation.
1589 lines
63 KiB
Python
1589 lines
63 KiB
Python
"""
|
|
Placeholder Resolver for AI Prompts
|
|
|
|
Provides a registry of placeholder functions that resolve to actual user data.
|
|
Used for prompt templates and preview functionality.
|
|
|
|
Phase 0c: Refactored to use data_layer for structured data.
|
|
This module now focuses on FORMATTING for AI consumption.
|
|
"""
|
|
import re
|
|
from datetime import datetime, timedelta
|
|
from typing import Dict, List, Optional, Callable, Tuple
|
|
from db import get_db, get_cursor, r2d
|
|
|
|
# Phase 0c: Import data layer
|
|
from data_layer.body_metrics import (
|
|
get_latest_weight_data,
|
|
get_bmi_data,
|
|
get_profile_goal_weight_data,
|
|
get_profile_goal_bf_pct_data,
|
|
get_weight_trend_data,
|
|
get_body_composition_data,
|
|
get_circumference_summary_data
|
|
)
|
|
from data_layer.nutrition_metrics import (
|
|
get_nutrition_average_data,
|
|
get_nutrition_days_data,
|
|
get_protein_targets_data
|
|
)
|
|
from data_layer.activity_metrics import (
|
|
get_activity_summary_data,
|
|
get_activity_detail_data,
|
|
get_training_type_distribution_data,
|
|
get_training_frequency_by_type_data,
|
|
get_training_inter_session_gap_data,
|
|
get_training_sessions_recent_weeks_data,
|
|
)
|
|
from data_layer.recovery_metrics import (
|
|
get_sleep_duration_data,
|
|
get_sleep_quality_data,
|
|
get_rest_days_data
|
|
)
|
|
from data_layer.health_metrics import (
|
|
get_resting_heart_rate_data,
|
|
get_heart_rate_variability_data,
|
|
get_vo2_max_data
|
|
)
|
|
|
|
|
|
# ── Helper Functions ──────────────────────────────────────────────────────────
|
|
|
|
def get_profile_data(profile_id: str) -> Dict:
|
|
"""Load profile data for a user."""
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute("SELECT * FROM profiles WHERE id=%s", (profile_id,))
|
|
return r2d(cur.fetchone()) if cur.rowcount > 0 else {}
|
|
|
|
|
|
def get_latest_weight(profile_id: str) -> Optional[str]:
|
|
"""
|
|
Get latest weight entry.
|
|
|
|
Phase 0c: Refactored to use data_layer.body_metrics.get_latest_weight_data()
|
|
This function now only FORMATS the data for AI consumption.
|
|
"""
|
|
data = get_latest_weight_data(profile_id)
|
|
|
|
if data['confidence'] == 'insufficient':
|
|
return "nicht verfügbar"
|
|
|
|
return f"{data['weight']:.1f} kg"
|
|
|
|
|
|
def get_weight_trend(profile_id: str, days: int = 28) -> str:
|
|
"""
|
|
Calculate weight trend description.
|
|
|
|
Phase 0c: Refactored to use data_layer.body_metrics.get_weight_trend_data()
|
|
This function now only FORMATS the data for AI consumption.
|
|
"""
|
|
data = get_weight_trend_data(profile_id, days)
|
|
|
|
if data['confidence'] == 'insufficient':
|
|
return "nicht genug Daten"
|
|
|
|
direction = data['direction']
|
|
delta = data['delta']
|
|
|
|
if direction == "stable":
|
|
return "stabil"
|
|
elif direction == "increasing":
|
|
return f"steigend (+{delta:.1f} kg in {days} Tagen)"
|
|
else: # decreasing
|
|
return f"sinkend ({delta:.1f} kg in {days} Tagen)"
|
|
|
|
|
|
def get_latest_bf(profile_id: str) -> Optional[str]:
|
|
"""
|
|
Get latest body fat percentage from caliper.
|
|
|
|
Phase 0c: Refactored to use data_layer.body_metrics.get_body_composition_data()
|
|
This function now only FORMATS the data for AI consumption.
|
|
"""
|
|
data = get_body_composition_data(profile_id)
|
|
|
|
if data['confidence'] == 'insufficient':
|
|
return "nicht verfügbar"
|
|
|
|
return f"{data['body_fat_pct']:.1f}%"
|
|
|
|
|
|
def get_nutrition_avg(profile_id: str, field: str, days: int = 30) -> str:
|
|
"""
|
|
Calculate average nutrition value.
|
|
|
|
Phase 0c: Refactored to use data_layer.nutrition_metrics.get_nutrition_average_data()
|
|
This function now only FORMATS the data for AI consumption.
|
|
"""
|
|
data = get_nutrition_average_data(profile_id, days)
|
|
|
|
if data['confidence'] == 'insufficient':
|
|
return "nicht verfügbar"
|
|
|
|
# Map field names to data keys
|
|
field_map = {
|
|
'protein': 'protein_avg',
|
|
'fat': 'fat_avg',
|
|
'carb': 'carbs_avg',
|
|
'kcal': 'kcal_avg'
|
|
}
|
|
data_key = field_map.get(field, f'{field}_avg')
|
|
value = data.get(data_key, 0)
|
|
|
|
if field == 'kcal':
|
|
return f"{int(value)} kcal/Tag (Ø {days} Tage)"
|
|
else:
|
|
return f"{int(value)}g/Tag (Ø {days} Tage)"
|
|
|
|
|
|
def get_caliper_summary(profile_id: str) -> str:
|
|
"""
|
|
Get latest caliper measurements summary.
|
|
|
|
Phase 0c: Refactored to use data_layer.body_metrics.get_body_composition_data()
|
|
This function now only FORMATS the data for AI consumption.
|
|
"""
|
|
data = get_body_composition_data(profile_id)
|
|
|
|
if data['confidence'] == 'insufficient':
|
|
return "keine Caliper-Messungen"
|
|
|
|
method = data.get('method', 'unbekannt')
|
|
return f"{data['body_fat_pct']:.1f}% ({method} am {data['date']})"
|
|
|
|
|
|
def get_circ_summary(profile_id: str) -> str:
|
|
"""
|
|
Get latest circumference measurements summary with age annotations.
|
|
|
|
Phase 0c: Refactored to use data_layer.body_metrics.get_circumference_summary_data()
|
|
This function now only FORMATS the data for AI consumption.
|
|
"""
|
|
data = get_circumference_summary_data(profile_id)
|
|
|
|
if data['confidence'] == 'insufficient':
|
|
return "keine Umfangsmessungen"
|
|
|
|
parts = []
|
|
for measurement in data['measurements']:
|
|
age_days = measurement['age_days']
|
|
|
|
# Format age annotation
|
|
if age_days == 0:
|
|
age_str = "heute"
|
|
elif age_days == 1:
|
|
age_str = "gestern"
|
|
elif age_days <= 7:
|
|
age_str = f"vor {age_days} Tagen"
|
|
elif age_days <= 30:
|
|
weeks = age_days // 7
|
|
age_str = f"vor {weeks} Woche{'n' if weeks > 1 else ''}"
|
|
else:
|
|
months = age_days // 30
|
|
age_str = f"vor {months} Monat{'en' if months > 1 else ''}"
|
|
|
|
parts.append(f"{measurement['point']} {measurement['value']:.1f}cm ({age_str})")
|
|
|
|
return ', '.join(parts) if parts else "keine Umfangsmessungen"
|
|
|
|
|
|
def get_goal_weight(profile_id: str) -> str:
|
|
"""Zielgewicht aus profiles.goal_weight (Layer 1: get_profile_goal_weight_data)."""
|
|
data = get_profile_goal_weight_data(profile_id)
|
|
g = data.get("goal_weight_kg")
|
|
return f"{g:.1f}" if g is not None else "nicht gesetzt"
|
|
|
|
|
|
def get_goal_bf_pct(profile_id: str) -> str:
|
|
"""Ziel-KFA aus profiles.goal_bf_pct (Layer 1: get_profile_goal_bf_pct_data)."""
|
|
data = get_profile_goal_bf_pct_data(profile_id)
|
|
g = data.get("goal_bf_pct")
|
|
return f"{g:.1f}" if g is not None else "nicht gesetzt"
|
|
|
|
|
|
def get_nutrition_days(profile_id: str, days: int = 30) -> str:
|
|
"""
|
|
Get number of days with nutrition data.
|
|
|
|
Phase 0c: Refactored to use data_layer.nutrition_metrics.get_nutrition_days_data()
|
|
This function now only FORMATS the data for AI consumption.
|
|
"""
|
|
data = get_nutrition_days_data(profile_id, days)
|
|
return str(data['days_with_data'])
|
|
|
|
|
|
def get_protein_ziel_low(profile_id: str) -> str:
|
|
"""
|
|
Calculate lower protein target based on current weight (1.6g/kg).
|
|
|
|
Phase 0c: Refactored to use data_layer.nutrition_metrics.get_protein_targets_data()
|
|
This function now only FORMATS the data for AI consumption.
|
|
"""
|
|
data = get_protein_targets_data(profile_id)
|
|
|
|
if data['confidence'] == 'insufficient':
|
|
return "nicht verfügbar"
|
|
|
|
return f"{int(data['protein_target_low'])}"
|
|
|
|
|
|
def get_protein_ziel_high(profile_id: str) -> str:
|
|
"""
|
|
Calculate upper protein target based on current weight (2.2g/kg).
|
|
|
|
Phase 0c: Refactored to use data_layer.nutrition_metrics.get_protein_targets_data()
|
|
This function now only FORMATS the data for AI consumption.
|
|
"""
|
|
data = get_protein_targets_data(profile_id)
|
|
|
|
if data['confidence'] == 'insufficient':
|
|
return "nicht verfügbar"
|
|
|
|
return f"{int(data['protein_target_high'])}"
|
|
|
|
|
|
def get_activity_summary(profile_id: str, days: int = 14) -> str:
|
|
"""
|
|
Get activity summary for recent period.
|
|
|
|
Phase 0c: Refactored to use data_layer.activity_metrics.get_activity_summary_data()
|
|
This function now only FORMATS the data for AI consumption.
|
|
"""
|
|
data = get_activity_summary_data(profile_id, days)
|
|
|
|
if data['confidence'] == 'insufficient':
|
|
return f"Keine Aktivitäten in den letzten {days} Tagen"
|
|
|
|
return f"{data['activity_count']} Einheiten in {days} Tagen (Ø {data['avg_duration_min']} min/Einheit, {data['total_kcal']} kcal gesamt)"
|
|
|
|
|
|
def calculate_age(dob) -> str:
|
|
"""Calculate age from date of birth (accepts date object or string)."""
|
|
if not dob:
|
|
return "unbekannt"
|
|
try:
|
|
# Handle both datetime.date objects and strings
|
|
if isinstance(dob, str):
|
|
birth = datetime.strptime(dob, '%Y-%m-%d').date()
|
|
else:
|
|
birth = dob # Already a date object from PostgreSQL
|
|
|
|
today = datetime.now().date()
|
|
age = today.year - birth.year - ((today.month, today.day) < (birth.month, birth.day))
|
|
return str(age)
|
|
except Exception as e:
|
|
return "unbekannt"
|
|
|
|
|
|
def get_profile_name(profile_id: str) -> str:
|
|
"""Profil-Platzhalter: Anzeigename (profiles.name)."""
|
|
return get_profile_data(profile_id).get('name', 'Nutzer')
|
|
|
|
|
|
def get_profile_age_display(profile_id: str) -> str:
|
|
"""Profil-Platzhalter: Alter aus Geburtsdatum."""
|
|
return calculate_age(get_profile_data(profile_id).get('dob'))
|
|
|
|
|
|
def get_profile_height_display(profile_id: str) -> str:
|
|
"""Profil-Platzhalter: Körpergröße (cm) als String."""
|
|
return str(get_profile_data(profile_id).get('height', 'unbekannt'))
|
|
|
|
|
|
def get_profile_geschlecht_display(profile_id: str) -> str:
|
|
"""Profil-Platzhalter: Geschlecht aus profiles.sex (m/w)."""
|
|
return 'männlich' if get_profile_data(profile_id).get('sex') == 'm' else 'weiblich'
|
|
|
|
|
|
def get_datum_heute(_profile_id: str) -> str:
|
|
"""Zeitraum-Platzhalter: heutiges Datum (dd.mm.yyyy)."""
|
|
return datetime.now().strftime('%d.%m.%Y')
|
|
|
|
|
|
def get_zeitraum_label_7d(_profile_id: str) -> str:
|
|
return 'letzte 7 Tage'
|
|
|
|
|
|
def get_zeitraum_label_30d(_profile_id: str) -> str:
|
|
return 'letzte 30 Tage'
|
|
|
|
|
|
def get_zeitraum_label_90d(_profile_id: str) -> str:
|
|
return 'letzte 90 Tage'
|
|
|
|
|
|
def get_activity_detail(profile_id: str, days: int = 14) -> str:
|
|
"""
|
|
Get detailed activity log for analysis.
|
|
|
|
Phase 0c: Refactored to use data_layer.activity_metrics.get_activity_detail_data()
|
|
This function now only FORMATS the data for AI consumption.
|
|
"""
|
|
data = get_activity_detail_data(profile_id, days)
|
|
|
|
if data['confidence'] == 'insufficient':
|
|
return f"Keine Aktivitäten in den letzten {days} Tagen"
|
|
|
|
# Format as readable list (max 20 entries to avoid token bloat)
|
|
lines = []
|
|
for activity in data['activities'][:20]:
|
|
hr_str = f" HF={activity['hr_avg']}" if activity['hr_avg'] else ""
|
|
lines.append(
|
|
f"{activity['date']}: {activity['activity_type']} "
|
|
f"({activity['duration_min']}min, {activity['kcal_active']}kcal{hr_str})"
|
|
)
|
|
|
|
return '\n'.join(lines)
|
|
|
|
|
|
def get_trainingstyp_verteilung(profile_id: str, days: int = 14) -> str:
|
|
"""
|
|
Get training type distribution.
|
|
|
|
Phase 0c: Refactored to use data_layer.activity_metrics.get_training_type_distribution_data()
|
|
This function now only FORMATS the data for AI consumption.
|
|
"""
|
|
data = get_training_type_distribution_data(profile_id, days)
|
|
|
|
if data['confidence'] == 'insufficient' or not data['distribution']:
|
|
return "Keine kategorisierten Trainings"
|
|
|
|
# Format top 3 categories with percentages
|
|
parts = [
|
|
f"{dist['category']}: {int(dist['percentage'])}%"
|
|
for dist in data['distribution'][:3]
|
|
]
|
|
return ", ".join(parts)
|
|
|
|
|
|
def get_training_frequency_by_type_md(profile_id: str, days: int = 28) -> str:
|
|
"""
|
|
Markdown-Tabelle: pro Trainingsart (Roh-Label) Ø Sessions/Woche, Dauer, kcal, HF, RPE, kcal/min.
|
|
"""
|
|
data = get_training_frequency_by_type_data(profile_id, days)
|
|
if data["confidence"] == "insufficient" or not data["by_type"]:
|
|
return f"Keine Trainingsdaten in den letzten {days} Tagen."
|
|
|
|
def _f(x, nd=1):
|
|
if x is None:
|
|
return "—"
|
|
if isinstance(x, float):
|
|
return f"{x:.{nd}f}"
|
|
return str(x)
|
|
|
|
lines = [
|
|
f"**Trainings-Häufigkeit & Intensität** (letzte {days} Tage, nach `activity_type`)",
|
|
"",
|
|
"| Art | n | Ø/Woche | Ø min | Ø kcal | Ø HF | HF max | Ø RPE | kcal/min |",
|
|
"|-----|--:|--------:|------:|-------:|-----:|-------:|------:|---------:|",
|
|
]
|
|
for x in data["by_type"]:
|
|
lines.append(
|
|
"| {name} | {n} | {pw} | {dm} | {kc} | {ha} | {hm} | {rp} | {kpm} |".format(
|
|
name=str(x["activity_type"]).replace("|", "/"),
|
|
n=x["session_count"],
|
|
pw=_f(x["sessions_per_week"], 2),
|
|
dm=_f(x["avg_duration_min"], 1),
|
|
kc=_f(x["avg_kcal_active"], 0),
|
|
ha=_f(x["avg_hr_avg"], 0),
|
|
hm=_f(x["avg_hr_max"], 0),
|
|
rp=_f(x["avg_rpe"], 1),
|
|
kpm=_f(x["avg_kcal_per_min"], 2),
|
|
)
|
|
)
|
|
lines.append("")
|
|
lines.append(
|
|
"_Intensität: kcal/min nur bei gesetzter Dauer & kcal; HF aus Import/Gerät; RPE optional._"
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
|
|
def get_training_inter_session_gap_md(profile_id: str, days: int = 28) -> str:
|
|
"""Kurztext: median/mittlere Stunden zwischen aufeinanderfolgenden Einheiten."""
|
|
d = get_training_inter_session_gap_data(profile_id, days)
|
|
if d["confidence"] == "insufficient" or d.get("gaps_count", 0) < 1:
|
|
return "Zu wenige Trainings für eine Pausen-Analyse (mindestens 2 Einheiten im Zeitraum)."
|
|
return (
|
|
f"**Pause zwischen Trainings** (letzte {days} Tage): Median **{d['gap_hours_median']} h**, "
|
|
f"Mittel **{d['gap_hours_mean']} h**, kürzeste Lücke **{d['gap_hours_min']} h** "
|
|
f"({d['gaps_count']} Intervalle). "
|
|
"Sortierung nach Datum/Uhrzeit (fehlende Uhrzeit → 12:00)."
|
|
)
|
|
|
|
|
|
def get_sleep_avg_duration(profile_id: str, days: int = 7) -> str:
|
|
"""
|
|
Calculate average sleep duration in hours.
|
|
|
|
Phase 0c: Refactored to use data_layer.recovery_metrics.get_sleep_duration_data()
|
|
This function now only FORMATS the data for AI consumption.
|
|
"""
|
|
data = get_sleep_duration_data(profile_id, days)
|
|
|
|
if data['confidence'] == 'insufficient':
|
|
return "nicht verfügbar"
|
|
|
|
return f"{data['avg_duration_hours']:.1f}h"
|
|
|
|
|
|
def get_sleep_avg_quality(profile_id: str, days: int = 7) -> str:
|
|
"""
|
|
Calculate average sleep quality (Deep+REM %).
|
|
|
|
Phase 0c: Refactored to use data_layer.recovery_metrics.get_sleep_quality_data()
|
|
This function now only FORMATS the data for AI consumption.
|
|
"""
|
|
data = get_sleep_quality_data(profile_id, days)
|
|
|
|
if data['confidence'] == 'insufficient':
|
|
return "nicht verfügbar"
|
|
|
|
return f"{data['quality_score']:.0f}% (Deep+REM)"
|
|
|
|
|
|
def get_rest_days_count(profile_id: str, days: int = 30) -> str:
|
|
"""
|
|
Count rest days in the given period.
|
|
|
|
Phase 0c: Refactored to use data_layer.recovery_metrics.get_rest_days_data()
|
|
This function now only FORMATS the data for AI consumption.
|
|
"""
|
|
data = get_rest_days_data(profile_id, days)
|
|
return f"{data['total_rest_days']} Ruhetage"
|
|
|
|
|
|
def get_vitals_avg_hr(profile_id: str, days: int = 7) -> str:
|
|
"""
|
|
Calculate average resting heart rate.
|
|
|
|
Phase 0c: Refactored to use data_layer.health_metrics.get_resting_heart_rate_data()
|
|
This function now only FORMATS the data for AI consumption.
|
|
"""
|
|
data = get_resting_heart_rate_data(profile_id, days)
|
|
|
|
if data['confidence'] == 'insufficient':
|
|
return "nicht verfügbar"
|
|
|
|
return f"{data['avg_rhr']} bpm"
|
|
|
|
|
|
def get_vitals_avg_hrv(profile_id: str, days: int = 7) -> str:
|
|
"""
|
|
Calculate average heart rate variability.
|
|
|
|
Phase 0c: Refactored to use data_layer.health_metrics.get_heart_rate_variability_data()
|
|
This function now only FORMATS the data for AI consumption.
|
|
"""
|
|
data = get_heart_rate_variability_data(profile_id, days)
|
|
|
|
if data['confidence'] == 'insufficient':
|
|
return "nicht verfügbar"
|
|
|
|
return f"{data['avg_hrv']} ms"
|
|
|
|
|
|
def get_vitals_vo2_max(profile_id: str) -> str:
|
|
"""
|
|
Get latest VO2 Max value.
|
|
|
|
Phase 0c: Refactored to use data_layer.health_metrics.get_vo2_max_data()
|
|
This function now only FORMATS the data for AI consumption.
|
|
"""
|
|
data = get_vo2_max_data(profile_id)
|
|
|
|
if data['confidence'] == 'insufficient':
|
|
return "nicht verfügbar"
|
|
|
|
return f"{data['vo2_max']:.1f} ml/kg/min"
|
|
|
|
|
|
# ── 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'
|
|
"""
|
|
import traceback
|
|
try:
|
|
# Import calculations dynamically to avoid circular imports
|
|
from data_layer import body_metrics, nutrition_metrics, activity_metrics, recovery_metrics, scores
|
|
from data_layer import correlations as 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:
|
|
print(f"[ERROR] _safe_int({func_name}, {profile_id}): {type(e).__name__}: {e}")
|
|
traceback.print_exc()
|
|
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'
|
|
"""
|
|
import traceback
|
|
try:
|
|
from data_layer 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:
|
|
print(f"[ERROR] _safe_float({func_name}, {profile_id}): {type(e).__name__}: {e}")
|
|
traceback.print_exc()
|
|
return 'nicht verfügbar'
|
|
|
|
|
|
def _safe_str(func_name: str, profile_id: str) -> str:
|
|
"""
|
|
Safely call calculation function and return string value or fallback.
|
|
"""
|
|
import traceback
|
|
try:
|
|
from data_layer import body_metrics, nutrition_metrics, activity_metrics, scores
|
|
from data_layer import correlations as correlation_metrics
|
|
|
|
func_map = {
|
|
'top_goal_name': lambda pid: (scores.get_top_priority_goal(pid).get('name') or scores.get_top_priority_goal(pid).get('goal_type')) 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:
|
|
print(f"[ERROR] _safe_str({func_name}, {profile_id}): {type(e).__name__}: {e}")
|
|
traceback.print_exc()
|
|
return 'nicht verfügbar'
|
|
|
|
|
|
def _safe_json(func_name: str, profile_id: str) -> str:
|
|
"""
|
|
Safely call calculation function and return JSON string or fallback.
|
|
"""
|
|
import traceback
|
|
try:
|
|
import json
|
|
from data_layer import scores
|
|
from data_layer import correlations as correlation_metrics
|
|
|
|
func_map = {
|
|
'training_sessions_recent_json': get_training_sessions_recent_weeks_data,
|
|
'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, default=str)
|
|
except Exception as e:
|
|
print(f"[ERROR] _safe_json({func_name}, {profile_id}): {type(e).__name__}: {e}")
|
|
traceback.print_exc()
|
|
return '{}'
|
|
|
|
|
|
def _get_active_goals_json(profile_id: str) -> str:
|
|
"""Get active goals as JSON string"""
|
|
import json
|
|
try:
|
|
from goal_utils import get_active_goals
|
|
goals = get_active_goals(profile_id)
|
|
return json.dumps(goals, default=str)
|
|
except Exception:
|
|
return '[]'
|
|
|
|
|
|
def _get_focus_areas_weighted_json(profile_id: str) -> str:
|
|
"""Get focus areas with weights as JSON string"""
|
|
import json
|
|
try:
|
|
from calculations.scores import get_user_focus_weights
|
|
from goal_utils import get_db, get_cursor
|
|
|
|
weights = get_user_focus_weights(profile_id)
|
|
|
|
# Get focus area details
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cur.execute("""
|
|
SELECT key, name_de, name_en, category
|
|
FROM focus_area_definitions
|
|
WHERE is_active = true
|
|
""")
|
|
definitions = {row['key']: row for row in cur.fetchall()}
|
|
|
|
# Build weighted list
|
|
result = []
|
|
for area_key, weight in weights.items():
|
|
if weight > 0 and area_key in definitions:
|
|
area = definitions[area_key]
|
|
result.append({
|
|
'key': area_key,
|
|
'name': area['name_de'],
|
|
'category': area['category'],
|
|
'weight': weight
|
|
})
|
|
|
|
# Sort by weight descending
|
|
result.sort(key=lambda x: x['weight'], reverse=True)
|
|
|
|
return json.dumps(result, default=str)
|
|
except Exception:
|
|
return '[]'
|
|
|
|
|
|
def _format_goals_as_markdown(profile_id: str) -> str:
|
|
"""Format goals as markdown table"""
|
|
try:
|
|
from goal_utils import get_active_goals
|
|
|
|
goals = get_active_goals(profile_id)
|
|
if not goals:
|
|
return 'Keine Ziele definiert'
|
|
|
|
# Build markdown table
|
|
lines = ['| Ziel | Aktuell | Ziel | Progress | Status |', '|------|---------|------|----------|--------|']
|
|
|
|
for goal in goals:
|
|
name = goal.get('name') or goal.get('goal_type', 'Unbekannt')
|
|
current = goal.get('current_value')
|
|
target = goal.get('target_value')
|
|
start = goal.get('start_value')
|
|
|
|
# Calculate progress if possible
|
|
progress_str = '-'
|
|
if None not in [current, target, start]:
|
|
try:
|
|
current_f = float(current)
|
|
target_f = float(target)
|
|
start_f = float(start)
|
|
|
|
if target_f == start_f:
|
|
progress_pct = 100 if current_f == target_f else 0
|
|
else:
|
|
progress_pct = ((current_f - start_f) / (target_f - start_f)) * 100
|
|
progress_pct = max(0, min(100, progress_pct))
|
|
|
|
progress_str = f"{int(progress_pct)}%"
|
|
except (ValueError, ZeroDivisionError):
|
|
progress_str = '-'
|
|
|
|
current_str = f"{current}" if current is not None else '-'
|
|
target_str = f"{target}" if target is not None else '-'
|
|
status = '🎯' if goal.get('is_primary') else '○'
|
|
|
|
lines.append(f"| {name} | {current_str} | {target_str} | {progress_str} | {status} |")
|
|
|
|
return '\n'.join(lines)
|
|
except Exception:
|
|
return 'Keine Ziele definiert'
|
|
|
|
|
|
def _format_focus_areas_as_markdown(profile_id: str) -> str:
|
|
"""Format focus areas as markdown"""
|
|
try:
|
|
import json
|
|
weighted_json = _get_focus_areas_weighted_json(profile_id)
|
|
areas = json.loads(weighted_json)
|
|
|
|
if not areas:
|
|
return 'Keine Focus Areas aktiv'
|
|
|
|
# Build markdown list
|
|
lines = []
|
|
for area in areas:
|
|
name = area.get('name', 'Unbekannt')
|
|
weight = area.get('weight', 0)
|
|
lines.append(f"- **{name}**: {weight}%")
|
|
|
|
return '\n'.join(lines)
|
|
except Exception:
|
|
return 'Keine Focus Areas aktiv'
|
|
|
|
|
|
def _format_top_focus_areas(profile_id: str, n: int = 3) -> str:
|
|
"""Format top N focus areas as text"""
|
|
try:
|
|
import json
|
|
weighted_json = _get_focus_areas_weighted_json(profile_id)
|
|
areas = json.loads(weighted_json)
|
|
|
|
if not areas:
|
|
return 'Keine Focus Areas definiert'
|
|
|
|
# Sort by weight descending and take top N
|
|
sorted_areas = sorted(areas, key=lambda x: x.get('weight', 0), reverse=True)[:n]
|
|
|
|
lines = []
|
|
for i, area in enumerate(sorted_areas, 1):
|
|
name = area.get('name', 'Unbekannt')
|
|
weight = area.get('weight', 0)
|
|
lines.append(f"{i}. {name} ({weight}%)")
|
|
|
|
return ', '.join(lines)
|
|
except Exception:
|
|
return 'nicht verfügbar'
|
|
|
|
|
|
def _format_goals_behind(profile_id: str, n: int = 3) -> str:
|
|
"""
|
|
Format top N goals behind schedule (based on time deviation).
|
|
|
|
Compares actual progress vs. expected progress based on elapsed time.
|
|
Negative deviation = behind schedule.
|
|
"""
|
|
try:
|
|
from goal_utils import get_active_goals
|
|
from datetime import date
|
|
|
|
goals = get_active_goals(profile_id)
|
|
|
|
if not goals:
|
|
return 'Keine Ziele definiert'
|
|
|
|
today = date.today()
|
|
goals_with_deviation = []
|
|
|
|
|
|
for g in goals:
|
|
goal_name = g.get('name') or g.get('goal_type', 'Unknown')
|
|
current = g.get('current_value')
|
|
target = g.get('target_value')
|
|
start = g.get('start_value')
|
|
start_date = g.get('start_date')
|
|
target_date = g.get('target_date')
|
|
|
|
|
|
# Skip if missing required values
|
|
if None in [current, target, start]:
|
|
continue
|
|
|
|
# Skip if no target_date (can't calculate time-based progress)
|
|
if not target_date:
|
|
continue
|
|
|
|
try:
|
|
current = float(current)
|
|
target = float(target)
|
|
start = float(start)
|
|
|
|
# Calculate actual progress percentage
|
|
if target == start:
|
|
actual_progress_pct = 100 if current == target else 0
|
|
else:
|
|
actual_progress_pct = ((current - start) / (target - start)) * 100
|
|
actual_progress_pct = max(0, min(100, actual_progress_pct))
|
|
|
|
# Calculate expected progress based on time
|
|
if start_date:
|
|
# Use start_date if available
|
|
start_dt = start_date if isinstance(start_date, date) else date.fromisoformat(str(start_date))
|
|
else:
|
|
# Fallback: assume start date = created_at date
|
|
created_at = g.get('created_at')
|
|
if created_at:
|
|
start_dt = date.fromisoformat(str(created_at).split('T')[0])
|
|
else:
|
|
continue # Can't calculate without start date
|
|
|
|
target_dt = target_date if isinstance(target_date, date) else date.fromisoformat(str(target_date))
|
|
|
|
# Calculate time progress
|
|
total_days = (target_dt - start_dt).days
|
|
elapsed_days = (today - start_dt).days
|
|
|
|
if total_days <= 0:
|
|
continue # Invalid date range
|
|
|
|
expected_progress_pct = (elapsed_days / total_days) * 100
|
|
expected_progress_pct = max(0, min(100, expected_progress_pct))
|
|
|
|
# Calculate deviation (negative = behind schedule)
|
|
deviation = actual_progress_pct - expected_progress_pct
|
|
|
|
g['_actual_progress'] = int(actual_progress_pct)
|
|
g['_expected_progress'] = int(expected_progress_pct)
|
|
g['_deviation'] = int(deviation)
|
|
goals_with_deviation.append(g)
|
|
|
|
except (ValueError, ZeroDivisionError, TypeError) as e:
|
|
continue
|
|
|
|
# Also process goals WITHOUT target_date (simple progress)
|
|
goals_without_date = []
|
|
|
|
for g in goals:
|
|
if g.get('target_date'):
|
|
continue # Already processed above
|
|
|
|
goal_name = g.get('name') or g.get('goal_type', 'Unknown')
|
|
current = g.get('current_value')
|
|
target = g.get('target_value')
|
|
start = g.get('start_value')
|
|
|
|
if None in [current, target, start]:
|
|
continue
|
|
|
|
try:
|
|
current = float(current)
|
|
target = float(target)
|
|
start = float(start)
|
|
|
|
if target == start:
|
|
progress_pct = 100 if current == target else 0
|
|
else:
|
|
progress_pct = ((current - start) / (target - start)) * 100
|
|
progress_pct = max(0, min(100, progress_pct))
|
|
|
|
g['_simple_progress'] = int(progress_pct)
|
|
goals_without_date.append(g)
|
|
except (ValueError, ZeroDivisionError, TypeError) as e:
|
|
continue
|
|
|
|
# Combine: Goals with negative deviation + Goals without date with low progress
|
|
behind_with_date = [g for g in goals_with_deviation if g['_deviation'] < 0]
|
|
behind_without_date = [g for g in goals_without_date if g['_simple_progress'] < 50]
|
|
|
|
|
|
# Create combined list with sort keys
|
|
combined = []
|
|
for g in behind_with_date:
|
|
combined.append({
|
|
'goal': g,
|
|
'sort_key': g['_deviation'], # Negative deviation (worst first)
|
|
'has_date': True
|
|
})
|
|
for g in behind_without_date:
|
|
# Map progress to deviation-like scale: 0% = -100, 50% = -50
|
|
combined.append({
|
|
'goal': g,
|
|
'sort_key': g['_simple_progress'] - 100, # Convert to negative scale
|
|
'has_date': False
|
|
})
|
|
|
|
if not combined:
|
|
return 'Alle Ziele im Zeitplan oder erreicht'
|
|
|
|
# Sort by sort_key (most negative first)
|
|
sorted_combined = sorted(combined, key=lambda x: x['sort_key'])[:n]
|
|
|
|
lines = []
|
|
for item in sorted_combined:
|
|
g = item['goal']
|
|
name = g.get('name') or g.get('goal_type', 'Unbekannt')
|
|
|
|
if item['has_date']:
|
|
actual = g['_actual_progress']
|
|
expected = g['_expected_progress']
|
|
deviation = g['_deviation']
|
|
lines.append(f"{name} ({actual}% statt {expected}%, {deviation}%)")
|
|
else:
|
|
progress = g['_simple_progress']
|
|
lines.append(f"{name} ({progress}% erreicht)")
|
|
|
|
return ', '.join(lines)
|
|
except Exception as e:
|
|
print(f"[ERROR] _format_goals_behind: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
return 'nicht verfügbar'
|
|
|
|
|
|
def _format_goals_on_track(profile_id: str, n: int = 3) -> str:
|
|
"""
|
|
Format top N goals ahead of schedule (based on time deviation).
|
|
|
|
Compares actual progress vs. expected progress based on elapsed time.
|
|
Positive deviation = ahead of schedule / on track.
|
|
"""
|
|
try:
|
|
from goal_utils import get_active_goals
|
|
from datetime import date
|
|
|
|
goals = get_active_goals(profile_id)
|
|
|
|
if not goals:
|
|
return 'Keine Ziele definiert'
|
|
|
|
today = date.today()
|
|
goals_with_deviation = []
|
|
|
|
|
|
for g in goals:
|
|
goal_name = g.get('name') or g.get('goal_type', 'Unknown')
|
|
current = g.get('current_value')
|
|
target = g.get('target_value')
|
|
start = g.get('start_value')
|
|
start_date = g.get('start_date')
|
|
target_date = g.get('target_date')
|
|
|
|
|
|
# Skip if missing required values
|
|
if None in [current, target, start]:
|
|
continue
|
|
|
|
# Skip if no target_date
|
|
if not target_date:
|
|
continue
|
|
|
|
try:
|
|
current = float(current)
|
|
target = float(target)
|
|
start = float(start)
|
|
|
|
# Calculate actual progress percentage
|
|
if target == start:
|
|
actual_progress_pct = 100 if current == target else 0
|
|
else:
|
|
actual_progress_pct = ((current - start) / (target - start)) * 100
|
|
actual_progress_pct = max(0, min(100, actual_progress_pct))
|
|
|
|
# Calculate expected progress based on time
|
|
if start_date:
|
|
start_dt = start_date if isinstance(start_date, date) else date.fromisoformat(str(start_date))
|
|
else:
|
|
created_at = g.get('created_at')
|
|
if created_at:
|
|
start_dt = date.fromisoformat(str(created_at).split('T')[0])
|
|
else:
|
|
continue
|
|
|
|
target_dt = target_date if isinstance(target_date, date) else date.fromisoformat(str(target_date))
|
|
|
|
# Calculate time progress
|
|
total_days = (target_dt - start_dt).days
|
|
elapsed_days = (today - start_dt).days
|
|
|
|
if total_days <= 0:
|
|
continue
|
|
|
|
expected_progress_pct = (elapsed_days / total_days) * 100
|
|
expected_progress_pct = max(0, min(100, expected_progress_pct))
|
|
|
|
# Calculate deviation (positive = ahead of schedule)
|
|
deviation = actual_progress_pct - expected_progress_pct
|
|
|
|
g['_actual_progress'] = int(actual_progress_pct)
|
|
g['_expected_progress'] = int(expected_progress_pct)
|
|
g['_deviation'] = int(deviation)
|
|
goals_with_deviation.append(g)
|
|
|
|
except (ValueError, ZeroDivisionError, TypeError) as e:
|
|
continue
|
|
|
|
# Also process goals WITHOUT target_date (simple progress)
|
|
goals_without_date = []
|
|
|
|
for g in goals:
|
|
if g.get('target_date'):
|
|
continue # Already processed above
|
|
|
|
goal_name = g.get('name') or g.get('goal_type', 'Unknown')
|
|
current = g.get('current_value')
|
|
target = g.get('target_value')
|
|
start = g.get('start_value')
|
|
|
|
if None in [current, target, start]:
|
|
continue
|
|
|
|
try:
|
|
current = float(current)
|
|
target = float(target)
|
|
start = float(start)
|
|
|
|
if target == start:
|
|
progress_pct = 100 if current == target else 0
|
|
else:
|
|
progress_pct = ((current - start) / (target - start)) * 100
|
|
progress_pct = max(0, min(100, progress_pct))
|
|
|
|
g['_simple_progress'] = int(progress_pct)
|
|
goals_without_date.append(g)
|
|
except (ValueError, ZeroDivisionError, TypeError) as e:
|
|
continue
|
|
|
|
# Combine: Goals with positive deviation + Goals without date with high progress
|
|
ahead_with_date = [g for g in goals_with_deviation if g['_deviation'] >= 0]
|
|
ahead_without_date = [g for g in goals_without_date if g['_simple_progress'] >= 50]
|
|
|
|
|
|
# Create combined list with sort keys
|
|
combined = []
|
|
for g in ahead_with_date:
|
|
combined.append({
|
|
'goal': g,
|
|
'sort_key': g['_deviation'], # Positive deviation (best first)
|
|
'has_date': True
|
|
})
|
|
for g in ahead_without_date:
|
|
# Map progress to deviation-like scale: 50% = 0, 100% = +50
|
|
combined.append({
|
|
'goal': g,
|
|
'sort_key': g['_simple_progress'] - 50, # Convert to positive scale
|
|
'has_date': False
|
|
})
|
|
|
|
if not combined:
|
|
return 'Keine Ziele im Zeitplan'
|
|
|
|
# Sort by sort_key descending (most positive first)
|
|
sorted_combined = sorted(combined, key=lambda x: x['sort_key'], reverse=True)[:n]
|
|
|
|
lines = []
|
|
for item in sorted_combined:
|
|
g = item['goal']
|
|
name = g.get('name') or g.get('goal_type', 'Unbekannt')
|
|
|
|
if item['has_date']:
|
|
actual = g['_actual_progress']
|
|
deviation = g['_deviation']
|
|
lines.append(f"{name} ({actual}%, +{deviation}% voraus)")
|
|
else:
|
|
progress = g['_simple_progress']
|
|
lines.append(f"{name} ({progress}% erreicht)")
|
|
|
|
return ', '.join(lines)
|
|
except Exception as e:
|
|
print(f"[ERROR] _format_goals_on_track: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
return 'nicht verfügbar'
|
|
|
|
|
|
# ── Placeholder Registry ──────────────────────────────────────────────────────
|
|
|
|
PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = {
|
|
# Profil
|
|
'{{name}}': get_profile_name,
|
|
'{{age}}': get_profile_age_display,
|
|
'{{height}}': get_profile_height_display,
|
|
'{{geschlecht}}': get_profile_geschlecht_display,
|
|
|
|
# Körper (21 Registry-Keys: body_metrics + body_extras — alles hier gebündelt)
|
|
'{{weight_aktuell}}': get_latest_weight,
|
|
'{{weight_trend}}': get_weight_trend,
|
|
'{{kf_aktuell}}': get_latest_bf,
|
|
'{{bmi}}': lambda pid: calculate_bmi(pid),
|
|
'{{caliper_summary}}': get_caliper_summary,
|
|
'{{circ_summary}}': get_circ_summary,
|
|
'{{goal_weight}}': get_goal_weight,
|
|
'{{goal_bf_pct}}': get_goal_bf_pct,
|
|
'{{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),
|
|
'{{body_progress_score}}': lambda pid: _safe_int('body_progress_score', pid),
|
|
|
|
# Ernährung (15 Registry-Keys — gebündelt; nutrition_score siehe hier, nicht unter Meta Scores)
|
|
'{{kcal_avg}}': lambda pid: get_nutrition_avg(pid, 'kcal', 30),
|
|
'{{protein_avg}}': lambda pid: get_nutrition_avg(pid, 'protein', 30),
|
|
'{{carb_avg}}': lambda pid: get_nutrition_avg(pid, 'carb', 30),
|
|
'{{fat_avg}}': lambda pid: get_nutrition_avg(pid, 'fat', 30),
|
|
'{{nutrition_days}}': lambda pid: get_nutrition_days(pid, 30),
|
|
'{{protein_ziel_low}}': get_protein_ziel_low,
|
|
'{{protein_ziel_high}}': get_protein_ziel_high,
|
|
'{{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),
|
|
'{{nutrition_score}}': lambda pid: _safe_int('nutrition_score', pid),
|
|
|
|
# Training / Aktivität (17 Registry-Keys — gebündelt; activity_score hier, nicht unter Meta Scores)
|
|
'{{activity_summary}}': get_activity_summary,
|
|
'{{activity_detail}}': get_activity_detail,
|
|
'{{trainingstyp_verteilung}}': get_trainingstyp_verteilung,
|
|
'{{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),
|
|
'{{activity_score}}': lambda pid: _safe_int('activity_score', pid),
|
|
'{{training_frequency_by_type_md}}': get_training_frequency_by_type_md,
|
|
'{{training_inter_session_gap_md}}': get_training_inter_session_gap_md,
|
|
'{{training_sessions_recent_json}}': lambda pid: _safe_json('training_sessions_recent_json', pid),
|
|
|
|
# Schlaf & Erholung (10 Registry-Keys; recovery_score hier, nicht unter Meta Scores)
|
|
'{{sleep_avg_duration}}': lambda pid: get_sleep_avg_duration(pid, 7),
|
|
'{{sleep_avg_quality}}': lambda pid: get_sleep_avg_quality(pid, 7),
|
|
'{{rest_days_count}}': lambda pid: get_rest_days_count(pid, 30),
|
|
'{{recovery_score}}': lambda pid: _safe_int('recovery_score_v2', 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_sleep_recovery}}': lambda pid: _safe_json('correlation_sleep_recovery', pid),
|
|
|
|
# Vitalwerte (5 Registry-Keys: Mittelwerte + vs. Baseline)
|
|
'{{vitals_avg_hr}}': lambda pid: get_vitals_avg_hr(pid, 7),
|
|
'{{vitals_avg_hrv}}': lambda pid: get_vitals_avg_hrv(pid, 7),
|
|
'{{vitals_vo2_max}}': get_vitals_vo2_max,
|
|
'{{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),
|
|
|
|
# Zeitraum
|
|
'{{datum_heute}}': get_datum_heute,
|
|
'{{zeitraum_7d}}': get_zeitraum_label_7d,
|
|
'{{zeitraum_30d}}': get_zeitraum_label_30d,
|
|
'{{zeitraum_90d}}': get_zeitraum_label_90d,
|
|
|
|
# ========================================================================
|
|
# PHASE 0b: Goal-Aware Placeholders (Dynamic Focus Areas v2.0)
|
|
# ========================================================================
|
|
|
|
# --- Meta Scores (Ebene 1; recovery_score → Schlaf & Erholung) ---
|
|
'{{goal_progress_score}}': lambda pid: _safe_int('goal_progress_score', 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_int('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),
|
|
|
|
# --- 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),
|
|
'{{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),
|
|
}
|
|
|
|
|
|
def calculate_bmi(profile_id: str) -> str:
|
|
"""BMI für Prompts; Berechnung in data_layer.body_metrics.get_bmi_data."""
|
|
data = get_bmi_data(profile_id)
|
|
bmi = data.get("bmi")
|
|
if bmi is None:
|
|
return "nicht verfügbar"
|
|
return f"{bmi:.1f}"
|
|
|
|
|
|
# ── Public API ────────────────────────────────────────────────────────────────
|
|
|
|
def resolve_placeholders(template: str, profile_id: str) -> str:
|
|
"""
|
|
Replace all {{placeholders}} in template with actual user data.
|
|
|
|
Args:
|
|
template: Prompt template with placeholders
|
|
profile_id: User profile ID
|
|
|
|
Returns:
|
|
Resolved template with placeholders replaced by values
|
|
"""
|
|
result = template
|
|
|
|
for placeholder, resolver in PLACEHOLDER_MAP.items():
|
|
if placeholder in result:
|
|
try:
|
|
value = resolver(profile_id)
|
|
result = result.replace(placeholder, str(value))
|
|
except Exception as e:
|
|
# On error, replace with error message
|
|
result = result.replace(placeholder, f"[Fehler: {placeholder}]")
|
|
|
|
return result
|
|
|
|
|
|
def get_unknown_placeholders(template: str) -> List[str]:
|
|
"""
|
|
Find all placeholders in template that are not in PLACEHOLDER_MAP.
|
|
|
|
Args:
|
|
template: Prompt template
|
|
|
|
Returns:
|
|
List of unknown placeholder names (without {{}})
|
|
"""
|
|
# Find all {{...}} patterns
|
|
found = re.findall(r'\{\{(\w+)\}\}', template)
|
|
|
|
# Filter to only unknown ones
|
|
known_names = {p.strip('{}') for p in PLACEHOLDER_MAP.keys()}
|
|
unknown = [p for p in found if p not in known_names]
|
|
|
|
return list(set(unknown)) # Remove duplicates
|
|
|
|
|
|
def get_available_placeholders(categories: Optional[List[str]] = None) -> Dict[str, List[str]]:
|
|
"""
|
|
Get available placeholders, optionally filtered by categories.
|
|
|
|
Args:
|
|
categories: Optional list of categories to filter (körper, ernährung, training, etc.)
|
|
|
|
Returns:
|
|
Dict mapping category to list of placeholders
|
|
"""
|
|
placeholder_categories = {
|
|
'profil': [
|
|
'{{name}}', '{{age}}', '{{height}}', '{{geschlecht}}'
|
|
],
|
|
'körper': [
|
|
'{{weight_aktuell}}', '{{weight_trend}}', '{{kf_aktuell}}', '{{bmi}}',
|
|
'{{caliper_summary}}', '{{circ_summary}}',
|
|
'{{goal_weight}}', '{{goal_bf_pct}}',
|
|
'{{weight_7d_median}}', '{{weight_28d_slope}}', '{{weight_90d_slope}}',
|
|
'{{fm_28d_change}}', '{{lbm_28d_change}}',
|
|
'{{waist_28d_delta}}', '{{hip_28d_delta}}', '{{chest_28d_delta}}',
|
|
'{{arm_28d_delta}}', '{{thigh_28d_delta}}',
|
|
'{{waist_hip_ratio}}', '{{recomposition_quadrant}}',
|
|
'{{body_progress_score}}',
|
|
],
|
|
'ernährung': [
|
|
'{{kcal_avg}}', '{{protein_avg}}', '{{carb_avg}}', '{{fat_avg}}',
|
|
'{{nutrition_days}}', '{{protein_ziel_low}}', '{{protein_ziel_high}}',
|
|
'{{energy_balance_7d}}', '{{energy_deficit_surplus}}',
|
|
'{{protein_g_per_kg}}', '{{protein_days_in_target}}', '{{protein_adequacy_28d}}',
|
|
'{{macro_consistency_score}}', '{{intake_volatility}}', '{{nutrition_score}}',
|
|
],
|
|
'training': [
|
|
'{{activity_summary}}', '{{activity_detail}}', '{{trainingstyp_verteilung}}',
|
|
'{{training_minutes_week}}', '{{training_frequency_7d}}', '{{quality_sessions_pct}}',
|
|
'{{ability_balance_strength}}', '{{ability_balance_endurance}}', '{{ability_balance_mental}}',
|
|
'{{ability_balance_coordination}}', '{{ability_balance_mobility}}',
|
|
'{{proxy_internal_load_7d}}', '{{monotony_score}}', '{{strain_score}}',
|
|
'{{rest_day_compliance}}', '{{vo2max_trend_28d}}', '{{activity_score}}',
|
|
'{{training_frequency_by_type_md}}', '{{training_inter_session_gap_md}}', '{{training_sessions_recent_json}}',
|
|
],
|
|
'schlaf': [
|
|
'{{sleep_avg_duration}}', '{{sleep_avg_quality}}', '{{rest_days_count}}',
|
|
'{{recovery_score}}',
|
|
'{{sleep_avg_duration_7d}}', '{{sleep_debt_hours}}', '{{sleep_regularity_proxy}}',
|
|
'{{recent_load_balance_3d}}', '{{sleep_quality_7d}}',
|
|
'{{correlation_sleep_recovery}}',
|
|
],
|
|
'vitalwerte': [
|
|
'{{vitals_avg_hr}}', '{{vitals_avg_hrv}}', '{{vitals_vo2_max}}',
|
|
'{{hrv_vs_baseline_pct}}', '{{rhr_vs_baseline_pct}}',
|
|
],
|
|
'zeitraum': [
|
|
'{{datum_heute}}', '{{zeitraum_7d}}', '{{zeitraum_30d}}', '{{zeitraum_90d}}'
|
|
],
|
|
'phase0b_meta': [
|
|
'{{goal_progress_score}}', '{{data_quality_score}}',
|
|
],
|
|
'ziele_fokus': [
|
|
'{{top_goal_name}}', '{{top_goal_progress_pct}}', '{{top_goal_status}}',
|
|
'{{top_focus_area_name}}', '{{top_focus_area_progress}}',
|
|
'{{focus_cat_körper_progress}}', '{{focus_cat_körper_weight}}',
|
|
'{{focus_cat_ernährung_progress}}', '{{focus_cat_ernährung_weight}}',
|
|
'{{focus_cat_aktivität_progress}}', '{{focus_cat_aktivität_weight}}',
|
|
'{{focus_cat_recovery_progress}}', '{{focus_cat_recovery_weight}}',
|
|
'{{focus_cat_vitalwerte_progress}}', '{{focus_cat_vitalwerte_weight}}',
|
|
'{{focus_cat_mental_progress}}', '{{focus_cat_mental_weight}}',
|
|
'{{focus_cat_lebensstil_progress}}', '{{focus_cat_lebensstil_weight}}',
|
|
'{{active_goals_json}}', '{{active_goals_md}}',
|
|
'{{focus_areas_weighted_json}}', '{{focus_areas_weighted_md}}', '{{focus_area_weights_json}}',
|
|
'{{top_3_focus_areas}}', '{{top_3_goals_behind_schedule}}', '{{top_3_goals_on_track}}',
|
|
],
|
|
'korrelationen': [
|
|
'{{correlation_energy_weight_lag}}', '{{correlation_protein_lbm}}',
|
|
'{{correlation_load_hrv}}', '{{correlation_load_rhr}}',
|
|
'{{plateau_detected}}', '{{top_drivers}}',
|
|
],
|
|
}
|
|
|
|
if not categories:
|
|
return placeholder_categories
|
|
|
|
# Filter to requested categories
|
|
return {k: v for k, v in placeholder_categories.items() if k in categories}
|
|
|
|
|
|
def get_placeholder_example_values(profile_id: str) -> Dict[str, str]:
|
|
"""
|
|
Get example values for all placeholders using real user data.
|
|
|
|
Args:
|
|
profile_id: User profile ID
|
|
|
|
Returns:
|
|
Dict mapping placeholder to example value
|
|
"""
|
|
examples = {}
|
|
|
|
for placeholder, resolver in PLACEHOLDER_MAP.items():
|
|
try:
|
|
examples[placeholder] = resolver(profile_id)
|
|
except Exception as e:
|
|
examples[placeholder] = f"[Fehler: {str(e)}]"
|
|
|
|
return examples
|
|
|
|
|
|
def get_placeholder_catalog(profile_id: str) -> Dict[str, List[Dict[str, str]]]:
|
|
"""
|
|
Get grouped placeholder catalog with descriptions and example values.
|
|
|
|
Uses the Placeholder Registry as single source of truth.
|
|
Falls back to hardcoded legacy placeholders for non-registry items.
|
|
|
|
Args:
|
|
profile_id: User profile ID
|
|
|
|
Returns:
|
|
Dict mapping category to list of {key, description, example}
|
|
"""
|
|
from placeholder_registry import get_registry
|
|
|
|
catalog = {}
|
|
|
|
# Get all registered placeholders from Registry
|
|
registry = get_registry()
|
|
all_registered = registry.get_all()
|
|
|
|
# Group registry placeholders by category
|
|
for key, metadata in all_registered.items():
|
|
category = metadata.category
|
|
if category not in catalog:
|
|
catalog[category] = []
|
|
|
|
# Try to resolve value
|
|
try:
|
|
if metadata._resolver_func:
|
|
example = metadata._resolver_func(profile_id)
|
|
else:
|
|
# Fallback to PLACEHOLDER_MAP
|
|
placeholder = f'{{{{{key}}}}}'
|
|
resolver = PLACEHOLDER_MAP.get(placeholder)
|
|
if resolver:
|
|
example = resolver(profile_id)
|
|
else:
|
|
example = '[Nicht implementiert]'
|
|
except Exception as e:
|
|
example = '[Nicht verfügbar]'
|
|
|
|
catalog[category].append({
|
|
'key': key,
|
|
'description': metadata.description,
|
|
'example': str(example)
|
|
})
|
|
|
|
# Legacy placeholders (not in registry yet)
|
|
legacy_placeholders: Dict[str, List[Tuple[str, str]]] = {}
|
|
|
|
# Add legacy placeholders (skip if already in registry)
|
|
for category, items in legacy_placeholders.items():
|
|
if category not in catalog:
|
|
catalog[category] = []
|
|
|
|
for key, description in items:
|
|
# Skip if already added from registry
|
|
if any(p['key'] == key for p in catalog[category]):
|
|
continue
|
|
|
|
placeholder = f'{{{{{key}}}}}'
|
|
resolver = PLACEHOLDER_MAP.get(placeholder)
|
|
if resolver:
|
|
try:
|
|
example = resolver(profile_id)
|
|
except Exception:
|
|
example = '[Nicht verfügbar]'
|
|
else:
|
|
example = '[Nicht implementiert]'
|
|
|
|
catalog[category].append({
|
|
'key': key,
|
|
'description': description,
|
|
'example': str(example)
|
|
})
|
|
|
|
# Add ALL remaining placeholders from PLACEHOLDER_MAP that aren't categorized yet
|
|
# This ensures PlaceholderPicker shows all 111+ placeholders, not just registry ones
|
|
all_categorized_keys = set()
|
|
for items in catalog.values():
|
|
all_categorized_keys.update(p['key'] for p in items)
|
|
|
|
sonstige_category = 'Sonstiges'
|
|
if sonstige_category not in catalog:
|
|
catalog[sonstige_category] = []
|
|
|
|
for placeholder, resolver in PLACEHOLDER_MAP.items():
|
|
# Extract key from {{key}}
|
|
key = placeholder.replace('{{', '').replace('}}', '')
|
|
|
|
# Skip if already categorized
|
|
if key in all_categorized_keys:
|
|
continue
|
|
|
|
try:
|
|
example = resolver(profile_id)
|
|
except Exception:
|
|
example = '[Nicht verfügbar]'
|
|
|
|
catalog[sonstige_category].append({
|
|
'key': key,
|
|
'description': f'Platzhalter: {key}', # Generic description
|
|
'example': str(example)
|
|
})
|
|
|
|
return catalog
|