feat: Enhance sleep module with CSV import functionality and date parsing improvements
All checks were successful
Deploy Development / deploy (push) Successful in 56s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s

This commit is contained in:
Lars 2026-04-05 17:35:48 +02:00
parent 24f60c0a6d
commit 00437a92ab
2 changed files with 401 additions and 80 deletions

View File

@ -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: Kernlight, REMrem, Tiefdeep, Wachawake
- 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

View File

@ -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.*