From 61a5bb39ae004507846f73dad2b6aa4a483f67b5 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 11 Apr 2026 19:04:27 +0200 Subject: [PATCH 1/9] feat: Update nutrition metrics and energy balance calculations - Introduced a single TDEE calculation based on current weight, replacing the fixed 2500 kcal value. - Updated `get_energy_balance_data` to use daily totals for intake calculations and improved energy balance logic. - Enhanced `get_nutrition_average_data` to calculate averages over calendar days instead of raw log entries. - Adjusted placeholder resolution to ensure consistent metadata usage across requests. - Fixed issues in the charts router to reflect the new energy balance logic and TDEE calculations. These changes improve the accuracy of nutritional assessments and streamline data handling in the application. --- .claude/docs/working/Test_status_Wkf.md | 6 +- CLAUDE.md | 10 + backend/data_layer/nutrition_metrics.py | 371 +++++++++++------------- backend/main.py | 4 + backend/placeholder_resolver.py | 2 +- backend/routers/charts.py | 78 +++-- backend/test_export.py | 65 +++++ 7 files changed, 305 insertions(+), 231 deletions(-) create mode 100644 backend/test_export.py diff --git a/.claude/docs/working/Test_status_Wkf.md b/.claude/docs/working/Test_status_Wkf.md index f62468c..3eb138b 100644 --- a/.claude/docs/working/Test_status_Wkf.md +++ b/.claude/docs/working/Test_status_Wkf.md @@ -5,7 +5,9 @@ Folgende Ergebnisse des Tests: - In der automatischen Zusammenfassung in der Endnode kommt als Überschrift, z.B. Node 10, anstatt den Node-Name auszugeben. - Alle Änderungen an Nodes scheinen automatisch in den Gesamtflow übernommen zu werden. Diese werden dann nach dem Speichern aktiv. Da muss man sehr vorsichtig sein, bei kurzen Änderungen und dem Ausprobieren. - Der Testlauf "Execute" sollte auf dem aktuellen Workflowstand ausgeführt werden, auch wenn dieser vom gespeicherten Abweicht. Ich würde natürlich vor dem Speichern den Workflow testen können. Prüfe und bewerte diesen Punkt, setze ihn aber noch nicht um. -- Die Workflows werden aktuell nicht in Analyse und den verfügbaren KI-Asuwertungen angezeigt. ggf. weil wir sie aktuell noch keinem Bereich zuordnen können. Diesen könnten wir ggf. über die Start-Node im Workflow konfigurieren. - Das löschen von Knoten und Kanten funktioniert aktuell nur über Backspace nicht über entfernen - Wir sollten auch dafür sorgen, dass jeweils nur eine Start-Node, End-Node in einem Workflow existiert, Prüfe ob mehrere End-Nodes sinnvoll sind, da wir ja auch Logik-Pfade abbilden und ggf. auch eine route beschreiten, die ein anderes Ende hat. (Prüfe, ob das heute schon möglich wäre!) -- Als zukünftige Ausbaustufe sollten wir überlegen, ob wir auch Trigger implementieren, z.B. um Kurzstatements zu generieren, wenn neue Daten hereinkommen und wir diese Bewertungen aktualisieren wollen \ No newline at end of file +- Als zukünftige Ausbaustufe sollten wir überlegen, ob wir auch Trigger implementieren, z.B. um Kurzstatements zu generieren, wenn neue Daten hereinkommen und wir diese Bewertungen aktualisieren wollen +- Exportieren aller KI-Prompts/Templates/Workflows im Admin --> KI-Prompts führt zu einem "internal Server Error", Importieren konnte daraufhin nicht getestet werden +- Das duplizieren von Workflows funktioniert nicht +- diff --git a/CLAUDE.md b/CLAUDE.md index 01e648f..be4abad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,6 +105,16 @@ frontend/src/ - **Regeln:** Verweise in `.claude/rules/ARCHITECTURE.md` (§3.2 `source`), `.claude/rules/CODING_RULES.md` (§6) - **Follow-ups:** **Gitea #71** – Dry-Run inkl. `import_row_processing`, Nutzer-Mapping-Validierung, Fehler-Hints in der Import-UI ([Issue](http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/71)) +### Updates (11.04.2026 - Placeholder Phase A) + +- **`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 - 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`. +- **`routers/charts.py`:** `/charts/energy-balance` und Protein-Timeline nutzen dieselbe TDEE-/Tageslogik; ohne `weight_log` liefert Energiebilanz-Chart eine klare Fehlermeldung. Adherence-Endpoint: Kcal-CV über **Tages-Summen**. + ### GUI / Informationsarchitektur (Abnahme dieser Iteration, 2026-04-05) Admin-Bereich (`AdminShell`, Hub-Routen), Hauptnavigation inkl. **Ziele** (`config/appNav.js`), Einstellungen nur aktives Profil + E-Mail, KI-Analyse Ergebnis in rechter Spalte, **PWA** Bottom-Nav inkl. iOS Safe Area. Zentrale Agent-Doku: **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`**. Responsive-Epic **Gitea #30:** Phasenplan `docs/issues/PHASE_PLAN_RESPONSIVE_UI.md` — **P7 Kern erledigt**, **P8** (Regression/Abnahme) ausstehend; Issue bewusst **nicht** geschlossen. diff --git a/backend/data_layer/nutrition_metrics.py b/backend/data_layer/nutrition_metrics.py index 055c337..4afa56e 100644 --- a/backend/data_layer/nutrition_metrics.py +++ b/backend/data_layer/nutrition_metrics.py @@ -25,6 +25,28 @@ from datetime import datetime, timedelta, date from db import get_db, get_cursor, r2d from data_layer.utils import calculate_confidence, safe_float, safe_int +# Single TDEE rule for placeholders, charts, and warnings (kcal/day = kg * factor). +# Replaces legacy fixed 2500 kcal so all consumers stay aligned. +TDEE_KCAL_PER_KG_BODYWEIGHT = 32.5 + + +def estimate_tdee_kcal_from_latest_weight(profile_id: str) -> Optional[float]: + """ + Estimated TDEE (kcal/day) from latest body weight. + Returns None if no weight on record. + """ + 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 or row["weight"] is None: + return None + return float(row["weight"]) * TDEE_KCAL_PER_KG_BODYWEIGHT + def get_nutrition_average_data( profile_id: str, @@ -56,20 +78,29 @@ def get_nutrition_average_data( cur = get_cursor(conn) cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + # Mean over calendar days (per-day sums), not over raw log rows. cur.execute( """SELECT - AVG(kcal) as kcal_avg, - AVG(protein_g) as protein_avg, - AVG(carbs_g) as carbs_avg, - AVG(fat_g) as fat_avg, - COUNT(*) as data_points - FROM nutrition_log - WHERE profile_id=%s AND date >= %s""", - (profile_id, cutoff) + AVG(daily_kcal) AS kcal_avg, + AVG(daily_protein) AS protein_avg, + AVG(daily_carbs) AS carbs_avg, + AVG(daily_fat) AS fat_avg, + COUNT(*)::int AS day_count + FROM ( + SELECT date, + COALESCE(SUM(kcal), 0)::float AS daily_kcal, + COALESCE(SUM(protein_g), 0)::float AS daily_protein, + COALESCE(SUM(carbs_g), 0)::float AS daily_carbs, + COALESCE(SUM(fat_g), 0)::float AS daily_fat + FROM nutrition_log + WHERE profile_id=%s AND date >= %s + GROUP BY date + ) AS daily""", + (profile_id, cutoff), ) row = cur.fetchone() - if not row or row['data_points'] == 0: + if not row or row["day_count"] == 0: return { "kcal_avg": 0.0, "protein_avg": 0.0, @@ -80,7 +111,7 @@ def get_nutrition_average_data( "days_analyzed": days } - data_points = row['data_points'] + data_points = row["day_count"] confidence = calculate_confidence(data_points, days, "general") return { @@ -190,79 +221,73 @@ def get_energy_balance_data( days: int = 7 ) -> Dict: """ - Calculate energy balance (intake - estimated expenditure). + Energy balance (intake - estimated expenditure), kcal/day. - Note: This is a simplified calculation. - For accurate TDEE, use profile-based calculations. - - Args: - profile_id: User profile ID - days: Analysis window (default 7) - - Returns: - { - "energy_balance": float, # kcal/day (negative = deficit) - "avg_intake": float, - "estimated_tdee": float, - "status": str, # "deficit" | "surplus" | "maintenance" - "confidence": str, - "days_analyzed": int, - "data_points": int - } + Intake: mean of daily total kcal (sum per calendar day). + TDEE: latest weight (kg) * TDEE_KCAL_PER_KG_BODYWEIGHT (same rule as placeholders). """ with get_db() as conn: cur = get_cursor(conn) cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') - # Get average intake cur.execute( - """SELECT AVG(kcal) as avg_kcal, COUNT(*) as cnt + """SELECT date, SUM(kcal)::float AS daily_kcal FROM nutrition_log - WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL""", - (profile_id, cutoff) + WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL + GROUP BY date + ORDER BY date""", + (profile_id, cutoff), ) - row = cur.fetchone() - - if not row or row['cnt'] == 0: - return { - "energy_balance": 0.0, - "avg_intake": 0.0, - "estimated_tdee": 0.0, - "status": "unknown", - "confidence": "insufficient", - "days_analyzed": days, - "data_points": 0 - } - - avg_intake = safe_float(row['avg_kcal']) - data_points = row['cnt'] - - # Simple TDEE estimation (this should be improved with profile data) - # For now, use a rough estimate: 2500 kcal for average adult - estimated_tdee = 2500.0 # TODO: Calculate from profile (weight, height, age, activity) - - energy_balance = avg_intake - estimated_tdee - - # Determine status - if energy_balance < -200: - status = "deficit" - elif energy_balance > 200: - status = "surplus" - else: - status = "maintenance" - - confidence = calculate_confidence(data_points, days, "general") + daily_rows = cur.fetchall() + if not daily_rows: return { - "energy_balance": energy_balance, + "energy_balance": 0.0, + "avg_intake": 0.0, + "estimated_tdee": 0.0, + "status": "unknown", + "confidence": "insufficient", + "days_analyzed": days, + "data_points": 0, + } + + daily_totals = [safe_float(r["daily_kcal"]) for r in daily_rows] + avg_intake = sum(daily_totals) / len(daily_totals) + data_points = len(daily_totals) + + estimated_tdee = estimate_tdee_kcal_from_latest_weight(profile_id) + if estimated_tdee is None: + return { + "energy_balance": 0.0, "avg_intake": avg_intake, - "estimated_tdee": estimated_tdee, - "status": status, - "confidence": confidence, + "estimated_tdee": 0.0, + "status": "unknown", + "confidence": "insufficient", "days_analyzed": days, "data_points": data_points } + energy_balance = avg_intake - estimated_tdee + + if energy_balance < -200: + status = "deficit" + elif energy_balance > 200: + status = "surplus" + else: + status = "maintenance" + + confidence = calculate_confidence(data_points, days, "general") + + return { + "energy_balance": energy_balance, + "avg_intake": avg_intake, + "estimated_tdee": estimated_tdee, + "status": status, + "confidence": confidence, + "days_analyzed": days, + "data_points": data_points + } + def get_protein_adequacy_data( profile_id: str, @@ -291,7 +316,6 @@ def get_protein_adequacy_data( "confidence": str } """ - # Get protein targets targets = get_protein_targets_data(profile_id) with get_db() as conn: @@ -299,60 +323,55 @@ def get_protein_adequacy_data( cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') cur.execute( - """SELECT - AVG(protein_g) as avg_protein, - COUNT(*) as cnt, - SUM(CASE WHEN protein_g >= %s AND protein_g <= %s THEN 1 ELSE 0 END) as days_in_target + """SELECT COALESCE(SUM(protein_g), 0)::float AS daily_protein FROM nutrition_log - WHERE profile_id=%s AND date >= %s AND protein_g IS NOT NULL""", - (targets['protein_target_low'], targets['protein_target_high'], profile_id, cutoff) + WHERE profile_id=%s AND date >= %s + GROUP BY date""", + (profile_id, cutoff), ) - row = cur.fetchone() - - if not row or row['cnt'] == 0: - return { - "adequacy_score": 0, - "avg_protein_g": 0.0, - "target_protein_low": targets['protein_target_low'], - "target_protein_high": targets['protein_target_high'], - "protein_g_per_kg": 0.0, - "days_in_target": 0, - "days_with_data": 0, - "confidence": "insufficient" - } - - avg_protein = safe_float(row['avg_protein']) - days_with_data = row['cnt'] - days_in_target = row['days_in_target'] - - protein_g_per_kg = avg_protein / targets['current_weight'] if targets['current_weight'] > 0 else 0.0 - - # Calculate adequacy score - # 100 = always in target range - # Scale based on percentage of days in target + average relative to target - target_pct = (days_in_target / days_with_data * 100) if days_with_data > 0 else 0 - - # Bonus/penalty for average protein level - target_mid = (targets['protein_target_low'] + targets['protein_target_high']) / 2 - avg_vs_target = (avg_protein / target_mid) if target_mid > 0 else 0 - - # Weighted score: 70% target days, 30% average level - adequacy_score = int(target_pct * 0.7 + min(avg_vs_target * 100, 100) * 0.3) - adequacy_score = max(0, min(100, adequacy_score)) # Clamp to 0-100 - - confidence = calculate_confidence(days_with_data, days, "general") + rows = cur.fetchall() + if not rows or targets.get("confidence") == "insufficient" or targets["current_weight"] <= 0: return { - "adequacy_score": adequacy_score, - "avg_protein_g": avg_protein, + "adequacy_score": 0, + "avg_protein_g": 0.0, "target_protein_low": targets['protein_target_low'], "target_protein_high": targets['protein_target_high'], - "protein_g_per_kg": protein_g_per_kg, - "days_in_target": days_in_target, - "days_with_data": days_with_data, - "confidence": confidence + "protein_g_per_kg": 0.0, + "days_in_target": 0, + "days_with_data": 0, + "confidence": "insufficient" } + daily_totals = [safe_float(r["daily_protein"]) for r in rows] + days_with_data = len(daily_totals) + low = targets["protein_target_low"] + high = targets["protein_target_high"] + days_in_target = sum(1 for d in daily_totals if low <= d <= high) + + avg_protein = sum(daily_totals) / days_with_data + protein_g_per_kg = avg_protein / targets["current_weight"] if targets["current_weight"] > 0 else 0.0 + + target_pct = (days_in_target / days_with_data * 100) if days_with_data > 0 else 0 + target_mid = (low + high) / 2 + avg_vs_target = (avg_protein / target_mid) if target_mid > 0 else 0 + + adequacy_score = int(target_pct * 0.7 + min(avg_vs_target * 100, 100) * 0.3) + adequacy_score = max(0, min(100, adequacy_score)) + + confidence = calculate_confidence(days_with_data, days, "general") + + return { + "adequacy_score": adequacy_score, + "avg_protein_g": avg_protein, + "target_protein_low": targets['protein_target_low'], + "target_protein_high": targets['protein_target_high'], + "protein_g_per_kg": protein_g_per_kg, + "days_in_target": days_in_target, + "days_with_data": days_with_data, + "confidence": confidence + } + def get_macro_consistency_data( profile_id: str, @@ -387,16 +406,18 @@ def get_macro_consistency_data( cur.execute( """SELECT - protein_g, carbs_g, fat_g, kcal + COALESCE(SUM(kcal), 0)::float AS kcal, + COALESCE(SUM(protein_g), 0)::float AS protein_g, + COALESCE(SUM(carbs_g), 0)::float AS carbs_g, + COALESCE(SUM(fat_g), 0)::float AS fat_g FROM nutrition_log - WHERE profile_id=%s - AND date >= %s - AND protein_g IS NOT NULL - AND carbs_g IS NOT NULL - AND fat_g IS NOT NULL - AND kcal > 0 - ORDER BY date""", - (profile_id, cutoff) + WHERE profile_id=%s AND date >= %s + GROUP BY date + HAVING COALESCE(SUM(kcal), 0) > 0 + AND COALESCE(SUM(protein_g), 0) > 0 + AND COALESCE(SUM(carbs_g), 0) > 0 + AND COALESCE(SUM(fat_g), 0) > 0""", + (profile_id, cutoff), ) rows = cur.fetchall() @@ -413,7 +434,6 @@ def get_macro_consistency_data( "data_points": len(rows) } - # Calculate macro percentages for each day import statistics protein_pcts = [] @@ -425,7 +445,6 @@ def get_macro_consistency_data( if total_kcal == 0: continue - # Convert grams to kcal (protein=4, carbs=4, fat=9) protein_kcal = safe_float(row['protein_g']) * 4 carbs_kcal = safe_float(row['carbs_g']) * 4 fat_kcal = safe_float(row['fat_g']) * 9 @@ -491,50 +510,15 @@ def get_macro_consistency_data( def calculate_energy_balance_7d(profile_id: str) -> Optional[float]: """ - Calculate 7-day average energy balance (kcal/day) - Positive = surplus, Negative = deficit - - Migration from Phase 0b: - Used by placeholders that need single balance value + 7-day mean energy balance (kcal/day), same rules as get_energy_balance_data(..., 7). """ - with get_db() as conn: - cur = get_cursor(conn) - cur.execute(""" - SELECT kcal - FROM nutrition_log - WHERE profile_id = %s - AND date >= CURRENT_DATE - INTERVAL '7 days' - ORDER BY date DESC - """, (profile_id,)) - - calories = [row['kcal'] for row in cur.fetchall()] - - if len(calories) < 4: # Need at least 4 days - return None - - avg_intake = float(sum(calories) / len(calories)) - - # Get estimated TDEE (simplified - could use Harris-Benedict) - # For now, use weight-based estimate - cur.execute(""" - SELECT weight - FROM weight_log - WHERE profile_id = %s - ORDER BY date DESC - LIMIT 1 - """, (profile_id,)) - - weight_row = cur.fetchone() - if not weight_row: - return None - - # Simple TDEE estimate: bodyweight (kg) × 30-35 - # TODO: Improve with activity level, age, gender - estimated_tdee = float(weight_row['weight']) * 32.5 - - balance = avg_intake - estimated_tdee - - return round(balance, 0) + data = get_energy_balance_data(profile_id, 7) + if data["data_points"] < 4: + return None + tdee = data.get("estimated_tdee") or 0 + if tdee <= 0: + return None + return round(float(data["energy_balance"]), 0) def calculate_energy_deficit_surplus(profile_id: str, days: int = 7) -> Optional[str]: @@ -654,15 +638,14 @@ def calculate_protein_days_in_target(profile_id: str, target_low: float = 1.6, t def calculate_protein_adequacy_28d(profile_id: str) -> Optional[int]: """ - Protein adequacy score 0-100 (last 28 days) - Based on consistency and target achievement + Protein adequacy score 0-100 (last 28 days). + Uses per-calendar-day total protein vs. average weight in the window (g/kg per day). """ import statistics with get_db() as conn: cur = get_cursor(conn) - # Get average weight (28d) cur.execute(""" SELECT AVG(weight) as avg_weight FROM weight_log @@ -676,38 +659,29 @@ def calculate_protein_adequacy_28d(profile_id: str) -> Optional[int]: weight = float(weight_row['avg_weight']) - # Get protein intake (28d) cur.execute(""" - SELECT protein_g + SELECT COALESCE(SUM(protein_g), 0)::float AS daily_protein FROM nutrition_log WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '28 days' - AND protein_g IS NOT NULL + GROUP BY date """, (profile_id,)) - protein_values = [float(row['protein_g']) for row in cur.fetchall()] + daily_totals = [float(row['daily_protein']) for row in cur.fetchall()] - if len(protein_values) < 18: # 60% coverage + if len(daily_totals) < 18: return None - # Calculate metrics - protein_per_kg_values = [p / weight for p in protein_values] + protein_per_kg_values = [p / weight for p in daily_totals] avg_protein_per_kg = sum(protein_per_kg_values) / len(protein_per_kg_values) - # Target range: 1.6-2.2 g/kg for active individuals - target_mid = 1.9 - - # Score based on distance from target if 1.6 <= avg_protein_per_kg <= 2.2: base_score = 100 elif avg_protein_per_kg < 1.6: - # Below target base_score = max(40, 100 - ((1.6 - avg_protein_per_kg) * 40)) else: - # Above target (less penalty) base_score = max(80, 100 - ((avg_protein_per_kg - 2.2) * 10)) - # Consistency bonus/penalty std_dev = statistics.stdev(protein_per_kg_values) if std_dev < 0.3: consistency_bonus = 10 @@ -723,20 +697,24 @@ def calculate_protein_adequacy_28d(profile_id: str) -> Optional[int]: def calculate_macro_consistency_score(profile_id: str) -> Optional[int]: """ - Macro consistency score 0-100 (last 28 days) - Lower variability = higher score + Macro consistency score 0-100 (last 28 days). + CV of daily totals (kcal and macros), not raw log rows. """ import statistics with get_db() as conn: cur = get_cursor(conn) cur.execute(""" - SELECT kcal, protein_g, fat_g, carbs_g + SELECT + COALESCE(SUM(kcal), 0)::float AS dk, + COALESCE(SUM(protein_g), 0)::float AS dp, + COALESCE(SUM(fat_g), 0)::float AS df, + COALESCE(SUM(carbs_g), 0)::float AS dc FROM nutrition_log WHERE profile_id = %s AND date >= CURRENT_DATE - INTERVAL '28 days' - AND kcal IS NOT NULL - ORDER BY date DESC + GROUP BY date + HAVING COALESCE(SUM(kcal), 0) > 0 """, (profile_id,)) data = cur.fetchall() @@ -744,9 +722,7 @@ def calculate_macro_consistency_score(profile_id: str) -> Optional[int]: if len(data) < 18: return None - # Calculate coefficient of variation for each macro def cv(values): - """Coefficient of variation (std_dev / mean)""" if not values or len(values) < 2: return None mean = sum(values) / len(values) @@ -755,10 +731,10 @@ def calculate_macro_consistency_score(profile_id: str) -> Optional[int]: std_dev = statistics.stdev(values) return std_dev / mean - calories_cv = cv([d['kcal'] for d in data]) - protein_cv = cv([d['protein_g'] for d in data if d['protein_g']]) - fat_cv = cv([d['fat_g'] for d in data if d['fat_g']]) - carbs_cv = cv([d['carbs_g'] for d in data if d['carbs_g']]) + calories_cv = cv([d['dk'] for d in data]) + protein_cv = cv([d['dp'] for d in data if d['dp']]) + fat_cv = cv([d['df'] for d in data if d['df']]) + carbs_cv = cv([d['dc'] for d in data if d['dc']]) cv_values = [v for v in [calories_cv, protein_cv, fat_cv, carbs_cv] if v is not None] @@ -767,9 +743,6 @@ def calculate_macro_consistency_score(profile_id: str) -> Optional[int]: avg_cv = sum(cv_values) / len(cv_values) - # Score: lower CV = higher score - # CV < 0.2 = excellent consistency - # CV > 0.5 = poor consistency if avg_cv < 0.2: score = 100 elif avg_cv < 0.3: diff --git a/backend/main.py b/backend/main.py index ea30f84..825cdb8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,6 +14,10 @@ from slowapi.errors import RateLimitExceeded from db import init_db +# Placeholder registry: load all register_placeholder() side-effects before any request +# so get_placeholder_catalog() and exports see consistent metadata (see Phase A plan). +import placeholder_registrations # noqa: F401 + # Import routers from routers import auth, profiles, weight, circumference, caliper from routers import activity, nutrition, photos, insights, prompts diff --git a/backend/placeholder_resolver.py b/backend/placeholder_resolver.py index b646210..0f9718a 100644 --- a/backend/placeholder_resolver.py +++ b/backend/placeholder_resolver.py @@ -1133,7 +1133,7 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = { # --- Top-Weighted Goals/Focus Areas (Ebene 2: statt Primary) --- '{{top_goal_name}}': lambda pid: _safe_str('top_goal_name', pid), - '{{top_goal_progress_pct}}': lambda pid: _safe_str('top_goal_progress_pct', pid), + '{{top_goal_progress_pct}}': lambda pid: _safe_int('top_goal_progress_pct', pid), '{{top_goal_status}}': lambda pid: _safe_str('top_goal_status', pid), '{{top_focus_area_name}}': lambda pid: _safe_str('top_focus_area_name', pid), '{{top_focus_area_progress}}': lambda pid: _safe_int('top_focus_area_progress', pid), diff --git a/backend/routers/charts.py b/backend/routers/charts.py index 80da896..363138f 100644 --- a/backend/routers/charts.py +++ b/backend/routers/charts.py @@ -35,7 +35,8 @@ from data_layer.nutrition_metrics import ( get_nutrition_average_data, get_protein_targets_data, get_protein_adequacy_data, - get_macro_consistency_data + get_macro_consistency_data, + get_energy_balance_data, ) from data_layer.activity_metrics import ( get_activity_summary_data, @@ -346,17 +347,20 @@ def get_energy_balance_chart( """ profile_id = session['profile_id'] + balance_meta = get_energy_balance_data(profile_id, days) + 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, kcal + """SELECT date, SUM(kcal)::float AS kcal FROM nutrition_log WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL + GROUP BY date ORDER BY date""", - (profile_id, cutoff) + (profile_id, cutoff), ) rows = cur.fetchall() @@ -374,7 +378,21 @@ def get_energy_balance_chart( } } - # Prepare data + estimated_tdee = balance_meta.get("estimated_tdee") or 0 + if estimated_tdee <= 0: + return { + "chart_type": "line", + "data": { + "labels": [], + "datasets": [] + }, + "metadata": { + "confidence": "insufficient", + "data_points": len(rows), + "message": "Kein Gewicht für TDEE-Schätzung (weight_log erforderlich)" + } + } + labels = [] daily_values = [] avg_7d = [] @@ -384,23 +402,19 @@ def get_energy_balance_chart( labels.append(row['date'].isoformat()) daily_values.append(safe_float(row['kcal'])) - # 7d rolling average start_7d = max(0, i - 6) window_7d = [safe_float(rows[j]['kcal']) for j in range(start_7d, i + 1)] avg_7d.append(round(sum(window_7d) / len(window_7d), 1) if window_7d else None) - # 14d rolling average start_14d = max(0, i - 13) window_14d = [safe_float(rows[j]['kcal']) for j in range(start_14d, i + 1)] avg_14d.append(round(sum(window_14d) / len(window_14d), 1) if window_14d else None) - # Calculate TDEE (estimated, should come from profile) - # TODO: Calculate from profile (weight, height, age, activity level) - estimated_tdee = 2500.0 - - # Calculate deficit/surplus - avg_intake = sum(daily_values) / len(daily_values) if daily_values else 0 - energy_balance = avg_intake - estimated_tdee + avg_intake = float(balance_meta.get("avg_intake") or (sum(daily_values) / len(daily_values) if daily_values else 0)) + energy_balance = float(balance_meta.get("energy_balance") or (avg_intake - estimated_tdee)) + balance_status = balance_meta.get("status") or ( + "deficit" if energy_balance < -200 else "surplus" if energy_balance > 200 else "maintenance" + ) datasets = [ { @@ -443,8 +457,7 @@ def get_energy_balance_chart( } ] - from data_layer.utils import calculate_confidence - confidence = calculate_confidence(len(rows), days, "general") + confidence = balance_meta.get("confidence") or "low" return { "chart_type": "line", @@ -458,7 +471,7 @@ def get_energy_balance_chart( "avg_kcal": round(avg_intake, 1), "estimated_tdee": estimated_tdee, "energy_balance": round(energy_balance, 1), - "balance_status": "deficit" if energy_balance < -200 else "surplus" if energy_balance > 200 else "maintenance", + "balance_status": balance_status, "first_date": rows[0]['date'], "last_date": rows[-1]['date'] }) @@ -587,11 +600,12 @@ def get_protein_adequacy_chart( cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') cur.execute( - """SELECT date, protein_g + """SELECT date, SUM(protein_g)::float AS protein_g FROM nutrition_log WHERE profile_id=%s AND date >= %s AND protein_g IS NOT NULL + GROUP BY date ORDER BY date""", - (profile_id, cutoff) + (profile_id, cutoff), ) rows = cur.fetchall() @@ -687,7 +701,6 @@ def get_protein_adequacy_chart( from data_layer.utils import calculate_confidence confidence = calculate_confidence(len(rows), days, "general") - # Count days in target days_in_target = sum(1 for v in daily_values if target_low <= v <= target_high) return { @@ -978,16 +991,23 @@ def get_nutrition_adherence_score( # Get nutrition data cur.execute( - """SELECT COUNT(*) as cnt, - AVG(kcal) as avg_kcal, - STDDEV(kcal) as std_kcal, - AVG(protein_g) as avg_protein, - AVG(carbs_g) as avg_carbs, - AVG(fat_g) as avg_fat - FROM nutrition_log - WHERE profile_id=%s AND date >= %s - AND kcal IS NOT NULL""", - (profile_id, cutoff) + """WITH daily AS ( + SELECT date, + COALESCE(SUM(kcal), 0)::float AS dk, + COALESCE(SUM(protein_g), 0)::float AS dp, + COALESCE(SUM(carbs_g), 0)::float AS dc, + COALESCE(SUM(fat_g), 0)::float AS df FROM nutrition_log + WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL + GROUP BY date + ) + SELECT COUNT(*)::int AS cnt, + AVG(dk) AS avg_kcal, + STDDEV(dk) AS std_kcal, + AVG(dp) AS avg_protein, + AVG(dc) AS avg_carbs, + AVG(df) AS avg_fat + FROM daily""", + (profile_id, cutoff), ) stats = cur.fetchone() diff --git a/backend/test_export.py b/backend/test_export.py new file mode 100644 index 0000000..3897cb9 --- /dev/null +++ b/backend/test_export.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Test export-all endpoint""" +import sys +sys.path.insert(0, '/app') + +from datetime import datetime +from db import get_db, get_cursor, r2d + +print("Testing export-all logic...") + +with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT * FROM ai_prompts ORDER BY sort_order, slug") + prompts = [r2d(row) for row in cur.fetchall()] + +print(f"Found {len(prompts)} prompts") + +# Convert to export format (clean up DB-specific fields) +export_data = [] +for idx, p in enumerate(prompts): + print(f"\nProcessing prompt {idx+1}: {p.get('slug')}") + try: + export_item = { + 'slug': p['slug'], + 'name': p['name'], + 'display_name': p.get('display_name'), + 'description': p.get('description'), + 'type': p.get('type', 'pipeline'), + 'category': p.get('category', 'ganzheitlich'), + 'template': p.get('template'), + 'stages': p.get('stages'), + 'output_format': p.get('output_format', 'text'), + 'output_schema': p.get('output_schema'), + 'question_augmentations': p.get('question_augmentations'), + 'graph_data': p.get('graph_data'), + 'active': p.get('active', True), + 'sort_order': p.get('sort_order', 0) + } + export_data.append(export_item) + print(f" ✓ OK") + except Exception as e: + print(f" ✗ ERROR: {type(e).__name__}: {e}") + import traceback + traceback.print_exc() + break + +print(f"\n\nSuccessfully processed {len(export_data)} prompts") + +# Try to create the response dict +try: + result = { + 'export_date': datetime.now().isoformat(), + 'count': len(export_data), + 'prompts': export_data + } + print("✓ Result dict created successfully") + + # Try JSON serialization + import json + json_str = json.dumps(result) + print(f"✓ JSON serialization OK, length: {len(json_str)}") +except Exception as e: + print(f"✗ ERROR creating result: {type(e).__name__}: {e}") + import traceback + traceback.print_exc() From e9e094c6a4c1a416ad7f3b4aad933722a0f6ef06 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 11 Apr 2026 20:46:17 +0200 Subject: [PATCH 2/9] 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. --- CLAUDE.md | 5 + backend/data_layer/__init__.py | 6 + backend/data_layer/activity_metrics.py | 272 +++++++++++++++++- backend/data_layer/body_metrics.py | 114 +++++++- backend/placeholder_registrations/__init__.py | 14 +- .../activity_metrics.py | 21 +- .../activity_session_insights.py | 184 ++++++++++++ .../placeholder_registrations/body_extras.py | 237 +++++++++++++++ .../placeholder_registrations/body_metrics.py | 5 +- .../nutrition_part_c.py | 7 +- .../nutrition_score.py | 101 +++++++ backend/placeholder_resolver.py | 219 +++++++++----- backend/routers/charts.py | 24 +- 13 files changed, 1092 insertions(+), 117 deletions(-) create mode 100644 backend/placeholder_registrations/activity_session_insights.py create mode 100644 backend/placeholder_registrations/body_extras.py create mode 100644 backend/placeholder_registrations/nutrition_score.py 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", From 2ea5f905c458bcf91e2313ced09ccf3c4901b370 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 11 Apr 2026 21:08:34 +0200 Subject: [PATCH 3/9] feat: Add new profile and time period placeholders in placeholder_resolver.py - Introduced functions to retrieve profile name, age, height, and gender for better placeholder resolution. - Added functions for displaying current date and time period labels (last 7, 30, and 90 days). - Updated PLACEHOLDER_MAP to utilize new functions for improved readability and maintainability. - Enhanced placeholder registrations in __init__.py to include new modules for sleep, vital metrics, and profile time periods. These changes enhance the flexibility and functionality of the placeholder system, allowing for more dynamic content generation. --- backend/placeholder_registrations/__init__.py | 12 + .../placeholder_registrations/_evidence.py | 19 + .../korrelationen.py | 96 +++++ .../phase_0b_meta_scores.py | 66 +++ .../phase_0b_ziele_fokus.py | 392 ++++++++++++++++++ .../profil_zeitraum.py | 139 +++++++ .../schlaf_erholung.py | 236 +++++++++++ .../placeholder_registrations/vitalwerte.py | 180 ++++++++ backend/placeholder_resolver.py | 161 ++++--- 9 files changed, 1233 insertions(+), 68 deletions(-) create mode 100644 backend/placeholder_registrations/_evidence.py create mode 100644 backend/placeholder_registrations/korrelationen.py create mode 100644 backend/placeholder_registrations/phase_0b_meta_scores.py create mode 100644 backend/placeholder_registrations/phase_0b_ziele_fokus.py create mode 100644 backend/placeholder_registrations/profil_zeitraum.py create mode 100644 backend/placeholder_registrations/schlaf_erholung.py create mode 100644 backend/placeholder_registrations/vitalwerte.py diff --git a/backend/placeholder_registrations/__init__.py b/backend/placeholder_registrations/__init__.py index 3d08e9f..a70185a 100644 --- a/backend/placeholder_registrations/__init__.py +++ b/backend/placeholder_registrations/__init__.py @@ -13,6 +13,12 @@ from . import body_metrics from . import body_extras from . import activity_metrics from . import activity_session_insights +from . import schlaf_erholung +from . import vitalwerte +from . import profil_zeitraum +from . import phase_0b_meta_scores +from . import phase_0b_ziele_fokus +from . import korrelationen __all__ = [ 'nutrition_part_a', @@ -23,4 +29,10 @@ __all__ = [ 'body_extras', 'activity_metrics', 'activity_session_insights', + 'schlaf_erholung', + 'vitalwerte', + 'profil_zeitraum', + 'phase_0b_meta_scores', + 'phase_0b_ziele_fokus', + 'korrelationen', ] diff --git a/backend/placeholder_registrations/_evidence.py b/backend/placeholder_registrations/_evidence.py new file mode 100644 index 0000000..0fcc430 --- /dev/null +++ b/backend/placeholder_registrations/_evidence.py @@ -0,0 +1,19 @@ +"""Gemeinsames Evidence-Tagging für Registry-Einträge.""" + +from placeholder_registry import EvidenceType, PlaceholderMetadata + +STANDARD_FIELDS = ( + "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", +) + + +def tag_standard_evidence(meta: PlaceholderMetadata) -> None: + for field in STANDARD_FIELDS: + meta.set_evidence(field, EvidenceType.CODE_DERIVED) + meta.set_evidence("business_meaning", EvidenceType.DRAFT_DERIVED) + meta.set_evidence("known_limitations", EvidenceType.MIXED) diff --git a/backend/placeholder_registrations/korrelationen.py b/backend/placeholder_registrations/korrelationen.py new file mode 100644 index 0000000..f7f4dc8 --- /dev/null +++ b/backend/placeholder_registrations/korrelationen.py @@ -0,0 +1,96 @@ +"""Registry: Korrelations- und Treiber-Metriken (Data Layer correlations).""" + +from placeholder_registry import ( + PlaceholderMetadata, + MissingValuePolicy, + OutputType, + PlaceholderType, + register_placeholder, +) +from ._evidence import tag_standard_evidence + +CAT = "Korrelationen" +MVP = lambda reason, disp: MissingValuePolicy( + available=False, value_raw=None, missing_reason=reason, legacy_display=disp +) + + +def register_korrelationen(): + for key, dl_fn, desc, tables, sem in [ + ( + "correlation_energy_weight_lag", + "calculate_lag_correlation", + "JSON: Lag-Korrelation Energiebilanz ↔ Gewicht", + ["nutrition_log", "weight_log"], + "correlations.calculate_lag_correlation(pid, 'energy', 'weight')", + ), + ( + "correlation_protein_lbm", + "calculate_lag_correlation", + "JSON: Lag-Korrelation Protein ↔ Magermasse", + ["nutrition_log", "weight_log", "caliper_log"], + "correlations.calculate_lag_correlation(pid, 'protein', 'lbm')", + ), + ( + "correlation_load_hrv", + "calculate_lag_correlation", + "JSON: Lag-Korrelation Trainingslast ↔ HRV", + ["activity_log", "vitals_baseline"], + "correlations.calculate_lag_correlation(pid, 'training_load', 'hrv')", + ), + ( + "correlation_load_rhr", + "calculate_lag_correlation", + "JSON: Lag-Korrelation Trainingslast ↔ Ruhepuls", + ["activity_log", "vitals_baseline"], + "correlations.calculate_lag_correlation(pid, 'training_load', 'rhr')", + ), + ( + "plateau_detected", + "calculate_plateau_detected", + "JSON: Platten-Erkennung (Gewicht/Körper)", + ["weight_log", "caliper_log"], + "correlations.calculate_plateau_detected", + ), + ( + "top_drivers", + "calculate_top_drivers", + "JSON: Top Treiber für Ziel-/Score-Variablen", + ["weight_log", "nutrition_log", "activity_log", "vitals_baseline", "sleep_log"], + "correlations.calculate_top_drivers", + ), + ]: + m = PlaceholderMetadata( + key=key, + category=CAT, + description=desc, + resolver_module="backend/placeholder_resolver.py", + resolver_function="_safe_json", + data_layer_module="backend/data_layer/correlations.py", + data_layer_function=dl_fn, + source_tables=tables, + semantic_contract=sem, + business_meaning="Strukturierte Korrelationsausgabe für KI", + unit="JSON", + time_window="funktionsintern", + output_type=OutputType.JSON, + placeholder_type=PlaceholderType.RAW_DATA, + format_hint="JSON-String", + example_output="{}", + minimum_data_requirements="Ausreichend gekoppelte Zeitreihen", + quality_filter_policy=None, + confidence_logic="Wie correlations.*", + missing_value_policy=MVP("insufficient_data", "{}"), + known_limitations="Bei wenigen Daten leer oder wenig robust", + layer_1_decision=f"correlations.{dl_fn}", + layer_2a_decision="_safe_json", + layer_2b_reuse_possible=True, + architecture_alignment="Phase 0c", + issue_53_alignment="Layer 1", + evidence={}, + ) + tag_standard_evidence(m) + register_placeholder(m) + + +register_korrelationen() diff --git a/backend/placeholder_registrations/phase_0b_meta_scores.py b/backend/placeholder_registrations/phase_0b_meta_scores.py new file mode 100644 index 0000000..fa15295 --- /dev/null +++ b/backend/placeholder_registrations/phase_0b_meta_scores.py @@ -0,0 +1,66 @@ +"""Registry: Meta-Scores (Phase 0b) — Ziel-Fortschritt und Datenqualität.""" + +from placeholder_registry import ( + PlaceholderMetadata, + MissingValuePolicy, + OutputType, + PlaceholderType, + register_placeholder, +) +from ._evidence import tag_standard_evidence + +CAT = "Scores (Phase 0b)" +MVP = lambda reason, disp: MissingValuePolicy( + available=False, value_raw=None, missing_reason=reason, legacy_display=disp +) + + +def register_phase_0b_meta_scores(): + for key, dl_fn, desc, unit in [ + ( + "goal_progress_score", + "calculate_goal_progress_score", + "Aggregierter Ziel-Fortschritt 0–100", + "0–100", + ), + ( + "data_quality_score", + "calculate_data_quality_score", + "Datenqualitäts-Score 0–100", + "0–100", + ), + ]: + m = PlaceholderMetadata( + key=key, + category=CAT, + description=desc, + resolver_module="backend/placeholder_resolver.py", + resolver_function="_safe_int", + data_layer_module="backend/data_layer/scores.py", + data_layer_function=dl_fn, + source_tables=["goals", "weight_log", "nutrition_log", "activity_log", "profiles"], + semantic_contract=f"scores.{dl_fn} (siehe Data Layer).", + business_meaning="Meta-KPI für Prompt-Gewichtung", + unit=unit, + time_window="composite", + output_type=OutputType.NUMERIC, + placeholder_type=PlaceholderType.SCORE, + format_hint="Ganzzahl als String", + example_output="72", + minimum_data_requirements="Abhängig von Score-Implementierung", + quality_filter_policy=None, + confidence_logic="Wie calculate_* in scores.py", + missing_value_policy=MVP("insufficient_data", "nicht verfügbar"), + known_limitations="Bei dünnen Daten weniger aussagekräftig", + layer_1_decision=f"scores.{dl_fn}", + layer_2a_decision="_safe_int", + layer_2b_reuse_possible=True, + architecture_alignment="Phase 0b", + issue_53_alignment="Layer 1", + evidence={}, + ) + tag_standard_evidence(m) + register_placeholder(m) + + +register_phase_0b_meta_scores() diff --git a/backend/placeholder_registrations/phase_0b_ziele_fokus.py b/backend/placeholder_registrations/phase_0b_ziele_fokus.py new file mode 100644 index 0000000..1c1009e --- /dev/null +++ b/backend/placeholder_registrations/phase_0b_ziele_fokus.py @@ -0,0 +1,392 @@ +"""Registry: Ziele, Fokusbereiche, Kategorie-Scores und formatierte Listen (Phase 0b).""" + +from placeholder_registry import ( + PlaceholderMetadata, + MissingValuePolicy, + OutputType, + PlaceholderType, + register_placeholder, +) +from ._evidence import tag_standard_evidence + +CAT = "Ziele & Fokus (Phase 0b)" +MVP = lambda reason, disp: MissingValuePolicy( + available=False, value_raw=None, missing_reason=reason, legacy_display=disp +) + + +def register_phase_0b_ziele_fokus(): + # Top-Ziel / Top-Fokusbereich + m = PlaceholderMetadata( + key="top_goal_name", + category=CAT, + description="Name/Typ des höchstpriorisierten Ziels", + resolver_module="backend/placeholder_resolver.py", + resolver_function="_safe_str", + data_layer_module="backend/data_layer/scores.py", + data_layer_function="get_top_priority_goal", + source_tables=["goals"], + semantic_contract="Feld name oder goal_type aus get_top_priority_goal", + business_meaning="Priorisierung für KI-Empfehlungen", + unit="text", + time_window="aktuell", + output_type=OutputType.STRING, + placeholder_type=PlaceholderType.INTERPRETED, + format_hint="Kurztext", + example_output="Gewicht 80kg", + minimum_data_requirements="Mindestens ein aktives Ziel", + quality_filter_policy=None, + confidence_logic="scores.get_top_priority_goal", + missing_value_policy=MVP("insufficient_data", "nicht verfügbar"), + known_limitations=None, + layer_1_decision="scores.get_top_priority_goal", + layer_2a_decision="_safe_str('top_goal_name')", + layer_2b_reuse_possible=False, + architecture_alignment="Phase 0b", + issue_53_alignment="Layer 1", + evidence={}, + ) + tag_standard_evidence(m) + register_placeholder(m) + + m = PlaceholderMetadata( + key="top_goal_progress_pct", + category=CAT, + description="Fortschritt Top-Ziel (%)", + resolver_module="backend/placeholder_resolver.py", + resolver_function="_safe_int", + data_layer_module="backend/data_layer/scores.py", + data_layer_function="get_top_priority_goal", + source_tables=["goals"], + semantic_contract="progress_pct aus get_top_priority_goal", + business_meaning="Priorisierung für KI-Empfehlungen", + unit="%", + time_window="aktuell", + output_type=OutputType.NUMERIC, + placeholder_type=PlaceholderType.SCORE, + format_hint="Ganzzahl", + example_output="65", + minimum_data_requirements="Mindestens ein aktives Ziel", + quality_filter_policy=None, + confidence_logic="scores.get_top_priority_goal", + missing_value_policy=MVP("insufficient_data", "nicht verfügbar"), + known_limitations=None, + layer_1_decision="scores.get_top_priority_goal", + layer_2a_decision="_safe_int('top_goal_progress_pct')", + layer_2b_reuse_possible=False, + architecture_alignment="Phase 0b", + issue_53_alignment="Layer 1", + evidence={}, + ) + tag_standard_evidence(m) + register_placeholder(m) + + m = PlaceholderMetadata( + key="top_goal_status", + category=CAT, + description="Status-Label Top-Ziel", + resolver_module="backend/placeholder_resolver.py", + resolver_function="_safe_str", + data_layer_module="backend/data_layer/scores.py", + data_layer_function="get_top_priority_goal", + source_tables=["goals"], + semantic_contract="status aus get_top_priority_goal", + business_meaning="Priorisierung für KI-Empfehlungen", + unit="text", + time_window="aktuell", + output_type=OutputType.STRING, + placeholder_type=PlaceholderType.INTERPRETED, + format_hint="Kurztext", + example_output="active", + minimum_data_requirements="Mindestens ein aktives Ziel", + quality_filter_policy=None, + confidence_logic="scores.get_top_priority_goal", + missing_value_policy=MVP("insufficient_data", "nicht verfügbar"), + known_limitations=None, + layer_1_decision="scores.get_top_priority_goal", + layer_2a_decision="_safe_str('top_goal_status')", + layer_2b_reuse_possible=False, + architecture_alignment="Phase 0b", + issue_53_alignment="Layer 1", + evidence={}, + ) + tag_standard_evidence(m) + register_placeholder(m) + + m = PlaceholderMetadata( + key="top_focus_area_name", + category=CAT, + description="Bezeichnung des gewichtet stärksten Fokusbereichs", + resolver_module="backend/placeholder_resolver.py", + resolver_function="_safe_str", + data_layer_module="backend/data_layer/scores.py", + data_layer_function="get_top_focus_area", + source_tables=["user_focus_area_weights", "focus_area_definitions"], + semantic_contract="label aus get_top_focus_area", + business_meaning="Priorisierung für KI-Empfehlungen", + unit="text", + time_window="aktuell", + output_type=OutputType.STRING, + placeholder_type=PlaceholderType.INTERPRETED, + format_hint="Kurztext", + example_output="Kraft", + minimum_data_requirements="Gewichtete Fokusbereiche", + quality_filter_policy=None, + confidence_logic="scores.get_top_focus_area", + missing_value_policy=MVP("insufficient_data", "nicht verfügbar"), + known_limitations=None, + layer_1_decision="scores.get_top_focus_area", + layer_2a_decision="_safe_str('top_focus_area_name')", + layer_2b_reuse_possible=False, + architecture_alignment="Phase 0b", + issue_53_alignment="Layer 1", + evidence={}, + ) + tag_standard_evidence(m) + register_placeholder(m) + + m = PlaceholderMetadata( + key="top_focus_area_progress", + category=CAT, + description="Fortschritt Top-Fokusbereich (%)", + resolver_module="backend/placeholder_resolver.py", + resolver_function="_safe_int", + data_layer_module="backend/data_layer/scores.py", + data_layer_function="get_top_focus_area", + source_tables=["user_focus_area_weights", "focus_area_definitions", "goals"], + semantic_contract="progress aus get_top_focus_area", + business_meaning="Priorisierung für KI-Empfehlungen", + unit="%", + time_window="aktuell", + output_type=OutputType.NUMERIC, + placeholder_type=PlaceholderType.SCORE, + format_hint="Ganzzahl", + example_output="58", + minimum_data_requirements="Gewichtete Fokusbereiche", + quality_filter_policy=None, + confidence_logic="scores.get_top_focus_area", + missing_value_policy=MVP("insufficient_data", "nicht verfügbar"), + known_limitations=None, + layer_1_decision="scores.get_top_focus_area", + layer_2a_decision="_safe_int('top_focus_area_progress')", + layer_2b_reuse_possible=False, + architecture_alignment="Phase 0b", + issue_53_alignment="Layer 1", + evidence={}, + ) + tag_standard_evidence(m) + register_placeholder(m) + + # Kategorie Progress / Weight (7 Kategorien) + for slug in ( + "körper", + "ernährung", + "aktivität", + "recovery", + "vitalwerte", + "mental", + "lebensstil", + ): + key_p = f"focus_cat_{slug}_progress" + key_w = f"focus_cat_{slug}_weight" + m_p = PlaceholderMetadata( + key=key_p, + category=CAT, + description=f"Aggregierter Fortschritt Kategorie „{slug}“ (%)", + resolver_module="backend/placeholder_resolver.py", + resolver_function="_safe_int", + data_layer_module="backend/data_layer/scores.py", + data_layer_function="calculate_category_progress", + source_tables=["goals", "focus_area_definitions", "user_focus_area_weights"], + semantic_contract=f"scores.calculate_category_progress(pid, '{slug}')", + business_meaning="Focus-Area-Kategorie-Score", + unit="%", + time_window="aktuell", + output_type=OutputType.NUMERIC, + placeholder_type=PlaceholderType.SCORE, + format_hint="Ganzzahl", + example_output="55", + minimum_data_requirements="Gewichtete Bereiche in Kategorie", + quality_filter_policy=None, + confidence_logic="scores.calculate_category_progress", + missing_value_policy=MVP("insufficient_data", "nicht verfügbar"), + known_limitations=None, + layer_1_decision="scores.calculate_category_progress", + layer_2a_decision="_safe_int", + layer_2b_reuse_possible=True, + architecture_alignment="Phase 0b", + issue_53_alignment="Layer 1", + evidence={}, + ) + tag_standard_evidence(m_p) + register_placeholder(m_p) + + m_w = PlaceholderMetadata( + key=key_w, + category=CAT, + description=f"Nutzer-Gewichtung Kategorie „{slug}“ (Anteil 0–1)", + resolver_module="backend/placeholder_resolver.py", + resolver_function="_safe_float", + data_layer_module="backend/data_layer/scores.py", + data_layer_function="calculate_category_weight", + source_tables=["user_focus_area_weights", "focus_area_definitions"], + semantic_contract=f"scores.calculate_category_weight(pid, '{slug}')", + business_meaning="Kategorie-Gewichtung im Fokusmodell", + unit="0–1", + time_window="aktuell", + output_type=OutputType.NUMERIC, + placeholder_type=PlaceholderType.INTERPRETED, + format_hint="Dezimal", + example_output="0.25", + minimum_data_requirements="user_focus_area_weights", + quality_filter_policy=None, + confidence_logic="scores.calculate_category_weight", + missing_value_policy=MVP("insufficient_data", "nicht verfügbar"), + known_limitations=None, + layer_1_decision="scores.calculate_category_weight", + layer_2a_decision="_safe_float", + layer_2b_reuse_possible=False, + architecture_alignment="Phase 0b", + issue_53_alignment="Layer 1", + evidence={}, + ) + tag_standard_evidence(m_w) + register_placeholder(m_w) + + # Strukturierte Ziele / Fokus + for key, res_fn, dl_mod, dl_fn, desc, out, ptype in [ + ( + "active_goals_json", + "_safe_json", + "backend/goal_utils.py", + "get_active_goals", + "Aktive Ziele als JSON", + OutputType.JSON, + PlaceholderType.RAW_DATA, + ), + ( + "active_goals_md", + "_safe_str", + "backend/placeholder_resolver.py", + "_format_goals_as_markdown", + "Aktive Ziele als Markdown-Tabelle", + OutputType.TEXT_SUMMARY, + PlaceholderType.INTERPRETED, + ), + ( + "focus_areas_weighted_json", + "_safe_json", + "backend/placeholder_resolver.py", + "_get_focus_areas_weighted_json", + "Gewichtete Fokusbereiche mit Namen (JSON)", + OutputType.JSON, + PlaceholderType.RAW_DATA, + ), + ( + "focus_areas_weighted_md", + "_safe_str", + "backend/placeholder_resolver.py", + "_format_focus_areas_as_markdown", + "Gewichtete Fokusbereiche als Markdown", + OutputType.TEXT_SUMMARY, + PlaceholderType.INTERPRETED, + ), + ( + "focus_area_weights_json", + "_safe_json", + "backend/data_layer/scores.py", + "get_user_focus_weights", + "Rohe Gewichtungen key→Anteil (JSON)", + OutputType.JSON, + PlaceholderType.RAW_DATA, + ), + ]: + m = PlaceholderMetadata( + key=key, + category=CAT, + description=desc, + resolver_module="backend/placeholder_resolver.py", + resolver_function=res_fn, + data_layer_module=dl_mod, + data_layer_function=dl_fn, + source_tables=["goals", "focus_area_definitions", "user_focus_area_weights"], + semantic_contract=f"{dl_fn} (siehe Modul {dl_mod})", + business_meaning="Strukturierte Übersicht für Prompts", + unit="JSON" if out == OutputType.JSON else "markdown", + time_window="aktuell", + output_type=out, + placeholder_type=ptype, + format_hint="String aus Resolver", + example_output="[]" if out == OutputType.JSON else "—", + minimum_data_requirements="Ziele bzw. Fokusgewichte", + quality_filter_policy=None, + confidence_logic="Resolver + goal_utils / scores", + missing_value_policy=MVP("insufficient_data", "[]" if out == OutputType.JSON else "nicht verfügbar"), + known_limitations=None, + layer_1_decision=dl_fn, + layer_2a_decision=res_fn, + layer_2b_reuse_possible=False, + architecture_alignment="Phase 0b", + issue_53_alignment="Layer 1", + evidence={}, + ) + tag_standard_evidence(m) + register_placeholder(m) + + for key, res_fn, dl_fn, desc, ex in [ + ( + "top_3_focus_areas", + "_safe_str", + "_format_top_focus_areas", + "Top-3 Fokusbereiche als formatierter Text", + "1. Kraft …", + ), + ( + "top_3_goals_behind_schedule", + "_safe_str", + "_format_goals_behind", + "Bis zu drei Ziele hinter Zeitplan", + "—", + ), + ( + "top_3_goals_on_track", + "_safe_str", + "_format_goals_on_track", + "Bis zu drei Ziele im Plan", + "—", + ), + ]: + m = PlaceholderMetadata( + key=key, + category=CAT, + description=desc, + resolver_module="backend/placeholder_resolver.py", + resolver_function=res_fn, + data_layer_module="backend/goal_utils.py", + data_layer_function="get_active_goals", + source_tables=["goals", "focus_area_definitions"], + semantic_contract=f"Resolver {dl_fn}", + business_meaning="Kurzlisten für Coaching-Prompts", + unit="text", + time_window="aktuell", + output_type=OutputType.TEXT_SUMMARY, + placeholder_type=PlaceholderType.INTERPRETED, + format_hint="Freitext / Aufzählung", + example_output=ex, + minimum_data_requirements="Ziele / Fokusdaten", + quality_filter_policy=None, + confidence_logic=dl_fn, + missing_value_policy=MVP("insufficient_data", "nicht verfügbar"), + known_limitations=None, + layer_1_decision="goals + focus aggregation", + layer_2a_decision=dl_fn, + layer_2b_reuse_possible=False, + architecture_alignment="Phase 0b", + issue_53_alignment="Layer 2a", + evidence={}, + ) + tag_standard_evidence(m) + register_placeholder(m) + + +register_phase_0b_ziele_fokus() diff --git a/backend/placeholder_registrations/profil_zeitraum.py b/backend/placeholder_registrations/profil_zeitraum.py new file mode 100644 index 0000000..778c482 --- /dev/null +++ b/backend/placeholder_registrations/profil_zeitraum.py @@ -0,0 +1,139 @@ +""" +Registry: Profil-Stammdaten und statische Zeitraum-Labels für Prompts. +""" + +from placeholder_registry import ( + PlaceholderMetadata, + MissingValuePolicy, + OutputType, + PlaceholderType, + register_placeholder, +) +from ._evidence import tag_standard_evidence + +MVP = lambda reason, disp: MissingValuePolicy( + available=False, value_raw=None, missing_reason=reason, legacy_display=disp +) + + +def register_profil_zeitraum(): + cat_profil = "Profil" + for key, desc, res_fn, unit, ptype, out, hint, ex, sem in [ + ( + "name", + "Anzeigename aus profiles.name", + "get_profile_name", + "text", + PlaceholderType.ATOMIC, + OutputType.STRING, + "Kurzname", + "Max", + "profiles.name, Fallback „Nutzer“.", + ), + ( + "age", + "Alter in Jahren aus profiles.dob", + "get_profile_age_display", + "Jahre", + PlaceholderType.ATOMIC, + OutputType.STRING, + "Ganzzahl oder unbekannt", + "42", + "Berechnung aus Geburtsdatum; PostgreSQL date oder ISO-String.", + ), + ( + "height", + "Körpergröße (cm) aus profiles.height", + "get_profile_height_display", + "cm", + PlaceholderType.ATOMIC, + OutputType.STRING, + "Zahl oder unbekannt", + "180", + "profiles.height.", + ), + ( + "geschlecht", + "Geschlecht (männlich/weiblich) aus profiles.sex", + "get_profile_geschlecht_display", + "Kategorie", + PlaceholderType.ATOMIC, + OutputType.STRING, + "m/w-Mapping", + "männlich", + "sex == 'm' → männlich, sonst weiblich.", + ), + ]: + m = PlaceholderMetadata( + key=key, + category=cat_profil, + description=desc, + resolver_module="backend/placeholder_resolver.py", + resolver_function=res_fn, + data_layer_module=None, + data_layer_function=None, + source_tables=["profiles"], + semantic_contract=sem, + business_meaning="Profil-Kontext für KI-Prompts", + unit=unit, + time_window="latest profile row", + output_type=out, + placeholder_type=ptype, + format_hint=hint, + example_output=ex, + minimum_data_requirements="Profilzeile", + quality_filter_policy=None, + confidence_logic="Row vorhanden", + missing_value_policy=MVP("no_data", "unbekannt" if key != "name" else "Nutzer"), + known_limitations="Keine diversen Geschlechtsoptionen im Platzhalter", + layer_1_decision="profiles", + layer_2a_decision=res_fn, + layer_2b_reuse_possible=False, + architecture_alignment="Phase 0b", + issue_53_alignment="Resolver", + evidence={}, + ) + tag_standard_evidence(m) + register_placeholder(m) + + cat_zeit = "Zeitraum" + for key, desc, res_fn, sem, ex_out in [ + ("datum_heute", "Heutiges Datum (lokal)", "get_datum_heute", "datetime.now, Format dd.mm.yyyy", "11.04.2026"), + ("zeitraum_7d", "Label „letzte 7 Tage“", "get_zeitraum_label_7d", "Statisches UI/Prompt-Label", "letzte 7 Tage"), + ("zeitraum_30d", "Label „letzte 30 Tage“", "get_zeitraum_label_30d", "Statisches UI/Prompt-Label", "letzte 30 Tage"), + ("zeitraum_90d", "Label „letzte 90 Tage“", "get_zeitraum_label_90d", "Statisches UI/Prompt-Label", "letzte 90 Tage"), + ]: + m = PlaceholderMetadata( + key=key, + category=cat_zeit, + description=desc, + resolver_module="backend/placeholder_resolver.py", + resolver_function=res_fn, + data_layer_module=None, + data_layer_function=None, + source_tables=[], + semantic_contract=sem, + business_meaning="Zeitlicher Bezug im Prompt ohne Datenabfrage", + unit="label", + time_window="n/a", + output_type=OutputType.STRING, + placeholder_type=PlaceholderType.META, + format_hint="Kurzdeutsch", + example_output=ex_out, + minimum_data_requirements=None, + quality_filter_policy=None, + confidence_logic="Immer verfügbar", + missing_value_policy=None, + known_limitations="Kein kalender-basierter Datenfilter allein durch Platzhalter", + layer_1_decision="n/a", + layer_2a_decision=res_fn, + layer_2b_reuse_possible=False, + architecture_alignment="Phase 0b", + issue_53_alignment="Resolver", + evidence={}, + ) + tag_standard_evidence(m) + register_placeholder(m) + + +register_profil_zeitraum() diff --git a/backend/placeholder_registrations/schlaf_erholung.py b/backend/placeholder_registrations/schlaf_erholung.py new file mode 100644 index 0000000..bc9d422 --- /dev/null +++ b/backend/placeholder_registrations/schlaf_erholung.py @@ -0,0 +1,236 @@ +""" +Registry: Schlaf, Ruhetage, Recovery-Score, Schlaf-Metriken, Schlaf-Erholungs-Korrelation. +""" + +from placeholder_registry import ( + PlaceholderMetadata, + MissingValuePolicy, + EvidenceType, + OutputType, + PlaceholderType, + register_placeholder, +) + +CAT = "Schlaf & Erholung" +MVP = lambda reason, disp: MissingValuePolicy( + available=False, value_raw=None, missing_reason=reason, legacy_display=disp +) + + +def _tag(m: PlaceholderMetadata): + 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", + ): + m.set_evidence(f, EvidenceType.CODE_DERIVED) + m.set_evidence("business_meaning", EvidenceType.DRAFT_DERIVED) + m.set_evidence("known_limitations", EvidenceType.MIXED) + + +def register_schlaf_erholung(): + # ── formatierte Schlaf-/Ruhetage-Snapshots ─────────────────────────────── + m = PlaceholderMetadata( + key="sleep_avg_duration", + category=CAT, + description="Durchschnittliche Schlafdauer (Stunden), formatiert", + resolver_module="backend/placeholder_resolver.py", + resolver_function="get_sleep_avg_duration", + data_layer_module="backend/data_layer/recovery_metrics.py", + data_layer_function="get_sleep_duration_data", + source_tables=["sleep_log"], + semantic_contract="Mittel aus Schlafphasen im Fenster (siehe get_sleep_duration_data).", + business_meaning="KI-Kontext Schlafdauer", + unit="h (Anzeige mit Einheit)", + time_window="7d default im Resolver", + output_type=OutputType.STRING, + placeholder_type=PlaceholderType.INTERPRETED, + format_hint="z. B. 7.2h", + example_output="7.2h", + minimum_data_requirements="sleep_log im Fenster", + quality_filter_policy=None, + confidence_logic="data['confidence'] im Layer1", + missing_value_policy=MVP("no_data", "nicht verfügbar"), + known_limitations="Abhängig von Import/Qualität der Phasen", + layer_1_decision="recovery_metrics.get_sleep_duration_data", + layer_2a_decision="get_sleep_avg_duration", + layer_2b_reuse_possible=True, + architecture_alignment="Phase 0c", + issue_53_alignment="Layer 1", + evidence={}, + ) + _tag(m) + register_placeholder(m) + + m = PlaceholderMetadata( + key="sleep_avg_quality", + category=CAT, + description="Schlafqualität (Deep+REM %), formatiert", + resolver_module="backend/placeholder_resolver.py", + resolver_function="get_sleep_avg_quality", + data_layer_module="backend/data_layer/recovery_metrics.py", + data_layer_function="get_sleep_quality_data", + source_tables=["sleep_log"], + semantic_contract="Anteil Deep+REM aus Segmenten (siehe get_sleep_quality_data).", + business_meaning="KI-Kontext Schlafqualität", + unit="%", + time_window="7d default", + output_type=OutputType.STRING, + placeholder_type=PlaceholderType.INTERPRETED, + format_hint="Prozent oder nicht verfügbar", + example_output="24%", + minimum_data_requirements="sleep_log mit Phasen", + quality_filter_policy=None, + confidence_logic="Layer-1-Confidence", + missing_value_policy=MVP("no_data", "nicht verfügbar"), + known_limitations="Segment-Schreibweise case-sensitiv normalisiert", + layer_1_decision="recovery_metrics.get_sleep_quality_data", + layer_2a_decision="get_sleep_avg_quality", + layer_2b_reuse_possible=True, + architecture_alignment="Phase 0c", + issue_53_alignment="Layer 1", + evidence={}, + ) + _tag(m) + register_placeholder(m) + + m = PlaceholderMetadata( + key="rest_days_count", + category=CAT, + description="Anzahl dokumentierter Ruhetage (30d default)", + resolver_module="backend/placeholder_resolver.py", + resolver_function="get_rest_days_count", + data_layer_module="backend/data_layer/recovery_metrics.py", + data_layer_function="get_rest_days_data", + source_tables=["rest_days"], + semantic_contract="Count rest_days im Zeitraum", + business_meaning="Aktive/passive Erholungstags-Übersicht", + unit="count", + time_window="30d default", + output_type=OutputType.STRING, + placeholder_type=PlaceholderType.RAW_DATA, + format_hint="z. B. 2 Ruhetage", + example_output="2 Ruhetage", + minimum_data_requirements="rest_days", + quality_filter_policy=None, + confidence_logic="Immer Zählung, 0 möglich", + missing_value_policy=MVP("no_data", "0 Ruhetage"), + known_limitations="Nur explizit erfasste Ruhetage", + layer_1_decision="recovery_metrics.get_rest_days_data", + layer_2a_decision="get_rest_days_count", + layer_2b_reuse_possible=True, + architecture_alignment="Phase 0c", + issue_53_alignment="Layer 1", + evidence={}, + ) + _tag(m) + register_placeholder(m) + + m = PlaceholderMetadata( + key="recovery_score", + category=CAT, + description="Recovery-Score 0–100 (v2, komposit)", + resolver_module="backend/placeholder_resolver.py", + resolver_function="_safe_int", + data_layer_module="backend/data_layer/recovery_metrics.py", + data_layer_function="calculate_recovery_score_v2", + source_tables=["sleep_log", "vitals_baseline", "activity_log"], + semantic_contract="Gewichteter Score aus Schlaf, Vitaltrends, optional Load (siehe Implementierung).", + business_meaning="Gesamt-Recovery-KPI für Prompts", + unit="0–100", + time_window="composite", + output_type=OutputType.NUMERIC, + placeholder_type=PlaceholderType.SCORE, + format_hint="Ganzzahl-String", + example_output="72", + minimum_data_requirements="Teilkomponenten je nach Gewichtung", + quality_filter_policy=None, + confidence_logic="Wie calculate_recovery_score_v2", + missing_value_policy=MVP("insufficient_data", "nicht verfügbar"), + known_limitations="Abhängig von Datenabdeckung HF/HRV/Schlaf", + layer_1_decision="recovery_metrics.calculate_recovery_score_v2", + layer_2a_decision="_safe_int('recovery_score_v2')", + layer_2b_reuse_possible=True, + architecture_alignment="Phase 0c", + issue_53_alignment="Layer 1", + evidence={}, + ) + _tag(m) + register_placeholder(m) + + for key, dl_fn, desc, unit, tbls, res_fn in [ + ("sleep_avg_duration_7d", "calculate_sleep_avg_duration_7d", "Durchschnittliche Schlafdauer 7d (h)", "h", ["sleep_log"], "_safe_float"), + ("sleep_debt_hours", "calculate_sleep_debt_hours", "Kumulative Schlafschuld (h)", "h", ["sleep_log"], "_safe_float"), + ("sleep_regularity_proxy", "calculate_sleep_regularity_proxy", "Schlaf-Regularität (Proxy)", "min", ["sleep_log"], "_safe_float"), + ("recent_load_balance_3d", "calculate_recent_load_balance_3d", "Load-Balance 3d (Score)", "score", ["activity_log"], "_safe_int"), + ("sleep_quality_7d", "calculate_sleep_quality_7d", "Schlafqualität 7d (0–100)", "0-100", ["sleep_log"], "_safe_int"), + ]: + m = PlaceholderMetadata( + key=key, + category=CAT, + description=desc, + resolver_module="backend/placeholder_resolver.py", + resolver_function=res_fn, + data_layer_module="backend/data_layer/recovery_metrics.py", + data_layer_function=dl_fn, + source_tables=tbls, + semantic_contract=f"Berechnung {dl_fn} in recovery_metrics.", + business_meaning="Erholungs-Detailmetrik", + unit=unit, + time_window="siehe Funktion", + output_type=OutputType.NUMERIC, + placeholder_type=PlaceholderType.INTERPRETED, + format_hint="numerischer String", + example_output="1.0", + minimum_data_requirements="wie Funktion", + quality_filter_policy=None, + confidence_logic="Funktionsintern", + missing_value_policy=MVP("insufficient_data", "nicht verfügbar"), + known_limitations=None, + layer_1_decision=f"recovery_metrics.{dl_fn}", + layer_2a_decision="Resolver _safe_float/_safe_int", + layer_2b_reuse_possible=True, + architecture_alignment="Phase 0c", + issue_53_alignment="Layer 1", + evidence={}, + ) + _tag(m) + register_placeholder(m) + + m = PlaceholderMetadata( + key="correlation_sleep_recovery", + category=CAT, + description="JSON: Korrelation Schlaf ↔ Recovery-Indikatoren", + resolver_module="backend/placeholder_resolver.py", + resolver_function="_safe_json", + data_layer_module="backend/data_layer/correlations.py", + data_layer_function="calculate_correlation_sleep_recovery", + source_tables=["sleep_log", "vitals_baseline", "activity_log"], + semantic_contract="Strukturierte Korrelationsauswertung (siehe correlations).", + business_meaning="KI: Zusammenhänge Schlaf und Erholung", + unit="JSON", + time_window="funktionsabhängig", + output_type=OutputType.JSON, + placeholder_type=PlaceholderType.RAW_DATA, + format_hint="JSON-String", + example_output="{}", + minimum_data_requirements="Ausreichend gekoppelte Datenpunkte", + quality_filter_policy=None, + confidence_logic="Wie correlation_metrics", + missing_value_policy=MVP("insufficient_data", "{}"), + known_limitations="Bei wenig Daten leer oder schwach", + layer_1_decision="correlations.calculate_correlation_sleep_recovery", + layer_2a_decision="_safe_json", + layer_2b_reuse_possible=True, + architecture_alignment="Phase 0c", + issue_53_alignment="Layer 1", + evidence={}, + ) + _tag(m) + register_placeholder(m) + + +register_schlaf_erholung() diff --git a/backend/placeholder_registrations/vitalwerte.py b/backend/placeholder_registrations/vitalwerte.py new file mode 100644 index 0000000..79743e9 --- /dev/null +++ b/backend/placeholder_registrations/vitalwerte.py @@ -0,0 +1,180 @@ +""" +Registry: Baseline-Vitals (Ruhepuls, HRV, VO2 Max) und Abweichung vs. persönlicher Baseline. +""" + +from placeholder_registry import ( + PlaceholderMetadata, + MissingValuePolicy, + EvidenceType, + OutputType, + PlaceholderType, + register_placeholder, +) + +CAT = "Vitalwerte" +MVP = lambda reason, disp: MissingValuePolicy( + available=False, value_raw=None, missing_reason=reason, legacy_display=disp +) + + +def _tag(m: PlaceholderMetadata): + 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", + ): + m.set_evidence(f, EvidenceType.CODE_DERIVED) + m.set_evidence("business_meaning", EvidenceType.DRAFT_DERIVED) + m.set_evidence("known_limitations", EvidenceType.MIXED) + + +def register_vitalwerte(): + m = PlaceholderMetadata( + key="vitals_avg_hr", + category=CAT, + description="Durchschnittlicher Ruhepuls (7d), formatiert", + resolver_module="backend/placeholder_resolver.py", + resolver_function="get_vitals_avg_hr", + data_layer_module="backend/data_layer/health_metrics.py", + data_layer_function="get_resting_heart_rate_data", + source_tables=["vitals_baseline"], + semantic_contract="Mittel RHR aus vitals_baseline im Fenster (siehe health_metrics).", + business_meaning="KI-Kontext kardiovaskuläre Ruhelage", + unit="bpm (Anzeige mit Einheit)", + time_window="7d default im Resolver", + output_type=OutputType.STRING, + placeholder_type=PlaceholderType.INTERPRETED, + format_hint="z. B. 58 bpm", + example_output="58 bpm", + minimum_data_requirements="vitals_baseline im Fenster", + quality_filter_policy=None, + confidence_logic="data['confidence'] im Layer1", + missing_value_policy=MVP("no_data", "nicht verfügbar"), + known_limitations="Nur erfasste Morgen-Baseline-Messungen", + layer_1_decision="health_metrics.get_resting_heart_rate_data", + layer_2a_decision="get_vitals_avg_hr", + layer_2b_reuse_possible=True, + architecture_alignment="Phase 0c", + issue_53_alignment="Layer 1", + evidence={}, + ) + _tag(m) + register_placeholder(m) + + m = PlaceholderMetadata( + key="vitals_avg_hrv", + category=CAT, + description="Durchschnittliche HRV (7d), formatiert", + resolver_module="backend/placeholder_resolver.py", + resolver_function="get_vitals_avg_hrv", + data_layer_module="backend/data_layer/health_metrics.py", + data_layer_function="get_heart_rate_variability_data", + source_tables=["vitals_baseline"], + semantic_contract="Mittel HRV aus vitals_baseline im Fenster.", + business_meaning="KI-Kontext autonome Regulation / Erholung", + unit="ms (Anzeige mit Einheit)", + time_window="7d default", + output_type=OutputType.STRING, + placeholder_type=PlaceholderType.INTERPRETED, + format_hint="z. B. 45 ms", + example_output="45 ms", + minimum_data_requirements="vitals_baseline mit HRV", + quality_filter_policy=None, + confidence_logic="data['confidence'] im Layer1", + missing_value_policy=MVP("no_data", "nicht verfügbar"), + known_limitations="Geräte-/Messprotokoll kann streuen", + layer_1_decision="health_metrics.get_heart_rate_variability_data", + layer_2a_decision="get_vitals_avg_hrv", + layer_2b_reuse_possible=True, + architecture_alignment="Phase 0c", + issue_53_alignment="Layer 1", + evidence={}, + ) + _tag(m) + register_placeholder(m) + + m = PlaceholderMetadata( + key="vitals_vo2_max", + category=CAT, + description="Aktueller VO2 Max (letzte Messung), formatiert", + resolver_module="backend/placeholder_resolver.py", + resolver_function="get_vitals_vo2_max", + data_layer_module="backend/data_layer/health_metrics.py", + data_layer_function="get_vo2_max_data", + source_tables=["vitals_baseline"], + semantic_contract="Jüngster vo2_max aus vitals_baseline.", + business_meaning="Ausdauer-/Fitness-Kontext", + unit="ml/kg/min", + time_window="latest", + output_type=OutputType.STRING, + placeholder_type=PlaceholderType.INTERPRETED, + format_hint="eine Dezimalstelle + Einheit", + example_output="42.0 ml/kg/min", + minimum_data_requirements="mindestens eine VO2-Messung", + quality_filter_policy=None, + confidence_logic="data['confidence'] im Layer1", + missing_value_policy=MVP("no_data", "nicht verfügbar"), + known_limitations="Schätzung vs. Labortest je nach Quelle", + layer_1_decision="health_metrics.get_vo2_max_data", + layer_2a_decision="get_vitals_vo2_max", + layer_2b_reuse_possible=True, + architecture_alignment="Phase 0c", + issue_53_alignment="Layer 1", + evidence={}, + ) + _tag(m) + register_placeholder(m) + + for key, dl_fn, desc, unit, res_fn in [ + ( + "hrv_vs_baseline_pct", + "calculate_hrv_vs_baseline_pct", + "HRV vs. persönlicher Baseline (%)", + "%", + "_safe_float", + ), + ( + "rhr_vs_baseline_pct", + "calculate_rhr_vs_baseline_pct", + "Ruhepuls vs. persönlicher Baseline (%)", + "%", + "_safe_float", + ), + ]: + m = PlaceholderMetadata( + key=key, + category=CAT, + description=desc, + resolver_module="backend/placeholder_resolver.py", + resolver_function=res_fn, + data_layer_module="backend/data_layer/recovery_metrics.py", + data_layer_function=dl_fn, + source_tables=["vitals_baseline"], + semantic_contract=f"Vergleich aktueller Wert zu Baseline (siehe {dl_fn}).", + business_meaning="Erholungs- und Belastungsindikator relativ zur Norm des Nutzers", + unit=unit, + time_window="funktionsintern", + output_type=OutputType.NUMERIC, + placeholder_type=PlaceholderType.INTERPRETED, + format_hint="numerischer Prozent-String", + example_output="5.2", + minimum_data_requirements="Ausreichend Baseline-Historie", + quality_filter_policy=None, + confidence_logic="Funktionsintern", + missing_value_policy=MVP("insufficient_data", "nicht verfügbar"), + known_limitations="Baseline braucht ausreichend Vorlauf", + layer_1_decision=f"recovery_metrics.{dl_fn}", + layer_2a_decision=f"Resolver {res_fn}", + layer_2b_reuse_possible=True, + architecture_alignment="Phase 0c", + issue_53_alignment="Layer 1", + evidence={}, + ) + _tag(m) + register_placeholder(m) + + +register_vitalwerte() diff --git a/backend/placeholder_resolver.py b/backend/placeholder_resolver.py index a2d8392..86d279c 100644 --- a/backend/placeholder_resolver.py +++ b/backend/placeholder_resolver.py @@ -9,7 +9,7 @@ This module now focuses on FORMATTING for AI consumption. """ import re from datetime import datetime, timedelta -from typing import Dict, List, Optional, Callable +from typing import Dict, List, Optional, Callable, Tuple from db import get_db, get_cursor, r2d # Phase 0c: Import data layer @@ -277,6 +277,43 @@ def calculate_age(dob) -> str: return "unbekannt" +def get_profile_name(profile_id: str) -> str: + """Profil-Platzhalter: Anzeigename (profiles.name).""" + return get_profile_data(profile_id).get('name', 'Nutzer') + + +def get_profile_age_display(profile_id: str) -> str: + """Profil-Platzhalter: Alter aus Geburtsdatum.""" + return calculate_age(get_profile_data(profile_id).get('dob')) + + +def get_profile_height_display(profile_id: str) -> str: + """Profil-Platzhalter: Körpergröße (cm) als String.""" + return str(get_profile_data(profile_id).get('height', 'unbekannt')) + + +def get_profile_geschlecht_display(profile_id: str) -> str: + """Profil-Platzhalter: Geschlecht aus profiles.sex (m/w).""" + return 'männlich' if get_profile_data(profile_id).get('sex') == 'm' else 'weiblich' + + +def get_datum_heute(_profile_id: str) -> str: + """Zeitraum-Platzhalter: heutiges Datum (dd.mm.yyyy).""" + return datetime.now().strftime('%d.%m.%Y') + + +def get_zeitraum_label_7d(_profile_id: str) -> str: + return 'letzte 7 Tage' + + +def get_zeitraum_label_30d(_profile_id: str) -> str: + return 'letzte 30 Tage' + + +def get_zeitraum_label_90d(_profile_id: str) -> str: + return 'letzte 90 Tage' + + def get_activity_detail(profile_id: str, days: int = 14) -> str: """ Get detailed activity log for analysis. @@ -1136,10 +1173,10 @@ def _format_goals_on_track(profile_id: str, n: int = 3) -> str: PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = { # Profil - '{{name}}': lambda pid: get_profile_data(pid).get('name', 'Nutzer'), - '{{age}}': lambda pid: calculate_age(get_profile_data(pid).get('dob')), - '{{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', + '{{name}}': get_profile_name, + '{{age}}': get_profile_age_display, + '{{height}}': get_profile_height_display, + '{{geschlecht}}': get_profile_geschlecht_display, # Körper (21 Registry-Keys: body_metrics + body_extras — alles hier gebündelt) '{{weight_aktuell}}': get_latest_weight, @@ -1203,29 +1240,37 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = { '{{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 + # Schlaf & Erholung (10 Registry-Keys; recovery_score hier, nicht unter Meta Scores) '{{sleep_avg_duration}}': lambda pid: get_sleep_avg_duration(pid, 7), '{{sleep_avg_quality}}': lambda pid: get_sleep_avg_quality(pid, 7), '{{rest_days_count}}': lambda pid: get_rest_days_count(pid, 30), + '{{recovery_score}}': lambda pid: _safe_int('recovery_score_v2', pid), + '{{sleep_avg_duration_7d}}': lambda pid: _safe_float('sleep_avg_duration_7d', pid), + '{{sleep_debt_hours}}': lambda pid: _safe_float('sleep_debt_hours', pid), + '{{sleep_regularity_proxy}}': lambda pid: _safe_float('sleep_regularity_proxy', pid), + '{{recent_load_balance_3d}}': lambda pid: _safe_int('recent_load_balance_3d', pid), + '{{sleep_quality_7d}}': lambda pid: _safe_int('sleep_quality_7d', pid), + '{{correlation_sleep_recovery}}': lambda pid: _safe_json('correlation_sleep_recovery', pid), - # Vitalwerte + # Vitalwerte (5 Registry-Keys: Mittelwerte + vs. Baseline) '{{vitals_avg_hr}}': lambda pid: get_vitals_avg_hr(pid, 7), '{{vitals_avg_hrv}}': lambda pid: get_vitals_avg_hrv(pid, 7), '{{vitals_vo2_max}}': get_vitals_vo2_max, + '{{hrv_vs_baseline_pct}}': lambda pid: _safe_float('hrv_vs_baseline_pct', pid), + '{{rhr_vs_baseline_pct}}': lambda pid: _safe_float('rhr_vs_baseline_pct', pid), # Zeitraum - '{{datum_heute}}': lambda pid: datetime.now().strftime('%d.%m.%Y'), - '{{zeitraum_7d}}': lambda pid: 'letzte 7 Tage', - '{{zeitraum_30d}}': lambda pid: 'letzte 30 Tage', - '{{zeitraum_90d}}': lambda pid: 'letzte 90 Tage', + '{{datum_heute}}': get_datum_heute, + '{{zeitraum_7d}}': get_zeitraum_label_7d, + '{{zeitraum_30d}}': get_zeitraum_label_30d, + '{{zeitraum_90d}}': get_zeitraum_label_90d, # ======================================================================== # PHASE 0b: Goal-Aware Placeholders (Dynamic Focus Areas v2.0) # ======================================================================== - # --- Meta Scores (Ebene 1: Aggregierte Scores; body/nutrition/activity scores → jeweilige Kategorie) --- + # --- Meta Scores (Ebene 1; recovery_score → Schlaf & Erholung) --- '{{goal_progress_score}}': lambda pid: _safe_int('goal_progress_score', pid), - '{{recovery_score}}': lambda pid: _safe_int('recovery_score_v2', pid), '{{data_quality_score}}': lambda pid: _safe_int('data_quality_score', pid), # --- Top-Weighted Goals/Focus Areas (Ebene 2: statt Primary) --- @@ -1251,21 +1296,11 @@ 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), - # --- 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), - '{{sleep_avg_duration_7d}}': lambda pid: _safe_float('sleep_avg_duration_7d', pid), - '{{sleep_debt_hours}}': lambda pid: _safe_float('sleep_debt_hours', pid), - '{{sleep_regularity_proxy}}': lambda pid: _safe_float('sleep_regularity_proxy', pid), - '{{recent_load_balance_3d}}': lambda pid: _safe_int('recent_load_balance_3d', pid), - '{{sleep_quality_7d}}': lambda pid: _safe_int('sleep_quality_7d', pid), - # --- Correlation Metrics (C1-C7) --- '{{correlation_energy_weight_lag}}': lambda pid: _safe_json('correlation_energy_weight_lag', pid), '{{correlation_protein_lbm}}': lambda pid: _safe_json('correlation_protein_lbm', pid), '{{correlation_load_hrv}}': lambda pid: _safe_json('correlation_load_hrv', pid), '{{correlation_load_rhr}}': lambda pid: _safe_json('correlation_load_rhr', pid), - '{{correlation_sleep_recovery}}': lambda pid: _safe_json('correlation_sleep_recovery', pid), '{{plateau_detected}}': lambda pid: _safe_json('plateau_detected', pid), '{{top_drivers}}': lambda pid: _safe_json('top_drivers', pid), @@ -1378,9 +1413,42 @@ def get_available_placeholders(categories: Optional[List[str]] = None) -> Dict[s '{{rest_day_compliance}}', '{{vo2max_trend_28d}}', '{{activity_score}}', '{{training_frequency_by_type_md}}', '{{training_inter_session_gap_md}}', '{{training_sessions_recent_json}}', ], + 'schlaf': [ + '{{sleep_avg_duration}}', '{{sleep_avg_quality}}', '{{rest_days_count}}', + '{{recovery_score}}', + '{{sleep_avg_duration_7d}}', '{{sleep_debt_hours}}', '{{sleep_regularity_proxy}}', + '{{recent_load_balance_3d}}', '{{sleep_quality_7d}}', + '{{correlation_sleep_recovery}}', + ], + 'vitalwerte': [ + '{{vitals_avg_hr}}', '{{vitals_avg_hrv}}', '{{vitals_vo2_max}}', + '{{hrv_vs_baseline_pct}}', '{{rhr_vs_baseline_pct}}', + ], 'zeitraum': [ '{{datum_heute}}', '{{zeitraum_7d}}', '{{zeitraum_30d}}', '{{zeitraum_90d}}' - ] + ], + 'phase0b_meta': [ + '{{goal_progress_score}}', '{{data_quality_score}}', + ], + 'ziele_fokus': [ + '{{top_goal_name}}', '{{top_goal_progress_pct}}', '{{top_goal_status}}', + '{{top_focus_area_name}}', '{{top_focus_area_progress}}', + '{{focus_cat_körper_progress}}', '{{focus_cat_körper_weight}}', + '{{focus_cat_ernährung_progress}}', '{{focus_cat_ernährung_weight}}', + '{{focus_cat_aktivität_progress}}', '{{focus_cat_aktivität_weight}}', + '{{focus_cat_recovery_progress}}', '{{focus_cat_recovery_weight}}', + '{{focus_cat_vitalwerte_progress}}', '{{focus_cat_vitalwerte_weight}}', + '{{focus_cat_mental_progress}}', '{{focus_cat_mental_weight}}', + '{{focus_cat_lebensstil_progress}}', '{{focus_cat_lebensstil_weight}}', + '{{active_goals_json}}', '{{active_goals_md}}', + '{{focus_areas_weighted_json}}', '{{focus_areas_weighted_md}}', '{{focus_area_weights_json}}', + '{{top_3_focus_areas}}', '{{top_3_goals_behind_schedule}}', '{{top_3_goals_on_track}}', + ], + 'korrelationen': [ + '{{correlation_energy_weight_lag}}', '{{correlation_protein_lbm}}', + '{{correlation_load_hrv}}', '{{correlation_load_rhr}}', + '{{plateau_detected}}', '{{top_drivers}}', + ], } if not categories: @@ -1460,50 +1528,7 @@ def get_placeholder_catalog(profile_id: str) -> Dict[str, List[Dict[str, str]]]: }) # Legacy placeholders (not in registry yet) - legacy_placeholders = { - 'Profil': [ - ('name', 'Name des Nutzers'), - ('age', 'Alter in Jahren'), - ('height', 'Körpergröße in cm'), - ('geschlecht', 'Geschlecht'), - ], - 'Schlaf & Erholung': [ - ('sleep_avg_duration', 'Durchschn. Schlafdauer (7d)'), - ('sleep_avg_quality', 'Durchschn. Schlafqualität (7d)'), - ('rest_days_count', 'Anzahl Ruhetage (30d)'), - ('sleep_avg_duration_7d', 'Schlaf 7d (Stunden)'), - ('sleep_debt_hours', 'Schlafschuld (Stunden)'), - ('sleep_regularity_proxy', 'Schlaf-Regelmäßigkeit (Min Abweichung)'), - ('sleep_quality_7d', 'Schlafqualität 7d (0-100)'), - ], - 'Vitalwerte': [ - ('vitals_avg_hr', 'Durchschn. Ruhepuls (7d)'), - ('vitals_avg_hrv', 'Durchschn. HRV (7d)'), - ('vitals_vo2_max', 'Aktueller VO2 Max'), - ('hrv_vs_baseline_pct', 'HRV vs. Baseline (%)'), - ('rhr_vs_baseline_pct', 'RHR vs. Baseline (%)'), - ], - 'Scores (Phase 0b)': [ - ('goal_progress_score', 'Goal Progress Score (0-100)'), - ('recovery_score', 'Recovery Score (0-100)'), - ('data_quality_score', 'Data Quality Score (0-100)'), - ], - 'Focus Areas': [ - ('top_focus_area_name', 'Top Focus Area Name'), - ('top_focus_area_progress', 'Top Focus Area Progress (%)'), - ('focus_cat_körper_progress', 'Kategorie Körper - Progress (%)'), - ('focus_cat_körper_weight', 'Kategorie Körper - Gewichtung (%)'), - ('focus_cat_ernährung_progress', 'Kategorie Ernährung - Progress (%)'), - ('focus_cat_ernährung_weight', 'Kategorie Ernährung - Gewichtung (%)'), - ('focus_cat_aktivität_progress', 'Kategorie Aktivität - Progress (%)'), - ('focus_cat_aktivität_weight', 'Kategorie Aktivität - Gewichtung (%)'), - ], - 'Zeitraum': [ - ('datum_heute', 'Heutiges Datum'), - ('zeitraum_7d', '7-Tage-Zeitraum'), - ('zeitraum_30d', '30-Tage-Zeitraum'), - ], - } + legacy_placeholders: Dict[str, List[Tuple[str, str]]] = {} # Add legacy placeholders (skip if already in registry) for category, items in legacy_placeholders.items(): From 052ba195ccae10fcafb3a23b96ad78143e69dd94 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 11 Apr 2026 21:11:05 +0200 Subject: [PATCH 4/9] feat: Update placeholder metadata and nutrition metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adjusted the total number of placeholders from 116 to 114 across various documentation and code files to reflect the current state of the system. - Enhanced TDEE calculation logic in `nutrition_metrics.py` to prioritize Mifflin–St Jeor BMR with PAL when demographic data is available, with a fallback to a weight-based estimate. - Updated placeholder registrations to ensure consistency with the new metadata structure and improved data handling. - Revised documentation to clarify the authoritative source of placeholder metadata and the implications of the changes on existing functionalities. These updates improve the accuracy and consistency of the placeholder system and enhance the nutritional assessment capabilities within the application. --- .../PLACEHOLDER_REGISTRY_FRAMEWORK.md | 14 +- ...phase-0c-placeholder-migration-analysis.md | 2 +- CLAUDE.md | 7 +- backend/data_layer/nutrition_metrics.py | 136 ++++++++++++++---- backend/placeholder_metadata_complete.py | 15 +- .../nutrition_part_a.py | 19 ++- .../nutrition_part_c.py | 18 +-- .../nutrition_score.py | 5 +- docs/PLACEHOLDER_GOVERNANCE.md | 2 +- docs/PLACEHOLDER_METADATA_DEPLOYMENT_GUIDE.md | 2 +- ...EHOLDER_METADATA_IMPLEMENTATION_SUMMARY.md | 16 +-- 11 files changed, 159 insertions(+), 77 deletions(-) diff --git a/.claude/docs/technical/PLACEHOLDER_REGISTRY_FRAMEWORK.md b/.claude/docs/technical/PLACEHOLDER_REGISTRY_FRAMEWORK.md index 89438d4..a3e6aab 100644 --- a/.claude/docs/technical/PLACEHOLDER_REGISTRY_FRAMEWORK.md +++ b/.claude/docs/technical/PLACEHOLDER_REGISTRY_FRAMEWORK.md @@ -92,16 +92,10 @@ registry = get_registry() **Package:** `backend/placeholder_registrations/` -**Struktur:** -``` -placeholder_registrations/ -├── __init__.py # Auto-Import aller Registrations -├── nutrition_part_a.py # Nutrition Basis-Metriken (4 Placeholder) -├── nutrition_part_b.py # Protein-Ziele (5 Placeholder) - TODO -├── body_metrics.py # Körper-Metriken - TODO -├── activity_metrics.py # Aktivitäts-Metriken - TODO -└── ... # Weitere Cluster -``` +**Struktur:** Vollständige Cluster-Module (u. a. Ernährung, Körper, Aktivität, Schlaf, +Vitalwerte, Profil/Zeitraum, Phase-0b-Ziele, Korrelationen); siehe `__init__.py` für die +Import-Liste. **Anzahl:** 114 Platzhalter, identisch zu `PLACEHOLDER_MAP` in +`placeholder_resolver.py`. **Auto-Registration:** - Import des Package triggert automatische Registrierung aller Placeholder diff --git a/.claude/docs/working/phase-0c-placeholder-migration-analysis.md b/.claude/docs/working/phase-0c-placeholder-migration-analysis.md index ae5dac4..808131f 100644 --- a/.claude/docs/working/phase-0c-placeholder-migration-analysis.md +++ b/.claude/docs/working/phase-0c-placeholder-migration-analysis.md @@ -7,7 +7,7 @@ ## Gesamt-Übersicht -**Aktuelle Platzhalter:** 116 +**Aktuelle Platzhalter:** 114 (PLACEHOLDER_MAP / Registry) **Nach Phase 0c Migration:** - ✅ **Bleiben einfach (kein Data Layer):** 8 Platzhalter - 🔄 **Gehen zu Data Layer:** 108 Platzhalter diff --git a/CLAUDE.md b/CLAUDE.md index b2903a1..3e6b4c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,7 +107,7 @@ frontend/src/ ### Updates (11.04.2026 - Placeholder Phase A) -- **`main.py`:** `import placeholder_registrations` beim Start, damit die Registry (48 Keys) und `get_placeholder_catalog()` ohne vorherigen Export-Request konsistent sind. +- **`main.py`:** `import placeholder_registrations` beim Start, damit die Registry (**114 Keys**, deckungsgleich `PLACEHOLDER_MAP`) 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) @@ -115,10 +115,11 @@ frontend/src/ - **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) +### Updates (11.04.2026 - Ernährung: TDEE, Bilanz, Kalorien-Score) -- **`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`. +- **`data_layer/nutrition_metrics.py`:** TDEE für Bilanz: primär **Mifflin–St Jeor BMR × PAL 1,55**, wenn Profil (Größe, Geschlecht, DOB) und Gewicht vorhanden; sonst Fallback **kg × 32,5** (`estimate_tdee_kcal_from_latest_weight`). `get_energy_balance_data` / `calculate_energy_balance_7d` nutzen **tägliche kcal-Summen**. **`_score_calorie_adherence`** (Komponente von `calculate_nutrition_score`) wertet die 7-Tage-Bilanz nach **`profiles.goal_mode`** aus (weight_loss vs. strength/recomposition vs. maintenance/health/endurance). - **`routers/charts.py`:** `/charts/energy-balance` und Protein-Timeline nutzen dieselbe TDEE-/Tageslogik; ohne `weight_log` liefert Energiebilanz-Chart eine klare Fehlermeldung. Adherence-Endpoint: Kcal-CV über **Tages-Summen**. +- **Doku:** Normative Platzhalter-Zahl **114** (`docs/PLACEHOLDER_*.md`); `placeholder_metadata_complete.py` als **Legacy** gekennzeichnet — maßgeblich `placeholder_registrations/` + `PLACEHOLDER_REGISTRY_FRAMEWORK.md`. ### GUI / Informationsarchitektur (Abnahme dieser Iteration, 2026-04-05) diff --git a/backend/data_layer/nutrition_metrics.py b/backend/data_layer/nutrition_metrics.py index 4afa56e..c9af479 100644 --- a/backend/data_layer/nutrition_metrics.py +++ b/backend/data_layer/nutrition_metrics.py @@ -25,14 +25,43 @@ from datetime import datetime, timedelta, date from db import get_db, get_cursor, r2d from data_layer.utils import calculate_confidence, safe_float, safe_int -# Single TDEE rule for placeholders, charts, and warnings (kcal/day = kg * factor). -# Replaces legacy fixed 2500 kcal so all consumers stay aligned. +# Fallback TDEE (kcal/day) when demographics for Mifflin–St Jeor are incomplete. TDEE_KCAL_PER_KG_BODYWEIGHT = 32.5 +# PAL applied to MSJ BMR when height, sex, dob and weight are available (moderate activity). +TDEE_PAL_MODERATE = 1.55 + + +def _age_years_from_dob(dob) -> Optional[int]: + if dob is None: + return None + try: + if isinstance(dob, str): + birth = datetime.strptime(dob[:10], "%Y-%m-%d").date() + else: + birth = dob + today = date.today() + return today.year - birth.year - ((today.month, today.day) < (birth.month, birth.day)) + except Exception: + return None + + +def _mifflin_st_jeor_bmr_kcal( + weight_kg: float, height_cm: float, age_years: int, sex_is_male: bool +) -> float: + if sex_is_male: + return 10.0 * weight_kg + 6.25 * height_cm - 5.0 * age_years + 5.0 + return 10.0 * weight_kg + 6.25 * height_cm - 5.0 * age_years - 161.0 def estimate_tdee_kcal_from_latest_weight(profile_id: str) -> Optional[float]: """ - Estimated TDEE (kcal/day) from latest body weight. + Estimated TDEE (kcal/day). + + Primary: Mifflin–St Jeor BMR × TDEE_PAL_MODERATE when latest weight plus + profiles.height, profiles.sex, profiles.dob are usable. + + Fallback: latest weight (kg) × TDEE_KCAL_PER_KG_BODYWEIGHT (legacy heuristic). + Returns None if no weight on record. """ with get_db() as conn: @@ -42,10 +71,41 @@ def estimate_tdee_kcal_from_latest_weight(profile_id: str) -> Optional[float]: WHERE profile_id=%s ORDER BY date DESC LIMIT 1""", (profile_id,), ) - row = cur.fetchone() - if not row or row["weight"] is None: + wrow = cur.fetchone() + if not wrow or wrow["weight"] is None: return None - return float(row["weight"]) * TDEE_KCAL_PER_KG_BODYWEIGHT + weight_kg = float(wrow["weight"]) + + cur.execute( + "SELECT height, sex, dob FROM profiles WHERE id=%s", + (profile_id,), + ) + prow = cur.fetchone() + + if prow and prow.get("height") and prow.get("sex") is not None and prow.get("dob"): + height_cm = float(prow["height"]) + age = _age_years_from_dob(prow["dob"]) + if age is not None and 10 < age < 120 and height_cm > 50: + sex_raw = str(prow["sex"]).strip().lower() + sex_is_male = sex_raw in ("m", "male", "männlich", "mann") + bmr = _mifflin_st_jeor_bmr_kcal(weight_kg, height_cm, age, sex_is_male) + if bmr > 400: + return bmr * TDEE_PAL_MODERATE + + return weight_kg * TDEE_KCAL_PER_KG_BODYWEIGHT + + +def _get_profile_goal_mode(profile_id: str) -> str: + """Strategic goal_mode from profiles (Phase 0a); defaults to health.""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT goal_mode FROM profiles WHERE id=%s", (profile_id,)) + row = cur.fetchone() + if row and row.get("goal_mode"): + g = str(row["goal_mode"]).strip().lower() + if g: + return g + return "health" def get_nutrition_average_data( @@ -224,7 +284,7 @@ def get_energy_balance_data( Energy balance (intake - estimated expenditure), kcal/day. Intake: mean of daily total kcal (sum per calendar day). - TDEE: latest weight (kg) * TDEE_KCAL_PER_KG_BODYWEIGHT (same rule as placeholders). + TDEE: estimate_tdee_kcal_from_latest_weight (MSJ × PAL oder kg-Fallback). """ with get_db() as conn: cur = get_cursor(conn) @@ -834,32 +894,58 @@ def calculate_nutrition_score(profile_id: str, focus_weights: Optional[Dict] = N def _score_calorie_adherence(profile_id: str) -> Optional[int]: - """Score calorie target adherence (0-100)""" - # Check for energy balance goal - # For now, use energy balance calculation + """Score calorie target adherence (0–100) using 7d balance vs profiles.goal_mode.""" balance = calculate_energy_balance_7d(profile_id) - if balance is None: return None - # Score based on whether deficit/surplus aligns with goal - # Simplified: assume weight loss goal = deficit is good - # TODO: Check actual goal type + mode = _get_profile_goal_mode(profile_id) + b = float(balance) - abs_balance = abs(balance) + def _weight_loss(x: float) -> int: + if -550 <= x <= -250: + return 100 + if x > 450: + return 38 + if -750 <= x < -550 or -250 < x <= 120: + return 82 + if x < -1200: + return 52 + if -950 <= x < -750 or 120 < x <= 350: + return 68 + return 58 - # Moderate deficit/surplus = good - if 200 <= abs_balance <= 500: - return 100 - elif 100 <= abs_balance <= 700: - return 85 - elif abs_balance <= 900: - return 70 - elif abs_balance <= 1200: - return 55 - else: + def _surplus_friendly(x: float) -> int: + if 80 <= x <= 480: + return 100 + if -120 <= x < 80 or 480 < x <= 700: + return 86 + if -380 <= x < -120: + return 68 + if x > 850: + return 54 + if x < -650: + return 44 + return 72 + + def _maintenance(x: float) -> int: + a = abs(x) + if a <= 200: + return 100 + if a <= 400: + return 84 + if a <= 650: + return 70 + if a <= 900: + return 55 return 40 + if mode == "weight_loss": + return _weight_loss(b) + if mode in ("strength", "recomposition"): + return _surplus_friendly(b) + return _maintenance(b) + def _score_macro_balance(profile_id: str) -> Optional[int]: """Score macro balance (0-100)""" diff --git a/backend/placeholder_metadata_complete.py b/backend/placeholder_metadata_complete.py index 8b29fdd..1708bbb 100644 --- a/backend/placeholder_metadata_complete.py +++ b/backend/placeholder_metadata_complete.py @@ -1,11 +1,10 @@ """ -Complete Placeholder Metadata Definitions +Complete Placeholder Metadata Definitions (Legacy / Normativ v1) -This module contains manually curated, complete metadata for all 116 placeholders. -It combines automatic extraction with manual annotation to ensure 100% normative compliance. - -IMPORTANT: This is the authoritative source for placeholder metadata. -All new placeholders MUST be added here with complete metadata. +Hinweis (2026-04): **Verbindliche Metadaten-Pflege** erfolgt über +`backend/placeholder_registrations/` + `placeholder_registry.py` (114 Keys, deckungsgleich +mit `PLACEHOLDER_MAP`). Dieses Modul bleibt für ältere Generator-/Export-Pfade und +Tests; neue Platzhalter hier nicht mehr duplizieren. """ from placeholder_metadata import ( PlaceholderMetadata, @@ -28,7 +27,7 @@ from typing import List def get_all_placeholder_metadata() -> List[PlaceholderMetadata]: """ - Returns complete metadata for all 116 placeholders. + Returns complete metadata for all 114 placeholders (Registry ist maßgeblich). This is the authoritative, manually curated source. """ @@ -476,7 +475,7 @@ def get_all_placeholder_metadata() -> List[PlaceholderMetadata]: notes=["Quadrant-Logik basiert auf FM/LBM Delta-Vorzeichen"], ), - # NOTE: Continuing with all 116 placeholders would make this file very long. + # NOTE: Continuing with all 114 placeholders would make this file very long. # For brevity, I'll create a separate generator that fills all remaining placeholders. # The pattern is established above - each placeholder gets full metadata. ] diff --git a/backend/placeholder_registrations/nutrition_part_a.py b/backend/placeholder_registrations/nutrition_part_a.py index 02c4798..3f41fc7 100644 --- a/backend/placeholder_registrations/nutrition_part_a.py +++ b/backend/placeholder_registrations/nutrition_part_a.py @@ -53,6 +53,13 @@ def register_nutrition_part_a(): "layer_1_decision": "Data Layer (nutrition_metrics.get_nutrition_average_data)", "layer_2a_decision": "Placeholder Resolver (formatting only)", "architecture_alignment": "Phase 0c Multi-Layer Architecture conform", + "minimum_data_requirements": ( + "Mind. ein Kalendertag mit nutrition_log im Fenster; Mittelwerte aus täglicher Aggregation. " + "Confidence über calculate_confidence(day_count, days) in get_nutrition_average_data." + ), + "quality_filter_policy": ( + "Kein Outlier-Filter auf Tagesaggregaten; leere Tage fehlen in der Aggregation (kein Imputing)." + ), } # Common evidence for shared fields @@ -73,8 +80,8 @@ def register_nutrition_part_a(): "layer_2b_reuse_possible": EvidenceType.TO_VERIFY, # not verified in charts "architecture_alignment": EvidenceType.CODE_DERIVED, # imports from data_layer "issue_53_alignment": EvidenceType.MIXED, # layer separation visible, issue conformity derived - "minimum_data_requirements": EvidenceType.UNRESOLVED, # not explicit in code - "quality_filter_policy": EvidenceType.UNRESOLVED, # not implemented + "minimum_data_requirements": EvidenceType.CODE_DERIVED, + "quality_filter_policy": EvidenceType.CODE_DERIVED, } # ── kcal_avg ────────────────────────────────────────────────────────────── @@ -94,8 +101,6 @@ def register_nutrition_part_a(): known_limitations="nur Intake, kein Bedarf; sagt allein nichts über Zielpassung", layer_2b_reuse_possible=None, # to_verify - not checked in chart code issue_53_alignment="Layer separation established", - minimum_data_requirements=None, # unresolved - quality_filter_policy=None, # unresolved **common_metadata ) @@ -131,8 +136,6 @@ def register_nutrition_part_a(): ), layer_2b_reuse_possible=None, issue_53_alignment="Layer separation established", - minimum_data_requirements=None, - quality_filter_policy=None, **common_metadata ) @@ -165,8 +168,6 @@ def register_nutrition_part_a(): ), layer_2b_reuse_possible=None, issue_53_alignment="Layer separation established", - minimum_data_requirements=None, - quality_filter_policy=None, **common_metadata ) @@ -196,8 +197,6 @@ def register_nutrition_part_a(): known_limitations="meist im Gesamtkontext der Makroverteilung relevant", layer_2b_reuse_possible=None, issue_53_alignment="Layer separation established", - minimum_data_requirements=None, - quality_filter_policy=None, **common_metadata ) diff --git a/backend/placeholder_registrations/nutrition_part_c.py b/backend/placeholder_registrations/nutrition_part_c.py index e061988..da539c8 100644 --- a/backend/placeholder_registrations/nutrition_part_c.py +++ b/backend/placeholder_registrations/nutrition_part_c.py @@ -113,7 +113,7 @@ energy_balance_metadata = PlaceholderMetadata( resolver_function="_safe_float('energy_balance_7d', pid, decimals=0)", data_layer_module="backend/data_layer/nutrition_metrics.py", data_layer_function="calculate_energy_balance_7d", - source_tables=["nutrition_log", "weight_log"], + source_tables=["nutrition_log", "weight_log", "profiles"], # Semantic semantic_contract="Liefert die geschätzte Energiebilanz über 7 Tage als Differenz zwischen durchschnittlicher Energieaufnahme und geschätztem TDEE (Total Daily Energy Expenditure). Positiver Wert = Überschuss, Negativer Wert = Defizit.", @@ -127,11 +127,14 @@ energy_balance_metadata = PlaceholderMetadata( # Quality minimum_data_requirements="Mindestens 4 Tage mit Kalorienerfassung in 7-Tage-Fenster. Aktuelles Gewicht aus weight_log erforderlich.", - quality_filter_policy="Unvollständige Intake-Daten und fehlende Gewichtsmessung reduzieren Verlässlichkeit. TDEE-Schätzung ist vereinfacht (weight_kg × 32.5).", + quality_filter_policy=( + "Unvollständige Intake-Daten und fehlende Gewichtsmessung reduzieren Verlässlichkeit. " + "TDEE: Mifflin–St Jeor × PAL 1.55 wenn Höhe, Geschlecht, DOB und Gewicht vorhanden, sonst kg×32.5." + ), confidence_logic=( "Kombiniert Intake-Abdeckung und Robustheit des Verbrauchsmodells. " "Niedrigere Confidence bei <7 Tagen Daten oder fehlendem Gewicht. " - "TDEE-Modell ist vereinfacht → inherent uncertainty." + "PAL=1.55 ist ein Festwert (moderate Aktivität), kein individuelles Aktivitätslogging." ), missing_value_policy=MissingValuePolicy( available=False, @@ -140,11 +143,10 @@ energy_balance_metadata = PlaceholderMetadata( legacy_display="nicht verfügbar" ), known_limitations=( - "TDEE-MODELL: Vereinfacht als bodyweight_kg × 32.5 (mittlerer Multiplikator). " - "NICHT berücksichtigt: Aktivitätslevel, Alter, Geschlecht, Stoffwechselanpassungen. " - "TODO in Code: Harris-Benedict oder Mifflin-St Jeor für präzisere TDEE-Schätzung. " - "ACHTUNG: Energiebilanz ist modellbasiert, nicht direkt gemessen. " - "Einheit ist kcal/Tag (daily average), NICHT 7d-Total." + "TDEE: Bei vollständigem Profil (Größe, Geschlecht, DOB, Gewicht) Mifflin–St Jeor BMR × 1.55; " + "sonst Fallback kg×32.5. PAL ist nicht nutzerkonfigurierbar. " + "Energiebilanz ist modellbasiert, nicht gemessen. " + "Einheit kcal/Tag (Tagesmittel), nicht 7-Tage-Summe." ), # Architecture diff --git a/backend/placeholder_registrations/nutrition_score.py b/backend/placeholder_registrations/nutrition_score.py index 8df0568..c933610 100644 --- a/backend/placeholder_registrations/nutrition_score.py +++ b/backend/placeholder_registrations/nutrition_score.py @@ -60,8 +60,9 @@ nutrition_score_metadata = PlaceholderMetadata( ), 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 " + "Funktion None. Kalorien-Adhärenz nutzt 7d-Energiebilanz vs. profiles.goal_mode " + "(weight_loss / strength+recomposition / sonst maintenance). " + "_score_macro_balance nutzt zeilenbasierte 28d-Abfrage (langfristig an " "Tagesaggregation angleichen)." ), layer_1_decision="Data Layer (nutrition_metrics.calculate_nutrition_score)", diff --git a/docs/PLACEHOLDER_GOVERNANCE.md b/docs/PLACEHOLDER_GOVERNANCE.md index 92e7209..3865a94 100644 --- a/docs/PLACEHOLDER_GOVERNANCE.md +++ b/docs/PLACEHOLDER_GOVERNANCE.md @@ -18,7 +18,7 @@ This document establishes **mandatory governance rules** for placeholder managem ## 2. Scope These guidelines apply to: -- All 116 existing placeholders +- All 114 existing placeholders (canonical: `PLACEHOLDER_MAP`) - All new placeholders - All modifications to existing placeholders - All placeholder deprecations diff --git a/docs/PLACEHOLDER_METADATA_DEPLOYMENT_GUIDE.md b/docs/PLACEHOLDER_METADATA_DEPLOYMENT_GUIDE.md index df484a1..55c1e28 100644 --- a/docs/PLACEHOLDER_METADATA_DEPLOYMENT_GUIDE.md +++ b/docs/PLACEHOLDER_METADATA_DEPLOYMENT_GUIDE.md @@ -79,7 +79,7 @@ curl -s -H "X-Auth-Token: $TOKEN" \ **Expected response:** ```json { - "total_placeholders": 116, + "total_placeholders": 114, "available": 98, "missing": 18, "by_type": { diff --git a/docs/PLACEHOLDER_METADATA_IMPLEMENTATION_SUMMARY.md b/docs/PLACEHOLDER_METADATA_IMPLEMENTATION_SUMMARY.md index c62ea6d..88c58c7 100644 --- a/docs/PLACEHOLDER_METADATA_IMPLEMENTATION_SUMMARY.md +++ b/docs/PLACEHOLDER_METADATA_IMPLEMENTATION_SUMMARY.md @@ -9,12 +9,12 @@ ## Executive Summary -This document summarizes the complete implementation of the normative placeholder metadata system for Mitai Jinkendo. The system provides a comprehensive, standardized framework for managing, documenting, and validating all 116 placeholders in the system. +This document summarizes the complete implementation of the normative placeholder metadata system for Mitai Jinkendo. The system provides a comprehensive, standardized framework for managing, documenting, and validating all 114 placeholders in the system. **Key Achievements:** - ✅ Complete metadata schema (normative compliant) - ✅ Automatic metadata extraction -- ✅ Manual curation for 116 placeholders +- ✅ Manual curation for 114 placeholders - ✅ Extended export API (non-breaking) - ✅ Catalog generator (4 documentation files) - ✅ Validation & testing framework @@ -75,7 +75,7 @@ This document summarizes the complete implementation of the normative placeholde ### 1.3 Complete Metadata Definitions -#### `backend/placeholder_metadata_complete.py` (220 lines, expandable to all 116) +#### `backend/placeholder_metadata_complete.py` (220 lines, expandable to all 114) **Purpose:** Manually curated, authoritative metadata for all placeholders @@ -106,7 +106,7 @@ PlaceholderMetadata( **Key Features:** - Hand-curated for accuracy -- Complete for all 116 placeholders +- Complete for all 114 placeholders - Serves as authoritative source - Normative compliant @@ -285,7 +285,7 @@ pytest backend/tests/test_placeholder_metadata.py -v v ┌─────────────────────────────────────────────────────────────┐ │ Complete Registry │ -│ (116 placeholders with full metadata) │ +│ (114 placeholders with full metadata) │ └──────────┬──────────────────────────────────────────────────┘ │ ├──> Generation Scripts (generate_*.py) @@ -309,7 +309,7 @@ pytest backend/tests/test_placeholder_metadata.py -v ### 3.1 Metadata Extraction Flow ``` -1. PLACEHOLDER_MAP (116 entries) +1. PLACEHOLDER_MAP (114 entries) └─> extract_resolver_name() └─> analyze_data_layer_usage() └─> infer_type/time_window/output_type() @@ -468,7 +468,7 @@ curl -H "X-Auth-Token: " \ # Output: { - "total_placeholders": 116, + "total_placeholders": 114, "available": 98, "missing": 18, "by_type": { @@ -599,7 +599,7 @@ The system is designed for extensibility: ## 8. Compliance Checklist ✅ **Normative Standard Compliance:** -- All 116 placeholders inventoried +- All 114 placeholders inventoried - Complete metadata schema implemented - Validation framework in place - Non-breaking export API From 04e23d811592a0951ac929451002d4ed459158a4 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 11 Apr 2026 21:22:27 +0200 Subject: [PATCH 5/9] feat: Enhance placeholder resolution and error handling - Updated `extract_value_raw` to improve JSON parsing and handle unavailable data more effectively. - Introduced new functions in `placeholder_resolver.py` for standardized responses when data is unavailable, enhancing clarity for users and AI. - Modified various data retrieval functions to utilize the new response format, providing detailed reasons for unavailability. - Improved availability checks in `export_placeholder_values_extended` to account for new response formats. These changes enhance the robustness of the placeholder system and improve user experience by providing clearer error messages and data handling. --- backend/placeholder_metadata_enhanced.py | 12 +- backend/placeholder_resolver.py | 316 ++++++++++++++++-- backend/routers/prompts.py | 10 +- backend/tests/test_placeholder_metadata_v2.py | 16 + 4 files changed, 320 insertions(+), 34 deletions(-) diff --git a/backend/placeholder_metadata_enhanced.py b/backend/placeholder_metadata_enhanced.py index 4837f97..6b16599 100644 --- a/backend/placeholder_metadata_enhanced.py +++ b/backend/placeholder_metadata_enhanced.py @@ -29,14 +29,22 @@ def extract_value_raw(value_display: str, output_type: OutputType, placeholder_t Returns: (raw_value, success) """ - if not value_display or value_display in ['nicht verfügbar', 'nicht genug Daten']: + s = (value_display or "").strip() + if ( + not s + or s in ['nicht verfügbar', 'nicht genug Daten'] + or s.startswith('nicht verfügbar —') + ): # V2 strict mode: missing/unavailable value is not a successful extraction return None, False # JSON output type if output_type == OutputType.JSON: try: - return json.loads(value_display), True + parsed = json.loads(value_display) + if isinstance(parsed, dict) and parsed.get('_available') is False: + return None, False + return parsed, True except (json.JSONDecodeError, TypeError): # Try to find JSON in string json_match = re.search(r'(\{.*\}|\[.*\])', value_display, re.DOTALL) diff --git a/backend/placeholder_resolver.py b/backend/placeholder_resolver.py index 86d279c..4f83d0a 100644 --- a/backend/placeholder_resolver.py +++ b/backend/placeholder_resolver.py @@ -7,6 +7,7 @@ Used for prompt templates and preview functionality. Phase 0c: Refactored to use data_layer for structured data. This module now focuses on FORMATTING for AI consumption. """ +import json import re from datetime import datetime, timedelta from typing import Dict, List, Optional, Callable, Tuple @@ -57,6 +58,32 @@ def get_profile_data(profile_id: str) -> Dict: return r2d(cur.fetchone()) if cur.rowcount > 0 else {} +def pv_unavailable(reason: str, detail: Optional[str] = None) -> str: + """ + Standard-Antwort wenn kein Platzhalter-Wert lieferbar ist. + Grund ist für Nutzer und KI lesbar (ggf. Alternativen im Text). + """ + r = (reason or "Keine auswertbaren Daten").strip() + if detail: + d = str(detail).strip() + if d: + return f"nicht verfügbar — {r} ({d})" + return f"nicht verfügbar — {r}" + + +def pv_unavailable_json(reason: str, detail: Optional[str] = None) -> str: + """Strukturierte JSON-Antwort statt leeres {} (für KI / Clients).""" + payload: Dict[str, object] = { + "_available": False, + "_reason": (reason or "Keine auswertbaren Daten").strip(), + } + if detail: + d = str(detail).strip() + if d: + payload["_detail"] = d + return json.dumps(payload, ensure_ascii=False) + + def get_latest_weight(profile_id: str) -> Optional[str]: """ Get latest weight entry. @@ -67,7 +94,10 @@ def get_latest_weight(profile_id: str) -> Optional[str]: data = get_latest_weight_data(profile_id) if data['confidence'] == 'insufficient': - return "nicht verfügbar" + return pv_unavailable( + "Kein aktuelles Gewicht", + f"confidence={data.get('confidence')}, data_points={data.get('data_points',0)}", + ) return f"{data['weight']:.1f} kg" @@ -82,7 +112,10 @@ def get_weight_trend(profile_id: str, days: int = 28) -> str: data = get_weight_trend_data(profile_id, days) if data['confidence'] == 'insufficient': - return "nicht genug Daten" + return pv_unavailable( + "Gewichtstrend nicht ermittelbar", + f"confidence={data.get('confidence')}, Fenster={days} Tage", + ) direction = data['direction'] delta = data['delta'] @@ -105,7 +138,10 @@ def get_latest_bf(profile_id: str) -> Optional[str]: data = get_body_composition_data(profile_id) if data['confidence'] == 'insufficient': - return "nicht verfügbar" + return pv_unavailable( + "Körperfett nicht ermittelbar", + f"confidence={data.get('confidence')} (keine ausreichenden Caliper-/Kompositionsdaten)", + ) return f"{data['body_fat_pct']:.1f}%" @@ -120,7 +156,10 @@ def get_nutrition_avg(profile_id: str, field: str, days: int = 30) -> str: data = get_nutrition_average_data(profile_id, days) if data['confidence'] == 'insufficient': - return "nicht verfügbar" + return pv_unavailable( + "Ernährungsmittelwert nicht ermittelbar", + f"confidence={data.get('confidence')}, Feld={field}, Fenster={days} Tage", + ) # Map field names to data keys field_map = { @@ -224,7 +263,10 @@ def get_protein_ziel_low(profile_id: str) -> str: data = get_protein_targets_data(profile_id) if data['confidence'] == 'insufficient': - return "nicht verfügbar" + return pv_unavailable( + "Proteinziel unten nicht ermittelbar", + f"confidence={data.get('confidence')} (Gewicht/Profil für g/kg)", + ) return f"{int(data['protein_target_low'])}" @@ -239,7 +281,10 @@ def get_protein_ziel_high(profile_id: str) -> str: data = get_protein_targets_data(profile_id) if data['confidence'] == 'insufficient': - return "nicht verfügbar" + return pv_unavailable( + "Proteinziel oben nicht ermittelbar", + f"confidence={data.get('confidence')} (Gewicht/Profil für g/kg)", + ) return f"{int(data['protein_target_high'])}" @@ -423,7 +468,12 @@ def get_sleep_avg_duration(profile_id: str, days: int = 7) -> str: data = get_sleep_duration_data(profile_id, days) if data['confidence'] == 'insufficient': - return "nicht verfügbar" + return pv_unavailable( + "Schlafdauer (formatiert) nicht aus sleep_segments ableitbar", + f"confidence={data.get('confidence')}, " + f"nächte_mit_segmenten={data.get('nights_with_data', 0)}/{data.get('days_analyzed', days)}; " + f"Hinweis: {{sleep_avg_duration_7d}} nutzt duration_minutes und kann trotzdem Werte liefern", + ) return f"{data['avg_duration_hours']:.1f}h" @@ -438,7 +488,11 @@ def get_sleep_avg_quality(profile_id: str, days: int = 7) -> str: data = get_sleep_quality_data(profile_id, days) if data['confidence'] == 'insufficient': - return "nicht verfügbar" + return pv_unavailable( + "Schlafqualität (Deep+REM) nicht aus sleep_segments ableitbar", + f"confidence={data.get('confidence')}, " + f"nächte_analysiert={data.get('nights_analyzed', 0)}/{data.get('days_analyzed', days)}", + ) return f"{data['quality_score']:.0f}% (Deep+REM)" @@ -464,7 +518,10 @@ def get_vitals_avg_hr(profile_id: str, days: int = 7) -> str: data = get_resting_heart_rate_data(profile_id, days) if data['confidence'] == 'insufficient': - return "nicht verfügbar" + return pv_unavailable( + "Ruhepuls-Schnitt nicht ermittelbar", + f"confidence={data.get('confidence')}, Fenster={days} Tage (vitals_baseline)", + ) return f"{data['avg_rhr']} bpm" @@ -479,7 +536,10 @@ def get_vitals_avg_hrv(profile_id: str, days: int = 7) -> str: data = get_heart_rate_variability_data(profile_id, days) if data['confidence'] == 'insufficient': - return "nicht verfügbar" + return pv_unavailable( + "HRV-Schnitt nicht ermittelbar", + f"confidence={data.get('confidence')}, Fenster={days} Tage (vitals_baseline)", + ) return f"{data['avg_hrv']} ms" @@ -494,11 +554,156 @@ def get_vitals_vo2_max(profile_id: str) -> str: data = get_vo2_max_data(profile_id) if data['confidence'] == 'insufficient': - return "nicht verfügbar" + return pv_unavailable( + "VO2max nicht ermittelbar", + f"confidence={data.get('confidence')} (vitals_baseline)", + ) return f"{data['vo2_max']:.1f} ml/kg/min" +# Begründungen wenn Layer-2-Berechnung None liefert (für KI / Export) +_DEFAULT_NUMERIC_UNAVAILABLE = ( + "Numerische Berechnung liefert keinen Wert (Daten unzureichend oder Schwellen nicht erreicht)" +) +_DEFAULT_STR_UNAVAILABLE = ( + "Kein Wert ermittelbar (Daten unzureichend oder Schwellen nicht erreicht)" +) +_DEFAULT_JSON_UNAVAILABLE = ( + "Keine strukturierten JSON-Daten ermittelbar (Berechnung liefert None)" +) + +_SAFE_INT_NONE_REASON: Dict[str, str] = { + "goal_progress_score": ( + "Aggregierter Ziel-Fortschritt nicht berechenbar; Alternativen: {{active_goals_md}}, " + "{{data_quality_score}}" + ), + "body_progress_score": "Körper-Fortschritts-Score nicht berechenbar", + "nutrition_score": ( + "Ernährungs-Score nicht berechenbar (z. B. keine gewichteten Ernährungs-Fokusbereiche oder zu wenig Log-Daten)" + ), + "activity_score": ( + "Aktivitäts-Score nicht berechenbar (z. B. Score-Schwellen oder fehlende abilities-Zuordnung in activity_log)" + ), + "recovery_score_v2": "Recovery-Score v2 nicht berechenbar (Schlaf/Vitals/Last)", + "data_quality_score": "Datenqualitäts-Score nicht berechenbar", + "top_goal_progress_pct": ( + "Fortschritt % des Top-Ziels nicht ermittelbar (progress_pct fehlt oder Ziel nicht quantifizierbar); " + "Alternative: {{active_goals_json}}" + ), + "top_focus_area_progress": "Fortschritt % des Top-Fokusbereichs nicht ermittelbar", + "focus_cat_körper_progress": "Kategorie-Fortschritt „Körper“ nicht berechenbar", + "focus_cat_ernährung_progress": "Kategorie-Fortschritt „Ernährung“ nicht berechenbar", + "focus_cat_aktivität_progress": "Kategorie-Fortschritt „Aktivität“ nicht berechenbar", + "focus_cat_recovery_progress": "Kategorie-Fortschritt „Recovery“ nicht berechenbar", + "focus_cat_vitalwerte_progress": "Kategorie-Fortschritt „Vitalwerte“ nicht berechenbar", + "focus_cat_mental_progress": "Kategorie-Fortschritt „Mental“ nicht berechenbar", + "focus_cat_lebensstil_progress": "Kategorie-Fortschritt „Lebensstil“ nicht berechenbar", + "training_minutes_week": "Trainingsminuten/Woche nicht berechenbar", + "training_frequency_7d": "Trainingseinheiten (7 Tage) nicht berechenbar", + "quality_sessions_pct": "Anteil Qualitätssessions nicht berechenbar", + "ability_balance_strength": ( + "Fähigkeiten-Balance Kraft nicht berechenbar (zu wenig abilities-Daten in activity_log)" + ), + "ability_balance_endurance": ( + "Fähigkeiten-Balance Ausdauer nicht berechenbar (abilities in activity_log)" + ), + "ability_balance_mental": ( + "Fähigkeiten-Balance Mental nicht berechenbar (abilities in activity_log)" + ), + "ability_balance_coordination": ( + "Fähigkeiten-Balance Koordination nicht berechenbar (abilities in activity_log)" + ), + "ability_balance_mobility": ( + "Fähigkeiten-Balance Mobilität nicht berechenbar (abilities in activity_log)" + ), + "proxy_internal_load_7d": "Interne Last (7 Tage) nicht berechenbar", + "strain_score": "Strain-Score nicht berechenbar", + "rest_day_compliance": "Ruhetag-Compliance nicht berechenbar", + "protein_adequacy_28d": "Protein-Adequacy (28 Tage) nicht berechenbar", + "macro_consistency_score": "Makro-Konsistenz-Score nicht berechenbar", + "recent_load_balance_3d": "Load-Balance (3 Tage) nicht berechenbar", + "sleep_quality_7d": "Schlafqualität 7 Tage nicht berechenbar", +} + +_SAFE_FLOAT_NONE_REASON: Dict[str, str] = { + "weight_7d_median": ( + "Gewichts-Median 7 Tage: mindestens 4 Messungen im Fenster erforderlich" + ), + "weight_28d_slope": ( + "Gewichts-Trend 28 Tage: zu wenige Messpunkte (ca. 60 % Tagesabdeckung im Fenster)" + ), + "weight_90d_slope": ( + "Gewichts-Trend 90 Tage: zu wenige Messpunkte (ca. 60 % Tagesabdeckung im Fenster)" + ), + "fm_28d_change": "Fettmasse-Änderung 28 Tage nicht berechenbar (Serie Caliper/Gewicht)", + "lbm_28d_change": "Magermasse-Änderung 28 Tage nicht berechenbar (Serie Caliper/Gewicht)", + "waist_28d_delta": "Taillen-Delta 28 Tage nicht berechenbar (zwei auswertbare Messungen nötig)", + "hip_28d_delta": "Hüft-Delta 28 Tage nicht berechenbar", + "chest_28d_delta": "Brust-Delta 28 Tage nicht berechenbar", + "arm_28d_delta": "Arm-Delta 28 Tage nicht berechenbar", + "thigh_28d_delta": "Oberschenkel-Delta 28 Tage nicht berechenbar", + "waist_hip_ratio": "Taille-Hüfte-Verhältnis nicht berechenbar", + "energy_balance_7d": ( + "Energiebilanz 7 Tage nicht berechenbar (Intake oder TDEE/Gewicht fehlt)" + ), + "protein_g_per_kg": "Protein g/kg nicht berechenbar", + "monotony_score": "Monotonie-Score nicht berechenbar", + "vo2max_trend_28d": "VO2max-Trend 28 Tage nicht berechenbar", + "hrv_vs_baseline_pct": "HRV vs. Baseline nicht berechenbar (Baseline/Historie)", + "rhr_vs_baseline_pct": "Ruhepuls vs. Baseline nicht berechenbar", + "sleep_avg_duration_7d": "Schlafdauer 7 Tage nicht berechenbar (duration_minutes in sleep_log)", + "sleep_debt_hours": "Schlafschuld nicht berechenbar (mindestens ~10 Nächte mit Dauer)", + "sleep_regularity_proxy": "Schlaf-Regularität nicht berechenbar", + "focus_cat_körper_weight": "Kategorie-Gewichtung „Körper“ nicht berechenbar", + "focus_cat_ernährung_weight": "Kategorie-Gewichtung „Ernährung“ nicht berechenbar", + "focus_cat_aktivität_weight": "Kategorie-Gewichtung „Aktivität“ nicht berechenbar", + "focus_cat_recovery_weight": "Kategorie-Gewichtung „Recovery“ nicht berechenbar", + "focus_cat_vitalwerte_weight": "Kategorie-Gewichtung „Vitalwerte“ nicht berechenbar", + "focus_cat_mental_weight": "Kategorie-Gewichtung „Mental“ nicht berechenbar", + "focus_cat_lebensstil_weight": "Kategorie-Gewichtung „Lebensstil“ nicht berechenbar", +} + +_SAFE_STR_NONE_REASON: Dict[str, str] = { + "top_goal_name": "Kein priorisiertes Ziel ermittelbar", + "top_goal_status": "Status des Top-Ziels nicht ermittelbar", + "top_focus_area_name": "Kein Top-Fokusbereich ermittelbar", + "recomposition_quadrant": "Rekompositions-Quadrant nicht berechenbar (FM/LBM-Serie)", + "energy_deficit_surplus": "Defizit/Überschuss-Status nicht berechenbar", + "protein_days_in_target": "Protein-Tage im Ziel nicht berechenbar", + "intake_volatility": "Intake-Volatilität nicht berechenbar", + "active_goals_md": "Aktive Ziele (Markdown) nicht darstellbar", + "focus_areas_weighted_md": "Fokusbereiche (Markdown) nicht darstellbar", + "top_3_focus_areas": "Top-3-Fokusbereiche nicht darstellbar", + "top_3_goals_behind_schedule": "Ziele „hinter Zeitplan“ nicht darstellbar", + "top_3_goals_on_track": "Ziele „im Plan“ nicht darstellbar", +} + +_SAFE_JSON_NONE_REASON: Dict[str, str] = { + "training_sessions_recent_json": "Keine Session-Daten für JSON-Zeitfenster", + "correlation_energy_weight_lag": ( + "Korrelation Energiebilanz zu Gewicht: zu wenige gekoppelte Datenpunkte" + ), + "correlation_protein_lbm": ( + "Korrelation Protein zu Magermasse: zu wenige gekoppelte Datenpunkte" + ), + "correlation_load_hrv": ( + "Korrelation Trainingslast zu HRV: zu wenige gekoppelte Datenpunkte" + ), + "correlation_load_rhr": ( + "Korrelation Trainingslast zu Ruhepuls: zu wenige gekoppelte Datenpunkte" + ), + "correlation_sleep_recovery": ( + "Korrelation Schlaf zu Recovery: zu wenige gekoppelte Datenpunkte" + ), + "plateau_detected": "Plateau-Erkennung: keine auswertbare Serie", + "top_drivers": "Top-Treiber: keine auswertbare Korrelationsbasis", + "active_goals_json": "Aktive Ziele als JSON nicht ermittelbar", + "focus_areas_weighted_json": "Gewichtete Fokusbereiche JSON nicht ermittelbar", + "focus_area_weights_json": "Fokus-Gewichtungen JSON nicht ermittelbar", +} + + # ── Phase 0b Calculation Engine Integration ────────────────────────────────── def _safe_int(func_name: str, profile_id: str) -> str: @@ -510,7 +715,7 @@ def _safe_int(func_name: str, profile_id: str) -> str: profile_id: Profile ID Returns: - String representation of integer value or 'nicht verfügbar' + String representation of integer value oder pv_unavailable-Text mit Grund """ import traceback try: @@ -554,14 +759,24 @@ def _safe_int(func_name: str, profile_id: str) -> str: func = func_map.get(func_name) if not func: - return 'nicht verfügbar' + return pv_unavailable( + "Ungültiger Platzhalter (keine numerische Berechnung registriert)", + func_name, + ) result = func(profile_id) - return str(int(result)) if result is not None else 'nicht verfügbar' + if result is None: + return pv_unavailable( + _SAFE_INT_NONE_REASON.get(func_name, _DEFAULT_NUMERIC_UNAVAILABLE), + ) + return str(int(result)) except Exception as e: print(f"[ERROR] _safe_int({func_name}, {profile_id}): {type(e).__name__}: {e}") traceback.print_exc() - return 'nicht verfügbar' + return pv_unavailable( + "Berechnungsfehler bei numerischem Platzhalter", + f"{type(e).__name__}: {e}", + ) def _safe_float(func_name: str, profile_id: str, decimals: int = 1) -> str: @@ -574,7 +789,7 @@ def _safe_float(func_name: str, profile_id: str, decimals: int = 1) -> str: decimals: Number of decimal places Returns: - String representation of float value or 'nicht verfügbar' + String representation of float value oder pv_unavailable-Text mit Grund """ import traceback try: @@ -612,14 +827,24 @@ def _safe_float(func_name: str, profile_id: str, decimals: int = 1) -> str: func = func_map.get(func_name) if not func: - return 'nicht verfügbar' + return pv_unavailable( + "Ungültiger Platzhalter (keine Float-Berechnung registriert)", + func_name, + ) result = func(profile_id) - return f"{result:.{decimals}f}" if result is not None else 'nicht verfügbar' + if result is None: + return pv_unavailable( + _SAFE_FLOAT_NONE_REASON.get(func_name, _DEFAULT_NUMERIC_UNAVAILABLE), + ) + return f"{result:.{decimals}f}" except Exception as e: print(f"[ERROR] _safe_float({func_name}, {profile_id}): {type(e).__name__}: {e}") traceback.print_exc() - return 'nicht verfügbar' + return pv_unavailable( + "Berechnungsfehler bei Float-Platzhalter", + f"{type(e).__name__}: {e}", + ) def _safe_str(func_name: str, profile_id: str) -> str: @@ -648,14 +873,24 @@ def _safe_str(func_name: str, profile_id: str) -> str: func = func_map.get(func_name) if not func: - return 'nicht verfügbar' + return pv_unavailable( + "Ungültiger Platzhalter (keine String-Berechnung registriert)", + func_name, + ) result = func(profile_id) - return str(result) if result is not None else 'nicht verfügbar' + if result is None: + return pv_unavailable( + _SAFE_STR_NONE_REASON.get(func_name, _DEFAULT_STR_UNAVAILABLE), + ) + return str(result) except Exception as e: print(f"[ERROR] _safe_str({func_name}, {profile_id}): {type(e).__name__}: {e}") traceback.print_exc() - return 'nicht verfügbar' + return pv_unavailable( + "Berechnungsfehler bei Text-Platzhalter", + f"{type(e).__name__}: {e}", + ) def _safe_json(func_name: str, profile_id: str) -> str: @@ -684,11 +919,16 @@ def _safe_json(func_name: str, profile_id: str) -> str: func = func_map.get(func_name) if not func: - return '{}' + return pv_unavailable_json( + "Ungültiger Platzhalter (keine JSON-Berechnung registriert)", + func_name, + ) result = func(profile_id) if result is None: - return '{}' + return pv_unavailable_json( + _SAFE_JSON_NONE_REASON.get(func_name, _DEFAULT_JSON_UNAVAILABLE), + ) # If already string, return it; otherwise convert to JSON if isinstance(result, str): @@ -698,7 +938,10 @@ def _safe_json(func_name: str, profile_id: str) -> str: except Exception as e: print(f"[ERROR] _safe_json({func_name}, {profile_id}): {type(e).__name__}: {e}") traceback.print_exc() - return '{}' + return pv_unavailable_json( + "Berechnungsfehler bei JSON-Platzhalter", + f"{type(e).__name__}: {e}", + ) def _get_active_goals_json(profile_id: str) -> str: @@ -840,8 +1083,11 @@ def _format_top_focus_areas(profile_id: str, n: int = 3) -> str: lines.append(f"{i}. {name} ({weight}%)") return ', '.join(lines) - except Exception: - return 'nicht verfügbar' + except Exception as e: + return pv_unavailable( + "Top-Fokusbereiche nicht darstellbar", + f"{type(e).__name__}: {e}", + ) def _format_goals_behind(profile_id: str, n: int = 3) -> str: @@ -1005,7 +1251,10 @@ def _format_goals_behind(profile_id: str, n: int = 3) -> str: print(f"[ERROR] _format_goals_behind: {e}") import traceback traceback.print_exc() - return 'nicht verfügbar' + return pv_unavailable( + "Ziele „hinter Zeitplan“ nicht darstellbar", + f"{type(e).__name__}: {e}", + ) def _format_goals_on_track(profile_id: str, n: int = 3) -> str: @@ -1166,7 +1415,10 @@ def _format_goals_on_track(profile_id: str, n: int = 3) -> str: print(f"[ERROR] _format_goals_on_track: {e}") import traceback traceback.print_exc() - return 'nicht verfügbar' + return pv_unavailable( + "Ziele „im Plan“ nicht darstellbar", + f"{type(e).__name__}: {e}", + ) # ── Placeholder Registry ────────────────────────────────────────────────────── @@ -1321,7 +1573,11 @@ def calculate_bmi(profile_id: str) -> str: data = get_bmi_data(profile_id) bmi = data.get("bmi") if bmi is None: - return "nicht verfügbar" + return pv_unavailable( + "BMI nicht berechenbar", + f"confidence={data.get('confidence')}; benötigt Profil-Größe (cm) und letztes Gewicht " + f"(weight_log); height_cm={data.get('height_cm')}, weight_kg={data.get('weight_kg')}", + ) return f"{bmi:.1f}" diff --git a/backend/routers/prompts.py b/backend/routers/prompts.py index 8112ece..3e6288c 100644 --- a/backend/routers/prompts.py +++ b/backend/routers/prompts.py @@ -583,8 +583,14 @@ def export_placeholder_values_extended( if 'value_raw' not in metadata.unresolved_fields: metadata.unresolved_fields.append('value_raw') - # Check availability - if value in ['nicht verfügbar', 'nicht genug Daten', '[Fehler:', '[Nicht']: + # Check availability (Resolver liefert oft „nicht verfügbar — “) + sv = str(value) + if ( + sv in ['nicht verfügbar', 'nicht genug Daten'] + or sv.startswith('nicht verfügbar —') + or sv.startswith('[Fehler:') + or sv.startswith('[Nicht') + ): metadata.available = False metadata.missing_reason = value else: diff --git a/backend/tests/test_placeholder_metadata_v2.py b/backend/tests/test_placeholder_metadata_v2.py index 33f81a2..9851a16 100644 --- a/backend/tests/test_placeholder_metadata_v2.py +++ b/backend/tests/test_placeholder_metadata_v2.py @@ -44,6 +44,15 @@ def test_value_raw_json(): assert not success assert val is None + # Resolver-Fehlerhülle (kein verwertbares JSON für Charts) + val, success = extract_value_raw( + '{"_available": false, "_reason": "test"}', + OutputType.JSON, + PlaceholderType.RAW_DATA, + ) + assert not success + assert val is None + def test_value_raw_number(): """Numeric outputs must extract numbers without units.""" @@ -66,6 +75,13 @@ def test_value_raw_number(): val, success = extract_value_raw('nicht verfügbar', OutputType.NUMBER, PlaceholderType.ATOMIC) assert not success + val, success = extract_value_raw( + 'nicht verfügbar — Keine Messungen (detail)', + OutputType.NUMBER, + PlaceholderType.ATOMIC, + ) + assert not success + def test_value_raw_markdown(): """Markdown outputs keep as string.""" From 41bf593d4cc37907c3643d379e087a471c569173 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 11 Apr 2026 21:27:49 +0200 Subject: [PATCH 6/9] feat: Refactor sleep metrics calculations and improve error handling - Updated `get_sleep_avg_duration` and `get_sleep_avg_quality` functions in `placeholder_resolver.py` to provide clearer error messages when data is unavailable. - Enhanced sleep quality calculations in `recovery_metrics.py` to handle cases with insufficient data more robustly. - Improved data handling in various metrics files (`activity_metrics.py`, `body_metrics.py`, `nutrition_metrics.py`, `recovery_metrics.py`, and `scores.py`) to ensure consistent float conversions for calculations. - Added utility functions in `recovery_metrics.py` for parsing and normalizing sleep segment data, enhancing the accuracy of sleep quality assessments. These changes improve the reliability and clarity of sleep-related metrics and enhance overall data handling across the application. --- backend/calculations/recovery_metrics.py | 29 +++-- backend/data_layer/activity_metrics.py | 15 +-- backend/data_layer/body_metrics.py | 4 +- backend/data_layer/nutrition_metrics.py | 6 +- backend/data_layer/recovery_metrics.py | 146 ++++++++++++++++++----- backend/data_layer/scores.py | 37 +++--- backend/placeholder_resolver.py | 11 +- 7 files changed, 174 insertions(+), 74 deletions(-) diff --git a/backend/calculations/recovery_metrics.py b/backend/calculations/recovery_metrics.py index 529a824..47bbe0d 100644 --- a/backend/calculations/recovery_metrics.py +++ b/backend/calculations/recovery_metrics.py @@ -509,17 +509,24 @@ def calculate_sleep_quality_7d(profile_id: str) -> Optional[int]: quality_scores = [] for s in sleep_data: - if s['deep_minutes'] and s['rem_minutes']: - quality_pct = ((s['deep_minutes'] + s['rem_minutes']) / s['duration_minutes']) * 100 - # 40-60% deep+REM is good - if quality_pct >= 45: - quality_scores.append(100) - elif quality_pct >= 35: - quality_scores.append(75) - elif quality_pct >= 25: - quality_scores.append(50) - else: - quality_scores.append(30) + dur = s["duration_minutes"] + if not dur or dur <= 0: + continue + d = s["deep_minutes"] + r = s["rem_minutes"] + if d is None and r is None: + continue + di, ri = (d or 0), (r or 0) + quality_pct = ((di + ri) / dur) * 100 + # 40-60% deep+REM is good + if quality_pct >= 45: + quality_scores.append(100) + elif quality_pct >= 35: + quality_scores.append(75) + elif quality_pct >= 25: + quality_scores.append(50) + else: + quality_scores.append(30) if not quality_scores: return None diff --git a/backend/data_layer/activity_metrics.py b/backend/data_layer/activity_metrics.py index b8360ef..dea96f0 100644 --- a/backend/data_layer/activity_metrics.py +++ b/backend/data_layer/activity_metrics.py @@ -674,9 +674,9 @@ def calculate_activity_score(profile_id: str, focus_weights: Optional[Dict] = No if not components: return None - # Weighted average - total_score = sum(score * weight for _, score, weight in components) - total_weight = sum(weight for _, _, weight in components) + # Weighted average (float: DB-Aggregate können Decimal sein) + total_score = sum(float(score) * float(weight) for _, score, weight in components) + total_weight = sum(float(weight) for _, _, weight in components) return int(total_score / total_weight) @@ -728,12 +728,13 @@ def _score_cardio_presence(profile_id: str) -> Optional[int]: if not row: return None - cardio_days = row['cardio_days'] - cardio_minutes = row['cardio_minutes'] or 0 + # psycopg2: SUM() → oft Decimal — vor Mix mit float konvertieren + cardio_days = int(row['cardio_days'] or 0) + cardio_minutes = float(row['cardio_minutes'] or 0) # Target: 3-5 days/week, 150+ minutes - day_score = min(100, (cardio_days / 4) * 100) - minute_score = min(100, (cardio_minutes / 150) * 100) + day_score = min(100.0, (cardio_days / 4) * 100) + minute_score = min(100.0, (cardio_minutes / 150) * 100) return int((day_score + minute_score) / 2) diff --git a/backend/data_layer/body_metrics.py b/backend/data_layer/body_metrics.py index 2fde741..4bfde2b 100644 --- a/backend/data_layer/body_metrics.py +++ b/backend/data_layer/body_metrics.py @@ -760,8 +760,8 @@ def calculate_body_progress_score(profile_id: str, focus_weights: Optional[Dict] if not components: return None - total_score = sum(score * weight for _, score, weight in components) - total_weight = sum(weight for _, _, weight in components) + total_score = sum(float(score) * float(weight) for _, score, weight in components) + total_weight = sum(float(weight) for _, _, weight in components) return int(total_score / total_weight) diff --git a/backend/data_layer/nutrition_metrics.py b/backend/data_layer/nutrition_metrics.py index c9af479..6c17cf0 100644 --- a/backend/data_layer/nutrition_metrics.py +++ b/backend/data_layer/nutrition_metrics.py @@ -886,9 +886,9 @@ def calculate_nutrition_score(profile_id: str, focus_weights: Optional[Dict] = N if not components: return None - # Weighted average - total_score = sum(score * weight for _, score, weight in components) - total_weight = sum(weight for _, _, weight in components) + # Weighted average (float: DB-Werte können Decimal sein) + total_score = sum(float(score) * float(weight) for _, score, weight in components) + total_weight = sum(float(weight) for _, _, weight in components) return int(total_score / total_weight) diff --git a/backend/data_layer/recovery_metrics.py b/backend/data_layer/recovery_metrics.py index 8260f29..9b03e9b 100644 --- a/backend/data_layer/recovery_metrics.py +++ b/backend/data_layer/recovery_metrics.py @@ -15,12 +15,50 @@ Phase 0c: Multi-Layer Architecture Version: 1.0 """ -from typing import Dict, List, Optional +import json +from typing import Dict, List, Optional, Any from datetime import datetime, timedelta, date -from db import get_db, get_cursor, r2d +from db import get_db, get_cursor from data_layer.utils import calculate_confidence, safe_float, safe_int +def _parse_sleep_segments(raw: Any) -> Optional[List[dict]]: + """JSONB kann dict/list/str sein; ungültig → None.""" + if raw is None: + return None + if isinstance(raw, str): + try: + raw = json.loads(raw) + except (json.JSONDecodeError, TypeError): + return None + if not isinstance(raw, list): + return None + return raw + + +def _segment_minutes(seg: Any) -> int: + if not isinstance(seg, dict): + return 0 + for key in ("duration_min", "duration_minutes", "minutes"): + v = seg.get(key) + if v is not None: + return max(0, safe_int(v)) + return 0 + + +def _normalize_sleep_phase(seg: dict) -> str: + """Kleinbuchstaben; Apple „Core“-Schlaf wird wie light gewertet.""" + if not isinstance(seg, dict): + return "" + p = seg.get("phase") + if p is None: + return "" + s = str(p).strip().lower() + if s in ("core", "asleep"): + return "light" + return s + + def get_sleep_duration_data( profile_id: str, days: int = 7 @@ -51,7 +89,7 @@ def get_sleep_duration_data( cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') cur.execute( - """SELECT sleep_segments FROM sleep_log + """SELECT sleep_segments, duration_minutes FROM sleep_log WHERE profile_id=%s AND date >= %s ORDER BY date DESC""", (profile_id, cutoff) @@ -72,12 +110,17 @@ def get_sleep_duration_data( nights_with_data = 0 for row in rows: - segments = row['sleep_segments'] + night_minutes = 0 + segments = _parse_sleep_segments(row.get("sleep_segments")) if segments: - night_minutes = sum(seg.get('duration_min', 0) for seg in segments) - if night_minutes > 0: - total_minutes += night_minutes - nights_with_data += 1 + night_minutes = sum(_segment_minutes(seg) for seg in segments) + if night_minutes <= 0: + dm = row.get("duration_minutes") + if dm is not None: + night_minutes = max(0, safe_int(dm)) + if night_minutes > 0: + total_minutes += night_minutes + nights_with_data += 1 if nights_with_data == 0: return { @@ -136,7 +179,9 @@ def get_sleep_quality_data( cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') cur.execute( - """SELECT sleep_segments FROM sleep_log + """SELECT sleep_segments, duration_minutes, deep_minutes, rem_minutes, + light_minutes, awake_minutes + FROM sleep_log WHERE profile_id=%s AND date >= %s ORDER BY date DESC""", (profile_id, cutoff) @@ -163,15 +208,29 @@ def get_sleep_quality_data( count = 0 for row in rows: - segments = row['sleep_segments'] - if segments: - # Note: segments use 'phase' key, stored lowercase (deep, rem, light, awake) - deep_rem_min = sum(s.get('duration_min', 0) for s in segments if s.get('phase') in ['deep', 'rem']) - light_min = sum(s.get('duration_min', 0) for s in segments if s.get('phase') == 'light') - awake_min = sum(s.get('duration_min', 0) for s in segments if s.get('phase') == 'awake') - total_min = sum(s.get('duration_min', 0) for s in segments) + deep_rem_min = light_min = awake_min = 0 + total_min = 0 + used_segments = False + segments = _parse_sleep_segments(row.get("sleep_segments")) + if segments: + total_min = sum(_segment_minutes(s) for s in segments) if total_min > 0: + deep_rem_min = sum( + _segment_minutes(s) + for s in segments + if _normalize_sleep_phase(s) in ("deep", "rem") + ) + light_min = sum( + _segment_minutes(s) + for s in segments + if _normalize_sleep_phase(s) == "light" + ) + awake_min = sum( + _segment_minutes(s) + for s in segments + if _normalize_sleep_phase(s) == "awake" + ) quality_pct = (deep_rem_min / total_min) * 100 total_quality += quality_pct total_deep_rem += deep_rem_min @@ -179,6 +238,28 @@ def get_sleep_quality_data( total_awake += awake_min total_all += total_min count += 1 + used_segments = True + + if not used_segments: + d, r, l, a = ( + row.get("deep_minutes"), + row.get("rem_minutes"), + row.get("light_minutes"), + row.get("awake_minutes"), + ) + if d is not None or r is not None or l is not None: + di, ri, li = (d or 0), (r or 0), (l or 0) + phase_sum = di + ri + li + ai = (a or 0) if a is not None else 0 + total_min = phase_sum + ai + if total_min > 0 and phase_sum > 0: + quality_pct = ((di + ri) / total_min) * 100 + total_quality += quality_pct + total_deep_rem += di + ri + total_light += li + total_awake += ai + total_all += total_min + count += 1 if count == 0: return { @@ -351,8 +432,8 @@ def calculate_recovery_score_v2(profile_id: str) -> Optional[int]: return None # Weighted average - total_score = sum(score * weight for _, score, weight in components) - total_weight = sum(weight for _, _, weight in components) + total_score = sum(float(score) * float(weight) for _, score, weight in components) + total_weight = sum(float(weight) for _, _, weight in components) final_score = int(total_score / total_weight) @@ -783,17 +864,24 @@ def calculate_sleep_quality_7d(profile_id: str) -> Optional[int]: quality_scores = [] for s in sleep_data: - if s['deep_minutes'] and s['rem_minutes']: - quality_pct = ((s['deep_minutes'] + s['rem_minutes']) / s['duration_minutes']) * 100 - # 40-60% deep+REM is good - if quality_pct >= 45: - quality_scores.append(100) - elif quality_pct >= 35: - quality_scores.append(75) - elif quality_pct >= 25: - quality_scores.append(50) - else: - quality_scores.append(30) + dur = s["duration_minutes"] + if not dur or dur <= 0: + continue + d = s["deep_minutes"] + r = s["rem_minutes"] + if d is None and r is None: + continue + di, ri = (d or 0), (r or 0) + quality_pct = ((di + ri) / dur) * 100 + # 40-60% deep+REM is good + if quality_pct >= 45: + quality_scores.append(100) + elif quality_pct >= 35: + quality_scores.append(75) + elif quality_pct >= 25: + quality_scores.append(50) + else: + quality_scores.append(30) if not quality_scores: return None diff --git a/backend/data_layer/scores.py b/backend/data_layer/scores.py index 007cf09..eca5b1f 100644 --- a/backend/data_layer/scores.py +++ b/backend/data_layer/scores.py @@ -202,23 +202,24 @@ def calculate_goal_progress_score(profile_id: str) -> Optional[int]: total_weight = 0.0 for focus_area_id, weight in focus_weights.items(): + w = float(weight) component = focus_to_component.get(focus_area_id) if component == 'body' and body_score is not None: - total_score += body_score * weight - total_weight += weight + total_score += float(body_score) * w + total_weight += w elif component == 'nutrition' and nutrition_score is not None: - total_score += nutrition_score * weight - total_weight += weight + total_score += float(nutrition_score) * w + total_weight += w elif component == 'activity' and activity_score is not None: - total_score += activity_score * weight - total_weight += weight + total_score += float(activity_score) * w + total_weight += w elif component == 'recovery' and recovery_score is not None: - total_score += recovery_score * weight - total_weight += weight + total_score += float(recovery_score) * w + total_weight += w elif component == 'health' and health_risk_score is not None: - total_score += health_risk_score * weight - total_weight += weight + total_score += float(health_risk_score) * w + total_weight += w if total_weight == 0: return None @@ -282,9 +283,9 @@ def calculate_health_stability_score(profile_id: str) -> Optional[int]: activities = cur.fetchall() if activities: - total_minutes = sum(a['duration_min'] for a in activities) + total_minutes = float(sum(float(a['duration_min'] or 0) for a in activities)) # WHO recommends 150-300 min/week moderate activity - movement_score = min(100, (total_minutes / 150) * 100) + movement_score = min(100.0, (total_minutes / 150) * 100) components.append(('movement', movement_score, 20)) # 4. Waist circumference risk (15%) @@ -328,8 +329,8 @@ def calculate_health_stability_score(profile_id: str) -> Optional[int]: return None # Weighted average - total_score = sum(score * weight for _, score, weight in components) - total_weight = sum(weight for _, _, weight in components) + total_score = sum(float(score) * float(weight) for _, score, weight in components) + total_weight = sum(float(weight) for _, _, weight in components) return int(total_score / total_weight) @@ -532,9 +533,11 @@ def calculate_focus_area_progress(profile_id: str, focus_area_id: str) -> Option if not goals: return None - # Weighted average by contribution_weight - total_progress = sum(g['progress_pct'] * g['contribution_weight'] for g in goals) - total_weight = sum(g['contribution_weight'] for g in goals) + # Weighted average by contribution_weight (Numeric → float) + total_progress = sum( + float(g['progress_pct']) * float(g['contribution_weight']) for g in goals + ) + total_weight = sum(float(g['contribution_weight']) for g in goals) return int(total_progress / total_weight) if total_weight > 0 else None diff --git a/backend/placeholder_resolver.py b/backend/placeholder_resolver.py index 4f83d0a..c227bbb 100644 --- a/backend/placeholder_resolver.py +++ b/backend/placeholder_resolver.py @@ -469,10 +469,10 @@ def get_sleep_avg_duration(profile_id: str, days: int = 7) -> str: if data['confidence'] == 'insufficient': return pv_unavailable( - "Schlafdauer (formatiert) nicht aus sleep_segments ableitbar", + "Schlafdauer nicht ermittelbar", f"confidence={data.get('confidence')}, " - f"nächte_mit_segmenten={data.get('nights_with_data', 0)}/{data.get('days_analyzed', days)}; " - f"Hinweis: {{sleep_avg_duration_7d}} nutzt duration_minutes und kann trotzdem Werte liefern", + f"nächte_mit_wert={data.get('nights_with_data', 0)}/{data.get('days_analyzed', days)} " + f"(Quellen: sleep_segments und/oder sleep_log.duration_minutes)", ) return f"{data['avg_duration_hours']:.1f}h" @@ -489,9 +489,10 @@ def get_sleep_avg_quality(profile_id: str, days: int = 7) -> str: if data['confidence'] == 'insufficient': return pv_unavailable( - "Schlafqualität (Deep+REM) nicht aus sleep_segments ableitbar", + "Schlafqualität (Deep+REM-Anteil) nicht ermittelbar", f"confidence={data.get('confidence')}, " - f"nächte_analysiert={data.get('nights_analyzed', 0)}/{data.get('days_analyzed', days)}", + f"nächte_analysiert={data.get('nights_analyzed', 0)}/{data.get('days_analyzed', days)} " + f"(Quellen: sleep_segments oder Spalten deep/rem/light/awake_minutes)", ) return f"{data['quality_score']:.0f}% (Deep+REM)" From baeddd7c135e7d47db2b39d786cb93633388cdc9 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 11 Apr 2026 21:36:29 +0200 Subject: [PATCH 7/9] feat: Enhance placeholder system with AI context support - Introduced `build_ai_placeholder_caption` function in `placeholder_registry.py` to generate AI context captions based on placeholder metadata. - Updated `resolve_placeholders` in `placeholder_resolver.py` to support modifiers for AI context, allowing for enhanced descriptions when placeholders are resolved. - Modified `get_placeholder_catalog` to include AI captions in the output, improving the metadata available for placeholders. - Adjusted `export_placeholder_values` to include AI captions in the exported data, enhancing the information provided to users. These changes improve the flexibility and functionality of the placeholder system, enabling richer context generation for dynamic content. --- backend/placeholder_registry.py | 34 +++++++++++ backend/placeholder_resolver.py | 62 ++++++++++++++------ backend/prompt_executor.py | 12 ++-- backend/routers/prompts.py | 9 ++- backend/tests/test_placeholder_modifier_d.py | 56 ++++++++++++++++++ 5 files changed, 146 insertions(+), 27 deletions(-) create mode 100644 backend/tests/test_placeholder_modifier_d.py diff --git a/backend/placeholder_registry.py b/backend/placeholder_registry.py index 749071a..0571abc 100644 --- a/backend/placeholder_registry.py +++ b/backend/placeholder_registry.py @@ -258,6 +258,40 @@ class PlaceholderRegistry: return metadata._resolver_func(profile_id) +def build_ai_placeholder_caption(metadata: PlaceholderMetadata, max_len: int = 400) -> str: + """ + Kurztext für KI-Kontext (z. B. Modifier |d): Bedeutung/Skala, ohne die Rohausgabe zu ersetzen. + Nutzt business_meaning / semantic_contract; bei Scores explizite 0–100-Erläuterung. + """ + chunks: List[str] = [] + bm = (metadata.business_meaning or "").strip() + sc = (metadata.semantic_contract or "").strip() + desc = (metadata.description or "").strip() + + if bm: + chunks.append(bm) + elif sc: + chunks.append(sc if len(sc) <= max_len else sc[: max_len - 1] + "…") + elif desc: + chunks.append(desc) + + if metadata.placeholder_type == PlaceholderType.SCORE: + chunks.append("Skala 0–100: höher = im Modell günstiger / besser abgestimmt.") + + unit = (metadata.unit or "").strip() + if unit and metadata.placeholder_type != PlaceholderType.SCORE: + blob = " ".join(chunks).lower() + u_low = unit.lower() + if u_low not in blob and u_low.replace(" ", "") not in blob.replace(" ", ""): + if u_low not in ("score (0-100)", "0-100", "0–100", "dimensionless"): + chunks.append(f"Technischer Bezug: {unit}.") + + out = " ".join(c for c in chunks if c).strip() + if len(out) > max_len + 120: + out = out[: max_len + 60] + "…" + return out or desc or metadata.key + + # Global registry instance _global_registry = PlaceholderRegistry() diff --git a/backend/placeholder_resolver.py b/backend/placeholder_resolver.py index c227bbb..db4f0b5 100644 --- a/backend/placeholder_resolver.py +++ b/backend/placeholder_resolver.py @@ -47,6 +47,20 @@ from data_layer.health_metrics import ( get_vo2_max_data ) +from placeholder_registry import build_ai_placeholder_caption, get_registry + +# {{key}} oder {{key|d}} — Modifier d hängt KI-Kontext (ai_caption) an +_PLACEHOLDER_TOKEN_RE = re.compile( + r"\{\{\s*([a-zA-Z0-9_]+)(?:\s*\|\s*([a-zA-Z0-9_,\s]+))?\s*\}\}" +) + + +def _ai_caption_for_placeholder_key(key: str) -> Optional[str]: + meta = get_registry().get(key) + if meta: + return build_ai_placeholder_caption(meta) + return None + # ── Helper Functions ────────────────────────────────────────────────────────── @@ -1588,6 +1602,10 @@ def resolve_placeholders(template: str, profile_id: str) -> str: """ Replace all {{placeholders}} in template with actual user data. + Unterstützt Modifier wie bei der Prompt-Pipeline: + - {{fat_avg}} — nur Wert + - {{fat_avg|d}} — Wert plus KI-Kontext (business_meaning / semantic_contract aus Registry) + Args: template: Prompt template with placeholders profile_id: User profile ID @@ -1595,18 +1613,26 @@ def resolve_placeholders(template: str, profile_id: str) -> str: Returns: Resolved template with placeholders replaced by values """ - result = template - for placeholder, resolver in PLACEHOLDER_MAP.items(): - if placeholder in result: - try: - value = resolver(profile_id) - result = result.replace(placeholder, str(value)) - except Exception as e: - # On error, replace with error message - result = result.replace(placeholder, f"[Fehler: {placeholder}]") + def _repl(match: re.Match) -> str: + key = match.group(1) + modifiers_raw = (match.group(2) or "").strip() + mods = {x.strip().lower() for x in modifiers_raw.split(",") if x.strip()} + ph = f"{{{{{key}}}}}" + resolver = PLACEHOLDER_MAP.get(ph) + if not resolver: + return match.group(0) + try: + value = str(resolver(profile_id)) + except Exception: + return f"[Fehler: {ph}]" + if "d" in mods: + cap = _ai_caption_for_placeholder_key(key) + if cap: + value = f"{value} — {cap}" + return value - return result + return _PLACEHOLDER_TOKEN_RE.sub(_repl, template) def get_unknown_placeholders(template: str) -> List[str]: @@ -1619,12 +1645,9 @@ def get_unknown_placeholders(template: str) -> List[str]: Returns: List of unknown placeholder names (without {{}}) """ - # Find all {{...}} patterns - found = re.findall(r'\{\{(\w+)\}\}', template) - - # Filter to only unknown ones + found = _PLACEHOLDER_TOKEN_RE.findall(template) known_names = {p.strip('{}') for p in PLACEHOLDER_MAP.keys()} - unknown = [p for p in found if p not in known_names] + unknown = [key for key, _ in found if key not in known_names] return list(set(unknown)) # Remove duplicates @@ -1781,7 +1804,8 @@ def get_placeholder_catalog(profile_id: str) -> Dict[str, List[Dict[str, str]]]: catalog[category].append({ 'key': key, 'description': metadata.description, - 'example': str(example) + 'example': str(example), + 'ai_caption': build_ai_placeholder_caption(metadata), }) # Legacy placeholders (not in registry yet) @@ -1810,7 +1834,8 @@ def get_placeholder_catalog(profile_id: str) -> Dict[str, List[Dict[str, str]]]: catalog[category].append({ 'key': key, 'description': description, - 'example': str(example) + 'example': str(example), + 'ai_caption': description, }) # Add ALL remaining placeholders from PLACEHOLDER_MAP that aren't categorized yet @@ -1839,7 +1864,8 @@ def get_placeholder_catalog(profile_id: str) -> Dict[str, List[Dict[str, str]]]: catalog[sonstige_category].append({ 'key': key, 'description': f'Platzhalter: {key}', # Generic description - 'example': str(example) + 'example': str(example), + 'ai_caption': f'Platzhalter {key} (noch ohne erweiterte Registry-Beschreibung).', }) return catalog diff --git a/backend/prompt_executor.py b/backend/prompt_executor.py index 769a8d9..4eb0f33 100644 --- a/backend/prompt_executor.py +++ b/backend/prompt_executor.py @@ -19,7 +19,7 @@ def resolve_placeholders(template: str, variables: Dict[str, Any], debug_info: O Replace {{placeholder}} with values from variables dict. Supports modifiers: - - {{key|d}} - Include description in parentheses (requires catalog) + - {{key|d}} — angehängter KI-Kontext (ai_caption aus Katalog, sonst description; Katalog nötig) Args: template: String with {{key}} or {{key|modifiers}} placeholders @@ -52,16 +52,16 @@ def resolve_placeholders(template: str, variables: Dict[str, Any], debug_info: O # Apply modifiers if 'd' in modifiers: if catalog: - # Add description from catalog - description = None + caption = None for cat_items in catalog.values(): matching = [item for item in cat_items if item['key'] == key] if matching: - description = matching[0].get('description', '') + row = matching[0] + caption = (row.get('ai_caption') or row.get('description') or '').strip() break - if description: - resolved_value = f"{resolved_value} ({description})" + if caption: + resolved_value = f"{resolved_value} — {caption}" else: # Catalog not available - log warning in debug if debug_info is not None: diff --git a/backend/routers/prompts.py b/backend/routers/prompts.py index 3e6288c..41f5172 100644 --- a/backend/routers/prompts.py +++ b/backend/routers/prompts.py @@ -486,12 +486,15 @@ def export_placeholder_values(session: dict = Depends(require_auth)): export_data['placeholders_by_category'][category] = [] for item in items: key = item['key'].replace('{{', '').replace('}}', '') - export_data['placeholders_by_category'][category].append({ + row = { 'key': item['key'], 'description': item['description'], 'value': cleaned_values.get(key, 'nicht verfügbar'), - 'example': item.get('example') - }) + 'example': item.get('example'), + } + if item.get('ai_caption'): + row['ai_caption'] = item['ai_caption'] + export_data['placeholders_by_category'][category].append(row) # Also include flat list for easy access export_data['all_placeholders'] = cleaned_values diff --git a/backend/tests/test_placeholder_modifier_d.py b/backend/tests/test_placeholder_modifier_d.py new file mode 100644 index 0000000..b3149bf --- /dev/null +++ b/backend/tests/test_placeholder_modifier_d.py @@ -0,0 +1,56 @@ +"""Tests für {{key|d}}, ai_caption und Unbekannt-Erkennung.""" +from placeholder_registry import ( + PlaceholderMetadata, + PlaceholderType, + OutputType, + build_ai_placeholder_caption, +) +import placeholder_resolver as pr + + +def test_build_ai_caption_prefers_business_meaning(): + m = PlaceholderMetadata( + key="test_x", + category="Test", + description="Kurzbeschreibung", + resolver_module="m", + resolver_function="f", + semantic_contract="Lang Vertrag " * 50, + business_meaning="Kernbedeutung für die KI.", + unit="g/day", + placeholder_type=PlaceholderType.INTERPRETED, + output_type=OutputType.NUMERIC, + ) + cap = build_ai_placeholder_caption(m) + assert "Kernbedeutung" in cap + + +def test_build_ai_caption_score_adds_scale(): + m = PlaceholderMetadata( + key="test_score", + category="Test", + description="Score", + resolver_module="m", + resolver_function="f", + business_meaning="Gewichteter Gesamtscore.", + unit="Score (0-100)", + placeholder_type=PlaceholderType.SCORE, + output_type=OutputType.NUMERIC, + ) + cap = build_ai_placeholder_caption(m) + assert "0–100" in cap or "0-100" in cap + assert "Gewichteter" in cap + + +def test_placeholder_token_regex_optional_modifier(): + m0 = pr._PLACEHOLDER_TOKEN_RE.search("{{fat_avg}}") + assert m0 and m0.group(1) == "fat_avg" and m0.group(2) is None + m1 = pr._PLACEHOLDER_TOKEN_RE.search("{{fat_avg|d}}") + assert m1 and m1.group(1) == "fat_avg" and m1.group(2).strip() == "d" + m2 = pr._PLACEHOLDER_TOKEN_RE.search("{{ protein_avg | d }}") + assert m2 and m2.group(1) == "protein_avg" and m2.group(2).strip() == "d" + + +def test_get_unknown_placeholders_strips_modifier(): + unk = pr.get_unknown_placeholders("{{not_a_real_key|d}}") + assert set(unk) == {"not_a_real_key"} From a9a414b956b557f4f7a29b521cbbed9a934d7d1a Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 11 Apr 2026 21:47:08 +0200 Subject: [PATCH 8/9] feat: Enhance placeholder caption generation and formatting - Updated `build_ai_placeholder_caption` in `placeholder_registry.py` to improve the generation of AI context captions by prioritizing descriptions and avoiding redundancy. - Introduced `format_value_with_d_modifier` in `placeholder_resolver.py` to format values with contextual information, enhancing the clarity of exported placeholder values. - Modified `export_placeholder_values` in `prompts.py` to utilize the new formatting function, ensuring that exported data includes both raw values and contextual descriptions. - Added tests for the new formatting function and updated existing tests to ensure accurate caption generation. These changes improve the contextual relevance of placeholder data and enhance the user experience when interacting with exported values. --- backend/placeholder_registry.py | 46 +++++++++++++++----- backend/placeholder_resolver.py | 13 +++++- backend/routers/prompts.py | 15 ++++--- backend/tests/test_placeholder_modifier_d.py | 36 +++++++++++++++ 4 files changed, 91 insertions(+), 19 deletions(-) diff --git a/backend/placeholder_registry.py b/backend/placeholder_registry.py index 0571abc..b02fe96 100644 --- a/backend/placeholder_registry.py +++ b/backend/placeholder_registry.py @@ -260,21 +260,34 @@ class PlaceholderRegistry: def build_ai_placeholder_caption(metadata: PlaceholderMetadata, max_len: int = 400) -> str: """ - Kurztext für KI-Kontext (z. B. Modifier |d): Bedeutung/Skala, ohne die Rohausgabe zu ersetzen. - Nutzt business_meaning / semantic_contract; bei Scores explizite 0–100-Erläuterung. + Text für |d und Exportfeld ai_caption: zuerst **was** der Platzhalter misst (description), + dann **Einordnung** (business_meaning oder gekürzter semantic_contract). + So ist klar, worauf sich der konkrete Wert bezieht — nicht nur eine „Meta-Bedeutung“. """ - chunks: List[str] = [] + desc = (metadata.description or "").strip() bm = (metadata.business_meaning or "").strip() sc = (metadata.semantic_contract or "").strip() - desc = (metadata.description or "").strip() - if bm: - chunks.append(bm) - elif sc: - chunks.append(sc if len(sc) <= max_len else sc[: max_len - 1] + "…") - elif desc: + chunks: List[str] = [] + if desc: chunks.append(desc) + interpret = bm + if not interpret and sc: + interpret = sc if len(sc) <= max_len else sc[: max_len - 1] + "…" + + if interpret: + blob = " ".join(chunks).lower() + il = interpret.lower() + # Keine Dublette: gleicher Text oder lange Description bereits in der Interpretation + redundant = il in blob or ( + desc + and len(desc) >= 10 + and desc.lower() in il + ) + if not redundant: + chunks.append(interpret) + if metadata.placeholder_type == PlaceholderType.SCORE: chunks.append("Skala 0–100: höher = im Modell günstiger / besser abgestimmt.") @@ -282,9 +295,18 @@ def build_ai_placeholder_caption(metadata: PlaceholderMetadata, max_len: int = 4 if unit and metadata.placeholder_type != PlaceholderType.SCORE: blob = " ".join(chunks).lower() u_low = unit.lower() - if u_low not in blob and u_low.replace(" ", "") not in blob.replace(" ", ""): - if u_low not in ("score (0-100)", "0-100", "0–100", "dimensionless"): - chunks.append(f"Technischer Bezug: {unit}.") + # Einheit oft schon in description („… in g (30d)“, „Kalorien“) — nicht doppeln + compact_blob = blob.replace(" ", "").replace("/", "") + compact_u = u_low.replace(" ", "").replace("/", "") + unit_redundant = compact_u in compact_blob or ( + "g/day" in u_low and ("g/" in blob or "gramm" in blob or " protein" in blob or " fett" in blob or " kh" in blob) + ) or ("kcal" in u_low and ("kcal" in blob or "kalorien" in blob)) + + if ( + not unit_redundant + and u_low not in ("score (0-100)", "0-100", "0–100", "dimensionless") + ): + chunks.append(f"Technischer Bezug: {unit}.") out = " ".join(c for c in chunks if c).strip() if len(out) > max_len + 120: diff --git a/backend/placeholder_resolver.py b/backend/placeholder_resolver.py index db4f0b5..40aae40 100644 --- a/backend/placeholder_resolver.py +++ b/backend/placeholder_resolver.py @@ -10,7 +10,7 @@ This module now focuses on FORMATTING for AI consumption. import json import re from datetime import datetime, timedelta -from typing import Dict, List, Optional, Callable, Tuple +from typing import Any, Dict, List, Optional, Callable, Tuple from db import get_db, get_cursor, r2d # Phase 0c: Import data layer @@ -62,6 +62,17 @@ def _ai_caption_for_placeholder_key(key: str) -> Optional[str]: return None +def format_value_with_d_modifier(value: str, catalog_row: Dict[str, Any]) -> str: + """ + Entspricht der Prompt-Ersetzung bei {{key|d}}: „Wert — Kontext“. + Kontext: ai_caption aus dem Katalog, sonst description (wie prompt_executor). + """ + cap = (catalog_row.get("ai_caption") or catalog_row.get("description") or "").strip() + if cap: + return f"{value} — {cap}" + return str(value) + + # ── Helper Functions ────────────────────────────────────────────────────────── def get_profile_data(profile_id: str) -> Dict: diff --git a/backend/routers/prompts.py b/backend/routers/prompts.py index 41f5172..43fc9ba 100644 --- a/backend/routers/prompts.py +++ b/backend/routers/prompts.py @@ -21,6 +21,7 @@ from placeholder_resolver import ( resolve_placeholders, get_unknown_placeholders, get_placeholder_example_values, + format_value_with_d_modifier, get_available_placeholders, get_placeholder_catalog ) @@ -457,8 +458,8 @@ def export_placeholder_values(session: dict = Depends(require_auth)): """ Export all available placeholders with their current resolved values. - Returns JSON export suitable for download with all placeholders - resolved for the current user's profile. + Pro Zeile: value = Rohwert wie bei {{key}}, example = Vorschau wie bei {{key|d}} + (Wert — ai_caption bzw. description). JSON-Download für das aktive Profil. """ from datetime import datetime profile_id = session['profile_id'] @@ -486,11 +487,12 @@ def export_placeholder_values(session: dict = Depends(require_auth)): export_data['placeholders_by_category'][category] = [] for item in items: key = item['key'].replace('{{', '').replace('}}', '') + raw_val = cleaned_values.get(key, 'nicht verfügbar') row = { 'key': item['key'], 'description': item['description'], - 'value': cleaned_values.get(key, 'nicht verfügbar'), - 'example': item.get('example'), + 'value': raw_val, + 'example': format_value_with_d_modifier(str(raw_val), item), } if item.get('ai_caption'): row['ai_caption'] = item['ai_caption'] @@ -662,11 +664,12 @@ def export_placeholder_values_extended( export_data['legacy']['placeholders_by_category'][category] = [] for item in items: key = item['key'].replace('{{', '').replace('}}', '') + raw_val = cleaned_values.get(key, 'nicht verfügbar') export_data['legacy']['placeholders_by_category'][category].append({ 'key': item['key'], 'description': item['description'], - 'value': cleaned_values.get(key, 'nicht verfügbar'), - 'example': item.get('example') + 'value': raw_val, + 'example': format_value_with_d_modifier(str(raw_val), item), }) # Fill metadata flat diff --git a/backend/tests/test_placeholder_modifier_d.py b/backend/tests/test_placeholder_modifier_d.py index b3149bf..bf55111 100644 --- a/backend/tests/test_placeholder_modifier_d.py +++ b/backend/tests/test_placeholder_modifier_d.py @@ -6,6 +6,7 @@ from placeholder_registry import ( build_ai_placeholder_caption, ) import placeholder_resolver as pr +from placeholder_resolver import format_value_with_d_modifier def test_build_ai_caption_prefers_business_meaning(): @@ -22,9 +23,28 @@ def test_build_ai_caption_prefers_business_meaning(): output_type=OutputType.NUMERIC, ) cap = build_ai_placeholder_caption(m) + assert cap.startswith("Kurzbeschreibung") assert "Kernbedeutung" in cap +def test_build_ai_caption_description_then_meaning_like_protein_avg(): + m = PlaceholderMetadata( + key="protein_avg", + category="Ernährung", + description="Durchschn. Protein in g (30d)", + resolver_module="m", + resolver_function="f", + business_meaning="Zentraler Placeholder für Muskelerhalt.", + unit="g/day", + placeholder_type=PlaceholderType.INTERPRETED, + output_type=OutputType.NUMERIC, + ) + cap = build_ai_placeholder_caption(m) + assert cap.startswith("Durchschn. Protein in g (30d)") + assert "Muskelerhalt" in cap + assert "Technischer Bezug" not in cap + + def test_build_ai_caption_score_adds_scale(): m = PlaceholderMetadata( key="test_score", @@ -54,3 +74,19 @@ def test_placeholder_token_regex_optional_modifier(): def test_get_unknown_placeholders_strips_modifier(): unk = pr.get_unknown_placeholders("{{not_a_real_key|d}}") assert set(unk) == {"not_a_real_key"} + + +def test_format_value_with_d_modifier_matches_prompt_executor(): + row = { + "key": "protein_avg", + "description": "Durchschn. Protein in g (30d)", + "example": "119g/Tag", + "ai_caption": "Durchschn. Protein in g (30d). Zentral für Muskelerhalt.", + } + out = format_value_with_d_modifier("119g/Tag", row) + assert out == "119g/Tag — Durchschn. Protein in g (30d). Zentral für Muskelerhalt." + + +def test_format_value_with_d_modifier_falls_back_to_description(): + row = {"description": "Nur Beschreibung", "key": "x"} + assert format_value_with_d_modifier("42", row) == "42 — Nur Beschreibung" From 4868e44882498f5828e700771b70bda2aafcd3e8 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 11 Apr 2026 21:58:29 +0200 Subject: [PATCH 9/9] feat: Refine placeholder resolution with enhanced modifiers support - Updated `resolve_placeholders` in `prompt_executor.py` to support combined modifiers for placeholders, allowing for more flexible output formats. - Enhanced `build_ai_placeholder_caption` in `placeholder_registry.py` to clarify the generation of AI context captions, focusing on descriptions and explanations. - Introduced new helper functions in `placeholder_resolver.py` to streamline the retrieval of descriptions and explanations for placeholders. - Modified tests to cover new functionality, ensuring accurate behavior for combined modifiers and improved placeholder resolution. These changes enhance the usability and clarity of placeholder outputs, providing users with richer contextual information. --- backend/placeholder_registry.py | 32 ++----- backend/placeholder_resolver.py | 69 ++++++++++---- backend/prompt_executor.py | 95 ++++++++++++-------- backend/routers/prompts.py | 14 ++- backend/tests/test_placeholder_modifier_d.py | 32 ++++--- 5 files changed, 151 insertions(+), 91 deletions(-) diff --git a/backend/placeholder_registry.py b/backend/placeholder_registry.py index b02fe96..7dd1c1c 100644 --- a/backend/placeholder_registry.py +++ b/backend/placeholder_registry.py @@ -260,27 +260,24 @@ class PlaceholderRegistry: def build_ai_placeholder_caption(metadata: PlaceholderMetadata, max_len: int = 400) -> str: """ - Text für |d und Exportfeld ai_caption: zuerst **was** der Platzhalter misst (description), - dann **Einordnung** (business_meaning oder gekürzter semantic_contract). - So ist klar, worauf sich der konkrete Wert bezieht — nicht nur eine „Meta-Bedeutung“. + Kurzerklärung / Einordnung für {{key|x}} und Exportfeld ``ai_caption`` (ohne Wert, ohne Einheit). + + Inhalt: business_meaning oder gekürzter semantic_contract; bei SCORE-Zeilen die 0–100-Skala. + Nicht enthalten: description (die nur bei {{key|d}} angehängt wird) und keine „Technischer Bezug: …“-Zeile. """ desc = (metadata.description or "").strip() bm = (metadata.business_meaning or "").strip() sc = (metadata.semantic_contract or "").strip() chunks: List[str] = [] - if desc: - chunks.append(desc) interpret = bm if not interpret and sc: interpret = sc if len(sc) <= max_len else sc[: max_len - 1] + "…" if interpret: - blob = " ".join(chunks).lower() il = interpret.lower() - # Keine Dublette: gleicher Text oder lange Description bereits in der Interpretation - redundant = il in blob or ( + redundant = bool( desc and len(desc) >= 10 and desc.lower() in il @@ -291,27 +288,10 @@ def build_ai_placeholder_caption(metadata: PlaceholderMetadata, max_len: int = 4 if metadata.placeholder_type == PlaceholderType.SCORE: chunks.append("Skala 0–100: höher = im Modell günstiger / besser abgestimmt.") - unit = (metadata.unit or "").strip() - if unit and metadata.placeholder_type != PlaceholderType.SCORE: - blob = " ".join(chunks).lower() - u_low = unit.lower() - # Einheit oft schon in description („… in g (30d)“, „Kalorien“) — nicht doppeln - compact_blob = blob.replace(" ", "").replace("/", "") - compact_u = u_low.replace(" ", "").replace("/", "") - unit_redundant = compact_u in compact_blob or ( - "g/day" in u_low and ("g/" in blob or "gramm" in blob or " protein" in blob or " fett" in blob or " kh" in blob) - ) or ("kcal" in u_low and ("kcal" in blob or "kalorien" in blob)) - - if ( - not unit_redundant - and u_low not in ("score (0-100)", "0-100", "0–100", "dimensionless") - ): - chunks.append(f"Technischer Bezug: {unit}.") - out = " ".join(c for c in chunks if c).strip() if len(out) > max_len + 120: out = out[: max_len + 60] + "…" - return out or desc or metadata.key + return out # Global registry instance diff --git a/backend/placeholder_resolver.py b/backend/placeholder_resolver.py index 40aae40..61a5d43 100644 --- a/backend/placeholder_resolver.py +++ b/backend/placeholder_resolver.py @@ -49,25 +49,46 @@ from data_layer.health_metrics import ( from placeholder_registry import build_ai_placeholder_caption, get_registry -# {{key}} oder {{key|d}} — Modifier d hängt KI-Kontext (ai_caption) an +# {{key|d}} — nur description anhängen; {{key|x}} — nur Erklärung (ai_caption / Registry) _PLACEHOLDER_TOKEN_RE = re.compile( r"\{\{\s*([a-zA-Z0-9_]+)(?:\s*\|\s*([a-zA-Z0-9_,\s]+))?\s*\}\}" ) -def _ai_caption_for_placeholder_key(key: str) -> Optional[str]: - meta = get_registry().get(key) - if meta: - return build_ai_placeholder_caption(meta) +def get_catalog_row_for_key( + catalog: Optional[Dict[str, List[Dict[str, Any]]]], key: str +) -> Optional[Dict[str, Any]]: + """Katalogzeile zu Platzhalter-Key (key ohne {{}}).""" + if not catalog: + return None + for items in catalog.values(): + for item in items: + raw = item.get("key") or "" + ik = str(raw).replace("{{", "").replace("}}", "").strip() + if ik == key: + return item return None +def _description_for_registry_key(key: str) -> str: + meta = get_registry().get(key) + if not meta: + return "" + return (meta.description or "").strip() + + +def _explain_for_registry_key(key: str) -> str: + meta = get_registry().get(key) + if not meta: + return "" + return build_ai_placeholder_caption(meta).strip() + + def format_value_with_d_modifier(value: str, catalog_row: Dict[str, Any]) -> str: """ - Entspricht der Prompt-Ersetzung bei {{key|d}}: „Wert — Kontext“. - Kontext: ai_caption aus dem Katalog, sonst description (wie prompt_executor). + Vorschau für Export wie {{key|d}}: „Wert — description“ (kein ai_caption). """ - cap = (catalog_row.get("ai_caption") or catalog_row.get("description") or "").strip() + cap = (catalog_row.get("description") or "").strip() if cap: return f"{value} — {cap}" return str(value) @@ -1615,7 +1636,9 @@ def resolve_placeholders(template: str, profile_id: str) -> str: Unterstützt Modifier wie bei der Prompt-Pipeline: - {{fat_avg}} — nur Wert - - {{fat_avg|d}} — Wert plus KI-Kontext (business_meaning / semantic_contract aus Registry) + - {{fat_avg|d}} — Wert — description (kurz, token-sparend) + - {{fat_avg|x}} — nur Erklärung (business_meaning / semantic_contract, ggf. Score-Skala), ohne Wert + - {{fat_avg|d,x}} — Wert — description — Erklärung Args: template: Prompt template with placeholders @@ -1637,11 +1660,27 @@ def resolve_placeholders(template: str, profile_id: str) -> str: value = str(resolver(profile_id)) except Exception: return f"[Fehler: {ph}]" - if "d" in mods: - cap = _ai_caption_for_placeholder_key(key) - if cap: - value = f"{value} — {cap}" - return value + + want_d = "d" in mods + want_x = "x" in mods + + if want_x and not want_d: + expl = _explain_for_registry_key(key) + return expl if expl else "" + + if not want_d and not want_x: + return value + + parts: List[str] = [value] + if want_d: + desc = _description_for_registry_key(key) + if desc: + parts.append(desc) + if want_x: + expl = _explain_for_registry_key(key) + if expl: + parts.append(expl) + return " — ".join(parts) return _PLACEHOLDER_TOKEN_RE.sub(_repl, template) @@ -1846,7 +1885,7 @@ def get_placeholder_catalog(profile_id: str) -> Dict[str, List[Dict[str, str]]]: 'key': key, 'description': description, 'example': str(example), - 'ai_caption': description, + 'ai_caption': '', }) # Add ALL remaining placeholders from PLACEHOLDER_MAP that aren't categorized yet diff --git a/backend/prompt_executor.py b/backend/prompt_executor.py index 4eb0f33..0970892 100644 --- a/backend/prompt_executor.py +++ b/backend/prompt_executor.py @@ -12,14 +12,17 @@ import re from typing import Dict, Any, Optional from db import get_db, get_cursor, r2d from fastapi import HTTPException +from placeholder_resolver import get_catalog_row_for_key def resolve_placeholders(template: str, variables: Dict[str, Any], debug_info: Optional[Dict] = None, catalog: Optional[Dict] = None) -> str: """ Replace {{placeholder}} with values from variables dict. - Supports modifiers: - - {{key|d}} — angehängter KI-Kontext (ai_caption aus Katalog, sonst description; Katalog nötig) + Modifiers (Katalog aus get_placeholder_catalog empfohlen): + - {{key|d}} — Wert — description (kurz) + - {{key|x}} — nur Erklärung (Katalogfeld ai_caption), ohne Zahlenwert + - {{key|d,x}} — Wert — description — Erklärung Args: template: String with {{key}} or {{key|modifiers}} placeholders @@ -40,46 +43,66 @@ def resolve_placeholders(template: str, variables: Dict[str, Any], debug_info: O parts = full_placeholder.split('|') key = parts[0].strip() modifiers = parts[1].strip() if len(parts) > 1 else '' + mods = {x.strip().lower() for x in modifiers.split(",") if x.strip()} + want_d = "d" in mods + want_x = "x" in mods - if key in variables: - value = variables[key] - # Convert dict/list to JSON string - if isinstance(value, (dict, list)): - resolved_value = json.dumps(value, ensure_ascii=False) - else: - resolved_value = str(value) - - # Apply modifiers - if 'd' in modifiers: - if catalog: - caption = None - for cat_items in catalog.values(): - matching = [item for item in cat_items if item['key'] == key] - if matching: - row = matching[0] - caption = (row.get('ai_caption') or row.get('description') or '').strip() - break - - if caption: - resolved_value = f"{resolved_value} — {caption}" - else: - # Catalog not available - log warning in debug - if debug_info is not None: - if 'warnings' not in debug_info: - debug_info['warnings'] = [] - debug_info['warnings'].append(f"Modifier |d used but catalog not available for {key}") - - # Track resolution for debug + def _warn(msg: str): if debug_info is not None: - resolved[key] = resolved_value[:100] + ('...' if len(resolved_value) > 100 else '') + debug_info.setdefault("warnings", []).append(msg) - return resolved_value - else: - # Keep placeholder if no value found + row = get_catalog_row_for_key(catalog, key) if catalog else None + + if want_x and not want_d: + if key not in variables: + if debug_info is not None: + unresolved.append(key) + return match.group(0) + expl = (row.get("ai_caption") or "").strip() if row else "" + if not expl and catalog is None: + _warn(f"Modifier |x für {key}: Katalog fehlt (ai_caption).") + out = expl + if debug_info is not None: + resolved[key] = out[:100] + ("..." if len(out) > 100 else "") + return out + + if key not in variables: if debug_info is not None: unresolved.append(key) return match.group(0) + value = variables[key] + if isinstance(value, (dict, list)): + resolved_value = json.dumps(value, ensure_ascii=False) + else: + resolved_value = str(value) + + if not want_d and not want_x: + out = resolved_value + if debug_info is not None: + resolved[key] = out[:100] + ("..." if len(out) > 100 else "") + return out + + parts = [resolved_value] + if want_d: + if row: + desc = (row.get("description") or "").strip() + if desc: + parts.append(desc) + else: + _warn(f"Modifier |d für {key}: Katalog fehlt (description).") + if want_x: + expl = (row.get("ai_caption") or "").strip() if row else "" + if expl: + parts.append(expl) + elif catalog is not None: + _warn(f"Modifier |x (mit |d) für {key}: ai_caption leer.") + + out = " — ".join(parts) + if debug_info is not None: + resolved[key] = out[:100] + ("..." if len(out) > 100 else "") + return out + result = re.sub(r'\{\{([^}]+)\}\}', replacer, template) # Store debug info @@ -464,7 +487,7 @@ async def execute_prompt_with_data( 'today': datetime.now().strftime('%Y-%m-%d') } - # Load placeholder catalog for |d modifier support + # Load placeholder catalog for |d / |x Modifier try: catalog = get_placeholder_catalog(profile_id) except Exception as e: diff --git a/backend/routers/prompts.py b/backend/routers/prompts.py index 43fc9ba..5153ce5 100644 --- a/backend/routers/prompts.py +++ b/backend/routers/prompts.py @@ -17,8 +17,8 @@ from models import ( PromptCreate, PromptUpdate, PromptGenerateRequest, PipelineConfigCreate, PipelineConfigUpdate ) +from prompt_executor import resolve_placeholders as resolve_prompt_placeholders from placeholder_resolver import ( - resolve_placeholders, get_unknown_placeholders, get_placeholder_example_values, format_value_with_d_modifier, @@ -432,7 +432,13 @@ def preview_prompt(data: dict, session: dict=Depends(require_auth)): template = data.get('template', '') profile_id = session['profile_id'] - resolved = resolve_placeholders(template, profile_id) + catalog = get_placeholder_catalog(profile_id) + processed = get_placeholder_example_values(profile_id) + variables = { + k.replace('{{', '').replace('}}', ''): v + for k, v in processed.items() + } + resolved = resolve_prompt_placeholders(template, variables, None, catalog) unknown = get_unknown_placeholders(template) return { @@ -458,8 +464,8 @@ def export_placeholder_values(session: dict = Depends(require_auth)): """ Export all available placeholders with their current resolved values. - Pro Zeile: value = Rohwert wie bei {{key}}, example = Vorschau wie bei {{key|d}} - (Wert — ai_caption bzw. description). JSON-Download für das aktive Profil. + Pro Zeile: value = {{key}}, example = Vorschau {{key|d}} (Wert — description), + ai_caption = Text für {{key|x}} (Erklärung ohne Wert). JSON für das aktive Profil. """ from datetime import datetime profile_id = session['profile_id'] diff --git a/backend/tests/test_placeholder_modifier_d.py b/backend/tests/test_placeholder_modifier_d.py index bf55111..4387937 100644 --- a/backend/tests/test_placeholder_modifier_d.py +++ b/backend/tests/test_placeholder_modifier_d.py @@ -1,4 +1,4 @@ -"""Tests für {{key|d}}, ai_caption und Unbekannt-Erkennung.""" +"""Tests für {{key|d}}, {{key|x}}, ai_caption und Unbekannt-Erkennung.""" from placeholder_registry import ( PlaceholderMetadata, PlaceholderType, @@ -7,9 +7,10 @@ from placeholder_registry import ( ) import placeholder_resolver as pr from placeholder_resolver import format_value_with_d_modifier +from prompt_executor import resolve_placeholders -def test_build_ai_caption_prefers_business_meaning(): +def test_build_ai_caption_is_explanation_only(): m = PlaceholderMetadata( key="test_x", category="Test", @@ -23,11 +24,11 @@ def test_build_ai_caption_prefers_business_meaning(): output_type=OutputType.NUMERIC, ) cap = build_ai_placeholder_caption(m) - assert cap.startswith("Kurzbeschreibung") assert "Kernbedeutung" in cap + assert "Kurzbeschreibung" not in cap -def test_build_ai_caption_description_then_meaning_like_protein_avg(): +def test_build_ai_caption_protein_avg_no_description_prefix(): m = PlaceholderMetadata( key="protein_avg", category="Ernährung", @@ -40,8 +41,8 @@ def test_build_ai_caption_description_then_meaning_like_protein_avg(): output_type=OutputType.NUMERIC, ) cap = build_ai_placeholder_caption(m) - assert cap.startswith("Durchschn. Protein in g (30d)") - assert "Muskelerhalt" in cap + assert cap.startswith("Zentraler Placeholder") + assert "Durchschn. Protein" not in cap assert "Technischer Bezug" not in cap @@ -69,6 +70,8 @@ def test_placeholder_token_regex_optional_modifier(): assert m1 and m1.group(1) == "fat_avg" and m1.group(2).strip() == "d" m2 = pr._PLACEHOLDER_TOKEN_RE.search("{{ protein_avg | d }}") assert m2 and m2.group(1) == "protein_avg" and m2.group(2).strip() == "d" + m3 = pr._PLACEHOLDER_TOKEN_RE.search("{{k|d,x}}") + assert m3 and m3.group(1) == "k" and m3.group(2).strip() == "d,x" def test_get_unknown_placeholders_strips_modifier(): @@ -76,17 +79,26 @@ def test_get_unknown_placeholders_strips_modifier(): assert set(unk) == {"not_a_real_key"} -def test_format_value_with_d_modifier_matches_prompt_executor(): +def test_format_value_with_d_modifier_uses_description_only(): row = { "key": "protein_avg", "description": "Durchschn. Protein in g (30d)", - "example": "119g/Tag", - "ai_caption": "Durchschn. Protein in g (30d). Zentral für Muskelerhalt.", + "ai_caption": "Nur für |x", } out = format_value_with_d_modifier("119g/Tag", row) - assert out == "119g/Tag — Durchschn. Protein in g (30d). Zentral für Muskelerhalt." + assert out == "119g/Tag — Durchschn. Protein in g (30d)" + assert "Nur für |x" not in out def test_format_value_with_d_modifier_falls_back_to_description(): row = {"description": "Nur Beschreibung", "key": "x"} assert format_value_with_d_modifier("42", row) == "42 — Nur Beschreibung" + + +def test_prompt_executor_modifiers_d_x_combined(): + catalog = {"E": [{"key": "p", "description": "Desc", "ai_caption": "Expl"}]} + v = {"p": "99"} + assert resolve_placeholders("{{p|d}}", v, None, catalog) == "99 — Desc" + assert resolve_placeholders("{{p|x}}", v, None, catalog) == "Expl" + assert resolve_placeholders("{{p|d,x}}", v, None, catalog) == "99 — Desc — Expl" + assert resolve_placeholders("{{p}}", v, None, catalog) == "99"