From 00437a92ab70b7594b72eee78de396a1819ad6f3 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 5 Apr 2026 17:35:48 +0200 Subject: [PATCH] feat: Enhance sleep module with CSV import functionality and date parsing improvements --- backend/routers/sleep.py | 279 +++++++++++++----- ...NINGSPROFILE_SPORTSPEZIFISCH_2026-04-05.md | 202 +++++++++++++ 2 files changed, 401 insertions(+), 80 deletions(-) create mode 100644 docs/issues/UMSETZUNGSPLAN_TRAININGSPROFILE_SPORTSPEZIFISCH_2026-04-05.md diff --git a/backend/routers/sleep.py b/backend/routers/sleep.py index dfdf8e6..da5b559 100644 --- a/backend/routers/sleep.py +++ b/backend/routers/sleep.py @@ -9,7 +9,8 @@ Endpoints: from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from pydantic import BaseModel from typing import Literal -from datetime import datetime, timedelta +from datetime import datetime, timedelta, date +from decimal import Decimal, InvalidOperation import csv import io import json @@ -19,6 +20,174 @@ from db import get_db, get_cursor router = APIRouter(prefix="/api/sleep", tags=["sleep"]) + +def _strip_row_keys(row: dict) -> dict: + return {(k or "").strip(): (v.strip() if isinstance(v, str) else v) for k, v in row.items()} + + +def _safe_float(value) -> float | None: + if value is None or value == "": + return None + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, Decimal): + return float(value) + try: + s = str(value).strip().replace(",", ".") + return float(s) + except (ValueError, InvalidOperation): + return None + + +def _parse_apple_sleep_datetime(value: str) -> datetime: + raw = (value or "").strip() + if not raw: + raise ValueError("empty datetime") + fmts = ( + "%Y-%m-%d %H:%M:%S %z", + "%d.%m.%y %H:%M:%S", + "%d.%m.%Y %H:%M:%S", + "%Y-%m-%d %H:%M:%S", + ) + last_err = None + for fmt in fmts: + try: + return datetime.strptime(raw, fmt) + except ValueError as e: + last_err = e + raise ValueError(f"Unbekanntes Datumsformat: {raw!r}") from last_err + + +def _hr_to_minutes(hours: float | None) -> int: + if hours is None: + return 0 + return int(round(float(hours) * 60)) + + +def _detect_apple_sleep_csv_format(fieldnames: list[str] | None) -> Literal["segments", "summary"]: + if not fieldnames: + raise HTTPException( + status_code=400, + detail="CSV enthält keine Spaltenüberschriften.", + ) + fn = {(f or "").strip() for f in fieldnames} + if {"Start", "End", "Duration (hr)", "Value"}.issubset(fn): + return "segments" + if "Start" in fn and "End" in fn and "Total Sleep (hr)" in fn: + return "summary" + raise HTTPException( + status_code=400, + detail=( + "Unbekanntes Apple-Health-Schlaf-CSV. " + "Erwartet wird entweder der Segment-Export (Spalten Start, End, Duration (hr), Value) " + "oder die Schlafanalyse mit Nacht-Zusammenfassung (u. a. Total Sleep (hr), Core/Light, Deep, REM)." + ), + ) + + +def _build_nights_from_apple_summary(reader: csv.DictReader) -> dict[date, dict]: + nights_dict: dict[date, dict] = {} + for raw in reader: + row = _strip_row_keys(raw) + start_s = row.get("Start") or "" + end_s = row.get("End") or "" + if not start_s or not end_s: + continue + try: + start_dt = _parse_apple_sleep_datetime(start_s) + end_dt = _parse_apple_sleep_datetime(end_s) + except ValueError: + continue + + dt_key = (row.get("Date/Time") or row.get("Datum/Uhrzeit") or "").strip() + if dt_key: + try: + wake_d = datetime.strptime(dt_key[:10], "%Y-%m-%d").date() + except ValueError: + wake_d = end_dt.date() + else: + wake_d = end_dt.date() + + core_hr = _safe_float(row.get("Core (hr)")) + if core_hr is None: + core_hr = _safe_float(row.get("Light (hr)")) or 0.0 + deep_min = _hr_to_minutes(_safe_float(row.get("Deep (hr)"))) + rem_min = _hr_to_minutes(_safe_float(row.get("REM (hr)"))) + light_min = _hr_to_minutes(core_hr) + awake_min = _hr_to_minutes(_safe_float(row.get("Awake (hr)"))) + nights_dict[wake_d] = { + "bedtime": start_dt, + "wake_time": end_dt, + "segments": [], + "deep_minutes": deep_min, + "rem_minutes": rem_min, + "light_minutes": light_min, + "awake_minutes": awake_min, + } + return nights_dict + + +def _build_nights_from_apple_segments(reader: csv.DictReader, phase_map: dict) -> dict[date, dict]: + segments = [] + for raw in reader: + row = _strip_row_keys(raw) + phase_key = (row.get("Value") or "").strip() + phase_en = phase_map.get(phase_key) + if phase_en is None: + continue + try: + start_dt = _parse_apple_sleep_datetime(row.get("Start") or "") + end_dt = _parse_apple_sleep_datetime(row.get("End") or "") + duration_hr = _safe_float(row.get("Duration (hr)")) + if duration_hr is None: + continue + except (ValueError, TypeError): + continue + duration_min = int(duration_hr * 60) + segments.append({ + "start": start_dt, + "end": end_dt, + "duration_min": duration_min, + "phase": phase_en, + }) + + segments.sort(key=lambda s: s["start"]) + nights = [] + current_night = None + + for seg in segments: + if current_night is None or (seg["start"] - current_night["wake_time"]).total_seconds() > 7200: + current_night = { + "bedtime": seg["start"], + "wake_time": seg["end"], + "segments": [], + "deep_minutes": 0, + "rem_minutes": 0, + "light_minutes": 0, + "awake_minutes": 0, + } + nights.append(current_night) + + current_night["segments"].append(seg) + current_night["wake_time"] = max(current_night["wake_time"], seg["end"]) + current_night["bedtime"] = min(current_night["bedtime"], seg["start"]) + + if seg["phase"] == "deep": + current_night["deep_minutes"] += seg["duration_min"] + elif seg["phase"] == "rem": + current_night["rem_minutes"] += seg["duration_min"] + elif seg["phase"] == "light": + current_night["light_minutes"] += seg["duration_min"] + elif seg["phase"] == "awake": + current_night["awake_minutes"] += seg["duration_min"] + + nights_dict: dict[date, dict] = {} + for night in nights: + wake_date = night["wake_time"].date() + nights_dict[wake_date] = night + return nights_dict + + # ── Models ──────────────────────────────────────────────────────────────────── class SleepCreate(BaseModel): @@ -460,96 +629,46 @@ async def import_apple_health_sleep( """ Import sleep data from Apple Health CSV export. - Expected CSV format: - Start,End,Duration (hr),Value,Source - 2026-03-14 22:44:23,2026-03-14 23:00:19,0.266,Kern,Apple Watch + Supports: + - Segment-Export: Start, End, Duration (hr), Value (Kern/Tief/REM/Wach oder Core/Deep/…) + - Schlafanalyse (Nacht-Zusammenfassung): Date/Time, Start, End, Total Sleep (hr), + Core/Light (hr), Deep (hr), REM (hr), Awake (hr), … - - Aggregates segments by night (wake date) - - Maps German phase names: Kern→light, REM→rem, Tief→deep, Wach→awake - - Stores raw segments in JSONB - - Does NOT overwrite manual entries (source='manual') + - Aggregates segments by night (wake date) where applicable + - Stores Roh-Segmente in JSONB (bei Zusammenfassung oft leer) + - Überschreibt keine manuellen Einträge (source='manual') """ pid = session['profile_id'] - # Read CSV content = await file.read() - csv_text = content.decode('utf-8-sig') # Handle BOM + csv_text = content.decode('utf-8-sig') reader = csv.DictReader(io.StringIO(csv_text)) + fmt = _detect_apple_sleep_csv_format(reader.fieldnames) - # Phase mapping (German → English) phase_map = { - 'Kern': 'light', - 'REM': 'rem', - 'Tief': 'deep', - 'Wach': 'awake', - 'Schlafend': None # Ignore initial sleep entry + "Kern": "light", + "Core": "light", + "Light": "light", + "REM": "rem", + "Tief": "deep", + "Deep": "deep", + "Wach": "awake", + "Awake": "awake", + "Schlafend": None, + "Asleep": None, + "In Bed": None, } - # Parse segments - segments = [] - for row in reader: - phase_de = row['Value'].strip() - phase_en = phase_map.get(phase_de) + if fmt == "summary": + nights_dict = _build_nights_from_apple_summary(reader) + else: + nights_dict = _build_nights_from_apple_segments(reader, phase_map) - if phase_en is None: # Skip "Schlafend" - continue - - start_dt = datetime.strptime(row['Start'], '%Y-%m-%d %H:%M:%S') - end_dt = datetime.strptime(row['End'], '%Y-%m-%d %H:%M:%S') - duration_hr = float(row['Duration (hr)']) - duration_min = int(duration_hr * 60) - - segments.append({ - 'start': start_dt, - 'end': end_dt, - 'duration_min': duration_min, - 'phase': phase_en - }) - - # Sort segments chronologically - segments.sort(key=lambda s: s['start']) - - # Group segments into nights (gap-based) - # If gap between segments > 2 hours → new night - nights = [] - current_night = None - - for seg in segments: - # Start new night if: - # 1. First segment - # 2. Gap > 2 hours since last segment - if current_night is None or (seg['start'] - current_night['wake_time']).total_seconds() > 7200: - current_night = { - 'bedtime': seg['start'], - 'wake_time': seg['end'], - 'segments': [], - 'deep_minutes': 0, - 'rem_minutes': 0, - 'light_minutes': 0, - 'awake_minutes': 0 - } - nights.append(current_night) - - # Add segment to current night - current_night['segments'].append(seg) - current_night['wake_time'] = max(current_night['wake_time'], seg['end']) - current_night['bedtime'] = min(current_night['bedtime'], seg['start']) - - # Sum phases - if seg['phase'] == 'deep': - current_night['deep_minutes'] += seg['duration_min'] - elif seg['phase'] == 'rem': - current_night['rem_minutes'] += seg['duration_min'] - elif seg['phase'] == 'light': - current_night['light_minutes'] += seg['duration_min'] - elif seg['phase'] == 'awake': - current_night['awake_minutes'] += seg['duration_min'] - - # Convert nights list to dict with wake_date as key - nights_dict = {} - for night in nights: - wake_date = night['wake_time'].date() # Date when you woke up - nights_dict[wake_date] = night + if not nights_dict: + raise HTTPException( + status_code=400, + detail="Keine importierbaren Schlafzeilen gefunden (prüfe Start/Ende und Format).", + ) # Insert nights imported = 0 diff --git a/docs/issues/UMSETZUNGSPLAN_TRAININGSPROFILE_SPORTSPEZIFISCH_2026-04-05.md b/docs/issues/UMSETZUNGSPLAN_TRAININGSPROFILE_SPORTSPEZIFISCH_2026-04-05.md new file mode 100644 index 0000000..4392549 --- /dev/null +++ b/docs/issues/UMSETZUNGSPLAN_TRAININGSPROFILE_SPORTSPEZIFISCH_2026-04-05.md @@ -0,0 +1,202 @@ +# Umsetzungsplan: Sportspezifisch auswertbare Trainingsprofile + +**Status:** Konzept für fachliche Abstimmung / Konzeptagent +**Datum:** 2026-04-05 +**Bezug:** Fachliches Teilkonzept „Sportspezifisch auswertbare Trainingsprofile“ (User-Entwurf) +**Hinweis:** Keine Implementierung ohne Freigabe der unter [Offene Fragen](#offene-fragen-für-den-fachlichen-konzeptagenten) gekläerten Punkte. + +--- + +## 1. Kurzfazit: Machbarkeit + +**Fachlich und technisch grundsätzlich machbar** als gestaffelte Erweiterung auf dem bestehenden System: + +- `training_types.profile` (JSONB) +- `TrainingProfileEvaluator` (`backend/profile_evaluator.py`) +- Persistenz: `activity_log.evaluation`, `quality_label`, `overall_score` + +**Engpass:** Viele im Teilkonzept genannten Kriterien (z. B. Muskelgruppen, Nähe zum muskulären Versagen, detaillierte Zonenzeiten) **lassen sich aus der aktuellen `activity_log`-Datenbasis nicht zuverlässig ableiten**. Dafür braucht es zusätzliche Erfassung, Importfelder oder explizite „niedrige Evidenz / unbekannt“-Markierung in Metriken und KI-Kontext (Placeholder-Registry, Erklärbarkeit). + +--- + +## 2. Ist-Analyse (Abhängigkeiten) + +### 2.1 Daten & Konfiguration + +| Baustein | Rolle | +|---------|--------| +| `training_types` | `category`, `subcategory`, `name_*`, optional `abilities` (JSONB), `profile` (JSONB) | +| `activity_type_mappings` | Import/manuelles Mapping `activity_type` → `training_type_id` (unverändert lassen) | +| `training_parameters` | Registry messbarer Parameter für Regeln (`source_field` → `activity_log`) | +| `activity_log` | u. a. `training_type_id`, `evaluation`, `quality_label`, `overall_score` | + +### 2.2 Bewertung heute + +- Der Evaluator läuft über mehrere **rule_sets** (Minimumanforderungen, Zonen, Effekte, Periodisierung, KPI, Safety). +- **Ein** zusammengefasster `overall_score` und **ein** `quality_label` (`excellent` | `good` | `acceptable` | `poor`). +- **Relevanz, intrinsische Qualität, Zielbezug, Platzierung** sind im Ergebnis **nicht formal getrennt**; Periodisierung/Performance sind teils **MVP/vereinfacht**. + +### 2.3 Downstream (nicht brechen) + +- **Issue #31 / Quality Filter:** `profiles.quality_filter_level`, `quality_filter.py` (SQL-Fragmente nach `quality_label`) +- **Frontend:** Badges in Aktivitäts-UI, History-Hinweise +- **Data Layer / Charts:** u. a. `calculate_quality_sessions_pct`, `activity_score`, Korrelationen +- **Placeholder Registry:** u. a. `quality_sessions_pct` (bekannte Semantik-/Label-Diskrepanz, siehe unten) + +### 2.4 Bekannte technische Inkonsistenz (Bestand) + +`calculate_quality_sessions_pct` filtert u. a. auf `quality_label IN (..., 'very_good', ...)`, der `TrainingProfileEvaluator` setzt **`very_good` nicht**. Das sollte **vor oder zusammen mit** größeren Profil-Erweiterungen bereinigt werden, damit neue Dimensionen nicht auf wackeliger Basis validiert werden. + +--- + +## 3. Umsetzungsprinzipien (Kompatibilität) + +1. **Kein Big-Bang:** Consumer erwarten weiterhin sinnvolle `quality_label` / `overall_score`, es sei denn, es gibt eine **versionierte Cutover-Strategie**. +2. **Additive JSON-Struktur** in `evaluation` (z. B. mehrdimensionale Teilergebnisse), ohne bestehende Schlüssel willkürlich zu entfernen. +3. **Legacy-Vertrag festlegen:** z. B. `overall_score` = **primär intrinsische** Qualität (oder explizit „Legacy-Komposit“ mit `schema_version`). +4. **`training_types` bleibt Single Source** für Typ + Profil; Mapping-Import-Logik nicht „mitziehen“, außer es ist fachlich nötig. +5. **Neue Coach-Metriken:** über Placeholder Registry, mit Evidence-Tags; keine doppelte Wahrheit neben der Registry. +6. **KI:** `ai_context` / `interpretation_boundary` im Profil ausbaubar machen (was erlaubt/verboten ist, bei Datenlücken). + +--- + +## 4. Phasenplan + +### Phase 0 – Begriffe, Verträge, Bestandsaufnahme + +- Glossar: Relevanz, intrinsische Qualität, Zielbeitrag, Platzierung, Belastung, sportspezifische Qualität → **Mapping auf bestehende `rule_sets`**. +- Vollständige Liste: wer liest `quality_label` / `evaluation` / `overall_score` (APIs, SQL, Frontend). +- Align: `very_good` vs. Evaluator-Labels; optional: Rolle von `acceptable` in „Qualitätssessions“ vs. globalem Filter **fachlich** klären. + +### Phase 1 – Metamodell im Profil-JSON + +- Erweiterung `training_types.profile`: z. B. `sport_logic`, `dimensions_config`, `goal_linkage`, `load_character`, `recovery_character`, `interference`, `ai_interpretation`. +- Evaluator liefert **Teilscores + Begründungen** pro Dimension; **Legacy-Felder** ableitbar und dokumentiert. + +### Phase 2 – Zielbezug + +- Input: aktive Ziele, `goal_mode`, Focus-Gewichte (`user_focus_area_weights`). +- Ausgabe: eigene Dimension **`goal_fit`**, ohne intrinsische Bewertung zu überschreiben. + +### Phase 3 – Wochenkontext / Platzierung / Interferenz + +- Kontext in `load_evaluation_context` erweitern (Reihenfolge, Vortag, Ruhetage, optional Vitals/Schlaf). +- Regeln schrittweise; False-Positive-Risiko beachten. + +### Phase 4 – Sportspezifische Templates + +- Profilvorlagen pro `sport_logic` (analog `profile_templates.py`), unterschiedliche Parameter und Schwellen. +- `confidence` / `data_sufficiency` pro Dimension. + +### Phase 5 – Coach-Metriken & Placeholder + +- Neue Platzhalter nur bei klarer Semantik; ggf. `quality_sessions_pct` **umschreiben** oder durch neuen Key ersetzen + Deprecation-Plan. + +### Phase 6 – Frontend / Admin + +- Dimensionen sichtbar machen; Badges können vorerst Legacy-Score anzeigen. + +--- + +## 5. Risiken (Kurz) + +| Risiko | Maßnahme | +|--------|----------| +| Bedeutung von `quality_label` wechselt still | `evaluation.schema_version`, dokumentierter Vertrag, ggf. separates `intrinsic_quality_label` nach Cutover | +| Charts/Scores springen | Shadow-Vergleich, gestuftes Rollout | +| KI übertreibt bei Datenlücken | `ai_interpretation` + Metadata-Evidence | +| Registry-Drift | Alle neuen Kennzahlen registrieren | + +--- + +## Offene Fragen für den fachlichen Konzeptagenten + +Die folgenden Blöcke sind bewusst als Markdown-Codeblöcke formatiert, damit sie **unverändert** in Prompts oder Tickets übernommen werden können. + +### Block A – Priorität & Konflikte zwischen Dimensionen + +```markdown +Wenn sich Dimensionen widersprechen (z. B. intrinsische Qualität „hoch“, Zielpassung „niedrig“, Platzierung „problematisch“): + +1. Welche Dimension soll für die **primäre Nutzer-/Coach-Aussage** in der UI führend sein? +2. Gibt es eine **feste Prioritätsreihenfolge** (z. B. Sicherheit > Platzierung > Zielpassung > intrinsische Qualität) oder ist sie kontextabhängig? +3. Soll es eine **explizite „Gesamt-Coach-Empfehlung“** geben, die aus den Dimensionen kombiniert wird, oder nur parallele Teilurteile ohne Gesamtnote? +``` + +### Block B – Minimaldatensatz & Sportlogiken + +```markdown +Pro Trainingsfamilie / „sport_logic“ (Kraft, Cardio, Schnellkraft, Kampfsport, Mobility, Erholung aktiv, Geist & Meditation): + +1. Welche **messbaren Felder** sind minimal nötig, damit eine sportspezifische Bewertung **fachlich vertretbar** ist (nicht nur „nice to have“)? +2. Welche Aussagen dürfen **ohne** diese Felder **nicht** getroffen werden (nur „unbekannt“ / Confidence niedrig)? +3. Sollen fehlende Daten die **intrinsische** Bewertung hart begrenzen (Cap), oder nur **Zusatzdimensionen** betreffen? +``` + +### Block C – Einzeltraining vs. Wochenkontext + +```markdown +Platzierung / Kontextqualität: + +1. Darf schlechte Platzierung die **sichtbare Einheit-Qualität** numerisch senken, oder nur **Flags/Hinweise** erzeugen? +2. Welche zeitliche Auflösung ist Pflicht: nur **Datum** oder auch **start_time** / Reihenfolge am Tag? +3. Wie gehen wir mit **unvollständiger Historie** um (Importlücken, manuell gelöschte Einträge)? +``` + +### Block D – Ziele & Multi-Ziel + +```markdown +Zielbezug (goal_fit): + +1. Bewertung strikt gegen **ein Primärziel**, oder gewichtet gegen **alle aktiven Ziele**? +2. Wie werden **Focus Areas** vs. **konkrete Goals** priorisiert, wenn sie sich widersprechen? +3. Soll „behindert das Ziel“ (Teilkonzept) ein **eigener Score** oder nur eine **kategorische** Aussage sein? +``` + +### Block E – KI & Haftung / Grenzen + +```markdown +KI-Interpretationsrahmen (pro Profil oder global): + +1. Welche Formulierungen sind **verboten** (medizinische Diagnosen, absolute Leistungsversprechen, …)? +2. Muss jede KI-gestützte Aussage einen **Evidenz-Hinweis** tragen („basierend auf Dauer/HF“, „Muskelgruppen nicht erfasst“)? +3. Soll der Nutzer **opt-in** für interpretative Texte jenseits der Regeln haben? +``` + +### Block F – Legacy-Metriken & Filter + +```markdown +Bestehende Kennzahlen: + +1. Soll `quality_label` / `overall_score` dauerhaft **nur intrinsisch** bedeuten, oder ein **Komposit** aus mehreren Dimensionen? +2. Wie sollen `quality_sessions_pct` und der globale **Quality Filter** künftig mit mehrdimensionalen Urteilen umgehen (nur intrinsisch filtern vs. Kombination)? +3. Ist `acceptable` fachlich eine „Qualitätssession“ oder nur „formal ok“? +``` + +### Block G – Abnahme / Akzeptanz + +```markdown +Abnahme: + +1. Welche **messbaren** Abnahmekriterien gelten pro Phase (z. B. % konsistent bewerteter Sessions, keine Regression bei Filter-Charts)? +2. Wer **fachlich** sign-off: Rolle „Domain Owner“ vs. Entwicklung? +``` + +--- + +## Anhang: Referenz im Repository + +| Thema | Pfad (orientierend) | +|-------|---------------------| +| Evaluator | `backend/profile_evaluator.py` | +| Regeln | `backend/rule_engine.py` | +| Auswertung + Kontext | `backend/evaluation_helper.py` | +| Profile JSON / Migration | `backend/migrations/014_training_profiles.sql`, Templates `backend/profile_templates.py` | +| Quality Filter | `backend/quality_filter.py` | +| Aktivitäts-API inkl. Eval | `backend/routers/activity.py` | +| Quality-Sessions-Metrik | `backend/data_layer/activity_metrics.py` (`calculate_quality_sessions_pct`) | +| Placeholder Registry | `backend/placeholder_registrations/activity_metrics.py` | + +--- + +*Ende des Dokuments.*