diff --git a/CLAUDE.md b/CLAUDE.md index be4abad..b2903a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/backend/data_layer/__init__.py b/backend/data_layer/__init__.py index dddcca9..b834a9e 100644 --- a/backend/data_layer/__init__.py +++ b/backend/data_layer/__init__.py @@ -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', diff --git a/backend/data_layer/activity_metrics.py b/backend/data_layer/activity_metrics.py index 055c45e..b8360ef 100644 --- a/backend/data_layer/activity_metrics.py +++ b/backend/data_layer/activity_metrics.py @@ -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, + }, + } + ) diff --git a/backend/data_layer/body_metrics.py b/backend/data_layer/body_metrics.py index 4e6441e..2fde741 100644 --- a/backend/data_layer/body_metrics.py +++ b/backend/data_layer/body_metrics.py @@ -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 + ], } diff --git a/backend/placeholder_registrations/__init__.py b/backend/placeholder_registrations/__init__.py index 588cd39..3d08e9f 100644 --- a/backend/placeholder_registrations/__init__.py +++ b/backend/placeholder_registrations/__init__.py @@ -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', +] diff --git a/backend/placeholder_registrations/activity_metrics.py b/backend/placeholder_registrations/activity_metrics.py index 92d2287..a61e526 100644 --- a/backend/placeholder_registrations/activity_metrics.py +++ b/backend/placeholder_registrations/activity_metrics.py @@ -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" diff --git a/backend/placeholder_registrations/activity_session_insights.py b/backend/placeholder_registrations/activity_session_insights.py new file mode 100644 index 0000000..bb78f50 --- /dev/null +++ b/backend/placeholder_registrations/activity_session_insights.py @@ -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() diff --git a/backend/placeholder_registrations/body_extras.py b/backend/placeholder_registrations/body_extras.py new file mode 100644 index 0000000..180579b --- /dev/null +++ b/backend/placeholder_registrations/body_extras.py @@ -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() diff --git a/backend/placeholder_registrations/body_metrics.py b/backend/placeholder_registrations/body_metrics.py index b3087eb..15a6372 100644 --- a/backend/placeholder_registrations/body_metrics.py +++ b/backend/placeholder_registrations/body_metrics.py @@ -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 ( diff --git a/backend/placeholder_registrations/nutrition_part_c.py b/backend/placeholder_registrations/nutrition_part_c.py index 4f1f47e..e061988 100644 --- a/backend/placeholder_registrations/nutrition_part_c.py +++ b/backend/placeholder_registrations/nutrition_part_c.py @@ -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 diff --git a/backend/placeholder_registrations/nutrition_score.py b/backend/placeholder_registrations/nutrition_score.py new file mode 100644 index 0000000..8df0568 --- /dev/null +++ b/backend/placeholder_registrations/nutrition_score.py @@ -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) diff --git a/backend/placeholder_resolver.py b/backend/placeholder_resolver.py index 0f9718a..a2d8392 100644 --- a/backend/placeholder_resolver.py +++ b/backend/placeholder_resolver.py @@ -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)'), ], diff --git a/backend/routers/charts.py b/backend/routers/charts.py index 363138f..d97139d 100644 --- a/backend/routers/charts.py +++ b/backend/routers/charts.py @@ -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",