feat: Enhance nutrition and activity metrics with new data layers
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s

- Added new functions for BMI and goal weight/body fat percentage retrieval in `body_metrics.py`.
- Introduced training frequency and inter-session gap calculations in `activity_metrics.py`.
- Updated placeholder registrations to include new metrics for nutrition and activity.
- Improved data handling in `placeholder_resolver.py` for better integration of new metrics.
- Enhanced documentation across modules to reflect the new functionalities.

These updates improve the accuracy and comprehensiveness of health and fitness assessments within the application.
This commit is contained in:
Lars 2026-04-11 20:46:17 +02:00
parent 61a5bb39ae
commit e9e094c6a4
13 changed files with 1092 additions and 117 deletions

View File

@ -110,6 +110,11 @@ frontend/src/
- **`main.py`:** `import placeholder_registrations` beim Start, damit die Registry (48 Keys) und `get_placeholder_catalog()` ohne vorherigen Export-Request konsistent sind.
- **`placeholder_resolver.py`:** `{{top_goal_progress_pct}}` nutzt `_safe_int` statt `_safe_str` (Verdrahtung zu `scores.get_top_priority_goal` korrigiert).
### Updates (11.04.2026 - Gitea #75, nutrition_score Registry)
- **Gitea #75** (offen): Zucker/Ballaststoffe/Lebensmittelqualität, automatisches Lebensmittelprofil, später Mahlzeiten-Timing/Abgleich mit Training — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/75
- **`nutrition_score`:** Registry in `backend/placeholder_registrations/nutrition_score.py`, Import in `placeholder_registrations/__init__.py`; Legacy-Duplikat unter „Scores“ im Platzhalter-Katalog entfernt.
### Updates (11.04.2026 - Ernährung: eine TDEE-/Tageslogik)
- **`data_layer/nutrition_metrics.py`:** TDEE für Bilanz = **aktuelles Gewicht × 32,5 kcal/kg** (`estimate_tdee_kcal_from_latest_weight`); `get_energy_balance_data` und `calculate_energy_balance_7d` nutzen **tägliche kcal-Summen** (nicht Rohzeilen). Makro-Durchschnitte über **Tagesmittel**; `protein_adequacy_28d`, `macro_consistency_score`, `get_protein_adequacy_data`, `get_macro_consistency_data` auf **Kalendertag** umgestellt. Entfernt: festes **2500 kcal** in `get_energy_balance_data`.

View File

@ -51,6 +51,9 @@ __all__ = [
# Body Metrics (Basic)
'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',
@ -99,6 +102,9 @@ __all__ = [
'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',
# Activity Metrics (Calculated)
'calculate_training_minutes_week',

View File

@ -7,6 +7,9 @@ Functions:
- get_activity_summary_data(): Count, total duration, calories, averages
- get_activity_detail_data(): Detailed activity log entries
- get_training_type_distribution_data(): Training category percentages
- get_training_frequency_by_type_data(): Häufigkeit & Intensität pro activity_type
- get_training_inter_session_gap_data(): Pausen zwischen Einheiten (Stunden)
- get_training_sessions_recent_weeks_data(): Wochen-JSON für KI-Kontext
All functions return structured data (dict) without formatting.
Use placeholder_resolver.py for formatted strings for AI.
@ -15,11 +18,11 @@ Phase 0c: Multi-Layer Architecture
Version: 1.0
"""
from typing import Dict, List, Optional
from datetime import datetime, timedelta, date
from typing import Dict, List, Optional, Any
from datetime import datetime, timedelta, date, time
import statistics
from db import get_db, get_cursor, r2d
from data_layer.utils import calculate_confidence, safe_float, safe_int
from data_layer.utils import calculate_confidence, safe_float, safe_int, serialize_dates
def get_activity_summary_data(
@ -904,3 +907,266 @@ def calculate_activity_data_quality(profile_id: str) -> Dict[str, any]:
"quality": int(quality_score)
}
}
def _session_sort_ts(row: Dict) -> datetime:
"""Einheitlicher Zeitstempel für Sortierung und Pausenberechnung."""
d = row["date"]
if isinstance(d, str):
d = datetime.strptime(d[:10], "%Y-%m-%d").date()
st = row.get("start_time")
if st is None:
t = time(12, 0, 0)
else:
t = st
return datetime.combine(d, t)
def get_training_frequency_by_type_data(
profile_id: str,
days: int = 28,
) -> Dict[str, Any]:
"""
Pro activity_type (Roh-Label aus Import/Anzeige): Häufigkeit & Intensitätskennzahlen.
Returns:
{
"days_analyzed": int,
"confidence": str,
"by_type": [
{
"activity_type": str,
"session_count": int,
"sessions_per_week": float,
"avg_duration_min": float | None,
"avg_kcal_active": float | None,
"avg_hr_avg": float | None,
"avg_hr_max": float | None,
"avg_rpe": float | None,
"avg_kcal_per_min": float | None, # grobe Intensität, wenn kcal & Dauer
},
...
],
}
"""
weeks = max(days / 7.0, 0.01)
with get_db() as conn:
cur = get_cursor(conn)
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
cur.execute(
"""
SELECT
activity_type,
COUNT(*)::int AS session_count,
AVG(duration_min)::float AS avg_duration_min,
AVG(kcal_active)::float AS avg_kcal_active,
AVG(hr_avg)::float AS avg_hr_avg,
AVG(hr_max)::float AS avg_hr_max,
AVG(rpe)::float AS avg_rpe,
SUM(COALESCE(duration_min, 0))::float AS sum_duration,
SUM(COALESCE(kcal_active, 0))::float AS sum_kcal
FROM activity_log
WHERE profile_id = %s AND date >= %s
GROUP BY activity_type
ORDER BY session_count DESC
""",
(profile_id, cutoff),
)
rows = [r2d(r) for r in cur.fetchall()]
if not rows:
return {
"days_analyzed": days,
"confidence": "insufficient",
"by_type": [],
}
by_type = []
for r in rows:
sc = int(r["session_count"])
sum_dur = float(r["sum_duration"] or 0)
sum_kcal = float(r["sum_kcal"] or 0)
kcal_per_min = (sum_kcal / sum_dur) if sum_dur > 0 else None
by_type.append(
{
"activity_type": r["activity_type"],
"session_count": sc,
"sessions_per_week": round(sc / weeks, 2),
"avg_duration_min": r["avg_duration_min"],
"avg_kcal_active": r["avg_kcal_active"],
"avg_hr_avg": r["avg_hr_avg"],
"avg_hr_max": r["avg_hr_max"],
"avg_rpe": r["avg_rpe"],
"avg_kcal_per_min": round(kcal_per_min, 2) if kcal_per_min is not None else None,
}
)
total_sessions = sum(x["session_count"] for x in by_type)
confidence = calculate_confidence(total_sessions, days, "general")
return {
"days_analyzed": days,
"confidence": confidence,
"by_type": by_type,
}
def get_training_inter_session_gap_data(
profile_id: str,
days: int = 28,
) -> Dict[str, Any]:
"""
Mittlere/median Pausen zwischen aufeinanderfolgenden Trainingseinheiten (Stunden).
Sortierung: Datum + start_time (fehlend 12:00), dann created.
"""
with get_db() as conn:
cur = get_cursor(conn)
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
cur.execute(
"""
SELECT date, start_time, created
FROM activity_log
WHERE profile_id = %s AND date >= %s
ORDER BY date ASC, start_time ASC NULLS LAST, created ASC
""",
(profile_id, cutoff),
)
rows = [r2d(r) for r in cur.fetchall()]
if len(rows) < 2:
return {
"days_analyzed": days,
"confidence": "insufficient",
"gap_hours_median": None,
"gap_hours_mean": None,
"gap_hours_min": None,
"gaps_count": 0,
}
gaps = []
prev_ts = None
for r in rows:
ts = _session_sort_ts(r)
if prev_ts is not None:
gaps.append((ts - prev_ts).total_seconds() / 3600.0)
prev_ts = ts
if not gaps:
return {
"days_analyzed": days,
"confidence": "insufficient",
"gap_hours_median": None,
"gap_hours_mean": None,
"gap_hours_min": None,
"gaps_count": 0,
}
gaps_sorted = sorted(gaps)
mid = len(gaps_sorted) // 2
median = (
gaps_sorted[mid]
if len(gaps_sorted) % 2
else (gaps_sorted[mid - 1] + gaps_sorted[mid]) / 2.0
)
confidence = calculate_confidence(len(rows), days, "general")
return {
"days_analyzed": days,
"confidence": confidence,
"gap_hours_median": round(median, 1),
"gap_hours_mean": round(statistics.mean(gaps), 1),
"gap_hours_min": round(min(gaps), 1),
"gaps_count": len(gaps),
}
def get_training_sessions_recent_weeks_data(
profile_id: str,
weeks: int = 4,
) -> Dict[str, Any]:
"""
Letzte Wochen mit Einzeltrainings für KI-Kontext (Dauer, kcal, HF, Typ).
weeks: Anzahl zurückliegender ISO-Kalenderwochen (Default 4).
"""
days = max(weeks * 7, 7)
with get_db() as conn:
cur = get_cursor(conn)
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
cur.execute(
"""
SELECT
a.id,
a.date,
a.start_time,
a.activity_type,
a.training_category,
a.duration_min,
a.kcal_active,
a.hr_avg,
a.hr_max,
a.rpe,
tt.name_de AS training_type_name
FROM activity_log a
LEFT JOIN training_types tt ON tt.id = a.training_type_id
WHERE a.profile_id = %s AND a.date >= %s
ORDER BY a.date ASC, a.start_time ASC NULLS LAST, a.created ASC
""",
(profile_id, cutoff),
)
rows = [r2d(r) for r in cur.fetchall()]
if not rows:
return {
"weeks": [],
"meta": {
"weeks_requested": weeks,
"days_loaded": days,
"session_count": 0,
"confidence": "insufficient",
},
}
by_week: Dict[str, List[Dict]] = {}
for r in rows:
d = r["date"]
if isinstance(d, str):
d = datetime.strptime(d[:10], "%Y-%m-%d").date()
iso = d.isocalendar()
wk = f"{iso.year}-W{iso.week:02d}"
if wk not in by_week:
by_week[wk] = []
dur = r.get("duration_min")
dur_f = float(dur) if dur is not None else None
kcal = r.get("kcal_active")
kcal_f = float(kcal) if kcal is not None else None
hr_a = r.get("hr_avg")
hr_m = r.get("hr_max")
by_week[wk].append(
{
"date": d,
"start_time": str(r["start_time"]) if r.get("start_time") is not None else None,
"activity_type": r.get("activity_type"),
"training_category": r.get("training_category"),
"training_type_name": r.get("training_type_name"),
"duration_min": dur_f,
"kcal_active": kcal_f,
"hr_avg": int(hr_a) if hr_a is not None else None,
"hr_max": int(hr_m) if hr_m is not None else None,
"rpe": int(r["rpe"]) if r.get("rpe") is not None else None,
}
)
week_keys = sorted(by_week.keys())
weeks_out = [{"week_iso": wk, "sessions": by_week[wk]} for wk in week_keys]
confidence = calculate_confidence(len(rows), days, "general")
return serialize_dates(
{
"weeks": weeks_out,
"meta": {
"weeks_requested": weeks,
"days_loaded": days,
"session_count": len(rows),
"confidence": confidence,
},
}
)

View File

@ -5,6 +5,9 @@ Provides structured data for body composition and measurements.
Functions:
- get_latest_weight_data(): Most recent weight entry
- get_bmi_data(): BMI from latest weight + profile height
- get_profile_goal_weight_data(): Zielgewicht (Profilfeld)
- get_profile_goal_bf_pct_data(): Ziel-KFA % (Profilfeld)
- get_weight_trend_data(): Weight trend with slope and direction
- get_body_composition_data(): Body fat percentage and lean mass
- get_circumference_summary_data(): Latest circumference measurements
@ -68,6 +71,105 @@ def get_latest_weight_data(
}
def get_bmi_data(profile_id: str) -> Dict:
"""
BMI from latest weight_log entry and profiles.height (cm).
Returns:
{
"bmi": float | None,
"weight_kg": float | None,
"height_cm": float | None,
"confidence": "high" | "insufficient",
}
"""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT pr.height,
(SELECT wl.weight FROM weight_log wl
WHERE wl.profile_id = pr.id
ORDER BY wl.date DESC
LIMIT 1) AS weight
FROM profiles pr
WHERE pr.id = %s
""",
(profile_id,),
)
row = cur.fetchone()
if not row:
return {
"bmi": None,
"weight_kg": None,
"height_cm": None,
"confidence": "insufficient",
}
height_cm = row["height"]
weight = row["weight"]
if height_cm is None or weight is None:
return {
"bmi": None,
"weight_kg": safe_float(weight) if weight is not None else None,
"height_cm": safe_float(height_cm) if height_cm is not None else None,
"confidence": "insufficient",
}
h = safe_float(height_cm)
w = safe_float(weight)
if h <= 0:
return {
"bmi": None,
"weight_kg": w,
"height_cm": h,
"confidence": "insufficient",
}
height_m = h / 100.0
bmi = w / (height_m ** 2)
return {
"bmi": bmi,
"weight_kg": w,
"height_cm": h,
"confidence": "high",
}
def get_profile_goal_weight_data(profile_id: str) -> Dict:
"""Strategisches Zielgewicht aus profiles.goal_weight (kg), nicht goals-Tabelle."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT goal_weight FROM profiles WHERE id=%s",
(profile_id,),
)
row = cur.fetchone()
if not row or row.get("goal_weight") is None:
return {"goal_weight_kg": None, "confidence": "insufficient"}
return {
"goal_weight_kg": safe_float(row["goal_weight"]),
"confidence": "high",
}
def get_profile_goal_bf_pct_data(profile_id: str) -> Dict:
"""Strategisches Ziel-KFA aus profiles.goal_bf_pct (%), nicht goals-Tabelle."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT goal_bf_pct FROM profiles WHERE id=%s",
(profile_id,),
)
row = cur.fetchone()
if not row or row.get("goal_bf_pct") is None:
return {"goal_bf_pct": None, "confidence": "insufficient"}
return {
"goal_bf_pct": safe_float(row["goal_bf_pct"]),
"confidence": "high",
}
def get_weight_trend_data(
profile_id: str,
days: int = 28
@ -89,7 +191,8 @@ def get_weight_trend_data(
"confidence": str,
"days_analyzed": int,
"first_date": date,
"last_date": date
"last_date": date,
"series": [{"date": date, "weight": float}, ...], # für Charts ohne zweites Query
}
Confidence Rules:
@ -127,7 +230,8 @@ def get_weight_trend_data(
"delta": 0.0,
"direction": "unknown",
"first_date": None,
"last_date": None
"last_date": None,
"series": [],
}
# Extract values
@ -152,7 +256,11 @@ def get_weight_trend_data(
"confidence": confidence,
"days_analyzed": days,
"first_date": rows[0]['date'],
"last_date": rows[-1]['date']
"last_date": rows[-1]['date'],
"series": [
{"date": r["date"], "weight": safe_float(r["weight"])}
for r in rows
],
}

View File

@ -8,7 +8,19 @@ Auto-imports all placeholder registrations to populate the global registry.
from . import nutrition_part_a
from . import nutrition_part_b
from . import nutrition_part_c
from . import nutrition_score
from . import body_metrics
from . import body_extras
from . import activity_metrics
from . import activity_session_insights
__all__ = ['nutrition_part_a', 'nutrition_part_b', 'nutrition_part_c', 'body_metrics', 'activity_metrics']
__all__ = [
'nutrition_part_a',
'nutrition_part_b',
'nutrition_part_c',
'nutrition_score',
'body_metrics',
'body_extras',
'activity_metrics',
'activity_session_insights',
]

View File

@ -1,7 +1,7 @@
"""
Activity Metrics Placeholder Registrations
Registers all 17 activity-related placeholders in the central placeholder registry.
Registers 17 Aktivitäts-Platzhalter hier; 3 Session-/Erholungs-Keys in activity_session_insights.py (20 gesamt).
Evidence-based metadata with clear tagging of source.
@ -10,6 +10,9 @@ Groups:
- Basic Metrics (7): training_minutes_week, training_frequency_7d, quality_sessions_pct,
proxy_internal_load_7d, monotony_score, strain_score, rest_day_compliance
- Advanced Metrics (7): ability_balance_*, vo2max_trend_28d, activity_score
Resolver: alle Keys gebündelt unter Training / Aktivität in PLACEHOLDER_MAP;
activity_score nicht unter Meta Scores.
"""
from placeholder_registry import (
@ -938,9 +941,9 @@ def register_activity_group_3():
description="VO2 Max Trend über 28 Tage",
category="Aktivität",
resolver_module="backend/placeholder_resolver.py",
resolver_function="get_vo2max_trend_28d",
resolver_function="_safe_float",
data_layer_module="backend/data_layer/activity_metrics.py",
data_layer_function="calculate_vo2max_trend",
data_layer_function="calculate_vo2max_trend_28d",
source_tables=["vitals_baseline"],
time_window="28d",
output_type=OutputType.NUMERIC,
@ -977,8 +980,8 @@ def register_activity_group_3():
"EDGE CASE: Nur 1 Messung → kein Trend → missing_value. "
"EDGE CASE: Große Zeitlücken zwischen Messungen → Trend nicht aussagekräftig."
),
layer_1_decision="Data Layer (activity_metrics.calculate_vo2max_trend) - QUESTIONABLE",
layer_2a_decision="Placeholder Resolver (formatting only)",
layer_1_decision="Data Layer (activity_metrics.calculate_vo2max_trend_28d) — Kategorie diskutierbar",
layer_2a_decision="Placeholder Resolver (_safe_float)",
layer_2b_reuse_possible=True,
architecture_alignment="Phase 0c Multi-Layer Architecture conform",
issue_53_alignment="Layer separation established"
@ -1020,8 +1023,8 @@ def register_activity_group_3():
description="Gesamtaktivitäts-Score (gewichtet)",
category="Aktivität",
resolver_module="backend/placeholder_resolver.py",
resolver_function="get_activity_score",
data_layer_module="backend/data_layer/scores.py",
resolver_function="_safe_int",
data_layer_module="backend/data_layer/activity_metrics.py",
data_layer_function="calculate_activity_score",
source_tables=["activity_log", "training_types", "rest_days", "vitals_baseline", "user_focus_area_weights"],
time_window="composite (7d, 14d, 28d mixed)",
@ -1065,8 +1068,8 @@ def register_activity_group_3():
"QUESTIONABLE: Vermischt Metriken mit unterschiedlicher Verlässlichkeit "
"(z.B. quality_sessions_pct hat TO_VERIFY Issues)."
),
layer_1_decision="Data Layer (scores.calculate_activity_score)",
layer_2a_decision="Placeholder Resolver (formatting only)",
layer_1_decision="Data Layer (activity_metrics.calculate_activity_score)",
layer_2a_decision="Placeholder Resolver (_safe_int)",
layer_2b_reuse_possible=False,
architecture_alignment="Phase 0c Multi-Layer Architecture conform",
issue_53_alignment="Layer separation established"

View File

@ -0,0 +1,184 @@
"""
Registry: Trainings-Häufigkeit, Pausen zwischen Einheiten, wöchentliche Session-JSON (KI-Rohkontext).
"""
from placeholder_registry import (
PlaceholderMetadata,
MissingValuePolicy,
EvidenceType,
OutputType,
PlaceholderType,
register_placeholder,
)
def _ev(meta: PlaceholderMetadata, field: str, et: EvidenceType = EvidenceType.CODE_DERIVED):
meta.set_evidence(field, et)
def register_activity_session_insights():
md_freq = PlaceholderMetadata(
key="training_frequency_by_type_md",
category="Aktivität",
description=(
"Markdown-Tabelle: pro Trainingsart (activity_type) Sessions, Ø/Woche, "
"Dauer, kcal, HF, RPE, kcal/min (Intensitätsproxy)"
),
resolver_module="backend/placeholder_resolver.py",
resolver_function="get_training_frequency_by_type_md",
data_layer_module="backend/data_layer/activity_metrics.py",
data_layer_function="get_training_frequency_by_type_data",
source_tables=["activity_log"],
semantic_contract=(
"Aggregat über activity_log gruppiert nach activity_type (Roh-Label). "
"sessions_per_week = count / (days/7). avg_kcal_per_min = Summe kcal / Summe min."
),
business_meaning="KI: Häufigkeit & Belastung pro Sportart, Erholungs-/Überlastungs-Kontext",
unit="Markdown",
time_window="default 28 Tage",
output_type=OutputType.TEXT_SUMMARY,
placeholder_type=PlaceholderType.INTERPRETED,
format_hint="GitHub-Flavored Markdown-Tabelle",
example_output="| Art | n | Ø/Woche | … |",
minimum_data_requirements="Mindestens eine Session im Fenster",
quality_filter_policy=None,
confidence_logic="Wie calculate_confidence anhand Session-Anzahl",
missing_value_policy=MissingValuePolicy(
available=False,
value_raw=None,
missing_reason="no_data",
legacy_display="Keine Trainingsdaten",
),
known_limitations=(
"Gruppierung nach activity_type-String (Import-Namen), nicht nur training_type_id. "
"HF/RPE oft NULL je nach Quelle. Pausen-Analyse separater Platzhalter."
),
layer_1_decision="activity_metrics.get_training_frequency_by_type_data",
layer_2a_decision="get_training_frequency_by_type_md",
layer_2b_reuse_possible=True,
architecture_alignment="Phase 0c",
issue_53_alignment="Layer 1",
evidence={},
)
for f in (
"key", "category", "description", "resolver_module", "resolver_function",
"data_layer_module", "data_layer_function", "source_tables", "semantic_contract",
"unit", "time_window", "output_type", "placeholder_type", "format_hint",
"example_output", "minimum_data_requirements", "confidence_logic",
"missing_value_policy", "layer_1_decision", "layer_2a_decision",
"layer_2b_reuse_possible", "architecture_alignment", "issue_53_alignment",
):
_ev(md_freq, f)
_ev(md_freq, "business_meaning", EvidenceType.DRAFT_DERIVED)
_ev(md_freq, "known_limitations", EvidenceType.MIXED)
register_placeholder(md_freq)
md_gap = PlaceholderMetadata(
key="training_inter_session_gap_md",
category="Aktivität",
description="Median/Mittel/Min der Stunden zwischen aufeinanderfolgenden Trainingseinheiten",
resolver_module="backend/placeholder_resolver.py",
resolver_function="get_training_inter_session_gap_md",
data_layer_module="backend/data_layer/activity_metrics.py",
data_layer_function="get_training_inter_session_gap_data",
source_tables=["activity_log"],
semantic_contract=(
"Sessions chronologisch; Zeitstempel = date + start_time oder 12:00. "
"Lücken in Stunden zwischen aufeinanderfolgenden Starts."
),
business_meaning="KI: ausreichend Erholung zwischen Belastungen? Doppelbelastung?",
unit="Markdown",
time_window="default 28 Tage",
output_type=OutputType.TEXT_SUMMARY,
placeholder_type=PlaceholderType.INTERPRETED,
format_hint="Kurzer Markdown-Fließtext",
example_output="**Pause zwischen Trainings** …",
minimum_data_requirements="Mindestens 2 Sessions",
quality_filter_policy=None,
confidence_logic="calculate_confidence über Session-Anzahl",
missing_value_policy=MissingValuePolicy(
available=False,
value_raw=None,
missing_reason="insufficient_data",
legacy_display="Zu wenige Trainings",
),
known_limitations=(
"Kein Unterscheidung aktiv/passiv außerhalb activity_log. "
"Fehlende Uhrzeit verzerrt Reihenfolge am selben Tag nicht (nur ein künstlicher Mittag)."
),
layer_1_decision="activity_metrics.get_training_inter_session_gap_data",
layer_2a_decision="get_training_inter_session_gap_md",
layer_2b_reuse_possible=True,
architecture_alignment="Phase 0c",
issue_53_alignment="Layer 1",
evidence={},
)
for f in (
"key", "category", "description", "resolver_module", "resolver_function",
"data_layer_module", "data_layer_function", "source_tables", "semantic_contract",
"unit", "time_window", "output_type", "placeholder_type", "format_hint",
"example_output", "minimum_data_requirements", "confidence_logic",
"missing_value_policy", "layer_1_decision", "layer_2a_decision",
"layer_2b_reuse_possible", "architecture_alignment", "issue_53_alignment",
):
_ev(md_gap, f)
_ev(md_gap, "business_meaning", EvidenceType.DRAFT_DERIVED)
_ev(md_gap, "known_limitations", EvidenceType.MIXED)
register_placeholder(md_gap)
pj = PlaceholderMetadata(
key="training_sessions_recent_json",
category="Aktivität",
description=(
"JSON: letzte ISO-Kalenderwochen mit Einheiten (Datum, Art, Dauer, kcal, HF Ø/max, RPE, Kategorie)"
),
resolver_module="backend/placeholder_resolver.py",
resolver_function="_safe_json",
data_layer_module="backend/data_layer/activity_metrics.py",
data_layer_function="get_training_sessions_recent_weeks_data",
source_tables=["activity_log", "training_types"],
semantic_contract=(
"Struktur weeks[].week_iso, sessions[] mit Feldern für KI-Auswertung. "
"Default 4 ISO-Wochen zurück."
),
business_meaning="Rohkontext für wochenweise Auswertung (Erholung, Intensität) in der KI",
unit="JSON string",
time_window="4 ISO-Wochen (28 Tage Datenfenster)",
output_type=OutputType.JSON,
placeholder_type=PlaceholderType.RAW_DATA,
format_hint="JSON-Objekt als String",
example_output='{"weeks":[...],"meta":{...}}',
minimum_data_requirements="Optional Sessions; meta.confidence bei leer insufficient",
quality_filter_policy=None,
confidence_logic="meta.confidence aus Session-Anzahl",
missing_value_policy=MissingValuePolicy(
available=False,
value_raw=None,
missing_reason="no_data",
legacy_display="{}",
),
known_limitations=(
"Token-Länge bei vielen Sessions beachten. training_type_name nur bei gesetztem training_type_id."
),
layer_1_decision="activity_metrics.get_training_sessions_recent_weeks_data",
layer_2a_decision="_safe_json('training_sessions_recent_json')",
layer_2b_reuse_possible=True,
architecture_alignment="Phase 0c",
issue_53_alignment="Layer 1",
evidence={},
)
for f in (
"key", "category", "description", "resolver_module", "resolver_function",
"data_layer_module", "data_layer_function", "source_tables", "semantic_contract",
"unit", "time_window", "output_type", "placeholder_type", "format_hint",
"example_output", "minimum_data_requirements", "confidence_logic",
"missing_value_policy", "layer_1_decision", "layer_2a_decision",
"layer_2b_reuse_possible", "architecture_alignment", "issue_53_alignment",
):
_ev(pj, f)
_ev(pj, "business_meaning", EvidenceType.DRAFT_DERIVED)
_ev(pj, "known_limitations", EvidenceType.MIXED)
register_placeholder(pj)
register_activity_session_insights()

View File

@ -0,0 +1,237 @@
"""
Registry: BMI, Profil-Ziele (goal_weight, goal_bf_pct), body_progress_score.
Profilfelder sind unabhängig von der goals-Tabelle; operative Ziele über andere Keys.
"""
from placeholder_registry import (
PlaceholderMetadata,
MissingValuePolicy,
EvidenceType,
OutputType,
PlaceholderType,
register_placeholder,
)
def register_body_extras():
bmi = PlaceholderMetadata(
key="bmi",
category="Körper",
description="Body-Mass-Index aus letztem Gewicht und Profilgröße (cm)",
resolver_module="backend/placeholder_resolver.py",
resolver_function="calculate_bmi",
data_layer_module="backend/data_layer/body_metrics.py",
data_layer_function="get_bmi_data",
source_tables=["profiles", "weight_log"],
semantic_contract=(
"BMI = Gewicht_kg / (Größe_m)² mit Größe_m = profiles.height / 100 "
"und Gewicht = jüngster Eintrag in weight_log."
),
business_meaning="Standard-Körpermaß für Coaching und Risiko-Kontext",
unit="kg/m²",
time_window="latest weight + aktuelle Profilgröße",
output_type=OutputType.NUMERIC,
placeholder_type=PlaceholderType.RAW_DATA,
format_hint="Eine Dezimalstelle, ohne Einheit im String",
example_output="24.3",
minimum_data_requirements="Profil mit height > 0 und mindestens ein weight_log",
quality_filter_policy=None,
confidence_logic="high nur wenn BMI berechenbar; sonst insufficient / Anzeige nicht verfügbar",
missing_value_policy=MissingValuePolicy(
available=False,
value_raw=None,
missing_reason="no_data",
legacy_display="nicht verfügbar",
),
known_limitations=(
"Keine ethnischen Referenzkurven; Profilgröße kann veraltet sein. "
"Unterscheidet nicht Muskelmasse vs. Fett."
),
layer_1_decision="body_metrics.get_bmi_data",
layer_2a_decision="placeholder_resolver.calculate_bmi (Format)",
layer_2b_reuse_possible=True,
architecture_alignment="Phase 0c",
issue_53_alignment="Layer 1 als Quelle",
evidence={},
)
for field in (
"key", "category", "description", "resolver_module", "resolver_function",
"data_layer_module", "data_layer_function", "source_tables",
"semantic_contract", "business_meaning", "unit", "time_window",
"output_type", "placeholder_type", "format_hint", "example_output",
"minimum_data_requirements", "confidence_logic", "missing_value_policy",
"known_limitations", "layer_1_decision", "layer_2a_decision",
"layer_2b_reuse_possible", "architecture_alignment", "issue_53_alignment",
):
bmi.set_evidence(field, EvidenceType.CODE_DERIVED)
bmi.set_evidence("business_meaning", EvidenceType.DRAFT_DERIVED)
bmi.set_evidence("known_limitations", EvidenceType.MIXED)
register_placeholder(bmi)
gw = PlaceholderMetadata(
key="goal_weight",
category="Körper",
description="Zielgewicht aus Profilfeld profiles.goal_weight (kg)",
resolver_module="backend/placeholder_resolver.py",
resolver_function="get_goal_weight",
data_layer_module="backend/data_layer/body_metrics.py",
data_layer_function="get_profile_goal_weight_data",
source_tables=["profiles"],
semantic_contract=(
"Strategisches Soll-Gewicht im Profil; unabhängig von der goals-Tabelle "
"(dort detaillierte Ziele mit Fortschritt)."
),
business_meaning="Schneller Abgleich Prompt vs. Profil-Default-Zielgewicht",
unit="kg",
time_window="Profil-Snapshot",
output_type=OutputType.NUMERIC,
placeholder_type=PlaceholderType.RAW_DATA,
format_hint="Eine Dezimalstelle oder Text „nicht gesetzt“",
example_output="82.0",
minimum_data_requirements="profiles.goal_weight IS NOT NULL",
quality_filter_policy=None,
confidence_logic="high wenn gesetzt",
missing_value_policy=MissingValuePolicy(
available=False,
value_raw=None,
missing_reason="not_set",
legacy_display="nicht gesetzt",
),
known_limitations="Kann von aktiven goals.weight-Zielen abweichen.",
layer_1_decision="body_metrics.get_profile_goal_weight_data",
layer_2a_decision="placeholder_resolver.get_goal_weight",
layer_2b_reuse_possible=True,
architecture_alignment="Phase 0c",
issue_53_alignment="Layer 1 als Quelle",
evidence={},
)
for field in (
"key", "category", "description", "resolver_module", "resolver_function",
"data_layer_module", "data_layer_function", "source_tables",
"semantic_contract", "unit", "time_window", "output_type",
"placeholder_type", "format_hint", "example_output",
"minimum_data_requirements", "confidence_logic", "missing_value_policy",
"layer_1_decision", "layer_2a_decision", "layer_2b_reuse_possible",
"architecture_alignment", "issue_53_alignment",
):
gw.set_evidence(field, EvidenceType.CODE_DERIVED)
gw.set_evidence("business_meaning", EvidenceType.DRAFT_DERIVED)
gw.set_evidence("known_limitations", EvidenceType.MIXED)
register_placeholder(gw)
gbf = PlaceholderMetadata(
key="goal_bf_pct",
category="Körper",
description="Ziel-Körperfettanteil aus Profilfeld profiles.goal_bf_pct (%)",
resolver_module="backend/placeholder_resolver.py",
resolver_function="get_goal_bf_pct",
data_layer_module="backend/data_layer/body_metrics.py",
data_layer_function="get_profile_goal_bf_pct_data",
source_tables=["profiles"],
semantic_contract="Strategisches Ziel-KFA im Profil.",
business_meaning="Prompt-Abgleich mit Profil-Ziel-KFA",
unit="%",
time_window="Profil-Snapshot",
output_type=OutputType.NUMERIC,
placeholder_type=PlaceholderType.RAW_DATA,
format_hint="Eine Dezimalstelle oder Text „nicht gesetzt“",
example_output="15.0",
minimum_data_requirements="profiles.goal_bf_pct IS NOT NULL",
quality_filter_policy=None,
confidence_logic="high wenn gesetzt",
missing_value_policy=MissingValuePolicy(
available=False,
value_raw=None,
missing_reason="not_set",
legacy_display="nicht gesetzt",
),
known_limitations="Kann von goals body_fat abweichen.",
layer_1_decision="body_metrics.get_profile_goal_bf_pct_data",
layer_2a_decision="placeholder_resolver.get_goal_bf_pct",
layer_2b_reuse_possible=True,
architecture_alignment="Phase 0c",
issue_53_alignment="Layer 1 als Quelle",
evidence={},
)
for field in (
"key", "category", "description", "resolver_module", "resolver_function",
"data_layer_module", "data_layer_function", "source_tables",
"semantic_contract", "unit", "time_window", "output_type",
"placeholder_type", "format_hint", "example_output",
"minimum_data_requirements", "confidence_logic", "missing_value_policy",
"layer_1_decision", "layer_2a_decision", "layer_2b_reuse_possible",
"architecture_alignment", "issue_53_alignment",
):
gbf.set_evidence(field, EvidenceType.CODE_DERIVED)
gbf.set_evidence("business_meaning", EvidenceType.DRAFT_DERIVED)
gbf.set_evidence("known_limitations", EvidenceType.MIXED)
register_placeholder(gbf)
bps = PlaceholderMetadata(
key="body_progress_score",
category="Körper",
description="Körper-Fortschritts-Score 0100, gewichtet nach Focus (Abnehmen, Muskelaufbau, Recomp)",
resolver_module="backend/placeholder_resolver.py",
resolver_function="_safe_int",
data_layer_module="backend/data_layer/body_metrics.py",
data_layer_function="calculate_body_progress_score",
source_tables=[
"user_focus_area_weights",
"focus_area_definitions",
"goals",
"weight_log",
"caliper_log",
"circumference_log",
],
semantic_contract=(
"Gewichteter Mittelwert aus bis zu drei Komponenten: Trend vs. Gewichtsziel, "
"Körperzusammensetzung (FM/LBM/Recomp-Quadrant), Taille-Trend. "
"Komponenten nur aktiv, wenn passende Focus-Gewichte > 0."
),
business_meaning="Meta-KPI: passt dokumentierter Körperfortschritt zur gewichteten Körper-Priorität?",
unit="Score (0100)",
time_window="composite (u. a. 28d Deltas, Ziel-Fortschritt)",
output_type=OutputType.NUMERIC,
placeholder_type=PlaceholderType.SCORE,
format_hint="Ganzzahl oder „nicht verfügbar“",
example_output="72",
minimum_data_requirements=(
"Summe der Körper-Focus-Gewichte (weight_loss + muscle_gain + body_recomposition) > 0 "
"und mindestens eine bewertbare Komponente mit Daten."
),
quality_filter_policy=None,
confidence_logic="Kein separates Confidence-Feld; None wenn keine Körper-Gewichtung oder keine Teilscores.",
missing_value_policy=MissingValuePolicy(
available=False,
value_raw=None,
missing_reason="not_applicable",
legacy_display="nicht verfügbar",
),
known_limitations=(
"Abhängig von user_focus_area_weights und aktiven weight-goals für Gewichts-Teilscore. "
"Taille-Score wird mit festem Basisgewicht 20+ eingemischt und kann dominieren."
),
layer_1_decision="body_metrics.calculate_body_progress_score",
layer_2a_decision="placeholder_resolver._safe_int('body_progress_score', …)",
layer_2b_reuse_possible=True,
architecture_alignment="Phase 0c",
issue_53_alignment="Layer 1 als Quelle",
evidence={},
)
for field in (
"key", "category", "description", "resolver_module", "resolver_function",
"data_layer_module", "data_layer_function", "source_tables",
"semantic_contract", "unit", "time_window", "output_type",
"placeholder_type", "format_hint", "example_output",
"minimum_data_requirements", "confidence_logic", "missing_value_policy",
"layer_1_decision", "layer_2a_decision", "layer_2b_reuse_possible",
"architecture_alignment", "issue_53_alignment",
):
bps.set_evidence(field, EvidenceType.CODE_DERIVED)
bps.set_evidence("business_meaning", EvidenceType.DRAFT_DERIVED)
bps.set_evidence("known_limitations", EvidenceType.MIXED)
register_placeholder(bps)
register_body_extras()

View File

@ -1,7 +1,8 @@
"""
Body Metrics Placeholder Registrations
Registers 17 body composition and measurement placeholders:
Registers 17 Körper-Metriken in diesem Modul; insgesamt 21 Körper-Keys in der Registry
(zusätzlich body_extras.py: bmi, goal_weight, goal_bf_pct, body_progress_score).
Weight & Trends (7):
- weight_aktuell
@ -29,7 +30,7 @@ Summaries (2):
- circ_summary
Evidence-based metadata with comprehensive formula documentation.
Code inspection: backend/data_layer/body_metrics.py (830 lines)
Siehe backend/data_layer/body_metrics.py als Layer-1-Implementierung.
"""
from placeholder_registry import (

View File

@ -1,7 +1,7 @@
"""
Placeholder Registrations - Nutrition Part C
Registers 5 nutrition-related placeholders with complete metadata:
Registers 5 nutrition-related placeholders in this file (nutrition_score: siehe nutrition_score.py):
- macro_consistency_score
- energy_balance_7d
- energy_deficit_surplus
@ -435,8 +435,9 @@ Part C Registration Complete:
Total Nutrition Cluster:
- Part A: 4 placeholders (kcal_avg, protein_avg, carb_avg, fat_avg)
- Part B: 5 placeholders (protein targets + adequacy)
- Part C: 5 placeholders (consistency + balance + meta)
14 nutrition placeholders total
- Part C: 5 placeholders in dieser Datei (consistency + balance + meta)
- nutrition_score: eigenes Modul nutrition_score.py
15 Ernährungs-Platzhalter gesamt (A+B+C+nutrition_score)
All registrations follow Phase 0c Multi-Layer Architecture:
- Layer 1 (Data Layer): Calculations

View File

@ -0,0 +1,101 @@
"""
Placeholder registration: nutrition_score
Focus-gewichteter Ernährungs-Meta-Score (separates Modul, um nutrition_part_c schlank zu halten).
"""
from placeholder_registry import (
PlaceholderMetadata,
MissingValuePolicy,
EvidenceType,
OutputType,
PlaceholderType,
register_placeholder,
)
nutrition_score_metadata = PlaceholderMetadata(
key="nutrition_score",
category="Ernährung",
description="Ernährungs-Score (0100), gewichtet nach Focus Areas",
resolver_module="backend/placeholder_resolver.py",
resolver_function="_safe_int",
data_layer_module="backend/data_layer/nutrition_metrics.py",
data_layer_function="calculate_nutrition_score",
source_tables=[
"nutrition_log",
"weight_log",
"user_focus_area_weights",
"focus_area_definitions",
],
semantic_contract=(
"Gewichteter Score 0100 aus Komponenten, die nur einfließen, wenn der Nutzer "
"passende Ernährungs-Focus-Gewichte gesetzt hat (z. B. protein_intake, "
"calorie_balance, macro_consistency). Nutzt u. a. Protein-Adequacy, "
"Makro-Konsistenz, Kalorien-Adhärenz (über Energiebilanz) und Makro-Balance."
),
business_meaning=(
"Verdichteter KPI für Prompts: passt die dokumentierte Ernährung zur "
"gewichteten strategischen Priorität des Nutzers?"
),
unit="score (0-100)",
time_window="composite (7d / 28d je Komponente)",
output_type=OutputType.NUMERIC,
placeholder_type=PlaceholderType.SCORE,
format_hint="Ganzzahl; bei fehlender Ernährungs-Gewichtung oft nicht verfügbar",
example_output="72",
minimum_data_requirements=(
"Mindestens eine Ernährungs-Focus-Komponente mit Gewicht > 0; "
"sowie je nach Komponente ausreichende nutrition_log-/weight_log-Abdeckung."
),
quality_filter_policy=None,
confidence_logic=(
"Kein separates Confidence-Feld im Resolver; fehlende Komponenten werden "
"aus der Gewichtung ausgeschlossen. total_nutrition_weight == 0 ergibt keinen Score."
),
missing_value_policy=MissingValuePolicy(
available=False,
value_raw=None,
missing_reason="not_applicable",
legacy_display="nicht verfügbar",
),
known_limitations=(
"Abhängig von user_focus_area_weights; ohne Ernährungs-Fokus liefert die "
"Funktion None. Kalorien-Adhärenz nutzt vereinfachte Heuristik (goal_type-TODO). "
"_score_macro_balance nutzt noch zeilenbasierte 28d-Abfrage (langfristig an "
"Tagesaggregation angleichen)."
),
layer_1_decision="Data Layer (nutrition_metrics.calculate_nutrition_score)",
layer_2a_decision="Placeholder Resolver (_safe_int)",
layer_2b_reuse_possible=True,
architecture_alignment="Phase 0c: Berechnung in nutrition_metrics",
issue_53_alignment="Layer 1 als Quelle; Komponenten nutzen weitere Layer-1-Funktionen",
evidence={},
)
nutrition_score_metadata.set_evidence("key", EvidenceType.CODE_DERIVED)
nutrition_score_metadata.set_evidence("category", EvidenceType.CODE_DERIVED)
nutrition_score_metadata.set_evidence("description", EvidenceType.MIXED)
nutrition_score_metadata.set_evidence("resolver_module", EvidenceType.CODE_DERIVED)
nutrition_score_metadata.set_evidence("resolver_function", EvidenceType.CODE_DERIVED)
nutrition_score_metadata.set_evidence("data_layer_module", EvidenceType.CODE_DERIVED)
nutrition_score_metadata.set_evidence("data_layer_function", EvidenceType.CODE_DERIVED)
nutrition_score_metadata.set_evidence("source_tables", EvidenceType.CODE_DERIVED)
nutrition_score_metadata.set_evidence("semantic_contract", EvidenceType.CODE_DERIVED)
nutrition_score_metadata.set_evidence("business_meaning", EvidenceType.DRAFT_DERIVED)
nutrition_score_metadata.set_evidence("unit", EvidenceType.CODE_DERIVED)
nutrition_score_metadata.set_evidence("time_window", EvidenceType.CODE_DERIVED)
nutrition_score_metadata.set_evidence("output_type", EvidenceType.CODE_DERIVED)
nutrition_score_metadata.set_evidence("placeholder_type", EvidenceType.CODE_DERIVED)
nutrition_score_metadata.set_evidence("format_hint", EvidenceType.CODE_DERIVED)
nutrition_score_metadata.set_evidence("example_output", EvidenceType.CODE_DERIVED)
nutrition_score_metadata.set_evidence("minimum_data_requirements", EvidenceType.MIXED)
nutrition_score_metadata.set_evidence("confidence_logic", EvidenceType.CODE_DERIVED)
nutrition_score_metadata.set_evidence("missing_value_policy", EvidenceType.CODE_DERIVED)
nutrition_score_metadata.set_evidence("known_limitations", EvidenceType.MIXED)
nutrition_score_metadata.set_evidence("layer_1_decision", EvidenceType.CODE_DERIVED)
nutrition_score_metadata.set_evidence("layer_2a_decision", EvidenceType.CODE_DERIVED)
nutrition_score_metadata.set_evidence("layer_2b_reuse_possible", EvidenceType.TO_VERIFY)
nutrition_score_metadata.set_evidence("architecture_alignment", EvidenceType.CODE_DERIVED)
nutrition_score_metadata.set_evidence("issue_53_alignment", EvidenceType.MIXED)
register_placeholder(nutrition_score_metadata)

View File

@ -15,6 +15,9 @@ 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
@ -27,7 +30,10 @@ from data_layer.nutrition_metrics import (
from data_layer.activity_metrics import (
get_activity_summary_data,
get_activity_detail_data,
get_training_type_distribution_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,
@ -184,17 +190,17 @@ def get_circ_summary(profile_id: str) -> str:
def get_goal_weight(profile_id: str) -> str:
"""Get goal weight from profile."""
profile = get_profile_data(profile_id)
goal = profile.get('goal_weight')
return f"{goal:.1f}" if goal else "nicht gesetzt"
"""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:
"""Get goal body fat percentage from profile."""
profile = get_profile_data(profile_id)
goal = profile.get('goal_bf_pct')
return f"{goal:.1f}" if goal else "nicht gesetzt"
"""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:
@ -315,6 +321,61 @@ def get_trainingstyp_verteilung(profile_id: str, days: int = 14) -> str:
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.
@ -571,6 +632,7 @@ def _safe_json(func_name: str, profile_id: str) -> str:
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'),
@ -595,7 +657,7 @@ def _safe_json(func_name: str, profile_id: str) -> str:
if isinstance(result, str):
return result
else:
return json.dumps(result, ensure_ascii=False)
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()
@ -1079,7 +1141,7 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = {
'{{height}}': lambda pid: str(get_profile_data(pid).get('height', 'unbekannt')),
'{{geschlecht}}': lambda pid: 'männlich' if get_profile_data(pid).get('sex') == 'm' else 'weiblich',
# Körper
# 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,
@ -1088,8 +1150,21 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = {
'{{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
# 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),
@ -1097,11 +1172,36 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = {
'{{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
# 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
'{{sleep_avg_duration}}': lambda pid: get_sleep_avg_duration(pid, 7),
@ -1123,11 +1223,8 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = {
# PHASE 0b: Goal-Aware Placeholders (Dynamic Focus Areas v2.0)
# ========================================================================
# --- Meta Scores (Ebene 1: Aggregierte Scores) ---
# --- Meta Scores (Ebene 1: Aggregierte Scores; body/nutrition/activity scores → jeweilige Kategorie) ---
'{{goal_progress_score}}': lambda pid: _safe_int('goal_progress_score', pid),
'{{body_progress_score}}': lambda pid: _safe_int('body_progress_score', pid),
'{{nutrition_score}}': lambda pid: _safe_int('nutrition_score', pid),
'{{activity_score}}': lambda pid: _safe_int('activity_score', pid),
'{{recovery_score}}': lambda pid: _safe_int('recovery_score_v2', pid),
'{{data_quality_score}}': lambda pid: _safe_int('data_quality_score', pid),
@ -1154,44 +1251,6 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = {
'{{focus_cat_lebensstil_progress}}': lambda pid: _safe_int('focus_cat_lebensstil_progress', pid),
'{{focus_cat_lebensstil_weight}}': lambda pid: _safe_float('focus_cat_lebensstil_weight', pid),
# --- Body Metrics (Ebene 4: Einzelmetriken K1-K5) ---
'{{weight_7d_median}}': lambda pid: _safe_float('weight_7d_median', pid),
'{{weight_28d_slope}}': lambda pid: _safe_float('weight_28d_slope', pid, decimals=4),
'{{weight_90d_slope}}': lambda pid: _safe_float('weight_90d_slope', pid, decimals=4),
'{{fm_28d_change}}': lambda pid: _safe_float('fm_28d_change', pid),
'{{lbm_28d_change}}': lambda pid: _safe_float('lbm_28d_change', pid),
'{{waist_28d_delta}}': lambda pid: _safe_float('waist_28d_delta', pid),
'{{hip_28d_delta}}': lambda pid: _safe_float('hip_28d_delta', pid),
'{{chest_28d_delta}}': lambda pid: _safe_float('chest_28d_delta', pid),
'{{arm_28d_delta}}': lambda pid: _safe_float('arm_28d_delta', pid),
'{{thigh_28d_delta}}': lambda pid: _safe_float('thigh_28d_delta', pid),
'{{waist_hip_ratio}}': lambda pid: _safe_float('waist_hip_ratio', pid, decimals=3),
'{{recomposition_quadrant}}': lambda pid: _safe_str('recomposition_quadrant', pid),
# --- Nutrition Metrics (E1-E5) ---
'{{energy_balance_7d}}': lambda pid: _safe_float('energy_balance_7d', pid, decimals=0),
'{{energy_deficit_surplus}}': lambda pid: _safe_str('energy_deficit_surplus', pid),
'{{protein_g_per_kg}}': lambda pid: _safe_float('protein_g_per_kg', pid),
'{{protein_days_in_target}}': lambda pid: _safe_str('protein_days_in_target', pid),
'{{protein_adequacy_28d}}': lambda pid: _safe_int('protein_adequacy_28d', pid),
'{{macro_consistency_score}}': lambda pid: _safe_int('macro_consistency_score', pid),
'{{intake_volatility}}': lambda pid: _safe_str('intake_volatility', pid),
# --- Activity Metrics (A1-A8) ---
'{{training_minutes_week}}': lambda pid: _safe_int('training_minutes_week', pid),
'{{training_frequency_7d}}': lambda pid: _safe_int('training_frequency_7d', pid),
'{{quality_sessions_pct}}': lambda pid: _safe_int('quality_sessions_pct', pid),
'{{ability_balance_strength}}': lambda pid: _safe_int('ability_balance_strength', pid),
'{{ability_balance_endurance}}': lambda pid: _safe_int('ability_balance_endurance', pid),
'{{ability_balance_mental}}': lambda pid: _safe_int('ability_balance_mental', pid),
'{{ability_balance_coordination}}': lambda pid: _safe_int('ability_balance_coordination', pid),
'{{ability_balance_mobility}}': lambda pid: _safe_int('ability_balance_mobility', pid),
'{{proxy_internal_load_7d}}': lambda pid: _safe_int('proxy_internal_load_7d', pid),
'{{monotony_score}}': lambda pid: _safe_float('monotony_score', pid),
'{{strain_score}}': lambda pid: _safe_int('strain_score', pid),
'{{rest_day_compliance}}': lambda pid: _safe_int('rest_day_compliance', pid),
'{{vo2max_trend_28d}}': lambda pid: _safe_float('vo2max_trend_28d', pid),
# --- Recovery Metrics (Recovery Score v2) ---
'{{hrv_vs_baseline_pct}}': lambda pid: _safe_float('hrv_vs_baseline_pct', pid),
'{{rhr_vs_baseline_pct}}': lambda pid: _safe_float('rhr_vs_baseline_pct', pid),
@ -1223,23 +1282,11 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = {
def calculate_bmi(profile_id: str) -> str:
"""Calculate BMI from latest weight and profile height."""
profile = get_profile_data(profile_id)
if not profile.get('height'):
"""BMI für Prompts; Berechnung in data_layer.body_metrics.get_bmi_data."""
data = get_bmi_data(profile_id)
bmi = data.get("bmi")
if bmi is None:
return "nicht verfügbar"
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT weight FROM weight_log WHERE profile_id=%s ORDER BY date DESC LIMIT 1",
(profile_id,)
)
row = cur.fetchone()
if not row:
return "nicht verfügbar"
height_m = profile['height'] / 100
bmi = row['weight'] / (height_m ** 2)
return f"{bmi:.1f}"
@ -1305,13 +1352,31 @@ def get_available_placeholders(categories: Optional[List[str]] = None) -> Dict[s
'{{name}}', '{{age}}', '{{height}}', '{{geschlecht}}'
],
'körper': [
'{{weight_aktuell}}', '{{weight_trend}}', '{{kf_aktuell}}', '{{bmi}}'
'{{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}}'
'{{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}}', '{{trainingstyp_verteilung}}'
'{{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}}',
],
'zeitraum': [
'{{datum_heute}}', '{{zeitraum_7d}}', '{{zeitraum_30d}}', '{{zeitraum_90d}}'
@ -1417,13 +1482,9 @@ def get_placeholder_catalog(profile_id: str) -> Dict[str, List[Dict[str, str]]]:
('vitals_vo2_max', 'Aktueller VO2 Max'),
('hrv_vs_baseline_pct', 'HRV vs. Baseline (%)'),
('rhr_vs_baseline_pct', 'RHR vs. Baseline (%)'),
('vo2max_trend_28d', 'VO2max Trend 28d'),
],
'Scores (Phase 0b)': [
('goal_progress_score', 'Goal Progress Score (0-100)'),
('body_progress_score', 'Body Progress Score (0-100)'),
('nutrition_score', 'Nutrition Score (0-100)'),
('activity_score', 'Activity Score (0-100)'),
('recovery_score', 'Recovery Score (0-100)'),
('data_quality_score', 'Data Quality Score (0-100)'),
],

View File

@ -119,7 +119,7 @@ def get_weight_trend_chart(
"""
profile_id = session['profile_id']
# Get structured data from data layer
# Get structured data from data layer (includes series — no second weight_log query)
trend_data = get_weight_trend_data(profile_id, days)
# Early return if insufficient data
@ -137,22 +137,12 @@ def get_weight_trend_chart(
}
}
# Get raw data points for chart
from db import get_db, get_cursor
with get_db() as conn:
cur = get_cursor(conn)
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
cur.execute(
"""SELECT date, weight FROM weight_log
WHERE profile_id=%s AND date >= %s
ORDER BY date""",
(profile_id, cutoff)
)
rows = cur.fetchall()
# Format for Chart.js
labels = [row['date'].isoformat() for row in rows]
values = [float(row['weight']) for row in rows]
series = trend_data.get("series") or []
labels = [
pt["date"].isoformat() if hasattr(pt["date"], "isoformat") else str(pt["date"])
for pt in series
]
values = [pt["weight"] for pt in series]
return {
"chart_type": "line",