- Introduced `build_ai_placeholder_caption` function in `placeholder_registry.py` to generate AI context captions based on placeholder metadata. - Updated `resolve_placeholders` in `placeholder_resolver.py` to support modifiers for AI context, allowing for enhanced descriptions when placeholders are resolved. - Modified `get_placeholder_catalog` to include AI captions in the output, improving the metadata available for placeholders. - Adjusted `export_placeholder_values` to include AI captions in the exported data, enhancing the information provided to users. These changes improve the flexibility and functionality of the placeholder system, enabling richer context generation for dynamic content.
1872 lines
77 KiB
Python
1872 lines
77 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 json
|
|
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
|
|
)
|
|
|
|
from placeholder_registry import build_ai_placeholder_caption, get_registry
|
|
|
|
# {{key}} oder {{key|d}} — Modifier d hängt KI-Kontext (ai_caption) an
|
|
_PLACEHOLDER_TOKEN_RE = re.compile(
|
|
r"\{\{\s*([a-zA-Z0-9_]+)(?:\s*\|\s*([a-zA-Z0-9_,\s]+))?\s*\}\}"
|
|
)
|
|
|
|
|
|
def _ai_caption_for_placeholder_key(key: str) -> Optional[str]:
|
|
meta = get_registry().get(key)
|
|
if meta:
|
|
return build_ai_placeholder_caption(meta)
|
|
return None
|
|
|
|
|
|
# ── 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 pv_unavailable(reason: str, detail: Optional[str] = None) -> str:
|
|
"""
|
|
Standard-Antwort wenn kein Platzhalter-Wert lieferbar ist.
|
|
Grund ist für Nutzer und KI lesbar (ggf. Alternativen im Text).
|
|
"""
|
|
r = (reason or "Keine auswertbaren Daten").strip()
|
|
if detail:
|
|
d = str(detail).strip()
|
|
if d:
|
|
return f"nicht verfügbar — {r} ({d})"
|
|
return f"nicht verfügbar — {r}"
|
|
|
|
|
|
def pv_unavailable_json(reason: str, detail: Optional[str] = None) -> str:
|
|
"""Strukturierte JSON-Antwort statt leeres {} (für KI / Clients)."""
|
|
payload: Dict[str, object] = {
|
|
"_available": False,
|
|
"_reason": (reason or "Keine auswertbaren Daten").strip(),
|
|
}
|
|
if detail:
|
|
d = str(detail).strip()
|
|
if d:
|
|
payload["_detail"] = d
|
|
return json.dumps(payload, ensure_ascii=False)
|
|
|
|
|
|
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 pv_unavailable(
|
|
"Kein aktuelles Gewicht",
|
|
f"confidence={data.get('confidence')}, data_points={data.get('data_points',0)}",
|
|
)
|
|
|
|
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 pv_unavailable(
|
|
"Gewichtstrend nicht ermittelbar",
|
|
f"confidence={data.get('confidence')}, Fenster={days} Tage",
|
|
)
|
|
|
|
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 pv_unavailable(
|
|
"Körperfett nicht ermittelbar",
|
|
f"confidence={data.get('confidence')} (keine ausreichenden Caliper-/Kompositionsdaten)",
|
|
)
|
|
|
|
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 pv_unavailable(
|
|
"Ernährungsmittelwert nicht ermittelbar",
|
|
f"confidence={data.get('confidence')}, Feld={field}, Fenster={days} Tage",
|
|
)
|
|
|
|
# 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 pv_unavailable(
|
|
"Proteinziel unten nicht ermittelbar",
|
|
f"confidence={data.get('confidence')} (Gewicht/Profil für g/kg)",
|
|
)
|
|
|
|
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 pv_unavailable(
|
|
"Proteinziel oben nicht ermittelbar",
|
|
f"confidence={data.get('confidence')} (Gewicht/Profil für g/kg)",
|
|
)
|
|
|
|
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 pv_unavailable(
|
|
"Schlafdauer nicht ermittelbar",
|
|
f"confidence={data.get('confidence')}, "
|
|
f"nächte_mit_wert={data.get('nights_with_data', 0)}/{data.get('days_analyzed', days)} "
|
|
f"(Quellen: sleep_segments und/oder sleep_log.duration_minutes)",
|
|
)
|
|
|
|
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 pv_unavailable(
|
|
"Schlafqualität (Deep+REM-Anteil) nicht ermittelbar",
|
|
f"confidence={data.get('confidence')}, "
|
|
f"nächte_analysiert={data.get('nights_analyzed', 0)}/{data.get('days_analyzed', days)} "
|
|
f"(Quellen: sleep_segments oder Spalten deep/rem/light/awake_minutes)",
|
|
)
|
|
|
|
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 pv_unavailable(
|
|
"Ruhepuls-Schnitt nicht ermittelbar",
|
|
f"confidence={data.get('confidence')}, Fenster={days} Tage (vitals_baseline)",
|
|
)
|
|
|
|
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 pv_unavailable(
|
|
"HRV-Schnitt nicht ermittelbar",
|
|
f"confidence={data.get('confidence')}, Fenster={days} Tage (vitals_baseline)",
|
|
)
|
|
|
|
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 pv_unavailable(
|
|
"VO2max nicht ermittelbar",
|
|
f"confidence={data.get('confidence')} (vitals_baseline)",
|
|
)
|
|
|
|
return f"{data['vo2_max']:.1f} ml/kg/min"
|
|
|
|
|
|
# Begründungen wenn Layer-2-Berechnung None liefert (für KI / Export)
|
|
_DEFAULT_NUMERIC_UNAVAILABLE = (
|
|
"Numerische Berechnung liefert keinen Wert (Daten unzureichend oder Schwellen nicht erreicht)"
|
|
)
|
|
_DEFAULT_STR_UNAVAILABLE = (
|
|
"Kein Wert ermittelbar (Daten unzureichend oder Schwellen nicht erreicht)"
|
|
)
|
|
_DEFAULT_JSON_UNAVAILABLE = (
|
|
"Keine strukturierten JSON-Daten ermittelbar (Berechnung liefert None)"
|
|
)
|
|
|
|
_SAFE_INT_NONE_REASON: Dict[str, str] = {
|
|
"goal_progress_score": (
|
|
"Aggregierter Ziel-Fortschritt nicht berechenbar; Alternativen: {{active_goals_md}}, "
|
|
"{{data_quality_score}}"
|
|
),
|
|
"body_progress_score": "Körper-Fortschritts-Score nicht berechenbar",
|
|
"nutrition_score": (
|
|
"Ernährungs-Score nicht berechenbar (z. B. keine gewichteten Ernährungs-Fokusbereiche oder zu wenig Log-Daten)"
|
|
),
|
|
"activity_score": (
|
|
"Aktivitäts-Score nicht berechenbar (z. B. Score-Schwellen oder fehlende abilities-Zuordnung in activity_log)"
|
|
),
|
|
"recovery_score_v2": "Recovery-Score v2 nicht berechenbar (Schlaf/Vitals/Last)",
|
|
"data_quality_score": "Datenqualitäts-Score nicht berechenbar",
|
|
"top_goal_progress_pct": (
|
|
"Fortschritt % des Top-Ziels nicht ermittelbar (progress_pct fehlt oder Ziel nicht quantifizierbar); "
|
|
"Alternative: {{active_goals_json}}"
|
|
),
|
|
"top_focus_area_progress": "Fortschritt % des Top-Fokusbereichs nicht ermittelbar",
|
|
"focus_cat_körper_progress": "Kategorie-Fortschritt „Körper“ nicht berechenbar",
|
|
"focus_cat_ernährung_progress": "Kategorie-Fortschritt „Ernährung“ nicht berechenbar",
|
|
"focus_cat_aktivität_progress": "Kategorie-Fortschritt „Aktivität“ nicht berechenbar",
|
|
"focus_cat_recovery_progress": "Kategorie-Fortschritt „Recovery“ nicht berechenbar",
|
|
"focus_cat_vitalwerte_progress": "Kategorie-Fortschritt „Vitalwerte“ nicht berechenbar",
|
|
"focus_cat_mental_progress": "Kategorie-Fortschritt „Mental“ nicht berechenbar",
|
|
"focus_cat_lebensstil_progress": "Kategorie-Fortschritt „Lebensstil“ nicht berechenbar",
|
|
"training_minutes_week": "Trainingsminuten/Woche nicht berechenbar",
|
|
"training_frequency_7d": "Trainingseinheiten (7 Tage) nicht berechenbar",
|
|
"quality_sessions_pct": "Anteil Qualitätssessions nicht berechenbar",
|
|
"ability_balance_strength": (
|
|
"Fähigkeiten-Balance Kraft nicht berechenbar (zu wenig abilities-Daten in activity_log)"
|
|
),
|
|
"ability_balance_endurance": (
|
|
"Fähigkeiten-Balance Ausdauer nicht berechenbar (abilities in activity_log)"
|
|
),
|
|
"ability_balance_mental": (
|
|
"Fähigkeiten-Balance Mental nicht berechenbar (abilities in activity_log)"
|
|
),
|
|
"ability_balance_coordination": (
|
|
"Fähigkeiten-Balance Koordination nicht berechenbar (abilities in activity_log)"
|
|
),
|
|
"ability_balance_mobility": (
|
|
"Fähigkeiten-Balance Mobilität nicht berechenbar (abilities in activity_log)"
|
|
),
|
|
"proxy_internal_load_7d": "Interne Last (7 Tage) nicht berechenbar",
|
|
"strain_score": "Strain-Score nicht berechenbar",
|
|
"rest_day_compliance": "Ruhetag-Compliance nicht berechenbar",
|
|
"protein_adequacy_28d": "Protein-Adequacy (28 Tage) nicht berechenbar",
|
|
"macro_consistency_score": "Makro-Konsistenz-Score nicht berechenbar",
|
|
"recent_load_balance_3d": "Load-Balance (3 Tage) nicht berechenbar",
|
|
"sleep_quality_7d": "Schlafqualität 7 Tage nicht berechenbar",
|
|
}
|
|
|
|
_SAFE_FLOAT_NONE_REASON: Dict[str, str] = {
|
|
"weight_7d_median": (
|
|
"Gewichts-Median 7 Tage: mindestens 4 Messungen im Fenster erforderlich"
|
|
),
|
|
"weight_28d_slope": (
|
|
"Gewichts-Trend 28 Tage: zu wenige Messpunkte (ca. 60 % Tagesabdeckung im Fenster)"
|
|
),
|
|
"weight_90d_slope": (
|
|
"Gewichts-Trend 90 Tage: zu wenige Messpunkte (ca. 60 % Tagesabdeckung im Fenster)"
|
|
),
|
|
"fm_28d_change": "Fettmasse-Änderung 28 Tage nicht berechenbar (Serie Caliper/Gewicht)",
|
|
"lbm_28d_change": "Magermasse-Änderung 28 Tage nicht berechenbar (Serie Caliper/Gewicht)",
|
|
"waist_28d_delta": "Taillen-Delta 28 Tage nicht berechenbar (zwei auswertbare Messungen nötig)",
|
|
"hip_28d_delta": "Hüft-Delta 28 Tage nicht berechenbar",
|
|
"chest_28d_delta": "Brust-Delta 28 Tage nicht berechenbar",
|
|
"arm_28d_delta": "Arm-Delta 28 Tage nicht berechenbar",
|
|
"thigh_28d_delta": "Oberschenkel-Delta 28 Tage nicht berechenbar",
|
|
"waist_hip_ratio": "Taille-Hüfte-Verhältnis nicht berechenbar",
|
|
"energy_balance_7d": (
|
|
"Energiebilanz 7 Tage nicht berechenbar (Intake oder TDEE/Gewicht fehlt)"
|
|
),
|
|
"protein_g_per_kg": "Protein g/kg nicht berechenbar",
|
|
"monotony_score": "Monotonie-Score nicht berechenbar",
|
|
"vo2max_trend_28d": "VO2max-Trend 28 Tage nicht berechenbar",
|
|
"hrv_vs_baseline_pct": "HRV vs. Baseline nicht berechenbar (Baseline/Historie)",
|
|
"rhr_vs_baseline_pct": "Ruhepuls vs. Baseline nicht berechenbar",
|
|
"sleep_avg_duration_7d": "Schlafdauer 7 Tage nicht berechenbar (duration_minutes in sleep_log)",
|
|
"sleep_debt_hours": "Schlafschuld nicht berechenbar (mindestens ~10 Nächte mit Dauer)",
|
|
"sleep_regularity_proxy": "Schlaf-Regularität nicht berechenbar",
|
|
"focus_cat_körper_weight": "Kategorie-Gewichtung „Körper“ nicht berechenbar",
|
|
"focus_cat_ernährung_weight": "Kategorie-Gewichtung „Ernährung“ nicht berechenbar",
|
|
"focus_cat_aktivität_weight": "Kategorie-Gewichtung „Aktivität“ nicht berechenbar",
|
|
"focus_cat_recovery_weight": "Kategorie-Gewichtung „Recovery“ nicht berechenbar",
|
|
"focus_cat_vitalwerte_weight": "Kategorie-Gewichtung „Vitalwerte“ nicht berechenbar",
|
|
"focus_cat_mental_weight": "Kategorie-Gewichtung „Mental“ nicht berechenbar",
|
|
"focus_cat_lebensstil_weight": "Kategorie-Gewichtung „Lebensstil“ nicht berechenbar",
|
|
}
|
|
|
|
_SAFE_STR_NONE_REASON: Dict[str, str] = {
|
|
"top_goal_name": "Kein priorisiertes Ziel ermittelbar",
|
|
"top_goal_status": "Status des Top-Ziels nicht ermittelbar",
|
|
"top_focus_area_name": "Kein Top-Fokusbereich ermittelbar",
|
|
"recomposition_quadrant": "Rekompositions-Quadrant nicht berechenbar (FM/LBM-Serie)",
|
|
"energy_deficit_surplus": "Defizit/Überschuss-Status nicht berechenbar",
|
|
"protein_days_in_target": "Protein-Tage im Ziel nicht berechenbar",
|
|
"intake_volatility": "Intake-Volatilität nicht berechenbar",
|
|
"active_goals_md": "Aktive Ziele (Markdown) nicht darstellbar",
|
|
"focus_areas_weighted_md": "Fokusbereiche (Markdown) nicht darstellbar",
|
|
"top_3_focus_areas": "Top-3-Fokusbereiche nicht darstellbar",
|
|
"top_3_goals_behind_schedule": "Ziele „hinter Zeitplan“ nicht darstellbar",
|
|
"top_3_goals_on_track": "Ziele „im Plan“ nicht darstellbar",
|
|
}
|
|
|
|
_SAFE_JSON_NONE_REASON: Dict[str, str] = {
|
|
"training_sessions_recent_json": "Keine Session-Daten für JSON-Zeitfenster",
|
|
"correlation_energy_weight_lag": (
|
|
"Korrelation Energiebilanz zu Gewicht: zu wenige gekoppelte Datenpunkte"
|
|
),
|
|
"correlation_protein_lbm": (
|
|
"Korrelation Protein zu Magermasse: zu wenige gekoppelte Datenpunkte"
|
|
),
|
|
"correlation_load_hrv": (
|
|
"Korrelation Trainingslast zu HRV: zu wenige gekoppelte Datenpunkte"
|
|
),
|
|
"correlation_load_rhr": (
|
|
"Korrelation Trainingslast zu Ruhepuls: zu wenige gekoppelte Datenpunkte"
|
|
),
|
|
"correlation_sleep_recovery": (
|
|
"Korrelation Schlaf zu Recovery: zu wenige gekoppelte Datenpunkte"
|
|
),
|
|
"plateau_detected": "Plateau-Erkennung: keine auswertbare Serie",
|
|
"top_drivers": "Top-Treiber: keine auswertbare Korrelationsbasis",
|
|
"active_goals_json": "Aktive Ziele als JSON nicht ermittelbar",
|
|
"focus_areas_weighted_json": "Gewichtete Fokusbereiche JSON nicht ermittelbar",
|
|
"focus_area_weights_json": "Fokus-Gewichtungen JSON nicht ermittelbar",
|
|
}
|
|
|
|
|
|
# ── 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 oder pv_unavailable-Text mit Grund
|
|
"""
|
|
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 pv_unavailable(
|
|
"Ungültiger Platzhalter (keine numerische Berechnung registriert)",
|
|
func_name,
|
|
)
|
|
|
|
result = func(profile_id)
|
|
if result is None:
|
|
return pv_unavailable(
|
|
_SAFE_INT_NONE_REASON.get(func_name, _DEFAULT_NUMERIC_UNAVAILABLE),
|
|
)
|
|
return str(int(result))
|
|
except Exception as e:
|
|
print(f"[ERROR] _safe_int({func_name}, {profile_id}): {type(e).__name__}: {e}")
|
|
traceback.print_exc()
|
|
return pv_unavailable(
|
|
"Berechnungsfehler bei numerischem Platzhalter",
|
|
f"{type(e).__name__}: {e}",
|
|
)
|
|
|
|
|
|
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 oder pv_unavailable-Text mit Grund
|
|
"""
|
|
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 pv_unavailable(
|
|
"Ungültiger Platzhalter (keine Float-Berechnung registriert)",
|
|
func_name,
|
|
)
|
|
|
|
result = func(profile_id)
|
|
if result is None:
|
|
return pv_unavailable(
|
|
_SAFE_FLOAT_NONE_REASON.get(func_name, _DEFAULT_NUMERIC_UNAVAILABLE),
|
|
)
|
|
return f"{result:.{decimals}f}"
|
|
except Exception as e:
|
|
print(f"[ERROR] _safe_float({func_name}, {profile_id}): {type(e).__name__}: {e}")
|
|
traceback.print_exc()
|
|
return pv_unavailable(
|
|
"Berechnungsfehler bei Float-Platzhalter",
|
|
f"{type(e).__name__}: {e}",
|
|
)
|
|
|
|
|
|
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 pv_unavailable(
|
|
"Ungültiger Platzhalter (keine String-Berechnung registriert)",
|
|
func_name,
|
|
)
|
|
|
|
result = func(profile_id)
|
|
if result is None:
|
|
return pv_unavailable(
|
|
_SAFE_STR_NONE_REASON.get(func_name, _DEFAULT_STR_UNAVAILABLE),
|
|
)
|
|
return str(result)
|
|
except Exception as e:
|
|
print(f"[ERROR] _safe_str({func_name}, {profile_id}): {type(e).__name__}: {e}")
|
|
traceback.print_exc()
|
|
return pv_unavailable(
|
|
"Berechnungsfehler bei Text-Platzhalter",
|
|
f"{type(e).__name__}: {e}",
|
|
)
|
|
|
|
|
|
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 pv_unavailable_json(
|
|
"Ungültiger Platzhalter (keine JSON-Berechnung registriert)",
|
|
func_name,
|
|
)
|
|
|
|
result = func(profile_id)
|
|
if result is None:
|
|
return pv_unavailable_json(
|
|
_SAFE_JSON_NONE_REASON.get(func_name, _DEFAULT_JSON_UNAVAILABLE),
|
|
)
|
|
|
|
# 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 pv_unavailable_json(
|
|
"Berechnungsfehler bei JSON-Platzhalter",
|
|
f"{type(e).__name__}: {e}",
|
|
)
|
|
|
|
|
|
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 as e:
|
|
return pv_unavailable(
|
|
"Top-Fokusbereiche nicht darstellbar",
|
|
f"{type(e).__name__}: {e}",
|
|
)
|
|
|
|
|
|
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 pv_unavailable(
|
|
"Ziele „hinter Zeitplan“ nicht darstellbar",
|
|
f"{type(e).__name__}: {e}",
|
|
)
|
|
|
|
|
|
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 pv_unavailable(
|
|
"Ziele „im Plan“ nicht darstellbar",
|
|
f"{type(e).__name__}: {e}",
|
|
)
|
|
|
|
|
|
# ── 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 pv_unavailable(
|
|
"BMI nicht berechenbar",
|
|
f"confidence={data.get('confidence')}; benötigt Profil-Größe (cm) und letztes Gewicht "
|
|
f"(weight_log); height_cm={data.get('height_cm')}, weight_kg={data.get('weight_kg')}",
|
|
)
|
|
return f"{bmi:.1f}"
|
|
|
|
|
|
# ── Public API ────────────────────────────────────────────────────────────────
|
|
|
|
def resolve_placeholders(template: str, profile_id: str) -> str:
|
|
"""
|
|
Replace all {{placeholders}} in template with actual user data.
|
|
|
|
Unterstützt Modifier wie bei der Prompt-Pipeline:
|
|
- {{fat_avg}} — nur Wert
|
|
- {{fat_avg|d}} — Wert plus KI-Kontext (business_meaning / semantic_contract aus Registry)
|
|
|
|
Args:
|
|
template: Prompt template with placeholders
|
|
profile_id: User profile ID
|
|
|
|
Returns:
|
|
Resolved template with placeholders replaced by values
|
|
"""
|
|
|
|
def _repl(match: re.Match) -> str:
|
|
key = match.group(1)
|
|
modifiers_raw = (match.group(2) or "").strip()
|
|
mods = {x.strip().lower() for x in modifiers_raw.split(",") if x.strip()}
|
|
ph = f"{{{{{key}}}}}"
|
|
resolver = PLACEHOLDER_MAP.get(ph)
|
|
if not resolver:
|
|
return match.group(0)
|
|
try:
|
|
value = str(resolver(profile_id))
|
|
except Exception:
|
|
return f"[Fehler: {ph}]"
|
|
if "d" in mods:
|
|
cap = _ai_caption_for_placeholder_key(key)
|
|
if cap:
|
|
value = f"{value} — {cap}"
|
|
return value
|
|
|
|
return _PLACEHOLDER_TOKEN_RE.sub(_repl, template)
|
|
|
|
|
|
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 {{}})
|
|
"""
|
|
found = _PLACEHOLDER_TOKEN_RE.findall(template)
|
|
known_names = {p.strip('{}') for p in PLACEHOLDER_MAP.keys()}
|
|
unknown = [key for key, _ in found if key 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),
|
|
'ai_caption': build_ai_placeholder_caption(metadata),
|
|
})
|
|
|
|
# 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),
|
|
'ai_caption': description,
|
|
})
|
|
|
|
# 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),
|
|
'ai_caption': f'Platzhalter {key} (noch ohne erweiterte Registry-Beschreibung).',
|
|
})
|
|
|
|
return catalog
|