feat: Enhance nutrition and activity metrics with new data layers
- 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:
parent
61a5bb39ae
commit
e9e094c6a4
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
184
backend/placeholder_registrations/activity_session_insights.py
Normal file
184
backend/placeholder_registrations/activity_session_insights.py
Normal 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()
|
||||
237
backend/placeholder_registrations/body_extras.py
Normal file
237
backend/placeholder_registrations/body_extras.py
Normal 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 0–100, 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 (0–100)",
|
||||
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()
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
101
backend/placeholder_registrations/nutrition_score.py
Normal file
101
backend/placeholder_registrations/nutrition_score.py
Normal 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 (0–100), 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 0–100 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)
|
||||
|
|
@ -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,24 +1282,12 @@ 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}"
|
||||
return f"{bmi:.1f}"
|
||||
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
|
@ -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)'),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user