- Introduced new fields for descriptions in training parameters, improving clarity for AI context in `training_sessions_recent_json`.
- Added a glossary placeholder `{{training_parameters_glossary_md}}` to provide a Markdown table of active training parameters, including names and descriptions.
- Updated the `placeholder_resolver.py` and `activity_metrics.py` to support the new glossary functionality.
- Enhanced the `AdminActivityAttributeProfilesPage` to allow input for descriptions in both German and English, ensuring better context for metrics.
- Revised tests to validate the inclusion of description fields in parameter schema merges and metrics handling.
1972 lines
80 KiB
Python
1972 lines
80 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 Any, 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,
|
|
get_training_parameters_ki_glossary_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|d}} — nur description anhängen; {{key|x}} — nur Erklärung (ai_caption / Registry)
|
|
_PLACEHOLDER_TOKEN_RE = re.compile(
|
|
r"\{\{\s*([a-zA-Z0-9_]+)(?:\s*\|\s*([a-zA-Z0-9_,\s]+))?\s*\}\}"
|
|
)
|
|
|
|
|
|
def get_catalog_row_for_key(
|
|
catalog: Optional[Dict[str, List[Dict[str, Any]]]], key: str
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""Katalogzeile zu Platzhalter-Key (key ohne {{}})."""
|
|
if not catalog:
|
|
return None
|
|
for items in catalog.values():
|
|
for item in items:
|
|
raw = item.get("key") or ""
|
|
ik = str(raw).replace("{{", "").replace("}}", "").strip()
|
|
if ik == key:
|
|
return item
|
|
return None
|
|
|
|
|
|
def _description_for_registry_key(key: str) -> str:
|
|
meta = get_registry().get(key)
|
|
if not meta:
|
|
return ""
|
|
return (meta.description or "").strip()
|
|
|
|
|
|
def _explain_for_registry_key(key: str) -> str:
|
|
meta = get_registry().get(key)
|
|
if not meta:
|
|
return ""
|
|
return build_ai_placeholder_caption(meta).strip()
|
|
|
|
|
|
def format_value_with_d_modifier(value: str, catalog_row: Dict[str, Any]) -> str:
|
|
"""
|
|
Vorschau für Export wie {{key|d}}: „Wert — description“ (kein ai_caption).
|
|
"""
|
|
cap = (catalog_row.get("description") or "").strip()
|
|
if cap:
|
|
return f"{value} — {cap}"
|
|
return str(value)
|
|
|
|
|
|
# ── 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.get("hr_avg") else ""
|
|
eav_parts = []
|
|
for m in activity.get("session_metrics") or []:
|
|
k, v = m.get("key"), m.get("value")
|
|
if k is None or v is None:
|
|
continue
|
|
label = m.get("name_de") or m.get("name_en") or k
|
|
eav_parts.append(f"{label} ({k})={v}")
|
|
eav_str = f" | EAV: {'; '.join(eav_parts)}" if eav_parts else ""
|
|
lines.append(
|
|
f"{activity['date']}: {activity['activity_type']} "
|
|
f"({activity['duration_min']}min, {activity['kcal_active']}kcal{hr_str}{eav_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_parameters_glossary_md(profile_id: str) -> str:
|
|
"""
|
|
Markdown-Tabelle: alle aktiven training_parameters (key, Namen, Beschreibungen, Typ, Einheit).
|
|
Für KI neben session_metrics / training_sessions_recent_json.
|
|
"""
|
|
data = get_training_parameters_ki_glossary_data(profile_id)
|
|
params = data.get("parameters") or []
|
|
if not params:
|
|
return "Keine aktiven Trainingsparameter im Katalog."
|
|
|
|
def cell(x: object) -> str:
|
|
if x is None:
|
|
return "—"
|
|
return str(x).replace("|", "·").replace("\n", " ").strip()[:400]
|
|
|
|
lines = [
|
|
"| Feld (key) | DE | EN | Beschreibung DE | Beschreibung EN | Typ | Einheit | Kategorie |",
|
|
"|---|---|---|---|---|---|---|---|",
|
|
]
|
|
for p in params:
|
|
lines.append(
|
|
"| "
|
|
+ " | ".join(
|
|
[
|
|
cell(p.get("key")),
|
|
cell(p.get("name_de")),
|
|
cell(p.get("name_en")),
|
|
cell(p.get("description_de")),
|
|
cell(p.get("description_en")),
|
|
cell(p.get("data_type")),
|
|
cell(p.get("unit")),
|
|
cell(p.get("category")),
|
|
]
|
|
)
|
|
+ " |"
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
|
|
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 (20 Keys: 17 activity_metrics + 3 activity_session_insights; 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),
|
|
'{{training_parameters_glossary_md}}': get_training_parameters_glossary_md,
|
|
|
|
# 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 — description (kurz, token-sparend)
|
|
- {{fat_avg|x}} — nur Erklärung (business_meaning / semantic_contract, ggf. Score-Skala), ohne Wert
|
|
- {{fat_avg|d,x}} — Wert — description — Erklärung
|
|
|
|
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}]"
|
|
|
|
want_d = "d" in mods
|
|
want_x = "x" in mods
|
|
|
|
if want_x and not want_d:
|
|
expl = _explain_for_registry_key(key)
|
|
return expl if expl else ""
|
|
|
|
if not want_d and not want_x:
|
|
return value
|
|
|
|
parts: List[str] = [value]
|
|
if want_d:
|
|
desc = _description_for_registry_key(key)
|
|
if desc:
|
|
parts.append(desc)
|
|
if want_x:
|
|
expl = _explain_for_registry_key(key)
|
|
if expl:
|
|
parts.append(expl)
|
|
return " — ".join(parts)
|
|
|
|
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}}',
|
|
'{{training_parameters_glossary_md}}',
|
|
],
|
|
'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': '',
|
|
})
|
|
|
|
# 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
|