Compare commits
No commits in common. "main" and "ench.1.0" have entirely different histories.
|
|
@ -52,10 +52,9 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
|
||||||
|--------|-------------|-------------------|
|
|--------|-------------|-------------------|
|
||||||
| Data Layer / Charts (Phase 0c) | `functional/DATA_ARCHITECTURE.md`, `technical/DATA_LAYER_EXTENSION_GUIDE.md` | `backend/data_layer/`, `backend/routers/charts.py` |
|
| Data Layer / Charts (Phase 0c) | `functional/DATA_ARCHITECTURE.md`, `technical/DATA_LAYER_EXTENSION_GUIDE.md` | `backend/data_layer/`, `backend/routers/charts.py` |
|
||||||
| Platzhalter / Registry | `technical/PLACEHOLDER_REGISTRY_FRAMEWORK.md`, `technical/PLACEHOLDER_DEVELOPMENT_GUIDE.md` | `backend/placeholder_registrations/`, `backend/placeholder_resolver.py` |
|
| Platzhalter / Registry | `technical/PLACEHOLDER_REGISTRY_FRAMEWORK.md`, `technical/PLACEHOLDER_DEVELOPMENT_GUIDE.md` | `backend/placeholder_registrations/`, `backend/placeholder_resolver.py` |
|
||||||
| Dashboard-Widgets | `technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` | Widget-Katalog + Registrierung (siehe Guide) |
|
| Dashboard-Lab-Widgets | `technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` | Widget-Katalog + Registrierung (siehe Guide) |
|
||||||
| Training Profiler / Resolver | `technical/TRAINING_PROFILE_RESOLVER_LAYER1.md`, `functional/TRAINING_TYPE_PROFILES.md` | Resolver-Module wie im Guide genannt |
|
| Training Profiler / Resolver | `technical/TRAINING_PROFILE_RESOLVER_LAYER1.md`, `functional/TRAINING_TYPE_PROFILES.md` | Resolver-Module wie im Guide genannt |
|
||||||
| Universal CSV Import | `technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | `backend/csv_parser/`, `routers/csv_import.py`, `routers/admin_csv_templates.py` |
|
| Universal CSV Import | `technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | `backend/csv_parser/`, `routers/csv_import.py`, `routers/admin_csv_templates.py` |
|
||||||
| Aktivität Produktionsreife | `technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` (+ EAV-Guide) | `backend/data_layer/activity_session_metrics.py`, `activity_metrics.py`, CSV-Orchestrierung |
|
|
||||||
| Mitgliedschaft / Features | `technical/MEMBERSHIP_SYSTEM.md`, `architecture/FEATURE_ENFORCEMENT.md` | `backend/auth.py`, Feature-Logging, Router mit Enforcement |
|
| Mitgliedschaft / Features | `technical/MEMBERSHIP_SYSTEM.md`, `architecture/FEATURE_ENFORCEMENT.md` | `backend/auth.py`, Feature-Logging, Router mit Enforcement |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -114,12 +113,6 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
|
||||||
| `TRAINING_PROFILE_RESOLVER_LAYER1.md` | Training-Resolver Schicht 1 |
|
| `TRAINING_PROFILE_RESOLVER_LAYER1.md` | Training-Resolver Schicht 1 |
|
||||||
| `TRAINING_TYPE_PROFILES_TECHNICAL.md` | Trainingsprofile technisch |
|
| `TRAINING_TYPE_PROFILES_TECHNICAL.md` | Trainingsprofile technisch |
|
||||||
| `UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | Universal CSV: Registry, Executor, Vorlagen, Agent-Checkliste |
|
| `UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | Universal CSV: Registry, Executor, Vorlagen, Agent-Checkliste |
|
||||||
| `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` | Session-Metriken EAV, Attributprofile, Layer-1, Prod-Migration |
|
|
||||||
| `ACTIVITY_COMPOSITE_METRICS_IMPLEMENTATION_CONCEPT.md` | Composite-Metriken in EAV (JSONB), Archetypen, CSV-Slots, Layer-1-Expand, Migration/Test-Checkliste |
|
|
||||||
| `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` | **Zielarchitektur** Aktivität (Spine/EAV/Composites/Import/Layer 1–2) + **Phasenplan A–F** Produktionsreife |
|
|
||||||
| `ACTIVITY_LAYER2A_PLACEHOLDER_AUDIT.md` | Issue #53: Aktivitäts-Platzhalter Layer 1 ↔ 2a (Audit Schritt 1) |
|
|
||||||
| `ACTIVITY_SCALAR_KANON_TABLE.md` | **Skalar-Kanon** Aktivität (eine Semantik → eine Quelle); Phase A |
|
|
||||||
| *(Code)* `backend/data_layer/activity_data_canon.py` | **Kanon** activity CSV-Modul vs. EAV-primär; Legacy-Lesefallback |
|
|
||||||
| `V9D_PHASE2_VITALS_SLEEP.md` | v9d Vitalwerte/Schlaf (Release-Bezug) |
|
| `V9D_PHASE2_VITALS_SLEEP.md` | v9d Vitalwerte/Schlaf (Release-Bezug) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -1,317 +0,0 @@
|
||||||
# Activity Session Metrics: Composite-Daten (EAV) – Umsetzungskonzept
|
|
||||||
|
|
||||||
**Stand:** 2026-04-16
|
|
||||||
**Status:** Normatives Konzept zur nahtlosen Weiterarbeit durch Code-Agenten
|
|
||||||
**Bezieht sich auf:** `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` (§2.3–2.4, Phasen D–E), `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md`, Issue #53 (Layer-1-Prinzip: Auswertungen nur über `data_layer`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Ziel und Abgrenzung
|
|
||||||
|
|
||||||
### 1.1 Ziel
|
|
||||||
|
|
||||||
- **Composite-Messgrößen** (strukturierte Werte mit mehreren benannten Slots) werden wie **normale Trainingsparameter** im Katalog geführt, **Kategorie-/Typ-Profilen** zugeordnet und pro Session in der **EAV-Tabelle** persistiert.
|
|
||||||
- **Persistenz:** ein JSON-Dokument pro Session und `training_parameter_id` (kanonisch **JSONB**), kompatibel mit der bestehenden „eine Zeile pro Parameter“-Semantik.
|
|
||||||
- **Import:** CSV liefert typischerweise **eine Spalte pro atomarem Slot**; das Mapping verweist auf **`(Parameter-Key, Slot-Key)`** (stabile Strings, nicht Spaltenreihenfolge).
|
|
||||||
- **Layer 1:** liefert für Consumer weiterhin **eine konsistente API**: Rohdokument **und** optional **aufgelöste Einzelwerte** (flach oder namenspaced), ohne dass Charts/Platzhalter direkt JSON parsen müssen.
|
|
||||||
|
|
||||||
### 1.2 Nicht-Ziele (explizit)
|
|
||||||
|
|
||||||
- Kein „freies“ JSON-Schema im Admin ohne Archetyp-Bindung (verhindert Datenmüll und nicht validierbare Dokumente).
|
|
||||||
- Keine Abschwächung bestehender **Skalar-Parameter** (`integer`, `float`, `string`, `boolean`): alle bisherigen Pfade bleiben gültig.
|
|
||||||
- Kein Ersatz für `activity_log`-**Spine** oder Session-Qualitätsblobs (`evaluation`, …).
|
|
||||||
|
|
||||||
### 1.3 Kompatibilitätsgarantie („keine Regression“)
|
|
||||||
|
|
||||||
| Bereich | Maßnahme |
|
|
||||||
|---------|----------|
|
|
||||||
| DB | Nur **additive** Migrationen; bestehende `CHECK`-Regeln für Skalare bleiben für Zeilen **ohne** Composite erhalten bzw. werden zu einer **Oder-Verknüpfung** erweitert (siehe §4). |
|
|
||||||
| `training_parameters` | Neuer `data_type`-Wert **`composite`** zusätzlich zu den vier bestehenden; bestehende CHECK-Constraint muss erweitert werden (Migration). |
|
|
||||||
| `activity_session_metrics` | Skalare Zeilen unverändert; Composite-Zeilen nutzen **`value_json`** (neu), alle `value_*` NULL. |
|
|
||||||
| Layer 1 | `resolve_activity_attribute_schema`, Merge, Replace: Composite erscheint als **ein** Schema-Eintrag; Lese-/Schreibpfade erweitern, nicht ersetzen. |
|
|
||||||
| CSV | Bestehende Map-Ziele auf Skalare/Registry unverändert; neue Zielnotation nur für Composites. |
|
|
||||||
| Admin | tcp/ttp-UI: gleiche Zuordnung wie heute; Zusatzfelder nur bei `data_type === composite`. |
|
|
||||||
|
|
||||||
### 1.4 Abgleich mit `functional_concept_composite_data.md` (fachliches Konzept)
|
|
||||||
|
|
||||||
Das **fachliche Konzeptpapier** (Composite Scalar/Layer-Trennung) und dieses **Umsetzungskonzept** sind **vereinbar**, wenn die Rollen klar getrennt bleiben:
|
|
||||||
|
|
||||||
| Thema | Fachliches Konzept (`functional_concept_composite_data.md`) | Dieses Umsetzungskonzept (technisch) |
|
|
||||||
|--------|-------------------------------------------------------------|--------------------------------------|
|
|
||||||
| **Speicher in der DB** | Einheitlicher Store; Composite = `jsonb` mit **kleinem Basisschema** (`v`, `kind`, `domain`, `items`, optional `basis`, `meta`) | `activity_session_metrics.value_json`; CHECK Skalar vs. Composite |
|
|
||||||
| **Technische Container** | Genau **vier** `kind`-Werte: `group_set`, `distribution_set`, `sequence_set`, `model_set` | Layer-1-Validierung **muss** diese Hülle durchsetzen; kein freies JSON ohne `kind`/`v`/`items` |
|
|
||||||
| **„Archetypen“** | **Fachliche** Ausprägungen werden in **Layer 2a** aus L1-Objekten abgeleitet | Benannte **Preset-/Validierungsprofile** im Code (z. B. Zonenverteilung HF) sind **kein** zweites Persistenz-Schema: sie legen fest, *welches* der vier `kind`-Muster, *welches* `domain`, *welche* Item-Keys/Typen erlaubt sind — inkl. CSV-Slot-Mapping |
|
|
||||||
| **Layer 1** | Validiert, minimal normalisiert, **keine** Scores/Bewertungen/KI-Texte | Validator + Merge + optional `expand_*` (**technische** Flachstellung für Consumer, z. B. `param.slot` → Skalar) |
|
|
||||||
| **Layer 2** | Diagramme, Kennzahlen, KI-Platzhalter-**Formulierung** | unverändert; konsumiert L1 (und ggf. L2a) |
|
|
||||||
|
|
||||||
**Konsequenz für die Registry:** Statt „8 freie JSON-Archetypen“ implementiert die Code-Registry **Validierungs-Presets**, die alle auf die **vier technischen `kind`-Formen** abbilden. Die Tabelle in §3 beschreibt weiterhin **fachlich benannte MVP-Anker** — technisch übersetzen sie sich in `(kind, domain, Item-Regeln, v)`.
|
|
||||||
|
|
||||||
**Konsequenz für Platzhalter:** Roh-JSON aus der DB **nicht** ungefiltert in Prompts; L2b nutzt L1/L2a-Aufbereitung (wie im fachlichen Konzept).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Begriffe
|
|
||||||
|
|
||||||
| Begriff | Bedeutung |
|
|
||||||
|---------|-----------|
|
|
||||||
| **Archetyp** | Im **Repo versionierte** Strukturvorlage (erlaubte Slots, Typen, Pflichtfelder, Validator, Version). **7–8** Stück geplant; Erweiterung nur per Code-Release. |
|
|
||||||
| **Slot** | Benanntes Teilfeld innerhalb des Composite-Dokuments, z. B. `z1_sec`, `z2_sec`, `avg_cadence`. |
|
|
||||||
| **Parameter-Instanz** | Eine Zeile in `training_parameters` mit `data_type = composite` und Metadaten, **welcher** Archetyp gilt (siehe §5). |
|
|
||||||
| **Dokument** | Ein JSON-Objekt, das alle Slots abbildet; gespeichert in `activity_session_metrics.value_json`. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Archetypen-Katalog (Planungsstand) — fachliche Namen → technische `kind`-Presets
|
|
||||||
|
|
||||||
Die **konkrete** Slot-Liste und Validierung wird im Code als **Registry** geführt (z. B. `backend/data_layer/activity_composite_archetypes.py`). Jedes Preset **mappt** auf genau eines von **`group_set` | `distribution_set` | `sequence_set` | `model_set`** und erfüllt das **Basisschema** aus `functional_concept_composite_data.md` §7.
|
|
||||||
|
|
||||||
Inhaltlich orientiert an `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` §2.4.
|
|
||||||
|
|
||||||
**Beispielhafte fachliche MVP-Anker** (8 Kandidaten; im Code als Preset-Key + `kind`/`domain` abbilden):
|
|
||||||
|
|
||||||
| `archetype_key` (stabil) | Kurzbeschreibung | Typische Slots (Beispiel) |
|
|
||||||
|--------------------------|------------------|---------------------------|
|
|
||||||
| `hr_zone_distribution` | Zeit-/Anteil je HF-Zone | `z1_sec`…`z5_sec` oder `zones[]` |
|
|
||||||
| `power_zone_distribution` | Leistungszonen | analog |
|
|
||||||
| `pace_band_profile` | Pace-Bänder / Histogramm | bucket-Struktur |
|
|
||||||
| `interval_block_summary` | Intervallblöcke aggregiert | `blocks[]` mit Dauer, Ziel, Ist |
|
|
||||||
| `event_marker_sequence` | Ereignisse mit Zeitstempel | `events[]` |
|
|
||||||
| `coupling_efficiency_profile` | Kopplungs-/Effizienzmetriken | sportabhängig |
|
|
||||||
| `model_parameter_profile` | Modell-/Schwellenparameter | key-value-ähnlich, validiert |
|
|
||||||
| `readiness_recovery_snapshot` | optional: kurzes Multi-Signal-Bundle | nur wenn fachlich gewünscht |
|
|
||||||
|
|
||||||
**Regel:** Jeder Archetyp hat `version` (Integer). Validator lehnt Dokumente mit falscher/fehlender Version ab oder migriert definiert (nur wenn spezifiziert).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Datenmodell-Erweiterungen
|
|
||||||
|
|
||||||
### 4.1 `training_parameters`
|
|
||||||
|
|
||||||
**Migration (additiv):**
|
|
||||||
|
|
||||||
1. `CHECK (data_type IN (...))` erweitern um **`composite`**.
|
|
||||||
2. Optional eigene Spalte **`composite_archetype_key` `VARCHAR(64)`** (NOT NULL wenn `data_type = composite`, sonst NULL) — **oder** ausschließlich in `validation_rules` speichern (siehe unten).
|
|
||||||
**Empfehlung:** Spalte `composite_archetype_key` + `composite_archetype_version INT` für einfache Admin-Queries und klare Semantik; `validation_rules` für archetyp-spezifische Feinheiten (z. B. erlaubte Zonenanzahl).
|
|
||||||
|
|
||||||
**Konsistenz-Constraint (DB oder App):**
|
|
||||||
|
|
||||||
- Wenn `data_type = composite`: `composite_archetype_key` gesetzt, `source_field` typischerweise **NULL** (kein `activity_log`-Skalar-Shadowing).
|
|
||||||
- `unit` am Parameter: optional für „Anzeige-Einheit“ des Gesamtwerts oder leer; Slots haben Einheiten im Archetyp oder in Slot-Metadaten.
|
|
||||||
|
|
||||||
### 4.2 `activity_session_metrics`
|
|
||||||
|
|
||||||
**Migration (additiv):**
|
|
||||||
|
|
||||||
```text
|
|
||||||
value_json JSONB NULL
|
|
||||||
```
|
|
||||||
|
|
||||||
**CHECK-Constraint ersetzen/erweitern** (Konzept):
|
|
||||||
|
|
||||||
- **Modus Skalar:** genau eine der Spalten `value_num`, `value_int`, `value_text`, `value_bool` ist NOT NULL; `value_json` IS NULL.
|
|
||||||
- **Modus Composite:** `value_json` IS NOT NULL; alle vier Skalar-Spalten IS NULL.
|
|
||||||
|
|
||||||
Damit bleibt die bestehende Semantik „eine Zeile = ein Parameter“ erhalten.
|
|
||||||
|
|
||||||
**Kommentar:** Tabelle trägt weiterhin „EAV“; Composites sind **keine** zusätzlichen Zeilen pro Slot.
|
|
||||||
|
|
||||||
### 4.3 Profil-Zuordnung (tcp / ttp)
|
|
||||||
|
|
||||||
**Keine** Tabellenänderung: `training_category_parameter` und `training_type_parameter` verweisen weiter nur auf `training_parameter_id`. Composite-Parameter verhalten sich wie Skalare in Bezug auf **Zuordnung**, **sort_order**, **required**, **ui_group**.
|
|
||||||
|
|
||||||
**`required`:** bedeutet „Dokument muss nach Validator vollständig sein“, nicht „jede CSV-Spalte muss in jeder Zeile vorkommen“.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Metadaten pro Composite-Parameter
|
|
||||||
|
|
||||||
Minimal in der DB (Beispiel):
|
|
||||||
|
|
||||||
| Feld | Zweck |
|
|
||||||
|------|--------|
|
|
||||||
| `data_type` | `composite` |
|
|
||||||
| `composite_archetype_key` | Verweis auf Code-Registry |
|
|
||||||
| `composite_archetype_version` | Schema-Version |
|
|
||||||
| `validation_rules` | optional: Overrides (z. B. `max_zones`, sport-spezifisch) — nur was der Validator explizit auswertet |
|
|
||||||
|
|
||||||
**Admin-API:** bestehende Endpoints erweitern (Payload-Validierung): bei `composite` müssen Archetyp + Version gesetzt sein und in der **Registry** existieren.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Layer 1 – Kontrakt (`activity_session_metrics.py` + Helfer)
|
|
||||||
|
|
||||||
### 6.1 Schema-Auflösung
|
|
||||||
|
|
||||||
`resolve_activity_attribute_schema` liefert pro Composite **einen** Eintrag wie bei Skalaren, mit:
|
|
||||||
|
|
||||||
- `data_type: "composite"`
|
|
||||||
- `composite_archetype_key`, `composite_archetype_version` (aus DB oder Join)
|
|
||||||
- ggf. `composite_slot_catalog`: **nur wenn** für Admin/UI gewünscht — alternativ separater Endpoint `GET .../composite-archetypes` (read-only) aus Registry, um Bundle-Größe klein zu halten.
|
|
||||||
|
|
||||||
### 6.2 Lesen / Merge
|
|
||||||
|
|
||||||
- `fetch_activity_session_metrics`: SELECT inkl. `value_json`.
|
|
||||||
- `merge_column_backed_and_eav_metrics`: Composites **nur** aus EAV (`value_json`), kein `activity_log`-Shadowing (außer später explizit im Kanon — Standard: nein).
|
|
||||||
- Ausgabe in `metrics`-Liste: ein Eintrag pro Parameter mit z. B.
|
|
||||||
`value: { "_composite": true, "document": { ... } }` **oder** kanonisch getrennt: `value_document` + `value` null — **festlegen beim Implementieren** und in API-Doku halten; Empfehlung: **`value` = deserialisiertes Objekt (dict)** für Composites, damit Frontend dieselbe Struktur wie Speicher hat.
|
|
||||||
|
|
||||||
### 6.3 „Einzelwerte für Layer 1 / Issue 53“
|
|
||||||
|
|
||||||
Neue **pure** Funktion (kein SQL im Router), z. B.:
|
|
||||||
|
|
||||||
```text
|
|
||||||
expand_composite_metrics_for_session(
|
|
||||||
schema: list[dict],
|
|
||||||
metrics: list[dict],
|
|
||||||
) -> dict[str, Any]
|
|
||||||
```
|
|
||||||
|
|
||||||
- Input: effektives Schema + gemergte Metriken.
|
|
||||||
- Output: flaches Dict **`slot_path → typisierter Wert`**, z. B.
|
|
||||||
`hr_zones.z1_sec → 1200`, oder namespaced Keys `training_param_key.slot_key` zur Kollisionssicherheit.
|
|
||||||
- Nutzung: `activity_metrics`, Chart-Builder, später Platzhalter-Registry (`data_layer_function`), **ohne** JSON-Parsing in Layer 2.
|
|
||||||
|
|
||||||
**Wichtig:** Skalare Parameter erscheinen im expandierten Dict mit ihrem `parameter_key` wie bisher (kein Breaking Change für Consumer, die nur Skalare erwarten).
|
|
||||||
|
|
||||||
### 6.4 Validierung / Schreiben
|
|
||||||
|
|
||||||
- **`replace_activity_session_metrics`:** Payload-Item für Composite: `value` ist **Objekt** (dict) oder JSON-String — Server normalisiert zu dict, validiert mit Archetyp-Validator, speichert als `value_json`.
|
|
||||||
- **`upsert_session_metrics_from_csv_mapped`:** siehe §7 (Zusammenbau aus Partial-Updates pro Zeile).
|
|
||||||
|
|
||||||
**Pflicht:** Keine Teil-Updates in DB, die ein halbes Dokument hinterlassen, ohne Validierung — außer explizit als „Draft“-Modus spezifiziert (nicht Teil dieses Konzepts).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. CSV / Universal Import
|
|
||||||
|
|
||||||
### 7.1 Map-Ziel-Notation
|
|
||||||
|
|
||||||
Stabiles Muster (Vorschlag, im Import-Modul zentral parsen):
|
|
||||||
|
|
||||||
```text
|
|
||||||
"<parameter_key>.<slot_key>"
|
|
||||||
```
|
|
||||||
|
|
||||||
Beispiel: `my_hr_zones.z1_sec` → nach Import-Zusammenfügung in den Parameter `my_hr_zones` unter Slot `z1_sec`.
|
|
||||||
|
|
||||||
**Alternative:** explizites Präfix `composite:` in der Vorlage — nur nötig, wenn Kollisionen mit normalen Keys befürchtet werden; sonst Punkt-Notation reicht.
|
|
||||||
|
|
||||||
### 7.2 Executor-Flow (Konzept)
|
|
||||||
|
|
||||||
1. `build_row_after_mapping` liefert flache Keys inkl. `param.slot`.
|
|
||||||
2. Nach Schreiben von `activity_log` / Skalar-EAV: **Composite-Accumulator** pro `activity_log_id` und `parameter_key`:
|
|
||||||
- Sammelt alle Slot-Werte aus der Zeile.
|
|
||||||
3. Vor Commit der Zeile (oder am Ende der Datei — **pro Zeile empfohlen**, damit SAVEPOINT pro Row funktioniert):
|
|
||||||
- Dokument aus Slots bauen → Validator → Upsert `activity_session_metrics` mit `value_json`.
|
|
||||||
|
|
||||||
**Teilbefüllung:** Validator entscheidet (Archetyp: optional vs. required Slots). CSV darf nur Teilmengen liefern, wenn Archetyp erlaubt.
|
|
||||||
|
|
||||||
### 7.3 Typkonvertierung
|
|
||||||
|
|
||||||
Pro **Slot** im Archetyp: definierter skalarer Typ (`float`, `int`, …). Converter wie bei Skalaren (Executor / zentrale Converter), **keine** Parallel-Logik in Routern.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Admin-UI / Mapping-UX
|
|
||||||
|
|
||||||
### 8.1 Parameter anlegen
|
|
||||||
|
|
||||||
- Auswahl **Datentyp „Composite“** → Dropdown **Archetyp** (aus Registry-API), Version readonly oder wählbar gemäß Policy.
|
|
||||||
- Rest wie Skalar: Name, Kategorie (`training_parameters.category`), Aktiv-Flag.
|
|
||||||
|
|
||||||
### 8.2 Profil zuordnen
|
|
||||||
|
|
||||||
Unverändert: Kategorie-/Typ-Matrix wie heute.
|
|
||||||
|
|
||||||
### 8.3 Universal-CSV-Vorlage
|
|
||||||
|
|
||||||
- Mapping-Ziele: neben bisherigen Keys **Slot-Ziele** `parameter_key.slot_key`.
|
|
||||||
- UI-Gruppierung: optisch **Composite-Block** (wie in `ACTIVITY_PRODUCTION_ARCHITECTURE` §2.5 angedeutet), um Verwechslung mit Spine-Spalten zu vermeiden.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. API-Oberflächen (Erweiterungen)
|
|
||||||
|
|
||||||
| Bereich | Änderung |
|
|
||||||
|---------|-----------|
|
|
||||||
| `GET /api/activity/{id}` | `metrics` enthält Composite-Werte als Objekt; `schema` kennzeichnet `data_type: composite`. |
|
|
||||||
| `PUT /api/activity/{id}/metrics` | Eintrag `{ parameter_key, value: { ... } }` für Composites. |
|
|
||||||
| Admin `training-parameters` | Create/Update mit Composite-Feldern. |
|
|
||||||
| Optional | `GET /api/admin/composite-archetypes` | Registry export für UI (Keys, Slot-Liste, Version). |
|
|
||||||
|
|
||||||
**Rückwärtskompatibilität:** Clients, die nur Skalare senden, unverändert.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Frontend (Kurz)
|
|
||||||
|
|
||||||
- `ActivityPage` / Session-Metrik-Editor: für `data_type === composite` **strukturierte Teilfelder** aus Slot-Katalog rendern (oder JSON-Editor nur als Entwickler-Fallback — Produkt: strukturierte Felder).
|
|
||||||
- Sortierung/Gruppierung: bestehende `param_category` / `ui_group` / `sort_order` gelten unverändert.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Tests (pytest)
|
|
||||||
|
|
||||||
| Test | Beschreibung |
|
|
||||||
|------|----------------|
|
|
||||||
| Archetyp-Validator | gültige / ungültige Dokumente je Version |
|
|
||||||
| DB-Constraint | Skalar vs. Composite Ausschluss |
|
|
||||||
| `expand_composite_metrics_for_session` | flache Keys, Kollisionen |
|
|
||||||
| CSV-Zusammenbau | mehrere Spalten → ein `value_json` |
|
|
||||||
| Regression | bestehende `test_activity_session_metrics.py` unverändert grün halten |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Rollout-Phasen (operativ)
|
|
||||||
|
|
||||||
Stimmt mit `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` überein:
|
|
||||||
|
|
||||||
1. **Phase D – MVP:** ein Preset (z. B. HF-Zonen → `distribution_set`, `domain: heart_rate`), Migration `value_json` + `composite` data_type, Validator gegen Basisschema §7, Import 3–5 Spalten → `items`, GET/PUT, minimale Admin-Anbindung.
|
|
||||||
2. **Phase E:** weitere Presets / `kind`-Varianten, Mapping-UX, `expand_*` für ausgewählte Layer-1-Consumer.
|
|
||||||
3. **Phase F:** Observability, Performance, Doku, Gitea-Issues schließen.
|
|
||||||
|
|
||||||
### 12.1 Empfohlene Reihenfolge: Skalar-Pipeline vs. Composite-Speicherung
|
|
||||||
|
|
||||||
**Frage:** Zuerst Skalar-EAV vollständig bis Platzhalter/Orchestrator abschließen, oder zuerst Composite-Speicherung?
|
|
||||||
|
|
||||||
| Option | Vorteil | Risiko |
|
|
||||||
|--------|---------|--------|
|
|
||||||
| **A: Nur Skalar zuerst** (Kanon, L1-Härtung, Platzhalter aus EAV/L1) | Eine klare, end-to-end **Referenzpipeline**; weniger gleichzeitige Variablen | Composite-Datenstrome verzögern sich |
|
|
||||||
| **B: Composite-Speicher zuerst** | JSON landet früh in der DB | Platzhalter/Charts nutzen noch **alte** Pfade → **zwei Wahrheiten** (Detail-API vs. KI) bis L1 vereinheitlicht ist |
|
|
||||||
| **C (Empfehlung): Skalar L1 + Platzhalter-Orchestrierung *vor* Composite-MVP**, oder **eng parallel** mit gemeinsamem L1-Einstieg | `get_activity_session_logical_unit` / `activity_metrics` werden **kanonisch**; Platzhalter lesen **dieselbe** Schicht; Composite wird **additiv** (`value_json` + Validator + später `expand_*`) | Erfordert kurze Planungsdisziplin: Composite-MVP **ohne** sofort alle KI-Platzhalter |
|
|
||||||
|
|
||||||
**Konkrete Empfehlung**
|
|
||||||
|
|
||||||
1. **`ACTIVITY_PRODUCTION` Phase A–B** nicht überspringen: Kanon „eine Semantik / eine Quelle“ + alle relevanten Consumer über **Layer 1** (mind. Session-Detail, Listen-Anreicherung, erste Platzhalter-Pfade für **Skalare**).
|
|
||||||
2. **Dann Phase D (Composite-MVP):** Migration + Speichern/Lesen mit **Basisschema** (`kind`/`items`/…); L1 liefert dasselbe API-Objekt wie Skalare, nur `value` als strukturiertes Dokument.
|
|
||||||
3. **Platzhalter für Composite:** erst **nach** L1 liefert stabil `value_json` **und** optional `expand_composite_metrics_*` — ein Orchestrator-Endpoint bzw. Resolver-Aufruf, der **eine** L1-Funktion nutzt, vermeidet doppelte Logik für Skalar vs. Composite.
|
|
||||||
|
|
||||||
**Kurz:** Composite **persistieren** kann kurz nach stabiler **Skalar-Lese-/Merge-API** folgen; **KI/Platzhalter für Composite** sinnvoll **gemeinsam** mit der erweiterten L1-Ausgabe bauen, nicht gegen eine noch nicht vereinheitlichte Skalar-Pipeline.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Checkliste für den nächsten Agenten
|
|
||||||
|
|
||||||
- [ ] Migration: `value_json`, erweiterte CHECKs, `training_parameters.data_type` + ggf. `composite_archetype_*` Spalten.
|
|
||||||
- [ ] Registry-Modul: Archetypen + Versionen + Slot-Metadaten + Validator-Einstieg.
|
|
||||||
- [ ] `activity_session_metrics.py`: Fetch/Merge/Replace/Upsert-Integration; keine Regression für Skalare.
|
|
||||||
- [ ] Optional: `expand_composite_metrics_for_session` + erste Nutzung in einem Layer-1-Consumer (Tests).
|
|
||||||
- [ ] CSV: Parser für `parameter_key.slot_key`, Row-Accumulator, Fehler melden wie bestehender Import.
|
|
||||||
- [ ] Admin-API + UI: Composite anlegen, tcp/ttp unverändert nutzbar.
|
|
||||||
- [ ] Doku: dieses Dokument mit **festgelegter** JSON-Beispielstruktur pro MVP-Archetyp ergänzen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Referenzen
|
|
||||||
|
|
||||||
- `functional_concept_composite_data.md` – **fachliches** Schichtenmodell, vier technische `kind`-Container, Basisschema JSON
|
|
||||||
- `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` – Zielbild, Phasen A–F
|
|
||||||
- `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` – Ist-Layer-1, APIs
|
|
||||||
- `UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` – Executor, Vorlagen
|
|
||||||
- Migration `054_activity_session_metrics_eav.sql` – Ist-Constraint Skalar
|
|
||||||
- Migration `013_training_parameters.sql` – Ist-`data_type`-Enum
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Version:** 1.1 · Abgleich mit fachlichem Konzept (§1.4, §3, §12.1); MVP auf `distribution_set` o. ä. konkretisieren.
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
# Aktivität: Layer-2a-Platzhalter — Audit Schritt 1 (Issue #53)
|
|
||||||
|
|
||||||
**Stand:** 2026-04-16
|
|
||||||
**Bezug:** [Issue #53 — Multi-Layer Architecture](../../../docs/issues/issue-53-phase-0c-multi-layer-architecture.md): Layer 1 = strukturierte Daten, Layer 2a = KI-Formatierung (keine parallele Domänen-Logik im Resolver).
|
|
||||||
|
|
||||||
**Ziel dieses Dokuments:** Jeder Aktivitäts-Platzhalter hat genau eine **Layer‑1‑Quelle** (`data_layer/activity_metrics.py`); `placeholder_resolver.py` formatiert oder serialisiert nur noch.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Ergebnisübersicht
|
|
||||||
|
|
||||||
| Kategorie | Anzahl | Resolver-SQL für Aktivität? |
|
|
||||||
|-----------|--------|------------------------------|
|
|
||||||
| Gebündelt in `PLACEHOLDER_MAP` (Training/Aktivität) | 20 | **Nein** |
|
|
||||||
| Abweichungen / offene Punkte | 0 | — |
|
|
||||||
|
|
||||||
**Hinweis:** `{{rest_days_count}}` steht in der Karte unter „Schlaf & Erholung“ und nutzt `recovery_metrics.get_rest_days_data` — nicht in dieser Tabelle.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Platzhalter → Layer 1 → Layer 2a
|
|
||||||
|
|
||||||
| Key | Layer 1 (`activity_metrics`) | Layer 2a (`placeholder_resolver`) | Bemerkung |
|
|
||||||
|-----|------------------------------|-------------------------------------|-----------|
|
|
||||||
| `activity_summary` | `get_activity_summary_data` | `get_activity_summary` | String-Zusammenfassung |
|
|
||||||
| `activity_detail` | `get_activity_detail_data` (+ `enrich_sessions_with_metrics`) | `get_activity_detail` | Dynamische `session_metrics[]` pro Zeile (Profil/EAV) |
|
|
||||||
| `trainingstyp_verteilung` | `get_training_type_distribution_data` | `get_trainingstyp_verteilung` | Ausgabe: Top-3-Text (kein JSON); Registry 2026-04 an Ist angeglichen |
|
|
||||||
| `training_minutes_week` | `calculate_training_minutes_week` | `_safe_int` | |
|
|
||||||
| `training_frequency_7d` | `calculate_training_frequency_7d` | `_safe_int` | |
|
|
||||||
| `quality_sessions_pct` | `calculate_quality_sessions_pct` | `_safe_int` | |
|
|
||||||
| `proxy_internal_load_7d` | `calculate_proxy_internal_load_7d` | `_safe_int` | |
|
|
||||||
| `monotony_score` | `calculate_monotony_score` | `_safe_float` | |
|
|
||||||
| `strain_score` | `calculate_strain_score` | `_safe_int` | |
|
|
||||||
| `rest_day_compliance` | `calculate_rest_day_compliance` | `_safe_int` | |
|
|
||||||
| `ability_balance_strength` | `calculate_ability_balance_strength` | `_safe_int` | abilities in `activity_log` |
|
|
||||||
| `ability_balance_endurance` | `calculate_ability_balance_endurance` | `_safe_int` | |
|
|
||||||
| `ability_balance_mental` | `calculate_ability_balance_mental` | `_safe_int` | |
|
|
||||||
| `ability_balance_coordination` | `calculate_ability_balance_coordination` | `_safe_int` | |
|
|
||||||
| `ability_balance_mobility` | `calculate_ability_balance_mobility` | `_safe_int` | |
|
|
||||||
| `vo2max_trend_28d` | `calculate_vo2max_trend_28d` | `_safe_float` | |
|
|
||||||
| `activity_score` | `calculate_activity_score` | `_safe_int` | |
|
|
||||||
| `training_frequency_by_type_md` | `get_training_frequency_by_type_data` | `get_training_frequency_by_type_md` | Markdown-Tabelle |
|
|
||||||
| `training_inter_session_gap_md` | `get_training_inter_session_gap_data` | `get_training_inter_session_gap_md` | Markdown-Text |
|
|
||||||
| `training_sessions_recent_json` | `get_training_sessions_recent_weeks_data` (+ `enrich_sessions_with_metrics`) | `_safe_json('training_sessions_recent_json')` | JSON inkl. `session_metrics[]` pro Session |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Schichten-Disziplin (Checkliste)
|
|
||||||
|
|
||||||
- [x] Kein `SELECT` auf `activity_log` / `activity_session_metrics` in den **Layer‑2a**-Funktionen oben — nur Aufrufe in Layer 1 bzw. `_safe_*`-Wrapper.
|
|
||||||
- [x] `get_activity_detail` / `get_training_sessions_recent_json` liefern EAV nur über **bereits gemergte** `session_metrics` (Merge-Kanon: `activity_log` vor EAV).
|
|
||||||
- [x] Registry-Metadaten: `data_layer_module` / `data_layer_function` pro Key in `placeholder_registrations/activity_metrics.py` und `activity_session_insights.py`.
|
|
||||||
- [x] Korrektur Registry: `activity_summary.resolver_function` = `get_activity_summary` (war veraltet: `_format_activity_summary`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Nächste Schritte (Roadmap)
|
|
||||||
|
|
||||||
2. ~~**Registry-Texte:** `semantic_contract` / `known_limitations` für dynamische `session_metrics` (tcp/ttp) und Merge-Kanon — **erledigt** (`activity_detail`, `training_sessions_recent_json`); dazu **`trainingstyp_verteilung`**-Metadaten von veraltetem „JSON/Resolver-SQL“ auf Ist (**Layer 1 + Top-3-Text**) korrigiert.~~
|
|
||||||
3. **History / Layer 2b:** EAV-Zeitreihen nicht über Platzhalter, sondern dedizierte Layer‑1-/Chart-Pfade.
|
|
||||||
4. **Optional:** Gitea-Issue „Activity Layer 2a“ bei Änderungen an `activity_metrics` pflegen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Referenzen
|
|
||||||
|
|
||||||
- `backend/placeholder_resolver.py` — `PLACEHOLDER_MAP` (Training/Aktivität)
|
|
||||||
- `backend/placeholder_registrations/activity_metrics.py`
|
|
||||||
- `backend/placeholder_registrations/activity_session_insights.py`
|
|
||||||
- `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` §2.1a (Navigation Read vs. Berechnen)
|
|
||||||
|
|
@ -1,215 +0,0 @@
|
||||||
# Aktivität: Zielarchitektur & Phasenplan (Produktionsreife)
|
|
||||||
|
|
||||||
**Stand:** 2026-04-16
|
|
||||||
**Status:** Normative Zielrichtung für `activity_log`, EAV, Composites, Import, Layer 1/2.
|
|
||||||
**Ergänzt:** `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` (Ist-Modell, APIs, Tests).
|
|
||||||
**Phase A:** abgeschlossen — Kanon-Tabelle [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md).
|
|
||||||
**Phase B:** in Arbeit — Consumer-Audit und Lesepfad-Härtung (siehe §4 Phase B).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Leitprinzipien
|
|
||||||
|
|
||||||
| Prinzip | Bedeutung |
|
|
||||||
|---------|-----------|
|
|
||||||
| **Layer 1 = Single Source of Truth** | Alle Auswertungen (Charts, Scores, strukturierte Platzhalter) lesen **nur** über `data_layer` (kanonische Funktionen). Keine parallele SQL-Logik in Routern oder im Placeholder-Resolver für Aktivität. |
|
|
||||||
| **Eine semantische Größe, eine kanonische Quelle** | Kein Dauer-Sync derselben Bedeutung in `activity_log`-Spalte **und** EAV. Übergang: dokumentierte Abschaltung, nicht implizites Driften. |
|
|
||||||
| **Spine vs. Parameter** | `activity_log` trägt Identität, Zeit, Typ, Notizen, Audit + **heiße** universelle Skalare (siehe §2.2). Alles Typ-/Admin-Dynamische über EAV. |
|
|
||||||
| **Composites = Archetyp im Code, Konfiguration in der DB** | Struktur (7+2 Archetypen) und Validierung **versioniert im Repo**; Admin **wählt** Archetyp, **benennt** Slots, **bindet** Sportarten, **mappt** CSV → `(parameter_id, slot_key)`. Kein freies JSON-Schema im Admin. |
|
|
||||||
| **Import explizit** | Jede CSV-Spalte hat ein klares Ziel: Spine-Spalte, skalarer Parameter oder **Slot** eines Composite-Parameters. Typkonvertierung zentral (Executor / Converter), nicht verteilt. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Zielarchitektur (Gesamtbild)
|
|
||||||
|
|
||||||
### 2.1 Schichtenmodell
|
|
||||||
|
|
||||||
```
|
|
||||||
[CSV / UI / API Write]
|
|
||||||
↓
|
|
||||||
Orchestrator & Router (Auth, Transaktionen, Feature-Checks)
|
|
||||||
↓
|
|
||||||
Persistenz: activity_log (Spine + heiße Skalare) + activity_session_metrics (EAV)
|
|
||||||
↓
|
|
||||||
Layer 1: data_layer (activity_session_metrics.py, activity_metrics.py, …)
|
|
||||||
↓
|
|
||||||
Layer 2a/2b: Platzhalter-Resolver (Formatierung), Chart-Endpoints (Chart.js-Shapes)
|
|
||||||
↓
|
|
||||||
KI / UI / Export
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Orchestrator:** Schreibpfad, Konsistenz nach Write (kein zweites „Lesen der Wahrheit“ neben Layer 1; optional nur Post-Write-Hooks).
|
|
||||||
- **Resolver:** für Aktivität **kein** direkter DB-Zugriff; nur Aufruf von Layer 1.
|
|
||||||
|
|
||||||
### 2.1a Navigationsregel: wo nachsehen (ohne Datei-Zwang)
|
|
||||||
|
|
||||||
Die **physische** Aufteilung ist dreigeteilt: **`activity_log`** (Spine + heiße Spalten), **EAV-Skalare** (`activity_session_metrics` + numerische/textuelle `value_*`), **EAV-Composites** (ein Parameter, Nutzlast z. B. JSON/JSONB im EAV-Datensatz). **Fachlich** soll nach außen **eine homogene Session-Sicht** entstehen — Consumer sollen nicht selbst entscheiden, aus welcher Tabelle/Welche Form ein Wert kommt.
|
|
||||||
|
|
||||||
| Thema | Wo nachsehen (Ist; Ziel: Schnittstelle stabil, Datei optional splittbar) |
|
|
||||||
|--------|--------------------------------------------------------------------------|
|
|
||||||
| **Homogene Session lesen** (Merge Spalte + EAV-Skalare + später Composite-Payload) | `data_layer/activity_session_metrics.py` — u. a. `get_activity_session_logical_unit`, `enrich_sessions_with_metrics`, `merge_column_backed_and_eav_metrics` |
|
|
||||||
| **Schreiben / Import / API-Persistenz** | `data_layer/activity_persistence_orchestrator.py` (+ Router) |
|
|
||||||
| **Berechnungen, Aggregationen, Scores** über viele Sessions oder Zeitfenster | `data_layer/activity_metrics.py` — arbeitet auf der **vereinheitlichten** Session-Datenlage (über die Read-Funktionen oben), nicht durch paralleles Mergen der drei Quellen im Caller |
|
|
||||||
|
|
||||||
**Hinweis:** Orchestrator und Read-Merge **müssen nicht** in derselben Datei stehen. Entscheidend ist, dass es **genau eine dokumentierte Read-Fassade** für „Session inkl. aller effektiven Metriken“ gibt und Layer‑1‑Berechnungen **nur** diese Fassade (oder deren Ergebnisstrukturen) nutzen. Eine spätere Umbenennung oder Auslagerung in z. B. `activity_read_gateway.py` ändert die Rolle nicht — nur der **eine Einstieg** muss in dieser Doku und im Code auffindbar bleiben.
|
|
||||||
|
|
||||||
### 2.2 `activity_log` (Spine + heiße Skalare)
|
|
||||||
|
|
||||||
**Maschinenlesbarer Kanon:** `backend/data_layer/activity_data_canon.py` (`ACTIVITY_MODULE_REGISTRY_FIELD_KEYS`, `ACTIVITY_EAV_PRIMARY_PARAMETER_KEYS`, Legacy-Lesefallback für EAV-primäre Parameter).
|
|
||||||
|
|
||||||
**Immer (fachlich minimal + listenfähig):** `id`, `profile_id`, Kalender-/Zeitfenster (`date`, `started_at`/`ended_at`, ggf. `start_time`/`end_time` bis Konsolidierung), `duration_min`, `training_type_id` (+ ggf. denormalisierte Kategorie), Legacy `activity_type`, `notes`, `source`, `created`.
|
|
||||||
|
|
||||||
**Heiße Skalare (CSV-Modul + `source_field` nach Migration 057):** u. a. `kcal_active`, `kcal_resting`, `distance_km`, `hr_avg`/`hr_max` (Parameter `avg_hr`/`max_hr`), `duration_min`, `rpe` – für Listen und Standard-Aggregate ohne EAV-Join.
|
|
||||||
|
|
||||||
**EAV-primär (erweiterte Metriken):** z. B. Kadenz, Pace, Leistung, Höhe, Umgebung — `training_parameters.source_field` = NULL; Import schreibt EAV; bei leerem EAV optional Lesefallback auf bestehende `activity_log`-Spalte (Migration 057 + Merge-Logik).
|
|
||||||
|
|
||||||
**Session-Qualität / Auswertungsblob:** z. B. `evaluation`, `quality_label`, `overall_score` – **kein** EAV-Parameter-Raster; semantisch „Ergebnis der Einheit“.
|
|
||||||
|
|
||||||
**Nicht dauerhaft doppelt:** dieselbe Semantik nicht parallel pflegen; siehe entfallener Spalte→EAV-Schreib-Sync, Lesepfad `merge_column_backed_and_eav_metrics`.
|
|
||||||
|
|
||||||
### 2.3 EAV (`activity_session_metrics`)
|
|
||||||
|
|
||||||
- **Skalare:** ein `training_parameter`, genau eine `value_*`-Spalte (wie heute).
|
|
||||||
- **Composites:** ein `training_parameter` pro Composite-Instanz, **ein** gespeichertes Dokument pro Session (serialisiert z. B. in `value_text` als JSON **oder** künftig dedizierte JSONB-Spalte – technische Entscheidung in eigener Migration, Vertrag im Archetyp).
|
|
||||||
- **Merge-/Schema-Logik:** weiterhin zentral in `activity_session_metrics.py` (effektives Schema aus Kategorie + Typ-Overrides).
|
|
||||||
|
|
||||||
### 2.4 Composite-Metamodell (Ziel)
|
|
||||||
|
|
||||||
**Archetypen (Code, begrenzte Menge):** u. a. Band-/Zonenverteilung, Sequenz-/Übergangsprofil, Intervallblock-, Ereignis-/Aktions-, Kopplungs-/Effizienz-, Modellparameter-Profil; optional Technik-/Zyklus-, Readiness-/Recovery-Profil.
|
|
||||||
|
|
||||||
**Pro Archetyp:** feste strukturelle Regeln (erlaubte Slots, Typen, Pflicht/Optional), Validator + Version.
|
|
||||||
|
|
||||||
**In der DB (Admin):** Zuordnung „Parameter X hat Archetyp A“, Slot-Labels (DE/EN), Einheiten, Aktivierung pro Sportart/Kategorie, Sortierung.
|
|
||||||
|
|
||||||
**Import:** CSV-Spalten → `(training_parameter_id, slot_key)` mit stabilen Keys (`z1_sec`, …), nie nur „Spaltenreihenfolge“.
|
|
||||||
|
|
||||||
### 2.5 Universal CSV & Admin
|
|
||||||
|
|
||||||
- Vorlagen: Mapping inkl. **Composite-Slots** und Typkonvertierung (vollständige Matrix Ziel).
|
|
||||||
- UI: Trennung **Kern activity_log** vs. **Parameter/EAV** vs. **Composite-Blöcke** (optisch/UX), um Doppel-Tabellen-Chaos zu vermeiden.
|
|
||||||
|
|
||||||
### 2.6 Layer 2 (Platzhalter & Diagramme)
|
|
||||||
|
|
||||||
- Datenbezug **nur** Layer 1.
|
|
||||||
- Registry-Einträge: `data_layer_module` / `data_layer_function` pflegen; Composite-Auswertung ggf. über Hilfsfunktionen, die JSON → normierte Struktur für Prompts/Charts liefern.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Ist → Soll (Kurz)
|
|
||||||
|
|
||||||
| Bereich | Ist (typisch) | Soll |
|
|
||||||
|---------|----------------|------|
|
|
||||||
| Schreibpfad | Teilweise Doppelhaltung Spalte ↔ EAV, Sync-Hooks | Kanon + gezielte Abschaltung; eine Quelle pro Semantik |
|
|
||||||
| Lesepfad | Layer 1 wächst; Legacy-Spalten noch relevant | `get_activity_session_logical_unit` / `activity_metrics` als alleinige Wahrheit für Consumer |
|
|
||||||
| Composites | Noch nicht im Einklang mit EAV-Metamodell | Archetypen + Slot-Admin + ein Dokument pro Parameter/Session |
|
|
||||||
| Import | Mapping teilweise; Typkonvertierung lückenhaft | Vollständige Konvertierung + Composite-Zusammenbau |
|
|
||||||
| Resolver | Aktivität sauber über Layer 1 | Profil/Focus ggf. später ebenfalls aus Layer 1 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Vorgehensmodell (Phasen)
|
|
||||||
|
|
||||||
Phasen sind **sequentiell** wo „Abhängigkeit“ steht; Teile können parallel (z. B. UI-Polish) laufen, wenn der Kanon steht.
|
|
||||||
|
|
||||||
### Phase A – Kanon & Abschaltplan (Grundlage) ✅
|
|
||||||
|
|
||||||
**Inhalt:** Schriftliche **Kanon-Tabelle**: pro Messgröße genau eine Quelle (`activity_log` | `eav_scalar` | `eav_composite` | `session_quality`). Liste der Keys, für die **Sync/Spiegelung** endet.
|
|
||||||
|
|
||||||
**Definition of Done:** Review im Team; Referenz in diesem Dokument oder Verweis auf Gitea-Kommentar; keine Code-Änderung zwingend.
|
|
||||||
|
|
||||||
**Erledigt (2026-04-16):** [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md) — eine Semantik pro Zeile, verlinkt mit `activity_data_canon.py` und Merge-Logik.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase B – Lesepfad härten (Layer 1) 🔄
|
|
||||||
|
|
||||||
**Inhalt:** Sicherstellen, dass **alle** relevanten Consumer (mind. `activity_metrics` für Platzhalter/Charts, Activity-Detail-API) dieselbe Merge-/Fallback-Logik nutzen; Legacy-Spalten nur noch als dokumentierter Fallback bis Enddatum.
|
|
||||||
|
|
||||||
**Definition of Done:** Kurze Audit-Liste „Router/Resolver greifen nicht an Aktivität vorbei“; Tests oder manuelle Stichprobe für Detail + ein Chart + 2 Platzhalter.
|
|
||||||
|
|
||||||
**Abhängigkeit:** Phase A für „welche Spalten noch Fallback sind“.
|
|
||||||
|
|
||||||
**Audit-Stand (2026-04-16, ergänzt Export):**
|
|
||||||
|
|
||||||
| Consumer | Nutzt Layer-1-Merge (`enrich_sessions_with_metrics` / `get_activity_session_logical_unit`) | Anmerkung |
|
|
||||||
|----------|---------------------------------------------------------------------------------------------|-----------|
|
|
||||||
| `GET /api/activity/{eid}` | ✅ `get_activity_session_logical_unit` | Referenz-Detail |
|
|
||||||
| `GET /api/activity` (Liste) | ✅ seit 2026-04-16 `enrich_sessions_with_metrics` auf jeder Listen-Antwort | vorher nur Roh-Spalten |
|
|
||||||
| `activity_metrics.get_activity_detail_data` | ✅ | Platzhalter `{{activity_detail}}` |
|
|
||||||
| `activity_metrics.get_training_sessions_recent_weeks_data` | ✅ | KI-Kontext |
|
|
||||||
| `placeholder_resolver` (Aktivität) | ✅ nur `activity_metrics` | kein paralleles SQL |
|
|
||||||
| `GET /api/export/json` (`activity`) | ✅ `enrich_sessions_with_metrics` + `serialize_dates` | `session_metrics` pro Zeile |
|
|
||||||
| `GET /api/export/csv` (Training-Zeilen) | ✅ `enrich_sessions_with_metrics` | gemergte EAV in Spalte „Details“ |
|
|
||||||
| `GET /api/export/zip` (`data/activity.csv`) | ✅ `enrich_sessions_with_metrics` | Zusatzspalte `session_metrics_json` (Import ignoriert sie) |
|
|
||||||
| `get_activity_summary_data` | n. a. | rein aggregiert (`SUM`/`COUNT`), keine Session-EAV |
|
|
||||||
| `routers/charts.py` (A1–A8) | Spalten-Aggregate | bewusst: Dauer/RPE/HF aus **`activity_log`**-Kanon; kein EAV-Join nötig für definierte Charts |
|
|
||||||
| `activity_stats` (`GET /api/activity/stats`) | nur Spalten | Kacheln: `kcal`/`duration` aus Kernspalten |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase C – Schreibpfad entschlacken
|
|
||||||
|
|
||||||
**Inhalt:** Orchestrierung/CSV: kein Schreiben derselben Semantik an zwei Orten; `sync_column_backed_session_metrics` (o. ä.) **stufig abschalten** oder auf Notfall-Flag; Import schreibt gemäß Kanon.
|
|
||||||
|
|
||||||
**Definition of Done:** Deploy auf Prod mit Monitoring; Stichprobe Import + manuelle Bearbeitung; keine Regression in Listenansicht.
|
|
||||||
|
|
||||||
**Abhängigkeit:** Phase A + B (sonst Lücken beim Lesen).
|
|
||||||
|
|
||||||
**Analyse (2026-04-16, nur Ist-Review):** Es gibt **keinen aktiven** Schreibpfad mehr, der `activity_log`-Spalten für `source_field`-Parameter **dauerhaft nach EAV spiegelt**.
|
|
||||||
|
|
||||||
| Prüfpunkt | Ergebnis |
|
|
||||||
|-----------|----------|
|
|
||||||
| `sync_column_backed_session_metrics` | Nur noch **Definition** in `activity_session_metrics.py`, als veraltet markiert; **keine Aufrufer** im Repo (grep). Laufzeit-Sync: **abgestellt**. |
|
|
||||||
| `run_activity_post_write_hooks` / `…_import` | Nur **Auto-Eval** (optional); Kommentar: **kein** Spalte→EAV-Sync. |
|
|
||||||
| Universal-CSV (`executor.py`) | Kernfelder → `activity_log` (`activity_csv_registry_updates_from_mapped` + `update_activity_columns` / Insert); EAV → `upsert_session_metrics_from_csv_mapped`. Registry-Keys werden **nicht** nach EAV geschrieben; bei `source_field` wird EAV **übersprungen**, wenn die Spalte **bereits befüllt** ist — vermeidet bewusst doppelte Speicherung. |
|
|
||||||
| REST `PUT /metrics` | Kommentar in Code: **kein** `sync_column_backed` nach EAV-Ersatz. |
|
|
||||||
| Migrationen 055 / 057 | **Einmaliger** Backfill/Schwenk, kein fortlaufender Sync. |
|
|
||||||
|
|
||||||
**Lesepfad (2026-04-16):** `merge_column_backed_and_eav_metrics` bevorzugt **immer** `activity_log`, wenn ein kanonischer Spaltenwert existiert: zuerst `source_field`, dann Registry-Spalte gleichen Keys, dann Legacy-Spalten für EAV-primäre Parameter, zuletzt EAV. Doppelte physische Schreiborte sind damit in der effektiven Sicht **ohne EAV-Vorrang** behoben.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase D – Composite MVP
|
|
||||||
|
|
||||||
**Inhalt:** Ein Archetyp end-to-end (z. B. **Band-/Zonenverteilung**): Code-Validator, DB-Binding (Parameter + Slots), Admin-UI minimal, Import **5 Spalten → ein JSON-Dokument** mit festen Keys, Layer-1-Read (Roh + optional `expand_*`).
|
|
||||||
|
|
||||||
**Definition of Done:** Eine Sportart/Kategorie befüllbar; Dokumentation des JSON-Vertrags im Repo; pytest für Validator/Zusammenbau wo möglich.
|
|
||||||
|
|
||||||
**Abhängigkeit:** Phase A (Kanon „Composites nur als Dokument, nicht doppelt in Spalten“).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase E – Composite-Ausbau & Typkonvertierung Import
|
|
||||||
|
|
||||||
**Inhalt:** Weitere Archetypen nach Priorität; Universal-CSV **vollständige** Typkonvertierung für alle gemappten Ziele; Dialog-/Mapping-Konzept (Kern vs. Parameter vs. Composite).
|
|
||||||
|
|
||||||
**Definition of Done:** Matrix „Zieltyp × Converter“ gepflegt; Admin-Flow reviewt.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase F – Produktionshärtung
|
|
||||||
|
|
||||||
**Inhalt:** Performance-Indizes bei Bedarf; Observability (Import-Fehler, Validierungs-Fails); Resolver/Profil optional komplett ohne `get_db` für domänische Daten; Doku + Gitea-Issues geschlossen/aktualisiert.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Was zuerst?
|
|
||||||
|
|
||||||
**Erledigt:** Phase A — [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md).
|
|
||||||
|
|
||||||
**Aktuell:** Phase B fortsetzen (weitere Consumer prüfen: Export, Import-Vorschau, ggf. zukünftige Chart-Metriken aus EAV), dann **Phase C** (Schreibpfad), dann **Phase D** (Composite-MVP).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Referenzen
|
|
||||||
|
|
||||||
- `ACTIVITY_SCALAR_KANON_TABLE.md` – **Skalar-Kanon** (Phase A)
|
|
||||||
- `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` – Tabellen, APIs, Tests, Backfill-Hinweise
|
|
||||||
- `ACTIVITY_COMPOSITE_METRICS_IMPLEMENTATION_CONCEPT.md` – Composite-EAV (JSONB), Archetypen, Import-Slots, Layer-1-Expand, Migrations- und Testplan
|
|
||||||
- `UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` – Executor, Vorlagen, Typen
|
|
||||||
- `PLACEHOLDER_REGISTRY_FRAMEWORK.md` – Layer-2-Registrierung
|
|
||||||
- `functional/DATA_ARCHITECTURE.md` – fachliche Datenarchitektur (Querschnitt)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Version:** 1.5 · Merge: activity_log (Registry + Legacy-Spalten) vor EAV bei Lesen.
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
# Aktivität: Skalar-Kanon (eine Semantik → eine Quelle)
|
|
||||||
|
|
||||||
**Stand:** 2026-04-16
|
|
||||||
**Normativer Code:** `backend/data_layer/activity_data_canon.py`
|
|
||||||
**Kontext:** `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` (Phase A abgeschlossen)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Spine & Identität (`activity_log`, nicht EAV)
|
|
||||||
|
|
||||||
Diese Felder sind **keine** `training_parameters`-Skalare. Sie gehören zur Session-Zeile.
|
|
||||||
|
|
||||||
| Semantik | DB / API | Kanonische Quelle | Lesefallback | Sync Spalte↔EAV |
|
|
||||||
|----------|----------|-------------------|--------------|-----------------|
|
|
||||||
| Primärschlüssel | `activity_log.id` | `activity_log` | — | — |
|
|
||||||
| Profil | `profile_id` | `activity_log` | — | — |
|
|
||||||
| Kalendertag | `date` | `activity_log` | — | — |
|
|
||||||
| Start / Ende (Zeit) | `start_time`, `end_time`, `started_at`, `ended_at` | `activity_log` | — | — |
|
|
||||||
| Trainingsart (Freitext/Legacy) | `activity_type` | `activity_log` | — | — |
|
|
||||||
| Referenz Trainingstyp | `training_type_id`, `training_category`, … | `activity_log` (+ `training_types`) | — | — |
|
|
||||||
| Notiz | `notes` | `activity_log` | — | — |
|
|
||||||
| Quelle / Import | `source`, `created`, … | `activity_log` | — | — |
|
|
||||||
| Session-Auswertung | `evaluation`, `quality_label`, `overall_score`, … | `activity_log` (Blob/Ergebnis) | — | Kein EAV-Raster |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Kernfelder CSV-Modul `activity` (= „heiße“ Skalare)
|
|
||||||
|
|
||||||
Abgeleitet aus `csv_parser.module_registry.MODULE_DEFINITIONS["activity"].fields` — maschinenlesbar über `ACTIVITY_MODULE_REGISTRY_FIELD_KEYS` in `activity_data_canon.py`.
|
|
||||||
|
|
||||||
| Semantik | Key (Registry/API) | Kanonische Quelle | Lesefallback | Bemerkung |
|
|
||||||
|----------|-------------------|-------------------|--------------|-----------|
|
|
||||||
| Dauer | `duration_min` | **`activity_log`** | — | Aggregates, Listen |
|
|
||||||
| Aktive Energie | `kcal_active` | **`activity_log`** | — | |
|
|
||||||
| Ruhe-Energie | `kcal_resting` | **`activity_log`** | — | |
|
|
||||||
| Distanz | `distance_km` | **`activity_log`** | — | |
|
|
||||||
| Ø HF | `hr_avg` (Parameter oft `avg_hr` in EAV-Schema) | **`activity_log`** | EAV nur wenn `source_field` / Profil-Schema | `merge_column_backed_and_eav_metrics`: Spalte schlägt EAV |
|
|
||||||
| Max-HF | `hr_max` | **`activity_log`** | analog | |
|
|
||||||
| RPE | `rpe` | **`activity_log`** | analog | |
|
|
||||||
|
|
||||||
Schreibpfad: Universal-CSV und API sollen diese Keys auf **`activity_log`** mappen, sofern nicht ausdrücklich ein EAV-primärer Parameter (§3) gewählt ist.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. EAV-primäre Parameter (erweiterte Skalare)
|
|
||||||
|
|
||||||
`ACTIVITY_EAV_PRIMARY_PARAMETER_KEYS` in `activity_data_canon.py`. **`training_parameters.source_field`** = NULL (nach Kanon / Migration 057): kanonischer Speicher ist **`activity_session_metrics`**.
|
|
||||||
|
|
||||||
| Parameter-Key (`training_parameters.key`) | Legacy-Spalte `activity_log` | Schreib-Kanon (Ziel) |
|
|
||||||
|-------------------------------------------|------------------------------|------------------------|
|
|
||||||
| `min_hr` | `hr_min` | **EAV** |
|
|
||||||
| `pace_min_per_km` | `pace_min_per_km` | **EAV** |
|
|
||||||
| `cadence` | `cadence` | **EAV** |
|
|
||||||
| `avg_power` | `avg_power` | **EAV** |
|
|
||||||
| `elevation_gain` | `elevation_gain` | **EAV** |
|
|
||||||
| `temperature_celsius` | `temperature_celsius` | **EAV** |
|
|
||||||
| `humidity_percent` | `humidity_percent` | **EAV** |
|
|
||||||
| `avg_hr_percent` | `avg_hr_percent` | **EAV** |
|
|
||||||
| `kcal_per_km` | `kcal_per_km` | **EAV** |
|
|
||||||
|
|
||||||
**Lesen:** `merge_column_backed_and_eav_metrics` — wenn Legacy-Spalte **und** EAV einen Wert haben, **gewinnt die Spalte** (kanonische `activity_log`-Sicht). EAV nur, wenn die Spalte leer/nicht koerzierbar ist.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Profil-/Typ-dynamische Skalare (EAV, nicht in Registry-Kernliste)
|
|
||||||
|
|
||||||
| Semantik | Kanonische Quelle | Lesefallback |
|
|
||||||
|----------|-------------------|--------------|
|
|
||||||
| Admin-definierte Parameter (Attributprofil Kategorie/Typ) | **`activity_session_metrics`** + `training_parameters` | — |
|
|
||||||
| Parameter mit `source_field` → Spalte | **`activity_log`** (Spalte) | EAV ergänzend; Leseregel: Spalte bevorzugt (kein veraltetes EAV) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Composites (Zielbild, noch nicht Kanon-Zeile pro Slot)
|
|
||||||
|
|
||||||
| Semantik | Kanonische Quelle (Ziel) |
|
|
||||||
|----------|---------------------------|
|
|
||||||
| Strukturierte Composite-Dokumente (z. B. Zonen/Bänder) | **EAV** ein Dokument pro Parameter/Session (siehe `ACTIVITY_COMPOSITE_METRICS_IMPLEMENTATION_CONCEPT.md`) |
|
|
||||||
|
|
||||||
Kein dauerhaftes Spiegeln derselben Semantik in `activity_log`-Spalten.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Sync & Übergang
|
|
||||||
|
|
||||||
- **Kein** automatischer Dauer-Sync „Spalte → EAV“ für dieselbe Semantik; Lesepfad vereinheitlicht die Sicht (`merge_column_backed_and_eav_metrics`).
|
|
||||||
- Optionale **Backfill**-Migration/Skript (idempotent) nur nach fachlicher Freigabe — siehe EAV-Agent-Guide §6.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Referenzen
|
|
||||||
|
|
||||||
- `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` — Phasen A–F
|
|
||||||
- `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` — APIs, Tests
|
|
||||||
- `activity_data_canon.py` — `ACTIVITY_LOG_PATCHABLE_COLUMNS`, Legacy-Map
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
# Activity Session Metrics (EAV) – Umsetzungs- & Agent-Guide
|
|
||||||
|
|
||||||
**Stand:** 2026-04-14
|
|
||||||
**Status:** Kern-Backend (Migration 054, Layer 1, Admin- & Nutzer-API) umgesetzt; Admin-UI & CSV-Mapping folgen.
|
|
||||||
**Ziel:** Sportspezifische **Attributprofile** (Kategorie + optional Trainingstyp-Override) administrierbar; Messwerte pro Session in **EAV**; **alle Auswertungen** sollen künftig über **Layer 1** (`data_layer`) laufen.
|
|
||||||
|
|
||||||
**Zielarchitektur, Phasenplan (Produktionsreife):** [`ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md`](./ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md) – Kanon `activity_log`/EAV, Composites, Import, Layer 1/2, Reihenfolge A–F.
|
|
||||||
|
|
||||||
**Composite-Parameter (EAV, JSONB, Archetypen):** detailliertes Umsetzungskonzept für Agenten: [`ACTIVITY_COMPOSITE_METRICS_IMPLEMENTATION_CONCEPT.md`](./ACTIVITY_COMPOSITE_METRICS_IMPLEMENTATION_CONCEPT.md).
|
|
||||||
|
|
||||||
**Kanon (Code):** `backend/data_layer/activity_data_canon.py` (Repo-Root) — CSV-Modul `activity` vs. EAV-primär; Migration **057**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Produktions-Migrationen (Pflicht)
|
|
||||||
|
|
||||||
- **Nur additive Änderungen** bis zur Stabilisierung: neue Tabellen/Spalten **nullable**, kein `DROP COLUMN` / `DELETE` von Altbestand in derselben Story.
|
|
||||||
- Neue Migrationen: **`backend/migrations/054_*.sql`** (nächste freie Nummer nach 053 einhalten).
|
|
||||||
- **Prod-Checkliste vor Deploy:**
|
|
||||||
1. Backup / Snapshot der DB.
|
|
||||||
2. Migration auf **Kopie** der Prod-DB laufen lassen; Container-Start (`db_init`) verifizieren.
|
|
||||||
3. Stichprobe: `activity_log`-Zeilen unverändert; neue Tabellen leer oder nur Seed.
|
|
||||||
- **Datenhaltung:** Bestehende Spalten in `activity_log` bleiben **Quelle für Alt-Daten**; EAV (`activity_session_metrics`) ist der **kanonische Ort für konfigurierte Session-Metriken**, sobald geschrieben. Backfill Altspalten → EAV ist **separater Schritt** (siehe §6).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Datenmodell (Ist nach Migration 054)
|
|
||||||
|
|
||||||
| Tabelle | Zweck |
|
|
||||||
|---------|--------|
|
|
||||||
| `training_parameters` | Katalog messbarer Größen (`key`, `data_type`, `unit`, `validation_rules`, …) – bereits Migration 013; Admin-API ergänzt. |
|
|
||||||
| `training_category_parameter` | Welche Parameter für welche **`training_types.category`** (z. B. `cardio`) gelten: `sort_order`, `required`, `ui_group`. |
|
|
||||||
| `training_type_parameter` | Zusatzparameter oder **Overrides** pro **`training_types.id`**: `sort_order`, `required`, `ui_group` (NULL = von Kategorie erben). |
|
|
||||||
| `activity_session_metrics` | EAV: `(activity_log_id, training_parameter_id)` eindeutig; genau eine Wertspalte `value_num` / `value_int` / `value_text` / `value_bool`. |
|
|
||||||
| `activity_log` | **Neu:** `started_at`, `ended_at` (`TIMESTAMPTZ`, nullable) – für spätere Dedupe/Intervalle; **kein** Pflichtfeld in v1. |
|
|
||||||
|
|
||||||
**Merge-Logik effektives Schema** (Layer 1, eine Funktion):
|
|
||||||
|
|
||||||
1. Kategorie ermitteln: aus Zeile `training_category` oder aus `training_types.category` via `training_type_id`.
|
|
||||||
2. Basis = alle Zeilen `training_category_parameter` für diese Kategorie, Join auf `training_parameters` (aktiv).
|
|
||||||
3. Für jeden Eintrag in `training_type_parameter` zum gewählten Typ: gleiche `training_parameter_id` → Overrides anwenden; nur im Typ vorhanden → anhängen.
|
|
||||||
4. Sortierung: `sort_order` aufsteigend, dann `key`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Layer 1 – Kanonische Module
|
|
||||||
|
|
||||||
| Modul | Pfad | Aufgabe |
|
|
||||||
|-------|------|---------|
|
|
||||||
| Session-Metriken & Schema | `backend/data_layer/activity_session_metrics.py` | `resolve_activity_attribute_schema`, `fetch_activity_session_metrics`, `replace_activity_session_metrics`, `get_activity_session_logical_unit`, `enrich_sessions_with_metrics`, `merge_column_backed_and_eav_metrics`. |
|
|
||||||
|
|
||||||
**Spalten vs. EAV (Lesepfad):** `merge_column_backed_and_eav_metrics` / `get_activity_session_logical_unit` / `enrich_sessions_with_metrics` werten Parameter mit `source_field` **primär aus `activity_log`** aus; EAV ist Fallback (z. B. Legacy) oder für Parameter ohne Spalte. **Kein** automatischer Spalte→EAV-Schreib-Sync mehr in `run_activity_post_write_hooks` / Import-Hooks (vermeidet Doppelhaltung).
|
|
||||||
|
|
||||||
**Regeln für Agenten:**
|
|
||||||
|
|
||||||
- **Keine** zweite Implementierung derselben Merge- oder Validierungslogik in Routern.
|
|
||||||
- Platzhalter / Charts, die Session-Details brauchen: **nur** diese Layer-1-Helfer erweitern oder aufrufen (z. B. `activity_metrics.get_training_sessions_recent_weeks_data` nutzt `enrich_sessions_with_metrics`).
|
|
||||||
- Router: `get_db`, `get_cursor`, Auth; Business-Validierung delegieren an `activity_session_metrics`.
|
|
||||||
|
|
||||||
**KI-Kontext:** In `training_sessions_recent_json` enthält jedes Element von `session_metrics` neben `key`/`value` die Felder `name_de`, `name_en`, `description_de`, `description_en` (aus dem effektiven Schema). Für nicht selbsterklärende Keys soll im Katalog `training_parameters.description_*` gepflegt werden (Admin). Ergänzend liefert der Platzhalter `{{training_parameters_glossary_md}}` die gesamte aktive Parameter-Legende als Markdown-Tabelle (`get_training_parameters_ki_glossary_data` → `get_training_parameters_glossary_md`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. API (Ist / geplant)
|
|
||||||
|
|
||||||
### Admin (`require_admin`)
|
|
||||||
|
|
||||||
| Methode | Pfad | Beschreibung |
|
|
||||||
|---------|------|--------------|
|
|
||||||
| GET/POST | `/api/admin/training-parameters` | Katalog lesen / Parameter anlegen |
|
|
||||||
| PUT/DELETE | `/api/admin/training-parameters/{id}` | Aktualisieren / Soft-deaktivieren (`is_active`) |
|
|
||||||
| GET | `/api/admin/training-category-parameters?category=` | Zuordnungen Kategorie |
|
|
||||||
| POST | `/api/admin/training-category-parameters` | Zuordnung anlegen |
|
|
||||||
| DELETE | `/api/admin/training-category-parameters/{id}` | Zuordnung entfernen |
|
|
||||||
| GET | `/api/admin/training-type-parameters?training_type_id=` | Zuordnungen Typ |
|
|
||||||
| POST | `/api/admin/training-type-parameters` | Zuordnung anlegen |
|
|
||||||
| DELETE | `/api/admin/training-type-parameters/{id}` | Zuordnung entfernen |
|
|
||||||
|
|
||||||
Router: `backend/routers/admin_training_parameters.py`, `backend/routers/admin_activity_attribute_profiles.py`.
|
|
||||||
|
|
||||||
### Nutzer (`require_auth`)
|
|
||||||
|
|
||||||
| Methode | Pfad | Beschreibung |
|
|
||||||
|---------|------|--------------|
|
|
||||||
| GET | `/api/activity/{eid}` | Session-Kopf + `schema` + `metrics` (Layer 1) |
|
|
||||||
| PUT | `/api/activity/{eid}/metrics` | **Voller Ersatz** der EAV-Metriken für diese Session (Liste `{parameter_key, value}`) |
|
|
||||||
|
|
||||||
`ActivityEntry` unverändert für bestehende Create/Update-Routen; optionale Erweiterung um `started_at`/`ended_at` in späterem Schritt.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Agent-Checkliste (nächste Iterationen)
|
|
||||||
|
|
||||||
**Layer 2a (Platzhalter Aktivität):** Abgleich Registry ↔ Resolver ↔ Layer 1 — [`ACTIVITY_LAYER2A_PLACEHOLDER_AUDIT.md`](./ACTIVITY_LAYER2A_PLACEHOLDER_AUDIT.md) (Issue #53). **Schritt 2:** `semantic_contract` / `known_limitations` für dynamische `session_metrics` und Korrektur `trainingstyp_verteilung` in der Registry.
|
|
||||||
|
|
||||||
Siehe **Phasen A–F** in [`ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md`](./ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md). Kurz:
|
|
||||||
|
|
||||||
- [x] **Phase A:** Kanon-Tabelle (eine Quelle pro Semantik) — [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md).
|
|
||||||
- [ ] **Phase B:** Lesepfad Layer 1 härten (Consumer-Audit fortlaufend — siehe `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` §4 Phase B).
|
|
||||||
- [ ] **Phase C:** Schreibpfad: Doppelhaltung / Sync stufenweise abschalten.
|
|
||||||
- [ ] **Phase D:** Composite-MVP (ein Archetyp E2E).
|
|
||||||
- [ ] **Phase E:** Archetypen ausbauen + CSV-Typkonvertierung vollständig + Mapping-UX.
|
|
||||||
- [ ] **Phase F:** Härtung Prod (Indizes, Observability, Doku).
|
|
||||||
|
|
||||||
Legacy-Punkte:
|
|
||||||
|
|
||||||
- [x] Admin-UI: `frontend/src/pages/AdminActivityAttributeProfilesPage.jsx`, Route `/admin/activity-attribute-profiles`, Admin-Nav-Gruppe „Trainingstypen“.
|
|
||||||
- [x] `/activity` Frontend: Bearbeiten lädt `GET /api/activity/{id}`, dynamische Felder + `PUT /api/activity/{id}/metrics`.
|
|
||||||
- [ ] Universal CSV: Mapping inkl. EAV/Composite-Ziele + Executor (fortlaufend).
|
|
||||||
- [ ] Optional: Backfill / Abschluss `source_field`-Pfad nach Kanon (Phase A/C).
|
|
||||||
- [ ] Dedupe Polar/Apple: nach stabilen `started_at`/`ended_at` + Policy (eigenes Issue).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Backfill (nicht in Migration 054)
|
|
||||||
|
|
||||||
Separates Skript oder Migration **055+**, wenn fachlich freigegeben:
|
|
||||||
|
|
||||||
- Pro aktivem `training_parameter` mit gesetztem `source_field`: Wert aus `activity_log` lesen, in EAV schreiben, wenn noch keine Zeile existiert.
|
|
||||||
- Idempotent (`ON CONFLICT DO NOTHING` oder Upsert-Regel dokumentieren).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Automatische Tests (pytest, ohne DB)
|
|
||||||
|
|
||||||
Aus **`backend/`**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pytest tests/test_activity_session_metrics.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Abdeckung: reine Merge-Logik (`merge_parameter_schema_rows`), Validierung (`_validate_single_value`), `resolve_activity_attribute_schema` mit Mock-Cursor, `enrich_sessions_with_metrics` mit Mock-Cursor.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Referenzen
|
|
||||||
|
|
||||||
- Migration 013: `training_parameters`
|
|
||||||
- Migration 004/014: `training_types`, `activity_log`-Erweiterungen
|
|
||||||
- Pattern Admin-Katalog: `routers/admin_reference_value_types.py`
|
|
||||||
- Platzhalter Session-JSON: `data_layer/activity_metrics.py` → `get_training_sessions_recent_weeks_data`
|
|
||||||
- KI-Legende: `get_training_parameters_ki_glossary_data`, Platzhalter `{{training_parameters_glossary_md}}`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Version:** 1.1 · Bei Schema- oder API-Änderungen dieses Dokument und ggf. `CLAUDE.md` Kurzverweis aktualisieren.
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Dashboard-Widgets – Anleitung für Coding-Agenten
|
# Dashboard-Lab-Widgets – Anleitung für Coding-Agenten
|
||||||
|
|
||||||
Ziel: Ein neues Dashboard-Widget **end-to-end** korrekt einbinden (Backend-Katalog, Validierung, API-Layout, Frontend-Registrierung, optional Editor für `config` in **Übersicht anpassen**).
|
Ziel: Ein neues Dashboard-Widget **end-to-end** korrekt einbinden (Backend-Katalog, Validierung, API-Layout, Frontend-Registrierung, optional Lab-Editor für `config`).
|
||||||
Kontext: Geschützte Endpoints `GET/PUT /api/app/...` (siehe `backend/routers/app_dashboard.py`). Layout liegt pro Profil in `profiles.dashboard_layout` (JSON). Nutzer-Oberfläche: `frontend/src/pages/DashboardConfigurePage.jsx` (Route z. B. `/settings/dashboard-layout`).
|
Kontext: **Dashboard-Lab** unter geschützten Endpoints `GET/PUT /api/app/...` (siehe `backend/routers/app_dashboard.py`). Layout liegt pro Profil in `profiles.dashboard_layout` (JSON).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -23,7 +23,7 @@ Kontext: Geschützte Endpoints `GET/PUT /api/app/...` (siehe `backend/routers/ap
|
||||||
| Anforderung | Beschreibung |
|
| Anforderung | Beschreibung |
|
||||||
|-------------|--------------|
|
|-------------|--------------|
|
||||||
| **A1 – Zentrale Auflösung** | Backend ermittelt pro Profil (effektiver Tier + Restrictions), welche Widget-IDs **erlaubt** sind – idealerweise in **einer** Stelle (Erweiterung des Katalog-Endpoints oder dedizierter Entitlements-Teil der Response). Intern: `check_feature_access` und später ggf. Mapping Widget-ID → Feature-ID(n) / Cluster. |
|
| **A1 – Zentrale Auflösung** | Backend ermittelt pro Profil (effektiver Tier + Restrictions), welche Widget-IDs **erlaubt** sind – idealerweise in **einer** Stelle (Erweiterung des Katalog-Endpoints oder dedizierter Entitlements-Teil der Response). Intern: `check_feature_access` und später ggf. Mapping Widget-ID → Feature-ID(n) / Cluster. |
|
||||||
| **A2 – Nutzer-Konfigurator** | Im Layout-Konfigurator (**Übersicht anpassen**): Widgets **ohne Berechtigung nicht anbieten** (ausgeblendet oder gar nicht in der Liste). Alle **erlaubten** Widgets bleiben wie heute wählbar. |
|
| **A2 – Nutzer-Konfigurator** | Im Dashboard-Lab (und jedem späteren Layout-Konfigurator): Widgets **ohne Berechtigung nicht anbieten** (ausgeblendet oder gar nicht in der Liste). Alle **erlaubten** Widgets bleiben wie heute wählbar. |
|
||||||
| **A3 – Layout-Persistenz** | `PUT /api/app/dashboard-layout`: Layout darf **keine** nicht erlaubten Widgets dauerhaft speichern – entweder **ablehnen** (422) oder **beim Speichern entfernen/deaktivieren** (Policy festlegen und dokumentieren). Verhindert „gespeichert, aber nie sichtbar“-Zombies. |
|
| **A3 – Layout-Persistenz** | `PUT /api/app/dashboard-layout`: Layout darf **keine** nicht erlaubten Widgets dauerhaft speichern – entweder **ablehnen** (422) oder **beim Speichern entfernen/deaktivieren** (Policy festlegen und dokumentieren). Verhindert „gespeichert, aber nie sichtbar“-Zombies. |
|
||||||
| **A4 – API-/Datenschutz** | Sichtbarkeit im UI reicht nicht: Endpoints, die **Inhalte** für gated Widgets liefern (Charts, KI, …), müssen weiterhin wie heute **eigenständig** über Features abgesichert sein (`check_feature_access`, 403). |
|
| **A4 – API-/Datenschutz** | Sichtbarkeit im UI reicht nicht: Endpoints, die **Inhalte** für gated Widgets liefern (Charts, KI, …), müssen weiterhin wie heute **eigenständig** über Features abgesichert sein (`check_feature_access`, 403). |
|
||||||
|
|
||||||
|
|
@ -42,8 +42,8 @@ Kontext: Geschützte Endpoints `GET/PUT /api/app/...` (siehe `backend/routers/ap
|
||||||
1. **`backend/widget_catalog.py`** – `WIDGET_CATALOG`: erlaubte Widget-IDs, Reihenfolge, Titel/Beschreibung für API und Default-Layout.
|
1. **`backend/widget_catalog.py`** – `WIDGET_CATALOG`: erlaubte Widget-IDs, Reihenfolge, Titel/Beschreibung für API und Default-Layout.
|
||||||
2. **`backend/dashboard_layout_schema.py`** – `DashboardLayoutPayload`: jede Zeile hat `id`, `enabled`, optional `config`. IDs müssen in `ALLOWED_WIDGET_IDS` sein (aus dem Katalog abgeleitet).
|
2. **`backend/dashboard_layout_schema.py`** – `DashboardLayoutPayload`: jede Zeile hat `id`, `enabled`, optional `config`. IDs müssen in `ALLOWED_WIDGET_IDS` sein (aus dem Katalog abgeleitet).
|
||||||
3. **`backend/dashboard_widget_config.py`** – `validate_widget_entry_config`: **nur** Widgets in `WIDGETS_ALLOWING_CONFIG` dürfen **nicht-leere** `config` haben; Keys werden streng validiert (unbekannte Keys → Fehler).
|
3. **`backend/dashboard_widget_config.py`** – `validate_widget_entry_config`: **nur** Widgets in `WIDGETS_ALLOWING_CONFIG` dürfen **nicht-leere** `config` haben; Keys werden streng validiert (unbekannte Keys → Fehler).
|
||||||
4. **Frontend** – `ensureDashboardWidgetsRegistered()` in `frontend/src/widgetSystem/registerDashboardWidgets.js`: verbindet jede Katalog-ID mit einer React-Komponente und mappt `ctx.layoutEntry.config` auf Props.
|
4. **Frontend** – `ensurePilotLabWidgetsRegistered()` in `frontend/src/widgetSystem/registerPilotLabWidgets.js`: verbindet jede Katalog-ID mit einer React-Komponente und mappt `ctx.layoutEntry.config` auf Props.
|
||||||
5. **Layout-Editor (Produkt)** – `frontend/src/pages/DashboardConfigurePage.jsx`: Umsortieren, Ein/Aus, Speichern; **zusätzliche** UI nur nötig, wenn das Widget konfigurierbare Felder braucht.
|
5. **Dashboard-Lab-UI** – `frontend/src/pages/DashboardLabPage.jsx`: Umsortieren, Ein/Aus, Speichern; **zusätzliche** UI nur nötig, wenn das Widget konfigurierbare Felder braucht.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -52,9 +52,9 @@ Kontext: Geschützte Endpoints `GET/PUT /api/app/...` (siehe `backend/routers/ap
|
||||||
| Schritt | Datei | Aktion |
|
| Schritt | Datei | Aktion |
|
||||||
|--------|--------|--------|
|
|--------|--------|--------|
|
||||||
| A | `backend/widget_catalog.py` | Neuen Eintrag `{ "id", "title", "description" }` in `WIDGET_CATALOG` einfügen (Reihenfolge = Default-Reihenfolge im Layout). Optional `"requires_feature": "<features.id>"` für Tarif-Gating (`dashboard_widget_entitlements`). |
|
| A | `backend/widget_catalog.py` | Neuen Eintrag `{ "id", "title", "description" }` in `WIDGET_CATALOG` einfügen (Reihenfolge = Default-Reihenfolge im Layout). Optional `"requires_feature": "<features.id>"` für Tarif-Gating (`dashboard_widget_entitlements`). |
|
||||||
| B | `backend/widget_catalog.py` | Optional: ID zu `DEFAULT_LAB_WIDGET_IDS` hinzufügen, wenn es im Server-Standardlayout **aktiv** sein soll (Feld `lab_default_layout` in der Layout-API). |
|
| B | `backend/widget_catalog.py` | Optional: ID zu `DEFAULT_LAB_WIDGET_IDS` hinzufügen, wenn es im Standard-Lab **aktiv** sein soll. |
|
||||||
| C | `frontend/src/components/dashboard-widgets/MyWidget.jsx` (oder Legacy-Widget unter `dashboard-widgets-legacy/`) | React-Komponente implementieren; typischerweise `refreshTick` aus `mapProps` nutzen, um Daten neu zu laden. |
|
| C | `frontend/src/components/dashboard-widgets/MyWidget.jsx` (oder Pilot-Komponente) | React-Komponente implementieren; typischerweise `refreshTick` aus `mapProps` nutzen, um Daten neu zu laden. |
|
||||||
| D | `frontend/src/widgetSystem/registerDashboardWidgets.js` | `import` + `registerDashboardWidget({ id, Component, mapProps })` – `id` **exakt** wie im Katalog. |
|
| D | `frontend/src/widgetSystem/registerPilotLabWidgets.js` | `import` + `registerDashboardWidget({ id, Component, mapProps })` – `id` **exakt** wie im Katalog. |
|
||||||
| E | `backend/tests/test_widget_catalog.py` | Läuft implizit mit; bei Strukturänderungen Katalog-Tests beachten. |
|
| E | `backend/tests/test_widget_catalog.py` | Läuft implizit mit; bei Strukturänderungen Katalog-Tests beachten. |
|
||||||
| F | `backend/version.py` | `MODULE_VERSIONS["app_dashboard"]` MINOR erhöhen und kurz kommentieren. |
|
| F | `backend/version.py` | `MODULE_VERSIONS["app_dashboard"]` MINOR erhöhen und kurz kommentieren. |
|
||||||
| G | Build/Tests | `pytest` (z. B. `tests/test_dashboard_layout_schema.py`, `test_widget_catalog.py`); `npm run build` im `frontend`. |
|
| G | Build/Tests | `pytest` (z. B. `tests/test_dashboard_layout_schema.py`, `test_widget_catalog.py`); `npm run build` im `frontend`. |
|
||||||
|
|
@ -110,11 +110,11 @@ mapProps: (ctx) => ({
|
||||||
|
|
||||||
**Abgleich mit Chart-Zeitraum:** Für `chart_days` existiert `frontend/src/widgetSystem/bodyChartDays.js` (`BODY_CHART_DAYS_MIN/MAX`, `normalizeBodyChartDays`). Entweder in `mapProps` normalisieren (wie `body_overview`) oder rohen Wert durchreichen und in der Widget-Komponente normalisieren (wie `nutrition_detail_charts` / `TrendKcalWeightWidget`) – **beides** ist im Projekt vertreten; wichtig ist Konsistenz mit der Backend-Grenze 7–90.
|
**Abgleich mit Chart-Zeitraum:** Für `chart_days` existiert `frontend/src/widgetSystem/bodyChartDays.js` (`BODY_CHART_DAYS_MIN/MAX`, `normalizeBodyChartDays`). Entweder in `mapProps` normalisieren (wie `body_overview`) oder rohen Wert durchreichen und in der Widget-Komponente normalisieren (wie `nutrition_detail_charts` / `TrendKcalWeightWidget`) – **beides** ist im Projekt vertreten; wichtig ist Konsistenz mit der Backend-Grenze 7–90.
|
||||||
|
|
||||||
### 3.4 Layout-Editor (`DashboardConfigurePage.jsx`)
|
### 3.4 Dashboard-Lab-Editor (`DashboardLabPage.jsx`)
|
||||||
|
|
||||||
Ohne UI-Änderung bleibt `config` beim Nutzer `{}` – konfigurierbare Widgets brauchen **Editor-Controls**:
|
Ohne UI-Änderung bleibt `config` beim Nutzer `{}` – konfigurierbare Widgets brauchen **Editor-Controls**:
|
||||||
|
|
||||||
- **Einfaches Zahlfeld `chart_days`:** Eintrag in `CHART_DAYS_WIDGET_IDS` (Set oben in `DashboardConfigurePage.jsx`) + bestehendes Label/`aria-label`-Pattern für die Zeitraum-Zeile erweitern (siehe `body_overview`, `nutrition_detail_charts`).
|
- **Einfaches Zahlfeld `chart_days`:** Eintrag in `CHART_DAYS_WIDGET_IDS` (Set oben in der Datei) + bestehendes Label/`aria-label`-Pattern für die Zeitraum-Zeile erweitern (siehe `body_overview`, `nutrition_detail_charts`).
|
||||||
- **Strukturierte Config (Listen, mehrere Booleans):** Eigenes Editor-Komponenten-File nach Vorbild `KpiBoardConfigEditor.jsx` / `QuickCaptureConfigEditor.jsx` einbinden und `setLayout` + `normalizeLayoutForEditor` wie bei den bestehenden Blöcken verwenden.
|
- **Strukturierte Config (Listen, mehrere Booleans):** Eigenes Editor-Komponenten-File nach Vorbild `KpiBoardConfigEditor.jsx` / `QuickCaptureConfigEditor.jsx` einbinden und `setLayout` + `normalizeLayoutForEditor` wie bei den bestehenden Blöcken verwenden.
|
||||||
|
|
||||||
Nach Speichern ruft die Seite `api.putAppDashboardLayout(layout)` auf; das Backend validiert über `DashboardLayoutPayload` → `validate_widget_entry_config`.
|
Nach Speichern ruft die Seite `api.putAppDashboardLayout(layout)` auf; das Backend validiert über `DashboardLayoutPayload` → `validate_widget_entry_config`.
|
||||||
|
|
@ -137,7 +137,7 @@ Nach Speichern ruft die Seite `api.putAppDashboardLayout(layout)` auf; das Backe
|
||||||
## 5. API zum Prüfen
|
## 5. API zum Prüfen
|
||||||
|
|
||||||
- `GET /api/app/widgets/catalog` – Katalog inkl. `allowed` je Widget (Auth + `X-Profile-Id` wie andere App-Endpoints).
|
- `GET /api/app/widgets/catalog` – Katalog inkl. `allowed` je Widget (Auth + `X-Profile-Id` wie andere App-Endpoints).
|
||||||
- `GET /api/app/dashboard-layout` – `layout` (effektiv, bereinigt), `custom`, `product_default_layout` (Übersichts-Standard), `lab_default_layout` (Servertemplate für Editor/Reset; Feldname historisch).
|
- `GET /api/app/dashboard-layout` – `layout` (effektiv, bereinigt), `custom`, `product_default_layout` (Übersichts-Standard), `lab_default_layout` (Dashboard-Lab-Standard).
|
||||||
- `PUT /api/app/dashboard-layout` – Body `{ "version": 1, "widgets": [ ... ] }` (unerlaubte Widgets werden auf `enabled: false` gesetzt).
|
- `PUT /api/app/dashboard-layout` – Body `{ "version": 1, "widgets": [ ... ] }` (unerlaubte Widgets werden auf `enabled: false` gesetzt).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -159,5 +159,5 @@ Nach Speichern ruft die Seite `api.putAppDashboardLayout(layout)` auf; das Backe
|
||||||
| Layout-Pydantic | `backend/dashboard_layout_schema.py` |
|
| Layout-Pydantic | `backend/dashboard_layout_schema.py` |
|
||||||
| HTTP | `backend/routers/app_dashboard.py` |
|
| HTTP | `backend/routers/app_dashboard.py` |
|
||||||
| Registry + Render | `frontend/src/widgetSystem/dashboardWidgetRegistry.jsx` |
|
| Registry + Render | `frontend/src/widgetSystem/dashboardWidgetRegistry.jsx` |
|
||||||
| Dashboard-Widget-Registrierung | `frontend/src/widgetSystem/registerDashboardWidgets.js` |
|
| Pilot/Lab-Registrierung | `frontend/src/widgetSystem/registerPilotLabWidgets.js` |
|
||||||
| Layout-Editor (Nutzer) | `frontend/src/pages/DashboardConfigurePage.jsx` |
|
| Lab-UI | `frontend/src/pages/DashboardLabPage.jsx` |
|
||||||
|
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
# Berichtsprofile & PDF (technisch)
|
|
||||||
|
|
||||||
**Stand:** 2026-04-29
|
|
||||||
|
|
||||||
## Begriffe
|
|
||||||
|
|
||||||
| Begriff | Bedeutung |
|
|
||||||
|--------|-----------|
|
|
||||||
| **Layout-Snapshot** | PDF aus gerasteter DOM-Übersicht (`html2canvas` + `jspdf`), optional Widget `report_export`. |
|
|
||||||
| **Strukturierter Bericht** | Profil mit Blöcken (`section`, `chart`, `ai_insight`), PDF serverseitig via Data Layer + Matplotlib + ReportLab. |
|
|
||||||
|
|
||||||
Die beiden Wege sind bewusst getrennt, damit das Dashboard nicht die einzige „Wahrheit“ für Dokumente wird.
|
|
||||||
|
|
||||||
## Datenbank
|
|
||||||
|
|
||||||
- Tabelle `report_profiles` (Migration `060_report_profiles.sql`): `profile_id` PK → `profiles`, `payload` JSONB, `updated_at`.
|
|
||||||
|
|
||||||
Ohne Zeile gilt ein **Code-Standard** (`default_report_profile_dict` in `report_profile_schema.py`).
|
|
||||||
|
|
||||||
## API (`/api/reports`)
|
|
||||||
|
|
||||||
| Methode | Pfad | Zweck |
|
|
||||||
|--------|------|--------|
|
|
||||||
| GET | `/catalog` | Diagramm-Katalog + Blocktypen für UI |
|
|
||||||
| GET | `/profile` | `{ stored, profile }` |
|
|
||||||
| PUT | `/profile` | Vollständiges Profil-JSON (Pydantic-validiert) |
|
|
||||||
| DELETE | `/profile` | DB-Zeile löschen → wieder Standard |
|
|
||||||
| POST | `/generate-pdf` | PDF-Download; `data_export`-Kontingent + `increment_feature_usage` |
|
|
||||||
|
|
||||||
## Schema v1 (`report_profile_schema.py`)
|
|
||||||
|
|
||||||
- `version`: nur `1`
|
|
||||||
- `document_title`: optional
|
|
||||||
- `blocks`: Liste mit Union:
|
|
||||||
- `section`: `title`
|
|
||||||
- `chart`: `chart_id` ∈ `ALLOWED_CHART_IDS`, `window_days` 7–365
|
|
||||||
- `ai_insight`: optional `insight_id` (UUID, `ai_insights.id`), optional `title`
|
|
||||||
|
|
||||||
## Diagrammdaten
|
|
||||||
|
|
||||||
`report_chart_fetch.fetch_chart_payload` ruft dieselben Bausteine auf wie `/api/charts` (ohne HTTP). Erweiterung: Eintrag in `ALLOWED_CHART_IDS`, Fetcher in `_CHART_FETCHERS`, Zeile in `CHART_CATALOG_FOR_API`.
|
|
||||||
|
|
||||||
## PDF-Rendering
|
|
||||||
|
|
||||||
`report_pdf_render.build_structured_report_pdf`: ReportLab-Flowable-Kette, Diagramme als PNG aus Chart-Payload (Matplotlib, Agg-Backend).
|
|
||||||
|
|
||||||
## Frontend
|
|
||||||
|
|
||||||
- **Einstellungen:** Karte „PDF-Bericht (strukturiert)“ — Blöcke bearbeiten, speichern, Standard, PDF erzeugen.
|
|
||||||
- **Dashboard:** Widget bleibt optionaler **Schnappschuss**; Hinweis verweist auf Einstellungen.
|
|
||||||
|
|
||||||
## Nächste sinnvolle Erweiterungen
|
|
||||||
|
|
||||||
- Dashboard-Layout → Berichtsprofil **einmalig importieren** (Mapping-Tabelle Widget-ID → chart_id).
|
|
||||||
- KI: Insights-Auswahl in der UI statt manueller UUID.
|
|
||||||
- Weitere `chart_id`-Werte / multipage Feintuning (Seitenumbrüche pro Block).
|
|
||||||
|
|
@ -18,7 +18,6 @@ Dieses Dokument ist **normativ für Agenten**, die ein neues Import-Zielmodul an
|
||||||
| Admin-Systemvorlagen | `backend/routers/admin_csv_templates.py` |
|
| Admin-Systemvorlagen | `backend/routers/admin_csv_templates.py` |
|
||||||
| Nutzer-Import (Profil-Mappings) | `backend/routers/csv_import.py` |
|
| Nutzer-Import (Profil-Mappings) | `backend/routers/csv_import.py` |
|
||||||
| Vorlagen-Validierung (strukturell + Sample) | `backend/csv_parser/template_validator.py` (`validate_csv_template`) |
|
| Vorlagen-Validierung (strukturell + Sample) | `backend/csv_parser/template_validator.py` (`validate_csv_template`) |
|
||||||
| Effektives Listentrennzeichen | `backend/csv_parser/core.py` (`resolve_effective_csv_delimiter`) — Datei kann `;` (z. B. Apple DE) haben, Vorlage `,` (EN); Import/Diagnose **nicht** nur das gespeicherte Trennzeichen blind nutzen. |
|
|
||||||
|
|
||||||
**Single Source of Truth** für erlaubte Zielfelder, Typen und Duplikat-Keys ist **`module_registry.py`**. Keine parallele Feldliste in Routern duplizieren.
|
**Single Source of Truth** für erlaubte Zielfelder, Typen und Duplikat-Keys ist **`module_registry.py`**. Keine parallele Feldliste in Routern duplizieren.
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -455,15 +455,15 @@ NIEMALS gegen mitai.jinkendo.de
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Dashboard-Widgets und Feature-System
|
## 10. Dashboard-Lab-Widgets und Feature-System
|
||||||
|
|
||||||
**Kontext:** Dashboard-Widgets (`backend/widget_catalog.py`, API unter `/api/app/...`) und das **Subscription-/Feature-Modell** (`features`, `tier_limits`, `check_feature_access` in `backend/auth.py`) sind **getrennte Schichten**, müssen aber bei tariffrelevanten Widgets **verknüpft** werden.
|
**Kontext:** Dashboard-Widgets (`backend/widget_catalog.py`, Lab unter `/api/app/...`) und das **Subscription-/Feature-Modell** (`features`, `tier_limits`, `check_feature_access` in `backend/auth.py`) sind **getrennte Schichten**, müssen aber bei tariffrelevanten Widgets **verknüpft** werden.
|
||||||
|
|
||||||
**Bindend:**
|
**Bindend:**
|
||||||
|
|
||||||
1. **Keine fest codierten Tier-Namen** für Widget-Rechte – Tiers und Limits kommen aus der DB.
|
1. **Keine fest codierten Tier-Namen** für Widget-Rechte – Tiers und Limits kommen aus der DB.
|
||||||
2. **Komplexität** (Module aus, Unter-Stufen, KI vs. Standard) liegt in der **Feature-/Subscription-Logik**, nicht verteilt in Widget-Komponenten.
|
2. **Komplexität** (Module aus, Unter-Stufen, KI vs. Standard) liegt in der **Feature-/Subscription-Logik**, nicht verteilt in Widget-Komponenten.
|
||||||
3. **Nutzer-Konfigurator** (**Übersicht anpassen** / `DashboardConfigurePage`): Widgets **ohne** passende Berechtigung **nicht anzeigen**; alle erlaubten Widgets bleiben verfügbar.
|
3. **Nutzer-Konfigurator** (z. B. Dashboard-Lab): Widgets **ohne** passende Berechtigung **nicht anzeigen**; alle erlaubten Widgets bleiben verfügbar.
|
||||||
4. **Backend** liefert die effektive Erlaubnis (z. B. über erweiterten Katalog oder Entitlements), und **validiert beim Speichern** des Layouts, dass keine unerlaubten Widget-IDs persistiert werden (Policy: ablehnen oder strippen – einheitlich halten).
|
4. **Backend** liefert die effektive Erlaubnis (z. B. über erweiterten Katalog oder Entitlements), und **validiert beim Speichern** des Layouts, dass keine unerlaubten Widget-IDs persistiert werden (Policy: ablehnen oder strippen – einheitlich halten).
|
||||||
5. **Daten/API:** Zusätzlich zur UI-Filterung müssen die **inhaltsliefernden Endpoints** weiterhin über `check_feature_access` geschützt sein (kein Leck über direkte API-Aufrufe).
|
5. **Daten/API:** Zusätzlich zur UI-Filterung müssen die **inhaltsliefernden Endpoints** weiterhin über `check_feature_access` geschützt sein (kein Leck über direkte API-Aufrufe).
|
||||||
|
|
||||||
|
|
|
||||||
25
CLAUDE.md
25
CLAUDE.md
|
|
@ -10,9 +10,8 @@
|
||||||
> | **Gitea-Landkarte (lokal gepflegt)** | **`.claude/docs/GITEA_ISSUES_INDEX.md`** |
|
> | **Gitea-Landkarte (lokal gepflegt)** | **`.claude/docs/GITEA_ISSUES_INDEX.md`** |
|
||||||
> | **Universal CSV Import** (neues Modul / Executor / Vorlagen) | **`.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md`** |
|
> | **Universal CSV Import** (neues Modul / Executor / Vorlagen) | **`.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md`** |
|
||||||
> | **GUI / IA / Admin / Nav / PWA-Leiste** | **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`** |
|
> | **GUI / IA / Admin / Nav / PWA-Leiste** | **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`** |
|
||||||
> | **Dashboard-Widgets** (Katalog, Registrierung, `config`) | **`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`** |
|
> | **Dashboard-Lab-Widgets** (Katalog, Registrierung, `config`) | **`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`** |
|
||||||
> | **Agent-Einstieg** | **`.claude/README.md`** |
|
> | **Agent-Einstieg** | **`.claude/README.md`** |
|
||||||
> | **Activity Session Metrics (EAV, Attributprofile)** | **`.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md`** |
|
|
||||||
|
|
||||||
## Claude Code Verantwortlichkeiten
|
## Claude Code Verantwortlichkeiten
|
||||||
|
|
||||||
|
|
@ -100,11 +99,6 @@ frontend/src/
|
||||||
**Branch:** develop
|
**Branch:** develop
|
||||||
**Nächster Schritt:** Frontend Chart Integration → Testing → Prod Deploy v0.9i
|
**Nächster Schritt:** Frontend Chart Integration → Testing → Prod Deploy v0.9i
|
||||||
|
|
||||||
### Updates (23.04.2026 - Dashboard: veraltete Demo-Route entfernt, klare Produkt-Registry)
|
|
||||||
|
|
||||||
- **Frontend:** Veraltete Visualisierungs-Demo-Route und festes Demo-Layout entfernt; Widget-Registrierung in `frontend/src/widgetSystem/registerDashboardWidgets.js` (`ensureDashboardWidgetsRegistered`). Kern-Widgets unter `frontend/src/components/dashboard-widgets-legacy/`. Chart-Hilfen in `frontend/src/widgetSystem/dashboardChartUtils.js`. Experimentelles Layout-Lab entfernt; Konfiguration nur noch **Übersicht anpassen** (`DashboardConfigurePage`).
|
|
||||||
- **Doku:** `.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` und Kommentar in `backend/widget_catalog.py` angepasst.
|
|
||||||
|
|
||||||
### Updates (09.04.2026 - Universal CSV Import, Prod-Migration abgeschlossen)
|
### Updates (09.04.2026 - Universal CSV Import, Prod-Migration abgeschlossen)
|
||||||
|
|
||||||
- **Agent-Leitfaden:** `.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` (Checkliste für neue Import-Module, Executor, Vorlagen, `source=csv`, SAVEPOINT-/Cursor-Regeln)
|
- **Agent-Leitfaden:** `.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` (Checkliste für neue Import-Module, Executor, Vorlagen, `source=csv`, SAVEPOINT-/Cursor-Regeln)
|
||||||
|
|
@ -121,21 +115,6 @@ 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
|
- **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.
|
- **`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 (14.04.2026 - Activity Session Metrics EAV, Kern-Backend)
|
|
||||||
|
|
||||||
- **Agent-Guide:** `.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` (Prod: nur additive Migration **054**; Layer1 `data_layer/activity_session_metrics.py`).
|
|
||||||
- **DB:** `training_category_parameter`, `training_type_parameter`, `activity_session_metrics`; `activity_log.started_at` / `ended_at` (nullable).
|
|
||||||
- **API:** Admin `/api/admin/training-parameters`, `/api/admin/training-category-parameters`, `/api/admin/training-type-parameters`; Nutzer `GET /api/activity/{id}`, `PUT /api/activity/{id}/metrics`; Platzhalter-Pfad `training_sessions_recent_json` liefert pro Session `session_metrics` inkl. `name_*` / `description_*`; **`{{training_parameters_glossary_md}}`** = Markdown-Legende aller aktiven Parameter (KI).
|
|
||||||
- **Frontend:** Admin `/admin/activity-attribute-profiles`; Aktivität → Verlauf → Bearbeiten: Profil-Kennwerte; `api.js` ergänzt.
|
|
||||||
|
|
||||||
### Updates (16.04.2026 - Aktivität Phase A abgeschlossen, Phase B gestartet)
|
|
||||||
|
|
||||||
- **Phase A:** Skalar-Kanon schriftlich fixiert — `.claude/docs/technical/ACTIVITY_SCALAR_KANON_TABLE.md`; `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` v1.1; Agent-Guide Checkliste Phase A erledigt.
|
|
||||||
- **Phase B:** `GET /api/activity` (Liste) reichert jede Zeile mit `session_metrics` über `enrich_sessions_with_metrics` an (gleiche Merge-Logik wie Detail); Consumer-Audit-Tabelle in Produktions-Architektur-Dok §4 Phase B.
|
|
||||||
- **Phase B (Export):** `routers/exportdata.py` — JSON-Export `activity` mit `session_metrics`; CSV-Gesamtexport Training-Details mit EAV-Zusammenfassung; ZIP `data/activity.csv` mit Zusatzspalte `session_metrics_json` (Standard-Import unverändert).
|
|
||||||
- **Issue #53 / Layer 2a:** `ACTIVITY_LAYER2A_PLACEHOLDER_AUDIT.md` — alle 20 Aktivitäts-Platzhalter gegen Layer 1 geprüft; Registry-Fix `activity_summary.resolver_function` → `get_activity_summary`.
|
|
||||||
- **Layer 2a Schritt 2:** Registry-Texte `activity_detail`, `training_sessions_recent_json` (dynamische session_metrics, Merge-Kanon); `trainingstyp_verteilung` Metadaten an Phase-0c-Code angeglichen.
|
|
||||||
|
|
||||||
### Updates (11.04.2026 - Ernährung: TDEE, Bilanz, Kalorien-Score)
|
### Updates (11.04.2026 - Ernährung: TDEE, Bilanz, Kalorien-Score)
|
||||||
|
|
||||||
- **`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).
|
- **`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).
|
||||||
|
|
@ -896,7 +875,7 @@ Bottom-Padding Mobile: 80px (Navigation)
|
||||||
|Auth-Flow|`.claude/library/AUTH.md`|Sicherheit + Sessions|
|
|Auth-Flow|`.claude/library/AUTH.md`|Sicherheit + Sessions|
|
||||||
|API-Referenz|`.claude/library/API\_REFERENCE.md`|Alle Endpoints|
|
|API-Referenz|`.claude/library/API\_REFERENCE.md`|Alle Endpoints|
|
||||||
|Datenbankschema|`.claude/library/DATABASE.md`|Tabellen + Beziehungen|
|
|Datenbankschema|`.claude/library/DATABASE.md`|Tabellen + Beziehungen|
|
||||||
|Dashboard-Widgets|`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`|Katalog, Validierung, Frontend-Registry, konfigurierbare `config`|
|
|Dashboard-Lab-Widgets|`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`|Katalog, Validierung, Frontend-Registry, konfigurierbare `config`|
|
||||||
|Projekt-Doku (Git)|`docs/README.md` + `docs/issues/`|Issue-Specs, Reviews, Platzhalter-Governance, Status-Snapshots|
|
|Projekt-Doku (Git)|`docs/README.md` + `docs/issues/`|Issue-Specs, Reviews, Platzhalter-Governance, Status-Snapshots|
|
||||||
|
|
||||||
> Library-Dateien werden mit `/document` generiert und nach größeren
|
> Library-Dateien werden mit `/document` generiert und nach größeren
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,6 @@ import bcrypt
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
from db import get_db, get_cursor
|
||||||
|
|
||||||
print("[AUTH.PY] Module loaded - require_auth_flexible will be defined")
|
|
||||||
|
|
||||||
|
|
||||||
def hash_pin(pin: str) -> str:
|
def hash_pin(pin: str) -> str:
|
||||||
"""Hash password with bcrypt. Falls back gracefully from legacy SHA256."""
|
"""Hash password with bcrypt. Falls back gracefully from legacy SHA256."""
|
||||||
|
|
@ -78,24 +76,21 @@ def require_auth(x_auth_token: Optional[str] = Header(default=None)):
|
||||||
return session
|
return session
|
||||||
|
|
||||||
|
|
||||||
def require_auth_flexible(x_auth_token: Optional[str] = Header(default=None), ssetoken: Optional[str] = Query(default=None)):
|
def require_auth_flexible(x_auth_token: Optional[str] = Header(default=None), token: Optional[str] = Query(default=None)):
|
||||||
"""
|
"""
|
||||||
FastAPI dependency - auth via header OR query parameter.
|
FastAPI dependency - auth via header OR query parameter.
|
||||||
|
|
||||||
Used for endpoints accessed by <img> tags and SSE connections that can't send headers.
|
Used for endpoints accessed by <img> tags that can't send headers.
|
||||||
Query parameter is 'ssetoken' to avoid conflicts with endpoint 'token' parameters.
|
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
@app.get("/api/photos/{id}")
|
@app.get("/api/photos/{id}")
|
||||||
def get_photo(id: str, session: dict = Depends(require_auth_flexible)):
|
def get_photo(id: str, session: dict = Depends(require_auth_flexible)):
|
||||||
...
|
...
|
||||||
|
|
||||||
Call with: ?ssetoken=XXX or Header: X-Auth-Token: XXX
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException 401 if not authenticated
|
HTTPException 401 if not authenticated
|
||||||
"""
|
"""
|
||||||
session = get_session(x_auth_token or ssetoken)
|
session = get_session(x_auth_token or token)
|
||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(401, "Nicht eingeloggt")
|
raise HTTPException(401, "Nicht eingeloggt")
|
||||||
return session
|
return session
|
||||||
|
|
|
||||||
|
|
@ -223,11 +223,6 @@ def calculate_arm_28d_delta(profile_id: str) -> Optional[float]:
|
||||||
return _calculate_circumference_delta(profile_id, 'c_arm', 28)
|
return _calculate_circumference_delta(profile_id, 'c_arm', 28)
|
||||||
|
|
||||||
|
|
||||||
def calculate_arm_relaxed_28d_delta(profile_id: str) -> Optional[float]:
|
|
||||||
"""28-day relaxed arm circumference change (cm)."""
|
|
||||||
return _calculate_circumference_delta(profile_id, 'c_arm_relaxed', 28)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_thigh_28d_delta(profile_id: str) -> Optional[float]:
|
def calculate_thigh_28d_delta(profile_id: str) -> Optional[float]:
|
||||||
"""Calculate 28-day thigh circumference change (cm)"""
|
"""Calculate 28-day thigh circumference change (cm)"""
|
||||||
delta = _calculate_circumference_delta(profile_id, 'c_thigh', 28)
|
delta = _calculate_circumference_delta(profile_id, 'c_thigh', 28)
|
||||||
|
|
|
||||||
|
|
@ -47,46 +47,6 @@ def sniff_delimiter(sample_line: str) -> str:
|
||||||
return best
|
return best
|
||||||
|
|
||||||
|
|
||||||
def _csv_field_count(line: str, delimiter: str) -> int:
|
|
||||||
"""Anzahl Felder in einer Zeile (csv.reader, berücksichtigt Anführungszeichen)."""
|
|
||||||
if not line or not line.strip():
|
|
||||||
return 0
|
|
||||||
try:
|
|
||||||
row = next(csv.reader(io.StringIO(line), delimiter=delimiter))
|
|
||||||
except StopIteration:
|
|
||||||
return 0
|
|
||||||
return len(row)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_effective_csv_delimiter(text: str, template_delimiter: str | None = None) -> str:
|
|
||||||
"""
|
|
||||||
Trennzeichen für die hochgeladene Datei wählen. Gespeicherte Vorlagen haben oft «,»
|
|
||||||
(Apple EN), tatsächliche Exporte je nach Region «;» (Apple DE / Excel) — mit falschem
|
|
||||||
Zeichen wird die Kopfzeile zu **einer** Spalte und das Mapping bricht vollständig.
|
|
||||||
"""
|
|
||||||
tpl = (template_delimiter or "").strip()
|
|
||||||
if tpl not in _DEFAULT_DELIMS:
|
|
||||||
tpl = None
|
|
||||||
|
|
||||||
lines = _split_first_lines(text, max_lines=5)
|
|
||||||
if not lines:
|
|
||||||
return tpl or ","
|
|
||||||
|
|
||||||
header = lines[0]
|
|
||||||
scores: list[tuple[int, str]] = []
|
|
||||||
for d in _DEFAULT_DELIMS:
|
|
||||||
scores.append((_csv_field_count(header, d), d))
|
|
||||||
|
|
||||||
max_n = max(n for n, _ in scores)
|
|
||||||
if max_n <= 1:
|
|
||||||
return tpl or sniff_delimiter(header)
|
|
||||||
|
|
||||||
at_max = [d for n, d in scores if n == max_n]
|
|
||||||
if tpl and tpl in at_max:
|
|
||||||
return tpl
|
|
||||||
return at_max[0]
|
|
||||||
|
|
||||||
|
|
||||||
def _split_first_lines(text: str, max_lines: int = 5) -> List[str]:
|
def _split_first_lines(text: str, max_lines: int = 5) -> List[str]:
|
||||||
lines: List[str] = []
|
lines: List[str] = []
|
||||||
for line in text.splitlines():
|
for line in text.splitlines():
|
||||||
|
|
@ -97,18 +57,6 @@ def _split_first_lines(text: str, max_lines: int = 5) -> List[str]:
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def canonical_csv_header_label(name: str | None) -> str:
|
|
||||||
"""
|
|
||||||
Einheitlicher Spalten-Key für Analyse (Vorlage/Dialog), Import und Signatur.
|
|
||||||
BOM und NBSP (häufig in Excel/Apple-Exporten) werden vereinheitlicht, damit
|
|
||||||
field_mappings exakt zu DictReader-Zeilen passt.
|
|
||||||
"""
|
|
||||||
if name is None:
|
|
||||||
return ""
|
|
||||||
s = str(name).replace("\ufeff", "").replace("\u00a0", " ").strip()
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
def parse_csv_sample(
|
def parse_csv_sample(
|
||||||
text: str,
|
text: str,
|
||||||
delimiter: str | None = None,
|
delimiter: str | None = None,
|
||||||
|
|
@ -137,7 +85,7 @@ def parse_csv_sample(
|
||||||
return [], [], delim
|
return [], [], delim
|
||||||
|
|
||||||
if has_header:
|
if has_header:
|
||||||
headers = [canonical_csv_header_label(h) for h in rows_raw[0]]
|
headers = [h.strip() for h in rows_raw[0]]
|
||||||
data = rows_raw[1 : 1 + max_data_rows]
|
data = rows_raw[1 : 1 + max_data_rows]
|
||||||
else:
|
else:
|
||||||
n = len(rows_raw[0])
|
n = len(rows_raw[0])
|
||||||
|
|
@ -155,7 +103,7 @@ def parse_csv_sample(
|
||||||
|
|
||||||
|
|
||||||
def normalize_header_for_signature(name: str) -> str:
|
def normalize_header_for_signature(name: str) -> str:
|
||||||
s = canonical_csv_header_label(name).lower()
|
s = name.strip().lower()
|
||||||
s = re.sub(r"\s+", "_", s)
|
s = re.sub(r"\s+", "_", s)
|
||||||
s = re.sub(r"[^a-z0-9_äöüß().%-]+", "_", s)
|
s = re.sub(r"[^a-z0-9_äöüß().%-]+", "_", s)
|
||||||
return s.strip("_")
|
return s.strip("_")
|
||||||
|
|
@ -163,9 +111,7 @@ def normalize_header_for_signature(name: str) -> str:
|
||||||
|
|
||||||
def column_signature(headers: List[str]) -> List[str]:
|
def column_signature(headers: List[str]) -> List[str]:
|
||||||
"""Sortierte normalisierte Spaltennamen für Signatur-Vergleich."""
|
"""Sortierte normalisierte Spaltennamen für Signatur-Vergleich."""
|
||||||
return sorted(
|
return sorted({normalize_header_for_signature(h) for h in headers if h is not None and str(h).strip()})
|
||||||
{normalize_header_for_signature(h) for h in headers if h is not None and canonical_csv_header_label(str(h))}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def headers_signature_match_score(sig_csv: List[str], sig_template: List[str]) -> float:
|
def headers_signature_match_score(sig_csv: List[str], sig_template: List[str]) -> float:
|
||||||
|
|
@ -232,6 +178,12 @@ def get_csv_import_limits(conn_row: dict | None) -> dict[str, int]:
|
||||||
return defaults
|
return defaults
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_header_key(k: str | None) -> str:
|
||||||
|
if k is None:
|
||||||
|
return ""
|
||||||
|
return str(k).strip().removeprefix("\ufeff")
|
||||||
|
|
||||||
|
|
||||||
def iter_csv_dict_rows(
|
def iter_csv_dict_rows(
|
||||||
text: str,
|
text: str,
|
||||||
delimiter: str,
|
delimiter: str,
|
||||||
|
|
@ -253,8 +205,4 @@ def iter_csv_dict_rows(
|
||||||
continue
|
continue
|
||||||
if not any(v and str(v).strip() for v in row.values()):
|
if not any(v and str(v).strip() for v in row.values()):
|
||||||
continue
|
continue
|
||||||
yield {
|
yield {_strip_header_key(k): (v or "").strip() for k, v in row.items() if _strip_header_key(k)}
|
||||||
canonical_csv_header_label(k): (v or "").strip()
|
|
||||||
for k, v in row.items()
|
|
||||||
if canonical_csv_header_label(k)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ from typing import Any
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from csv_parser.core import iter_csv_dict_rows, resolve_effective_csv_delimiter
|
from csv_parser.core import iter_csv_dict_rows
|
||||||
from csv_parser.import_row_processing import (
|
from csv_parser.import_row_processing import (
|
||||||
aggregate_mapped_rows,
|
aggregate_mapped_rows,
|
||||||
resolve_import_row_processing,
|
resolve_import_row_processing,
|
||||||
|
|
@ -23,6 +23,14 @@ from csv_parser.type_converter import build_row_after_mapping
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from evaluation_helper import evaluate_and_save_activity as _evaluate_and_save_activity
|
||||||
|
|
||||||
|
_EVALUATION_AVAILABLE = True
|
||||||
|
except Exception: # pragma: no cover
|
||||||
|
_evaluate_and_save_activity = None
|
||||||
|
_EVALUATION_AVAILABLE = False
|
||||||
|
|
||||||
|
|
||||||
def _resolve_training_type_for_activity(cur, activity_type: str, profile_id: str):
|
def _resolve_training_type_for_activity(cur, activity_type: str, profile_id: str):
|
||||||
"""Lazy import — gleicher DB-Cursor wie der Import (kein verschachteltes get_db / Pool-Deadlock)."""
|
"""Lazy import — gleicher DB-Cursor wie der Import (kein verschachteltes get_db / Pool-Deadlock)."""
|
||||||
|
|
@ -97,8 +105,7 @@ def run_universal_csv_import(
|
||||||
if tc is not None and not isinstance(tc, dict):
|
if tc is not None and not isinstance(tc, dict):
|
||||||
tc = None
|
tc = None
|
||||||
|
|
||||||
tpl_delim = str(mapping.get("delimiter") or ",").strip() or ","
|
delim = mapping.get("delimiter") or ","
|
||||||
delim = resolve_effective_csv_delimiter(text, tpl_delim)
|
|
||||||
has_header = mapping.get("has_header", True)
|
has_header = mapping.get("has_header", True)
|
||||||
|
|
||||||
if module == "nutrition":
|
if module == "nutrition":
|
||||||
|
|
@ -807,17 +814,6 @@ def _import_activity(
|
||||||
error_details: list,
|
error_details: list,
|
||||||
affected_ids: dict,
|
affected_ids: dict,
|
||||||
) -> dict[str, int]:
|
) -> dict[str, int]:
|
||||||
from data_layer.activity_time_normalize import normalize_activity_start
|
|
||||||
from data_layer.activity_persistence_orchestrator import (
|
|
||||||
activity_csv_registry_updates_from_mapped,
|
|
||||||
find_activity_duplicate_id,
|
|
||||||
insert_activity_csv_minimal,
|
|
||||||
new_activity_id,
|
|
||||||
run_activity_post_write_hooks_import,
|
|
||||||
update_activity_columns,
|
|
||||||
)
|
|
||||||
from data_layer.activity_session_metrics import upsert_session_metrics_from_csv_mapped
|
|
||||||
|
|
||||||
rows_total = 0
|
rows_total = 0
|
||||||
inserted = 0
|
inserted = 0
|
||||||
updated = 0
|
updated = 0
|
||||||
|
|
@ -889,7 +885,6 @@ def _import_activity(
|
||||||
|
|
||||||
wtype = str(activity_type).strip()
|
wtype = str(activity_type).strip()
|
||||||
iso = date_d.isoformat()
|
iso = date_d.isoformat()
|
||||||
_, workout_start_t = normalize_activity_start(start_key)
|
|
||||||
|
|
||||||
# Pro Zeile: bei SQL-Fehler sonst „current transaction is aborted“ bis Xact-Ende.
|
# Pro Zeile: bei SQL-Fehler sonst „current transaction is aborted“ bis Xact-Ende.
|
||||||
cur.execute("SAVEPOINT csv_activity_row")
|
cur.execute("SAVEPOINT csv_activity_row")
|
||||||
|
|
@ -897,79 +892,113 @@ def _import_activity(
|
||||||
training_type_id, training_category, training_subcategory = _resolve_training_type_for_activity(
|
training_type_id, training_category, training_subcategory = _resolve_training_type_for_activity(
|
||||||
cur, wtype, profile_id
|
cur, wtype, profile_id
|
||||||
)
|
)
|
||||||
registry_updates = activity_csv_registry_updates_from_mapped(mapped)
|
cur.execute(
|
||||||
existing_id = find_activity_duplicate_id(cur, profile_id, iso, workout_start_t)
|
"""
|
||||||
|
SELECT id FROM activity_log
|
||||||
|
WHERE profile_id = %s AND date = %s AND start_time = %s
|
||||||
|
""",
|
||||||
|
(profile_id, iso, start_key),
|
||||||
|
)
|
||||||
|
existing = cur.fetchone()
|
||||||
|
|
||||||
if existing_id:
|
if existing:
|
||||||
upd = {
|
eid = existing["id"]
|
||||||
"start_time": workout_start_t,
|
cur.execute(
|
||||||
"end_time": end_str or None,
|
"""
|
||||||
"activity_type": wtype,
|
UPDATE activity_log
|
||||||
"duration_min": duration_min,
|
SET end_time = %s,
|
||||||
"kcal_active": kcal_a,
|
activity_type = %s,
|
||||||
"kcal_resting": kcal_r,
|
duration_min = %s,
|
||||||
"hr_avg": hr_a,
|
kcal_active = %s,
|
||||||
"hr_max": hr_m,
|
kcal_resting = %s,
|
||||||
"distance_km": dist,
|
hr_avg = %s,
|
||||||
"training_type_id": training_type_id,
|
hr_max = %s,
|
||||||
"training_category": training_category,
|
distance_km = %s,
|
||||||
"training_subcategory": training_subcategory,
|
training_type_id = %s,
|
||||||
"source": "csv",
|
training_category = %s,
|
||||||
}
|
training_subcategory = %s,
|
||||||
upd.update(registry_updates)
|
source = 'csv'
|
||||||
update_activity_columns(cur, profile_id, existing_id, upd)
|
WHERE id = %s
|
||||||
updated += 1
|
RETURNING id
|
||||||
affected_ids["activity_log"].append(str(existing_id))
|
""",
|
||||||
aid = existing_id
|
(
|
||||||
else:
|
end_str or None,
|
||||||
eid = new_activity_id()
|
wtype,
|
||||||
insert_activity_csv_minimal(
|
duration_min,
|
||||||
cur,
|
kcal_a,
|
||||||
profile_id,
|
kcal_r,
|
||||||
eid,
|
hr_a,
|
||||||
date_iso=iso,
|
hr_m,
|
||||||
start_time=workout_start_t,
|
dist,
|
||||||
end_time=end_str or None,
|
training_type_id,
|
||||||
activity_type=wtype,
|
training_category,
|
||||||
duration_min=duration_min,
|
training_subcategory,
|
||||||
kcal_active=kcal_a,
|
eid,
|
||||||
kcal_resting=kcal_r,
|
),
|
||||||
hr_avg=hr_a,
|
|
||||||
hr_max=hr_m,
|
|
||||||
distance_km=dist,
|
|
||||||
training_type_id=training_type_id,
|
|
||||||
training_category=training_category,
|
|
||||||
training_subcategory=training_subcategory,
|
|
||||||
source="csv",
|
|
||||||
)
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
updated += 1
|
||||||
|
if row and row.get("id"):
|
||||||
|
affected_ids["activity_log"].append(str(row["id"]))
|
||||||
|
aid = eid
|
||||||
|
else:
|
||||||
|
eid = str(uuid.uuid4())
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO activity_log (
|
||||||
|
id, profile_id, date, start_time, end_time, activity_type, duration_min,
|
||||||
|
kcal_active, kcal_resting, hr_avg, hr_max, distance_km,
|
||||||
|
source, training_type_id, training_category, training_subcategory, created
|
||||||
|
)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'csv',%s,%s,%s,CURRENT_TIMESTAMP)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
eid,
|
||||||
|
profile_id,
|
||||||
|
iso,
|
||||||
|
start_key,
|
||||||
|
end_str or None,
|
||||||
|
wtype,
|
||||||
|
duration_min,
|
||||||
|
kcal_a,
|
||||||
|
kcal_r,
|
||||||
|
hr_a,
|
||||||
|
hr_m,
|
||||||
|
dist,
|
||||||
|
training_type_id,
|
||||||
|
training_category,
|
||||||
|
training_subcategory,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
inserted += 1
|
inserted += 1
|
||||||
new_entries += 1
|
new_entries += 1
|
||||||
affected_ids["activity_log"].append(str(eid))
|
if row and row.get("id"):
|
||||||
|
affected_ids["activity_log"].append(str(row["id"]))
|
||||||
aid = eid
|
aid = eid
|
||||||
if registry_updates:
|
|
||||||
update_activity_columns(cur, profile_id, aid, registry_updates)
|
|
||||||
|
|
||||||
run_activity_post_write_hooks_import(
|
if _EVALUATION_AVAILABLE and training_type_id and _evaluate_and_save_activity:
|
||||||
cur,
|
try:
|
||||||
profile_id,
|
activity_dict = {
|
||||||
str(aid),
|
"id": aid,
|
||||||
workout_date=iso,
|
"profile_id": profile_id,
|
||||||
training_type_id=training_type_id,
|
"date": iso,
|
||||||
duration_min=duration_min,
|
"training_type_id": training_type_id,
|
||||||
hr_avg=hr_a,
|
"duration_min": duration_min,
|
||||||
hr_max=hr_m,
|
"hr_avg": hr_a,
|
||||||
distance_km=dist,
|
"hr_max": hr_m,
|
||||||
kcal_active=kcal_a,
|
"distance_km": dist,
|
||||||
kcal_resting=kcal_r,
|
"kcal_active": kcal_a,
|
||||||
)
|
"kcal_resting": kcal_r,
|
||||||
upsert_session_metrics_from_csv_mapped(
|
"rpe": None,
|
||||||
cur,
|
"pace_min_per_km": None,
|
||||||
profile_id,
|
"cadence": None,
|
||||||
str(aid),
|
"elevation_gain": None,
|
||||||
mapped,
|
}
|
||||||
training_category,
|
_evaluate_and_save_activity(cur, aid, activity_dict, training_type_id, profile_id)
|
||||||
training_type_id,
|
except Exception as eval_err:
|
||||||
)
|
logger.warning("[csv activity] Auto-Eval fehlgeschlagen: %s", eval_err)
|
||||||
cur.execute("RELEASE SAVEPOINT csv_activity_row")
|
cur.execute("RELEASE SAVEPOINT csv_activity_row")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -37,16 +37,12 @@ def validate_import_row_processing(
|
||||||
module: str,
|
module: str,
|
||||||
spec: Mapping[str, Any],
|
spec: Mapping[str, Any],
|
||||||
field_mappings: Mapping[str, Any],
|
field_mappings: Mapping[str, Any],
|
||||||
cur=None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Wirft ValueError bei ungültiger Konfiguration."""
|
"""Wirft ValueError bei ungültiger Konfiguration."""
|
||||||
mod = get_module_definition(module)
|
mod = get_module_definition(module)
|
||||||
if not mod:
|
if not mod:
|
||||||
raise ValueError(f"Unbekanntes Modul: {module}")
|
raise ValueError(f"Unbekanntes Modul: {module}")
|
||||||
allowed = set(mod.get("fields") or [])
|
allowed = set(mod.get("fields") or [])
|
||||||
if module == "activity" and cur is not None:
|
|
||||||
cur.execute("SELECT key FROM training_parameters WHERE is_active = true")
|
|
||||||
allowed.update(str(r["key"]) for r in cur.fetchall())
|
|
||||||
fm_targets = {str(v) for v in field_mappings.values() if v and v not in ("-", "_skip")}
|
fm_targets = {str(v) for v in field_mappings.values() if v and v not in ("-", "_skip")}
|
||||||
|
|
||||||
group_by = spec.get("group_by") or []
|
group_by = spec.get("group_by") or []
|
||||||
|
|
|
||||||
|
|
@ -127,19 +127,13 @@ def _match_seed_to_db_field(header: str, seed_fm: Mapping[str, str]) -> str | No
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _alias_suggest(
|
def _alias_suggest(norm: str, module: str, used: set[str]) -> str | None:
|
||||||
norm: str,
|
|
||||||
module: str,
|
|
||||||
used: set[str],
|
|
||||||
*,
|
|
||||||
field_order: list[str] | None = None,
|
|
||||||
) -> str | None:
|
|
||||||
aliases = _MODULE_HEADER_ALIASES.get(module, {})
|
aliases = _MODULE_HEADER_ALIASES.get(module, {})
|
||||||
mod = get_module_definition(module)
|
mod = get_module_definition(module)
|
||||||
if not mod:
|
if not mod:
|
||||||
return None
|
return None
|
||||||
order = field_order if field_order is not None else list(mod["fields"].keys())
|
field_order = list(mod["fields"].keys())
|
||||||
for db_field in order:
|
for db_field in field_order:
|
||||||
if db_field in used:
|
if db_field in used:
|
||||||
continue
|
continue
|
||||||
tokens = aliases.get(db_field, frozenset())
|
tokens = aliases.get(db_field, frozenset())
|
||||||
|
|
@ -158,8 +152,6 @@ def suggest_field_mappings(
|
||||||
headers: list[str],
|
headers: list[str],
|
||||||
module: str,
|
module: str,
|
||||||
seed_fm: Mapping[str, str] | None = None,
|
seed_fm: Mapping[str, str] | None = None,
|
||||||
*,
|
|
||||||
effective_fields: Mapping[str, Any] | None = None,
|
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""
|
"""
|
||||||
Mappt jede CSV-Spalte (Roh-Header als Key) auf DB-Feld oder '-'.
|
Mappt jede CSV-Spalte (Roh-Header als Key) auf DB-Feld oder '-'.
|
||||||
|
|
@ -172,16 +164,13 @@ def suggest_field_mappings(
|
||||||
if not mod:
|
if not mod:
|
||||||
return {h: "-" for h in headers}
|
return {h: "-" for h in headers}
|
||||||
|
|
||||||
fields_map = dict(effective_fields) if effective_fields is not None else dict(mod["fields"])
|
|
||||||
field_order = list(fields_map.keys())
|
|
||||||
|
|
||||||
fm: dict[str, str] = {h: "-" for h in headers}
|
fm: dict[str, str] = {h: "-" for h in headers}
|
||||||
used: set[str] = set()
|
used: set[str] = set()
|
||||||
|
|
||||||
if seed_fm:
|
if seed_fm:
|
||||||
for h in headers:
|
for h in headers:
|
||||||
db = _match_seed_to_db_field(h, seed_fm)
|
db = _match_seed_to_db_field(h, seed_fm)
|
||||||
if db and db not in used and db in fields_map:
|
if db and db not in used:
|
||||||
fm[h] = db
|
fm[h] = db
|
||||||
used.add(db)
|
used.add(db)
|
||||||
|
|
||||||
|
|
@ -189,7 +178,7 @@ def suggest_field_mappings(
|
||||||
if fm[h] != "-":
|
if fm[h] != "-":
|
||||||
continue
|
continue
|
||||||
norm = _norm_key(h)
|
norm = _norm_key(h)
|
||||||
db = _alias_suggest(norm, module, used, field_order=field_order)
|
db = _alias_suggest(norm, module, used)
|
||||||
if db:
|
if db:
|
||||||
fm[h] = db
|
fm[h] = db
|
||||||
used.add(db)
|
used.add(db)
|
||||||
|
|
@ -201,8 +190,6 @@ def build_type_conversions_for_mapping(
|
||||||
module: str,
|
module: str,
|
||||||
field_mappings: Mapping[str, str],
|
field_mappings: Mapping[str, str],
|
||||||
seed_tc: Mapping[str, Any] | None = None,
|
seed_tc: Mapping[str, Any] | None = None,
|
||||||
*,
|
|
||||||
effective_fields: Mapping[str, Any] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""type_conversions nur für zugewiesene Zielfelder; Seed überschreibt Defaults."""
|
"""type_conversions nur für zugewiesene Zielfelder; Seed überschreibt Defaults."""
|
||||||
if module == "sleep":
|
if module == "sleep":
|
||||||
|
|
@ -211,7 +198,6 @@ def build_type_conversions_for_mapping(
|
||||||
defaults = _DEFAULT_TYPE_CONVERSIONS.get(module, {})
|
defaults = _DEFAULT_TYPE_CONVERSIONS.get(module, {})
|
||||||
out: dict[str, Any] = {}
|
out: dict[str, Any] = {}
|
||||||
targets = {v for v in field_mappings.values() if v and v not in ("-", "_skip")}
|
targets = {v for v in field_mappings.values() if v and v not in ("-", "_skip")}
|
||||||
field_meta = dict(effective_fields) if effective_fields is not None else None
|
|
||||||
|
|
||||||
if seed_tc:
|
if seed_tc:
|
||||||
for k, v in seed_tc.items():
|
for k, v in seed_tc.items():
|
||||||
|
|
@ -222,20 +208,6 @@ def build_type_conversions_for_mapping(
|
||||||
if t not in out and t in defaults:
|
if t not in out and t in defaults:
|
||||||
out[t] = deepcopy(defaults[t])
|
out[t] = deepcopy(defaults[t])
|
||||||
|
|
||||||
for t in sorted(targets):
|
|
||||||
if t in out:
|
|
||||||
continue
|
|
||||||
finfo = (field_meta or {}).get(t) if field_meta else None
|
|
||||||
if not finfo:
|
|
||||||
continue
|
|
||||||
typ = finfo.get("type")
|
|
||||||
if typ == "int":
|
|
||||||
out[t] = {"type": "int", "flexible": True}
|
|
||||||
elif typ == "float":
|
|
||||||
out[t] = {"type": "float", "decimal_separator": "auto", "flexible": True}
|
|
||||||
else:
|
|
||||||
out[t] = {"type": "string"}
|
|
||||||
|
|
||||||
_apply_energy_kj_hint_from_headers(module, field_mappings, out)
|
_apply_energy_kj_hint_from_headers(module, field_mappings, out)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,39 +34,19 @@ MODULE_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
# Kanon: nur Kern/spine + „heiße“ Metriken → activity_log. Erweiterte Parameter → training_parameters / EAV
|
|
||||||
# (siehe backend/data_layer/activity_data_canon.py).
|
|
||||||
"activity": {
|
"activity": {
|
||||||
"table": "activity_log",
|
"table": "activity_log",
|
||||||
"fields": {
|
"fields": {
|
||||||
"date": {"type": "date", "required": False, "label_de": "Datum"},
|
"date": {"type": "date", "required": False},
|
||||||
"start_time": {
|
"start_time": {"type": "datetime", "required": False},
|
||||||
"type": "datetime",
|
"end_time": {"type": "datetime", "required": False},
|
||||||
"required": False,
|
"activity_type": {"type": "string", "required": True},
|
||||||
"label_de": "Start (Datum/Uhrzeit)",
|
"duration_min": {"type": "float", "required": False, "min": 0},
|
||||||
},
|
"kcal_active": {"type": "float", "required": False, "unit": "kcal"},
|
||||||
"end_time": {"type": "datetime", "required": False, "label_de": "Ende (Datum/Uhrzeit)"},
|
"kcal_resting": {"type": "float", "required": False, "unit": "kcal"},
|
||||||
"activity_type": {"type": "string", "required": True, "label_de": "Trainingsart / Workout-Typ"},
|
"distance_km": {"type": "float", "required": False, "unit": "km"},
|
||||||
"duration_min": {"type": "float", "required": False, "min": 0, "label_de": "Dauer (Minuten)"},
|
"hr_avg": {"type": "float", "required": False, "min": 30, "max": 220},
|
||||||
"kcal_active": {"type": "float", "required": False, "unit": "kcal", "label_de": "Kalorien aktiv"},
|
"hr_max": {"type": "float", "required": False, "min": 30, "max": 220},
|
||||||
"kcal_resting": {"type": "float", "required": False, "unit": "kcal", "label_de": "Kalorien Ruhe"},
|
|
||||||
"distance_km": {"type": "float", "required": False, "unit": "km", "label_de": "Distanz (km)"},
|
|
||||||
"hr_avg": {
|
|
||||||
"type": "float",
|
|
||||||
"required": False,
|
|
||||||
"min": 30,
|
|
||||||
"max": 220,
|
|
||||||
"label_de": "Herzfrequenz Ø (bpm)",
|
|
||||||
},
|
|
||||||
"hr_max": {
|
|
||||||
"type": "float",
|
|
||||||
"required": False,
|
|
||||||
"min": 30,
|
|
||||||
"max": 220,
|
|
||||||
"label_de": "Herzfrequenz max (bpm)",
|
|
||||||
},
|
|
||||||
"rpe": {"type": "int", "required": False, "label_de": "RPE (1–10)"},
|
|
||||||
"notes": {"type": "string", "required": False, "label_de": "Notiz"},
|
|
||||||
},
|
},
|
||||||
"derive_date_from_datetime_field": "start_time",
|
"derive_date_from_datetime_field": "start_time",
|
||||||
"duplicate_key": ["profile_id", "date", "start_time"],
|
"duplicate_key": ["profile_id", "date", "start_time"],
|
||||||
|
|
@ -145,16 +125,13 @@ def list_modules() -> list[str]:
|
||||||
return sorted(MODULE_DEFINITIONS.keys())
|
return sorted(MODULE_DEFINITIONS.keys())
|
||||||
|
|
||||||
|
|
||||||
def validate_field_mappings(module: str, field_mappings: dict, cur=None) -> None:
|
def validate_field_mappings(module: str, field_mappings: dict) -> None:
|
||||||
"""Wirft ValueError bei unbekanntem Modul oder unbekanntem DB-Feld."""
|
"""Wirft ValueError bei unbekanntem Modul oder unbekanntem DB-Feld."""
|
||||||
mod = get_module_definition(module)
|
mod = get_module_definition(module)
|
||||||
if not mod:
|
if not mod:
|
||||||
raise ValueError(f"Unbekanntes Modul: {module}")
|
raise ValueError(f"Unbekanntes Modul: {module}")
|
||||||
fields = cast(dict, mod["fields"])
|
fields = cast(dict, mod["fields"])
|
||||||
allowed = set(fields.keys())
|
allowed = set(fields.keys())
|
||||||
if module == "activity" and cur is not None:
|
|
||||||
cur.execute("SELECT key FROM training_parameters WHERE is_active = true")
|
|
||||||
allowed.update(str(r["key"]) for r in cur.fetchall())
|
|
||||||
if not allowed:
|
if not allowed:
|
||||||
for _csv_col, db_field in field_mappings.items():
|
for _csv_col, db_field in field_mappings.items():
|
||||||
if db_field not in ("", None, "-", "_skip"):
|
if db_field not in ("", None, "-", "_skip"):
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ from csv_parser.module_registry import (
|
||||||
validate_field_mappings,
|
validate_field_mappings,
|
||||||
validate_required_field_targets,
|
validate_required_field_targets,
|
||||||
)
|
)
|
||||||
from data_layer.activity_persistence_orchestrator import merge_activity_csv_module_fields
|
|
||||||
|
|
||||||
ALLOWED_SPEC_TYPES = frozenset(
|
ALLOWED_SPEC_TYPES = frozenset(
|
||||||
{"string", "float", "number", "int", "date", "time", "datetime", "duration"}
|
{"string", "float", "number", "int", "date", "time", "datetime", "duration"}
|
||||||
|
|
@ -51,8 +50,6 @@ def validate_csv_template(
|
||||||
type_conversions: Mapping[str, Any] | None = None,
|
type_conversions: Mapping[str, Any] | None = None,
|
||||||
import_row_processing: Mapping[str, Any] | None = None,
|
import_row_processing: Mapping[str, Any] | None = None,
|
||||||
column_signature: list[str] | None = None,
|
column_signature: list[str] | None = None,
|
||||||
*,
|
|
||||||
cur=None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Prüft eine Vorlage ohne Datei-Upload.
|
Prüft eine Vorlage ohne Datei-Upload.
|
||||||
|
|
@ -77,12 +74,8 @@ def validate_csv_template(
|
||||||
)
|
)
|
||||||
return {"valid": False, "errors": errors, "warnings": warnings}
|
return {"valid": False, "errors": errors, "warnings": warnings}
|
||||||
|
|
||||||
field_defs = dict(mod.get("fields") or {})
|
|
||||||
if module == "activity" and cur is not None:
|
|
||||||
field_defs = merge_activity_csv_module_fields(cur, field_defs)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
validate_field_mappings(module, fm, cur=cur)
|
validate_field_mappings(module, fm)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
errors.append(
|
errors.append(
|
||||||
_issue(
|
_issue(
|
||||||
|
|
@ -107,7 +100,7 @@ def validate_csv_template(
|
||||||
|
|
||||||
if import_row_processing:
|
if import_row_processing:
|
||||||
try:
|
try:
|
||||||
validate_import_row_processing_spec(module, import_row_processing, fm, cur=cur)
|
validate_import_row_processing_spec(module, import_row_processing, fm)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
errors.append(
|
errors.append(
|
||||||
_issue(
|
_issue(
|
||||||
|
|
@ -118,6 +111,7 @@ def validate_csv_template(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
field_defs = mod.get("fields") or {}
|
||||||
for db_field, spec in tc.items():
|
for db_field, spec in tc.items():
|
||||||
if db_field not in field_defs:
|
if db_field not in field_defs:
|
||||||
errors.append(
|
errors.append(
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
from dateutil import parser as dateutil_parser
|
from dateutil import parser as dateutil_parser
|
||||||
|
|
||||||
from csv_parser.core import canonical_csv_header_label, normalize_header_for_signature
|
from csv_parser.core import normalize_header_for_signature
|
||||||
from csv_parser.field_units import factor_source_to_canonical
|
from csv_parser.field_units import factor_source_to_canonical
|
||||||
|
|
||||||
# Alias → strptime (JSON in Kleinbuchstaben)
|
# Alias → strptime (JSON in Kleinbuchstaben)
|
||||||
|
|
@ -477,12 +477,7 @@ def _lookup_db_field(csv_col: str, field_mappings: Mapping[str, str]) -> str | N
|
||||||
CSV-Spaltennamen können Roh-Header sein; Vorlagen-Schlüssel oft normalisiert
|
CSV-Spaltennamen können Roh-Header sein; Vorlagen-Schlüssel oft normalisiert
|
||||||
(wie column_signature). Exakter Treffer, dann Schlüssel nach Normalisierung,
|
(wie column_signature). Exakter Treffer, dann Schlüssel nach Normalisierung,
|
||||||
dann Abgleich aller Vorlagen-Keys über deren Normalform.
|
dann Abgleich aller Vorlagen-Keys über deren Normalform.
|
||||||
|
|
||||||
Zusätzlich: Präfix-Treffer für lange manuelle Keys (z. B. Apple
|
|
||||||
„Aufgestiegene Höhe (m)“ → ``aufgestiegene_höhe_(m)`` vs. Mapping
|
|
||||||
„aufgestiegene Höhe“ → ``aufgestiegene_höhe``) — gewinnt der längste passende Key.
|
|
||||||
"""
|
"""
|
||||||
csv_col = canonical_csv_header_label(csv_col)
|
|
||||||
v = field_mappings.get(csv_col)
|
v = field_mappings.get(csv_col)
|
||||||
if v:
|
if v:
|
||||||
return v if v not in ("-", "_skip") else None
|
return v if v not in ("-", "_skip") else None
|
||||||
|
|
@ -493,27 +488,6 @@ def _lookup_db_field(csv_col: str, field_mappings: Mapping[str, str]) -> str | N
|
||||||
for k, fv in field_mappings.items():
|
for k, fv in field_mappings.items():
|
||||||
if normalize_header_for_signature(str(k)) == norm:
|
if normalize_header_for_signature(str(k)) == norm:
|
||||||
return fv if fv not in ("-", "_skip") else None
|
return fv if fv not in ("-", "_skip") else None
|
||||||
|
|
||||||
# Präfix-Match (min. Länge gegen false positives wie „datum“ → „datum_xyz“)
|
|
||||||
best_fv: str | None = None
|
|
||||||
best_nk_len = 0
|
|
||||||
min_prefix = 10
|
|
||||||
for k, fv in field_mappings.items():
|
|
||||||
if not fv or fv in ("-", "_skip"):
|
|
||||||
continue
|
|
||||||
nk = normalize_header_for_signature(str(k))
|
|
||||||
if len(nk) < min_prefix or len(nk) >= len(norm):
|
|
||||||
continue
|
|
||||||
if not norm.startswith(nk):
|
|
||||||
continue
|
|
||||||
boundary = norm[len(nk) : len(nk) + 1]
|
|
||||||
if boundary not in ("", "_", "("):
|
|
||||||
continue
|
|
||||||
if len(nk) > best_nk_len:
|
|
||||||
best_nk_len = len(nk)
|
|
||||||
best_fv = fv
|
|
||||||
if best_fv:
|
|
||||||
return best_fv
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
"""
|
"""
|
||||||
Dashboard-Layout v1: Validierung, Produkt-Standard (Übersicht) und Servertemplate (`lab_default_layout_dict`).
|
Dashboard-Layout v1: Validierung, Produkt-Standard (Übersicht) und Lab-Standard.
|
||||||
|
|
||||||
Erlaubte Widget-IDs und Reihenfolge: widget_catalog.WIDGET_CATALOG.
|
Erlaubte Widget-IDs und Reihenfolge: widget_catalog.WIDGET_CATALOG.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
|
|
@ -26,13 +25,12 @@ __all__ = [
|
||||||
"coalesce_effective_layout",
|
"coalesce_effective_layout",
|
||||||
"default_layout_dict",
|
"default_layout_dict",
|
||||||
"lab_default_layout_dict",
|
"lab_default_layout_dict",
|
||||||
"merge_missing_catalog_widgets",
|
|
||||||
"product_default_layout_dict",
|
"product_default_layout_dict",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def lab_default_layout_dict() -> dict[str, Any]:
|
def lab_default_layout_dict() -> dict[str, Any]:
|
||||||
"""Serverseitiges Standardlayout (DEFAULT_LAB_WIDGET_IDS); API-Feld `lab_default_layout`, u. a. für Editor/Reset."""
|
"""Standard für Dashboard-Lab (Experimentier-Widgets)."""
|
||||||
on = DEFAULT_LAB_WIDGET_IDS
|
on = DEFAULT_LAB_WIDGET_IDS
|
||||||
return {
|
return {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
|
|
@ -54,25 +52,6 @@ def default_layout_dict() -> dict[str, Any]:
|
||||||
return product_default_layout_dict()
|
return product_default_layout_dict()
|
||||||
|
|
||||||
|
|
||||||
def merge_missing_catalog_widgets(layout: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Hängt fehlende Widget-IDs aus WIDGET_CATALOG an (enabled=False, leere config).
|
|
||||||
Bestehende Reihenfolge bleibt erhalten — nötig, damit neue Katalog-Einträge in
|
|
||||||
„Übersicht anpassen“ / Lab erscheinen, ohne dass Nutzer:innen das Layout resetten müssen.
|
|
||||||
"""
|
|
||||||
out = copy.deepcopy(layout)
|
|
||||||
widgets: list[dict[str, Any]] = list(out.get("widgets") or [])
|
|
||||||
seen: set[str] = {str(w["id"]) for w in widgets if w.get("id")}
|
|
||||||
for e in WIDGET_CATALOG:
|
|
||||||
wid = e["id"]
|
|
||||||
if wid not in seen:
|
|
||||||
widgets.append({"id": wid, "enabled": False, "config": {}})
|
|
||||||
seen.add(wid)
|
|
||||||
out["version"] = out.get("version", 1)
|
|
||||||
out["widgets"] = widgets
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
class DashboardWidgetEntry(BaseModel):
|
class DashboardWidgetEntry(BaseModel):
|
||||||
id: str = Field(min_length=1, max_length=64)
|
id: str = Field(min_length=1, max_length=64)
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
|
|
|
||||||
|
|
@ -14,18 +14,12 @@ MAX_WIDGET_CONFIG_JSON_BYTES = 3072
|
||||||
|
|
||||||
WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({
|
WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({
|
||||||
"body_overview",
|
"body_overview",
|
||||||
"body_history_viz",
|
|
||||||
"nutrition_history_viz",
|
|
||||||
"fitness_history_viz",
|
|
||||||
"recovery_history_viz",
|
|
||||||
"history_overview_viz",
|
|
||||||
"activity_overview",
|
"activity_overview",
|
||||||
"kpi_board",
|
"kpi_board",
|
||||||
"quick_capture",
|
"quick_capture",
|
||||||
"trend_kcal_weight",
|
"trend_kcal_weight",
|
||||||
"nutrition_detail_charts",
|
"nutrition_detail_charts",
|
||||||
"recovery_charts_panel",
|
"recovery_charts_panel",
|
||||||
"report_export",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
_QUICK_CAPTURE_KEYS: frozenset[str] = frozenset({
|
_QUICK_CAPTURE_KEYS: frozenset[str] = frozenset({
|
||||||
|
|
@ -38,141 +32,6 @@ _QUICK_CAPTURE_KEYS: frozenset[str] = frozenset({
|
||||||
_KPI_TILE_FIXED: frozenset[str] = frozenset({"body_fat", "avg_kcal"})
|
_KPI_TILE_FIXED: frozenset[str] = frozenset({"body_fat", "avg_kcal"})
|
||||||
_KPI_REF_TILE_RE = re.compile(r"^ref:[a-z0-9_]{1,64}$")
|
_KPI_REF_TILE_RE = re.compile(r"^ref:[a-z0-9_]{1,64}$")
|
||||||
|
|
||||||
_BODY_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
|
|
||||||
"show_goals_strip",
|
|
||||||
"show_intro_blurb",
|
|
||||||
"show_layer_meta",
|
|
||||||
"show_kpis",
|
|
||||||
"show_weight_chart",
|
|
||||||
"show_body_fat_chart",
|
|
||||||
"show_proportion_chart",
|
|
||||||
"show_circumference_index_chart",
|
|
||||||
"show_circumference_lines_chart",
|
|
||||||
})
|
|
||||||
|
|
||||||
_BODY_HISTORY_VIZ_DEFAULTS: dict[str, Any] = {
|
|
||||||
"chart_days": 30,
|
|
||||||
"show_goals_strip": False,
|
|
||||||
"show_intro_blurb": False,
|
|
||||||
"show_layer_meta": False,
|
|
||||||
"show_kpis": True,
|
|
||||||
"kpi_detail": "compact",
|
|
||||||
"show_weight_chart": True,
|
|
||||||
"show_body_fat_chart": False,
|
|
||||||
"show_proportion_chart": False,
|
|
||||||
"show_circumference_index_chart": False,
|
|
||||||
"show_circumference_lines_chart": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
_NUTRITION_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
|
|
||||||
"show_goals_strip",
|
|
||||||
"show_intro_blurb",
|
|
||||||
"show_kpis",
|
|
||||||
"show_kcal_vs_weight",
|
|
||||||
"show_calorie_balance_chart",
|
|
||||||
"show_protein_lean_chart",
|
|
||||||
"show_heuristics",
|
|
||||||
"show_macro_daily_bars",
|
|
||||||
"show_macro_distribution_pair",
|
|
||||||
"show_energy_protein_charts",
|
|
||||||
})
|
|
||||||
|
|
||||||
_NUTRITION_HISTORY_VIZ_DEFAULTS: dict[str, Any] = {
|
|
||||||
"chart_days": 30,
|
|
||||||
"show_goals_strip": False,
|
|
||||||
"show_intro_blurb": False,
|
|
||||||
"show_kpis": True,
|
|
||||||
"kpi_detail": "compact",
|
|
||||||
"show_kcal_vs_weight": True,
|
|
||||||
"show_calorie_balance_chart": False,
|
|
||||||
"show_protein_lean_chart": False,
|
|
||||||
"show_heuristics": False,
|
|
||||||
"show_macro_daily_bars": True,
|
|
||||||
"show_macro_distribution_pair": True,
|
|
||||||
"show_energy_protein_charts": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
_FITNESS_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
|
|
||||||
"show_layer_meta",
|
|
||||||
"show_kpis",
|
|
||||||
"show_progress_insights",
|
|
||||||
"show_chart_training_volume",
|
|
||||||
"show_chart_training_type_distribution",
|
|
||||||
"show_chart_quality_sessions",
|
|
||||||
"show_chart_load_monitoring",
|
|
||||||
})
|
|
||||||
|
|
||||||
_FITNESS_HISTORY_VIZ_DEFAULTS: dict[str, Any] = {
|
|
||||||
"chart_days": 30,
|
|
||||||
"show_layer_meta": False,
|
|
||||||
"show_kpis": True,
|
|
||||||
"kpi_detail": "compact",
|
|
||||||
"show_progress_insights": False,
|
|
||||||
"show_chart_training_volume": True,
|
|
||||||
"show_chart_training_type_distribution": True,
|
|
||||||
"show_chart_quality_sessions": False,
|
|
||||||
"show_chart_load_monitoring": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
_RECOVERY_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
|
|
||||||
"show_layer_meta",
|
|
||||||
"show_kpis",
|
|
||||||
"show_progress_insights",
|
|
||||||
"show_sleep_section_heading",
|
|
||||||
"show_chart_recovery_score",
|
|
||||||
"show_chart_sleep_quality",
|
|
||||||
"show_chart_sleep_debt",
|
|
||||||
"show_heart_section_heading",
|
|
||||||
"show_heart_context_card",
|
|
||||||
"show_chart_hrv_rhr",
|
|
||||||
"show_vitals_extra_heading",
|
|
||||||
"show_vitals_extra_trends",
|
|
||||||
})
|
|
||||||
|
|
||||||
_RECOVERY_HISTORY_VIZ_DEFAULTS: dict[str, Any] = {
|
|
||||||
"chart_days": 30,
|
|
||||||
"show_layer_meta": False,
|
|
||||||
"show_kpis": True,
|
|
||||||
"kpi_detail": "compact",
|
|
||||||
"show_progress_insights": False,
|
|
||||||
"show_sleep_section_heading": True,
|
|
||||||
"show_chart_recovery_score": True,
|
|
||||||
"show_chart_sleep_quality": True,
|
|
||||||
"show_chart_sleep_debt": False,
|
|
||||||
"show_heart_section_heading": True,
|
|
||||||
"show_heart_context_card": False,
|
|
||||||
"show_chart_hrv_rhr": True,
|
|
||||||
"show_vitals_extra_heading": False,
|
|
||||||
"show_vitals_extra_trends": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
_HISTORY_OVERVIEW_VIZ_SECTION_KEYS: frozenset[str] = frozenset({
|
|
||||||
"show_section_body",
|
|
||||||
"show_section_nutrition",
|
|
||||||
"show_section_fitness",
|
|
||||||
"show_section_recovery",
|
|
||||||
})
|
|
||||||
|
|
||||||
_HISTORY_OVERVIEW_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
|
|
||||||
"show_confidence_banner",
|
|
||||||
"show_intro_blurb",
|
|
||||||
*_HISTORY_OVERVIEW_VIZ_SECTION_KEYS,
|
|
||||||
"show_correlation_c1_c3",
|
|
||||||
"show_drivers_c4",
|
|
||||||
})
|
|
||||||
|
|
||||||
_HISTORY_OVERVIEW_VIZ_DEFAULTS: dict[str, Any] = {
|
|
||||||
"chart_days": 30,
|
|
||||||
"show_confidence_banner": True,
|
|
||||||
"show_intro_blurb": True,
|
|
||||||
"show_section_body": True,
|
|
||||||
"show_section_nutrition": True,
|
|
||||||
"show_section_fitness": True,
|
|
||||||
"show_section_recovery": True,
|
|
||||||
"show_correlation_c1_c3": True,
|
|
||||||
"show_drivers_c4": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _config_json_size_bytes(config: dict[str, Any]) -> int:
|
def _config_json_size_bytes(config: dict[str, Any]) -> int:
|
||||||
return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8"))
|
return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8"))
|
||||||
|
|
@ -180,44 +39,19 @@ def _config_json_size_bytes(config: dict[str, Any]) -> int:
|
||||||
|
|
||||||
def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
|
def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
|
||||||
if raw is None:
|
if raw is None:
|
||||||
raw = {}
|
return {}
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
raise ValueError(f"Widget {widget_id}: config muss ein Objekt sein")
|
raise ValueError(f"Widget {widget_id}: config muss ein Objekt sein")
|
||||||
if _config_json_size_bytes(raw) > MAX_WIDGET_CONFIG_JSON_BYTES:
|
if _config_json_size_bytes(raw) > MAX_WIDGET_CONFIG_JSON_BYTES:
|
||||||
raise ValueError(f"Widget {widget_id}: config zu groß (max. {MAX_WIDGET_CONFIG_JSON_BYTES} Byte JSON)")
|
raise ValueError(f"Widget {widget_id}: config zu groß (max. {MAX_WIDGET_CONFIG_JSON_BYTES} Byte JSON)")
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
|
||||||
if widget_id not in WIDGETS_ALLOWING_CONFIG:
|
if widget_id not in WIDGETS_ALLOWING_CONFIG:
|
||||||
if raw:
|
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
|
||||||
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
if not raw:
|
|
||||||
if widget_id == "body_history_viz":
|
|
||||||
return _validate_body_history_viz_config({})
|
|
||||||
if widget_id == "nutrition_history_viz":
|
|
||||||
return _validate_nutrition_history_viz_config({})
|
|
||||||
if widget_id == "fitness_history_viz":
|
|
||||||
return _validate_fitness_history_viz_config({})
|
|
||||||
if widget_id == "recovery_history_viz":
|
|
||||||
return _validate_recovery_history_viz_config({})
|
|
||||||
if widget_id == "history_overview_viz":
|
|
||||||
return _validate_history_overview_viz_config({})
|
|
||||||
if widget_id == "report_export":
|
|
||||||
return _validate_report_export_config({})
|
|
||||||
return {}
|
|
||||||
|
|
||||||
if widget_id == "body_overview":
|
if widget_id == "body_overview":
|
||||||
return _validate_chart_days_only(raw, label="body_overview")
|
return _validate_chart_days_only(raw, label="body_overview")
|
||||||
if widget_id == "body_history_viz":
|
|
||||||
return _validate_body_history_viz_config(raw)
|
|
||||||
if widget_id == "nutrition_history_viz":
|
|
||||||
return _validate_nutrition_history_viz_config(raw)
|
|
||||||
if widget_id == "fitness_history_viz":
|
|
||||||
return _validate_fitness_history_viz_config(raw)
|
|
||||||
if widget_id == "recovery_history_viz":
|
|
||||||
return _validate_recovery_history_viz_config(raw)
|
|
||||||
if widget_id == "history_overview_viz":
|
|
||||||
return _validate_history_overview_viz_config(raw)
|
|
||||||
if widget_id == "activity_overview":
|
if widget_id == "activity_overview":
|
||||||
return _validate_chart_days_only(raw, label="activity_overview")
|
return _validate_chart_days_only(raw, label="activity_overview")
|
||||||
if widget_id == "kpi_board":
|
if widget_id == "kpi_board":
|
||||||
|
|
@ -230,8 +64,6 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
|
||||||
return _validate_chart_days_only(raw, label="nutrition_detail_charts")
|
return _validate_chart_days_only(raw, label="nutrition_detail_charts")
|
||||||
if widget_id == "recovery_charts_panel":
|
if widget_id == "recovery_charts_panel":
|
||||||
return _validate_chart_days_only(raw, label="recovery_charts_panel")
|
return _validate_chart_days_only(raw, label="recovery_charts_panel")
|
||||||
if widget_id == "report_export":
|
|
||||||
return _validate_report_export_config(raw)
|
|
||||||
|
|
||||||
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
|
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
|
||||||
|
|
||||||
|
|
@ -318,210 +150,6 @@ def _parse_chart_days(v: Any, label: str) -> int:
|
||||||
raise ValueError(f"{label}: chart_days muss ganze Zahl sein")
|
raise ValueError(f"{label}: chart_days muss ganze Zahl sein")
|
||||||
|
|
||||||
|
|
||||||
def _validate_body_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
label = "body_history_viz"
|
|
||||||
allowed = _BODY_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"})
|
|
||||||
unknown = set(raw) - allowed
|
|
||||||
if unknown:
|
|
||||||
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
|
|
||||||
out: dict[str, Any] = dict(_BODY_HISTORY_VIZ_DEFAULTS)
|
|
||||||
for k in _BODY_HISTORY_VIZ_BOOL_KEYS:
|
|
||||||
if k not in raw:
|
|
||||||
continue
|
|
||||||
v = raw[k]
|
|
||||||
if not isinstance(v, bool):
|
|
||||||
raise ValueError(f"{label}: {k} muss boolean sein")
|
|
||||||
out[k] = v
|
|
||||||
if "kpi_detail" in raw:
|
|
||||||
kd = raw["kpi_detail"]
|
|
||||||
if kd not in ("compact", "full"):
|
|
||||||
raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein")
|
|
||||||
out["kpi_detail"] = kd
|
|
||||||
if "chart_days" in raw:
|
|
||||||
v = _parse_chart_days(raw["chart_days"], label)
|
|
||||||
if v < 7 or v > 90:
|
|
||||||
raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen")
|
|
||||||
out["chart_days"] = v
|
|
||||||
if not out["show_kpis"] and not any(
|
|
||||||
out[k]
|
|
||||||
for k in (
|
|
||||||
"show_weight_chart",
|
|
||||||
"show_body_fat_chart",
|
|
||||||
"show_proportion_chart",
|
|
||||||
"show_circumference_index_chart",
|
|
||||||
"show_circumference_lines_chart",
|
|
||||||
)
|
|
||||||
):
|
|
||||||
raise ValueError(f"{label}: mindestens KPIs oder ein Chart muss sichtbar sein")
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_nutrition_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
label = "nutrition_history_viz"
|
|
||||||
allowed = _NUTRITION_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"})
|
|
||||||
unknown = set(raw) - allowed
|
|
||||||
if unknown:
|
|
||||||
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
|
|
||||||
out: dict[str, Any] = dict(_NUTRITION_HISTORY_VIZ_DEFAULTS)
|
|
||||||
for k in _NUTRITION_HISTORY_VIZ_BOOL_KEYS:
|
|
||||||
if k not in raw:
|
|
||||||
continue
|
|
||||||
v = raw[k]
|
|
||||||
if not isinstance(v, bool):
|
|
||||||
raise ValueError(f"{label}: {k} muss boolean sein")
|
|
||||||
out[k] = v
|
|
||||||
if "kpi_detail" in raw:
|
|
||||||
kd = raw["kpi_detail"]
|
|
||||||
if kd not in ("compact", "full"):
|
|
||||||
raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein")
|
|
||||||
out["kpi_detail"] = kd
|
|
||||||
if "chart_days" in raw:
|
|
||||||
v = _parse_chart_days(raw["chart_days"], label)
|
|
||||||
if v < 7 or v > 90:
|
|
||||||
raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen")
|
|
||||||
out["chart_days"] = v
|
|
||||||
if not out["show_kpis"] and not any(
|
|
||||||
out[k]
|
|
||||||
for k in (
|
|
||||||
"show_kcal_vs_weight",
|
|
||||||
"show_calorie_balance_chart",
|
|
||||||
"show_protein_lean_chart",
|
|
||||||
"show_heuristics",
|
|
||||||
"show_macro_daily_bars",
|
|
||||||
"show_macro_distribution_pair",
|
|
||||||
"show_energy_protein_charts",
|
|
||||||
)
|
|
||||||
):
|
|
||||||
raise ValueError(f"{label}: mindestens KPIs oder ein Chart-Bereich muss sichtbar sein")
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_fitness_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
label = "fitness_history_viz"
|
|
||||||
allowed = _FITNESS_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"})
|
|
||||||
unknown = set(raw) - allowed
|
|
||||||
if unknown:
|
|
||||||
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
|
|
||||||
out: dict[str, Any] = dict(_FITNESS_HISTORY_VIZ_DEFAULTS)
|
|
||||||
for k in _FITNESS_HISTORY_VIZ_BOOL_KEYS:
|
|
||||||
if k not in raw:
|
|
||||||
continue
|
|
||||||
v = raw[k]
|
|
||||||
if not isinstance(v, bool):
|
|
||||||
raise ValueError(f"{label}: {k} muss boolean sein")
|
|
||||||
out[k] = v
|
|
||||||
if "kpi_detail" in raw:
|
|
||||||
kd = raw["kpi_detail"]
|
|
||||||
if kd not in ("compact", "full"):
|
|
||||||
raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein")
|
|
||||||
out["kpi_detail"] = kd
|
|
||||||
if "chart_days" in raw:
|
|
||||||
v = _parse_chart_days(raw["chart_days"], label)
|
|
||||||
if v < 7 or v > 90:
|
|
||||||
raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen")
|
|
||||||
out["chart_days"] = v
|
|
||||||
if not out["show_kpis"] and not out["show_progress_insights"] and not any(
|
|
||||||
out[k]
|
|
||||||
for k in (
|
|
||||||
"show_chart_training_volume",
|
|
||||||
"show_chart_training_type_distribution",
|
|
||||||
"show_chart_quality_sessions",
|
|
||||||
"show_chart_load_monitoring",
|
|
||||||
)
|
|
||||||
):
|
|
||||||
raise ValueError(f"{label}: mindestens KPIs, Einschätzungen oder ein Chart muss sichtbar sein")
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_recovery_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
label = "recovery_history_viz"
|
|
||||||
allowed = _RECOVERY_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"})
|
|
||||||
unknown = set(raw) - allowed
|
|
||||||
if unknown:
|
|
||||||
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
|
|
||||||
out: dict[str, Any] = dict(_RECOVERY_HISTORY_VIZ_DEFAULTS)
|
|
||||||
for k in _RECOVERY_HISTORY_VIZ_BOOL_KEYS:
|
|
||||||
if k not in raw:
|
|
||||||
continue
|
|
||||||
v = raw[k]
|
|
||||||
if not isinstance(v, bool):
|
|
||||||
raise ValueError(f"{label}: {k} muss boolean sein")
|
|
||||||
out[k] = v
|
|
||||||
if "kpi_detail" in raw:
|
|
||||||
kd = raw["kpi_detail"]
|
|
||||||
if kd not in ("compact", "full"):
|
|
||||||
raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein")
|
|
||||||
out["kpi_detail"] = kd
|
|
||||||
if "chart_days" in raw:
|
|
||||||
v = _parse_chart_days(raw["chart_days"], label)
|
|
||||||
if v < 7 or v > 90:
|
|
||||||
raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen")
|
|
||||||
out["chart_days"] = v
|
|
||||||
if not out["show_kpis"] and not out["show_progress_insights"] and not out["show_heart_context_card"] and not out[
|
|
||||||
"show_vitals_extra_trends"
|
|
||||||
] and not any(
|
|
||||||
out[k]
|
|
||||||
for k in (
|
|
||||||
"show_chart_recovery_score",
|
|
||||||
"show_chart_sleep_quality",
|
|
||||||
"show_chart_sleep_debt",
|
|
||||||
"show_chart_hrv_rhr",
|
|
||||||
)
|
|
||||||
):
|
|
||||||
raise ValueError(f"{label}: mindestens KPIs, Überblick, Kontextkarte, Extra-Vitals oder ein Chart muss sichtbar sein")
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _migrate_history_overview_viz_raw(raw: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Alt: show_area_summaries → vier show_section_* (nur wo keine expliziten Section-Keys gesetzt)."""
|
|
||||||
r = dict(raw)
|
|
||||||
if "show_area_summaries" not in r:
|
|
||||||
return r
|
|
||||||
leg = r.pop("show_area_summaries")
|
|
||||||
if not isinstance(leg, bool):
|
|
||||||
raise ValueError("history_overview_viz: show_area_summaries muss boolean sein (veraltet — nutze show_section_*)")
|
|
||||||
for k in _HISTORY_OVERVIEW_VIZ_SECTION_KEYS:
|
|
||||||
if k not in r:
|
|
||||||
r[k] = leg
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_history_overview_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
label = "history_overview_viz"
|
|
||||||
raw_m = _migrate_history_overview_viz_raw(raw)
|
|
||||||
allowed = _HISTORY_OVERVIEW_VIZ_BOOL_KEYS | frozenset({"chart_days"})
|
|
||||||
unknown = set(raw_m) - allowed
|
|
||||||
if unknown:
|
|
||||||
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
|
|
||||||
out: dict[str, Any] = dict(_HISTORY_OVERVIEW_VIZ_DEFAULTS)
|
|
||||||
for k in _HISTORY_OVERVIEW_VIZ_BOOL_KEYS:
|
|
||||||
if k not in raw_m:
|
|
||||||
continue
|
|
||||||
v = raw_m[k]
|
|
||||||
if not isinstance(v, bool):
|
|
||||||
raise ValueError(f"{label}: {k} muss boolean sein")
|
|
||||||
out[k] = v
|
|
||||||
if "chart_days" in raw_m:
|
|
||||||
v = _parse_chart_days(raw_m["chart_days"], label)
|
|
||||||
if v < 7 or v > 90:
|
|
||||||
raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen")
|
|
||||||
out["chart_days"] = v
|
|
||||||
has_section = any(out[k] for k in _HISTORY_OVERVIEW_VIZ_SECTION_KEYS)
|
|
||||||
has_other = any(
|
|
||||||
out[k]
|
|
||||||
for k in (
|
|
||||||
"show_confidence_banner",
|
|
||||||
"show_correlation_c1_c3",
|
|
||||||
"show_drivers_c4",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if not has_section and not has_other:
|
|
||||||
raise ValueError(
|
|
||||||
f"{label}: mindestens eine Bereichs-Kachel, das Datenlage-Banner, Lag-Korrelationen (C1–C3) oder Treiber (C4) muss sichtbar sein"
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, Any]:
|
def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, Any]:
|
||||||
allowed = frozenset({"chart_days"})
|
allowed = frozenset({"chart_days"})
|
||||||
unknown = set(raw) - allowed
|
unknown = set(raw) - allowed
|
||||||
|
|
@ -535,43 +163,3 @@ def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, A
|
||||||
return {"chart_days": v}
|
return {"chart_days": v}
|
||||||
|
|
||||||
|
|
||||||
def _validate_report_export_config(raw: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
label = "report_export"
|
|
||||||
allowed = frozenset({"document_title", "subtitle", "capture_scale"})
|
|
||||||
unknown = set(raw) - allowed
|
|
||||||
if unknown:
|
|
||||||
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
|
|
||||||
out: dict[str, Any] = {"capture_scale": 2}
|
|
||||||
if "document_title" in raw:
|
|
||||||
t = raw["document_title"]
|
|
||||||
if t is not None and not isinstance(t, str):
|
|
||||||
raise ValueError(f"{label}: document_title muss Text sein")
|
|
||||||
s = (t or "").strip()
|
|
||||||
if len(s) > 120:
|
|
||||||
raise ValueError(f"{label}: document_title max. 120 Zeichen")
|
|
||||||
if s:
|
|
||||||
out["document_title"] = s
|
|
||||||
if "subtitle" in raw:
|
|
||||||
t = raw["subtitle"]
|
|
||||||
if t is not None and not isinstance(t, str):
|
|
||||||
raise ValueError(f"{label}: subtitle muss Text sein")
|
|
||||||
s = (t or "").strip()
|
|
||||||
if len(s) > 240:
|
|
||||||
raise ValueError(f"{label}: subtitle max. 240 Zeichen")
|
|
||||||
if s:
|
|
||||||
out["subtitle"] = s
|
|
||||||
if "capture_scale" in raw:
|
|
||||||
v = raw["capture_scale"]
|
|
||||||
if isinstance(v, bool) or isinstance(v, float):
|
|
||||||
if isinstance(v, float) and math.isfinite(v) and abs(v - round(v)) < 1e-9:
|
|
||||||
v = int(round(v))
|
|
||||||
else:
|
|
||||||
raise ValueError(f"{label}: capture_scale muss ganze Zahl 1–3 sein")
|
|
||||||
if not isinstance(v, int):
|
|
||||||
raise ValueError(f"{label}: capture_scale muss ganze Zahl 1–3 sein")
|
|
||||||
if v < 1 or v > 3:
|
|
||||||
raise ValueError(f"{label}: capture_scale muss zwischen 1 und 3 liegen")
|
|
||||||
out["capture_scale"] = v
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,6 @@ __all__ = [
|
||||||
'calculate_hip_28d_delta',
|
'calculate_hip_28d_delta',
|
||||||
'calculate_chest_28d_delta',
|
'calculate_chest_28d_delta',
|
||||||
'calculate_arm_28d_delta',
|
'calculate_arm_28d_delta',
|
||||||
'calculate_arm_relaxed_28d_delta',
|
|
||||||
'calculate_thigh_28d_delta',
|
'calculate_thigh_28d_delta',
|
||||||
'calculate_waist_hip_ratio',
|
'calculate_waist_hip_ratio',
|
||||||
'calculate_recomposition_quadrant',
|
'calculate_recomposition_quadrant',
|
||||||
|
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
"""
|
|
||||||
Kanonische Aufteilung activity_log vs. EAV für Aktivitätssessions.
|
|
||||||
|
|
||||||
- **Kern / Mapping-Ziele für activity_log:** ausschließlich die Keys aus
|
|
||||||
``csv_parser.module_registry.MODULE_DEFINITIONS["activity"].fields`` (keine zweite hartcodierte Liste).
|
|
||||||
- **Alle anderen Attribute:** ``training_parameters`` + Attributprofil (Kategorie/Typ) → EAV;
|
|
||||||
Lesefallback für bekannte Legacy-Spalten siehe unten.
|
|
||||||
|
|
||||||
Normative Doku: .claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md,
|
|
||||||
ACTIVITY_SCALAR_KANON_TABLE.md
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Dict, Final
|
|
||||||
|
|
||||||
from csv_parser.module_registry import get_module_definition
|
|
||||||
|
|
||||||
|
|
||||||
def get_activity_module_registry_field_keys() -> frozenset[str]:
|
|
||||||
"""Keys des Universal-CSV-Moduls ``activity`` (= feste activity_log-Kernfelder / Mapping-Ziele)."""
|
|
||||||
mod = get_module_definition("activity")
|
|
||||||
if not mod:
|
|
||||||
return frozenset()
|
|
||||||
return frozenset((mod.get("fields") or {}).keys())
|
|
||||||
|
|
||||||
|
|
||||||
# Gleiche Menge wie ``MODULE_DEFINITIONS["activity"].fields`` — zur Laufzeit aus der Registry abgeleitet.
|
|
||||||
ACTIVITY_MODULE_REGISTRY_FIELD_KEYS: Final[frozenset[str]] = get_activity_module_registry_field_keys()
|
|
||||||
|
|
||||||
# Teil-UPDATEs (Import): alle Kernfelder außer ``date`` (Identität / Duplikat-Key).
|
|
||||||
ACTIVITY_LOG_PATCHABLE_COLUMNS: Final[frozenset[str]] = ACTIVITY_MODULE_REGISTRY_FIELD_KEYS - {"date"}
|
|
||||||
|
|
||||||
# Parameter-Keys (training_parameters.key), die primär in EAV geführt werden; source_field nach Migration 057 NULL.
|
|
||||||
# Lesen (Merge): activity_log-Legacy-Spalte schlägt EAV, wenn beide befüllt; sonst EAV.
|
|
||||||
ACTIVITY_EAV_PRIMARY_PARAMETER_KEYS: Final[frozenset[str]] = frozenset(
|
|
||||||
{
|
|
||||||
"min_hr",
|
|
||||||
"pace_min_per_km",
|
|
||||||
"cadence",
|
|
||||||
"avg_power",
|
|
||||||
"elevation_gain",
|
|
||||||
"temperature_celsius",
|
|
||||||
"humidity_percent",
|
|
||||||
"avg_hr_percent",
|
|
||||||
"kcal_per_km",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Spaltenname activity_log für Legacy-Merge (Vorrang vor EAV bei gesetztem Spaltenwert).
|
|
||||||
ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM: Final[Dict[str, str]] = {
|
|
||||||
"min_hr": "hr_min",
|
|
||||||
"pace_min_per_km": "pace_min_per_km",
|
|
||||||
"cadence": "cadence",
|
|
||||||
"avg_power": "avg_power",
|
|
||||||
"elevation_gain": "elevation_gain",
|
|
||||||
"temperature_celsius": "temperature_celsius",
|
|
||||||
"humidity_percent": "humidity_percent",
|
|
||||||
"avg_hr_percent": "avg_hr_percent",
|
|
||||||
"kcal_per_km": "kcal_per_km",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -10,7 +10,6 @@ Functions:
|
||||||
- get_training_frequency_by_type_data(): Häufigkeit & Intensität pro activity_type
|
- 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_inter_session_gap_data(): Pausen zwischen Einheiten (Stunden)
|
||||||
- get_training_sessions_recent_weeks_data(): Wochen-JSON für KI-Kontext
|
- get_training_sessions_recent_weeks_data(): Wochen-JSON für KI-Kontext
|
||||||
- get_training_parameters_ki_glossary_data(): Parameter-Katalog (Feld, Namen, Beschreibungen) für KI
|
|
||||||
|
|
||||||
All functions return structured data (dict) without formatting.
|
All functions return structured data (dict) without formatting.
|
||||||
Use placeholder_resolver.py for formatted strings for AI.
|
Use placeholder_resolver.py for formatted strings for AI.
|
||||||
|
|
@ -23,12 +22,7 @@ from typing import Dict, List, Optional, Any
|
||||||
from datetime import datetime, timedelta, date, time
|
from datetime import datetime, timedelta, date, time
|
||||||
import statistics
|
import statistics
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
from data_layer.activity_session_metrics import enrich_sessions_with_metrics
|
|
||||||
from data_layer.utils import calculate_confidence, safe_float, safe_int, serialize_dates
|
from data_layer.utils import calculate_confidence, safe_float, safe_int, serialize_dates
|
||||||
from data_layer.prompt_output_compact import (
|
|
||||||
normalize_prompt_number,
|
|
||||||
session_metrics_list_to_key_value_compact,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_activity_summary_data(
|
def get_activity_summary_data(
|
||||||
|
|
@ -129,8 +123,7 @@ def get_activity_detail_data(
|
||||||
"duration_min": int,
|
"duration_min": int,
|
||||||
"kcal_active": int,
|
"kcal_active": int,
|
||||||
"hr_avg": int | None,
|
"hr_avg": int | None,
|
||||||
"training_category": str | None,
|
"training_category": str | None
|
||||||
"session_metrics": list | None, # EAV (enrich_sessions_with_metrics)
|
|
||||||
},
|
},
|
||||||
...
|
...
|
||||||
],
|
],
|
||||||
|
|
@ -149,7 +142,6 @@ def get_activity_detail_data(
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""SELECT
|
"""SELECT
|
||||||
id,
|
|
||||||
date,
|
date,
|
||||||
activity_type,
|
activity_type,
|
||||||
duration_min,
|
duration_min,
|
||||||
|
|
@ -160,7 +152,7 @@ def get_activity_detail_data(
|
||||||
WHERE profile_id=%s AND date >= %s
|
WHERE profile_id=%s AND date >= %s
|
||||||
ORDER BY date DESC
|
ORDER BY date DESC
|
||||||
LIMIT %s""",
|
LIMIT %s""",
|
||||||
(profile_id, cutoff, limit),
|
(profile_id, cutoff, limit)
|
||||||
)
|
)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
|
@ -169,24 +161,19 @@ def get_activity_detail_data(
|
||||||
"activities": [],
|
"activities": [],
|
||||||
"total_count": 0,
|
"total_count": 0,
|
||||||
"confidence": "insufficient",
|
"confidence": "insufficient",
|
||||||
"days_analyzed": days,
|
"days_analyzed": days
|
||||||
}
|
}
|
||||||
|
|
||||||
activities = []
|
activities = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
activities.append(
|
activities.append({
|
||||||
{
|
"date": row['date'],
|
||||||
"id": str(row["id"]),
|
"activity_type": row['activity_type'],
|
||||||
"date": row["date"],
|
"duration_min": safe_int(row['duration_min']),
|
||||||
"activity_type": row["activity_type"],
|
"kcal_active": safe_int(row['kcal_active']),
|
||||||
"duration_min": safe_int(row["duration_min"]),
|
"hr_avg": safe_int(row['hr_avg']) if row.get('hr_avg') else None,
|
||||||
"kcal_active": safe_int(row["kcal_active"]),
|
"training_category": row.get('training_category')
|
||||||
"hr_avg": safe_int(row["hr_avg"]) if row.get("hr_avg") else None,
|
})
|
||||||
"training_category": row.get("training_category"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
enrich_sessions_with_metrics(cur, activities)
|
|
||||||
|
|
||||||
confidence = calculate_confidence(len(activities), days, "general")
|
confidence = calculate_confidence(len(activities), days, "general")
|
||||||
|
|
||||||
|
|
@ -194,7 +181,7 @@ def get_activity_detail_data(
|
||||||
"activities": activities,
|
"activities": activities,
|
||||||
"total_count": len(activities),
|
"total_count": len(activities),
|
||||||
"confidence": confidence,
|
"confidence": confidence,
|
||||||
"days_analyzed": days,
|
"days_analyzed": days
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -330,30 +317,24 @@ def calculate_training_frequency_7d(profile_id: str) -> Optional[int]:
|
||||||
return int(row['session_count']) if row else None
|
return int(row['session_count']) if row else None
|
||||||
|
|
||||||
|
|
||||||
def calculate_quality_sessions_pct(profile_id: str, days: int = 28) -> Optional[int]:
|
def calculate_quality_sessions_pct(profile_id: str) -> Optional[int]:
|
||||||
"""Anteil qualitativ guter Sessions (quality_label) im Zeitfenster ``days``."""
|
"""Calculate percentage of quality sessions (good or better) last 28 days"""
|
||||||
if days < 1:
|
|
||||||
days = 28
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute(
|
cur.execute("""
|
||||||
"""
|
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) as total,
|
COUNT(*) as total,
|
||||||
COUNT(*) FILTER (WHERE quality_label IN ('excellent', 'very_good', 'good')) as quality_count
|
COUNT(*) FILTER (WHERE quality_label IN ('excellent', 'very_good', 'good')) as quality_count
|
||||||
FROM activity_log
|
FROM activity_log
|
||||||
WHERE profile_id = %s
|
WHERE profile_id = %s
|
||||||
AND date >= %s
|
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
||||||
""",
|
""", (profile_id,))
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if not row or row["total"] == 0:
|
if not row or row['total'] == 0:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
pct = (row["quality_count"] / row["total"]) * 100
|
pct = (row['quality_count'] / row['total']) * 100
|
||||||
return int(pct)
|
return int(pct)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -501,12 +482,11 @@ def calculate_ability_balance_mobility(profile_id: str) -> Optional[int]:
|
||||||
# A5: Load Monitoring (Proxy-based)
|
# A5: Load Monitoring (Proxy-based)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
def calculate_proxy_internal_load_window(profile_id: str, days: int = 7) -> Optional[float]:
|
def calculate_proxy_internal_load_7d(profile_id: str) -> Optional[int]:
|
||||||
"""
|
"""
|
||||||
Proxy-Last über die letzten ``days`` Kalendertage (gleiche Formel wie bisher nur für 7 Tage).
|
Calculate proxy internal load (last 7 days)
|
||||||
|
Formula: duration × intensity_factor × quality_factor
|
||||||
"""
|
"""
|
||||||
if days < 1:
|
|
||||||
days = 7
|
|
||||||
intensity_factors = {'low': 1.0, 'moderate': 1.5, 'high': 2.0}
|
intensity_factors = {'low': 1.0, 'moderate': 1.5, 'high': 2.0}
|
||||||
quality_factors = {
|
quality_factors = {
|
||||||
'excellent': 1.15,
|
'excellent': 1.15,
|
||||||
|
|
@ -519,15 +499,12 @@ def calculate_proxy_internal_load_window(profile_id: str, days: int = 7) -> Opti
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute(
|
cur.execute("""
|
||||||
"""
|
|
||||||
SELECT duration_min, hr_avg, rpe
|
SELECT duration_min, hr_avg, rpe
|
||||||
FROM activity_log
|
FROM activity_log
|
||||||
WHERE profile_id = %s
|
WHERE profile_id = %s
|
||||||
AND date >= CURRENT_DATE - (%s::int * INTERVAL '1 day')
|
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
||||||
""",
|
""", (profile_id,))
|
||||||
(profile_id, days),
|
|
||||||
)
|
|
||||||
|
|
||||||
activities = cur.fetchall()
|
activities = cur.fetchall()
|
||||||
|
|
||||||
|
|
@ -564,12 +541,7 @@ def calculate_proxy_internal_load_window(profile_id: str, days: int = 7) -> Opti
|
||||||
load = float(duration) * intensity_factors[intensity] * quality_factors.get(quality, 1.0)
|
load = float(duration) * intensity_factors[intensity] * quality_factors.get(quality, 1.0)
|
||||||
total_load += load
|
total_load += load
|
||||||
|
|
||||||
return float(total_load)
|
return int(total_load)
|
||||||
|
|
||||||
|
|
||||||
def calculate_proxy_internal_load_7d(profile_id: str) -> Optional[float]:
|
|
||||||
"""Letzte 7 Tage — Kompatibilität mit Platzhaltern / älteren Aufrufern."""
|
|
||||||
return calculate_proxy_internal_load_window(profile_id, 7)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_monotony_score(profile_id: str) -> Optional[float]:
|
def calculate_monotony_score(profile_id: str) -> Optional[float]:
|
||||||
|
|
@ -1113,10 +1085,6 @@ def get_training_sessions_recent_weeks_data(
|
||||||
Letzte Wochen mit Einzeltrainings für KI-Kontext (Dauer, kcal, HF, Typ).
|
Letzte Wochen mit Einzeltrainings für KI-Kontext (Dauer, kcal, HF, Typ).
|
||||||
|
|
||||||
weeks: Anzahl zurückliegender ISO-Kalenderwochen (Default 4).
|
weeks: Anzahl zurückliegender ISO-Kalenderwochen (Default 4).
|
||||||
|
|
||||||
session_metrics pro Einheit: kompaktes Objekt ``{key: Wert}`` (keine wiederholten
|
|
||||||
Namen/Beschreibungen). Bedeutung der Keys: Platzhalter ``{{training_parameters_glossary_md}}``.
|
|
||||||
Zahlen werden für Prompt-Token kompakt gerundet.
|
|
||||||
"""
|
"""
|
||||||
days = max(weeks * 7, 7)
|
days = max(weeks * 7, 7)
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
|
|
@ -1144,7 +1112,6 @@ def get_training_sessions_recent_weeks_data(
|
||||||
(profile_id, cutoff),
|
(profile_id, cutoff),
|
||||||
)
|
)
|
||||||
rows = [r2d(r) for r in cur.fetchall()]
|
rows = [r2d(r) for r in cur.fetchall()]
|
||||||
enrich_sessions_with_metrics(cur, rows)
|
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
return {
|
return {
|
||||||
|
|
@ -1154,8 +1121,6 @@ def get_training_sessions_recent_weeks_data(
|
||||||
"days_loaded": days,
|
"days_loaded": days,
|
||||||
"session_count": 0,
|
"session_count": 0,
|
||||||
"confidence": "insufficient",
|
"confidence": "insufficient",
|
||||||
"session_metrics_shape": "key_value",
|
|
||||||
"metric_semantics_placeholder": "{{training_parameters_glossary_md}}",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1174,21 +1139,18 @@ def get_training_sessions_recent_weeks_data(
|
||||||
kcal_f = float(kcal) if kcal is not None else None
|
kcal_f = float(kcal) if kcal is not None else None
|
||||||
hr_a = r.get("hr_avg")
|
hr_a = r.get("hr_avg")
|
||||||
hr_m = r.get("hr_max")
|
hr_m = r.get("hr_max")
|
||||||
sm_compact = session_metrics_list_to_key_value_compact(r.get("session_metrics"))
|
|
||||||
by_week[wk].append(
|
by_week[wk].append(
|
||||||
{
|
{
|
||||||
"id": str(r["id"]),
|
|
||||||
"date": d,
|
"date": d,
|
||||||
"start_time": str(r["start_time"]) if r.get("start_time") is not None else None,
|
"start_time": str(r["start_time"]) if r.get("start_time") is not None else None,
|
||||||
"activity_type": r.get("activity_type"),
|
"activity_type": r.get("activity_type"),
|
||||||
"training_category": r.get("training_category"),
|
"training_category": r.get("training_category"),
|
||||||
"training_type_name": r.get("training_type_name"),
|
"training_type_name": r.get("training_type_name"),
|
||||||
"duration_min": normalize_prompt_number(dur_f) if dur_f is not None else None,
|
"duration_min": dur_f,
|
||||||
"kcal_active": normalize_prompt_number(kcal_f) if kcal_f is not None else None,
|
"kcal_active": kcal_f,
|
||||||
"hr_avg": int(hr_a) if hr_a is not None else None,
|
"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,
|
"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,
|
"rpe": int(r["rpe"]) if r.get("rpe") is not None else None,
|
||||||
"session_metrics": sm_compact,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1203,335 +1165,6 @@ def get_training_sessions_recent_weeks_data(
|
||||||
"days_loaded": days,
|
"days_loaded": days,
|
||||||
"session_count": len(rows),
|
"session_count": len(rows),
|
||||||
"confidence": confidence,
|
"confidence": confidence,
|
||||||
"session_metrics_shape": "key_value",
|
|
||||||
"metric_semantics_placeholder": "{{training_parameters_glossary_md}}",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_training_parameters_ki_glossary_data(profile_id: str) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Alle aktiven ``training_parameters`` für KI-Kontext (z. B. neben ``training_sessions_recent_json``).
|
|
||||||
|
|
||||||
Enthält technischen key, name_de/name_en, description_de/description_en, data_type, unit, category.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_id: Reserviert für spätere Einschränkung (z. B. nur im Profil vorkommende Keys);
|
|
||||||
aktuell ungenutzt, Signatur bleibt für Platzhalter-Resolver.
|
|
||||||
"""
|
|
||||||
_ = profile_id
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT key, name_de, name_en, description_de, description_en,
|
|
||||||
data_type, unit, category
|
|
||||||
FROM training_parameters
|
|
||||||
WHERE is_active = true
|
|
||||||
ORDER BY category, key
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
rows = [r2d(r) for r in cur.fetchall()]
|
|
||||||
return {
|
|
||||||
"parameters": rows,
|
|
||||||
"meta": {"count": len(rows), "scope": "global_active_catalog"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Chart payloads (Phase 0c / Layer 1) — gemeinsam mit charts-Router und Layer-2b-Bundles
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def build_training_volume_chart_payload(profile_id: str, weeks: int) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Wöchentliches Trainingsvolumen (Minuten) — gleiche Logik wie GET /api/charts/training-volume.
|
|
||||||
"""
|
|
||||||
if weeks < 4:
|
|
||||||
weeks = 4
|
|
||||||
if weeks > 52:
|
|
||||||
weeks = 52
|
|
||||||
|
|
||||||
cutoff = (datetime.now() - timedelta(weeks=weeks)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT
|
|
||||||
DATE_TRUNC('week', date) as week_start,
|
|
||||||
SUM(duration_min) as total_minutes,
|
|
||||||
COUNT(*) as session_count
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id=%s AND date >= %s
|
|
||||||
GROUP BY week_start
|
|
||||||
ORDER BY week_start""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
|
|
||||||
if not rows:
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Keine Aktivitätsdaten vorhanden",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
labels = [row["week_start"].strftime("KW %V") for row in rows]
|
|
||||||
values = [safe_float(row["total_minutes"]) for row in rows]
|
|
||||||
|
|
||||||
confidence = calculate_confidence(len(rows), weeks * 7, "general")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {
|
|
||||||
"labels": labels,
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "Trainingsminuten",
|
|
||||||
"data": values,
|
|
||||||
"backgroundColor": "#1D9E75",
|
|
||||||
"borderColor": "#085041",
|
|
||||||
"borderWidth": 1,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"metadata": serialize_dates(
|
|
||||||
{
|
|
||||||
"confidence": confidence,
|
|
||||||
"data_points": len(rows),
|
|
||||||
"avg_minutes_week": round(sum(values) / len(values), 1) if values else 0,
|
|
||||||
"total_sessions": sum(row["session_count"] for row in rows),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_training_type_distribution_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Trainingstyp-Verteilung — gleiche Logik wie GET /api/charts/training-type-distribution.
|
|
||||||
"""
|
|
||||||
dist_data = get_training_type_distribution_data(profile_id, days)
|
|
||||||
|
|
||||||
if dist_data["confidence"] == "insufficient":
|
|
||||||
return {
|
|
||||||
"chart_type": "pie",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Keine Trainingstypen-Daten",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
labels = [item["category"] for item in dist_data["distribution"]]
|
|
||||||
values = [item["count"] for item in dist_data["distribution"]]
|
|
||||||
|
|
||||||
colors = [
|
|
||||||
"#1D9E75",
|
|
||||||
"#3B82F6",
|
|
||||||
"#F59E0B",
|
|
||||||
"#EF4444",
|
|
||||||
"#8B5CF6",
|
|
||||||
"#10B981",
|
|
||||||
"#F97316",
|
|
||||||
"#06B6D4",
|
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "pie",
|
|
||||||
"data": {
|
|
||||||
"labels": labels,
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"data": values,
|
|
||||||
"backgroundColor": colors[: len(values)],
|
|
||||||
"borderWidth": 2,
|
|
||||||
"borderColor": "#fff",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": dist_data["confidence"],
|
|
||||||
"total_sessions": dist_data["total_sessions"],
|
|
||||||
"categorized_sessions": dist_data["categorized_sessions"],
|
|
||||||
"uncategorized_sessions": dist_data["uncategorized_sessions"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_training_volume_two_week_delta(profile_id: str) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Trainingsminuten: letzte 7 Kalendertage vs. die 7 Tage davor (Fortschritt Volumen).
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
COALESCE(SUM(duration_min) FILTER (WHERE date >= CURRENT_DATE - INTERVAL '7 days'), 0)::bigint AS last7,
|
|
||||||
COALESCE(SUM(duration_min) FILTER (
|
|
||||||
WHERE date < CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '14 days'), 0)::bigint AS prev7
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '14 days'
|
|
||||||
""",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return {"last7_min": 0, "prior7_min": 0, "delta_pct": None, "has_data": False}
|
|
||||||
last7 = int(row["last7"] or 0)
|
|
||||||
prev7 = int(row["prev7"] or 0)
|
|
||||||
if last7 == 0 and prev7 == 0:
|
|
||||||
return {"last7_min": 0, "prior7_min": 0, "delta_pct": None, "has_data": False}
|
|
||||||
delta_pct: Optional[float] = None
|
|
||||||
if prev7 > 0:
|
|
||||||
delta_pct = round((last7 - prev7) / float(prev7) * 100.0, 1)
|
|
||||||
return {
|
|
||||||
"last7_min": last7,
|
|
||||||
"prior7_min": prev7,
|
|
||||||
"delta_pct": delta_pct,
|
|
||||||
"has_data": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_quality_sessions_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
"""Qualitäts-Sessions vs. regulär — gleiche Logik wie GET /api/charts/quality-sessions."""
|
|
||||||
if days < 7:
|
|
||||||
days = 7
|
|
||||||
if days > 90:
|
|
||||||
days = 90
|
|
||||||
quality_pct = calculate_quality_sessions_pct(profile_id, days)
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT COUNT(*) as total
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id=%s AND date >= %s""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
total_sessions = row["total"] if row else 0
|
|
||||||
|
|
||||||
if total_sessions == 0:
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Keine Aktivitätsdaten",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
q = float(quality_pct or 0)
|
|
||||||
quality_count = int(round(q / 100.0 * total_sessions))
|
|
||||||
quality_count = max(0, min(quality_count, total_sessions))
|
|
||||||
regular_count = total_sessions - quality_count
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {
|
|
||||||
"labels": ["Qualitäts-Sessions", "Reguläre Sessions"],
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "Anzahl",
|
|
||||||
"data": [quality_count, regular_count],
|
|
||||||
"backgroundColor": ["#1D9E75", "#888"],
|
|
||||||
"borderColor": "#085041",
|
|
||||||
"borderWidth": 1,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": calculate_confidence(total_sessions, days, "general"),
|
|
||||||
"data_points": total_sessions,
|
|
||||||
"quality_pct": round(q, 1),
|
|
||||||
"quality_count": quality_count,
|
|
||||||
"regular_count": regular_count,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_load_monitoring_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
"""Tages-Load-Zeitreihe + ACWR — gleiche Logik wie GET /api/charts/load-monitoring."""
|
|
||||||
if days < 14:
|
|
||||||
days = 14
|
|
||||||
if days > 90:
|
|
||||||
days = 90
|
|
||||||
|
|
||||||
acute_load = calculate_proxy_internal_load_window(profile_id, 7)
|
|
||||||
chronic_load = calculate_proxy_internal_load_window(profile_id, 28)
|
|
||||||
|
|
||||||
acwr = (
|
|
||||||
(acute_load / chronic_load) if acute_load is not None and chronic_load and chronic_load > 0 else 0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT
|
|
||||||
date,
|
|
||||||
SUM(duration_min * COALESCE(rpe, 5)) as daily_load
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id=%s AND date >= %s
|
|
||||||
GROUP BY date
|
|
||||||
ORDER BY date""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
|
|
||||||
if not rows:
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Keine Load-Daten",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
labels = [row["date"].isoformat() for row in rows]
|
|
||||||
values = [safe_float(row["daily_load"]) for row in rows]
|
|
||||||
|
|
||||||
al = float(acute_load) if acute_load is not None else 0.0
|
|
||||||
cl = float(chronic_load) if chronic_load is not None else 0.0
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {
|
|
||||||
"labels": labels,
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "Tages-Load",
|
|
||||||
"data": values,
|
|
||||||
"borderColor": "#1D9E75",
|
|
||||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"tension": 0.3,
|
|
||||||
"fill": True,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"metadata": serialize_dates(
|
|
||||||
{
|
|
||||||
"confidence": calculate_confidence(len(rows), days, "general"),
|
|
||||||
"data_points": len(rows),
|
|
||||||
"acute_load_7d": round(al, 1),
|
|
||||||
"chronic_load_28d": round(cl, 1),
|
|
||||||
"acwr": round(acwr, 2),
|
|
||||||
"acwr_status": "optimal" if 0.8 <= acwr <= 1.3 else "suboptimal",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,406 +0,0 @@
|
||||||
"""
|
|
||||||
Zentrale Persistenz für activity_log + EAV-Nebenwirkungen (Eval).
|
|
||||||
|
|
||||||
Alle Schreibpfade (REST, Universal-CSV, Legacy-Upload) laufen hier zusammen.
|
|
||||||
|
|
||||||
Feld-Katalog für CSV-Mappings: get_mappable_activity_field_catalog()
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import datetime as dt
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
from typing import Any, Dict, List, Mapping, Optional
|
|
||||||
|
|
||||||
from models import ActivityEntry
|
|
||||||
|
|
||||||
from csv_parser.module_registry import get_module_definition
|
|
||||||
from data_layer.activity_data_canon import get_activity_module_registry_field_keys
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
try:
|
|
||||||
from evaluation_helper import evaluate_and_save_activity as _evaluate_and_save_activity
|
|
||||||
|
|
||||||
_EVALUATION_AVAILABLE = True
|
|
||||||
except Exception: # pragma: no cover
|
|
||||||
_evaluate_and_save_activity = None
|
|
||||||
_EVALUATION_AVAILABLE = False
|
|
||||||
|
|
||||||
|
|
||||||
def find_activity_duplicate_id(
|
|
||||||
cur,
|
|
||||||
profile_id: str,
|
|
||||||
date_iso: str,
|
|
||||||
start_time: Optional[Any],
|
|
||||||
) -> Optional[str]:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT id FROM activity_log
|
|
||||||
WHERE profile_id = %s AND date = %s::date
|
|
||||||
AND start_time IS NOT DISTINCT FROM %s::time
|
|
||||||
""",
|
|
||||||
(profile_id, date_iso, start_time),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
return str(row["id"]) if row else None
|
|
||||||
|
|
||||||
|
|
||||||
# Datum/Start/Ende/Typ setzt der CSV-Executor explizit (Normalisierung); nicht aus diesem Patch überschreiben.
|
|
||||||
_ACTIVITY_CSV_REGISTRY_EXCLUDE = frozenset({"date", "start_time", "end_time", "activity_type"})
|
|
||||||
|
|
||||||
|
|
||||||
def activity_registry_field_keys() -> frozenset[str]:
|
|
||||||
"""Gleiche Menge wie ``ACTIVITY_MODULE_REGISTRY_FIELD_KEYS`` (Registry als Single Source)."""
|
|
||||||
return get_activity_module_registry_field_keys()
|
|
||||||
|
|
||||||
|
|
||||||
def activity_csv_registry_updates_from_mapped(mapped: Mapping[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
activity_log-Updates nur aus Modul-Registry-Feldern (Kernspalten).
|
|
||||||
Trainingsparameter-Keys (nur in training_parameters) laufen über EAV, nicht hier.
|
|
||||||
"""
|
|
||||||
mod = get_module_definition("activity")
|
|
||||||
if not mod:
|
|
||||||
return {}
|
|
||||||
fields = mod.get("fields") or {}
|
|
||||||
out: Dict[str, Any] = {}
|
|
||||||
|
|
||||||
def _sf(v: Any) -> float | None:
|
|
||||||
try:
|
|
||||||
if v is None or (isinstance(v, str) and not str(v).strip()):
|
|
||||||
return None
|
|
||||||
return round(float(v), 1)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _si(v: Any) -> int | None:
|
|
||||||
try:
|
|
||||||
if v is None or (isinstance(v, str) and not str(v).strip()):
|
|
||||||
return None
|
|
||||||
return int(round(float(v)))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _hr(v: Any) -> float | None:
|
|
||||||
x = _sf(v)
|
|
||||||
if x is None or x < 20 or x > 280:
|
|
||||||
return None
|
|
||||||
return x
|
|
||||||
|
|
||||||
for key, spec in fields.items():
|
|
||||||
if key in _ACTIVITY_CSV_REGISTRY_EXCLUDE:
|
|
||||||
continue
|
|
||||||
if key not in mapped:
|
|
||||||
continue
|
|
||||||
raw = mapped[key]
|
|
||||||
if raw is None or raw == "":
|
|
||||||
continue
|
|
||||||
if isinstance(raw, str) and not raw.strip():
|
|
||||||
continue
|
|
||||||
typ = spec.get("type", "string")
|
|
||||||
if typ == "float":
|
|
||||||
v = _hr(raw) if key in ("hr_avg", "hr_max") else _sf(raw)
|
|
||||||
if v is not None:
|
|
||||||
out[key] = v
|
|
||||||
elif typ == "int":
|
|
||||||
v = _si(raw)
|
|
||||||
if v is not None:
|
|
||||||
out[key] = v
|
|
||||||
elif typ == "datetime":
|
|
||||||
if isinstance(raw, dt.datetime):
|
|
||||||
out[key] = raw.strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
elif isinstance(raw, dt.date):
|
|
||||||
out[key] = f"{raw.isoformat()} 00:00:00"
|
|
||||||
elif isinstance(raw, str) and raw.strip():
|
|
||||||
out[key] = raw.strip()
|
|
||||||
elif typ == "date":
|
|
||||||
if isinstance(raw, dt.date):
|
|
||||||
out[key] = raw.isoformat()
|
|
||||||
elif isinstance(raw, dt.datetime):
|
|
||||||
out[key] = raw.date().isoformat()
|
|
||||||
elif isinstance(raw, str) and raw.strip():
|
|
||||||
out[key] = raw.strip()
|
|
||||||
else:
|
|
||||||
out[key] = str(raw).strip()
|
|
||||||
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def insert_activity_from_entry(cur, profile_id: str, eid: str, e: ActivityEntry) -> None:
|
|
||||||
"""INSERT activity_log aus ActivityEntry (manueller API-Pfad)."""
|
|
||||||
d = e.model_dump()
|
|
||||||
cur.execute(
|
|
||||||
"""INSERT INTO activity_log (id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting,
|
|
||||||
hr_avg,hr_max,hr_min,distance_km,pace_min_per_km,cadence,avg_power,elevation_gain,
|
|
||||||
temperature_celsius,humidity_percent,avg_hr_percent,kcal_per_km,rpe,source,notes,
|
|
||||||
training_type_id,training_category,training_subcategory,created)
|
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
|
|
||||||
(
|
|
||||||
eid,
|
|
||||||
profile_id,
|
|
||||||
d["date"],
|
|
||||||
d["start_time"],
|
|
||||||
d["end_time"],
|
|
||||||
d["activity_type"],
|
|
||||||
d["duration_min"],
|
|
||||||
d["kcal_active"],
|
|
||||||
d["kcal_resting"],
|
|
||||||
d["hr_avg"],
|
|
||||||
d["hr_max"],
|
|
||||||
d.get("hr_min"),
|
|
||||||
d["distance_km"],
|
|
||||||
d.get("pace_min_per_km"),
|
|
||||||
d.get("cadence"),
|
|
||||||
d.get("avg_power"),
|
|
||||||
d.get("elevation_gain"),
|
|
||||||
d.get("temperature_celsius"),
|
|
||||||
d.get("humidity_percent"),
|
|
||||||
d.get("avg_hr_percent"),
|
|
||||||
d.get("kcal_per_km"),
|
|
||||||
d["rpe"],
|
|
||||||
d["source"],
|
|
||||||
d["notes"],
|
|
||||||
d.get("training_type_id"),
|
|
||||||
d.get("training_category"),
|
|
||||||
d.get("training_subcategory"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def update_activity_from_entry(cur, profile_id: str, eid: str, e: ActivityEntry) -> None:
|
|
||||||
"""Volles UPDATE aus ActivityEntry (REST PUT)."""
|
|
||||||
d = e.model_dump()
|
|
||||||
cur.execute(
|
|
||||||
f"UPDATE activity_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s",
|
|
||||||
list(d.values()) + [eid, profile_id],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def update_activity_columns(
|
|
||||||
cur,
|
|
||||||
profile_id: str,
|
|
||||||
eid: str,
|
|
||||||
updates: Dict[str, Any],
|
|
||||||
) -> None:
|
|
||||||
"""Teil-UPDATE nur für übergebene Spalten (Importe)."""
|
|
||||||
if not updates:
|
|
||||||
return
|
|
||||||
cols = [f"{k} = %s" for k in updates]
|
|
||||||
vals = list(updates.values()) + [eid, profile_id]
|
|
||||||
cur.execute(
|
|
||||||
f"UPDATE activity_log SET {', '.join(cols)} WHERE id = %s AND profile_id = %s",
|
|
||||||
vals,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def insert_activity_csv_minimal(
|
|
||||||
cur,
|
|
||||||
profile_id: str,
|
|
||||||
eid: str,
|
|
||||||
*,
|
|
||||||
date_iso: str,
|
|
||||||
start_time: Any,
|
|
||||||
end_time: Any,
|
|
||||||
activity_type: str,
|
|
||||||
duration_min: Any,
|
|
||||||
kcal_active: Any,
|
|
||||||
kcal_resting: Any,
|
|
||||||
hr_avg: Any,
|
|
||||||
hr_max: Any,
|
|
||||||
distance_km: Any,
|
|
||||||
training_type_id: Any,
|
|
||||||
training_category: Any,
|
|
||||||
training_subcategory: Any,
|
|
||||||
source: str,
|
|
||||||
) -> None:
|
|
||||||
"""INSERT minimale activity_log-Zeile (Universal-CSV)."""
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO activity_log (
|
|
||||||
id, profile_id, date, start_time, end_time, activity_type, duration_min,
|
|
||||||
kcal_active, kcal_resting, hr_avg, hr_max, distance_km,
|
|
||||||
source, training_type_id, training_category, training_subcategory, created
|
|
||||||
)
|
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
eid,
|
|
||||||
profile_id,
|
|
||||||
date_iso,
|
|
||||||
start_time,
|
|
||||||
end_time,
|
|
||||||
activity_type,
|
|
||||||
duration_min,
|
|
||||||
kcal_active,
|
|
||||||
kcal_resting,
|
|
||||||
hr_avg,
|
|
||||||
hr_max,
|
|
||||||
distance_km,
|
|
||||||
source,
|
|
||||||
training_type_id,
|
|
||||||
training_category,
|
|
||||||
training_subcategory,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_activity_post_write_hooks(cur, profile_id: str, eid: str) -> None:
|
|
||||||
"""Auto-Eval (falls aktiv). Kein Spalte→EAV-Sync: Lesepfad merge_column_backed_and_eav_metrics."""
|
|
||||||
if _EVALUATION_AVAILABLE and _evaluate_and_save_activity:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, profile_id, date, training_type_id, duration_min,
|
|
||||||
hr_avg, hr_max, distance_km, kcal_active, kcal_resting,
|
|
||||||
rpe, pace_min_per_km, cadence, elevation_gain
|
|
||||||
FROM activity_log
|
|
||||||
WHERE id = %s
|
|
||||||
""",
|
|
||||||
(eid,),
|
|
||||||
)
|
|
||||||
activity_row = cur.fetchone()
|
|
||||||
if activity_row:
|
|
||||||
activity_dict = dict(activity_row)
|
|
||||||
training_type_id = activity_dict.get("training_type_id")
|
|
||||||
if training_type_id:
|
|
||||||
try:
|
|
||||||
_evaluate_and_save_activity(cur, eid, activity_dict, training_type_id, profile_id)
|
|
||||||
except Exception as eval_error:
|
|
||||||
logger.error("[AUTO-EVAL] activity %s: %s", eid, eval_error)
|
|
||||||
|
|
||||||
|
|
||||||
def run_activity_post_write_hooks_import(
|
|
||||||
cur,
|
|
||||||
profile_id: str,
|
|
||||||
eid: str,
|
|
||||||
*,
|
|
||||||
workout_date: str,
|
|
||||||
training_type_id: Optional[int],
|
|
||||||
duration_min: Any,
|
|
||||||
hr_avg: Any,
|
|
||||||
hr_max: Any,
|
|
||||||
distance_km: Any,
|
|
||||||
kcal_active: Any,
|
|
||||||
kcal_resting: Any,
|
|
||||||
) -> None:
|
|
||||||
"""Auto-Eval nach Import. Kein Spalte→EAV-Sync (siehe run_activity_post_write_hooks)."""
|
|
||||||
if _EVALUATION_AVAILABLE and training_type_id and _evaluate_and_save_activity:
|
|
||||||
try:
|
|
||||||
activity_dict = {
|
|
||||||
"id": eid,
|
|
||||||
"profile_id": profile_id,
|
|
||||||
"date": workout_date,
|
|
||||||
"training_type_id": training_type_id,
|
|
||||||
"duration_min": duration_min,
|
|
||||||
"hr_avg": hr_avg,
|
|
||||||
"hr_max": hr_max,
|
|
||||||
"distance_km": distance_km,
|
|
||||||
"kcal_active": kcal_active,
|
|
||||||
"kcal_resting": kcal_resting,
|
|
||||||
"rpe": None,
|
|
||||||
"pace_min_per_km": None,
|
|
||||||
"cadence": None,
|
|
||||||
"elevation_gain": None,
|
|
||||||
}
|
|
||||||
_evaluate_and_save_activity(cur, eid, activity_dict, training_type_id, profile_id)
|
|
||||||
except Exception as eval_err:
|
|
||||||
logger.warning("[activity import] Auto-Eval fehlgeschlagen: %s", eval_err)
|
|
||||||
|
|
||||||
|
|
||||||
def merge_activity_csv_module_fields(
|
|
||||||
cur,
|
|
||||||
static_fields: Dict[str, Any],
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
activity-Modul für CSV: statische Registry-Felder + alle aktiven training_parameters.
|
|
||||||
|
|
||||||
Gleiche Quelle wie get_mappable_activity_field_catalog.training_parameters — erscheint
|
|
||||||
in Admin-CSV-Ziel-Liste, Validierung und Import-Zeilenaggregation.
|
|
||||||
"""
|
|
||||||
out = dict(static_fields)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT key, data_type, unit, name_de
|
|
||||||
FROM training_parameters
|
|
||||||
WHERE is_active = true
|
|
||||||
ORDER BY key
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
for row in cur.fetchall():
|
|
||||||
k = row["key"]
|
|
||||||
if k in out:
|
|
||||||
continue
|
|
||||||
dt = row["data_type"] or "float"
|
|
||||||
if dt == "integer":
|
|
||||||
mtype = "int"
|
|
||||||
elif dt == "float":
|
|
||||||
mtype = "float"
|
|
||||||
elif dt == "boolean":
|
|
||||||
mtype = "string"
|
|
||||||
else:
|
|
||||||
mtype = "string"
|
|
||||||
spec: Dict[str, Any] = {
|
|
||||||
"type": mtype,
|
|
||||||
"required": False,
|
|
||||||
"from_training_parameter": True,
|
|
||||||
}
|
|
||||||
if row.get("unit"):
|
|
||||||
spec["unit"] = row["unit"]
|
|
||||||
if row.get("name_de"):
|
|
||||||
spec["label_de"] = row["name_de"]
|
|
||||||
out[k] = spec
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def get_mappable_activity_field_catalog(cur, profile_id: str) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Felder für konfigurierbare Import-Mappings.
|
|
||||||
|
|
||||||
core_fields: module_registry „activity“ → activity_log.
|
|
||||||
training_parameters: alle aktiven Parameter (global); bei Anwendung auf eine Session
|
|
||||||
werden Keys verworfen, die nicht in resolve_activity_attribute_schema(Kategorie/Typ) liegen.
|
|
||||||
|
|
||||||
profile_id: reserviert für künftige Profil-Filter.
|
|
||||||
"""
|
|
||||||
_ = profile_id
|
|
||||||
mod = get_module_definition("activity") or {}
|
|
||||||
fields = mod.get("fields") or {}
|
|
||||||
core_fields: List[Dict[str, Any]] = []
|
|
||||||
for key, spec in fields.items():
|
|
||||||
s = spec or {}
|
|
||||||
core_fields.append(
|
|
||||||
{
|
|
||||||
"key": key,
|
|
||||||
"target": "activity_log",
|
|
||||||
"column": key,
|
|
||||||
"data_type": s.get("type", "string"),
|
|
||||||
"required": bool(s.get("required")),
|
|
||||||
"unit": s.get("unit"),
|
|
||||||
"label_de": s.get("label_de") or key,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
core_fields.sort(key=lambda x: x["key"])
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, key, name_de, name_en, category AS param_category,
|
|
||||||
data_type, unit, source_field
|
|
||||||
FROM training_parameters
|
|
||||||
WHERE is_active = true
|
|
||||||
ORDER BY key
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
parameters = [dict(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"core_fields": core_fields,
|
|
||||||
"training_parameters": parameters,
|
|
||||||
"notes": (
|
|
||||||
"training_parameters listet alle aktiven Keys. Pro Session werden Werte ignoriert, "
|
|
||||||
"die für deren training_category/training_type_id nicht im Attribut-Schema vorkommen."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def new_activity_id() -> str:
|
|
||||||
return str(uuid.uuid4())
|
|
||||||
|
|
@ -1,779 +0,0 @@
|
||||||
"""
|
|
||||||
Activity session metrics (EAV) and resolved attribute schema — Layer 1.
|
|
||||||
|
|
||||||
See: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from decimal import Decimal
|
|
||||||
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
|
||||||
|
|
||||||
from data_layer.activity_data_canon import (
|
|
||||||
ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM,
|
|
||||||
ACTIVITY_MODULE_REGISTRY_FIELD_KEYS,
|
|
||||||
)
|
|
||||||
from data_layer.prompt_output_compact import normalize_prompt_number
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_metric_value_for_read(data_type: str, val: Any) -> Any:
|
|
||||||
"""Lesepfad (Layer 1): keine unnötig langen Float-Strings für KI/UI (Issue 53 / Platzhalter)."""
|
|
||||||
if val is None:
|
|
||||||
return None
|
|
||||||
dt = (data_type or "").strip().lower()
|
|
||||||
if dt == "string":
|
|
||||||
return normalize_prompt_number(val)
|
|
||||||
if dt == "boolean":
|
|
||||||
return bool(val)
|
|
||||||
if dt == "integer":
|
|
||||||
try:
|
|
||||||
if isinstance(val, bool):
|
|
||||||
return int(val)
|
|
||||||
return int(val)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return normalize_prompt_number(val)
|
|
||||||
if dt == "float":
|
|
||||||
return normalize_prompt_number(val)
|
|
||||||
return normalize_prompt_number(val)
|
|
||||||
|
|
||||||
# Diese Spalten nicht aus CSV-Parameter-Zuordnung überschreiben (kommen aus Typ-Mapping / System).
|
|
||||||
ACTIVITY_LOG_PATCH_FORBIDDEN = frozenset(
|
|
||||||
{
|
|
||||||
"id",
|
|
||||||
"profile_id",
|
|
||||||
"date",
|
|
||||||
"created",
|
|
||||||
"training_type_id",
|
|
||||||
"training_category",
|
|
||||||
"training_subcategory",
|
|
||||||
"source",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ActivitySessionMetricsError(Exception):
|
|
||||||
"""Raised by Layer 1; routers map to HTTP (404/400)."""
|
|
||||||
|
|
||||||
def __init__(self, status_code: int, detail: str):
|
|
||||||
self.status_code = status_code
|
|
||||||
self.detail = detail
|
|
||||||
super().__init__(detail)
|
|
||||||
|
|
||||||
|
|
||||||
def _effective_training_category(
|
|
||||||
cur, training_category: Optional[str], training_type_id: Optional[int]
|
|
||||||
) -> Optional[str]:
|
|
||||||
if training_category:
|
|
||||||
return training_category.strip() or None
|
|
||||||
if training_type_id is None:
|
|
||||||
return None
|
|
||||||
cur.execute("SELECT category FROM training_types WHERE id = %s", (training_type_id,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
if row and row.get("category"):
|
|
||||||
return row["category"]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def merge_parameter_schema_rows(
|
|
||||||
category_rows: Sequence[Dict[str, Any]],
|
|
||||||
type_rows: Sequence[Dict[str, Any]],
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Pure merge: category assignments + type assignments → sorted schema list.
|
|
||||||
Row shapes match SELECTs in resolve_activity_attribute_schema (cat_sort / typ_* aliases).
|
|
||||||
"""
|
|
||||||
merged: Dict[int, Dict[str, Any]] = {}
|
|
||||||
|
|
||||||
for r in category_rows:
|
|
||||||
pid = r["training_parameter_id"]
|
|
||||||
merged[pid] = {
|
|
||||||
"training_parameter_id": pid,
|
|
||||||
"key": r["key"],
|
|
||||||
"name_de": r["name_de"],
|
|
||||||
"name_en": r["name_en"],
|
|
||||||
"description_de": r.get("description_de"),
|
|
||||||
"description_en": r.get("description_en"),
|
|
||||||
"param_category": r["param_category"],
|
|
||||||
"data_type": r["data_type"],
|
|
||||||
"unit": r["unit"],
|
|
||||||
"validation_rules": r["validation_rules"] or {},
|
|
||||||
"source_field": r["source_field"],
|
|
||||||
"sort_order": r["cat_sort"],
|
|
||||||
"required": bool(r["cat_required"]),
|
|
||||||
"ui_group": r["cat_ui_group"],
|
|
||||||
}
|
|
||||||
|
|
||||||
for r in type_rows:
|
|
||||||
pid = r["training_parameter_id"]
|
|
||||||
base = merged.get(pid)
|
|
||||||
if base is None:
|
|
||||||
merged[pid] = {
|
|
||||||
"training_parameter_id": pid,
|
|
||||||
"key": r["key"],
|
|
||||||
"name_de": r["name_de"],
|
|
||||||
"name_en": r["name_en"],
|
|
||||||
"description_de": r.get("description_de"),
|
|
||||||
"description_en": r.get("description_en"),
|
|
||||||
"param_category": r["param_category"],
|
|
||||||
"data_type": r["data_type"],
|
|
||||||
"unit": r["unit"],
|
|
||||||
"validation_rules": r["validation_rules"] or {},
|
|
||||||
"source_field": r["source_field"],
|
|
||||||
"sort_order": r["typ_sort"] if r["typ_sort"] is not None else 0,
|
|
||||||
"required": bool(r["typ_required"]) if r["typ_required"] is not None else False,
|
|
||||||
"ui_group": r["typ_ui_group"],
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
if r["typ_sort"] is not None:
|
|
||||||
base["sort_order"] = r["typ_sort"]
|
|
||||||
if r["typ_required"] is not None:
|
|
||||||
base["required"] = bool(r["typ_required"])
|
|
||||||
if r["typ_ui_group"] is not None:
|
|
||||||
base["ui_group"] = r["typ_ui_group"]
|
|
||||||
|
|
||||||
out = list(merged.values())
|
|
||||||
out.sort(key=lambda x: (x["sort_order"], x["key"]))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_activity_attribute_schema(
|
|
||||||
cur,
|
|
||||||
training_category: Optional[str],
|
|
||||||
training_type_id: Optional[int],
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Merged parameter definitions for UI / validation (category base + type overrides/additions).
|
|
||||||
Sorted by sort_order, then key.
|
|
||||||
"""
|
|
||||||
cat = _effective_training_category(cur, training_category, training_type_id)
|
|
||||||
category_rows: List[Dict[str, Any]] = []
|
|
||||||
type_rows: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
if cat:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
tcp.training_parameter_id,
|
|
||||||
tcp.sort_order AS cat_sort,
|
|
||||||
tcp.required AS cat_required,
|
|
||||||
tcp.ui_group AS cat_ui_group,
|
|
||||||
tp.key, tp.name_de, tp.name_en,
|
|
||||||
tp.description_de, tp.description_en,
|
|
||||||
tp.category AS param_category,
|
|
||||||
tp.data_type, tp.unit, tp.validation_rules, tp.source_field
|
|
||||||
FROM training_category_parameter tcp
|
|
||||||
JOIN training_parameters tp ON tp.id = tcp.training_parameter_id
|
|
||||||
WHERE tcp.training_category = %s AND tp.is_active = true
|
|
||||||
""",
|
|
||||||
(cat,),
|
|
||||||
)
|
|
||||||
category_rows = list(cur.fetchall())
|
|
||||||
|
|
||||||
if training_type_id is not None:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
ttp.training_parameter_id,
|
|
||||||
ttp.sort_order AS typ_sort,
|
|
||||||
ttp.required AS typ_required,
|
|
||||||
ttp.ui_group AS typ_ui_group,
|
|
||||||
tp.key, tp.name_de, tp.name_en,
|
|
||||||
tp.description_de, tp.description_en,
|
|
||||||
tp.category AS param_category,
|
|
||||||
tp.data_type, tp.unit, tp.validation_rules, tp.source_field
|
|
||||||
FROM training_type_parameter ttp
|
|
||||||
JOIN training_parameters tp ON tp.id = ttp.training_parameter_id
|
|
||||||
WHERE ttp.training_type_id = %s AND tp.is_active = true
|
|
||||||
""",
|
|
||||||
(training_type_id,),
|
|
||||||
)
|
|
||||||
type_rows = list(cur.fetchall())
|
|
||||||
|
|
||||||
return merge_parameter_schema_rows(category_rows, type_rows)
|
|
||||||
|
|
||||||
|
|
||||||
def _metric_human_labels(schema_row: Mapping[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""Bezeichnung + Kurzbeschreibung aus training_parameters (KI / Export)."""
|
|
||||||
return {
|
|
||||||
"name_de": schema_row.get("name_de"),
|
|
||||||
"name_en": schema_row.get("name_en"),
|
|
||||||
"description_de": schema_row.get("description_de"),
|
|
||||||
"description_en": schema_row.get("description_en"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _validation_rules_dict(raw: Any) -> Dict[str, Any]:
|
|
||||||
if isinstance(raw, dict):
|
|
||||||
return raw
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_single_value(data_type: str, value: Any, rules: Dict[str, Any]) -> None:
|
|
||||||
if data_type == "integer":
|
|
||||||
if not isinstance(value, int) or isinstance(value, bool):
|
|
||||||
raise ActivitySessionMetricsError(400, f"Erwartet integer, erhalten: {type(value).__name__}")
|
|
||||||
if "min" in rules and value < rules["min"]:
|
|
||||||
raise ActivitySessionMetricsError(400, f"Wert unter min ({rules['min']})")
|
|
||||||
if "max" in rules and value > rules["max"]:
|
|
||||||
raise ActivitySessionMetricsError(400, f"Wert über max ({rules['max']})")
|
|
||||||
elif data_type == "float":
|
|
||||||
if isinstance(value, bool) or not isinstance(value, (int, float, Decimal)):
|
|
||||||
raise ActivitySessionMetricsError(400, f"Erwartet Zahl, erhalten: {type(value).__name__}")
|
|
||||||
v = float(value)
|
|
||||||
if "min" in rules and v < float(rules["min"]):
|
|
||||||
raise ActivitySessionMetricsError(400, f"Wert unter min ({rules['min']})")
|
|
||||||
if "max" in rules and v > float(rules["max"]):
|
|
||||||
raise ActivitySessionMetricsError(400, f"Wert über max ({rules['max']})")
|
|
||||||
elif data_type == "string":
|
|
||||||
if not isinstance(value, str):
|
|
||||||
raise ActivitySessionMetricsError(400, f"Erwartet string, erhalten: {type(value).__name__}")
|
|
||||||
if rules.get("not_empty") and not value.strip():
|
|
||||||
raise ActivitySessionMetricsError(400, "Leerer String nicht erlaubt")
|
|
||||||
if "max_length" in rules and len(value) > int(rules["max_length"]):
|
|
||||||
raise ActivitySessionMetricsError(400, f"String zu lang (max {rules['max_length']})")
|
|
||||||
allowed = rules.get("allowed_values")
|
|
||||||
if allowed and value not in allowed:
|
|
||||||
raise ActivitySessionMetricsError(400, "Wert nicht in erlaubter Menge")
|
|
||||||
elif data_type == "boolean":
|
|
||||||
if not isinstance(value, bool):
|
|
||||||
raise ActivitySessionMetricsError(400, f"Erwartet boolean, erhalten: {type(value).__name__}")
|
|
||||||
else:
|
|
||||||
raise ActivitySessionMetricsError(400, f"Unbekannter data_type: {data_type}")
|
|
||||||
|
|
||||||
|
|
||||||
def _row_value_tuple(data_type: str, value: Any) -> tuple:
|
|
||||||
if data_type == "integer":
|
|
||||||
return (None, int(value), None, None)
|
|
||||||
if data_type == "float":
|
|
||||||
return (float(value), None, None, None)
|
|
||||||
if data_type == "string":
|
|
||||||
return (None, None, str(value), None)
|
|
||||||
if data_type == "boolean":
|
|
||||||
return (None, None, None, bool(value))
|
|
||||||
raise ValueError(data_type)
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce_raw_value_for_parameter(data_type: str, raw: Any) -> Any:
|
|
||||||
"""Wert aus activity_log-Spalte in den Typ bringen, den training_parameters.data_type erwartet."""
|
|
||||||
if data_type == "integer":
|
|
||||||
if isinstance(raw, bool):
|
|
||||||
raise TypeError("boolean nicht als integer erlaubt")
|
|
||||||
if isinstance(raw, str):
|
|
||||||
s = raw.strip().replace(",", ".")
|
|
||||||
return int(round(float(s)))
|
|
||||||
return int(round(float(raw)))
|
|
||||||
if data_type == "float":
|
|
||||||
if isinstance(raw, str):
|
|
||||||
s = raw.strip().replace(",", ".")
|
|
||||||
return float(s)
|
|
||||||
return float(raw)
|
|
||||||
if data_type == "string":
|
|
||||||
return str(raw) if raw is not None else ""
|
|
||||||
if data_type == "boolean":
|
|
||||||
if isinstance(raw, bool):
|
|
||||||
return raw
|
|
||||||
s = str(raw).strip().lower()
|
|
||||||
if s in ("true", "1", "t", "yes"):
|
|
||||||
return True
|
|
||||||
if s in ("false", "0", "f", "no", ""):
|
|
||||||
return False
|
|
||||||
raise TypeError(f"boolean-Koercion nicht möglich: {raw!r}")
|
|
||||||
raise ValueError(data_type)
|
|
||||||
|
|
||||||
|
|
||||||
def upsert_session_metrics_from_csv_mapped(
|
|
||||||
cur,
|
|
||||||
profile_id: str,
|
|
||||||
activity_log_id: str,
|
|
||||||
mapped: Mapping[str, Any],
|
|
||||||
training_category: Optional[str],
|
|
||||||
training_type_id: Optional[int],
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
EAV für Trainingsparameter aus CSV.
|
|
||||||
|
|
||||||
Es werden nur Parameter geschrieben, die in ``resolve_activity_attribute_schema`` (Kategorie +
|
|
||||||
Trainingstyp) vorkommen. CSV-Spalten-Mappings sind import-spezifisch und definieren **nicht** das
|
|
||||||
UI-/Auswertungs-Schema — fehlende tcp/ttp-Zuordnung bedeutet: kein EAV für diesen Key (Werte ggf.
|
|
||||||
nur in ``activity_log``-Kernfeldern).
|
|
||||||
|
|
||||||
Kernfelder schreibt der Executor nach ``activity_log``; hier keine EAV-Zeilen für Registry-Keys.
|
|
||||||
|
|
||||||
Hat ein Parameter ``source_field`` (Semantik aus ``activity_log``), wird EAV nur dann **nicht**
|
|
||||||
geschrieben, wenn diese Spalte nach dem Import bereits befüllt ist — sonst gäbe es doppelte
|
|
||||||
Speicherung und der Merge würde ohnehin die Spalte bevorzugen. Ist die Spalte leer (z. B. Feld
|
|
||||||
nur noch über EAV / Custom-Mapping, ohne Registry-Patch), schreibt der Import den Wert aus
|
|
||||||
``mapped`` nach EAV — analog zum Lesepfad (Spalte zuerst, sonst EAV).
|
|
||||||
"""
|
|
||||||
cur.execute("SELECT * FROM activity_log WHERE id = %s", (activity_log_id,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row or str(row["profile_id"]) != str(profile_id):
|
|
||||||
return
|
|
||||||
header = dict(row)
|
|
||||||
schema = resolve_activity_attribute_schema(cur, training_category, training_type_id)
|
|
||||||
for spec in schema:
|
|
||||||
pkey = spec["key"]
|
|
||||||
if pkey not in mapped:
|
|
||||||
continue
|
|
||||||
raw = mapped[pkey]
|
|
||||||
if raw is None or raw == "":
|
|
||||||
continue
|
|
||||||
if pkey in ACTIVITY_MODULE_REGISTRY_FIELD_KEYS:
|
|
||||||
continue
|
|
||||||
sf_raw = spec.get("source_field")
|
|
||||||
if sf_raw is not None and str(sf_raw).strip():
|
|
||||||
col = str(sf_raw).strip()
|
|
||||||
if col in header and header[col] is not None:
|
|
||||||
continue
|
|
||||||
tid = spec["training_parameter_id"]
|
|
||||||
dt = spec["data_type"]
|
|
||||||
rules = _validation_rules_dict(spec["validation_rules"])
|
|
||||||
try:
|
|
||||||
coerced = _coerce_raw_value_for_parameter(dt, raw)
|
|
||||||
_validate_single_value(dt, coerced, rules)
|
|
||||||
except (ActivitySessionMetricsError, TypeError, ValueError) as ex:
|
|
||||||
logger.warning("CSV EAV skipped %s: %s", pkey, ex)
|
|
||||||
continue
|
|
||||||
vn, vi, vt, vb = _row_value_tuple(dt, coerced)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, NOW())
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id)
|
|
||||||
DO UPDATE SET
|
|
||||||
value_num = EXCLUDED.value_num,
|
|
||||||
value_int = EXCLUDED.value_int,
|
|
||||||
value_text = EXCLUDED.value_text,
|
|
||||||
value_bool = EXCLUDED.value_bool,
|
|
||||||
updated_at = NOW()
|
|
||||||
""",
|
|
||||||
(activity_log_id, tid, vn, vi, vt, vb),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def merge_column_backed_and_eav_metrics(
|
|
||||||
header: Mapping[str, Any],
|
|
||||||
schema: Sequence[Dict[str, Any]],
|
|
||||||
eav_metrics: Sequence[Dict[str, Any]],
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Effektive Metrikliste **nur** für Parameter aus ``schema`` (Kategorie + Trainingstyp / tcp+ttp).
|
|
||||||
|
|
||||||
Kanon beim Lesen: **activity_log** schlägt EAV, sobald ein passender Spaltenwert existiert und
|
|
||||||
koerzierbar ist — in dieser Reihenfolge:
|
|
||||||
|
|
||||||
1. ``source_field`` → Spalte
|
|
||||||
2. Parameter-Key = Registry-Kernfeld (``ACTIVITY_MODULE_REGISTRY_FIELD_KEYS``) → gleichnamige Spalte
|
|
||||||
3. EAV-primäre Keys → Legacy-Spalte laut ``ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM``
|
|
||||||
4. sonst EAV
|
|
||||||
|
|
||||||
EAV-Zeilen zu Parametern, die nicht im Schema sind, werden nicht ausgegeben.
|
|
||||||
"""
|
|
||||||
eav_by_key = {m["key"]: m for m in eav_metrics}
|
|
||||||
merged: List[Dict[str, Any]] = []
|
|
||||||
keys_handled: set[str] = set()
|
|
||||||
|
|
||||||
for s in schema:
|
|
||||||
k = s["key"]
|
|
||||||
tid = s["training_parameter_id"]
|
|
||||||
dt = s["data_type"]
|
|
||||||
unit = s.get("unit")
|
|
||||||
sf = s.get("source_field")
|
|
||||||
|
|
||||||
used_column = False
|
|
||||||
if sf and isinstance(sf, str) and str(sf).strip():
|
|
||||||
col = str(sf).strip()
|
|
||||||
if col in header and header[col] is not None:
|
|
||||||
try:
|
|
||||||
val = _coerce_raw_value_for_parameter(dt, header[col])
|
|
||||||
merged.append(
|
|
||||||
{
|
|
||||||
"training_parameter_id": tid,
|
|
||||||
"key": k,
|
|
||||||
"data_type": dt,
|
|
||||||
"unit": unit,
|
|
||||||
"value": val,
|
|
||||||
**_metric_human_labels(s),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
used_column = True
|
|
||||||
keys_handled.add(k)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
if used_column:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if k in ACTIVITY_MODULE_REGISTRY_FIELD_KEYS and k in header and header[k] is not None:
|
|
||||||
try:
|
|
||||||
val = _coerce_raw_value_for_parameter(dt, header[k])
|
|
||||||
merged.append(
|
|
||||||
{
|
|
||||||
"training_parameter_id": tid,
|
|
||||||
"key": k,
|
|
||||||
"data_type": dt,
|
|
||||||
"unit": unit,
|
|
||||||
"value": val,
|
|
||||||
**_metric_human_labels(s),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
keys_handled.add(k)
|
|
||||||
continue
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
legacy_col = ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM.get(k)
|
|
||||||
if legacy_col and legacy_col in header and header[legacy_col] is not None:
|
|
||||||
try:
|
|
||||||
val = _coerce_raw_value_for_parameter(dt, header[legacy_col])
|
|
||||||
merged.append(
|
|
||||||
{
|
|
||||||
"training_parameter_id": tid,
|
|
||||||
"key": k,
|
|
||||||
"data_type": dt,
|
|
||||||
"unit": unit,
|
|
||||||
"value": val,
|
|
||||||
**_metric_human_labels(s),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
keys_handled.add(k)
|
|
||||||
continue
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
if k in eav_by_key:
|
|
||||||
row = dict(eav_by_key[k])
|
|
||||||
row.update(_metric_human_labels(s))
|
|
||||||
merged.append(row)
|
|
||||||
keys_handled.add(k)
|
|
||||||
|
|
||||||
merged.sort(key=lambda x: x["key"])
|
|
||||||
for m in merged:
|
|
||||||
m["value"] = _normalize_metric_value_for_read(m.get("data_type") or "", m.get("value"))
|
|
||||||
return merged
|
|
||||||
|
|
||||||
|
|
||||||
def sync_column_backed_session_metrics(cur, profile_id: str, activity_log_id: str) -> None:
|
|
||||||
"""
|
|
||||||
[Veraltet / nicht mehr in Schreibpfaden aufgerufen]
|
|
||||||
|
|
||||||
Früher: EAV spiegelte activity_log-Spalten für Parameter mit source_field.
|
|
||||||
Kanon: Spaltenwerte werden bei merge_column_backed_and_eav_metrics beim Lesen berücksichtigt; keine
|
|
||||||
doppelte Speicherung. Funktion bleibt für optionale Admin-/Reparatur-Skripte.
|
|
||||||
"""
|
|
||||||
cur.execute("SELECT * FROM activity_log WHERE id = %s", (activity_log_id,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row or str(row["profile_id"]) != str(profile_id):
|
|
||||||
return
|
|
||||||
header = dict(row)
|
|
||||||
schema = resolve_activity_attribute_schema(
|
|
||||||
cur, header.get("training_category"), header.get("training_type_id")
|
|
||||||
)
|
|
||||||
for spec in schema:
|
|
||||||
sf = spec.get("source_field")
|
|
||||||
if sf is None or (isinstance(sf, str) and not str(sf).strip()):
|
|
||||||
continue
|
|
||||||
col = str(sf).strip()
|
|
||||||
if col not in header:
|
|
||||||
continue
|
|
||||||
raw = header[col]
|
|
||||||
tid = spec["training_parameter_id"]
|
|
||||||
dt = spec["data_type"]
|
|
||||||
rules = _validation_rules_dict(spec["validation_rules"])
|
|
||||||
|
|
||||||
if raw is None:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
DELETE FROM activity_session_metrics
|
|
||||||
WHERE activity_log_id = %s AND training_parameter_id = %s
|
|
||||||
""",
|
|
||||||
(activity_log_id, tid),
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
coerced = _coerce_raw_value_for_parameter(dt, raw)
|
|
||||||
_validate_single_value(dt, coerced, rules)
|
|
||||||
except (ActivitySessionMetricsError, TypeError, ValueError) as ex:
|
|
||||||
logger.warning(
|
|
||||||
"sync_column_backed_session_metrics: überspringe %s (Spalte %s): %s",
|
|
||||||
spec.get("key"),
|
|
||||||
col,
|
|
||||||
ex,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
vn, vi, vt, vb = _row_value_tuple(dt, coerced)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, NOW())
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id)
|
|
||||||
DO UPDATE SET
|
|
||||||
value_num = EXCLUDED.value_num,
|
|
||||||
value_int = EXCLUDED.value_int,
|
|
||||||
value_text = EXCLUDED.value_text,
|
|
||||||
value_bool = EXCLUDED.value_bool,
|
|
||||||
updated_at = NOW()
|
|
||||||
""",
|
|
||||||
(activity_log_id, tid, vn, vi, vt, vb),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_activity_session_metrics(cur, activity_log_id: str) -> List[Dict[str, Any]]:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
m.id,
|
|
||||||
m.activity_log_id,
|
|
||||||
m.training_parameter_id,
|
|
||||||
m.value_num,
|
|
||||||
m.value_int,
|
|
||||||
m.value_text,
|
|
||||||
m.value_bool,
|
|
||||||
tp.key,
|
|
||||||
tp.data_type,
|
|
||||||
tp.unit
|
|
||||||
FROM activity_session_metrics m
|
|
||||||
JOIN training_parameters tp ON tp.id = m.training_parameter_id
|
|
||||||
WHERE m.activity_log_id = %s
|
|
||||||
ORDER BY tp.key
|
|
||||||
""",
|
|
||||||
(activity_log_id,),
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
out: List[Dict[str, Any]] = []
|
|
||||||
for r in rows:
|
|
||||||
dt = r["data_type"]
|
|
||||||
if dt == "integer":
|
|
||||||
val = int(r["value_int"]) if r["value_int"] is not None else None
|
|
||||||
elif dt == "float":
|
|
||||||
val = float(r["value_num"]) if r["value_num"] is not None else None
|
|
||||||
elif dt == "string":
|
|
||||||
val = r["value_text"]
|
|
||||||
else:
|
|
||||||
val = r["value_bool"]
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"training_parameter_id": r["training_parameter_id"],
|
|
||||||
"key": r["key"],
|
|
||||||
"data_type": dt,
|
|
||||||
"unit": r["unit"],
|
|
||||||
"value": val,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def replace_activity_session_metrics(
|
|
||||||
cur,
|
|
||||||
profile_id: str,
|
|
||||||
activity_log_id: str,
|
|
||||||
metrics: Sequence[Dict[str, Any]],
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Full replace of EAV rows for this session. metrics: [{ "parameter_key": str, "value": ... }, ...]
|
|
||||||
"""
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, profile_id, training_category, training_type_id
|
|
||||||
FROM activity_log WHERE id = %s
|
|
||||||
""",
|
|
||||||
(activity_log_id,),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row or str(row["profile_id"]) != str(profile_id):
|
|
||||||
raise ActivitySessionMetricsError(404, "Aktivität nicht gefunden")
|
|
||||||
|
|
||||||
schema = resolve_activity_attribute_schema(
|
|
||||||
cur, row.get("training_category"), row.get("training_type_id")
|
|
||||||
)
|
|
||||||
by_key = {s["key"]: s for s in schema}
|
|
||||||
payload_by_key: Dict[str, Dict[str, Any]] = {}
|
|
||||||
for item in metrics:
|
|
||||||
raw_k = item.get("parameter_key")
|
|
||||||
if raw_k is None or not str(raw_k).strip():
|
|
||||||
raise ActivitySessionMetricsError(400, "parameter_key fehlt")
|
|
||||||
k = str(raw_k).strip()
|
|
||||||
if k not in by_key:
|
|
||||||
raise ActivitySessionMetricsError(400, f"Unbekannter oder nicht zugewiesener Parameter: {k}")
|
|
||||||
payload_by_key[k] = item
|
|
||||||
|
|
||||||
for s in schema:
|
|
||||||
if not s["required"]:
|
|
||||||
continue
|
|
||||||
itk = s["key"]
|
|
||||||
hit = payload_by_key.get(itk)
|
|
||||||
if hit is None or hit.get("value") is None:
|
|
||||||
raise ActivitySessionMetricsError(400, f"Pflichtfeld fehlt: {itk}")
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"DELETE FROM activity_session_metrics WHERE activity_log_id = %s",
|
|
||||||
(activity_log_id,),
|
|
||||||
)
|
|
||||||
|
|
||||||
for item in metrics:
|
|
||||||
k = str(item["parameter_key"]).strip()
|
|
||||||
spec = by_key[k]
|
|
||||||
val = item.get("value")
|
|
||||||
if val is None:
|
|
||||||
if spec["required"]:
|
|
||||||
raise ActivitySessionMetricsError(400, f"Pflichtfeld fehlt: {k}")
|
|
||||||
continue
|
|
||||||
rules = _validation_rules_dict(spec["validation_rules"])
|
|
||||||
_validate_single_value(spec["data_type"], val, rules)
|
|
||||||
vn, vi, vt, vb = _row_value_tuple(spec["data_type"], val)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, NOW())
|
|
||||||
""",
|
|
||||||
(activity_log_id, spec["training_parameter_id"], vn, vi, vt, vb),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Kein sync_column_backed nach PUT /metrics: der Request ist maßgeblich für EAV. Ein Spalten-Sync würde
|
|
||||||
# Werte aus nicht mitgeschriebenen activity_log-Spalten wieder verwerfen.
|
|
||||||
|
|
||||||
return fetch_activity_session_metrics(cur, activity_log_id)
|
|
||||||
|
|
||||||
|
|
||||||
def get_activity_session_logical_unit(
|
|
||||||
cur,
|
|
||||||
profile_id: str,
|
|
||||||
activity_log_id: str,
|
|
||||||
*,
|
|
||||||
use_form_training_context: bool = False,
|
|
||||||
form_training_category: Optional[str] = None,
|
|
||||||
form_training_type_id: Optional[int] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
cur.execute("SELECT * FROM activity_log WHERE id = %s", (activity_log_id,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row or str(row["profile_id"]) != str(profile_id):
|
|
||||||
raise ActivitySessionMetricsError(404, "Aktivität nicht gefunden")
|
|
||||||
|
|
||||||
header = dict(row)
|
|
||||||
if use_form_training_context:
|
|
||||||
cat = form_training_category
|
|
||||||
if isinstance(cat, str):
|
|
||||||
cat = cat.strip() or None
|
|
||||||
tid = form_training_type_id
|
|
||||||
else:
|
|
||||||
cat = header.get("training_category")
|
|
||||||
tid = header.get("training_type_id")
|
|
||||||
if tid is not None:
|
|
||||||
try:
|
|
||||||
tid = int(tid)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
tid = None
|
|
||||||
schema = resolve_activity_attribute_schema(cur, cat, tid)
|
|
||||||
metrics = fetch_activity_session_metrics(cur, activity_log_id)
|
|
||||||
merged_metrics = merge_column_backed_and_eav_metrics(header, schema, metrics)
|
|
||||||
return {
|
|
||||||
"header": header,
|
|
||||||
"schema": schema,
|
|
||||||
"metrics": merged_metrics,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def enrich_sessions_with_metrics(cur, sessions: List[Dict[str, Any]]) -> None:
|
|
||||||
"""
|
|
||||||
Mutates each session dict: adds key 'session_metrics' (list).
|
|
||||||
|
|
||||||
Kombiniert EAV mit activity_log-Spalten für Parameter mit source_field (kanonisch: Spalte),
|
|
||||||
analog zu get_activity_session_logical_unit – ohne doppelte EAV-Speicherung beim Import.
|
|
||||||
"""
|
|
||||||
if not sessions:
|
|
||||||
return
|
|
||||||
ids = [str(s["id"]) for s in sessions if s.get("id")]
|
|
||||||
if not ids:
|
|
||||||
return
|
|
||||||
ph = ",".join(["%s"] * len(ids))
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
f"SELECT * FROM activity_log WHERE id IN ({ph})",
|
|
||||||
ids,
|
|
||||||
)
|
|
||||||
headers_by_id: Dict[str, Dict[str, Any]] = {}
|
|
||||||
for r in cur.fetchall():
|
|
||||||
h = dict(r)
|
|
||||||
headers_by_id[str(h["id"])] = h
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
f"""
|
|
||||||
SELECT
|
|
||||||
m.activity_log_id,
|
|
||||||
m.training_parameter_id,
|
|
||||||
tp.key,
|
|
||||||
tp.data_type,
|
|
||||||
tp.unit,
|
|
||||||
m.value_num,
|
|
||||||
m.value_int,
|
|
||||||
m.value_text,
|
|
||||||
m.value_bool
|
|
||||||
FROM activity_session_metrics m
|
|
||||||
JOIN training_parameters tp ON tp.id = m.training_parameter_id
|
|
||||||
WHERE m.activity_log_id IN ({ph})
|
|
||||||
ORDER BY m.activity_log_id, tp.key
|
|
||||||
""",
|
|
||||||
ids,
|
|
||||||
)
|
|
||||||
by_act: Dict[str, List[Dict[str, Any]]] = {}
|
|
||||||
for r in cur.fetchall():
|
|
||||||
aid = str(r["activity_log_id"])
|
|
||||||
dt = r["data_type"]
|
|
||||||
if dt == "integer":
|
|
||||||
val = int(r["value_int"]) if r["value_int"] is not None else None
|
|
||||||
elif dt == "float":
|
|
||||||
val = float(r["value_num"]) if r["value_num"] is not None else None
|
|
||||||
elif dt == "string":
|
|
||||||
val = r["value_text"]
|
|
||||||
else:
|
|
||||||
val = r["value_bool"]
|
|
||||||
by_act.setdefault(aid, []).append(
|
|
||||||
{
|
|
||||||
"training_parameter_id": r["training_parameter_id"],
|
|
||||||
"key": r["key"],
|
|
||||||
"data_type": dt,
|
|
||||||
"unit": r["unit"],
|
|
||||||
"value": val,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
schema_cache: Dict[tuple[Any, Any], List[Dict[str, Any]]] = {}
|
|
||||||
|
|
||||||
def _schema(cat: Any, tid: Any) -> List[Dict[str, Any]]:
|
|
||||||
cache_key = (cat, tid)
|
|
||||||
if cache_key not in schema_cache:
|
|
||||||
schema_cache[cache_key] = resolve_activity_attribute_schema(cur, cat, tid)
|
|
||||||
return schema_cache[cache_key]
|
|
||||||
|
|
||||||
for s in sessions:
|
|
||||||
aid = str(s.get("id"))
|
|
||||||
header = headers_by_id.get(aid)
|
|
||||||
if not header:
|
|
||||||
s["session_metrics"] = []
|
|
||||||
continue
|
|
||||||
schema = _schema(header.get("training_category"), header.get("training_type_id"))
|
|
||||||
eav_list = by_act.get(aid, [])
|
|
||||||
merged = merge_column_backed_and_eav_metrics(header, schema, eav_list)
|
|
||||||
s["session_metrics"] = [
|
|
||||||
{
|
|
||||||
"key": m["key"],
|
|
||||||
"data_type": m["data_type"],
|
|
||||||
"unit": m["unit"],
|
|
||||||
"value": m["value"],
|
|
||||||
"name_de": m.get("name_de"),
|
|
||||||
"name_en": m.get("name_en"),
|
|
||||||
"description_de": m.get("description_de"),
|
|
||||||
"description_en": m.get("description_en"),
|
|
||||||
}
|
|
||||||
for m in merged
|
|
||||||
]
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
"""
|
|
||||||
Einheitliche Startzeit-Normalisierung für Aktivität (CSV, Legacy-Import, Dedupe).
|
|
||||||
|
|
||||||
Anbieter-agnostisch: beliebige ISO-/Export-Strings über dateutil.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import time as dt_time
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from dateutil import parser as du_parser
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_activity_start(start_raw: str) -> tuple[str, Optional[dt_time]]:
|
|
||||||
"""
|
|
||||||
Roh-String „Start“ aus Exporten → (YYYY-MM-DD, TIME ohne μs) für DB Dedupe/INSERT.
|
|
||||||
|
|
||||||
Leerer Input → ("", None). Fallback bei Parse-Fehler: erstes Datum aus ersten 10 Zeichen.
|
|
||||||
"""
|
|
||||||
s = (start_raw or "").strip()
|
|
||||||
if not s:
|
|
||||||
return "", None
|
|
||||||
try:
|
|
||||||
parsed = du_parser.parse(s, dayfirst=False)
|
|
||||||
t = parsed.time().replace(microsecond=0)
|
|
||||||
return parsed.date().isoformat(), t
|
|
||||||
except (ValueError, TypeError, OverflowError):
|
|
||||||
if len(s) >= 10:
|
|
||||||
return s[:10], None
|
|
||||||
return "", None
|
|
||||||
|
|
@ -1,330 +0,0 @@
|
||||||
"""
|
|
||||||
Body interpretation tiles for Layer 2b (Verlauf UI).
|
|
||||||
|
|
||||||
Logic aligned with frontend/src/utils/interpret.js (Körper-Kontext).
|
|
||||||
Uses the same thresholds; outputs structured tiles + related_placeholder_keys
|
|
||||||
for alignment with Layer 2a registry keys.
|
|
||||||
|
|
||||||
No formatting for KI — structured dicts only.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date, datetime
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_float(v: Any) -> Optional[float]:
|
|
||||||
if v is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return round(float(v), 4)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _calc_derived(m: Dict, height_cm: float) -> Dict[str, float]:
|
|
||||||
out: Dict[str, float] = {}
|
|
||||||
w = _safe_float(m.get("c_waist"))
|
|
||||||
h = _safe_float(m.get("c_hip"))
|
|
||||||
lean = _safe_float(m.get("lean_mass"))
|
|
||||||
if w and h:
|
|
||||||
out["whr"] = round(w / h, 2)
|
|
||||||
if w and height_cm:
|
|
||||||
out["whtr"] = round(w / height_cm, 2)
|
|
||||||
if lean and height_cm:
|
|
||||||
hm = height_cm / 100.0
|
|
||||||
out["ffmi"] = round(lean / (hm ** 2), 1)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _bf_status_ranges(sex: str) -> Dict[str, float]:
|
|
||||||
if sex == "f":
|
|
||||||
return {"essential": 14, "athletic": 21, "fit": 25, "avg": 32}
|
|
||||||
return {"essential": 6, "athletic": 14, "fit": 18, "avg": 25}
|
|
||||||
|
|
||||||
|
|
||||||
def get_body_interpretation_tiles(
|
|
||||||
measurement: Dict[str, Any],
|
|
||||||
profile: Dict[str, Any],
|
|
||||||
prev_measurement: Optional[Dict[str, Any]] = None,
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Returns interpretation tiles. Each tile includes related_placeholder_keys
|
|
||||||
pointing to Layer 2a registry keys fed by the same Layer-1 metrics.
|
|
||||||
"""
|
|
||||||
results: List[Dict[str, Any]] = []
|
|
||||||
sex = profile.get("sex") or "m"
|
|
||||||
height = _safe_float(profile.get("height")) or 178.0
|
|
||||||
|
|
||||||
m = measurement
|
|
||||||
derived = _calc_derived(m, height)
|
|
||||||
|
|
||||||
# ── Körperfett ──────────────────────────────────────────────────────────
|
|
||||||
bf = _safe_float(m.get("body_fat_pct"))
|
|
||||||
if bf is not None:
|
|
||||||
ranges = _bf_status_ranges(sex)
|
|
||||||
if bf <= ranges["essential"]:
|
|
||||||
msg = "Sehr niedriger Körperfettanteil"
|
|
||||||
detail = (
|
|
||||||
"Essenzielle Fettwerte – nur für Leistungssportler geeignet, "
|
|
||||||
"auf Dauer nicht empfehlenswert."
|
|
||||||
)
|
|
||||||
status = "warn"
|
|
||||||
elif bf <= ranges["athletic"]:
|
|
||||||
msg = "Athletischer Körperfettanteil"
|
|
||||||
detail = "Ausgezeichnet. Typisch für aktive Sportler mit hohem Trainingsvolumen."
|
|
||||||
status = "good"
|
|
||||||
elif bf <= ranges["fit"]:
|
|
||||||
msg = "Guter Körperfettanteil"
|
|
||||||
detail = "Sehr gute Fitness-Kategorie. Gesund und gut in Form."
|
|
||||||
status = "good"
|
|
||||||
elif bf <= ranges["avg"]:
|
|
||||||
msg = "Durchschnittlicher Körperfettanteil"
|
|
||||||
detail = (
|
|
||||||
"Im normalen Bereich. Verbesserung durch Kombination aus Kraft- "
|
|
||||||
"und Ausdauertraining möglich."
|
|
||||||
)
|
|
||||||
status = "warn"
|
|
||||||
else:
|
|
||||||
msg = "Erhöhter Körperfettanteil"
|
|
||||||
detail = (
|
|
||||||
"Über dem empfohlenen Bereich. Ernährungsumstellung und "
|
|
||||||
"regelmäßiges Training empfohlen."
|
|
||||||
)
|
|
||||||
status = "bad"
|
|
||||||
|
|
||||||
results.append(
|
|
||||||
{
|
|
||||||
"category": "Körperfett",
|
|
||||||
"icon": "🫧",
|
|
||||||
"status": status,
|
|
||||||
"title": msg,
|
|
||||||
"detail": detail,
|
|
||||||
"value": f"{bf}%",
|
|
||||||
"related_placeholder_keys": ["caliper_summary", "fm_28d_change"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── WHR ─────────────────────────────────────────────────────────────────
|
|
||||||
whr = derived.get("whr")
|
|
||||||
if whr is not None:
|
|
||||||
limit = 0.90 if sex == "m" else 0.85
|
|
||||||
limit_high = 1.0 if sex == "m" else 0.95
|
|
||||||
if whr < limit:
|
|
||||||
status = "good"
|
|
||||||
title = "Günstige Fettverteilung"
|
|
||||||
detail = (
|
|
||||||
f"Dein WHR von {whr} liegt unter dem Grenzwert ({limit}). "
|
|
||||||
"Birnenförmige Fettverteilung – metabolisch günstig."
|
|
||||||
)
|
|
||||||
elif whr < limit_high:
|
|
||||||
status = "warn"
|
|
||||||
title = "Grenzwertiger WHR"
|
|
||||||
detail = (
|
|
||||||
f"Dein WHR von {whr} liegt leicht über dem Zielwert ({limit}). "
|
|
||||||
"Apfelförmige Tendenz – Bauchfett reduzieren empfohlen."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
status = "bad"
|
|
||||||
title = "Erhöhtes Risiko durch Fettverteilung"
|
|
||||||
detail = (
|
|
||||||
f"WHR von {whr} deutlich über dem Grenzwert. Erhöhtes "
|
|
||||||
"kardiovaskuläres Risiko durch viszerales Fett."
|
|
||||||
)
|
|
||||||
results.append(
|
|
||||||
{
|
|
||||||
"category": "Fettverteilung",
|
|
||||||
"icon": "📐",
|
|
||||||
"status": status,
|
|
||||||
"title": title,
|
|
||||||
"detail": detail,
|
|
||||||
"value": str(whr),
|
|
||||||
"related_placeholder_keys": ["waist_hip_ratio", "circ_summary"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── WHtR ────────────────────────────────────────────────────────────────
|
|
||||||
whtr = derived.get("whtr")
|
|
||||||
if whtr is not None:
|
|
||||||
if whtr < 0.40:
|
|
||||||
status = "warn"
|
|
||||||
title = "Sehr schlanke Taille"
|
|
||||||
detail = f"WHtR {whtr} – möglicherweise zu wenig Körpermasse."
|
|
||||||
elif whtr < 0.50:
|
|
||||||
status = "good"
|
|
||||||
title = "Optimale Taillen-Größen-Relation"
|
|
||||||
detail = (
|
|
||||||
f"WHtR {whtr} – im optimalen Bereich. Geringstes kardiovaskuläres Risiko."
|
|
||||||
)
|
|
||||||
elif whtr < 0.60:
|
|
||||||
status = "warn"
|
|
||||||
title = "Leicht erhöhter WHtR"
|
|
||||||
detail = f"WHtR {whtr} – Ziel ist unter 0,50. Moderat erhöhtes Risiko."
|
|
||||||
else:
|
|
||||||
status = "bad"
|
|
||||||
title = "Stark erhöhter WHtR"
|
|
||||||
detail = (
|
|
||||||
f"WHtR {whtr} – deutlich erhöhtes Risiko. Taille sollte weniger "
|
|
||||||
"als die Hälfte der Körpergröße betragen."
|
|
||||||
)
|
|
||||||
results.append(
|
|
||||||
{
|
|
||||||
"category": "Taille/Größe",
|
|
||||||
"icon": "📏",
|
|
||||||
"status": status,
|
|
||||||
"title": title,
|
|
||||||
"detail": detail,
|
|
||||||
"value": str(whtr),
|
|
||||||
"related_placeholder_keys": ["circ_summary", "waist_28d_delta"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── FFMI ─────────────────────────────────────────────────────────────────
|
|
||||||
ffmi = derived.get("ffmi")
|
|
||||||
if ffmi is not None:
|
|
||||||
natural_limit = 25.0 if sex == "m" else 22.0
|
|
||||||
if ffmi < (18.0 if sex == "m" else 15.0):
|
|
||||||
status = "warn"
|
|
||||||
title = "Unterdurchschnittliche Muskelmasse"
|
|
||||||
detail = (
|
|
||||||
f"FFMI {ffmi} – Krafttraining kann die Muskelmasse und den "
|
|
||||||
"Grundumsatz deutlich verbessern."
|
|
||||||
)
|
|
||||||
elif ffmi < (22.0 if sex == "m" else 19.0):
|
|
||||||
status = "good"
|
|
||||||
title = "Durchschnittliche Muskelmasse"
|
|
||||||
detail = f"FFMI {ffmi} – gute Basis. Mit regelmäßigem Krafttraining weiter ausbaubar."
|
|
||||||
elif ffmi <= natural_limit:
|
|
||||||
status = "good"
|
|
||||||
title = "Überdurchschnittliche Muskelmasse"
|
|
||||||
detail = f"FFMI {ffmi} – sehr gut. Oberes natürliches Spektrum für Kraftsportler."
|
|
||||||
else:
|
|
||||||
status = "warn"
|
|
||||||
title = "Außergewöhnlich hohe Muskelmasse"
|
|
||||||
detail = (
|
|
||||||
f"FFMI {ffmi} – oberhalb der natürlichen Grenze (~{natural_limit}). "
|
|
||||||
"Selten ohne unterstützende Mittel erreichbar."
|
|
||||||
)
|
|
||||||
results.append(
|
|
||||||
{
|
|
||||||
"category": "Muskelmasse",
|
|
||||||
"icon": "💪",
|
|
||||||
"status": status,
|
|
||||||
"title": title,
|
|
||||||
"detail": detail,
|
|
||||||
"value": str(ffmi),
|
|
||||||
"related_placeholder_keys": ["lbm_28d_change", "caliper_summary"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── BMI ───────────────────────────────────────────────────────────────────
|
|
||||||
w_kg = _safe_float(m.get("weight"))
|
|
||||||
if w_kg is not None and height > 0:
|
|
||||||
bmi = round(w_kg / ((height / 100.0) ** 2), 1)
|
|
||||||
if bmi < 18.5:
|
|
||||||
status = "warn"
|
|
||||||
title = "Untergewicht (BMI)"
|
|
||||||
detail = f"BMI {bmi} – unter 18,5. Auf ausreichende Kalorienzufuhr und Nährstoffversorgung achten."
|
|
||||||
elif bmi < 25:
|
|
||||||
status = "good"
|
|
||||||
title = "Normalgewicht (BMI)"
|
|
||||||
detail = f"BMI {bmi} – im optimalen Bereich (18,5–24,9)."
|
|
||||||
elif bmi < 30:
|
|
||||||
status = "warn"
|
|
||||||
title = "Übergewicht (BMI)"
|
|
||||||
detail = (
|
|
||||||
f"BMI {bmi} – leichtes Übergewicht. BMI allein ist wenig aussagekräftig "
|
|
||||||
"bei Muskelmasse – Körperfett-% beachten."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
status = "bad"
|
|
||||||
title = "Adipositas (BMI)"
|
|
||||||
detail = f"BMI {bmi} – deutliches Übergewicht. Ärztliche Beratung empfohlen."
|
|
||||||
results.append(
|
|
||||||
{
|
|
||||||
"category": "BMI",
|
|
||||||
"icon": "⚖️",
|
|
||||||
"status": status,
|
|
||||||
"title": title,
|
|
||||||
"detail": detail,
|
|
||||||
"value": str(bmi),
|
|
||||||
"related_placeholder_keys": ["bmi", "weight_aktuell"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Vergleich zur letzten Messung (Caliper) ───────────────────────────────
|
|
||||||
if prev_measurement:
|
|
||||||
p = prev_measurement
|
|
||||||
m_date = m.get("date")
|
|
||||||
p_date = p.get("date")
|
|
||||||
days = 0
|
|
||||||
if m_date and p_date:
|
|
||||||
if isinstance(m_date, str):
|
|
||||||
m_date = datetime.fromisoformat(m_date[:10]).date()
|
|
||||||
if isinstance(p_date, str):
|
|
||||||
p_date = datetime.fromisoformat(p_date[:10]).date()
|
|
||||||
if isinstance(m_date, date) and isinstance(p_date, date):
|
|
||||||
days = (m_date - p_date).days
|
|
||||||
|
|
||||||
changes: List[Dict[str, Any]] = []
|
|
||||||
if m.get("body_fat_pct") is not None and p.get("body_fat_pct") is not None:
|
|
||||||
diff = round(float(m["body_fat_pct"]) - float(p["body_fat_pct"]), 1)
|
|
||||||
if abs(diff) >= 0.3:
|
|
||||||
changes.append({"label": "Körperfett", "diff": diff, "unit": "%", "invert": True})
|
|
||||||
if m.get("weight") is not None and p.get("weight") is not None:
|
|
||||||
diff = round(float(m["weight"]) - float(p["weight"]), 1)
|
|
||||||
if abs(diff) >= 0.2:
|
|
||||||
changes.append({"label": "Gewicht", "diff": diff, "unit": "kg", "invert": True})
|
|
||||||
if m.get("lean_mass") is not None and p.get("lean_mass") is not None:
|
|
||||||
diff = round(float(m["lean_mass"]) - float(p["lean_mass"]), 1)
|
|
||||||
if abs(diff) >= 0.2:
|
|
||||||
changes.append({"label": "Magermasse", "diff": diff, "unit": "kg", "invert": False})
|
|
||||||
if m.get("c_waist") is not None and p.get("c_waist") is not None:
|
|
||||||
diff = round(float(m["c_waist"]) - float(p["c_waist"]), 1)
|
|
||||||
if abs(diff) >= 0.5:
|
|
||||||
changes.append({"label": "Taille", "diff": diff, "unit": "cm", "invert": True})
|
|
||||||
if m.get("c_belly") is not None and p.get("c_belly") is not None:
|
|
||||||
diff = round(float(m["c_belly"]) - float(p["c_belly"]), 1)
|
|
||||||
if abs(diff) >= 0.5:
|
|
||||||
changes.append({"label": "Bauch", "diff": diff, "unit": "cm", "invert": True})
|
|
||||||
|
|
||||||
if changes:
|
|
||||||
positive = [c for c in changes if (c["diff"] < 0 if c["invert"] else c["diff"] > 0)]
|
|
||||||
negative = [c for c in changes if (c["diff"] > 0 if c["invert"] else c["diff"] < 0)]
|
|
||||||
detail_parts = []
|
|
||||||
for c in changes:
|
|
||||||
sign = "+" if c["diff"] > 0 else ""
|
|
||||||
good = (c["diff"] < 0) if c["invert"] else (c["diff"] > 0)
|
|
||||||
detail_parts.append(
|
|
||||||
f"{c['label']}: {sign}{c['diff']} {c['unit']} {'✓' if good else '↑'}"
|
|
||||||
)
|
|
||||||
detail = " · ".join(detail_parts)
|
|
||||||
if len(positive) > len(negative):
|
|
||||||
st = "good"
|
|
||||||
title = "Positive Entwicklung seit letzter Messung"
|
|
||||||
elif len(negative) > len(positive):
|
|
||||||
st = "warn"
|
|
||||||
title = "Verschlechterung seit letzter Messung"
|
|
||||||
else:
|
|
||||||
st = "warn"
|
|
||||||
title = "Gemischte Entwicklung seit letzter Messung"
|
|
||||||
|
|
||||||
results.append(
|
|
||||||
{
|
|
||||||
"category": f"Seit letzter Messung ({days} Tage)",
|
|
||||||
"icon": "📊",
|
|
||||||
"status": st,
|
|
||||||
"title": title,
|
|
||||||
"detail": detail,
|
|
||||||
"value": f"{days}d",
|
|
||||||
"related_placeholder_keys": [
|
|
||||||
"caliper_summary",
|
|
||||||
"weight_trend",
|
|
||||||
"lbm_28d_change",
|
|
||||||
"waist_28d_delta",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
@ -370,8 +370,7 @@ def get_circumference_summary_data(
|
||||||
('c_hip', 'Hüfte'),
|
('c_hip', 'Hüfte'),
|
||||||
('c_thigh', 'Oberschenkel'),
|
('c_thigh', 'Oberschenkel'),
|
||||||
('c_calf', 'Wade'),
|
('c_calf', 'Wade'),
|
||||||
('c_arm', 'Oberarm kontrahiert'),
|
('c_arm', 'Arm')
|
||||||
('c_arm_relaxed', 'Oberarm'),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
measurements = []
|
measurements = []
|
||||||
|
|
@ -402,7 +401,7 @@ def get_circumference_summary_data(
|
||||||
})
|
})
|
||||||
|
|
||||||
# Calculate confidence based on how many points we have
|
# Calculate confidence based on how many points we have
|
||||||
confidence = calculate_confidence(len(measurements), 9, "general")
|
confidence = calculate_confidence(len(measurements), 8, "general")
|
||||||
|
|
||||||
if not measurements:
|
if not measurements:
|
||||||
return {
|
return {
|
||||||
|
|
@ -641,15 +640,10 @@ def calculate_chest_28d_delta(profile_id: str) -> Optional[float]:
|
||||||
|
|
||||||
|
|
||||||
def calculate_arm_28d_delta(profile_id: str) -> Optional[float]:
|
def calculate_arm_28d_delta(profile_id: str) -> Optional[float]:
|
||||||
"""28-Tage-Delta Oberarm kontrahiert (c_arm), cm."""
|
"""Calculate 28-day arm circumference change (cm)"""
|
||||||
return _calculate_circumference_delta(profile_id, 'c_arm', 28)
|
return _calculate_circumference_delta(profile_id, 'c_arm', 28)
|
||||||
|
|
||||||
|
|
||||||
def calculate_arm_relaxed_28d_delta(profile_id: str) -> Optional[float]:
|
|
||||||
"""28-Tage-Delta Oberarm entspannt (c_arm_relaxed), cm."""
|
|
||||||
return _calculate_circumference_delta(profile_id, 'c_arm_relaxed', 28)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_thigh_28d_delta(profile_id: str) -> Optional[float]:
|
def calculate_thigh_28d_delta(profile_id: str) -> Optional[float]:
|
||||||
"""Calculate 28-day thigh circumference change (cm)"""
|
"""Calculate 28-day thigh circumference change (cm)"""
|
||||||
delta = _calculate_circumference_delta(profile_id, 'c_thigh', 28)
|
delta = _calculate_circumference_delta(profile_id, 'c_thigh', 28)
|
||||||
|
|
|
||||||
|
|
@ -1,494 +0,0 @@
|
||||||
"""
|
|
||||||
Layer 2b: Structured body history / Verlauf «Körper» bundle.
|
|
||||||
|
|
||||||
Single source for Verlauf-UI: series + Kennzahlen + Interpretation tiles.
|
|
||||||
All queries use the same tables as Layer 1 / Layer 2a body placeholders.
|
|
||||||
|
|
||||||
See: placeholder_registrations/body_metrics.py, body_extras.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date, datetime, timedelta
|
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from data_layer.body_interpretation import get_body_interpretation_tiles
|
|
||||||
from data_layer.utils import safe_float
|
|
||||||
|
|
||||||
|
|
||||||
def _cutoff_sql(days: int) -> Optional[str]:
|
|
||||||
if days >= 9999:
|
|
||||||
return None
|
|
||||||
return (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
|
|
||||||
def _rolling_avg(rows: List[Dict[str, Any]], key: str, window: int) -> List[Dict[str, Any]]:
|
|
||||||
out: List[Dict[str, Any]] = []
|
|
||||||
for i, d in enumerate(rows):
|
|
||||||
sl = rows[max(0, i - window + 1) : i + 1]
|
|
||||||
vals: List[float] = []
|
|
||||||
for x in sl:
|
|
||||||
v = safe_float(x.get(key))
|
|
||||||
if v is not None:
|
|
||||||
vals.append(v)
|
|
||||||
if not vals:
|
|
||||||
out.append({**d, f"{key}_avg": None})
|
|
||||||
continue
|
|
||||||
avg = round(sum(vals) / len(vals), 1)
|
|
||||||
out.append({**d, f"{key}_avg": avg})
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _iso(d: Any) -> Optional[str]:
|
|
||||||
if d is None:
|
|
||||||
return None
|
|
||||||
if hasattr(d, "isoformat"):
|
|
||||||
return d.isoformat()
|
|
||||||
return str(d)[:10]
|
|
||||||
|
|
||||||
|
|
||||||
def _weight_trend_kpi(trend_periods: List[Dict[str, Any]]) -> Dict[str, str]:
|
|
||||||
"""
|
|
||||||
Kurzurteil Gewichtstrend (Schwelle ±0,25 kg, Priorität 90T → 30T → erste Periode).
|
|
||||||
Eine Quelle mit dem Verlauf-Bundle — kein paralleles Frontend-Routing mehr.
|
|
||||||
"""
|
|
||||||
if not trend_periods:
|
|
||||||
return {"verdict": "Stabil", "status": "good"}
|
|
||||||
t90 = next((t for t in trend_periods if t.get("label") == "90T"), None)
|
|
||||||
t30 = next((t for t in trend_periods if t.get("label") == "30T"), None)
|
|
||||||
d: Optional[float] = None
|
|
||||||
if t90 is not None and t90.get("diff_kg") is not None:
|
|
||||||
d = float(t90["diff_kg"])
|
|
||||||
elif t30 is not None and t30.get("diff_kg") is not None:
|
|
||||||
d = float(t30["diff_kg"])
|
|
||||||
elif trend_periods[0].get("diff_kg") is not None:
|
|
||||||
d = float(trend_periods[0]["diff_kg"])
|
|
||||||
else:
|
|
||||||
return {"verdict": "Stabil", "status": "good"}
|
|
||||||
if d < -0.25:
|
|
||||||
return {"verdict": "Trend ↓", "status": "good"}
|
|
||||||
if d > 0.25:
|
|
||||||
return {"verdict": "Trend ↑", "status": "warn"}
|
|
||||||
return {"verdict": "Stabil", "status": "good"}
|
|
||||||
|
|
||||||
|
|
||||||
def get_body_history_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Returns chart-ready series and interpretation tiles for the body history tab.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_id: profiles.id
|
|
||||||
days: analysis window (use >= 9999 for full history)
|
|
||||||
|
|
||||||
Tables: weight_log, caliper_log, circumference_log, profiles
|
|
||||||
"""
|
|
||||||
cutoff = _cutoff_sql(days)
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, sex, height, dob, goal_weight, goal_bf_pct
|
|
||||||
FROM profiles WHERE id = %s
|
|
||||||
""",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
pr = r2d(cur.fetchone())
|
|
||||||
if not pr:
|
|
||||||
return {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"message": "Profil nicht gefunden",
|
|
||||||
"profile": {},
|
|
||||||
"weight": {},
|
|
||||||
"caliper": {},
|
|
||||||
"circumference": {},
|
|
||||||
"interpretation_tiles": [],
|
|
||||||
"meta": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
profile_ui = {
|
|
||||||
"sex": pr.get("sex") or "m",
|
|
||||||
"height": safe_float(pr.get("height")) or 178.0,
|
|
||||||
"goal_weight_kg": safe_float(pr.get("goal_weight")),
|
|
||||||
"goal_bf_pct": safe_float(pr.get("goal_bf_pct")),
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Weight (same window as Verlauf-Filter) ────────────────────────────
|
|
||||||
if cutoff:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date, weight FROM weight_log
|
|
||||||
WHERE profile_id = %s AND date >= %s
|
|
||||||
ORDER BY date ASC
|
|
||||||
""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date, weight FROM weight_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
ORDER BY date ASC
|
|
||||||
""",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
wrows = [r2d(r) for r in cur.fetchall()]
|
|
||||||
w_points = [
|
|
||||||
{"date": r["date"], "weight": safe_float(r["weight"])}
|
|
||||||
for r in wrows
|
|
||||||
if r.get("weight") is not None
|
|
||||||
]
|
|
||||||
w_with_avg7 = _rolling_avg([dict(x) for x in w_points], "weight", 7)
|
|
||||||
w_with_avg14 = _rolling_avg([dict(x) for x in w_points], "weight", 14)
|
|
||||||
weight_series: List[Dict[str, Any]] = []
|
|
||||||
for i, base in enumerate(w_points):
|
|
||||||
weight_series.append(
|
|
||||||
{
|
|
||||||
"date": _iso(base["date"]),
|
|
||||||
"weight": base["weight"],
|
|
||||||
"avg7": w_with_avg7[i].get("weight_avg") if i < len(w_with_avg7) else None,
|
|
||||||
"avg14": w_with_avg14[i].get("weight_avg") if i < len(w_with_avg14) else None,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ws = [p["weight"] for p in w_points if p.get("weight") is not None]
|
|
||||||
overall_avg = round(sum(ws) / len(ws), 1) if len(ws) else None
|
|
||||||
min_w = min(ws) if ws else None
|
|
||||||
max_w = max(ws) if ws else None
|
|
||||||
|
|
||||||
today = datetime.now().date()
|
|
||||||
trend_periods: List[Dict[str, Any]] = []
|
|
||||||
for span in (7, 30, 90):
|
|
||||||
cut = today - timedelta(days=span)
|
|
||||||
per = [p for p in w_points if p["date"] >= cut]
|
|
||||||
if len(per) >= 2:
|
|
||||||
diff = round(float(per[-1]["weight"]) - float(per[0]["weight"]), 1)
|
|
||||||
trend_periods.append({"label": f"{span}T", "diff_kg": diff, "count": len(per)})
|
|
||||||
|
|
||||||
# ── Caliper series ───────────────────────────────────────────────────
|
|
||||||
if cutoff:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date, body_fat_pct, lean_mass, fat_mass
|
|
||||||
FROM caliper_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND body_fat_pct IS NOT NULL
|
|
||||||
AND date >= %s
|
|
||||||
ORDER BY date ASC
|
|
||||||
""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date, body_fat_pct, lean_mass, fat_mass
|
|
||||||
FROM caliper_log
|
|
||||||
WHERE profile_id = %s AND body_fat_pct IS NOT NULL
|
|
||||||
ORDER BY date ASC
|
|
||||||
""",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
cal_rows = [r2d(r) for r in cur.fetchall()]
|
|
||||||
caliper_series = [
|
|
||||||
{
|
|
||||||
"date": _iso(r["date"]),
|
|
||||||
"body_fat_pct": safe_float(r.get("body_fat_pct")),
|
|
||||||
"lean_mass": safe_float(r.get("lean_mass")),
|
|
||||||
}
|
|
||||||
for r in cal_rows
|
|
||||||
]
|
|
||||||
|
|
||||||
# Latest / prev caliper in window (for interpretation)
|
|
||||||
if cutoff:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date, body_fat_pct, lean_mass
|
|
||||||
FROM caliper_log
|
|
||||||
WHERE profile_id = %s AND date >= %s
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 2
|
|
||||||
""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date, body_fat_pct, lean_mass
|
|
||||||
FROM caliper_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 2
|
|
||||||
""",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
cal_latest_rows = [r2d(r) for r in cur.fetchall()]
|
|
||||||
latest_cal = cal_latest_rows[0] if cal_latest_rows else None
|
|
||||||
prev_cal = cal_latest_rows[1] if len(cal_latest_rows) > 1 else None
|
|
||||||
|
|
||||||
# ── Circumference rows ───────────────────────────────────────────────
|
|
||||||
if cutoff:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date, c_chest, c_waist, c_hip, c_belly
|
|
||||||
FROM circumference_log
|
|
||||||
WHERE profile_id = %s AND date >= %s
|
|
||||||
ORDER BY date ASC
|
|
||||||
""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date, c_chest, c_waist, c_hip, c_belly
|
|
||||||
FROM circumference_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
ORDER BY date ASC
|
|
||||||
""",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
cir_rows = [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
if cutoff:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date, c_chest, c_waist, c_hip, c_belly
|
|
||||||
FROM circumference_log
|
|
||||||
WHERE profile_id = %s AND date >= %s
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 2
|
|
||||||
""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date, c_chest, c_waist, c_hip, c_belly
|
|
||||||
FROM circumference_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 2
|
|
||||||
""",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
circ_latest_desc = [r2d(r) for r in cur.fetchall()]
|
|
||||||
latest_circ_row = circ_latest_desc[0] if circ_latest_desc else None
|
|
||||||
prev_circ_row = circ_latest_desc[1] if len(circ_latest_desc) > 1 else None
|
|
||||||
|
|
||||||
# Latest weight in window
|
|
||||||
latest_w = w_points[-1] if w_points else None
|
|
||||||
|
|
||||||
# ── Proportion & index (computed from L1 rows only) ─────────────────────
|
|
||||||
prop_base: List[Dict[str, Any]] = []
|
|
||||||
for r in cir_rows:
|
|
||||||
ch = safe_float(r.get("c_chest"))
|
|
||||||
wa = safe_float(r.get("c_waist"))
|
|
||||||
if ch is None or wa is None:
|
|
||||||
continue
|
|
||||||
belly = safe_float(r.get("c_belly"))
|
|
||||||
prop_base.append(
|
|
||||||
{
|
|
||||||
"date": _iso(r["date"]),
|
|
||||||
"v_taper_cm": round(ch - wa, 1),
|
|
||||||
"belly_cm": belly,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
prop_chart = _rolling_avg([dict(x) for x in prop_base], "v_taper_cm", 3) if len(prop_base) >= 2 else []
|
|
||||||
for i, row in enumerate(prop_chart):
|
|
||||||
row["belly_cm"] = prop_base[i].get("belly_cm")
|
|
||||||
|
|
||||||
fb_first: Dict[str, Optional[float]] = {"chest": None, "waist": None, "belly": None}
|
|
||||||
for r in cir_rows:
|
|
||||||
if fb_first["chest"] is None and r.get("c_chest") is not None:
|
|
||||||
fb_first["chest"] = safe_float(r["c_chest"])
|
|
||||||
if fb_first["waist"] is None and r.get("c_waist") is not None:
|
|
||||||
fb_first["waist"] = safe_float(r["c_waist"])
|
|
||||||
if fb_first["belly"] is None and r.get("c_belly") is not None:
|
|
||||||
fb_first["belly"] = safe_float(r["c_belly"])
|
|
||||||
|
|
||||||
index_series: List[Dict[str, Any]] = []
|
|
||||||
for r in cir_rows:
|
|
||||||
idx_row: Dict[str, Any] = {"date": _iso(r["date"])}
|
|
||||||
cc = safe_float(r.get("c_chest"))
|
|
||||||
ww = safe_float(r.get("c_waist"))
|
|
||||||
bb = safe_float(r.get("c_belly"))
|
|
||||||
if cc is not None and fb_first["chest"]:
|
|
||||||
idx_row["chest_idx"] = round(cc / fb_first["chest"] * 100, 1)
|
|
||||||
else:
|
|
||||||
idx_row["chest_idx"] = None
|
|
||||||
if ww is not None and fb_first["waist"]:
|
|
||||||
idx_row["waist_idx"] = round(ww / fb_first["waist"] * 100, 1)
|
|
||||||
else:
|
|
||||||
idx_row["waist_idx"] = None
|
|
||||||
if bb is not None and fb_first["belly"]:
|
|
||||||
idx_row["belly_idx"] = round(bb / fb_first["belly"] * 100, 1)
|
|
||||||
else:
|
|
||||||
idx_row["belly_idx"] = None
|
|
||||||
index_series.append(idx_row)
|
|
||||||
|
|
||||||
idx_nonempty = sum(
|
|
||||||
1
|
|
||||||
for row in index_series
|
|
||||||
if row.get("chest_idx") is not None
|
|
||||||
or row.get("waist_idx") is not None
|
|
||||||
or row.get("belly_idx") is not None
|
|
||||||
)
|
|
||||||
|
|
||||||
fallback_circ = [
|
|
||||||
{
|
|
||||||
"date": _iso(r["date"]),
|
|
||||||
"waist": safe_float(r.get("c_waist")),
|
|
||||||
"hip": safe_float(r.get("c_hip")),
|
|
||||||
"belly": safe_float(r.get("c_belly")),
|
|
||||||
}
|
|
||||||
for r in cir_rows
|
|
||||||
if r.get("c_waist") or r.get("c_hip") or r.get("c_belly")
|
|
||||||
]
|
|
||||||
|
|
||||||
# ── Merge measurement for interpretation ────────────────────────────────
|
|
||||||
measurement: Dict[str, Any] = {}
|
|
||||||
if latest_cal:
|
|
||||||
measurement.update(
|
|
||||||
{
|
|
||||||
"date": latest_cal.get("date"),
|
|
||||||
"body_fat_pct": safe_float(latest_cal.get("body_fat_pct")),
|
|
||||||
"lean_mass": safe_float(latest_cal.get("lean_mass")),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if latest_circ_row:
|
|
||||||
measurement["c_waist"] = safe_float(latest_circ_row.get("c_waist"))
|
|
||||||
measurement["c_hip"] = safe_float(latest_circ_row.get("c_hip"))
|
|
||||||
measurement["c_belly"] = safe_float(latest_circ_row.get("c_belly"))
|
|
||||||
if latest_w:
|
|
||||||
measurement["weight"] = safe_float(latest_w.get("weight"))
|
|
||||||
# Referenzdatum für „aktuell“: neueste verfügbare Quelle (Caliper > Umfang > Gewicht)
|
|
||||||
if not measurement.get("date"):
|
|
||||||
if latest_circ_row and latest_circ_row.get("date"):
|
|
||||||
measurement["date"] = latest_circ_row.get("date")
|
|
||||||
elif latest_w and latest_w.get("date"):
|
|
||||||
measurement["date"] = latest_w.get("date")
|
|
||||||
|
|
||||||
# Vorperiode: vorherige Caliper-Zeile + vorherige Umfangsmessung + vorheriges Gewicht (w_points[-2])
|
|
||||||
prev_for_interp: Optional[Dict[str, Any]] = {}
|
|
||||||
if prev_cal:
|
|
||||||
prev_for_interp["date"] = prev_cal.get("date")
|
|
||||||
prev_for_interp["body_fat_pct"] = safe_float(prev_cal.get("body_fat_pct"))
|
|
||||||
prev_for_interp["lean_mass"] = safe_float(prev_cal.get("lean_mass"))
|
|
||||||
if prev_circ_row:
|
|
||||||
prev_for_interp["c_waist"] = safe_float(prev_circ_row.get("c_waist"))
|
|
||||||
prev_for_interp["c_hip"] = safe_float(prev_circ_row.get("c_hip"))
|
|
||||||
prev_for_interp["c_belly"] = safe_float(prev_circ_row.get("c_belly"))
|
|
||||||
if not prev_for_interp.get("date") and prev_circ_row.get("date"):
|
|
||||||
prev_for_interp["date"] = prev_circ_row.get("date")
|
|
||||||
if len(w_points) >= 2:
|
|
||||||
prev_for_interp["weight"] = safe_float(w_points[-2].get("weight"))
|
|
||||||
if not prev_for_interp.get("date") and w_points[-2].get("date"):
|
|
||||||
prev_for_interp["date"] = w_points[-2].get("date")
|
|
||||||
|
|
||||||
if not prev_for_interp:
|
|
||||||
prev_for_interp = None
|
|
||||||
else:
|
|
||||||
# Mindestens ein vergleichbares Feld zur aktuellen Messung
|
|
||||||
has_cmp = any(
|
|
||||||
prev_for_interp.get(k) is not None
|
|
||||||
for k in ("body_fat_pct", "lean_mass", "weight", "c_waist", "c_belly")
|
|
||||||
)
|
|
||||||
if not has_cmp:
|
|
||||||
prev_for_interp = None
|
|
||||||
|
|
||||||
tiles = get_body_interpretation_tiles(measurement, profile_ui, prev_for_interp)
|
|
||||||
|
|
||||||
last_dates: List[date] = []
|
|
||||||
if w_points:
|
|
||||||
last_dates.append(w_points[-1]["date"])
|
|
||||||
if latest_cal and latest_cal.get("date"):
|
|
||||||
d = latest_cal["date"]
|
|
||||||
if isinstance(d, str):
|
|
||||||
d = datetime.fromisoformat(d[:10]).date()
|
|
||||||
last_dates.append(d)
|
|
||||||
if latest_circ_row and latest_circ_row.get("date"):
|
|
||||||
d = latest_circ_row["date"]
|
|
||||||
if isinstance(d, str):
|
|
||||||
d = datetime.fromisoformat(d[:10]).date()
|
|
||||||
last_dates.append(d)
|
|
||||||
last_updated = max(last_dates).isoformat() if last_dates else None
|
|
||||||
|
|
||||||
bf_cat = None
|
|
||||||
if measurement.get("body_fat_pct") is not None:
|
|
||||||
# simple label bucket (aligned with frontend BF_CATEGORIES order)
|
|
||||||
bf = float(measurement["body_fat_pct"])
|
|
||||||
sex = profile_ui["sex"]
|
|
||||||
if sex == "f":
|
|
||||||
labels = ["Essenziell", "Athletisch", "Fit", "Durchschnitt", "Übergewicht"]
|
|
||||||
bounds = [14, 21, 25, 32, 1000]
|
|
||||||
else:
|
|
||||||
labels = ["Essenziell", "Athletisch", "Fit", "Durchschnitt", "Übergewicht"]
|
|
||||||
bounds = [6, 14, 18, 25, 1000]
|
|
||||||
for i, b in enumerate(bounds):
|
|
||||||
if bf <= b:
|
|
||||||
bf_cat = labels[i]
|
|
||||||
break
|
|
||||||
|
|
||||||
summary = {
|
|
||||||
"weight_kg": measurement.get("weight"),
|
|
||||||
"body_fat_pct": measurement.get("body_fat_pct"),
|
|
||||||
"lean_mass_kg": measurement.get("lean_mass"),
|
|
||||||
"whr": (
|
|
||||||
round(measurement["c_waist"] / measurement["c_hip"], 2)
|
|
||||||
if measurement.get("c_waist") and measurement.get("c_hip")
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
"whtr": (
|
|
||||||
round(measurement["c_waist"] / profile_ui["height"], 2)
|
|
||||||
if measurement.get("c_waist") and profile_ui.get("height")
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
"ffmi": None,
|
|
||||||
"bf_category_label": bf_cat,
|
|
||||||
}
|
|
||||||
if measurement.get("lean_mass") and profile_ui.get("height"):
|
|
||||||
hm = float(profile_ui["height"]) / 100.0
|
|
||||||
summary["ffmi"] = round(float(measurement["lean_mass"]) / (hm**2), 1)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"confidence": "high" if w_points or caliper_series or cir_rows else "insufficient",
|
|
||||||
"days_requested": days,
|
|
||||||
"last_updated": last_updated,
|
|
||||||
"profile": profile_ui,
|
|
||||||
"summary": summary,
|
|
||||||
"weight": {
|
|
||||||
"series": weight_series,
|
|
||||||
"overall_avg_kg": overall_avg,
|
|
||||||
"min_kg": min_w,
|
|
||||||
"max_kg": max_w,
|
|
||||||
"trend_periods": trend_periods,
|
|
||||||
"trend_kpi": _weight_trend_kpi(trend_periods),
|
|
||||||
"data_points": len(w_points),
|
|
||||||
"related_placeholder_keys": [
|
|
||||||
"weight_aktuell",
|
|
||||||
"weight_trend",
|
|
||||||
"weight_7d_median",
|
|
||||||
"weight_28d_slope",
|
|
||||||
"weight_90d_slope",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"caliper": {
|
|
||||||
"series": caliper_series,
|
|
||||||
"data_points": len(caliper_series),
|
|
||||||
"related_placeholder_keys": ["caliper_summary", "fm_28d_change", "lbm_28d_change"],
|
|
||||||
},
|
|
||||||
"circumference": {
|
|
||||||
"proportion_series": prop_chart,
|
|
||||||
"index_series": index_series,
|
|
||||||
"index_usable": idx_nonempty >= 2 and any(v for v in fb_first.values()),
|
|
||||||
"fallback_multiline": fallback_circ,
|
|
||||||
"has_chest_waist": len(prop_base) >= 2,
|
|
||||||
"related_placeholder_keys": ["circ_summary", "waist_hip_ratio", "waist_28d_delta"],
|
|
||||||
},
|
|
||||||
"interpretation_tiles": tiles,
|
|
||||||
"meta": {
|
|
||||||
"layer_1": "data_layer.body_viz + data_layer.body_interpretation",
|
|
||||||
"layer_2b": "This bundle — sole numeric source for Verlauf Körper charts/tiles",
|
|
||||||
"layer_2a_alignment": "Tiles carry related_placeholder_keys; metrics from same tables as body_metrics placeholders",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -1,256 +0,0 @@
|
||||||
"""
|
|
||||||
Chart.js-kompatible Payloads für Lag-Korrelationen C1–C3 und Treiber C4.
|
|
||||||
|
|
||||||
Gemeinsame Quelle für GET /charts/* und history_overview_viz.chart_payloads (Issue 53).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from data_layer.correlations import calculate_lag_correlation, calculate_top_drivers
|
|
||||||
|
|
||||||
|
|
||||||
def build_weight_energy_correlation_chart_payload(profile_id: str, max_lag: int) -> Dict[str, Any]:
|
|
||||||
corr_data = calculate_lag_correlation(profile_id, "energy_balance", "weight", max_lag)
|
|
||||||
|
|
||||||
if not corr_data or corr_data.get("correlation") is None:
|
|
||||||
msg = "Nicht genug Daten für Korrelationsanalyse"
|
|
||||||
if isinstance(corr_data, dict):
|
|
||||||
msg = str(corr_data.get("interpretation") or corr_data.get("reason") or msg)
|
|
||||||
return {
|
|
||||||
"chart_type": "scatter",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": corr_data.get("data_points", 0) if isinstance(corr_data, dict) else 0,
|
|
||||||
"message": msg,
|
|
||||||
"lag_details": corr_data.get("lag_details") if isinstance(corr_data, dict) else None,
|
|
||||||
"tdee_kcal_used": corr_data.get("tdee_kcal_used") if isinstance(corr_data, dict) else None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
best_lag = corr_data.get("best_lag_days", corr_data.get("best_lag", 0))
|
|
||||||
correlation = corr_data.get("correlation", 0)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "scatter",
|
|
||||||
"data": {
|
|
||||||
"labels": [f"Lag {best_lag} Tage"],
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "Korrelation",
|
|
||||||
"data": [{"x": best_lag, "y": correlation}],
|
|
||||||
"backgroundColor": "#1D9E75",
|
|
||||||
"borderColor": "#085041",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"pointRadius": 8,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": corr_data.get("confidence", "low"),
|
|
||||||
"correlation": round(float(correlation), 3),
|
|
||||||
"best_lag_days": best_lag,
|
|
||||||
"interpretation": corr_data.get("interpretation", ""),
|
|
||||||
"data_points": corr_data.get("data_points", 0),
|
|
||||||
"lag_details": corr_data.get("lag_details"),
|
|
||||||
"tdee_kcal_used": corr_data.get("tdee_kcal_used"),
|
|
||||||
"layer_1": "correlations._correlate_energy_weight",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_lbm_protein_correlation_chart_payload(profile_id: str, max_lag: int) -> Dict[str, Any]:
|
|
||||||
corr_data = calculate_lag_correlation(profile_id, "protein", "lbm", max_lag)
|
|
||||||
|
|
||||||
if not corr_data or corr_data.get("correlation") is None:
|
|
||||||
msg = "Nicht genug Daten für LBM-Protein Korrelation"
|
|
||||||
if isinstance(corr_data, dict):
|
|
||||||
msg = str(corr_data.get("interpretation") or corr_data.get("reason") or msg)
|
|
||||||
return {
|
|
||||||
"chart_type": "scatter",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": corr_data.get("data_points", 0) if isinstance(corr_data, dict) else 0,
|
|
||||||
"message": msg,
|
|
||||||
"lag_details": corr_data.get("lag_details") if isinstance(corr_data, dict) else None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
best_lag = corr_data.get("best_lag_days", corr_data.get("best_lag", 0))
|
|
||||||
correlation = corr_data.get("correlation", 0)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "scatter",
|
|
||||||
"data": {
|
|
||||||
"labels": [f"Lag {best_lag} Tage"],
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "Korrelation",
|
|
||||||
"data": [{"x": best_lag, "y": correlation}],
|
|
||||||
"backgroundColor": "#3B82F6",
|
|
||||||
"borderColor": "#1E40AF",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"pointRadius": 8,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": corr_data.get("confidence", "low"),
|
|
||||||
"correlation": round(float(correlation), 3),
|
|
||||||
"best_lag_days": best_lag,
|
|
||||||
"interpretation": corr_data.get("interpretation", ""),
|
|
||||||
"data_points": corr_data.get("data_points", 0),
|
|
||||||
"lag_details": corr_data.get("lag_details"),
|
|
||||||
"layer_1": "correlations._correlate_protein_lbm",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_load_vitals_correlation_chart_payload(profile_id: str, max_lag: int) -> Dict[str, Any]:
|
|
||||||
corr_hrv = calculate_lag_correlation(profile_id, "load", "hrv", max_lag)
|
|
||||||
corr_rhr = calculate_lag_correlation(profile_id, "load", "rhr", max_lag)
|
|
||||||
|
|
||||||
def _abs_corr(c: Any) -> float:
|
|
||||||
if not c or c.get("correlation") is None:
|
|
||||||
return -1.0
|
|
||||||
try:
|
|
||||||
return abs(float(c["correlation"]))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return -1.0
|
|
||||||
|
|
||||||
if _abs_corr(corr_hrv) < 0 and _abs_corr(corr_rhr) < 0:
|
|
||||||
msg = "Nicht genug Daten für Load-Vitals Korrelation"
|
|
||||||
h_msg = corr_hrv.get("interpretation") if isinstance(corr_hrv, dict) else None
|
|
||||||
r_msg = corr_rhr.get("interpretation") if isinstance(corr_rhr, dict) else None
|
|
||||||
if h_msg or r_msg:
|
|
||||||
msg = f"HRV: {h_msg or '—'} · RHR: {r_msg or '—'}"
|
|
||||||
return {
|
|
||||||
"chart_type": "scatter",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": msg,
|
|
||||||
"lag_details_hrv": corr_hrv.get("lag_details") if isinstance(corr_hrv, dict) else None,
|
|
||||||
"lag_details_rhr": corr_rhr.get("lag_details") if isinstance(corr_rhr, dict) else None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if _abs_corr(corr_hrv) >= _abs_corr(corr_rhr):
|
|
||||||
corr_data = corr_hrv
|
|
||||||
metric_name = "HRV"
|
|
||||||
else:
|
|
||||||
corr_data = corr_rhr
|
|
||||||
metric_name = "RHR"
|
|
||||||
|
|
||||||
if not corr_data or corr_data.get("correlation") is None:
|
|
||||||
return {
|
|
||||||
"chart_type": "scatter",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": str(corr_data.get("interpretation") or "Nicht genug Daten für Load-Vitals Korrelation"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
best_lag = corr_data.get("best_lag_days", corr_data.get("best_lag", 0))
|
|
||||||
correlation = corr_data.get("correlation", 0)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "scatter",
|
|
||||||
"data": {
|
|
||||||
"labels": [f"Load → {metric_name} (Lag {best_lag}d)"],
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "Korrelation",
|
|
||||||
"data": [{"x": best_lag, "y": correlation}],
|
|
||||||
"backgroundColor": "#F59E0B",
|
|
||||||
"borderColor": "#D97706",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"pointRadius": 8,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": corr_data.get("confidence", "low"),
|
|
||||||
"correlation": round(float(correlation), 3),
|
|
||||||
"best_lag_days": best_lag,
|
|
||||||
"metric": metric_name,
|
|
||||||
"interpretation": corr_data.get("interpretation", ""),
|
|
||||||
"data_points": corr_data.get("data_points", 0),
|
|
||||||
"lag_details": corr_data.get("lag_details"),
|
|
||||||
"layer_1": "correlations._correlate_load_vitals",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_recovery_performance_chart_payload(profile_id: str) -> Dict[str, Any]:
|
|
||||||
drivers = calculate_top_drivers(profile_id)
|
|
||||||
|
|
||||||
if not drivers or len(drivers) == 0:
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Nicht genug Daten für Driver-Analyse",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
hindering = [d for d in drivers if d.get("impact", "") == "hindering"]
|
|
||||||
helpful = [d for d in drivers if d.get("impact", "") == "helpful"]
|
|
||||||
|
|
||||||
top_hindering = hindering[:3]
|
|
||||||
top_helpful = helpful[:3]
|
|
||||||
|
|
||||||
labels = []
|
|
||||||
values = []
|
|
||||||
colors = []
|
|
||||||
|
|
||||||
for d in top_hindering:
|
|
||||||
labels.append(f"❌ {d.get('factor', '')}")
|
|
||||||
values.append(-abs(d.get("score", 0)))
|
|
||||||
colors.append("#EF4444")
|
|
||||||
|
|
||||||
for d in top_helpful:
|
|
||||||
labels.append(f"✅ {d.get('factor', '')}")
|
|
||||||
values.append(abs(d.get("score", 0)))
|
|
||||||
colors.append("#1D9E75")
|
|
||||||
|
|
||||||
if not labels:
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "low",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Keine signifikanten Treiber gefunden",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {
|
|
||||||
"labels": labels,
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "Impact Score",
|
|
||||||
"data": values,
|
|
||||||
"backgroundColor": colors,
|
|
||||||
"borderColor": "#085041",
|
|
||||||
"borderWidth": 1,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "medium",
|
|
||||||
"hindering_count": len(top_hindering),
|
|
||||||
"helpful_count": len(top_helpful),
|
|
||||||
"total_factors": len(drivers),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -17,402 +17,117 @@ Phase 0c: Multi-Layer Architecture
|
||||||
Version: 1.0
|
Version: 1.0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from datetime import datetime, timedelta, date
|
from datetime import datetime, timedelta, date
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
import statistics
|
import statistics
|
||||||
|
|
||||||
from data_layer.nutrition_body_merge import build_merged_daily_nutrition_body_rows
|
|
||||||
from data_layer.nutrition_metrics import estimate_tdee_kcal_from_latest_weight
|
|
||||||
|
|
||||||
# Lag-Korrelation (Issue #53): gleiche TDEE-Logik wie nutrition_metrics / nutrition_viz
|
|
||||||
MIN_PAIRS_LAG_CORR = 15
|
|
||||||
LAG_CORR_LOOKBACK_DAYS = 120
|
|
||||||
|
|
||||||
def calculate_lag_correlation(profile_id: str, var1: str, var2: str, max_lag_days: int = 14) -> Optional[Dict]:
|
def calculate_lag_correlation(profile_id: str, var1: str, var2: str, max_lag_days: int = 14) -> Optional[Dict]:
|
||||||
"""
|
"""
|
||||||
Pearson-Korrelation mit Lag-Sweep (Issue 53, Data-Layer).
|
Calculate lagged correlation between two variables
|
||||||
|
|
||||||
C1: Tagesbilanz (kcal − TDEE wie ``estimate_tdee_kcal_from_latest_weight``) vs. ΔGewicht [t→t+L], L≥1.
|
Args:
|
||||||
C2: Protein (g) vs. ΔMager [t→t+L] aus ``build_merged_daily_nutrition_body_rows``, L≥1.
|
var1: 'energy', 'protein', 'training_load'
|
||||||
C3: Summe ``duration_min`` pro Tag vs. HRV oder Ruhepuls am Tag t+L (L≥0).
|
var2: 'weight', 'lbm', 'hrv', 'rhr'
|
||||||
|
max_lag_days: Maximum lag to test
|
||||||
|
|
||||||
Rückgabe enthält u. a. ``best_lag`` / ``best_lag_days``, ``correlation``, ``interpretation``,
|
Returns:
|
||||||
optional ``lag_details`` (r, n je Lag), mindestens ``MIN_PAIRS_LAG_CORR`` Paare am besten Lag.
|
{
|
||||||
|
'best_lag': X, # days
|
||||||
|
'correlation': 0.XX, # -1 to 1
|
||||||
|
'direction': 'positive'/'negative'/'none',
|
||||||
|
'confidence': 'high'/'medium'/'low',
|
||||||
|
'data_points': N
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
v1 = (var1 or "").strip().lower()
|
if var1 == 'energy' and var2 == 'weight':
|
||||||
if v1 in ("energy", "energy_balance"):
|
return _correlate_energy_weight(profile_id, max_lag_days)
|
||||||
v1n = "energy"
|
elif var1 == 'protein' and var2 == 'lbm':
|
||||||
elif v1 in ("training_load", "load"):
|
return _correlate_protein_lbm(profile_id, max_lag_days)
|
||||||
v1n = "training_load"
|
elif var1 == 'training_load' and var2 in ['hrv', 'rhr']:
|
||||||
elif v1 == "protein":
|
return _correlate_load_vitals(profile_id, var2, max_lag_days)
|
||||||
v1n = "protein"
|
|
||||||
else:
|
|
||||||
v1n = v1
|
|
||||||
|
|
||||||
if v1n == 'energy' and var2 == 'weight':
|
|
||||||
return _normalize_lag_payload(_correlate_energy_weight(profile_id, max_lag_days))
|
|
||||||
elif v1n == 'protein' and var2 == 'lbm':
|
|
||||||
return _normalize_lag_payload(_correlate_protein_lbm(profile_id, max_lag_days))
|
|
||||||
elif v1n == 'training_load' and var2 in ['hrv', 'rhr']:
|
|
||||||
return _normalize_lag_payload(_correlate_load_vitals(profile_id, var2, max_lag_days))
|
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _normalize_lag_payload(raw: Optional[Dict]) -> Optional[Dict]:
|
|
||||||
"""Charts erwarten u. a. ``best_lag_days``; Layer liefert teils ``best_lag``."""
|
|
||||||
if not raw:
|
|
||||||
return None
|
|
||||||
out = dict(raw)
|
|
||||||
if out.get("best_lag_days") is None and out.get("best_lag") is not None:
|
|
||||||
out["best_lag_days"] = out["best_lag"]
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _iso_date_key(d: Any) -> str:
|
|
||||||
if d is None:
|
|
||||||
return ""
|
|
||||||
if hasattr(d, "isoformat"):
|
|
||||||
return str(d.isoformat())[:10]
|
|
||||||
s = str(d)
|
|
||||||
return s[:10] if len(s) >= 10 else s
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_iso_to_date(ds: str) -> Optional[date]:
|
|
||||||
if not ds or len(ds) < 10:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return date.fromisoformat(ds[:10])
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _pearson_r(xs: List[float], ys: List[float]) -> Optional[float]:
|
|
||||||
"""Pearson-Korrelation; mindestens ``MIN_PAIRS_LAG_CORR`` Paare."""
|
|
||||||
n = len(xs)
|
|
||||||
if n < MIN_PAIRS_LAG_CORR or n != len(ys):
|
|
||||||
return None
|
|
||||||
mx = sum(xs) / n
|
|
||||||
my = sum(ys) / n
|
|
||||||
num = sum((xs[i] - mx) * (ys[i] - my) for i in range(n))
|
|
||||||
dx = sum((xs[i] - mx) ** 2 for i in range(n))
|
|
||||||
dy = sum((ys[i] - my) ** 2 for i in range(n))
|
|
||||||
if dx <= 1e-12 or dy <= 1e-12:
|
|
||||||
return None
|
|
||||||
r = num / ((dx**0.5) * (dy**0.5))
|
|
||||||
return float(max(-1.0, min(1.0, r)))
|
|
||||||
|
|
||||||
|
|
||||||
def _direction_from_r(r: float) -> str:
|
|
||||||
if r > 0.05:
|
|
||||||
return "positive"
|
|
||||||
if r < -0.05:
|
|
||||||
return "negative"
|
|
||||||
return "none"
|
|
||||||
|
|
||||||
|
|
||||||
def _lag_confidence(n_pairs: int, r: float) -> str:
|
|
||||||
return calculate_correlation_confidence(n_pairs, abs(r))
|
|
||||||
|
|
||||||
|
|
||||||
def _correlate_energy_weight(profile_id: str, max_lag: int) -> Optional[Dict]:
|
def _correlate_energy_weight(profile_id: str, max_lag: int) -> Optional[Dict]:
|
||||||
"""
|
"""
|
||||||
Pearson: Tagesbilanz (kcal − TDEE wie nutrition_metrics) vs. Gewichtsdifferenz
|
Correlate energy balance with weight change
|
||||||
vom Tag t zu Tag t+L (L = 0 … max_lag). Bestes Lag nach maximalem |r|.
|
Test lags: 0, 3, 7, 10, 14 days
|
||||||
"""
|
"""
|
||||||
tdee = estimate_tdee_kcal_from_latest_weight(profile_id)
|
|
||||||
if tdee is None or float(tdee) <= 0:
|
|
||||||
return {
|
|
||||||
"best_lag": None,
|
|
||||||
"correlation": None,
|
|
||||||
"direction": "none",
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"interpretation": "Keine TDEE-Schätzung möglich (Gewicht/Demografie).",
|
|
||||||
"reason": "no_tdee",
|
|
||||||
}
|
|
||||||
|
|
||||||
tdee_f = float(tdee)
|
|
||||||
cutoff = (datetime.now() - timedelta(days=LAG_CORR_LOOKBACK_DAYS)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date::date AS d, SUM(kcal)::float AS kcal
|
|
||||||
FROM nutrition_log
|
|
||||||
WHERE profile_id = %s AND date >= %s::date AND kcal IS NOT NULL
|
|
||||||
GROUP BY date
|
|
||||||
ORDER BY date
|
|
||||||
""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
kcal_rows = cur.fetchall()
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date::date AS d, weight::float AS weight
|
|
||||||
FROM weight_log
|
|
||||||
WHERE profile_id = %s AND date >= %s::date AND weight IS NOT NULL
|
|
||||||
ORDER BY date
|
|
||||||
""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
w_rows = cur.fetchall()
|
|
||||||
|
|
||||||
kcal_by: Dict[str, float] = {}
|
# Get energy balance data (daily calories - estimated TDEE)
|
||||||
for r in kcal_rows:
|
cur.execute("""
|
||||||
kcal_by[_iso_date_key(r["d"])] = float(r["kcal"] or 0)
|
SELECT n.date, n.kcal, w.weight
|
||||||
weight_by: Dict[str, float] = {}
|
FROM nutrition_log n
|
||||||
for r in w_rows:
|
LEFT JOIN weight_log w ON w.profile_id = n.profile_id
|
||||||
weight_by[_iso_date_key(r["d"])] = float(r["weight"])
|
AND w.date = n.date
|
||||||
|
WHERE n.profile_id = %s
|
||||||
|
AND n.date >= CURRENT_DATE - INTERVAL '90 days'
|
||||||
|
ORDER BY n.date
|
||||||
|
""", (profile_id,))
|
||||||
|
|
||||||
balance_by = {d: kcal_by[d] - tdee_f for d in kcal_by}
|
data = cur.fetchall()
|
||||||
|
|
||||||
best: Optional[Tuple[int, float, int]] = None
|
if len(data) < 30:
|
||||||
lag_details: List[Dict[str, Any]] = []
|
return {
|
||||||
|
'best_lag': None,
|
||||||
|
'correlation': None,
|
||||||
|
'direction': 'none',
|
||||||
|
'confidence': 'low',
|
||||||
|
'data_points': len(data),
|
||||||
|
'reason': 'Insufficient data (<30 days)'
|
||||||
|
}
|
||||||
|
|
||||||
max_l = max(0, min(int(max_lag), 28))
|
# Calculate 7d rolling energy balance
|
||||||
# Lag 0: ΔGewicht am selben Tag ist immer 0 → sinnvoll erst ab Tag 1
|
# (Simplified - actual implementation would need TDEE estimation)
|
||||||
for lag in range(1, max_l + 1):
|
|
||||||
xs: List[float] = []
|
|
||||||
ys: List[float] = []
|
|
||||||
for ds in sorted(balance_by.keys()):
|
|
||||||
d0 = _parse_iso_to_date(ds)
|
|
||||||
if d0 is None:
|
|
||||||
continue
|
|
||||||
d1 = d0 + timedelta(days=lag)
|
|
||||||
ds1 = d1.isoformat()
|
|
||||||
w0 = weight_by.get(ds)
|
|
||||||
w1 = weight_by.get(ds1)
|
|
||||||
if w0 is None or w1 is None:
|
|
||||||
continue
|
|
||||||
xs.append(balance_by[ds])
|
|
||||||
ys.append(w1 - w0)
|
|
||||||
r = _pearson_r(xs, ys)
|
|
||||||
n_p = len(xs)
|
|
||||||
lag_details.append({"lag": lag, "n_pairs": n_p, "r": None if r is None else round(r, 4)})
|
|
||||||
if r is None:
|
|
||||||
continue
|
|
||||||
if best is None or abs(r) > abs(best[1]):
|
|
||||||
best = (lag, r, n_p)
|
|
||||||
|
|
||||||
if best is None:
|
|
||||||
return {
|
|
||||||
"best_lag": None,
|
|
||||||
"correlation": None,
|
|
||||||
"direction": "none",
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"interpretation": "Zu wenige gepaarte Tage mit Ernährung, Gewicht und gewähltem Lag.",
|
|
||||||
"reason": "insufficient_pairs",
|
|
||||||
"lag_details": lag_details,
|
|
||||||
"tdee_kcal_used": round(tdee_f, 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
lag_b, r_b, n_b = best
|
|
||||||
direction = _direction_from_r(r_b)
|
|
||||||
conf = _lag_confidence(n_b, r_b)
|
|
||||||
interp = (
|
|
||||||
f"Tagesbilanz (kcal − TDEE ~{tdee_f:.0f}) vs. Gewichtsänderung nach {lag_b} Tagen: "
|
|
||||||
f"r ≈ {r_b:.2f} ({direction}). "
|
|
||||||
f"Basierend auf {n_b} Kalendertagen mit vollständigen Paaren."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
# For now, return placeholder
|
||||||
return {
|
return {
|
||||||
"best_lag": lag_b,
|
'best_lag': 7,
|
||||||
"correlation": round(r_b, 4),
|
'correlation': -0.45, # Placeholder
|
||||||
"direction": direction,
|
'direction': 'negative', # Higher deficit = lower weight (expected)
|
||||||
"confidence": conf,
|
'confidence': 'medium',
|
||||||
"data_points": n_b,
|
'data_points': len(data)
|
||||||
"interpretation": interp,
|
|
||||||
"lag_details": lag_details,
|
|
||||||
"tdee_kcal_used": round(tdee_f, 0),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _correlate_protein_lbm(profile_id: str, max_lag: int) -> Optional[Dict]:
|
def _correlate_protein_lbm(profile_id: str, max_lag: int) -> Optional[Dict]:
|
||||||
"""
|
"""Correlate protein intake with LBM trend"""
|
||||||
Pearson: Protein (g/Tag) vs. Magermasse-Differenz (kg) vom Tag t zu t+L.
|
# TODO: Implement full correlation calculation
|
||||||
Datenbasis: nutrition_body_merge (Caliper-LBM forward-filled wie Ernährungs-Verlauf).
|
|
||||||
"""
|
|
||||||
merged = build_merged_daily_nutrition_body_rows(profile_id)
|
|
||||||
if not merged:
|
|
||||||
return {
|
|
||||||
"best_lag": None,
|
|
||||||
"correlation": None,
|
|
||||||
"direction": "none",
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"interpretation": "Keine zusammengeführten Ernährungs-/Körperdaten.",
|
|
||||||
"reason": "no_merged_rows",
|
|
||||||
}
|
|
||||||
|
|
||||||
protein_by: Dict[str, float] = {}
|
|
||||||
lbm_by: Dict[str, float] = {}
|
|
||||||
for row in merged:
|
|
||||||
ds = _iso_date_key(row.get("date"))
|
|
||||||
if not ds:
|
|
||||||
continue
|
|
||||||
pg = row.get("protein_g")
|
|
||||||
lm = row.get("lean_mass")
|
|
||||||
if pg is not None:
|
|
||||||
protein_by[ds] = float(pg)
|
|
||||||
if lm is not None:
|
|
||||||
lbm_by[ds] = float(lm)
|
|
||||||
|
|
||||||
best: Optional[Tuple[int, float, int]] = None
|
|
||||||
lag_details: List[Dict[str, Any]] = []
|
|
||||||
max_l = max(0, min(int(max_lag), 28))
|
|
||||||
|
|
||||||
for lag in range(1, max_l + 1):
|
|
||||||
xs: List[float] = []
|
|
||||||
ys: List[float] = []
|
|
||||||
for ds in sorted(protein_by.keys()):
|
|
||||||
if ds not in lbm_by:
|
|
||||||
continue
|
|
||||||
d0 = _parse_iso_to_date(ds)
|
|
||||||
if d0 is None:
|
|
||||||
continue
|
|
||||||
d1 = d0 + timedelta(days=lag)
|
|
||||||
ds1 = d1.isoformat()
|
|
||||||
if ds1 not in lbm_by:
|
|
||||||
continue
|
|
||||||
xs.append(protein_by[ds])
|
|
||||||
ys.append(lbm_by[ds1] - lbm_by[ds])
|
|
||||||
r = _pearson_r(xs, ys)
|
|
||||||
n_p = len(xs)
|
|
||||||
lag_details.append({"lag": lag, "n_pairs": n_p, "r": None if r is None else round(r, 4)})
|
|
||||||
if r is None:
|
|
||||||
continue
|
|
||||||
if best is None or abs(r) > abs(best[1]):
|
|
||||||
best = (lag, r, n_p)
|
|
||||||
|
|
||||||
if best is None:
|
|
||||||
return {
|
|
||||||
"best_lag": None,
|
|
||||||
"correlation": None,
|
|
||||||
"direction": "none",
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"interpretation": "Zu wenige Tage mit Protein und Magermasse (Caliper) für die gewählten Lags.",
|
|
||||||
"reason": "insufficient_pairs",
|
|
||||||
"lag_details": lag_details,
|
|
||||||
}
|
|
||||||
|
|
||||||
lag_b, r_b, n_b = best
|
|
||||||
direction = _direction_from_r(r_b)
|
|
||||||
conf = _lag_confidence(n_b, r_b)
|
|
||||||
interp = (
|
|
||||||
f"Protein (g/Tag) vs. Magermasse-Änderung nach {lag_b} Tagen: r ≈ {r_b:.2f} ({direction}). "
|
|
||||||
f"{n_b} gepaarte Tage."
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"best_lag": lag_b,
|
'best_lag': 0,
|
||||||
"correlation": round(r_b, 4),
|
'correlation': 0.32, # Placeholder
|
||||||
"direction": direction,
|
'direction': 'positive',
|
||||||
"confidence": conf,
|
'confidence': 'medium',
|
||||||
"data_points": n_b,
|
'data_points': 28
|
||||||
"interpretation": interp,
|
|
||||||
"lag_details": lag_details,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _correlate_load_vitals(profile_id: str, vital: str, max_lag: int) -> Optional[Dict]:
|
def _correlate_load_vitals(profile_id: str, vital: str, max_lag: int) -> Optional[Dict]:
|
||||||
"""
|
"""
|
||||||
Pearson: Tages-Trainingslast (Summe duration_min) vs. Vitals (HRV ms oder Ruhepuls)
|
Correlate training load with HRV or RHR
|
||||||
am Kalendertag t+Lag (typisch: Belastung am Vortag, Vitalwert am Folgetag bei Lag ≥ 1).
|
Test lags: 1, 2, 3 days
|
||||||
"""
|
"""
|
||||||
col = "hrv" if vital == "hrv" else "resting_hr"
|
# TODO: Implement full correlation calculation
|
||||||
cutoff = (datetime.now() - timedelta(days=LAG_CORR_LOOKBACK_DAYS)).strftime("%Y-%m-%d")
|
if vital == 'hrv':
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date::text AS d, COALESCE(SUM(duration_min), 0)::float AS minutes
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s AND date >= %s::date
|
|
||||||
AND duration_min IS NOT NULL AND duration_min > 0
|
|
||||||
GROUP BY date
|
|
||||||
ORDER BY date
|
|
||||||
""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
load_rows = cur.fetchall()
|
|
||||||
cur.execute(
|
|
||||||
f"""
|
|
||||||
SELECT date::text AS d, {col}::float AS v
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s AND date >= %s::date AND {col} IS NOT NULL
|
|
||||||
ORDER BY date
|
|
||||||
""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
vit_rows = cur.fetchall()
|
|
||||||
|
|
||||||
load_by = {str(r["d"])[:10]: float(r["minutes"] or 0) for r in load_rows}
|
|
||||||
vital_by = {str(r["d"])[:10]: float(r["v"]) for r in vit_rows}
|
|
||||||
|
|
||||||
best: Optional[Tuple[int, float, int]] = None
|
|
||||||
lag_details: List[Dict[str, Any]] = []
|
|
||||||
max_l = max(0, min(int(max_lag), 28))
|
|
||||||
vlabel = "HRV (ms)" if vital == "hrv" else "Ruhepuls (bpm)"
|
|
||||||
|
|
||||||
for lag in range(0, max_l + 1):
|
|
||||||
xs: List[float] = []
|
|
||||||
ys: List[float] = []
|
|
||||||
for ds in sorted(load_by.keys()):
|
|
||||||
d0 = _parse_iso_to_date(ds)
|
|
||||||
if d0 is None:
|
|
||||||
continue
|
|
||||||
d1 = d0 + timedelta(days=lag)
|
|
||||||
ds1 = d1.isoformat()
|
|
||||||
if ds1 not in vital_by:
|
|
||||||
continue
|
|
||||||
xs.append(load_by[ds])
|
|
||||||
ys.append(vital_by[ds1])
|
|
||||||
r = _pearson_r(xs, ys)
|
|
||||||
n_p = len(xs)
|
|
||||||
lag_details.append({"lag": lag, "n_pairs": n_p, "r": None if r is None else round(r, 4)})
|
|
||||||
if r is None:
|
|
||||||
continue
|
|
||||||
if best is None or abs(r) > abs(best[1]):
|
|
||||||
best = (lag, r, n_p)
|
|
||||||
|
|
||||||
if best is None:
|
|
||||||
return {
|
return {
|
||||||
"best_lag": None,
|
'best_lag': 1,
|
||||||
"correlation": None,
|
'correlation': -0.38, # Negative = high load reduces HRV (expected)
|
||||||
"direction": "none",
|
'direction': 'negative',
|
||||||
"confidence": "insufficient",
|
'confidence': 'medium',
|
||||||
"data_points": 0,
|
'data_points': 25
|
||||||
"interpretation": f"Zu wenige gepaarte Tage mit Training und {vlabel}.",
|
}
|
||||||
"reason": "insufficient_pairs",
|
else: # rhr
|
||||||
"lag_details": lag_details,
|
return {
|
||||||
"vital": vital,
|
'best_lag': 1,
|
||||||
|
'correlation': 0.42, # Positive = high load increases RHR (expected)
|
||||||
|
'direction': 'positive',
|
||||||
|
'confidence': 'medium',
|
||||||
|
'data_points': 25
|
||||||
}
|
}
|
||||||
|
|
||||||
lag_b, r_b, n_b = best
|
|
||||||
direction = _direction_from_r(r_b)
|
|
||||||
conf = _lag_confidence(n_b, r_b)
|
|
||||||
interp = (
|
|
||||||
f"Trainingsminuten/Tag vs. {vlabel} nach {lag_b} Tagen Lag: r ≈ {r_b:.2f} ({direction}). "
|
|
||||||
f"{n_b} Paare."
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"best_lag": lag_b,
|
|
||||||
"correlation": round(r_b, 4),
|
|
||||||
"direction": direction,
|
|
||||||
"confidence": conf,
|
|
||||||
"data_points": n_b,
|
|
||||||
"interpretation": interp,
|
|
||||||
"lag_details": lag_details,
|
|
||||||
"vital": vital,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -1,283 +0,0 @@
|
||||||
"""
|
|
||||||
KPI-Kacheln für Layer-2b Fitness-Dashboard (Issue #53).
|
|
||||||
|
|
||||||
Ausgabe für KpiTilesOverview; ``keys`` = Platzhalter-Registry-Referenzen.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
|
|
||||||
def _verdict(status: str) -> str:
|
|
||||||
if status == "good":
|
|
||||||
return "Gut"
|
|
||||||
if status == "warn":
|
|
||||||
return "Hinweis"
|
|
||||||
return "Achtung"
|
|
||||||
|
|
||||||
|
|
||||||
def _minutes_status(minutes: Optional[int]) -> str:
|
|
||||||
if minutes is None:
|
|
||||||
return "warn"
|
|
||||||
if 150 <= minutes <= 300:
|
|
||||||
return "good"
|
|
||||||
if minutes < 150:
|
|
||||||
return "warn" if minutes >= 90 else "bad"
|
|
||||||
return "warn"
|
|
||||||
|
|
||||||
|
|
||||||
def _quality_status(pct: Optional[int]) -> str:
|
|
||||||
if pct is None:
|
|
||||||
return "warn"
|
|
||||||
if pct >= 60:
|
|
||||||
return "good"
|
|
||||||
if pct >= 40:
|
|
||||||
return "warn"
|
|
||||||
return "bad"
|
|
||||||
|
|
||||||
|
|
||||||
def _score_status(score: Optional[int]) -> str:
|
|
||||||
if score is None:
|
|
||||||
return "warn"
|
|
||||||
if score >= 70:
|
|
||||||
return "good"
|
|
||||||
if score >= 50:
|
|
||||||
return "warn"
|
|
||||||
return "bad"
|
|
||||||
|
|
||||||
|
|
||||||
def _vo2_status(trend: Optional[float]) -> str:
|
|
||||||
if trend is None:
|
|
||||||
return "warn"
|
|
||||||
if trend > 0.5:
|
|
||||||
return "good"
|
|
||||||
if trend >= -0.5:
|
|
||||||
return "warn"
|
|
||||||
return "bad"
|
|
||||||
|
|
||||||
|
|
||||||
def _vol_delta_status(delta_pct: Optional[float], prior7: int, last7: int) -> str:
|
|
||||||
if delta_pct is None:
|
|
||||||
if last7 > 0 and prior7 == 0:
|
|
||||||
return "good"
|
|
||||||
return "warn"
|
|
||||||
if delta_pct >= 5:
|
|
||||||
return "good"
|
|
||||||
if delta_pct >= -10:
|
|
||||||
return "warn"
|
|
||||||
return "bad"
|
|
||||||
|
|
||||||
|
|
||||||
def build_fitness_progress_insights(
|
|
||||||
vol_delta: Dict[str, Any],
|
|
||||||
load_meta: Dict[str, Any],
|
|
||||||
quality_pct: Optional[int],
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Kurz-Aussagen für die UI (Layer 2b), keine zweite Datenquelle.
|
|
||||||
"""
|
|
||||||
out: List[Dict[str, Any]] = []
|
|
||||||
if vol_delta.get("has_data"):
|
|
||||||
last7 = int(vol_delta.get("last7_min") or 0)
|
|
||||||
prev7 = int(vol_delta.get("prior7_min") or 0)
|
|
||||||
d = vol_delta.get("delta_pct")
|
|
||||||
if d is not None:
|
|
||||||
sign = "+" if d > 0 else ""
|
|
||||||
body = (
|
|
||||||
f"Trainingsminuten letzte 7 Tage ({last7} min) vs. Vorwoche ({prev7} min): "
|
|
||||||
f"{sign}{d} %."
|
|
||||||
)
|
|
||||||
elif last7 > 0 and prev7 == 0:
|
|
||||||
body = f"Mehr Volumen als in der Vorwoche: zuletzt {last7} min (Vorwoche 0 min)."
|
|
||||||
else:
|
|
||||||
body = "Zu wenig Daten für einen Vorwochen-Vergleich."
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"key": "ins_vol_trend",
|
|
||||||
"tone": _vol_delta_status(
|
|
||||||
float(d) if d is not None else None, prev7, last7
|
|
||||||
),
|
|
||||||
"title": "Volumen-Trend",
|
|
||||||
"body": body,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
acwr = load_meta.get("acwr")
|
|
||||||
st = load_meta.get("acwr_status")
|
|
||||||
if acwr is not None and isinstance(load_meta, dict) and load_meta.get("data_points", 0) > 0:
|
|
||||||
if st == "optimal":
|
|
||||||
tone = "good"
|
|
||||||
hint = "Akute zu chronischer Last (ACWR) liegt im oft empfohlenen Bereich (ca. 0,8–1,3)."
|
|
||||||
else:
|
|
||||||
tone = "warn"
|
|
||||||
hint = (
|
|
||||||
"ACWR außerhalb des häufig genannten Zielkorridors — bei anhaltender Belastung "
|
|
||||||
"Erholung oder Volumen prüfen (Proxy-Modell)."
|
|
||||||
)
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"key": "ins_acwr",
|
|
||||||
"tone": tone,
|
|
||||||
"title": "Belastungsverhältnis (ACWR)",
|
|
||||||
"body": f"Verhältnis akut (7 Tage) zu chronisch (28 Tage): {float(acwr):.2f}. {hint}",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if quality_pct is not None:
|
|
||||||
tone = "good" if quality_pct >= 60 else "warn" if quality_pct >= 40 else "bad"
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"key": "ins_quality",
|
|
||||||
"tone": tone,
|
|
||||||
"title": "Session-Qualität",
|
|
||||||
"body": f"{quality_pct} % der Sessions sind als «gut» oder besser eingestuft — Grundlage für progressive Belastung.",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def build_fitness_dashboard_kpi_tiles(
|
|
||||||
summary: Dict[str, Any],
|
|
||||||
minutes_7d: Optional[int],
|
|
||||||
quality_pct: Optional[int],
|
|
||||||
quality_window_days: int,
|
|
||||||
activity_score: Optional[int],
|
|
||||||
vo2_trend: Optional[float],
|
|
||||||
top_focus: Optional[Dict[str, Any]],
|
|
||||||
vol_delta: Optional[Dict[str, Any]] = None,
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
spw = summary.get("sessions_per_week")
|
|
||||||
try:
|
|
||||||
spw_f = float(spw) if spw is not None else None
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
spw_f = None
|
|
||||||
spw_s = f"{spw_f:.1f}".replace(".", ",") if spw_f is not None else "—"
|
|
||||||
|
|
||||||
m_status = _minutes_status(minutes_7d)
|
|
||||||
q_status = _quality_status(quality_pct)
|
|
||||||
s_status = _score_status(activity_score)
|
|
||||||
v_status = _vo2_status(vo2_trend)
|
|
||||||
|
|
||||||
tiles: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
if vol_delta and vol_delta.get("has_data"):
|
|
||||||
d = vol_delta.get("delta_pct")
|
|
||||||
last7 = int(vol_delta.get("last7_min") or 0)
|
|
||||||
prev7 = int(vol_delta.get("prior7_min") or 0)
|
|
||||||
if d is not None:
|
|
||||||
sign = "+" if float(d) > 0 else ""
|
|
||||||
v_s = f"{sign}{d:.1f} %".replace(".", ",")
|
|
||||||
sub = f"{last7} min vs. {prev7} min (7-Tage-Fenster)"
|
|
||||||
elif last7 > 0 and prev7 == 0:
|
|
||||||
v_s = "neu"
|
|
||||||
sub = f"{last7} min letzte Woche"
|
|
||||||
else:
|
|
||||||
v_s = "—"
|
|
||||||
sub = "Vergleich Vorwoche"
|
|
||||||
vd_st = _vol_delta_status(float(d) if d is not None else None, prev7, last7)
|
|
||||||
tiles.append(
|
|
||||||
{
|
|
||||||
"key": "volume_vs_prior_week",
|
|
||||||
"category": "Volumen vs. Vorwoche",
|
|
||||||
"icon": "📈",
|
|
||||||
"value": v_s,
|
|
||||||
"sublabel": sub,
|
|
||||||
"status": vd_st,
|
|
||||||
"verdict": _verdict(vd_st),
|
|
||||||
"hoverTop": "Fortschritt Trainingsminuten",
|
|
||||||
"hoverBody": "Letzte 7 Kalendertage vs. die 7 Tage davor (activity_log).",
|
|
||||||
"keys": ["training_minutes_week", "activity_summary"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
tiles.extend(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"key": "minutes_week",
|
|
||||||
"category": "Minuten (7 Tage)",
|
|
||||||
"icon": "⏱",
|
|
||||||
"value": f"{minutes_7d} min" if minutes_7d is not None else "—",
|
|
||||||
"sublabel": "WHO: 150–300 min/Woche",
|
|
||||||
"status": m_status,
|
|
||||||
"verdict": _verdict(m_status),
|
|
||||||
"hoverTop": "Summe Trainingsminuten (letzte 7 Tage)",
|
|
||||||
"hoverBody": "Gleiche Quelle wie Platzhalter training_minutes_week.",
|
|
||||||
"keys": ["training_minutes_week", "activity_score"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "sessions_per_week",
|
|
||||||
"category": "Sessions / Woche",
|
|
||||||
"icon": "📅",
|
|
||||||
"value": spw_s,
|
|
||||||
"sublabel": f"Fenster: {summary.get('days_analyzed', '—')} Tage",
|
|
||||||
"status": "good",
|
|
||||||
"verdict": "Gut",
|
|
||||||
"hoverTop": "Durchschnittliche Sessions pro Woche",
|
|
||||||
"hoverBody": "Aus activity_summary (activity_log im gewählten Zeitraum).",
|
|
||||||
"keys": ["activity_summary"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "quality_pct",
|
|
||||||
"category": "Qualitätssessions",
|
|
||||||
"icon": "✓",
|
|
||||||
"value": f"{quality_pct} %" if quality_pct is not None else "—",
|
|
||||||
"sublabel": f"Anteil «gut+» · {quality_window_days} Tage",
|
|
||||||
"status": q_status,
|
|
||||||
"verdict": _verdict(q_status),
|
|
||||||
"hoverTop": "Anteil Sessions mit guter Qualitätslabel-Klassifikation",
|
|
||||||
"hoverBody": "Entspricht quality_sessions_pct (Fenster wie gewählt).",
|
|
||||||
"keys": ["quality_sessions_pct"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "activity_score",
|
|
||||||
"category": "Activity-Score",
|
|
||||||
"icon": "🎯",
|
|
||||||
"value": str(activity_score) if activity_score is not None else "—",
|
|
||||||
"sublabel": "Ausrichtung an gewichteten Fokusbereichen",
|
|
||||||
"status": s_status,
|
|
||||||
"verdict": _verdict(s_status) if activity_score is not None else "Hinweis",
|
|
||||||
"hoverTop": "Gewichteter Score (0–100)",
|
|
||||||
"hoverBody": "Ohne gewichtete Aktivitäts-Fokusbereiche kein Score.",
|
|
||||||
"keys": ["activity_score"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "vo2_trend",
|
|
||||||
"category": "VO₂max-Trend",
|
|
||||||
"icon": "🫁",
|
|
||||||
"value": f"{vo2_trend:+.1f}" if vo2_trend is not None else "—",
|
|
||||||
"sublabel": "28-Tage-Trend (geschätzt)",
|
|
||||||
"status": v_status,
|
|
||||||
"verdict": _verdict(v_status) if vo2_trend is not None else "Hinweis",
|
|
||||||
"hoverTop": "Trend der VO₂max-Schätzung aus Aktivitätsdaten",
|
|
||||||
"hoverBody": "Wie vo2max_trend_28d im Data Layer.",
|
|
||||||
"keys": ["vo2max_trend_28d"],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
if top_focus:
|
|
||||||
prog = top_focus.get("progress")
|
|
||||||
prog_s = f"{prog} %" if prog is not None else "—"
|
|
||||||
w = top_focus.get("weight")
|
|
||||||
try:
|
|
||||||
w_s = f"{float(w):.0f} %" if w is not None else "—"
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
w_s = "—"
|
|
||||||
tiles.append(
|
|
||||||
{
|
|
||||||
"key": "top_focus",
|
|
||||||
"category": "Schwerpunkt-Fokus",
|
|
||||||
"icon": "🔭",
|
|
||||||
"value": str(top_focus.get("label") or "—"),
|
|
||||||
"sublabel": f"Fortschritt {prog_s} · Gewicht {w_s}",
|
|
||||||
"status": "good",
|
|
||||||
"verdict": "Gut",
|
|
||||||
"hoverTop": "Höchstgewichteter Fokusbereich",
|
|
||||||
"hoverBody": "Aus focus_area_definitions + Nutzer-Gewichtungen.",
|
|
||||||
"keys": ["top_focus_area_name", "top_focus_area_progress"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return tiles
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
"""
|
|
||||||
Layer 2b: Fitness-Hub — ein Bundle für die Aktivitäts-/Fitness-UI (Issue #53).
|
|
||||||
|
|
||||||
Single Source: activity_metrics + dieselben Hilfsfunktionen wie Chart-Endpunkte A1/A2.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
|
||||||
from data_layer.activity_metrics import (
|
|
||||||
build_load_monitoring_chart_payload,
|
|
||||||
build_quality_sessions_chart_payload,
|
|
||||||
build_training_type_distribution_chart_payload,
|
|
||||||
build_training_volume_chart_payload,
|
|
||||||
calculate_activity_score,
|
|
||||||
calculate_training_minutes_week,
|
|
||||||
calculate_quality_sessions_pct,
|
|
||||||
calculate_vo2max_trend_28d,
|
|
||||||
get_activity_summary_data,
|
|
||||||
get_training_volume_two_week_delta,
|
|
||||||
)
|
|
||||||
from data_layer.fitness_interpretation import (
|
|
||||||
build_fitness_dashboard_kpi_tiles,
|
|
||||||
build_fitness_progress_insights,
|
|
||||||
)
|
|
||||||
from data_layer.scores import get_top_focus_area
|
|
||||||
|
|
||||||
|
|
||||||
def _iso(d: Any) -> Optional[str]:
|
|
||||||
if d is None:
|
|
||||||
return None
|
|
||||||
if hasattr(d, "isoformat"):
|
|
||||||
return d.isoformat()[:10]
|
|
||||||
return str(d)[:10]
|
|
||||||
|
|
||||||
|
|
||||||
def _has_activity_entries(profile_id: str) -> bool:
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"SELECT 1 FROM activity_log WHERE profile_id=%s LIMIT 1",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
return cur.fetchone() is not None
|
|
||||||
|
|
||||||
|
|
||||||
def _last_activity_date(profile_id: str) -> Optional[str]:
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"SELECT MAX(date) AS d FROM activity_log WHERE profile_id=%s",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row or row["d"] is None:
|
|
||||||
return None
|
|
||||||
return _iso(row["d"])
|
|
||||||
|
|
||||||
|
|
||||||
def get_activity_last_updated_iso(profile_id: str) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Leichtgewicht: letztes activity_log.date — identisch zu ``last_updated`` im Fitness-Viz-Bundle.
|
|
||||||
|
|
||||||
Für History-Header o. Ä. ohne vollständige Aktivitätsliste (Phase A, Issue-53-Pfad).
|
|
||||||
"""
|
|
||||||
return _last_activity_date(profile_id)
|
|
||||||
|
|
||||||
|
|
||||||
def get_fitness_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Bundle für Fitness-Übersicht: KPI-Kacheln + eingebettete Chart-Payloads (Chart.js-Format).
|
|
||||||
|
|
||||||
``days``: Analysefenster für Zusammenfassung; >=9999 = lange Historie (max. 3650 Tage).
|
|
||||||
"""
|
|
||||||
if not _has_activity_entries(profile_id):
|
|
||||||
return {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"has_activity_entries": False,
|
|
||||||
"message": "Noch keine Aktivitätsdaten",
|
|
||||||
"kpi_tiles": [],
|
|
||||||
"summary": {},
|
|
||||||
"progress_insights": [],
|
|
||||||
"volume_delta": {},
|
|
||||||
"charts": {},
|
|
||||||
"meta": {"layer_1": "activity_metrics", "layer_2b": "fitness_viz"},
|
|
||||||
}
|
|
||||||
|
|
||||||
all_history = days >= 9999
|
|
||||||
eff_days = 3650 if all_history else max(7, min(int(days), 3650))
|
|
||||||
|
|
||||||
summary = get_activity_summary_data(profile_id, eff_days)
|
|
||||||
|
|
||||||
weeks_vol = max(4, min(52, (min(eff_days, 365) + 6) // 7))
|
|
||||||
dist_days = min(90, max(7, min(eff_days, 365)))
|
|
||||||
load_days = min(90, max(14, min(eff_days, 365)))
|
|
||||||
|
|
||||||
volume_chart = build_training_volume_chart_payload(profile_id, weeks_vol)
|
|
||||||
type_chart = build_training_type_distribution_chart_payload(profile_id, dist_days)
|
|
||||||
quality_chart = build_quality_sessions_chart_payload(profile_id, dist_days)
|
|
||||||
load_chart = build_load_monitoring_chart_payload(profile_id, load_days)
|
|
||||||
|
|
||||||
quality_days = dist_days
|
|
||||||
quality_pct = calculate_quality_sessions_pct(profile_id, quality_days)
|
|
||||||
minutes_7d = calculate_training_minutes_week(profile_id)
|
|
||||||
activity_score = calculate_activity_score(profile_id)
|
|
||||||
vo2_trend = calculate_vo2max_trend_28d(profile_id)
|
|
||||||
top_focus = get_top_focus_area(profile_id)
|
|
||||||
vol_delta = get_training_volume_two_week_delta(profile_id)
|
|
||||||
|
|
||||||
kpi_tiles = build_fitness_dashboard_kpi_tiles(
|
|
||||||
summary,
|
|
||||||
minutes_7d,
|
|
||||||
quality_pct,
|
|
||||||
quality_days,
|
|
||||||
activity_score,
|
|
||||||
vo2_trend,
|
|
||||||
top_focus,
|
|
||||||
vol_delta,
|
|
||||||
)
|
|
||||||
|
|
||||||
load_meta = load_chart.get("metadata") or {}
|
|
||||||
if not isinstance(load_meta, dict):
|
|
||||||
load_meta = {}
|
|
||||||
progress_insights = build_fitness_progress_insights(vol_delta, load_meta, quality_pct)
|
|
||||||
|
|
||||||
conf = summary.get("confidence") or "medium"
|
|
||||||
if summary.get("activity_count", 0) == 0:
|
|
||||||
conf = "insufficient"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"confidence": conf,
|
|
||||||
"has_activity_entries": True,
|
|
||||||
"days_requested": days,
|
|
||||||
"effective_window_days": eff_days,
|
|
||||||
"training_volume_weeks_used": weeks_vol,
|
|
||||||
"training_type_dist_days_used": dist_days,
|
|
||||||
"last_updated": _last_activity_date(profile_id),
|
|
||||||
"summary": summary,
|
|
||||||
"kpi_tiles": kpi_tiles,
|
|
||||||
"interpretation_tiles": [],
|
|
||||||
"progress_insights": progress_insights,
|
|
||||||
"volume_delta": vol_delta,
|
|
||||||
"charts": {
|
|
||||||
"training_volume": volume_chart,
|
|
||||||
"training_type_distribution": type_chart,
|
|
||||||
"quality_sessions": quality_chart,
|
|
||||||
"load_monitoring": load_chart,
|
|
||||||
},
|
|
||||||
"load_chart_days_used": load_days,
|
|
||||||
"meta": {
|
|
||||||
"layer_1": "activity_metrics",
|
|
||||||
"layer_2b": "fitness_viz",
|
|
||||||
"issue": "53-layer-2b-fitness",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -1,251 +0,0 @@
|
||||||
"""
|
|
||||||
Layer 2b: Gesamtansicht «Verlauf» — komponiert nur Bundles aus body-, nutrition-, fitness-, recovery_viz.
|
|
||||||
|
|
||||||
Issue #53: keine parallele Business-Logik; ein Router-Endpoint liefert diese Zusammenfassung.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from data_layer.body_viz import get_body_history_viz_bundle
|
|
||||||
from data_layer.correlation_chart_payloads import (
|
|
||||||
build_lbm_protein_correlation_chart_payload,
|
|
||||||
build_load_vitals_correlation_chart_payload,
|
|
||||||
build_recovery_performance_chart_payload,
|
|
||||||
build_weight_energy_correlation_chart_payload,
|
|
||||||
)
|
|
||||||
from data_layer.correlations import calculate_lag_correlation, calculate_top_drivers
|
|
||||||
from data_layer.fitness_viz import get_fitness_dashboard_viz_bundle
|
|
||||||
from data_layer.nutrition_viz import get_nutrition_history_viz_bundle
|
|
||||||
from data_layer.recovery_viz import get_recovery_dashboard_viz_bundle
|
|
||||||
from data_layer.utils import safe_float
|
|
||||||
|
|
||||||
|
|
||||||
def _take_kpis(tiles: Any, max_n: int = 4) -> List[Dict[str, Any]]:
|
|
||||||
if not isinstance(tiles, list):
|
|
||||||
return []
|
|
||||||
out: List[Dict[str, Any]] = []
|
|
||||||
for t in tiles[:max_n]:
|
|
||||||
if not isinstance(t, dict):
|
|
||||||
continue
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"key": t.get("key"),
|
|
||||||
"category": t.get("category"),
|
|
||||||
"icon": t.get("icon"),
|
|
||||||
"value": t.get("value"),
|
|
||||||
"sublabel": t.get("sublabel"),
|
|
||||||
"status": t.get("status"),
|
|
||||||
"verdict": t.get("verdict"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _short_body_interpretation_tiles(tiles: Any, max_n: int = 3) -> List[Dict[str, Any]]:
|
|
||||||
"""Körper-Interpretationskacheln (keine KPI-Kacheln)."""
|
|
||||||
if not isinstance(tiles, list):
|
|
||||||
return []
|
|
||||||
out: List[Dict[str, Any]] = []
|
|
||||||
for t in tiles[:max_n]:
|
|
||||||
if not isinstance(t, dict):
|
|
||||||
continue
|
|
||||||
det = str(t.get("detail") or "")
|
|
||||||
if len(det) > 140:
|
|
||||||
det = det[:137] + "…"
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"title": t.get("title") or t.get("category") or "Hinweis",
|
|
||||||
"detail": det,
|
|
||||||
"status": t.get("status"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _take_insights(items: Any, max_n: int = 2) -> List[Dict[str, Any]]:
|
|
||||||
if not isinstance(items, list):
|
|
||||||
return []
|
|
||||||
out: List[Dict[str, Any]] = []
|
|
||||||
for it in items[:max_n]:
|
|
||||||
if not isinstance(it, dict):
|
|
||||||
continue
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"title": it.get("title") or it.get("title_de"),
|
|
||||||
"body": it.get("body") or it.get("detail") or it.get("message"),
|
|
||||||
"tone": it.get("tone") or it.get("status"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def get_history_overview_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Kompakte Übersicht für den ersten Reiter «Gesamtansicht»: KPI-Kurzformen + Lag-Korrelationen (C1–C4).
|
|
||||||
"""
|
|
||||||
eff = max(7, min(int(days), 9999))
|
|
||||||
body = get_body_history_viz_bundle(profile_id, eff)
|
|
||||||
nutr = get_nutrition_history_viz_bundle(profile_id, eff)
|
|
||||||
fit = get_fitness_dashboard_viz_bundle(profile_id, eff)
|
|
||||||
rec = get_recovery_dashboard_viz_bundle(profile_id, eff)
|
|
||||||
|
|
||||||
c1 = calculate_lag_correlation(profile_id, "energy_balance", "weight", 14)
|
|
||||||
c2 = calculate_lag_correlation(profile_id, "protein", "lbm", 14)
|
|
||||||
c3_hrv = calculate_lag_correlation(profile_id, "load", "hrv", 14)
|
|
||||||
c3_rhr = calculate_lag_correlation(profile_id, "load", "rhr", 14)
|
|
||||||
c3 = None
|
|
||||||
if c3_hrv and c3_rhr:
|
|
||||||
a1 = abs(safe_float(c3_hrv.get("correlation"), 0.0))
|
|
||||||
a2 = abs(safe_float(c3_rhr.get("correlation"), 0.0))
|
|
||||||
c3 = c3_hrv if a1 >= a2 else c3_rhr
|
|
||||||
if c3 is c3_hrv:
|
|
||||||
c3 = dict(c3)
|
|
||||||
c3["metric"] = "HRV"
|
|
||||||
else:
|
|
||||||
c3 = dict(c3_rhr)
|
|
||||||
c3["metric"] = "RHR"
|
|
||||||
elif c3_hrv:
|
|
||||||
c3 = dict(c3_hrv)
|
|
||||||
c3["metric"] = "HRV"
|
|
||||||
elif c3_rhr:
|
|
||||||
c3 = dict(c3_rhr)
|
|
||||||
c3["metric"] = "RHR"
|
|
||||||
|
|
||||||
drivers = calculate_top_drivers(profile_id)
|
|
||||||
|
|
||||||
b_sum = body.get("summary") if isinstance(body.get("summary"), dict) else {}
|
|
||||||
last_w = b_sum.get("weight_kg")
|
|
||||||
|
|
||||||
fs = fit.get("summary") if isinstance(fit.get("summary"), dict) else {}
|
|
||||||
if fit.get("has_activity_entries"):
|
|
||||||
ac = int(fs.get("activity_count") or 0)
|
|
||||||
fitness_line = f"{ac} Trainingseinheiten im gewählten Fenster"
|
|
||||||
else:
|
|
||||||
fitness_line = fit.get("message") or "Keine Trainingsdaten"
|
|
||||||
|
|
||||||
drv_list = drivers if isinstance(drivers, list) else []
|
|
||||||
|
|
||||||
return {
|
|
||||||
"days_requested": days,
|
|
||||||
"effective_window_days": eff,
|
|
||||||
"confidence": _overview_confidence(body, nutr, fit, rec),
|
|
||||||
"sections": [
|
|
||||||
{
|
|
||||||
"id": "body",
|
|
||||||
"title": "Körper",
|
|
||||||
"tab_id": "body",
|
|
||||||
"summary_line": (
|
|
||||||
f"Letztes Gewicht: {last_w} kg"
|
|
||||||
if last_w is not None
|
|
||||||
else "Keine Gewichtsdaten im Fenster"
|
|
||||||
),
|
|
||||||
"interpretation_short": _short_body_interpretation_tiles(body.get("interpretation_tiles"), 3),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "nutrition",
|
|
||||||
"title": "Ernährung",
|
|
||||||
"tab_id": "nutrition",
|
|
||||||
"summary_line": (
|
|
||||||
f"Ø {round(float((nutr.get('summary') or {}).get('kcal_avg') or 0))} kcal/Tag"
|
|
||||||
if nutr.get("has_nutrition_entries")
|
|
||||||
else (nutr.get("message") or "Keine Ernährungsdaten")
|
|
||||||
),
|
|
||||||
"kpi_short": _take_kpis(nutr.get("kpi_tiles"), 4),
|
|
||||||
"heuristic_short": (nutr.get("nutrition_correlation_heuristics") or [])[:2],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fitness",
|
|
||||||
"title": "Fitness",
|
|
||||||
"tab_id": "activity",
|
|
||||||
"summary_line": fitness_line,
|
|
||||||
"kpi_short": _take_kpis(fit.get("kpi_tiles"), 4),
|
|
||||||
"insights_short": _take_insights(fit.get("progress_insights"), 2),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "recovery",
|
|
||||||
"title": "Erholung",
|
|
||||||
"tab_id": "activity",
|
|
||||||
"summary_line": "Schlaf & Vitalwerte"
|
|
||||||
if rec.get("has_recovery_data")
|
|
||||||
else (rec.get("message") or "Keine Erholungsdaten"),
|
|
||||||
"kpi_short": _take_kpis(rec.get("kpi_tiles"), 4),
|
|
||||||
"insights_short": _take_insights(rec.get("progress_insights"), 2),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"lag_correlations": {
|
|
||||||
"weight_energy": _compact_lag("C1 Energiebilanz ↔ Gewicht", c1),
|
|
||||||
"protein_lbm": _compact_lag("C2 Protein ↔ Magermasse", c2),
|
|
||||||
"load_vitals": _compact_lag(
|
|
||||||
f"C3 Last ↔ {(c3 or {}).get('metric') or 'Vital'}",
|
|
||||||
c3,
|
|
||||||
extra_keys=("metric",),
|
|
||||||
),
|
|
||||||
"recovery_performance": {
|
|
||||||
"label": "C4 Top-Treiber (Einflussfaktoren)",
|
|
||||||
"drivers": drv_list[:8],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"chart_payloads": {
|
|
||||||
"c1_weight_energy": build_weight_energy_correlation_chart_payload(profile_id, 14),
|
|
||||||
"c2_protein_lbm": build_lbm_protein_correlation_chart_payload(profile_id, 14),
|
|
||||||
"c3_load_vitals": build_load_vitals_correlation_chart_payload(profile_id, 14),
|
|
||||||
"c4_recovery_performance": build_recovery_performance_chart_payload(profile_id),
|
|
||||||
},
|
|
||||||
"meta": {
|
|
||||||
"layer_1": "composed_metrics",
|
|
||||||
"layer_2b": "history_overview_viz",
|
|
||||||
"issue": "53-history-overview",
|
|
||||||
"sources": {
|
|
||||||
"body": "body_viz",
|
|
||||||
"nutrition": "nutrition_viz",
|
|
||||||
"fitness": "fitness_viz",
|
|
||||||
"recovery": "recovery_viz",
|
|
||||||
"lag": "correlations.calculate_lag_correlation",
|
|
||||||
"drivers": "correlations.calculate_top_drivers",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _overview_confidence(b: Dict, n: Dict, f: Dict, r: Dict) -> str:
|
|
||||||
scores = []
|
|
||||||
for x in (b, n, f, r):
|
|
||||||
c = x.get("confidence")
|
|
||||||
if c == "high":
|
|
||||||
scores.append(3)
|
|
||||||
elif c == "medium":
|
|
||||||
scores.append(2)
|
|
||||||
elif c == "low":
|
|
||||||
scores.append(1)
|
|
||||||
else:
|
|
||||||
scores.append(0)
|
|
||||||
s = sum(scores) / max(len(scores), 1)
|
|
||||||
if s >= 2.5:
|
|
||||||
return "high"
|
|
||||||
if s >= 1.5:
|
|
||||||
return "medium"
|
|
||||||
return "low"
|
|
||||||
|
|
||||||
|
|
||||||
def _compact_lag(
|
|
||||||
label: str,
|
|
||||||
payload: Optional[Dict[str, Any]],
|
|
||||||
extra_keys: tuple = (),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
if not payload:
|
|
||||||
return {"label": label, "available": False}
|
|
||||||
out: Dict[str, Any] = {
|
|
||||||
"label": label,
|
|
||||||
"available": payload.get("correlation") is not None,
|
|
||||||
"correlation": payload.get("correlation"),
|
|
||||||
"best_lag_days": payload.get("best_lag_days", payload.get("best_lag")),
|
|
||||||
"confidence": payload.get("confidence"),
|
|
||||||
"interpretation": payload.get("interpretation", ""),
|
|
||||||
"data_points": payload.get("data_points"),
|
|
||||||
}
|
|
||||||
for k in extra_keys:
|
|
||||||
if k in payload:
|
|
||||||
out[k] = payload[k]
|
|
||||||
return out
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
"""
|
|
||||||
Layer 1 Hilfslogik: Ernährung + Gewicht + Caliper (forward-filled Magermasse).
|
|
||||||
|
|
||||||
Genutzt von Layer 2b (nutrition_viz) und vom Router GET /api/nutrition/correlations.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from caliper_composition import as_date, compute_lean_fat_kg, nearest_weight_kg_from_map
|
|
||||||
|
|
||||||
|
|
||||||
def build_merged_daily_nutrition_body_rows(profile_id: str) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Pro Kalendertag: Makros aus nutrition_log, Gewicht, forward-filled Caliper (lean_mass, bf%).
|
|
||||||
Gleiche Semantik wie bisher ``GET /api/nutrition/correlations``.
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date", (profile_id,))
|
|
||||||
nutr: Dict[Any, Dict[str, Any]] = {}
|
|
||||||
for r in cur.fetchall():
|
|
||||||
rd = r2d(r)
|
|
||||||
dk = as_date(rd.get("date"))
|
|
||||||
if dk is not None:
|
|
||||||
nutr[dk] = rd
|
|
||||||
cur.execute("SELECT date, weight FROM weight_log WHERE profile_id=%s ORDER BY date", (profile_id,))
|
|
||||||
wlog: Dict[Any, Any] = {}
|
|
||||||
for r in cur.fetchall():
|
|
||||||
rd = r2d(r)
|
|
||||||
dk = as_date(rd.get("date"))
|
|
||||||
if dk is not None:
|
|
||||||
wlog[dk] = rd["weight"]
|
|
||||||
cur.execute(
|
|
||||||
"SELECT date, lean_mass, body_fat_pct FROM caliper_log WHERE profile_id=%s ORDER BY date",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
cals = [r2d(r) for r in cur.fetchall()]
|
|
||||||
cals = sorted(
|
|
||||||
[c for c in cals if as_date(c.get("date")) is not None],
|
|
||||||
key=lambda x: as_date(x["date"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Alle Keys sind datetime.date — vermeidet TypeError bei Vergleichen (str vs date)
|
|
||||||
all_dates = sorted(set(nutr.keys()) | set(wlog.keys()))
|
|
||||||
mi = 0
|
|
||||||
last_cal: Dict[str, Any] = {}
|
|
||||||
cal_by_date: Dict[Any, Dict[str, Any]] = {}
|
|
||||||
for d in all_dates:
|
|
||||||
while mi < len(cals):
|
|
||||||
cd = as_date(cals[mi].get("date"))
|
|
||||||
if cd is None:
|
|
||||||
mi += 1
|
|
||||||
continue
|
|
||||||
if cd > d:
|
|
||||||
break
|
|
||||||
last_cal = cals[mi]
|
|
||||||
mi += 1
|
|
||||||
if last_cal:
|
|
||||||
cal_by_date[d] = last_cal
|
|
||||||
|
|
||||||
result: List[Dict[str, Any]] = []
|
|
||||||
for d in all_dates:
|
|
||||||
if d not in nutr and d not in wlog:
|
|
||||||
continue
|
|
||||||
row: Dict[str, Any] = {"date": d}
|
|
||||||
if d in nutr:
|
|
||||||
for k in ("kcal", "protein_g", "fat_g", "carbs_g"):
|
|
||||||
v = nutr[d].get(k)
|
|
||||||
row[k] = float(v) if v is not None else None
|
|
||||||
if d in wlog:
|
|
||||||
row["weight"] = float(wlog[d])
|
|
||||||
if d in cal_by_date:
|
|
||||||
lm = cal_by_date[d].get("lean_mass")
|
|
||||||
bf = cal_by_date[d].get("body_fat_pct")
|
|
||||||
if bf is not None and lm is None:
|
|
||||||
wkg = nearest_weight_kg_from_map(wlog, d)
|
|
||||||
if wkg is not None:
|
|
||||||
lm, _fat = compute_lean_fat_kg(wkg, float(bf))
|
|
||||||
row["lean_mass"] = float(lm) if lm is not None else None
|
|
||||||
row["body_fat_pct"] = float(bf) if bf is not None else None
|
|
||||||
result.append(row)
|
|
||||||
return result
|
|
||||||
|
|
@ -1,404 +0,0 @@
|
||||||
"""
|
|
||||||
Chart.js-kompatible Payloads für Ernährungs-Charts (E1, E2, E4).
|
|
||||||
|
|
||||||
Gleiche Logik wie ``routers/charts.py`` — hier zentral, damit ``nutrition_viz``
|
|
||||||
und die API dieselbe Berechnung nutzen (Phase C, Issue 53).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
|
||||||
from data_layer.nutrition_metrics import (
|
|
||||||
get_energy_balance_data,
|
|
||||||
get_protein_adequacy_data,
|
|
||||||
get_protein_targets_data,
|
|
||||||
)
|
|
||||||
from data_layer.utils import calculate_confidence, safe_float, serialize_dates
|
|
||||||
|
|
||||||
|
|
||||||
def build_energy_balance_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
"""E1 Energiebilanz — identisch zu GET /api/charts/energy-balance."""
|
|
||||||
balance_meta = get_energy_balance_data(profile_id, days)
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""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),
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
|
|
||||||
if not rows or len(rows) < 3:
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": len(rows) if rows else 0,
|
|
||||||
"message": "Nicht genug Ernährungsdaten (min. 3 Tage)",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = []
|
|
||||||
avg_14d = []
|
|
||||||
|
|
||||||
for i, row in enumerate(rows):
|
|
||||||
labels.append(row["date"].isoformat())
|
|
||||||
daily_values.append(safe_float(row["kcal"]))
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
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 = [
|
|
||||||
{
|
|
||||||
"label": "Kalorien (täglich)",
|
|
||||||
"data": daily_values,
|
|
||||||
"borderColor": "#1D9E7599",
|
|
||||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
|
||||||
"borderWidth": 1.5,
|
|
||||||
"tension": 0.2,
|
|
||||||
"fill": False,
|
|
||||||
"pointRadius": 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Ø 7 Tage",
|
|
||||||
"data": avg_7d,
|
|
||||||
"borderColor": "#1D9E75",
|
|
||||||
"borderWidth": 2.5,
|
|
||||||
"tension": 0.3,
|
|
||||||
"fill": False,
|
|
||||||
"pointRadius": 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Ø 14 Tage",
|
|
||||||
"data": avg_14d,
|
|
||||||
"borderColor": "#085041",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"tension": 0.3,
|
|
||||||
"fill": False,
|
|
||||||
"pointRadius": 0,
|
|
||||||
"borderDash": [6, 3],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "TDEE (geschätzt)",
|
|
||||||
"data": [estimated_tdee] * len(labels),
|
|
||||||
"borderColor": "#888",
|
|
||||||
"borderWidth": 1,
|
|
||||||
"borderDash": [5, 5],
|
|
||||||
"fill": False,
|
|
||||||
"pointRadius": 0,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
confidence = balance_meta.get("confidence") or "low"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {"labels": labels, "datasets": datasets},
|
|
||||||
"metadata": serialize_dates(
|
|
||||||
{
|
|
||||||
"confidence": confidence,
|
|
||||||
"data_points": len(rows),
|
|
||||||
"avg_kcal": round(avg_intake, 1),
|
|
||||||
"estimated_tdee": estimated_tdee,
|
|
||||||
"energy_balance": round(energy_balance, 1),
|
|
||||||
"balance_status": balance_status,
|
|
||||||
"first_date": rows[0]["date"],
|
|
||||||
"last_date": rows[-1]["date"],
|
|
||||||
}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_protein_adequacy_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
"""E2 Protein Adequacy — identisch zu GET /api/charts/protein-adequacy."""
|
|
||||||
targets = get_protein_targets_data(profile_id)
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""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),
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
|
|
||||||
if not rows or len(rows) < 3:
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": len(rows) if rows else 0,
|
|
||||||
"message": "Nicht genug Protein-Daten (min. 3 Tage)",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
labels = []
|
|
||||||
daily_values = []
|
|
||||||
avg_7d = []
|
|
||||||
avg_28d = []
|
|
||||||
|
|
||||||
for i, row in enumerate(rows):
|
|
||||||
labels.append(row["date"].isoformat())
|
|
||||||
daily_values.append(safe_float(row["protein_g"]))
|
|
||||||
|
|
||||||
start_7d = max(0, i - 6)
|
|
||||||
window_7d = [safe_float(rows[j]["protein_g"]) for j in range(start_7d, i + 1)]
|
|
||||||
avg_7d.append(round(sum(window_7d) / len(window_7d), 1) if window_7d else None)
|
|
||||||
|
|
||||||
start_28d = max(0, i - 27)
|
|
||||||
window_28d = [safe_float(rows[j]["protein_g"]) for j in range(start_28d, i + 1)]
|
|
||||||
avg_28d.append(round(sum(window_28d) / len(window_28d), 1) if window_28d else None)
|
|
||||||
|
|
||||||
target_low = targets["protein_target_low"]
|
|
||||||
target_high = targets["protein_target_high"]
|
|
||||||
|
|
||||||
datasets = [
|
|
||||||
{
|
|
||||||
"label": "Protein (täglich)",
|
|
||||||
"data": daily_values,
|
|
||||||
"borderColor": "#1D9E7599",
|
|
||||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
|
||||||
"borderWidth": 1.5,
|
|
||||||
"tension": 0.2,
|
|
||||||
"fill": False,
|
|
||||||
"pointRadius": 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Ø 7 Tage",
|
|
||||||
"data": avg_7d,
|
|
||||||
"borderColor": "#1D9E75",
|
|
||||||
"borderWidth": 2.5,
|
|
||||||
"tension": 0.3,
|
|
||||||
"fill": False,
|
|
||||||
"pointRadius": 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Ø 28 Tage",
|
|
||||||
"data": avg_28d,
|
|
||||||
"borderColor": "#085041",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"tension": 0.3,
|
|
||||||
"fill": False,
|
|
||||||
"pointRadius": 0,
|
|
||||||
"borderDash": [6, 3],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Ziel Min",
|
|
||||||
"data": [target_low] * len(labels),
|
|
||||||
"borderColor": "#888",
|
|
||||||
"borderWidth": 1,
|
|
||||||
"borderDash": [5, 5],
|
|
||||||
"fill": False,
|
|
||||||
"pointRadius": 0,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
datasets.append(
|
|
||||||
{
|
|
||||||
"label": "Ziel Max",
|
|
||||||
"data": [target_high] * len(labels),
|
|
||||||
"borderColor": "#888",
|
|
||||||
"borderWidth": 1,
|
|
||||||
"borderDash": [5, 5],
|
|
||||||
"fill": False,
|
|
||||||
"pointRadius": 0,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
confidence = calculate_confidence(len(rows), days, "general")
|
|
||||||
|
|
||||||
days_in_target = sum(1 for v in daily_values if target_low <= v <= target_high)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {"labels": labels, "datasets": datasets},
|
|
||||||
"metadata": serialize_dates(
|
|
||||||
{
|
|
||||||
"confidence": confidence,
|
|
||||||
"data_points": len(rows),
|
|
||||||
"target_low": round(target_low, 1),
|
|
||||||
"target_high": round(target_high, 1),
|
|
||||||
"days_in_target": days_in_target,
|
|
||||||
"target_compliance_pct": round(
|
|
||||||
days_in_target / len(daily_values) * 100, 1
|
|
||||||
)
|
|
||||||
if daily_values
|
|
||||||
else 0,
|
|
||||||
"first_date": rows[0]["date"],
|
|
||||||
"last_date": rows[-1]["date"],
|
|
||||||
}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_nutrition_adherence_score_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
"""E4 Adhärenz — identisch zu GET /api/charts/nutrition-adherence-score."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT goal_mode FROM profiles WHERE id = %s", (profile_id,))
|
|
||||||
profile_row = cur.fetchone()
|
|
||||||
goal_mode = (
|
|
||||||
profile_row["goal_mode"]
|
|
||||||
if profile_row and profile_row["goal_mode"]
|
|
||||||
else "health"
|
|
||||||
)
|
|
||||||
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""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()
|
|
||||||
|
|
||||||
if not stats or stats["cnt"] < 7:
|
|
||||||
return {
|
|
||||||
"score": 0,
|
|
||||||
"components": {},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"message": "Nicht genug Daten (min. 7 Tage)",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
protein_data = get_protein_adequacy_data(profile_id, days)
|
|
||||||
|
|
||||||
calorie_adherence = 70.0
|
|
||||||
protein_adequacy_pct = protein_data.get("adequacy_score", 0)
|
|
||||||
protein_adherence = min(100, protein_adequacy_pct)
|
|
||||||
|
|
||||||
kcal_cv = (
|
|
||||||
(safe_float(stats["std_kcal"]) / safe_float(stats["avg_kcal"]) * 100)
|
|
||||||
if safe_float(stats["avg_kcal"]) > 0
|
|
||||||
else 100
|
|
||||||
)
|
|
||||||
intake_consistency = max(0, 100 - kcal_cv)
|
|
||||||
|
|
||||||
food_quality = 60.0
|
|
||||||
|
|
||||||
if goal_mode == "weight_loss":
|
|
||||||
weights = {
|
|
||||||
"calorie": 0.35,
|
|
||||||
"protein": 0.25,
|
|
||||||
"consistency": 0.20,
|
|
||||||
"quality": 0.20,
|
|
||||||
}
|
|
||||||
elif goal_mode == "strength":
|
|
||||||
weights = {
|
|
||||||
"calorie": 0.25,
|
|
||||||
"protein": 0.35,
|
|
||||||
"consistency": 0.20,
|
|
||||||
"quality": 0.20,
|
|
||||||
}
|
|
||||||
elif goal_mode == "endurance":
|
|
||||||
weights = {
|
|
||||||
"calorie": 0.30,
|
|
||||||
"protein": 0.20,
|
|
||||||
"consistency": 0.20,
|
|
||||||
"quality": 0.30,
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
weights = {
|
|
||||||
"calorie": 0.25,
|
|
||||||
"protein": 0.25,
|
|
||||||
"consistency": 0.25,
|
|
||||||
"quality": 0.25,
|
|
||||||
}
|
|
||||||
|
|
||||||
final_score = (
|
|
||||||
calorie_adherence * weights["calorie"]
|
|
||||||
+ protein_adherence * weights["protein"]
|
|
||||||
+ intake_consistency * weights["consistency"]
|
|
||||||
+ food_quality * weights["quality"]
|
|
||||||
)
|
|
||||||
|
|
||||||
components = {
|
|
||||||
"calorie_adherence": round(calorie_adherence, 1),
|
|
||||||
"protein_adherence": round(protein_adherence, 1),
|
|
||||||
"intake_consistency": round(intake_consistency, 1),
|
|
||||||
"food_quality": round(food_quality, 1),
|
|
||||||
}
|
|
||||||
|
|
||||||
weak_areas = [k for k, v in components.items() if v < 60]
|
|
||||||
if weak_areas:
|
|
||||||
recommendation = f"Verbesserungspotenzial: {', '.join(weak_areas)}"
|
|
||||||
else:
|
|
||||||
recommendation = "Gute Adhärenz, weiter so!"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"score": round(final_score, 1),
|
|
||||||
"components": components,
|
|
||||||
"goal_mode": goal_mode,
|
|
||||||
"weights": weights,
|
|
||||||
"recommendation": recommendation,
|
|
||||||
"metadata": {
|
|
||||||
"confidence": calculate_confidence(stats["cnt"], days, "general"),
|
|
||||||
"data_points": stats["cnt"],
|
|
||||||
"days_analyzed": days,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -1,323 +0,0 @@
|
||||||
"""
|
|
||||||
Interpretation + KPI-Kacheln für Layer 2b Ernährungs-Verlauf.
|
|
||||||
|
|
||||||
Gleiche Schwellen wie zuvor im Frontend (History.jsx); Ausgabe strukturiert
|
|
||||||
für KpiTilesOverview (keys = related_placeholder_keys).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
|
|
||||||
def _verdict(status: str) -> str:
|
|
||||||
if status == "good":
|
|
||||||
return "Gut"
|
|
||||||
if status == "warn":
|
|
||||||
return "Hinweis"
|
|
||||||
return "Achtung"
|
|
||||||
|
|
||||||
|
|
||||||
def build_nutrition_history_kpi_tiles(
|
|
||||||
navg: Dict[str, Any],
|
|
||||||
targets: Dict[str, Any],
|
|
||||||
date_span_label: str,
|
|
||||||
n_days_with_entries: int,
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
KPI-Kacheln wie buildNutritionKpiTiles im Frontend (Kalorien/KH/Fett + Regeln).
|
|
||||||
"""
|
|
||||||
kcal_avg = round(float(navg.get("kcal_avg") or 0))
|
|
||||||
avg_carbs = round(float(navg.get("carbs_avg") or 0) * 10) / 10
|
|
||||||
avg_fat = round(float(navg.get("fat_avg") or 0) * 10) / 10
|
|
||||||
avg_protein = round(float(navg.get("protein_avg") or 0) * 10) / 10
|
|
||||||
|
|
||||||
pt_low = round(float(targets.get("protein_target_low") or 0))
|
|
||||||
pt_high = round(float(targets.get("protein_target_high") or 0))
|
|
||||||
targets_ok = targets.get("confidence") != "insufficient" and pt_low > 0
|
|
||||||
protein_ok = targets_ok and avg_protein >= pt_low
|
|
||||||
|
|
||||||
total_macro_kcal = avg_protein * 4 + avg_carbs * 4 + avg_fat * 9
|
|
||||||
prot_pct = (
|
|
||||||
round(avg_protein * 4 / total_macro_kcal * 100)
|
|
||||||
if total_macro_kcal > 0
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
kh_pct = (
|
|
||||||
round(avg_carbs * 4 / total_macro_kcal * 100)
|
|
||||||
if total_macro_kcal > 0
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
fat_pct = (
|
|
||||||
round(avg_fat * 9 / total_macro_kcal * 100)
|
|
||||||
if total_macro_kcal > 0
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
|
|
||||||
tiles: List[Dict[str, Any]] = [
|
|
||||||
{
|
|
||||||
"key": "kcal",
|
|
||||||
"category": "Kalorien (Ø)",
|
|
||||||
"icon": "🔥",
|
|
||||||
"value": f"{kcal_avg} kcal",
|
|
||||||
"sublabel": date_span_label,
|
|
||||||
"status": "good",
|
|
||||||
"verdict": "Gut",
|
|
||||||
"hoverTop": "Durchschnittliche tägliche Energie",
|
|
||||||
"hoverBody": f"Mittel über {n_days_with_entries} Tage mit Ernährungseinträgen im gewählten Zeitraum.",
|
|
||||||
"keys": ["nutrition_score"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "carbs",
|
|
||||||
"category": "KH (Ø)",
|
|
||||||
"icon": "🌾",
|
|
||||||
"value": f"{avg_carbs} g",
|
|
||||||
"sublabel": "Kohlenhydrate / Tag",
|
|
||||||
"status": "good",
|
|
||||||
"verdict": "Gut",
|
|
||||||
"hoverTop": "Durchschnittliche Kohlenhydrate",
|
|
||||||
"hoverBody": "Summe der täglichen Werte im Zeitraum, gemittelt.",
|
|
||||||
"keys": ["nutrition_summary"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "fat",
|
|
||||||
"category": "Fett (Ø)",
|
|
||||||
"icon": "🧈",
|
|
||||||
"value": f"{avg_fat} g",
|
|
||||||
"sublabel": "Fett / Tag",
|
|
||||||
"status": "good",
|
|
||||||
"verdict": "Gut",
|
|
||||||
"hoverTop": "Durchschnittliches Fett",
|
|
||||||
"hoverBody": "Summe der täglichen Werte im Zeitraum, gemittelt.",
|
|
||||||
"keys": ["nutrition_summary"],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
if not targets_ok:
|
|
||||||
tiles.append(
|
|
||||||
{
|
|
||||||
"key": "eval-protein",
|
|
||||||
"category": "Protein",
|
|
||||||
"icon": "🥩",
|
|
||||||
"value": f"{avg_protein}g",
|
|
||||||
"sublabel": "Referenzgewicht fehlt",
|
|
||||||
"status": "warn",
|
|
||||||
"verdict": _verdict("warn"),
|
|
||||||
"hint": "Ohne aktuelles Körpergewicht lässt sich das Protein-Ziel (g/kg) nicht bewerten.",
|
|
||||||
"hoverTop": "Protein-Ziel nicht berechenbar",
|
|
||||||
"hoverBody": "Für 1,6–2,2 g/kg wird ein aktuelles Körpergewicht benötigt.",
|
|
||||||
"keys": ["protein_adequacy"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
elif not protein_ok:
|
|
||||||
miss = max(0, pt_low - round(avg_protein))
|
|
||||||
tiles.append(
|
|
||||||
{
|
|
||||||
"key": "eval-protein",
|
|
||||||
"category": "Protein",
|
|
||||||
"icon": "🥩",
|
|
||||||
"value": f"{avg_protein}g",
|
|
||||||
"sublabel": f"Unterversorgung: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)",
|
|
||||||
"status": "bad",
|
|
||||||
"verdict": _verdict("bad"),
|
|
||||||
"hint": (
|
|
||||||
f"~{miss} g Protein/Tag fehlen – bei Defizit Muskelerhalt gefährdet."
|
|
||||||
),
|
|
||||||
"hoverTop": f"Unterversorgung: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)",
|
|
||||||
"hoverBody": (
|
|
||||||
f"1,6–2,2g/kg KG. Fehlend: ~{miss}g täglich. "
|
|
||||||
"Konsequenz: Muskelverlust bei Defizit."
|
|
||||||
),
|
|
||||||
"keys": ["protein_adequacy", "nutrition_score"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
tiles.append(
|
|
||||||
{
|
|
||||||
"key": "eval-protein",
|
|
||||||
"category": "Protein",
|
|
||||||
"icon": "🥩",
|
|
||||||
"value": f"{avg_protein}g",
|
|
||||||
"sublabel": f"Gut: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)",
|
|
||||||
"status": "good",
|
|
||||||
"verdict": _verdict("good"),
|
|
||||||
"hoverTop": f"Gut: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)",
|
|
||||||
"hoverBody": "Ausreichend für Muskelerhalt und -aufbau.",
|
|
||||||
"keys": ["protein_adequacy", "nutrition_score"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if prot_pct < 20 and total_macro_kcal > 0:
|
|
||||||
tiles.append(
|
|
||||||
{
|
|
||||||
"key": "eval-macro-pct",
|
|
||||||
"category": "Makro-Anteil",
|
|
||||||
"icon": "📊",
|
|
||||||
"value": f"{prot_pct}%",
|
|
||||||
"sublabel": f"Protein-Anteil niedrig: {prot_pct}% der Kalorien",
|
|
||||||
"status": "warn",
|
|
||||||
"verdict": _verdict("warn"),
|
|
||||||
"hint": (
|
|
||||||
f"Protein-Kalorienanteil niedrig (P {prot_pct} % / KH {kh_pct} % / F {fat_pct} %); "
|
|
||||||
"Ziel oft 25–35 %."
|
|
||||||
),
|
|
||||||
"hoverTop": f"Protein-Anteil niedrig: {prot_pct}% der Kalorien",
|
|
||||||
"hoverBody": (
|
|
||||||
f"Empfehlung oft 25–35%. Aktuell: {prot_pct}% P / {kh_pct}% KH / {fat_pct}% F"
|
|
||||||
),
|
|
||||||
"keys": ["nutrition_summary"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return tiles
|
|
||||||
|
|
||||||
|
|
||||||
def build_energy_availability_kpi_tile(ea: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
||||||
"""E5: nur bei caution/warning — gleiche Daten wie /charts/energy-availability-warning."""
|
|
||||||
level = str(ea.get("warning_level") or "none").strip().lower()
|
|
||||||
if level == "none":
|
|
||||||
return None
|
|
||||||
triggers: List[str] = list(ea.get("triggers") or [])
|
|
||||||
msg = str(ea.get("message") or "").strip()
|
|
||||||
st = "bad" if level == "warning" else "warn"
|
|
||||||
first = triggers[0] if triggers else msg
|
|
||||||
if len(first) > 90:
|
|
||||||
first = first[:87] + "…"
|
|
||||||
meta = ea.get("metadata") if isinstance(ea.get("metadata"), dict) else {}
|
|
||||||
note = str(meta.get("note") or "")
|
|
||||||
hover_lines = [msg] + [f"• {t}" for t in triggers]
|
|
||||||
if note:
|
|
||||||
hover_lines.append(note)
|
|
||||||
return {
|
|
||||||
"key": "energy-availability-e5",
|
|
||||||
"category": "Energieverfügbarkeit",
|
|
||||||
"icon": "⚡",
|
|
||||||
"value": "Achtung" if level == "warning" else "Hinweis",
|
|
||||||
"sublabel": first or "Signale prüfen",
|
|
||||||
"status": st,
|
|
||||||
"verdict": _verdict(st),
|
|
||||||
"hint": msg,
|
|
||||||
"hoverTop": "Energieverfügbarkeit (Heuristik)",
|
|
||||||
"hoverBody": "\n".join(hover_lines),
|
|
||||||
"keys": ["nutrition_score"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_macro_donut_from_averages(navg: Dict[str, Any]) -> Optional[List[Dict[str, Any]]]:
|
|
||||||
"""Anteile in % der Makro-kcal + Gramm für Legende."""
|
|
||||||
p = float(navg.get("protein_avg") or 0)
|
|
||||||
c = float(navg.get("carbs_avg") or 0)
|
|
||||||
f = float(navg.get("fat_avg") or 0)
|
|
||||||
pkcal, ckcal, fkcal = p * 4, c * 4, f * 9
|
|
||||||
tot = pkcal + ckcal + fkcal
|
|
||||||
if tot <= 0:
|
|
||||||
return None
|
|
||||||
return [
|
|
||||||
{"name": "Protein", "value": round(pkcal / tot * 100), "color": "#4a8f72", "grams": round(p, 1)},
|
|
||||||
{"name": "KH", "value": round(ckcal / tot * 100), "color": "#c17d45", "grams": round(c, 1)},
|
|
||||||
{"name": "Fett", "value": round(fkcal / tot * 100), "color": "#6e8eb8", "grams": round(f, 1)},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def build_nutrition_correlation_heuristic_items(
|
|
||||||
merged_rows: List[Dict[str, Any]],
|
|
||||||
tdee_kcal: float,
|
|
||||||
protein_target_low_g: float,
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Heuristische Kurz-Aussagen (vormals Reiter «Korrelation») — gleiche Logik wie History.jsx,
|
|
||||||
TDEE aber aus Data-Layer (nutrition_metrics / estimate_tdee), nicht ×1,4 im Frontend.
|
|
||||||
"""
|
|
||||||
filtered = [
|
|
||||||
r
|
|
||||||
for r in merged_rows
|
|
||||||
if r.get("kcal") is not None and r.get("weight") is not None
|
|
||||||
]
|
|
||||||
if len(filtered) < 5:
|
|
||||||
return []
|
|
||||||
|
|
||||||
td = float(tdee_kcal)
|
|
||||||
latest_w = float(filtered[-1].get("weight") or 0) or 80.0
|
|
||||||
pt_low = round(float(protein_target_low_g or 0)) or max(1, round(latest_w * 1.6))
|
|
||||||
|
|
||||||
items: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
if len(filtered) >= 14:
|
|
||||||
high_k = [d for d in filtered if float(d.get("kcal") or 0) > td + 200]
|
|
||||||
low_k = [d for d in filtered if float(d.get("kcal") or 0) < td - 200]
|
|
||||||
if len(high_k) >= 3 and len(low_k) >= 3:
|
|
||||||
avg_wh = sum(float(d["weight"]) for d in high_k) / len(high_k)
|
|
||||||
avg_wl = sum(float(d["weight"]) for d in low_k) / len(low_k)
|
|
||||||
avg_wh_r = round(avg_wh * 10) / 10
|
|
||||||
avg_wl_r = round(avg_wl * 10) / 10
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"icon": "📊",
|
|
||||||
"status": "good" if avg_wl < avg_wh else "warn",
|
|
||||||
"title": (
|
|
||||||
f"Kalorienreduktion wirkt: Ø {avg_wl_r} kg bei Defizit vs. {avg_wh_r} kg bei Überschuss"
|
|
||||||
if avg_wl < avg_wh
|
|
||||||
else "Kein klarer Kalorieneffekt auf Gewicht erkennbar"
|
|
||||||
),
|
|
||||||
"detail": (
|
|
||||||
f"Tage mit Überschuss (>{int(td + 200)} kcal): Ø {avg_wh_r} kg · "
|
|
||||||
f"Tage mit Defizit (<{int(td - 200)} kcal): Ø {avg_wl_r} kg"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
prot_vs_lean = [
|
|
||||||
d
|
|
||||||
for d in filtered
|
|
||||||
if d.get("protein_g") is not None and d.get("lean_mass") is not None
|
|
||||||
]
|
|
||||||
if len(prot_vs_lean) >= 3:
|
|
||||||
high_p = [d for d in prot_vs_lean if float(d.get("protein_g") or 0) >= pt_low]
|
|
||||||
low_p = [d for d in prot_vs_lean if float(d.get("protein_g") or 0) < pt_low]
|
|
||||||
if len(high_p) >= 2 and len(low_p) >= 2:
|
|
||||||
avg_lh = sum(float(d["lean_mass"]) for d in high_p) / len(high_p)
|
|
||||||
avg_ll = sum(float(d["lean_mass"]) for d in low_p) / len(low_p)
|
|
||||||
avg_lh_r = round(avg_lh * 10) / 10
|
|
||||||
avg_ll_r = round(avg_ll * 10) / 10
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"icon": "🥩",
|
|
||||||
"status": "good" if avg_lh >= avg_ll else "warn",
|
|
||||||
"title": (
|
|
||||||
f"Hohe Proteinzufuhr (≥{pt_low} g): Ø {avg_lh_r} kg Mager · Niedrig: Ø {avg_ll_r} kg"
|
|
||||||
),
|
|
||||||
"detail": (
|
|
||||||
f"{len(high_p)} Messpunkte mit hoher vs. {len(low_p)} mit niedriger Proteinzufuhr verglichen."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
balances = [float(d["kcal"]) - td for d in filtered if d.get("kcal") is not None]
|
|
||||||
avg_balance = int(round(sum(balances) / len(balances))) if balances else 0
|
|
||||||
ab_s = f"{avg_balance:+d}" if avg_balance > 0 else str(avg_balance)
|
|
||||||
if avg_balance < -100:
|
|
||||||
ic, st = "✅", "good"
|
|
||||||
elif avg_balance > 200:
|
|
||||||
ic, st = "⬆️", "warn" if avg_balance > 300 else "good"
|
|
||||||
else:
|
|
||||||
ic, st = "➡️", "good"
|
|
||||||
|
|
||||||
if avg_balance < -500:
|
|
||||||
bal_detail = "Starkes Defizit – Muskelerhalt durch ausreichend Protein sicherstellen."
|
|
||||||
elif avg_balance < -100:
|
|
||||||
bal_detail = "Moderates Defizit – ideal für Fettabbau bei Muskelerhalt."
|
|
||||||
elif avg_balance > 300:
|
|
||||||
bal_detail = "Kalorienüberschuss – günstig für Muskelaufbau, Fettzunahme möglich."
|
|
||||||
else:
|
|
||||||
bal_detail = "Nahezu ausgeglichen – Gewicht sollte stabil bleiben."
|
|
||||||
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"icon": ic,
|
|
||||||
"status": st,
|
|
||||||
"title": f"Ø Kalorienbilanz: {ab_s} kcal/Tag",
|
|
||||||
"detail": f"Geschätzter TDEE: {int(round(td))} kcal (Data-Layer, konsistent mit Verlauf). {bal_detail}",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return items
|
|
||||||
|
|
@ -20,7 +20,6 @@ Phase 0c: Multi-Layer Architecture
|
||||||
Version: 1.0
|
Version: 1.0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import statistics
|
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
from datetime import datetime, timedelta, date
|
from datetime import datetime, timedelta, date
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
|
|
@ -111,9 +110,7 @@ def _get_profile_goal_mode(profile_id: str) -> str:
|
||||||
|
|
||||||
def get_nutrition_average_data(
|
def get_nutrition_average_data(
|
||||||
profile_id: str,
|
profile_id: str,
|
||||||
days: int = 30,
|
days: int = 30
|
||||||
*,
|
|
||||||
all_history: bool = False,
|
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""
|
"""
|
||||||
Get average nutrition values for all macros.
|
Get average nutrition values for all macros.
|
||||||
|
|
@ -139,18 +136,11 @@ def get_nutrition_average_data(
|
||||||
"""
|
"""
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cutoff = None if all_history else (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||||
|
|
||||||
# Mean over calendar days (per-day sums), not over raw log rows.
|
# Mean over calendar days (per-day sums), not over raw log rows.
|
||||||
if cutoff:
|
|
||||||
inner_where = "WHERE profile_id=%s AND date >= %s"
|
|
||||||
params = (profile_id, cutoff)
|
|
||||||
else:
|
|
||||||
inner_where = "WHERE profile_id=%s"
|
|
||||||
params = (profile_id,)
|
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""SELECT
|
"""SELECT
|
||||||
AVG(daily_kcal) AS kcal_avg,
|
AVG(daily_kcal) AS kcal_avg,
|
||||||
AVG(daily_protein) AS protein_avg,
|
AVG(daily_protein) AS protein_avg,
|
||||||
AVG(daily_carbs) AS carbs_avg,
|
AVG(daily_carbs) AS carbs_avg,
|
||||||
|
|
@ -163,10 +153,10 @@ def get_nutrition_average_data(
|
||||||
COALESCE(SUM(carbs_g), 0)::float AS daily_carbs,
|
COALESCE(SUM(carbs_g), 0)::float AS daily_carbs,
|
||||||
COALESCE(SUM(fat_g), 0)::float AS daily_fat
|
COALESCE(SUM(fat_g), 0)::float AS daily_fat
|
||||||
FROM nutrition_log
|
FROM nutrition_log
|
||||||
{inner_where}
|
WHERE profile_id=%s AND date >= %s
|
||||||
GROUP BY date
|
GROUP BY date
|
||||||
) AS daily""",
|
) AS daily""",
|
||||||
params,
|
(profile_id, cutoff),
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
|
|
||||||
|
|
@ -504,6 +494,8 @@ def get_macro_consistency_data(
|
||||||
"data_points": len(rows)
|
"data_points": len(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import statistics
|
||||||
|
|
||||||
protein_pcts = []
|
protein_pcts = []
|
||||||
carbs_pcts = []
|
carbs_pcts = []
|
||||||
fat_pcts = []
|
fat_pcts = []
|
||||||
|
|
@ -569,200 +561,6 @@ def get_macro_consistency_data(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_weekly_macro_distribution_chart_data(profile_id: str, weeks: int) -> Dict:
|
|
||||||
"""
|
|
||||||
Chart E3: gestapelte Wochenbalken (Makro-%), gleiche Logik wie /charts/weekly-macro-distribution.
|
|
||||||
"""
|
|
||||||
cutoff = (datetime.now() - timedelta(weeks=weeks)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT date, protein_g, carbs_g, fat_g, kcal
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
|
|
||||||
if not rows or len(rows) < 7:
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {
|
|
||||||
"labels": [],
|
|
||||||
"datasets": [],
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": len(rows) if rows else 0,
|
|
||||||
"message": "Nicht genug Daten für Wochen-Analyse (min. 7 Tage)",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
weekly_data: Dict[str, Dict[str, List[float]]] = {}
|
|
||||||
for row in rows:
|
|
||||||
date_obj = row["date"] if isinstance(row["date"], datetime) else datetime.fromisoformat(str(row["date"]))
|
|
||||||
iso_week = date_obj.strftime("%Y-W%V")
|
|
||||||
|
|
||||||
if iso_week not in weekly_data:
|
|
||||||
weekly_data[iso_week] = {
|
|
||||||
"protein": [],
|
|
||||||
"carbs": [],
|
|
||||||
"fat": [],
|
|
||||||
"kcal": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
weekly_data[iso_week]["protein"].append(safe_float(row["protein_g"]))
|
|
||||||
weekly_data[iso_week]["carbs"].append(safe_float(row["carbs_g"]))
|
|
||||||
weekly_data[iso_week]["fat"].append(safe_float(row["fat_g"]))
|
|
||||||
weekly_data[iso_week]["kcal"].append(safe_float(row["kcal"]))
|
|
||||||
|
|
||||||
labels: List[str] = []
|
|
||||||
protein_pcts: List[float] = []
|
|
||||||
carbs_pcts: List[float] = []
|
|
||||||
fat_pcts: List[float] = []
|
|
||||||
|
|
||||||
for iso_week in sorted(weekly_data.keys())[-weeks:]:
|
|
||||||
data = weekly_data[iso_week]
|
|
||||||
|
|
||||||
avg_protein = sum(data["protein"]) / len(data["protein"]) if data["protein"] else 0
|
|
||||||
avg_carbs = sum(data["carbs"]) / len(data["carbs"]) if data["carbs"] else 0
|
|
||||||
avg_fat = sum(data["fat"]) / len(data["fat"]) if data["fat"] else 0
|
|
||||||
|
|
||||||
protein_kcal = avg_protein * 4
|
|
||||||
carbs_kcal = avg_carbs * 4
|
|
||||||
fat_kcal = avg_fat * 9
|
|
||||||
|
|
||||||
total_kcal = protein_kcal + carbs_kcal + fat_kcal
|
|
||||||
|
|
||||||
if total_kcal > 0:
|
|
||||||
labels.append(f"KW {iso_week[-2:]}")
|
|
||||||
protein_pcts.append(round((protein_kcal / total_kcal) * 100, 1))
|
|
||||||
carbs_pcts.append(round((carbs_kcal / total_kcal) * 100, 1))
|
|
||||||
fat_pcts.append(round((fat_kcal / total_kcal) * 100, 1))
|
|
||||||
|
|
||||||
protein_cv = (
|
|
||||||
statistics.stdev(protein_pcts) / statistics.mean(protein_pcts) * 100
|
|
||||||
if len(protein_pcts) > 1 and statistics.mean(protein_pcts) > 0
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
carbs_cv = (
|
|
||||||
statistics.stdev(carbs_pcts) / statistics.mean(carbs_pcts) * 100
|
|
||||||
if len(carbs_pcts) > 1 and statistics.mean(carbs_pcts) > 0
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
fat_cv = (
|
|
||||||
statistics.stdev(fat_pcts) / statistics.mean(fat_pcts) * 100
|
|
||||||
if len(fat_pcts) > 1 and statistics.mean(fat_pcts) > 0
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {
|
|
||||||
"labels": labels,
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "Protein (%)",
|
|
||||||
"data": protein_pcts,
|
|
||||||
"backgroundColor": "#4a8f72",
|
|
||||||
"stack": "macro",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Kohlenhydrate (%)",
|
|
||||||
"data": carbs_pcts,
|
|
||||||
"backgroundColor": "#c17d45",
|
|
||||||
"stack": "macro",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Fett (%)",
|
|
||||||
"data": fat_pcts,
|
|
||||||
"backgroundColor": "#6e8eb8",
|
|
||||||
"stack": "macro",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": calculate_confidence(len(rows), weeks * 7, "general"),
|
|
||||||
"data_points": len(rows),
|
|
||||||
"weeks_analyzed": len(labels),
|
|
||||||
"avg_protein_pct": round(statistics.mean(protein_pcts), 1) if protein_pcts else 0,
|
|
||||||
"avg_carbs_pct": round(statistics.mean(carbs_pcts), 1) if carbs_pcts else 0,
|
|
||||||
"avg_fat_pct": round(statistics.mean(fat_pcts), 1) if fat_pcts else 0,
|
|
||||||
"protein_cv": round(protein_cv, 1),
|
|
||||||
"carbs_cv": round(carbs_cv, 1),
|
|
||||||
"fat_cv": round(fat_cv, 1),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_energy_availability_warning_payload(profile_id: str, days: int = 14) -> Dict:
|
|
||||||
"""
|
|
||||||
E5 Energieverfügbarkeit — gleiche Heuristik wie GET /charts/energy-availability-warning.
|
|
||||||
"""
|
|
||||||
from data_layer.recovery_metrics import calculate_recovery_score_v2, calculate_sleep_quality_7d
|
|
||||||
from data_layer.body_metrics import calculate_lbm_28d_change
|
|
||||||
|
|
||||||
triggers: List[str] = []
|
|
||||||
warning_level = "none"
|
|
||||||
|
|
||||||
energy_data = get_energy_balance_data(profile_id, days)
|
|
||||||
if energy_data.get("energy_balance", 0) < -500:
|
|
||||||
triggers.append("Großes Energiedefizit (>500 kcal/Tag)")
|
|
||||||
|
|
||||||
try:
|
|
||||||
recovery_score = calculate_recovery_score_v2(profile_id)
|
|
||||||
if recovery_score and recovery_score < 50:
|
|
||||||
triggers.append("Recovery Score niedrig (<50)")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
sleep_quality = calculate_sleep_quality_7d(profile_id)
|
|
||||||
if sleep_quality and sleep_quality < 60:
|
|
||||||
triggers.append("Schlafqualität reduziert (<60%)")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
lbm_change = calculate_lbm_28d_change(profile_id)
|
|
||||||
if lbm_change and lbm_change < -1.0:
|
|
||||||
triggers.append("Magermasse sinkt (-{:.1f} kg)".format(abs(lbm_change)))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if len(triggers) >= 3:
|
|
||||||
warning_level = "warning"
|
|
||||||
message = (
|
|
||||||
"⚠️ Hinweis auf mögliche Unterversorgung. Mehrere Indikatoren auffällig. "
|
|
||||||
"Erwäge Defizit-Anpassung oder Regenerationswoche."
|
|
||||||
)
|
|
||||||
elif len(triggers) >= 2:
|
|
||||||
warning_level = "caution"
|
|
||||||
message = (
|
|
||||||
"⚡ Beobachte folgende Signale genau. Aktuell noch kein Handlungsbedarf, aber Trend beachten."
|
|
||||||
)
|
|
||||||
elif len(triggers) >= 1:
|
|
||||||
warning_level = "caution"
|
|
||||||
message = "💡 Ein Indikator auffällig. Weiter beobachten."
|
|
||||||
else:
|
|
||||||
message = "✅ Energieverfügbarkeit unauffällig."
|
|
||||||
|
|
||||||
return {
|
|
||||||
"warning_level": warning_level,
|
|
||||||
"triggers": triggers,
|
|
||||||
"message": message,
|
|
||||||
"metadata": {
|
|
||||||
"days_analyzed": days,
|
|
||||||
"trigger_count": len(triggers),
|
|
||||||
"note": "Heuristische Einschätzung, keine medizinische Diagnose",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Calculated Metrics (migrated from calculations/nutrition_metrics.py)
|
# Calculated Metrics (migrated from calculations/nutrition_metrics.py)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -1,393 +0,0 @@
|
||||||
"""
|
|
||||||
Layer 2b: Ernährungs-Verlauf — ein Bundle für die UI (Issue #53).
|
|
||||||
|
|
||||||
Single Source: nutrition_metrics + dieselben Tabellen wie Ernährungs-Platzhalter.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date, datetime, timedelta
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from data_layer.nutrition_body_merge import build_merged_daily_nutrition_body_rows
|
|
||||||
from data_layer.nutrition_interpretation import (
|
|
||||||
build_energy_availability_kpi_tile,
|
|
||||||
build_macro_donut_from_averages,
|
|
||||||
build_nutrition_correlation_heuristic_items,
|
|
||||||
build_nutrition_history_kpi_tiles,
|
|
||||||
)
|
|
||||||
from data_layer.nutrition_chart_payloads import (
|
|
||||||
build_energy_balance_chart_payload,
|
|
||||||
build_nutrition_adherence_score_payload,
|
|
||||||
build_protein_adequacy_chart_payload,
|
|
||||||
)
|
|
||||||
from data_layer.nutrition_metrics import (
|
|
||||||
estimate_tdee_kcal_from_latest_weight,
|
|
||||||
get_energy_availability_warning_payload,
|
|
||||||
get_energy_balance_data,
|
|
||||||
get_nutrition_average_data,
|
|
||||||
get_protein_targets_data,
|
|
||||||
get_weekly_macro_distribution_chart_data,
|
|
||||||
)
|
|
||||||
from data_layer.utils import safe_float
|
|
||||||
|
|
||||||
|
|
||||||
def _cutoff_sql(days: int) -> Optional[str]:
|
|
||||||
if days >= 9999:
|
|
||||||
return None
|
|
||||||
return (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
|
|
||||||
def _iso(d: Any) -> Optional[str]:
|
|
||||||
if d is None:
|
|
||||||
return None
|
|
||||||
if hasattr(d, "isoformat"):
|
|
||||||
return d.isoformat()[:10]
|
|
||||||
return str(d)[:10]
|
|
||||||
|
|
||||||
|
|
||||||
def _rolling_avg(rows: List[Dict[str, Any]], key: str, window: int) -> List[Dict[str, Any]]:
|
|
||||||
out: List[Dict[str, Any]] = []
|
|
||||||
for i, d in enumerate(rows):
|
|
||||||
sl = rows[max(0, i - window + 1) : i + 1]
|
|
||||||
vals: List[float] = []
|
|
||||||
for x in sl:
|
|
||||||
v = safe_float(x.get(key))
|
|
||||||
if v is not None:
|
|
||||||
vals.append(v)
|
|
||||||
if not vals:
|
|
||||||
out.append({**d, f"{key}_avg": None})
|
|
||||||
continue
|
|
||||||
avg = round(sum(vals) / len(vals), 1)
|
|
||||||
out.append({**d, f"{key}_avg": avg})
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _has_nutrition_entries(profile_id: str) -> bool:
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"SELECT 1 FROM nutrition_log WHERE profile_id=%s LIMIT 1",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
return cur.fetchone() is not None
|
|
||||||
|
|
||||||
|
|
||||||
def _last_nutrition_date(profile_id: str) -> Optional[str]:
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"SELECT MAX(date) AS d FROM nutrition_log WHERE profile_id=%s",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row or row["d"] is None:
|
|
||||||
return None
|
|
||||||
return _iso(row["d"])
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_daily_macro_totals(profile_id: str, cutoff: Optional[str]) -> List[Dict[str, Any]]:
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
if cutoff:
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT date,
|
|
||||||
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
|
|
||||||
GROUP BY date
|
|
||||||
ORDER BY date ASC""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT date,
|
|
||||||
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
|
|
||||||
GROUP BY date
|
|
||||||
ORDER BY date ASC""",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
return [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
|
|
||||||
def _filter_merged_rows_by_cutoff(
|
|
||||||
merged: List[Dict[str, Any]], cutoff: Optional[str]
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
if not cutoff:
|
|
||||||
return list(merged)
|
|
||||||
return [r for r in merged if str(r.get("date"))[:10] >= cutoff]
|
|
||||||
|
|
||||||
|
|
||||||
def _calorie_balance_daily_series(
|
|
||||||
merged_filtered: List[Dict[str, Any]], tdee: float
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""Tagesbilanz (Aufnahme − TDEE) + 7-Tage-Mittel der Bilanz — gleiche TDEE-Quelle wie kcal_vs_weight."""
|
|
||||||
rows: List[Dict[str, Any]] = []
|
|
||||||
for r in merged_filtered:
|
|
||||||
if r.get("kcal") is None:
|
|
||||||
continue
|
|
||||||
ds = _iso(r.get("date"))
|
|
||||||
if not ds:
|
|
||||||
continue
|
|
||||||
bal = round(float(r["kcal"]) - float(tdee))
|
|
||||||
rows.append({"date": ds, "balance_kcal": bal})
|
|
||||||
rolled = _rolling_avg([dict(x) for x in rows], "balance_kcal", 7)
|
|
||||||
out: List[Dict[str, Any]] = []
|
|
||||||
for x in rolled:
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"date": x["date"],
|
|
||||||
"balance_kcal": x.get("balance_kcal"),
|
|
||||||
"balance_kcal_avg": x.get("balance_kcal_avg"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _protein_lean_mass_points(merged_filtered: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
||||||
out: List[Dict[str, Any]] = []
|
|
||||||
for r in merged_filtered:
|
|
||||||
if r.get("protein_g") is None or r.get("lean_mass") is None:
|
|
||||||
continue
|
|
||||||
ds = _iso(r.get("date"))
|
|
||||||
if not ds:
|
|
||||||
continue
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"date": ds,
|
|
||||||
"protein_g": round(safe_float(r.get("protein_g")) or 0, 1),
|
|
||||||
"lean_mass_kg": round(safe_float(r.get("lean_mass")) or 0, 2),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _kcal_weight_points_for_window(
|
|
||||||
profile_id: str, cutoff: Optional[str]
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""Gemeinsame Tage: Tages-kcal vs. Gewicht; gleiche Idee wie /nutrition/correlations, gefiltert."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
if cutoff:
|
|
||||||
cur.execute(
|
|
||||||
"""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""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT date, SUM(kcal)::float AS kcal
|
|
||||||
FROM nutrition_log
|
|
||||||
WHERE profile_id=%s AND kcal IS NOT NULL
|
|
||||||
GROUP BY date""",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
nk = { _iso(r["date"]): safe_float(r["kcal"]) for r in cur.fetchall() }
|
|
||||||
|
|
||||||
if cutoff:
|
|
||||||
cur.execute(
|
|
||||||
"SELECT date, weight FROM weight_log WHERE profile_id=%s AND date >= %s ORDER BY date",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cur.execute(
|
|
||||||
"SELECT date, weight FROM weight_log WHERE profile_id=%s ORDER BY date",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
wk = { _iso(r["date"]): safe_float(r["weight"]) for r in cur.fetchall() if r.get("weight") is not None }
|
|
||||||
|
|
||||||
common = sorted(set(nk) & set(wk))
|
|
||||||
raw: List[Dict[str, Any]] = []
|
|
||||||
for ds in common:
|
|
||||||
raw.append({"date": ds, "kcal": nk[ds], "weight": wk[ds]})
|
|
||||||
rolled = _rolling_avg(raw, "kcal", 7)
|
|
||||||
out: List[Dict[str, Any]] = []
|
|
||||||
for r in rolled:
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"date": r["date"],
|
|
||||||
"kcal": r.get("kcal"),
|
|
||||||
"weight": r.get("weight"),
|
|
||||||
"kcal_avg": r.get("kcal_avg"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Layer 2b Bundle für Verlauf «Ernährung».
|
|
||||||
|
|
||||||
days: Analysefenster (>=9999 = gesamte Historie für Mittelwerte / Reihen).
|
|
||||||
"""
|
|
||||||
if not _has_nutrition_entries(profile_id):
|
|
||||||
return {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"has_nutrition_entries": False,
|
|
||||||
"message": "Noch keine Ernährungsdaten",
|
|
||||||
"kpi_tiles": [],
|
|
||||||
"summary": {},
|
|
||||||
"daily_macros": [],
|
|
||||||
"donut_avg_pct": None,
|
|
||||||
"kcal_vs_weight": {"points": [], "tdee_reference_kcal": None, "common_days_count": 0},
|
|
||||||
"weekly_macro_chart": {},
|
|
||||||
"tdee_reference_kcal": None,
|
|
||||||
"energy_balance_meta": {},
|
|
||||||
"interpretation_tiles": [],
|
|
||||||
"energy_availability_warning": None,
|
|
||||||
"calorie_balance_daily": [],
|
|
||||||
"protein_vs_lean_mass": {"points": [], "protein_target_low_g": None},
|
|
||||||
"nutrition_correlation_heuristics": [],
|
|
||||||
"chart_payloads": {},
|
|
||||||
"chart_payloads_days": None,
|
|
||||||
"meta": {"layer_1": "nutrition_metrics", "layer_2b": "nutrition_viz"},
|
|
||||||
}
|
|
||||||
|
|
||||||
all_history = days >= 9999
|
|
||||||
eff_days = 3650 if all_history else max(7, min(int(days), 3650))
|
|
||||||
cutoff = _cutoff_sql(days)
|
|
||||||
chart_days_for_pipeline = 90 if all_history else max(7, min(eff_days, 365))
|
|
||||||
|
|
||||||
navg = get_nutrition_average_data(profile_id, eff_days, all_history=all_history)
|
|
||||||
targets = get_protein_targets_data(profile_id)
|
|
||||||
energy_days = eff_days if not all_history else min(9999, 3650)
|
|
||||||
energy_meta = get_energy_balance_data(profile_id, energy_days)
|
|
||||||
tdee = estimate_tdee_kcal_from_latest_weight(profile_id)
|
|
||||||
if tdee is None:
|
|
||||||
tdee = safe_float(energy_meta.get("estimated_tdee")) or None
|
|
||||||
else:
|
|
||||||
tdee = float(tdee)
|
|
||||||
|
|
||||||
daily_rows = _fetch_daily_macro_totals(profile_id, cutoff)
|
|
||||||
daily_macros: List[Dict[str, Any]] = []
|
|
||||||
for r in daily_rows:
|
|
||||||
daily_macros.append(
|
|
||||||
{
|
|
||||||
"date": _iso(r["date"]),
|
|
||||||
"kcal": round(safe_float(r.get("kcal")) or 0),
|
|
||||||
"Protein": round(safe_float(r.get("protein_g")) or 0),
|
|
||||||
"KH": round(safe_float(r.get("carbs_g")) or 0),
|
|
||||||
"Fett": round(safe_float(r.get("fat_g")) or 0),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
date_span_label = ""
|
|
||||||
if daily_macros:
|
|
||||||
date_span_label = f"{daily_macros[0]['date']} – {daily_macros[-1]['date']}"
|
|
||||||
|
|
||||||
n_days = int(navg.get("data_points") or 0)
|
|
||||||
kpi_tiles = build_nutrition_history_kpi_tiles(
|
|
||||||
navg, targets, date_span_label or "—", max(1, n_days)
|
|
||||||
)
|
|
||||||
|
|
||||||
ea_days = min(28, max(7, chart_days_for_pipeline))
|
|
||||||
ea_payload = get_energy_availability_warning_payload(profile_id, ea_days)
|
|
||||||
ea_tile = build_energy_availability_kpi_tile(ea_payload)
|
|
||||||
kpi_tiles_out: List[Dict[str, Any]] = list(kpi_tiles)
|
|
||||||
if ea_tile:
|
|
||||||
kpi_tiles_out.append(ea_tile)
|
|
||||||
|
|
||||||
donut = build_macro_donut_from_averages(navg)
|
|
||||||
|
|
||||||
kw_points = _kcal_weight_points_for_window(profile_id, cutoff)
|
|
||||||
pt_low = round(float(targets.get("protein_target_low") or 0))
|
|
||||||
|
|
||||||
merged_all = build_merged_daily_nutrition_body_rows(profile_id)
|
|
||||||
merged_win = _filter_merged_rows_by_cutoff(merged_all, cutoff)
|
|
||||||
tdee_eff = float(tdee) if tdee is not None else float(safe_float(energy_meta.get("estimated_tdee")) or 0)
|
|
||||||
calorie_balance_daily: List[Dict[str, Any]] = (
|
|
||||||
_calorie_balance_daily_series(merged_win, tdee_eff) if tdee_eff > 0 else []
|
|
||||||
)
|
|
||||||
pl_points = _protein_lean_mass_points(merged_win)
|
|
||||||
nutrition_correlation_heuristics = (
|
|
||||||
build_nutrition_correlation_heuristic_items(merged_win, tdee_eff, float(pt_low))
|
|
||||||
if tdee_eff > 0
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
|
|
||||||
weeks_for_weekly = max(4, min(52, (chart_days_for_pipeline + 6) // 7))
|
|
||||||
weekly_chart = get_weekly_macro_distribution_chart_data(profile_id, weeks_for_weekly)
|
|
||||||
|
|
||||||
# E1/E2/E4 Chart.js-Payloads — gleiche Funktionen wie /api/charts/* (kein zweiter HTTP-Roundtrip im Verlauf)
|
|
||||||
days_for_embedded_charts = max(7, min(int(chart_days_for_pipeline), 90))
|
|
||||||
chart_payloads = {
|
|
||||||
"energy_balance": build_energy_balance_chart_payload(
|
|
||||||
profile_id, days_for_embedded_charts
|
|
||||||
),
|
|
||||||
"protein_adequacy": build_protein_adequacy_chart_payload(
|
|
||||||
profile_id, days_for_embedded_charts
|
|
||||||
),
|
|
||||||
"nutrition_adherence": build_nutrition_adherence_score_payload(
|
|
||||||
profile_id, days_for_embedded_charts
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
conf = navg.get("confidence") or "medium"
|
|
||||||
if targets.get("confidence") == "insufficient":
|
|
||||||
conf = "insufficient"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"confidence": conf,
|
|
||||||
"has_nutrition_entries": True,
|
|
||||||
"days_requested": days,
|
|
||||||
"effective_window_days": eff_days,
|
|
||||||
"nutrition_charts_days": chart_days_for_pipeline,
|
|
||||||
"weekly_macro_weeks_used": weeks_for_weekly,
|
|
||||||
"last_updated": _last_nutrition_date(profile_id),
|
|
||||||
"summary": {
|
|
||||||
"kcal_avg": navg.get("kcal_avg"),
|
|
||||||
"protein_avg": navg.get("protein_avg"),
|
|
||||||
"carbs_avg": navg.get("carbs_avg"),
|
|
||||||
"fat_avg": navg.get("fat_avg"),
|
|
||||||
"data_points": navg.get("data_points"),
|
|
||||||
"days_analyzed": navg.get("days_analyzed"),
|
|
||||||
"protein_target_low": targets.get("protein_target_low"),
|
|
||||||
"protein_target_high": targets.get("protein_target_high"),
|
|
||||||
"reference_weight_kg": targets.get("current_weight"),
|
|
||||||
},
|
|
||||||
"kpi_tiles": kpi_tiles_out,
|
|
||||||
"interpretation_tiles": [],
|
|
||||||
"energy_availability_warning": ea_payload,
|
|
||||||
"daily_macros": daily_macros,
|
|
||||||
"donut_avg_pct": donut,
|
|
||||||
"protein_reference_line_g": pt_low,
|
|
||||||
"kcal_vs_weight": {
|
|
||||||
"points": kw_points,
|
|
||||||
"tdee_reference_kcal": tdee,
|
|
||||||
"common_days_count": len(kw_points),
|
|
||||||
},
|
|
||||||
"weekly_macro_chart": weekly_chart,
|
|
||||||
"tdee_reference_kcal": tdee,
|
|
||||||
"energy_balance_meta": {
|
|
||||||
"energy_balance": energy_meta.get("energy_balance"),
|
|
||||||
"avg_intake": energy_meta.get("avg_intake"),
|
|
||||||
"estimated_tdee": energy_meta.get("estimated_tdee"),
|
|
||||||
"status": energy_meta.get("status"),
|
|
||||||
"confidence": energy_meta.get("confidence"),
|
|
||||||
"data_points": energy_meta.get("data_points"),
|
|
||||||
},
|
|
||||||
"calorie_balance_daily": calorie_balance_daily,
|
|
||||||
"protein_vs_lean_mass": {
|
|
||||||
"points": pl_points,
|
|
||||||
"protein_target_low_g": pt_low if pt_low > 0 else None,
|
|
||||||
},
|
|
||||||
"nutrition_correlation_heuristics": nutrition_correlation_heuristics,
|
|
||||||
"chart_payloads": chart_payloads,
|
|
||||||
"chart_payloads_days": days_for_embedded_charts,
|
|
||||||
"meta": {
|
|
||||||
"layer_1": "nutrition_metrics",
|
|
||||||
"layer_2b": "nutrition_viz",
|
|
||||||
"issue": "53-phase-0c",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
"""
|
|
||||||
Kompakte Zahlen- und JSON-Aufbereitung für KI-Platzhalter (Token sparen).
|
|
||||||
|
|
||||||
- Floats: sinnvolle Nachkommastellen je nach Größenordnung (kleine Werte <0,1 mehr Präzision).
|
|
||||||
- ≥10 meist ganzzahlig; Prozent/Verhältnisse über denselben Mechanismus lesbar.
|
|
||||||
- Rekursiv auf dict/list-Strukturen vor json.dumps in _safe_json anwendbar.
|
|
||||||
|
|
||||||
Hinweis: numpy.float64 und numerische Strings (DB/API) sind keine ``float``-Instanzen —
|
|
||||||
diese werden explizit mit float() normalisiert.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import math
|
|
||||||
import re
|
|
||||||
from decimal import Decimal
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
def compact_float_for_prompt(x: float) -> float | int:
|
|
||||||
"""
|
|
||||||
Reduziert unnötige Nachkommastellen; erhält kleine Beträge (<0,1) mit mehr Stellen.
|
|
||||||
"""
|
|
||||||
if not math.isfinite(x):
|
|
||||||
return x
|
|
||||||
ax = abs(x)
|
|
||||||
if ax == 0.0:
|
|
||||||
return 0
|
|
||||||
if ax >= 100.0:
|
|
||||||
return int(round(x))
|
|
||||||
if ax >= 10.0:
|
|
||||||
return int(round(x))
|
|
||||||
if ax >= 1.0:
|
|
||||||
r = round(x, 2)
|
|
||||||
return int(r) if abs(r - int(round(r))) < 1e-6 else r
|
|
||||||
if ax >= 0.1:
|
|
||||||
r = round(x, 2)
|
|
||||||
return int(r) if abs(r - int(round(r))) < 1e-6 else r
|
|
||||||
if ax >= 0.01:
|
|
||||||
return round(x, 3)
|
|
||||||
return round(x, 4)
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_prompt_number(x: Any) -> Any:
|
|
||||||
"""int/Decimal/float kompakt; numpy-Scalars; numerische Strings; sonst unverändert."""
|
|
||||||
if x is None:
|
|
||||||
return None
|
|
||||||
if isinstance(x, bool):
|
|
||||||
return x
|
|
||||||
if isinstance(x, int) and not isinstance(x, bool):
|
|
||||||
return x
|
|
||||||
if isinstance(x, str):
|
|
||||||
s = x.strip()
|
|
||||||
if not s:
|
|
||||||
return x
|
|
||||||
try:
|
|
||||||
if re.fullmatch(r"-?\d+", s):
|
|
||||||
return int(s)
|
|
||||||
xf = float(s)
|
|
||||||
except ValueError:
|
|
||||||
return x
|
|
||||||
if not math.isfinite(xf):
|
|
||||||
return x
|
|
||||||
return compact_float_for_prompt(xf)
|
|
||||||
if isinstance(x, Decimal):
|
|
||||||
try:
|
|
||||||
xf = float(x)
|
|
||||||
except Exception:
|
|
||||||
return x
|
|
||||||
if not math.isfinite(xf):
|
|
||||||
return x
|
|
||||||
return compact_float_for_prompt(xf)
|
|
||||||
if isinstance(x, float):
|
|
||||||
if not math.isfinite(x):
|
|
||||||
return x
|
|
||||||
return compact_float_for_prompt(x)
|
|
||||||
try:
|
|
||||||
xf = float(x)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return x
|
|
||||||
if not math.isfinite(xf):
|
|
||||||
return x
|
|
||||||
return compact_float_for_prompt(xf)
|
|
||||||
|
|
||||||
|
|
||||||
def compact_json_payload_for_prompts(obj: Any) -> Any:
|
|
||||||
"""
|
|
||||||
Tiefe Kopie mit kompakten Zahlen (dicts/list/tuples rekursiv).
|
|
||||||
Strings und dict-Keys werden nicht verändert.
|
|
||||||
"""
|
|
||||||
if obj is None:
|
|
||||||
return None
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
return {k: compact_json_payload_for_prompts(v) for k, v in obj.items()}
|
|
||||||
if isinstance(obj, (list, tuple)):
|
|
||||||
t = [compact_json_payload_for_prompts(v) for v in obj]
|
|
||||||
return tuple(t) if isinstance(obj, tuple) else t
|
|
||||||
return normalize_prompt_number(obj)
|
|
||||||
|
|
||||||
|
|
||||||
def format_scalar_for_prompt_text(x: Any) -> str:
|
|
||||||
"""
|
|
||||||
Kurzdarstellung für Text-Platzhalter (activity_detail, Tabellen, …).
|
|
||||||
Alle Zahlenpfade über normalize_prompt_number; Ausgabe kurz (%g, keine Float-Schweife).
|
|
||||||
"""
|
|
||||||
if x is None:
|
|
||||||
return "—"
|
|
||||||
if isinstance(x, bool):
|
|
||||||
return "ja" if x else "nein"
|
|
||||||
n = normalize_prompt_number(x)
|
|
||||||
if isinstance(n, bool):
|
|
||||||
return "ja" if n else "nein"
|
|
||||||
if isinstance(n, str):
|
|
||||||
return n
|
|
||||||
if isinstance(n, int) and not isinstance(n, bool):
|
|
||||||
return str(n)
|
|
||||||
if isinstance(n, float):
|
|
||||||
if not math.isfinite(n):
|
|
||||||
return str(n)
|
|
||||||
return "%g" % n
|
|
||||||
return str(n)
|
|
||||||
|
|
||||||
|
|
||||||
def session_metrics_list_to_key_value_compact(metrics: list[Any] | None) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Session-Metriken für KI-JSON: nur key → Wert (keine wiederholten Namen/Beschreibungen).
|
|
||||||
|
|
||||||
Semantik: {{training_parameters_glossary_md}} im Prompt ergänzen.
|
|
||||||
"""
|
|
||||||
out: dict[str, Any] = {}
|
|
||||||
for m in metrics or []:
|
|
||||||
if not isinstance(m, dict):
|
|
||||||
continue
|
|
||||||
k = m.get("key")
|
|
||||||
if not k:
|
|
||||||
continue
|
|
||||||
v = m.get("value")
|
|
||||||
dt = (m.get("data_type") or "").lower()
|
|
||||||
if v is None:
|
|
||||||
out[str(k)] = None
|
|
||||||
continue
|
|
||||||
if dt == "integer":
|
|
||||||
try:
|
|
||||||
out[str(k)] = int(v)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
out[str(k)] = normalize_prompt_number(v)
|
|
||||||
elif dt == "boolean":
|
|
||||||
out[str(k)] = bool(v)
|
|
||||||
elif dt == "string":
|
|
||||||
out[str(k)] = normalize_prompt_number(v)
|
|
||||||
else:
|
|
||||||
out[str(k)] = normalize_prompt_number(v)
|
|
||||||
return out
|
|
||||||
|
|
@ -1,573 +0,0 @@
|
||||||
"""
|
|
||||||
Chart.js-Payloads für Recovery (R1–R5) — gemeinsam mit routers/charts und recovery-dashboard-viz.
|
|
||||||
|
|
||||||
Ausgelagert aus routers/charts.py (Issue 53 / Layer 1).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date, datetime, timedelta
|
|
||||||
from typing import Any, Dict, Optional, Set
|
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
|
||||||
from data_layer.recovery_metrics import (
|
|
||||||
SLEEP_DEBT_ROLLING_WINDOW_DAYS,
|
|
||||||
SLEEP_DEBT_TARGET_HOURS_DEFAULT,
|
|
||||||
calculate_hrv_vs_baseline_pct,
|
|
||||||
calculate_recovery_score_v2,
|
|
||||||
calculate_rhr_vs_baseline_pct,
|
|
||||||
calculate_sleep_debt_hours,
|
|
||||||
get_sleep_duration_data,
|
|
||||||
get_sleep_quality_data,
|
|
||||||
sleep_debt_sum_hours_in_window,
|
|
||||||
)
|
|
||||||
from data_layer.utils import calculate_confidence, safe_float, serialize_dates
|
|
||||||
from data_layer.vital_signs_assessment import build_vital_items_from_rows
|
|
||||||
|
|
||||||
|
|
||||||
def build_recovery_score_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
if days < 7:
|
|
||||||
days = 7
|
|
||||||
if days > 90:
|
|
||||||
days = 90
|
|
||||||
current_score = calculate_recovery_score_v2(profile_id)
|
|
||||||
|
|
||||||
if current_score is None:
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Keine Recovery-Daten vorhanden",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT date, resting_hr, hrv
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id=%s AND date >= %s
|
|
||||||
ORDER BY date""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
|
|
||||||
if not rows:
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {
|
|
||||||
"labels": [datetime.now().strftime("%Y-%m-%d")],
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "Recovery Score",
|
|
||||||
"data": [current_score],
|
|
||||||
"borderColor": "#1D9E75",
|
|
||||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"tension": 0.3,
|
|
||||||
"fill": True,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "low",
|
|
||||||
"data_points": 1,
|
|
||||||
"current_score": current_score,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
labels = [row["date"].isoformat() for row in rows]
|
|
||||||
values = [min(100, max(0, safe_float(row["hrv"]) if row["hrv"] else 50)) for row in rows]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {
|
|
||||||
"labels": labels,
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "HRV (ms, auf 0–100 begrenzt) — nicht der KPI Recovery-Score",
|
|
||||||
"data": values,
|
|
||||||
"borderColor": "#1D9E75",
|
|
||||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"tension": 0.3,
|
|
||||||
"fill": True,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"metadata": serialize_dates(
|
|
||||||
{
|
|
||||||
"confidence": calculate_confidence(len(rows), days, "general"),
|
|
||||||
"data_points": len(rows),
|
|
||||||
"current_score": current_score,
|
|
||||||
"chart_series_kind": "hrv_ms_clamped",
|
|
||||||
"kpi_score_source": "calculate_recovery_score_v2",
|
|
||||||
"note": "Kurve = HRV-Rohwert (ms) begrenzt auf 0–100, nur Verlaufsorientierung. "
|
|
||||||
"KPI-Kachel «Recovery-Score» = gewichteter Score (HRV, RHR, Schlaf, …).",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_hrv_rhr_baseline_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
if days < 7:
|
|
||||||
days = 7
|
|
||||||
if days > 90:
|
|
||||||
days = 90
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT date, resting_hr, hrv
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id=%s AND date >= %s
|
|
||||||
ORDER BY date""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
|
|
||||||
if not rows:
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Keine Vitalwerte vorhanden",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
labels = [row["date"].isoformat() for row in rows]
|
|
||||||
hrv_values = [safe_float(row["hrv"]) if row["hrv"] else None for row in rows]
|
|
||||||
rhr_values = [safe_float(row["resting_hr"]) if row["resting_hr"] else None for row in rows]
|
|
||||||
|
|
||||||
hrv_baseline = calculate_hrv_vs_baseline_pct(profile_id)
|
|
||||||
rhr_baseline = calculate_rhr_vs_baseline_pct(profile_id)
|
|
||||||
|
|
||||||
hrv_filtered = [v for v in hrv_values if v is not None]
|
|
||||||
rhr_filtered = [v for v in rhr_values if v is not None]
|
|
||||||
|
|
||||||
avg_hrv = sum(hrv_filtered) / len(hrv_filtered) if hrv_filtered else 50
|
|
||||||
avg_rhr = sum(rhr_filtered) / len(rhr_filtered) if rhr_filtered else 60
|
|
||||||
|
|
||||||
datasets = [
|
|
||||||
{
|
|
||||||
"label": "HRV (ms)",
|
|
||||||
"data": hrv_values,
|
|
||||||
"borderColor": "#1D9E75",
|
|
||||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"tension": 0.3,
|
|
||||||
"yAxisID": "y1",
|
|
||||||
"fill": False,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "RHR (bpm)",
|
|
||||||
"data": rhr_values,
|
|
||||||
"borderColor": "#3B82F6",
|
|
||||||
"backgroundColor": "rgba(59, 130, 246, 0.1)",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"tension": 0.3,
|
|
||||||
"yAxisID": "y2",
|
|
||||||
"fill": False,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {"labels": labels, "datasets": datasets},
|
|
||||||
"metadata": serialize_dates(
|
|
||||||
{
|
|
||||||
"confidence": calculate_confidence(len(rows), days, "general"),
|
|
||||||
"data_points": len(rows),
|
|
||||||
"avg_hrv": round(avg_hrv, 1),
|
|
||||||
"avg_rhr": round(avg_rhr, 1),
|
|
||||||
"hrv_vs_baseline_pct": hrv_baseline,
|
|
||||||
"rhr_vs_baseline_pct": rhr_baseline,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_sleep_duration_quality_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
if days < 7:
|
|
||||||
days = 7
|
|
||||||
if days > 90:
|
|
||||||
days = 90
|
|
||||||
duration_data = get_sleep_duration_data(profile_id, days)
|
|
||||||
quality_data = get_sleep_quality_data(profile_id, days)
|
|
||||||
|
|
||||||
if duration_data["confidence"] == "insufficient":
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Keine Schlafdaten vorhanden",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT date, duration_minutes
|
|
||||||
FROM sleep_log
|
|
||||||
WHERE profile_id=%s AND date >= %s
|
|
||||||
ORDER BY date""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
|
|
||||||
if not rows:
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Keine Schlafdaten",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
labels = [row["date"].isoformat() for row in rows]
|
|
||||||
duration_hours = [
|
|
||||||
safe_float(row["duration_minutes"]) / 60 if row["duration_minutes"] else None for row in rows
|
|
||||||
]
|
|
||||||
|
|
||||||
quality_scores = [(d / 8 * 100) if d else None for d in duration_hours]
|
|
||||||
|
|
||||||
datasets = [
|
|
||||||
{
|
|
||||||
"label": "Schlafdauer (h)",
|
|
||||||
"data": duration_hours,
|
|
||||||
"borderColor": "#3B82F6",
|
|
||||||
"backgroundColor": "rgba(59, 130, 246, 0.1)",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"tension": 0.3,
|
|
||||||
"yAxisID": "y1",
|
|
||||||
"fill": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Qualität (%)",
|
|
||||||
"data": quality_scores,
|
|
||||||
"borderColor": "#1D9E75",
|
|
||||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"tension": 0.3,
|
|
||||||
"yAxisID": "y2",
|
|
||||||
"fill": False,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {"labels": labels, "datasets": datasets},
|
|
||||||
"metadata": serialize_dates(
|
|
||||||
{
|
|
||||||
"confidence": duration_data["confidence"],
|
|
||||||
"data_points": len(rows),
|
|
||||||
"avg_duration_hours": round(duration_data["avg_duration_hours"], 1),
|
|
||||||
"sleep_quality_score": quality_data.get("quality_score", 0),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_sleep_debt_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
if days < 7:
|
|
||||||
days = 7
|
|
||||||
if days > 90:
|
|
||||||
days = 90
|
|
||||||
current_debt = calculate_sleep_debt_hours(profile_id)
|
|
||||||
|
|
||||||
if current_debt is None:
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Keine Schlafdaten für Schulden-Berechnung",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
chart_cutoff = (datetime.now() - timedelta(days=days)).date()
|
|
||||||
# Historie vor dem Chart-Fenster, damit das rollierende 14-Tage-Fenster früh korrekt gefüllt ist
|
|
||||||
ext_cutoff = (datetime.now() - timedelta(days=days + SLEEP_DEBT_ROLLING_WINDOW_DAYS + 3)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT date, duration_minutes
|
|
||||||
FROM sleep_log
|
|
||||||
WHERE profile_id=%s AND date >= %s
|
|
||||||
AND duration_minutes IS NOT NULL
|
|
||||||
ORDER BY date ASC""",
|
|
||||||
(profile_id, ext_cutoff),
|
|
||||||
)
|
|
||||||
all_rows = [dict(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
visible = []
|
|
||||||
for r in all_rows:
|
|
||||||
rd = r.get("date")
|
|
||||||
d = rd.date() if isinstance(rd, datetime) else rd
|
|
||||||
if d >= chart_cutoff:
|
|
||||||
visible.append(r)
|
|
||||||
|
|
||||||
if not visible:
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Keine Schlafdaten",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
labels: list[str] = []
|
|
||||||
debt_values: list[float] = []
|
|
||||||
for r in visible:
|
|
||||||
rd = r.get("date")
|
|
||||||
end_d = rd.date() if isinstance(rd, datetime) else rd
|
|
||||||
if not isinstance(end_d, date):
|
|
||||||
continue
|
|
||||||
labels.append(end_d.isoformat())
|
|
||||||
debt_values.append(sleep_debt_sum_hours_in_window(all_rows, end_d))
|
|
||||||
|
|
||||||
# KPI nutzt immer Fensterende = heute; die Kurve endete bisher am Datum der letzten Schlaf-Zeile
|
|
||||||
# (z. B. gestern) → anderes 14-Tage-Fenster. Letzter Punkt = exakt KPI-Wert, Datum = heute.
|
|
||||||
today = datetime.now().date()
|
|
||||||
if labels and debt_values:
|
|
||||||
try:
|
|
||||||
last_d = date.fromisoformat(labels[-1])
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
last_d = None
|
|
||||||
if last_d is not None:
|
|
||||||
if last_d < today:
|
|
||||||
labels.append(today.isoformat())
|
|
||||||
debt_values.append(float(current_debt))
|
|
||||||
elif last_d == today:
|
|
||||||
debt_values[-1] = float(current_debt)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {
|
|
||||||
"labels": labels,
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": f"Schlafschuld (h), rollierend {SLEEP_DEBT_ROLLING_WINDOW_DAYS} Tage — wie KPI",
|
|
||||||
"data": debt_values,
|
|
||||||
"borderColor": "#EF4444",
|
|
||||||
"backgroundColor": "rgba(239, 68, 68, 0.1)",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"tension": 0.3,
|
|
||||||
"fill": True,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"metadata": serialize_dates(
|
|
||||||
{
|
|
||||||
"confidence": calculate_confidence(len(visible), days, "general"),
|
|
||||||
"data_points": len(labels),
|
|
||||||
"current_debt_hours": round(float(current_debt), 1),
|
|
||||||
"sleep_debt_target_hours_per_night": SLEEP_DEBT_TARGET_HOURS_DEFAULT,
|
|
||||||
"rolling_window_days": SLEEP_DEBT_ROLLING_WINDOW_DAYS,
|
|
||||||
"note": "Gleiche Formel wie KPI: Summe der nächtlichen Defizite vs. "
|
|
||||||
f"{SLEEP_DEBT_TARGET_HOURS_DEFAULT} h/Nacht im rollierenden {SLEEP_DEBT_ROLLING_WINDOW_DAYS}-Tage-Fenster. "
|
|
||||||
"Zwischenpunkte: Fensterende = Datum der jeweiligen Schlaf-Zeile; "
|
|
||||||
"letzter Punkt ist auf «heute» bzw. KPI-Wert gesetzt, damit Kurve und Kachel übereinstimmen.",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
VITAL_BASELINE_KEYS = ("resting_hr", "hrv", "vo2_max", "spo2", "respiratory_rate")
|
|
||||||
|
|
||||||
|
|
||||||
def _vitals_row_has_any_value(row: Any) -> bool:
|
|
||||||
if not row:
|
|
||||||
return False
|
|
||||||
for k in VITAL_BASELINE_KEYS:
|
|
||||||
if row.get(k) is not None:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_vitals_baseline_rows(rows: Any) -> tuple[Optional[Dict[str, Any]], Optional[Any]]:
|
|
||||||
"""
|
|
||||||
Pro Kennzahl den jeweils neuesten nicht-leeren Wert (Zeilen sortiert: date DESC).
|
|
||||||
So können KPIs (Aggregation über Zeilen) Daten haben, obwohl die jüngste Zeile leer ist.
|
|
||||||
"""
|
|
||||||
if not rows:
|
|
||||||
return None, None
|
|
||||||
merged: Dict[str, Any] = {k: None for k in VITAL_BASELINE_KEYS}
|
|
||||||
for row in rows:
|
|
||||||
for k in VITAL_BASELINE_KEYS:
|
|
||||||
if merged[k] is None and row.get(k) is not None:
|
|
||||||
merged[k] = row[k]
|
|
||||||
if all(merged[k] is not None for k in VITAL_BASELINE_KEYS):
|
|
||||||
break
|
|
||||||
if not _vitals_row_has_any_value(merged):
|
|
||||||
return None, None
|
|
||||||
newest_date = rows[0].get("date") if rows else None
|
|
||||||
return merged, newest_date
|
|
||||||
|
|
||||||
|
|
||||||
def _bp_row_complete(row: Any) -> bool:
|
|
||||||
return bool(row and row.get("systolic") is not None and row.get("diastolic") is not None)
|
|
||||||
|
|
||||||
|
|
||||||
def _tone_to_bar_value(tone: str) -> float:
|
|
||||||
return {"good": 88.0, "warn": 52.0, "bad": 22.0, "neutral": 62.0}.get(tone, 55.0)
|
|
||||||
|
|
||||||
|
|
||||||
def build_vital_signs_matrix_chart_payload(
|
|
||||||
profile_id: str,
|
|
||||||
days: int,
|
|
||||||
omit_snapshot_keys: Optional[Set[str]] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Letzte Messungen im Fenster; sonst Fallback auf jüngste Messung überhaupt (Issue 53 / Layer 1).
|
|
||||||
|
|
||||||
omit_snapshot_keys: z. B. {'resting_hr','hrv'} wenn dieselbe Einordnung bereits im Vital-Verlauf steht.
|
|
||||||
"""
|
|
||||||
if days < 7:
|
|
||||||
days = 7
|
|
||||||
if days > 365:
|
|
||||||
days = 365
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
bp_row = None
|
|
||||||
vitals_measured_at = None
|
|
||||||
bp_measured_at = None
|
|
||||||
vitals_for_items: Optional[Dict[str, Any]] = None
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT date, resting_hr, hrv, vo2_max, spo2, respiratory_rate
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id=%s AND date >= %s
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 200""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
vitals_merged, vitals_date = _merge_vitals_baseline_rows(cur.fetchall())
|
|
||||||
if vitals_merged is None:
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT date, resting_hr, hrv, vo2_max, spo2, respiratory_rate
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id=%s
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 400""",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
vitals_merged, vitals_date = _merge_vitals_baseline_rows(cur.fetchall())
|
|
||||||
if vitals_merged is not None:
|
|
||||||
vitals_for_items = dict(vitals_merged)
|
|
||||||
if vitals_date is not None:
|
|
||||||
vitals_measured_at = vitals_date.isoformat() if hasattr(vitals_date, "isoformat") else str(vitals_date)
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT measured_at, systolic, diastolic
|
|
||||||
FROM blood_pressure_log
|
|
||||||
WHERE profile_id=%s AND measured_at::date >= %s::date
|
|
||||||
ORDER BY measured_at DESC
|
|
||||||
LIMIT 1""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
bp_row = cur.fetchone()
|
|
||||||
if bp_row and bp_row.get("measured_at") is not None:
|
|
||||||
bp_measured_at = bp_row["measured_at"]
|
|
||||||
|
|
||||||
if not _bp_row_complete(bp_row):
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT measured_at, systolic, diastolic
|
|
||||||
FROM blood_pressure_log
|
|
||||||
WHERE profile_id=%s
|
|
||||||
ORDER BY measured_at DESC
|
|
||||||
LIMIT 1""",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
bp_row = cur.fetchone()
|
|
||||||
if bp_row and bp_row.get("measured_at") is not None:
|
|
||||||
bp_measured_at = bp_row["measured_at"]
|
|
||||||
|
|
||||||
bp_for_items = None
|
|
||||||
if bp_row:
|
|
||||||
bp_for_items = {"systolic": bp_row.get("systolic"), "diastolic": bp_row.get("diastolic")}
|
|
||||||
|
|
||||||
items = build_vital_items_from_rows(
|
|
||||||
vitals_for_items, bp_for_items, omit_keys=omit_snapshot_keys
|
|
||||||
)
|
|
||||||
if not items and vitals_for_items and omit_snapshot_keys:
|
|
||||||
items = build_vital_items_from_rows(vitals_for_items, bp_for_items, omit_keys=None)
|
|
||||||
|
|
||||||
if not items:
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Keine Vitalwerte mit Zahlenwerten — Baseline-Vitals und/oder Blutdruck erfassen.",
|
|
||||||
"vital_items": [],
|
|
||||||
"vitals_measured_at": vitals_measured_at,
|
|
||||||
"blood_pressure_measured_at": bp_measured_at.isoformat() if bp_measured_at and hasattr(bp_measured_at, "isoformat") else None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for it in items:
|
|
||||||
it["bar_value"] = round(_tone_to_bar_value(it["tone"]), 1)
|
|
||||||
|
|
||||||
labels_short = [it["label_de"] for it in items]
|
|
||||||
bar_values = [it["bar_value"] for it in items]
|
|
||||||
colors = []
|
|
||||||
for it in items:
|
|
||||||
t = it["tone"]
|
|
||||||
if t == "good":
|
|
||||||
colors.append("#1D9E75")
|
|
||||||
elif t == "warn":
|
|
||||||
colors.append("#EF9F27")
|
|
||||||
elif t == "bad":
|
|
||||||
colors.append("#D85A30")
|
|
||||||
else:
|
|
||||||
colors.append("#6B7280")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {
|
|
||||||
"labels": labels_short,
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "Einschätzung (relativ)",
|
|
||||||
"data": bar_values,
|
|
||||||
"backgroundColor": colors,
|
|
||||||
"borderColor": colors,
|
|
||||||
"borderWidth": 1,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"metadata": serialize_dates(
|
|
||||||
{
|
|
||||||
"confidence": "medium",
|
|
||||||
"data_points": len(items),
|
|
||||||
"note": "Orientierende Zonen, keine Diagnose. Balken = relative Einordnung (nicht körperliche Einheit).",
|
|
||||||
"vital_items": items,
|
|
||||||
"bar_is_relative_score": True,
|
|
||||||
"vitals_measured_at": vitals_measured_at,
|
|
||||||
"blood_pressure_measured_at": bp_measured_at.isoformat()
|
|
||||||
if bp_measured_at and hasattr(bp_measured_at, "isoformat")
|
|
||||||
else (str(bp_measured_at) if bp_measured_at else None),
|
|
||||||
"disclaimer_de": "Hinweis: Nur Orientierung; bei Beschwerden oder auffälligen Werten ärztlich abklären.",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
@ -1,218 +0,0 @@
|
||||||
"""
|
|
||||||
KPIs und Kurz-Aussagen für Recovery-Dashboard (Layer 2b).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
|
|
||||||
def _verdict(status: str) -> str:
|
|
||||||
if status == "good":
|
|
||||||
return "Gut"
|
|
||||||
if status == "warn":
|
|
||||||
return "Hinweis"
|
|
||||||
return "Achtung"
|
|
||||||
|
|
||||||
|
|
||||||
def _recovery_score_status(score: Optional[int]) -> str:
|
|
||||||
if score is None:
|
|
||||||
return "warn"
|
|
||||||
if score >= 70:
|
|
||||||
return "good"
|
|
||||||
if score >= 45:
|
|
||||||
return "warn"
|
|
||||||
return "bad"
|
|
||||||
|
|
||||||
|
|
||||||
def _debt_status(hours: Optional[float]) -> str:
|
|
||||||
if hours is None:
|
|
||||||
return "warn"
|
|
||||||
if hours <= 2:
|
|
||||||
return "good"
|
|
||||||
if hours <= 8:
|
|
||||||
return "warn"
|
|
||||||
return "bad"
|
|
||||||
|
|
||||||
|
|
||||||
def build_recovery_dashboard_kpi_tiles(
|
|
||||||
recovery_score: Optional[int],
|
|
||||||
sleep_debt_hours: Optional[float],
|
|
||||||
avg_sleep_hours: Optional[float],
|
|
||||||
hrv_vs_baseline_pct: Optional[float],
|
|
||||||
rhr_vs_baseline_pct: Optional[float],
|
|
||||||
merge_heart_autonomic_tiles: bool = True,
|
|
||||||
include_avg_sleep_kpi: bool = True,
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
tiles: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
rs = _recovery_score_status(recovery_score)
|
|
||||||
tiles.append(
|
|
||||||
{
|
|
||||||
"key": "recovery_score",
|
|
||||||
"category": "Recovery-Score",
|
|
||||||
"icon": "💚",
|
|
||||||
"value": str(recovery_score) if recovery_score is not None else "—",
|
|
||||||
"sublabel": "Modell aus Schlaf + Vitaldaten",
|
|
||||||
"status": rs,
|
|
||||||
"verdict": _verdict(rs),
|
|
||||||
"hoverTop": "Gesamt-Recovery-Score (0–100)",
|
|
||||||
"hoverBody": "calculate_recovery_score_v2 — gleiche Quelle wie Platzhalter.",
|
|
||||||
"keys": ["recovery_score"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ds = _debt_status(sleep_debt_hours)
|
|
||||||
tiles.append(
|
|
||||||
{
|
|
||||||
"key": "sleep_debt",
|
|
||||||
"category": "Schlafschuld",
|
|
||||||
"icon": "⏳",
|
|
||||||
"value": f"{sleep_debt_hours:.1f} h".replace(".", ",")
|
|
||||||
if sleep_debt_hours is not None
|
|
||||||
else "—",
|
|
||||||
"sublabel": "Kumuliert (Ziel 8 h/Nacht)",
|
|
||||||
"status": ds,
|
|
||||||
"verdict": _verdict(ds),
|
|
||||||
"hoverTop": "Geschätzte Schlafschuld",
|
|
||||||
"hoverBody": "calculate_sleep_debt_hours",
|
|
||||||
"keys": ["sleep_debt_hours"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if include_avg_sleep_kpi:
|
|
||||||
tiles.append(
|
|
||||||
{
|
|
||||||
"key": "avg_sleep",
|
|
||||||
"category": "Ø Schlafdauer",
|
|
||||||
"icon": "🌙",
|
|
||||||
"value": f"{avg_sleep_hours:.1f} h".replace(".", ",") if avg_sleep_hours is not None else "—",
|
|
||||||
"sublabel": "Im gewählten Fenster",
|
|
||||||
"status": "good" if avg_sleep_hours and avg_sleep_hours >= 7 else "warn",
|
|
||||||
"verdict": "Gut" if avg_sleep_hours and avg_sleep_hours >= 7 else "Hinweis",
|
|
||||||
"hoverTop": "Durchschnittliche Schlafdauer",
|
|
||||||
"hoverBody": "get_sleep_duration_data",
|
|
||||||
"keys": ["sleep_duration_avg"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if merge_heart_autonomic_tiles and (
|
|
||||||
hrv_vs_baseline_pct is not None or rhr_vs_baseline_pct is not None
|
|
||||||
):
|
|
||||||
h_s = (
|
|
||||||
"good"
|
|
||||||
if hrv_vs_baseline_pct is not None and hrv_vs_baseline_pct >= 0
|
|
||||||
else "warn"
|
|
||||||
if hrv_vs_baseline_pct is not None
|
|
||||||
else "warn"
|
|
||||||
)
|
|
||||||
parts: List[str] = []
|
|
||||||
if hrv_vs_baseline_pct is not None:
|
|
||||||
parts.append(f"HRV {hrv_vs_baseline_pct:+.1f} %".replace(".", ","))
|
|
||||||
if rhr_vs_baseline_pct is not None:
|
|
||||||
parts.append(f"RHR {rhr_vs_baseline_pct:+.1f} %".replace(".", ","))
|
|
||||||
tiles.append(
|
|
||||||
{
|
|
||||||
"key": "herz_autonom",
|
|
||||||
"category": "Herz & autonomes System",
|
|
||||||
"icon": "❤️🩹",
|
|
||||||
"value": " · ".join(parts) if parts else "—",
|
|
||||||
"sublabel": "HRV/Ruhepuls vs. Referenz (3-Tage-Mittel vs. ältere Basis)",
|
|
||||||
"status": h_s,
|
|
||||||
"verdict": _verdict(h_s),
|
|
||||||
"hoverTop": "HRV und Ruhepuls relativ zur persönlichen Basis",
|
|
||||||
"hoverBody": "calculate_hrv_vs_baseline_pct · calculate_rhr_vs_baseline_pct",
|
|
||||||
"keys": ["hrv_vs_baseline", "rhr_vs_baseline"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
h_s = (
|
|
||||||
"good"
|
|
||||||
if hrv_vs_baseline_pct is not None and hrv_vs_baseline_pct >= 0
|
|
||||||
else "warn"
|
|
||||||
if hrv_vs_baseline_pct is not None
|
|
||||||
else "warn"
|
|
||||||
)
|
|
||||||
tiles.append(
|
|
||||||
{
|
|
||||||
"key": "hrv_baseline",
|
|
||||||
"category": "HRV vs. Basis",
|
|
||||||
"icon": "〰️",
|
|
||||||
"value": f"{hrv_vs_baseline_pct:+.1f} %".replace(".", ",")
|
|
||||||
if hrv_vs_baseline_pct is not None
|
|
||||||
else "—",
|
|
||||||
"sublabel": "Letzte 3 Tage vs. ältere Basis",
|
|
||||||
"status": h_s,
|
|
||||||
"verdict": _verdict(h_s),
|
|
||||||
"hoverTop": "Abweichung HRV vom Referenzmittel",
|
|
||||||
"hoverBody": "calculate_hrv_vs_baseline_pct",
|
|
||||||
"keys": ["hrv_vs_baseline"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
tiles.append(
|
|
||||||
{
|
|
||||||
"key": "rhr_baseline",
|
|
||||||
"category": "Ruhepuls vs. Basis",
|
|
||||||
"icon": "❤️",
|
|
||||||
"value": f"{rhr_vs_baseline_pct:+.1f} %".replace(".", ",")
|
|
||||||
if rhr_vs_baseline_pct is not None
|
|
||||||
else "—",
|
|
||||||
"sublabel": "Niedriger oft günstiger",
|
|
||||||
"status": "good",
|
|
||||||
"verdict": "Gut",
|
|
||||||
"hoverTop": "Abweichung Ruhepuls",
|
|
||||||
"hoverBody": "calculate_rhr_vs_baseline_pct",
|
|
||||||
"keys": ["rhr_vs_baseline"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return tiles
|
|
||||||
|
|
||||||
|
|
||||||
def build_recovery_progress_insights(
|
|
||||||
recovery_score: Optional[int],
|
|
||||||
sleep_debt_hours: Optional[float],
|
|
||||||
hrv_vs_baseline_pct: Optional[float],
|
|
||||||
include_autonomic_hrv_narrative: bool = False,
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""HRV-Basistext optional: steckt gebündelt im Vital-Verlauf (consolidated_paragraphs)."""
|
|
||||||
out: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
if recovery_score is not None:
|
|
||||||
tone = "good" if recovery_score >= 65 else "warn" if recovery_score >= 45 else "bad"
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"key": "ins_rec",
|
|
||||||
"tone": tone,
|
|
||||||
"title": "Gesamterholung",
|
|
||||||
"body": f"Der Recovery-Score liegt bei {recovery_score}/100. "
|
|
||||||
"Er kombiniert Schlaf- und Vital-Signale — ideal für die Einordnung von Trainingstagen.",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if sleep_debt_hours is not None:
|
|
||||||
tone = "good" if sleep_debt_hours <= 3 else "warn" if sleep_debt_hours <= 10 else "bad"
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"key": "ins_debt",
|
|
||||||
"tone": tone,
|
|
||||||
"title": "Schlaf nachholen",
|
|
||||||
"body": f"Geschätzte Schlafschuld: {sleep_debt_hours:.1f} h. "
|
|
||||||
"Hohe Schulden erhöhen Verletzungs- und Ermüdungsrisiko — Priorität Schlafhygiene.",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if include_autonomic_hrv_narrative and hrv_vs_baseline_pct is not None:
|
|
||||||
tone = "good" if hrv_vs_baseline_pct >= 0 else "warn"
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"key": "ins_hrv",
|
|
||||||
"tone": tone,
|
|
||||||
"title": "Autonomes System",
|
|
||||||
"body": f"HRV liegt {hrv_vs_baseline_pct:+.1f} % relativ zur Basis. "
|
|
||||||
"Positive Werte werden oft mit guter Regeneration assoziiert (individuell interpretieren).",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return out
|
|
||||||
|
|
@ -21,11 +21,6 @@ from datetime import datetime, timedelta, date
|
||||||
from db import get_db, get_cursor
|
from db import get_db, get_cursor
|
||||||
from data_layer.utils import calculate_confidence, safe_float, safe_int
|
from data_layer.utils import calculate_confidence, safe_float, safe_int
|
||||||
|
|
||||||
# ── Schlafschuld (KPI + Charts): eine Zielschlafdauer, bis ein Profil-Feld existiert
|
|
||||||
SLEEP_DEBT_TARGET_HOURS_DEFAULT = 7.5
|
|
||||||
SLEEP_DEBT_ROLLING_WINDOW_DAYS = 14
|
|
||||||
SLEEP_DEBT_MIN_NIGHTS_FOR_KPI = 10
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_sleep_segments(raw: Any) -> Optional[List[dict]]:
|
def _parse_sleep_segments(raw: Any) -> Optional[List[dict]]:
|
||||||
"""JSONB kann dict/list/str sein; ungültig → None."""
|
"""JSONB kann dict/list/str sein; ungültig → None."""
|
||||||
|
|
@ -749,70 +744,34 @@ def calculate_sleep_avg_duration_7d(profile_id: str) -> Optional[float]:
|
||||||
return round(avg_hours, 1)
|
return round(avg_hours, 1)
|
||||||
|
|
||||||
|
|
||||||
def _row_date_as_date(d: Any) -> Optional[date]:
|
|
||||||
if d is None:
|
|
||||||
return None
|
|
||||||
if isinstance(d, datetime):
|
|
||||||
return d.date()
|
|
||||||
if isinstance(d, date):
|
|
||||||
return d
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def sleep_debt_sum_hours_in_window(
|
|
||||||
night_rows: List[Dict[str, Any]],
|
|
||||||
window_end: date,
|
|
||||||
*,
|
|
||||||
target_hours: float = SLEEP_DEBT_TARGET_HOURS_DEFAULT,
|
|
||||||
window_days: int = SLEEP_DEBT_ROLLING_WINDOW_DAYS,
|
|
||||||
min_nights: int = SLEEP_DEBT_MIN_NIGHTS_FOR_KPI,
|
|
||||||
) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
Summe der nächtlichen Defizite (nur Unter-Ziel, kein „Überschuss-Guthaben“) im Fenster
|
|
||||||
(window_end − window_days … window_end], Kalendertage).
|
|
||||||
Gleiche Logik wie KPI calculate_sleep_debt_hours für window_end = heute.
|
|
||||||
"""
|
|
||||||
start = window_end - timedelta(days=window_days)
|
|
||||||
tmin = target_hours * 60.0
|
|
||||||
total_min = 0.0
|
|
||||||
nights = 0
|
|
||||||
for row in night_rows:
|
|
||||||
rd = _row_date_as_date(row.get("date"))
|
|
||||||
if rd is None or rd < start or rd > window_end:
|
|
||||||
continue
|
|
||||||
dm = row.get("duration_minutes")
|
|
||||||
if dm is None:
|
|
||||||
continue
|
|
||||||
nights += 1
|
|
||||||
total_min += max(0.0, tmin - float(dm))
|
|
||||||
if nights < min_nights:
|
|
||||||
return None
|
|
||||||
return round(total_min / 60.0, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_sleep_debt_hours(profile_id: str) -> Optional[float]:
|
def calculate_sleep_debt_hours(profile_id: str) -> Optional[float]:
|
||||||
"""
|
"""
|
||||||
Aufsummierte Schlafschuld (h) der letzten 14 Kalendertage bis heute —
|
Calculate accumulated sleep debt (hours) last 14 days
|
||||||
Ziel pro Nacht: SLEEP_DEBT_TARGET_HOURS_DEFAULT (aktuell nicht profilkonfigurierbar).
|
Assumes 7.5h target per night
|
||||||
"""
|
"""
|
||||||
today = datetime.now().date()
|
target_hours = 7.5
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute(
|
cur.execute("""
|
||||||
"""
|
SELECT duration_minutes
|
||||||
SELECT date, duration_minutes
|
|
||||||
FROM sleep_log
|
FROM sleep_log
|
||||||
WHERE profile_id = %s
|
WHERE profile_id = %s
|
||||||
AND date >= %s::date - INTERVAL '14 days'
|
AND date >= CURRENT_DATE - INTERVAL '14 days'
|
||||||
AND date <= %s::date
|
|
||||||
AND duration_minutes IS NOT NULL
|
AND duration_minutes IS NOT NULL
|
||||||
ORDER BY date DESC
|
ORDER BY date DESC
|
||||||
""",
|
""", (profile_id,))
|
||||||
(profile_id, today, today),
|
|
||||||
)
|
|
||||||
rows = [dict(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
return sleep_debt_sum_hours_in_window(rows, today)
|
sleep_data = [row['duration_minutes'] for row in cur.fetchall()]
|
||||||
|
|
||||||
|
if len(sleep_data) < 10: # Need at least 10 days
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Calculate cumulative debt
|
||||||
|
total_debt_min = sum(max(0, (target_hours * 60) - sleep_min) for sleep_min in sleep_data)
|
||||||
|
debt_hours = total_debt_min / 60
|
||||||
|
|
||||||
|
return round(debt_hours, 1)
|
||||||
|
|
||||||
|
|
||||||
def calculate_sleep_regularity_proxy(profile_id: str) -> Optional[float]:
|
def calculate_sleep_regularity_proxy(profile_id: str) -> Optional[float]:
|
||||||
|
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
"""
|
|
||||||
Layer 2b: Recovery/Erholung — Bundle für Verlauf unter Fitness (Issue 53).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
|
||||||
from data_layer.recovery_chart_payloads import (
|
|
||||||
build_hrv_rhr_baseline_chart_payload,
|
|
||||||
build_recovery_score_chart_payload,
|
|
||||||
build_sleep_debt_chart_payload,
|
|
||||||
build_sleep_duration_quality_chart_payload,
|
|
||||||
build_vital_signs_matrix_chart_payload,
|
|
||||||
)
|
|
||||||
from data_layer.vitals_fitness_insights import build_vitals_history_and_analytics
|
|
||||||
from data_layer.recovery_interpretation import (
|
|
||||||
build_recovery_dashboard_kpi_tiles,
|
|
||||||
build_recovery_progress_insights,
|
|
||||||
)
|
|
||||||
from data_layer.recovery_metrics import (
|
|
||||||
calculate_hrv_vs_baseline_pct,
|
|
||||||
calculate_recovery_score_v2,
|
|
||||||
calculate_rhr_vs_baseline_pct,
|
|
||||||
calculate_sleep_debt_hours,
|
|
||||||
get_sleep_duration_data,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _has_recovery_sources(profile_id: str) -> bool:
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT 1 FROM sleep_log WHERE profile_id=%s LIMIT 1", (profile_id,))
|
|
||||||
if cur.fetchone():
|
|
||||||
return True
|
|
||||||
cur.execute("SELECT 1 FROM vitals_baseline WHERE profile_id=%s LIMIT 1", (profile_id,))
|
|
||||||
return cur.fetchone() is not None
|
|
||||||
|
|
||||||
|
|
||||||
def get_recovery_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Ein Request: KPIs, Insights, Charts R1–R5 (Chart.js-kompatibel).
|
|
||||||
"""
|
|
||||||
if not _has_recovery_sources(profile_id):
|
|
||||||
return {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"has_recovery_data": False,
|
|
||||||
"message": "Noch keine Schlaf- oder Vitaldaten",
|
|
||||||
"kpi_tiles": [],
|
|
||||||
"progress_insights": [],
|
|
||||||
"charts": {},
|
|
||||||
"meta": {"layer_1": "recovery_metrics", "layer_2b": "recovery_viz"},
|
|
||||||
}
|
|
||||||
|
|
||||||
all_history = days >= 9999
|
|
||||||
eff_days = 3650 if all_history else max(7, min(int(days), 3650))
|
|
||||||
chart_days = min(90, max(7, min(eff_days, 365)))
|
|
||||||
# Vital-Matrix: längeres Fenster + Fallback im Builder, damit nicht nur „letzte 30 Tage“
|
|
||||||
vital_days = min(365, max(30, min(eff_days, 365)))
|
|
||||||
|
|
||||||
recovery_score_val = calculate_recovery_score_v2(profile_id)
|
|
||||||
sleep_debt = calculate_sleep_debt_hours(profile_id)
|
|
||||||
dur = get_sleep_duration_data(profile_id, chart_days)
|
|
||||||
avg_sleep = None
|
|
||||||
if dur.get("confidence") != "insufficient":
|
|
||||||
avg_sleep = float(dur.get("avg_duration_hours") or 0) or None
|
|
||||||
|
|
||||||
hrv_dev = calculate_hrv_vs_baseline_pct(profile_id)
|
|
||||||
rhr_dev = calculate_rhr_vs_baseline_pct(profile_id)
|
|
||||||
|
|
||||||
kpi_tiles = build_recovery_dashboard_kpi_tiles(
|
|
||||||
recovery_score_val,
|
|
||||||
float(sleep_debt) if sleep_debt is not None else None,
|
|
||||||
avg_sleep,
|
|
||||||
float(hrv_dev) if hrv_dev is not None else None,
|
|
||||||
float(rhr_dev) if rhr_dev is not None else None,
|
|
||||||
include_avg_sleep_kpi=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
insights = build_recovery_progress_insights(
|
|
||||||
recovery_score_val,
|
|
||||||
float(sleep_debt) if sleep_debt is not None else None,
|
|
||||||
float(hrv_dev) if hrv_dev is not None else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
hrv_f = float(hrv_dev) if hrv_dev is not None else None
|
|
||||||
rhr_f = float(rhr_dev) if rhr_dev is not None else None
|
|
||||||
|
|
||||||
charts = {
|
|
||||||
"recovery_score": build_recovery_score_chart_payload(profile_id, chart_days),
|
|
||||||
"hrv_rhr": build_hrv_rhr_baseline_chart_payload(profile_id, chart_days),
|
|
||||||
"sleep_duration_quality": build_sleep_duration_quality_chart_payload(profile_id, chart_days),
|
|
||||||
"sleep_debt": build_sleep_debt_chart_payload(profile_id, chart_days),
|
|
||||||
"vital_signs_matrix": build_vital_signs_matrix_chart_payload(profile_id, vital_days),
|
|
||||||
"vitals_history": build_vitals_history_and_analytics(
|
|
||||||
profile_id, vital_days, hrv_vs_baseline_pct=hrv_f, rhr_vs_baseline_pct=rhr_f
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
conf = "medium"
|
|
||||||
if recovery_score_val is None and sleep_debt is None:
|
|
||||||
conf = "low"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"confidence": conf,
|
|
||||||
"has_recovery_data": True,
|
|
||||||
"days_requested": days,
|
|
||||||
"effective_window_days": eff_days,
|
|
||||||
"chart_days_used": chart_days,
|
|
||||||
"vital_matrix_days_used": vital_days,
|
|
||||||
"kpi_tiles": kpi_tiles,
|
|
||||||
"progress_insights": insights,
|
|
||||||
"charts": charts,
|
|
||||||
"meta": {
|
|
||||||
"layer_1": "recovery_metrics",
|
|
||||||
"layer_2b": "recovery_viz",
|
|
||||||
"issue": "53-layer-2b-recovery",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -9,34 +9,11 @@ Dates are normalized to ISO strings; Decimals to float — suitable for JSON/API
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from db import get_cursor, get_db, r2d
|
from db import get_cursor, get_db, r2d
|
||||||
|
|
||||||
# Spalten des Messwerts (ohne Typ-Metadaten) für Snapshot-Payloads / Platzhalter-JSON
|
|
||||||
_REFERENCE_ENTRY_KEYS = frozenset(
|
|
||||||
{
|
|
||||||
"id",
|
|
||||||
"profile_id",
|
|
||||||
"reference_value_type_id",
|
|
||||||
"effective_date",
|
|
||||||
"value_numeric",
|
|
||||||
"value_text",
|
|
||||||
"unit",
|
|
||||||
"source",
|
|
||||||
"confidence",
|
|
||||||
"method",
|
|
||||||
"notes",
|
|
||||||
"extra",
|
|
||||||
"created_at",
|
|
||||||
"updated_at",
|
|
||||||
"type_key",
|
|
||||||
"type_label",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_reference_row(d: Optional[dict[str, Any]]) -> dict[str, Any]:
|
def normalize_reference_row(d: Optional[dict[str, Any]]) -> dict[str, Any]:
|
||||||
"""Normalize DB row dict for JSON (dates → ISO, Decimal → float)."""
|
"""Normalize DB row dict for JSON (dates → ISO, Decimal → float)."""
|
||||||
|
|
@ -200,173 +177,3 @@ def get_profile_reference_values_summary(profile_id: str) -> dict[str, Any]:
|
||||||
|
|
||||||
tiles = build_summary_tiles_from_ranked_rows(raw_rows)
|
tiles = build_summary_tiles_from_ranked_rows(raw_rows)
|
||||||
return {"tiles": tiles}
|
return {"tiles": tiles}
|
||||||
|
|
||||||
|
|
||||||
def _entry_dict_from_ranked_row(d: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Eintragsfelder inkl. type_key/type_label für KI-Kontext."""
|
|
||||||
out = {k: d[k] for k in _REFERENCE_ENTRY_KEYS if k in d}
|
|
||||||
return normalize_reference_row(out)
|
|
||||||
|
|
||||||
|
|
||||||
def get_profile_reference_values_current_snapshot(profile_id: str) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Layer 1: Alle **aktuellen** Referenzwerte (jüngster Eintrag pro aktivem Typ), Katalog-Sortierung.
|
|
||||||
|
|
||||||
Struktur: ``items`` = Liste mit ``type_key``, ``type_label``, ``value_data_type``,
|
|
||||||
``type_sort_order``, ``latest`` (vollständiger Eintrag).
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
WITH ranked AS (
|
|
||||||
SELECT
|
|
||||||
v.id,
|
|
||||||
v.profile_id,
|
|
||||||
v.reference_value_type_id,
|
|
||||||
v.effective_date,
|
|
||||||
v.value_numeric,
|
|
||||||
v.value_text,
|
|
||||||
v.unit,
|
|
||||||
v.source,
|
|
||||||
v.confidence,
|
|
||||||
v.method,
|
|
||||||
v.notes,
|
|
||||||
v.extra,
|
|
||||||
v.created_at,
|
|
||||||
v.updated_at,
|
|
||||||
rt.key AS type_key,
|
|
||||||
rt.label AS type_label,
|
|
||||||
rt.sort_order AS type_sort_order,
|
|
||||||
rt.value_data_type,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY v.reference_value_type_id
|
|
||||||
ORDER BY v.effective_date DESC, v.created_at DESC
|
|
||||||
) AS rn
|
|
||||||
FROM profile_reference_values v
|
|
||||||
JOIN reference_value_types rt ON rt.id = v.reference_value_type_id
|
|
||||||
WHERE v.profile_id = %s AND rt.active = TRUE
|
|
||||||
)
|
|
||||||
SELECT * FROM ranked WHERE rn = 1
|
|
||||||
ORDER BY type_sort_order ASC, type_key ASC
|
|
||||||
""",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
raw_rows = [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
items: list[dict[str, Any]] = []
|
|
||||||
for row in raw_rows:
|
|
||||||
row.pop("rn", None)
|
|
||||||
vdt = (row.get("value_data_type") or "decimal").strip().lower()
|
|
||||||
latest = _entry_dict_from_ranked_row(row)
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"type_key": row.get("type_key"),
|
|
||||||
"type_label": row.get("type_label"),
|
|
||||||
"value_data_type": vdt,
|
|
||||||
"type_sort_order": int(row.get("type_sort_order") or 0),
|
|
||||||
"latest": latest,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"schema": "profile_reference_values_current_v1",
|
|
||||||
"count": len(items),
|
|
||||||
"items": items,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_profile_reference_values_recent_snapshot(
|
|
||||||
profile_id: str,
|
|
||||||
*,
|
|
||||||
limit_per_type: int = 5,
|
|
||||||
date_from: Optional[date | str] = None,
|
|
||||||
date_to: Optional[date | str] = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Layer 1: Pro Referenztyp die **letzten N** Einträge (neueste zuerst), optional nach
|
|
||||||
``effective_date`` gefiltert.
|
|
||||||
|
|
||||||
``date_from`` / ``date_to``: inclusive; als ``date`` oder ISO-``YYYY-MM-DD``-String.
|
|
||||||
"""
|
|
||||||
lim = max(1, min(int(limit_per_type), 50))
|
|
||||||
|
|
||||||
df = date_from
|
|
||||||
dt = date_to
|
|
||||||
if isinstance(df, str) and df.strip():
|
|
||||||
df = date.fromisoformat(df.strip())
|
|
||||||
elif df is not None and not isinstance(df, date):
|
|
||||||
df = None
|
|
||||||
if isinstance(dt, str) and dt.strip():
|
|
||||||
dt = date.fromisoformat(dt.strip())
|
|
||||||
elif dt is not None and not isinstance(dt, date):
|
|
||||||
dt = None
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
WITH filtered AS (
|
|
||||||
SELECT
|
|
||||||
v.id,
|
|
||||||
v.profile_id,
|
|
||||||
v.reference_value_type_id,
|
|
||||||
v.effective_date,
|
|
||||||
v.value_numeric,
|
|
||||||
v.value_text,
|
|
||||||
v.unit,
|
|
||||||
v.source,
|
|
||||||
v.confidence,
|
|
||||||
v.method,
|
|
||||||
v.notes,
|
|
||||||
v.extra,
|
|
||||||
v.created_at,
|
|
||||||
v.updated_at,
|
|
||||||
rt.key AS type_key,
|
|
||||||
rt.label AS type_label,
|
|
||||||
rt.sort_order AS type_sort_order,
|
|
||||||
rt.value_data_type
|
|
||||||
FROM profile_reference_values v
|
|
||||||
JOIN reference_value_types rt ON rt.id = v.reference_value_type_id
|
|
||||||
WHERE v.profile_id = %s
|
|
||||||
AND rt.active = TRUE
|
|
||||||
AND (%s::date IS NULL OR v.effective_date >= %s::date)
|
|
||||||
AND (%s::date IS NULL OR v.effective_date <= %s::date)
|
|
||||||
),
|
|
||||||
ranked AS (
|
|
||||||
SELECT
|
|
||||||
f.*,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY f.reference_value_type_id
|
|
||||||
ORDER BY f.effective_date DESC, f.created_at DESC
|
|
||||||
) AS rn
|
|
||||||
FROM filtered f
|
|
||||||
)
|
|
||||||
SELECT * FROM ranked WHERE rn <= %s
|
|
||||||
ORDER BY type_sort_order ASC, type_key ASC, rn ASC
|
|
||||||
""",
|
|
||||||
(profile_id, df, df, dt, dt, lim),
|
|
||||||
)
|
|
||||||
raw_rows = [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
by_type: dict[str, list[dict[str, Any]]] = {}
|
|
||||||
type_order: list[str] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
|
|
||||||
for row in raw_rows:
|
|
||||||
row.pop("rn", None)
|
|
||||||
tk = row.get("type_key") or ""
|
|
||||||
if tk not in seen:
|
|
||||||
seen.add(tk)
|
|
||||||
type_order.append(tk)
|
|
||||||
entry = _entry_dict_from_ranked_row(row)
|
|
||||||
by_type.setdefault(tk, []).append(entry)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"schema": "profile_reference_values_recent_v1",
|
|
||||||
"limit_per_type": lim,
|
|
||||||
"date_from": df.isoformat() if isinstance(df, date) else None,
|
|
||||||
"date_to": dt.isoformat() if isinstance(dt, date) else None,
|
|
||||||
"ordered_type_keys": type_order,
|
|
||||||
"by_type_key": by_type,
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
"""
|
|
||||||
Orientierende Zonen-Einschätzungen für Vitalwerte (Layer 1, Issue 53).
|
|
||||||
Keine Diagnose — typische Referenzbereiche für UI/Coaching.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional, Set
|
|
||||||
|
|
||||||
from data_layer.utils import safe_float
|
|
||||||
|
|
||||||
Tone = str # good | warn | bad | neutral
|
|
||||||
|
|
||||||
|
|
||||||
def _item(
|
|
||||||
key: str,
|
|
||||||
label_de: str,
|
|
||||||
value_display: str,
|
|
||||||
tone: Tone,
|
|
||||||
zone_label_de: str,
|
|
||||||
hint_de: str,
|
|
||||||
sort_order: int,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"key": key,
|
|
||||||
"label_de": label_de,
|
|
||||||
"value_display": value_display,
|
|
||||||
"tone": tone,
|
|
||||||
"zone_label_de": zone_label_de,
|
|
||||||
"hint_de": hint_de,
|
|
||||||
"sort_order": sort_order,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def assess_resting_hr(bpm: float) -> tuple:
|
|
||||||
if bpm < 50:
|
|
||||||
return (
|
|
||||||
"warn",
|
|
||||||
"Niedrig",
|
|
||||||
"Unter 50 bpm kann bei Sportlern normal sein — sonst ärztlich klären, wenn neu oder mit Beschwerden.",
|
|
||||||
)
|
|
||||||
if bpm < 60:
|
|
||||||
return ("good", "Günstig / athletisch", "Häufig bei gut trainierten Personen im unteren Normbereich.")
|
|
||||||
if bpm <= 100:
|
|
||||||
return ("good", "Im üblichen Normbereich", "Typischer Ruhepuls bei Erwachsenen oft ca. 60–100 bpm.")
|
|
||||||
if bpm <= 110:
|
|
||||||
return ("warn", "Leicht erhöht", "Kann durch Stress, Krankheit, Koffein oder Untrainiertheit erhöht sein — Verlauf beobachten.")
|
|
||||||
return ("bad", "Deutlich erhöht", "Bei anhaltend hohem Ruhepuls medizinische Abklärung sinnvoll.")
|
|
||||||
|
|
||||||
|
|
||||||
def assess_hrv_ms(ms: float) -> tuple:
|
|
||||||
_ = ms
|
|
||||||
return (
|
|
||||||
"neutral",
|
|
||||||
"Individuell",
|
|
||||||
"HRV (ms) ist sehr personenabhängig; Aussagekraft vor allem im Vergleich zu deiner eigenen Basis/Trend.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def assess_blood_pressure(systolic: float, diastolic: float) -> tuple:
|
|
||||||
sys_, dia = systolic, diastolic
|
|
||||||
if sys_ >= 180 or dia >= 110:
|
|
||||||
return ("bad", "Sehr hoch", "Sehr hohe Werte — bei Beschwerden oder neu aufgetreten ärztlich zeitnah abklären.")
|
|
||||||
if sys_ >= 140 or dia >= 90:
|
|
||||||
return (
|
|
||||||
"bad",
|
|
||||||
"Erhöht",
|
|
||||||
"Liegt in einem Bereich, der oft als Hypertonie eingestuft wird — Bestätigung und Beratung durch ärztliche Messung.",
|
|
||||||
)
|
|
||||||
if sys_ >= 130 or dia >= 85:
|
|
||||||
return ("warn", "Hochnormal", "Oberer Normal-/hochnormaler Bereich — Lebensstil und Verlauf beachten.")
|
|
||||||
if sys_ < 120 and dia < 80:
|
|
||||||
return ("good", "Optimal", "Liegt in einem oft als günstig beschriebenen Bereich (<120/80 mmHg).")
|
|
||||||
return ("good", "Normal", "Im gängigen Zielbereich für viele Erwachsene.")
|
|
||||||
|
|
||||||
|
|
||||||
def assess_spo2(pct: float) -> tuple:
|
|
||||||
if pct >= 97:
|
|
||||||
return ("good", "Günstig", "Sauerstoffsättigung im üblichen Zielbereich.")
|
|
||||||
if pct >= 95:
|
|
||||||
return ("good", "Unauffällig", "Häufig noch als normal eingestuft; Verlauf bei Atembeschwerden beobachten.")
|
|
||||||
if pct >= 90:
|
|
||||||
return ("warn", "Leicht vermindert", "Unter 95 % kann je nach Kontext relevant sein — bei Symptomen abklären.")
|
|
||||||
return ("bad", "Niedrig", "Niedrige SpO2 — bei anhaltend unter 90 % oder Beschwerden ärztlich vorstellen.")
|
|
||||||
|
|
||||||
|
|
||||||
def assess_respiratory_rate(rpm: float) -> tuple:
|
|
||||||
if 12 <= rpm <= 20:
|
|
||||||
return ("good", "Im üblichen Bereich", "Ruheatmung oft ca. 12–20/min.")
|
|
||||||
if 10 <= rpm < 12 or 20 < rpm <= 24:
|
|
||||||
return ("warn", "Grenzbereich", "Leicht außerhalb des häufig zitierten Ruhebereichs — Kontext (Belastung, Stress) beachten.")
|
|
||||||
return ("bad", "Auffällig", "Deutlich außerhalb typischer Ruhewerte — bei Beschwerden medizinisch abklären.")
|
|
||||||
|
|
||||||
|
|
||||||
def assess_vo2_max(value: float) -> tuple:
|
|
||||||
_ = value
|
|
||||||
return (
|
|
||||||
"neutral",
|
|
||||||
"Orientativ",
|
|
||||||
"VO2max hängt stark von Alter, Geschlecht und Messmethode ab; Trends in der App sind aussagekräftiger als Einzelwerte.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_vital_items_from_rows(
|
|
||||||
vitals_row: Optional[Dict[str, Any]],
|
|
||||||
bp_row: Optional[Dict[str, Any]],
|
|
||||||
omit_keys: Optional[Set[str]] = None,
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""omit_keys: z. B. {'resting_hr','hrv'} wenn Einordnung zentral im Herz-/Autonomie-Block steht."""
|
|
||||||
skip = omit_keys or set()
|
|
||||||
items: List[Dict[str, Any]] = []
|
|
||||||
order = 0
|
|
||||||
|
|
||||||
if vitals_row:
|
|
||||||
rhr = vitals_row.get("resting_hr")
|
|
||||||
if rhr is not None and "resting_hr" not in skip:
|
|
||||||
v = safe_float(rhr)
|
|
||||||
t, z, h = assess_resting_hr(v)
|
|
||||||
items.append(_item("resting_hr", "Ruhepuls", f"{v:.0f} bpm", t, z, h, order))
|
|
||||||
order += 1
|
|
||||||
|
|
||||||
hrv = vitals_row.get("hrv")
|
|
||||||
if hrv is not None and "hrv" not in skip:
|
|
||||||
v = safe_float(hrv)
|
|
||||||
t, z, h = assess_hrv_ms(v)
|
|
||||||
items.append(_item("hrv", "HRV", f"{v:.0f} ms", t, z, h, order))
|
|
||||||
order += 1
|
|
||||||
|
|
||||||
vo2 = vitals_row.get("vo2_max")
|
|
||||||
if vo2 is not None:
|
|
||||||
v = safe_float(vo2)
|
|
||||||
t, z, h = assess_vo2_max(v)
|
|
||||||
items.append(_item("vo2_max", "VO2max", f"{v:.1f} ml/kg/min", t, z, h, order))
|
|
||||||
order += 1
|
|
||||||
|
|
||||||
spo2 = vitals_row.get("spo2")
|
|
||||||
if spo2 is not None:
|
|
||||||
v = safe_float(spo2)
|
|
||||||
t, z, h = assess_spo2(v)
|
|
||||||
items.append(_item("spo2", "SpO2", f"{v:.0f} %", t, z, h, order))
|
|
||||||
order += 1
|
|
||||||
|
|
||||||
rr = vitals_row.get("respiratory_rate")
|
|
||||||
if rr is not None:
|
|
||||||
v = safe_float(rr)
|
|
||||||
t, z, h = assess_respiratory_rate(v)
|
|
||||||
items.append(_item("respiratory_rate", "Atemfrequenz", f"{v:.0f} /min", t, z, h, order))
|
|
||||||
order += 1
|
|
||||||
|
|
||||||
if bp_row and bp_row.get("systolic") is not None and bp_row.get("diastolic") is not None:
|
|
||||||
sys_v = safe_float(bp_row["systolic"])
|
|
||||||
dia_v = safe_float(bp_row["diastolic"])
|
|
||||||
t, z, h = assess_blood_pressure(sys_v, dia_v)
|
|
||||||
items.append(_item("blood_pressure", "Blutdruck", f"{sys_v:.0f}/{dia_v:.0f} mmHg", t, z, h, order))
|
|
||||||
|
|
||||||
return items
|
|
||||||
|
|
@ -1,400 +0,0 @@
|
||||||
"""
|
|
||||||
Vitalwerte: Zeitreihen + einfache Fitness-/Recovery-Einordnung (Layer 1, Issue 53).
|
|
||||||
|
|
||||||
Keine Diagnose — deskriptive Trends, Korrelationen und Varianz-Hinweise.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import statistics
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Any, Dict, List, Optional, Sequence
|
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
|
||||||
from data_layer.utils import safe_float, serialize_dates
|
|
||||||
|
|
||||||
SERIES_CONFIG = (
|
|
||||||
("resting_hr", "Ruhepuls", "bpm", "#3B82F6"),
|
|
||||||
("hrv", "HRV", "ms", "#1D9E75"),
|
|
||||||
("vo2_max", "VO2max", "ml/kg/min", "#8B5CF6"),
|
|
||||||
("spo2", "SpO2", "%", "#0EA5E9"),
|
|
||||||
("respiratory_rate", "Atemfrequenz", "/min", "#F59E0B"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _date_to_ord(d: Any) -> float:
|
|
||||||
if hasattr(d, "toordinal"):
|
|
||||||
return float(d.toordinal())
|
|
||||||
if isinstance(d, str):
|
|
||||||
return float(datetime.fromisoformat(d[:10]).date().toordinal())
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def _linear_slope(dates: Sequence[Any], values: Sequence[float]) -> float:
|
|
||||||
if len(values) < 3 or len(dates) != len(values):
|
|
||||||
return 0.0
|
|
||||||
xs = [_date_to_ord(d) for d in dates]
|
|
||||||
ys = list(values)
|
|
||||||
n = len(xs)
|
|
||||||
mx = sum(xs) / n
|
|
||||||
my = sum(ys) / n
|
|
||||||
den = sum((x - mx) ** 2 for x in xs)
|
|
||||||
if den < 1e-9:
|
|
||||||
return 0.0
|
|
||||||
return sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / den
|
|
||||||
|
|
||||||
|
|
||||||
def _pearson(xs: Sequence[float], ys: Sequence[float]) -> Optional[float]:
|
|
||||||
n = len(xs)
|
|
||||||
if n < 5 or len(ys) != n:
|
|
||||||
return None
|
|
||||||
mx = statistics.mean(xs)
|
|
||||||
my = statistics.mean(ys)
|
|
||||||
sx = statistics.pstdev(xs) if n > 1 else 0.0
|
|
||||||
sy = statistics.pstdev(ys) if n > 1 else 0.0
|
|
||||||
if sx < 1e-9 or sy < 1e-9:
|
|
||||||
return None
|
|
||||||
cov = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / n
|
|
||||||
return cov / (sx * sy)
|
|
||||||
|
|
||||||
|
|
||||||
def _daily_training_load(cur: Any, profile_id: str, cutoff: str) -> Dict[str, float]:
|
|
||||||
"""Summe Trainingsminuten pro Kalendertag als Belastungs-Proxy."""
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date::text AS d, COALESCE(SUM(duration_min), 0)::float AS minutes
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s AND date >= %s::date AND duration_min IS NOT NULL AND duration_min > 0
|
|
||||||
GROUP BY date
|
|
||||||
ORDER BY date
|
|
||||||
""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
return {r["d"]: float(r["minutes"]) for r in rows}
|
|
||||||
|
|
||||||
|
|
||||||
def _trailing_window_means(vals: List[float], window: int = 7) -> List[float]:
|
|
||||||
"""Gleitender Mittelwert über die letzten bis zu `window` aufeinanderfolgenden Messungen (nicht Kalendertage)."""
|
|
||||||
out: List[float] = []
|
|
||||||
for i in range(len(vals)):
|
|
||||||
chunk = vals[max(0, i - window + 1) : i + 1]
|
|
||||||
out.append(round(statistics.mean(chunk), 2))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _de_num(x: float) -> str:
|
|
||||||
"""Dezimalzahl mit Komma für Fließtext."""
|
|
||||||
return f"{x:.1f}".replace(".", ",")
|
|
||||||
|
|
||||||
|
|
||||||
def _de_num_signed(x: float) -> str:
|
|
||||||
"""Wie _de_num, mit explizitem Vorzeichen (für %-Abweichungen)."""
|
|
||||||
return f"{x:+.1f}".replace(".", ",")
|
|
||||||
|
|
||||||
|
|
||||||
def _ins(
|
|
||||||
key: str,
|
|
||||||
section: str,
|
|
||||||
title_de: str,
|
|
||||||
body: str,
|
|
||||||
tone: str = "neutral",
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Ein strukturierter Hinweis für UI-Platzierung (section: heart | vo2)."""
|
|
||||||
return {"key": key, "section": section, "title_de": title_de, "body": body, "tone": tone}
|
|
||||||
|
|
||||||
|
|
||||||
def _build_section_insights(
|
|
||||||
series: Dict[str, Any],
|
|
||||||
hrv_vs_baseline_pct: Optional[float],
|
|
||||||
rhr_vs_baseline_pct: Optional[float],
|
|
||||||
r_pearson: Optional[float],
|
|
||||||
pairs_n: int,
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Gleiche Inhalte wie früher konsolidierter Fließtext, aber nach UI-Bereich getrennt.
|
|
||||||
section: heart = Herz/Kreislauf/Training-Folge; vo2 = VO2max-Verlauf.
|
|
||||||
"""
|
|
||||||
out: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
basis_bits: List[str] = []
|
|
||||||
if hrv_vs_baseline_pct is not None:
|
|
||||||
basis_bits.append(
|
|
||||||
f"HRV gegenüber älterer Referenz: {_de_num_signed(float(hrv_vs_baseline_pct))} %"
|
|
||||||
)
|
|
||||||
if rhr_vs_baseline_pct is not None:
|
|
||||||
basis_bits.append(
|
|
||||||
f"Ruhepuls relativ zur Referenz: {_de_num_signed(float(rhr_vs_baseline_pct))} %"
|
|
||||||
)
|
|
||||||
if basis_bits:
|
|
||||||
out.append(
|
|
||||||
_ins(
|
|
||||||
"heart_baseline",
|
|
||||||
"heart",
|
|
||||||
"Kurzfristiges Mittel vs. ältere Basis",
|
|
||||||
" ".join(basis_bits)
|
|
||||||
+ " — Vergleich letzter Tage zum älteren Referenzmittel; individuell interpretieren (keine Diagnose).",
|
|
||||||
"neutral",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
rhr = series.get("resting_hr")
|
|
||||||
hrv_s = series.get("hrv")
|
|
||||||
|
|
||||||
rhr_short_body = ""
|
|
||||||
r_short_tone = "neutral"
|
|
||||||
if rhr and rhr.get("points") and len(rhr["points"]) >= 10:
|
|
||||||
pts = rhr["points"]
|
|
||||||
last7 = [p["value"] for p in pts[-7:]]
|
|
||||||
before = [p["value"] for p in pts[:-7][-14:]] if len(pts) > 7 else []
|
|
||||||
if before:
|
|
||||||
m7 = statistics.mean(last7)
|
|
||||||
mb = statistics.mean(before)
|
|
||||||
diff = m7 - mb
|
|
||||||
if diff > 3:
|
|
||||||
rhr_short_body = (
|
|
||||||
f"Die letzten 7 Messungen liegen im Mittel ca. {_de_num(diff)} bpm über dem vorangehenden Fenster — "
|
|
||||||
"kann mit Belastung, Stress, Schlaf oder Infekt zusammenhängen."
|
|
||||||
)
|
|
||||||
r_short_tone = "warn"
|
|
||||||
elif diff < -3:
|
|
||||||
rhr_short_body = (
|
|
||||||
"Der Ruhepuls liegt im kurzen Vergleich unter dem vorherigen Mittel — oft mit Entlastung oder "
|
|
||||||
"besserer Regeneration vereinbar (individuell)."
|
|
||||||
)
|
|
||||||
r_short_tone = "good"
|
|
||||||
|
|
||||||
rhr_var_sentence = ""
|
|
||||||
if rhr and rhr.get("stdev") is not None and rhr.get("n", 0) >= 6:
|
|
||||||
rhr_var_sentence = (
|
|
||||||
f"Ruhepuls: Standardabweichung im Fenster ca. {_de_num(float(rhr['stdev']))} bpm — kurzfristige Schwankungen "
|
|
||||||
"sind normal; extreme Sprünge mit Kontext (Training, Schlaf) betrachten."
|
|
||||||
)
|
|
||||||
|
|
||||||
hrv_var_sentence = ""
|
|
||||||
if hrv_s and hrv_s.get("stdev") is not None and hrv_s.get("n", 0) >= 6:
|
|
||||||
hrv_var_sentence = (
|
|
||||||
f"HRV: σ im Fenster ca. {_de_num(float(hrv_s['stdev']))} ms — "
|
|
||||||
"Vergleich mit der eigenen Basis ist aussagekräftiger als Einzelwerte."
|
|
||||||
)
|
|
||||||
|
|
||||||
ma_hint = (
|
|
||||||
"Einzelwerte können stark springen; die gestrichelte Linie in den Verläufen zeigt einen gleitenden Mittelwert "
|
|
||||||
"über bis zu sieben aufeinanderfolgende Messungen (nicht Kalendertage)."
|
|
||||||
)
|
|
||||||
|
|
||||||
streuung_parts: List[str] = [ma_hint]
|
|
||||||
if rhr_var_sentence:
|
|
||||||
streuung_parts.append(rhr_var_sentence)
|
|
||||||
if hrv_var_sentence:
|
|
||||||
streuung_parts.append(hrv_var_sentence)
|
|
||||||
if rhr or hrv_s:
|
|
||||||
out.append(
|
|
||||||
_ins(
|
|
||||||
"heart_streuung_ma",
|
|
||||||
"heart",
|
|
||||||
"Streuung & gleitender Mittelwert",
|
|
||||||
" ".join(streuung_parts),
|
|
||||||
"neutral",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if rhr_short_body:
|
|
||||||
out.append(_ins("heart_rhr_kurz", "heart", "Ruhepuls: Kurzvergleich", rhr_short_body, r_short_tone))
|
|
||||||
|
|
||||||
vo2 = series.get("vo2_max")
|
|
||||||
if vo2 and vo2.get("n", 0) >= 4 and vo2.get("slope_per_day") is not None:
|
|
||||||
s = vo2["slope_per_day"]
|
|
||||||
if s > 0.002:
|
|
||||||
out.append(
|
|
||||||
_ins(
|
|
||||||
"vo2_trend_up",
|
|
||||||
"vo2",
|
|
||||||
"VO2max-Verlauf",
|
|
||||||
"Im gewählten Fenster steigt der erfasste VO2max tendenziell — häufig mit Trainingsreiz oder "
|
|
||||||
"besserer Datenlage vereinbar.",
|
|
||||||
"good",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
elif s < -0.002:
|
|
||||||
out.append(
|
|
||||||
_ins(
|
|
||||||
"vo2_trend_down",
|
|
||||||
"vo2",
|
|
||||||
"VO2max-Verlauf",
|
|
||||||
"VO2max zeigt im Fenster einen fallenden Trend — kann z. B. durch Pause, Krankheit oder Messrauschen "
|
|
||||||
"entstehen; Verlauf beobachten.",
|
|
||||||
"warn",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if r_pearson is not None and pairs_n >= 8:
|
|
||||||
if r_pearson > 0.35:
|
|
||||||
out.append(
|
|
||||||
_ins(
|
|
||||||
"heart_load_rhr",
|
|
||||||
"heart",
|
|
||||||
"Training und Folge-Ruhepuls",
|
|
||||||
(
|
|
||||||
"An Tagen nach höherer Trainingsdauer (Minuten-Summe) steigt der Ruhepuls am nächsten Morgen in deinen "
|
|
||||||
"Daten tendenziell — typisches Muster während Erholungsreaktion (kein Kausalbeweis). "
|
|
||||||
f"Korrelation (Trainingsminuten am Tag → Ruhepuls am Folgetag): r ≈ {r_pearson:.2f} bei n = {pairs_n} Paaren."
|
|
||||||
),
|
|
||||||
"warn",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
elif r_pearson < -0.25:
|
|
||||||
out.append(
|
|
||||||
_ins(
|
|
||||||
"heart_load_rhr_neg",
|
|
||||||
"heart",
|
|
||||||
"Training und Folge-Ruhepuls",
|
|
||||||
"Es zeigt sich ein leicht negatives Zusammenspiel zwischen Tages-Belastung und Folge-Ruhepuls in diesem "
|
|
||||||
f"Fenster — stark von Datenlage und Ausreißern abhängig. r ≈ {r_pearson:.2f}, n = {pairs_n} Paare.",
|
|
||||||
"neutral",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _rhr_by_date(cur: Any, profile_id: str, cutoff: str) -> Dict[str, float]:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date::text AS d, resting_hr::float AS rhr
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s AND date >= %s::date AND resting_hr IS NOT NULL
|
|
||||||
ORDER BY date
|
|
||||||
""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
return {r["d"]: float(r["rhr"]) for r in cur.fetchall()}
|
|
||||||
|
|
||||||
|
|
||||||
def build_vitals_history_and_analytics(
|
|
||||||
profile_id: str,
|
|
||||||
days: int,
|
|
||||||
hrv_vs_baseline_pct: Optional[float] = None,
|
|
||||||
rhr_vs_baseline_pct: Optional[float] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Zeitreihen pro Kennzahl (eigene Einheit / eigene Skala im Frontend) + zusammengefasste Einordnung.
|
|
||||||
|
|
||||||
Optional: Abweichung HRV/Ruhepuls zur älteren Basis — für einen Absatz statt doppelter KPI-Texte.
|
|
||||||
"""
|
|
||||||
if days < 7:
|
|
||||||
days = 7
|
|
||||||
if days > 365:
|
|
||||||
days = 365
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date, resting_hr, hrv, vo2_max, spo2, respiratory_rate
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s AND date >= %s
|
|
||||||
ORDER BY date ASC
|
|
||||||
""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
|
|
||||||
series: Dict[str, Any] = {}
|
|
||||||
for key, label_de, unit, color in SERIES_CONFIG:
|
|
||||||
pts: List[Dict[str, Any]] = []
|
|
||||||
dates: List[Any] = []
|
|
||||||
vals: List[float] = []
|
|
||||||
for r in rows:
|
|
||||||
v = r.get(key)
|
|
||||||
if v is None:
|
|
||||||
continue
|
|
||||||
fv = safe_float(v)
|
|
||||||
d = r["date"]
|
|
||||||
d_iso = d.isoformat() if hasattr(d, "isoformat") else str(d)[:10]
|
|
||||||
pts.append({"date": d_iso, "value": round(fv, 2)})
|
|
||||||
dates.append(d)
|
|
||||||
vals.append(fv)
|
|
||||||
if pts:
|
|
||||||
ma_vals = _trailing_window_means(vals, window=7)
|
|
||||||
points_ma7 = [
|
|
||||||
{"date": pts[i]["date"], "value": ma_vals[i]} for i in range(len(pts))
|
|
||||||
]
|
|
||||||
series[key] = {
|
|
||||||
"key": key,
|
|
||||||
"label_de": label_de,
|
|
||||||
"unit": unit,
|
|
||||||
"color": color,
|
|
||||||
"points": pts,
|
|
||||||
"points_ma7": points_ma7,
|
|
||||||
"n": len(pts),
|
|
||||||
"last": vals[-1] if vals else None,
|
|
||||||
"mean": round(statistics.mean(vals), 2) if len(vals) >= 1 else None,
|
|
||||||
"stdev": round(statistics.pstdev(vals), 2) if len(vals) >= 2 else None,
|
|
||||||
"slope_per_day": round(_linear_slope(dates, vals), 6) if len(vals) >= 3 else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Belastung (Activity) vs Ruhepuls am Folgetag
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
load_by_d = _daily_training_load(cur, profile_id, cutoff)
|
|
||||||
rhr_by_d = _rhr_by_date(cur, profile_id, cutoff)
|
|
||||||
|
|
||||||
pairs_load: List[float] = []
|
|
||||||
pairs_rhr: List[float] = []
|
|
||||||
for d_str, load_min in load_by_d.items():
|
|
||||||
try:
|
|
||||||
d0 = datetime.fromisoformat(d_str[:10]).date()
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
d1 = (d0 + timedelta(days=1)).isoformat()
|
|
||||||
if d1 in rhr_by_d and load_min > 0:
|
|
||||||
pairs_load.append(load_min)
|
|
||||||
pairs_rhr.append(rhr_by_d[d1])
|
|
||||||
|
|
||||||
r_pearson = _pearson(pairs_load, pairs_rhr) if len(pairs_load) >= 8 else None
|
|
||||||
pairs_n = len(pairs_load)
|
|
||||||
|
|
||||||
section_insights = _build_section_insights(
|
|
||||||
series,
|
|
||||||
hrv_vs_baseline_pct,
|
|
||||||
rhr_vs_baseline_pct,
|
|
||||||
r_pearson,
|
|
||||||
pairs_n,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not series:
|
|
||||||
return {
|
|
||||||
"chart_type": "vitals_dashboard",
|
|
||||||
"window_days": days,
|
|
||||||
"series": {},
|
|
||||||
"analytics": {
|
|
||||||
"bullets": [],
|
|
||||||
"consolidated_paragraphs": [],
|
|
||||||
"section_insights": section_insights,
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"message": "Keine Vital-Zeitreihen im Fenster",
|
|
||||||
"load_rhr_pairs_n": pairs_n,
|
|
||||||
"load_rhr_correlation": round(r_pearson, 3) if r_pearson is not None else None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "vitals_dashboard",
|
|
||||||
"window_days": days,
|
|
||||||
"series": serialize_dates(series),
|
|
||||||
"analytics": {
|
|
||||||
"bullets": [],
|
|
||||||
"consolidated_paragraphs": [],
|
|
||||||
"section_insights": section_insights,
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "medium",
|
|
||||||
"note": "Deskriptive Auswertung; keine medizinische Diagnose.",
|
|
||||||
"load_rhr_pairs_n": pairs_n,
|
|
||||||
"load_rhr_correlation": round(r_pearson, 3) if r_pearson is not None else None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -29,9 +29,7 @@ def init_pool():
|
||||||
user=os.getenv("DB_USER", "mitai"),
|
user=os.getenv("DB_USER", "mitai"),
|
||||||
password=os.getenv("DB_PASSWORD", "")
|
password=os.getenv("DB_PASSWORD", "")
|
||||||
)
|
)
|
||||||
print(
|
print(f"✓ PostgreSQL connection pool initialized ({os.getenv('DB_HOST', 'postgres')}:{os.getenv('DB_PORT', '5432')})")
|
||||||
f"[OK] PostgreSQL connection pool initialized ({os.getenv('DB_HOST', 'postgres')}:{os.getenv('DB_PORT', '5432')})"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
|
|
@ -173,7 +171,7 @@ def init_db():
|
||||||
) as table_exists
|
) as table_exists
|
||||||
""")
|
""")
|
||||||
if not cur.fetchone()['table_exists']:
|
if not cur.fetchone()['table_exists']:
|
||||||
print("[WARN] ai_prompts table doesn't exist yet - skipping pipeline prompt creation")
|
print("⚠️ ai_prompts table doesn't exist yet - skipping pipeline prompt creation")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Ensure "pipeline" master prompt exists
|
# Ensure "pipeline" master prompt exists
|
||||||
|
|
@ -191,7 +189,7 @@ def init_db():
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
print("[OK] Pipeline master prompt created")
|
print("✓ Pipeline master prompt created")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[WARN] Could not create pipeline prompt: {e}")
|
print(f"⚠️ Could not create pipeline prompt: {e}")
|
||||||
# Don't fail startup - prompt can be created manually
|
# Don't fail startup - prompt can be created manually
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,8 @@ from routers import workflow_questions # Phase 1 Workflow Engine - Question Cat
|
||||||
from routers import workflows # Phase 2 Workflow Engine - Execution
|
from routers import workflows # Phase 2 Workflow Engine - Execution
|
||||||
from routers import reference_values # Persönliche Referenzwerte (Profil)
|
from routers import reference_values # Persönliche Referenzwerte (Profil)
|
||||||
from routers import admin_reference_value_types # Admin: Referenzwert-Typen
|
from routers import admin_reference_value_types # Admin: Referenzwert-Typen
|
||||||
from routers import app_dashboard # Geschützter App-Bereich: Dashboard-Layout + Widget-Katalog
|
from routers import app_dashboard # Geschützter App-Bereich: Dashboard-Lab Layout
|
||||||
from routers import reports # Strukturierter PDF-Bericht (Profil v1)
|
|
||||||
from routers import csv_import, admin_csv_templates # Issue #21 Universal CSV Parser
|
from routers import csv_import, admin_csv_templates # Issue #21 Universal CSV Parser
|
||||||
from routers import admin_training_parameters, admin_activity_attribute_profiles # EAV session metrics
|
|
||||||
|
|
||||||
# ── App Configuration ─────────────────────────────────────────────────────────
|
# ── App Configuration ─────────────────────────────────────────────────────────
|
||||||
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
||||||
|
|
@ -68,7 +66,7 @@ async def startup_event():
|
||||||
try:
|
try:
|
||||||
init_db()
|
init_db()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[WARN] init_db() failed (non-fatal): {e}")
|
print(f"⚠️ init_db() failed (non-fatal): {e}")
|
||||||
# Don't crash on startup - can be created manually
|
# Don't crash on startup - can be created manually
|
||||||
|
|
||||||
# Apply v9c migration if needed
|
# Apply v9c migration if needed
|
||||||
|
|
@ -76,7 +74,7 @@ async def startup_event():
|
||||||
from apply_v9c_migration import apply_migration
|
from apply_v9c_migration import apply_migration
|
||||||
apply_migration()
|
apply_migration()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[WARN] v9c migration failed (non-fatal): {e}")
|
print(f"⚠️ v9c migration failed (non-fatal): {e}")
|
||||||
|
|
||||||
# ── Register Routers ──────────────────────────────────────────────────────────
|
# ── Register Routers ──────────────────────────────────────────────────────────
|
||||||
app.include_router(auth.router) # /api/auth/*
|
app.include_router(auth.router) # /api/auth/*
|
||||||
|
|
@ -128,11 +126,8 @@ app.include_router(workflows.router) # /api/workflows/* (Phase 2 Exec
|
||||||
app.include_router(reference_values.router) # /api/reference-value-types, /api/profile-reference-values
|
app.include_router(reference_values.router) # /api/reference-value-types, /api/profile-reference-values
|
||||||
app.include_router(admin_reference_value_types.router) # /api/admin/reference-value-types
|
app.include_router(admin_reference_value_types.router) # /api/admin/reference-value-types
|
||||||
app.include_router(app_dashboard.router) # /api/app/dashboard-layout
|
app.include_router(app_dashboard.router) # /api/app/dashboard-layout
|
||||||
app.include_router(reports.router) # /api/reports/* (Berichtsprofil + PDF)
|
|
||||||
app.include_router(csv_import.router) # /api/csv/* (Issue #21)
|
app.include_router(csv_import.router) # /api/csv/* (Issue #21)
|
||||||
app.include_router(admin_csv_templates.router) # /api/admin/csv-templates/* (Issue #21)
|
app.include_router(admin_csv_templates.router) # /api/admin/csv-templates/* (Issue #21)
|
||||||
app.include_router(admin_training_parameters.router) # /api/admin/training-parameters
|
|
||||||
app.include_router(admin_activity_attribute_profiles.router) # /api/admin/training-*-parameters
|
|
||||||
|
|
||||||
# ── Health Check ──────────────────────────────────────────────────────────────
|
# ── Health Check ──────────────────────────────────────────────────────────────
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
-- Migration 054: Activity session metrics (EAV) + attribute profiles
|
|
||||||
-- Date: 2026-04-14
|
|
||||||
-- Additive only: safe for production (no data deletion).
|
|
||||||
-- Agent guide: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md
|
|
||||||
|
|
||||||
-- Session interval (nullable; optional backfill later)
|
|
||||||
ALTER TABLE activity_log
|
|
||||||
ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ,
|
|
||||||
ADD COLUMN IF NOT EXISTS ended_at TIMESTAMPTZ;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_activity_log_profile_started
|
|
||||||
ON activity_log (profile_id, started_at DESC)
|
|
||||||
WHERE started_at IS NOT NULL;
|
|
||||||
|
|
||||||
COMMENT ON COLUMN activity_log.started_at IS 'Training start (wall clock, TZ-aware); optional; for dedupe/analysis';
|
|
||||||
COMMENT ON COLUMN activity_log.ended_at IS 'Training end (wall clock, TZ-aware); optional';
|
|
||||||
|
|
||||||
-- Which parameters apply to which training category (training_types.category)
|
|
||||||
CREATE TABLE IF NOT EXISTS training_category_parameter (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
training_category VARCHAR(50) NOT NULL,
|
|
||||||
training_parameter_id INT NOT NULL REFERENCES training_parameters(id) ON DELETE CASCADE,
|
|
||||||
sort_order INT NOT NULL DEFAULT 0,
|
|
||||||
required BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
ui_group VARCHAR(50),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
CONSTRAINT uq_training_category_parameter UNIQUE (training_category, training_parameter_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_tcp_category ON training_category_parameter (training_category);
|
|
||||||
|
|
||||||
COMMENT ON TABLE training_category_parameter IS 'EAV schema: parameters enabled per training category';
|
|
||||||
|
|
||||||
-- Per training type: extra parameters or overrides (NULL sort/required/ui = inherit from category row if present)
|
|
||||||
CREATE TABLE IF NOT EXISTS training_type_parameter (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
training_type_id INT NOT NULL REFERENCES training_types(id) ON DELETE CASCADE,
|
|
||||||
training_parameter_id INT NOT NULL REFERENCES training_parameters(id) ON DELETE CASCADE,
|
|
||||||
sort_order INT,
|
|
||||||
required BOOLEAN,
|
|
||||||
ui_group VARCHAR(50),
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
CONSTRAINT uq_training_type_parameter UNIQUE (training_type_id, training_parameter_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ttp_type ON training_type_parameter (training_type_id);
|
|
||||||
|
|
||||||
COMMENT ON TABLE training_type_parameter IS 'EAV schema: add/override parameters for a concrete training_types row';
|
|
||||||
|
|
||||||
-- EAV values per activity session
|
|
||||||
CREATE TABLE IF NOT EXISTS activity_session_metrics (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
activity_log_id UUID NOT NULL REFERENCES activity_log(id) ON DELETE CASCADE,
|
|
||||||
training_parameter_id INT NOT NULL REFERENCES training_parameters(id) ON DELETE RESTRICT,
|
|
||||||
value_num DOUBLE PRECISION,
|
|
||||||
value_int BIGINT,
|
|
||||||
value_text TEXT,
|
|
||||||
value_bool BOOLEAN,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
CONSTRAINT uq_activity_session_metric UNIQUE (activity_log_id, training_parameter_id),
|
|
||||||
CONSTRAINT chk_activity_session_metric_one_value CHECK (
|
|
||||||
(
|
|
||||||
(value_num IS NOT NULL)::int
|
|
||||||
+ (value_int IS NOT NULL)::int
|
|
||||||
+ (value_text IS NOT NULL)::int
|
|
||||||
+ (value_bool IS NOT NULL)::int
|
|
||||||
) = 1
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_asm_activity ON activity_session_metrics (activity_log_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_asm_parameter ON activity_session_metrics (training_parameter_id);
|
|
||||||
|
|
||||||
COMMENT ON TABLE activity_session_metrics IS 'EAV: one row per (session, training_parameter); exactly one value_* set';
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
RAISE NOTICE 'Migration 054: activity_session_metrics EAV + attribute profile tables + activity_log timestamps';
|
|
||||||
END $$;
|
|
||||||
|
|
@ -1,213 +0,0 @@
|
||||||
-- Migration 055: Seed training_category_parameter (all categories × parameters with activity_log source_field)
|
|
||||||
-- + idempotent backfill activity_log → activity_session_metrics (EAV)
|
|
||||||
-- Date: 2026-04-15
|
|
||||||
-- SAFE: INSERT … ON CONFLICT DO NOTHING only; no DELETE/TRUNCATE on activity_log.
|
|
||||||
-- Agent guide: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md
|
|
||||||
|
|
||||||
--1) Jede in training_types vorkommende Kategorie erhält alle aktiven Parameter mit source_field (Spalte in activity_log).
|
|
||||||
INSERT INTO training_category_parameter (
|
|
||||||
training_category,
|
|
||||||
training_parameter_id,
|
|
||||||
sort_order,
|
|
||||||
required,
|
|
||||||
ui_group
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
tc.training_category,
|
|
||||||
tp.id,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY tc.training_category
|
|
||||||
ORDER BY tp.category, tp.id
|
|
||||||
),
|
|
||||||
false,
|
|
||||||
NULL
|
|
||||||
FROM (
|
|
||||||
SELECT DISTINCT category AS training_category
|
|
||||||
FROM training_types
|
|
||||||
WHERE category IS NOT NULL AND trim(category) <> ''
|
|
||||||
) tc
|
|
||||||
CROSS JOIN training_parameters tp
|
|
||||||
WHERE tp.is_active = true
|
|
||||||
AND tp.source_field IS NOT NULL
|
|
||||||
AND trim(tp.source_field) <> ''
|
|
||||||
ON CONFLICT (training_category, training_parameter_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- 2) Backfill: activity_log-Spalten → EAV (nur wenn noch keine Zeile existiert)
|
|
||||||
|
|
||||||
-- duration_min → integer
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
a.id,
|
|
||||||
tp.id,
|
|
||||||
NULL,
|
|
||||||
ROUND(a.duration_min::numeric)::bigint,
|
|
||||||
NULL,
|
|
||||||
NULL,
|
|
||||||
NOW()
|
|
||||||
FROM activity_log a
|
|
||||||
JOIN training_parameters tp ON tp.key = 'duration_min' AND tp.is_active = true
|
|
||||||
WHERE a.duration_min IS NOT NULL
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- distance_km → float
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
)
|
|
||||||
SELECT a.id, tp.id, a.distance_km::double precision, NULL, NULL, NULL, NOW()
|
|
||||||
FROM activity_log a
|
|
||||||
JOIN training_parameters tp ON tp.key = 'distance_km' AND tp.is_active = true
|
|
||||||
WHERE a.distance_km IS NOT NULL
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- kcal_active
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
)
|
|
||||||
SELECT a.id, tp.id, NULL, ROUND(a.kcal_active::numeric)::bigint, NULL, NULL, NOW()
|
|
||||||
FROM activity_log a
|
|
||||||
JOIN training_parameters tp ON tp.key = 'kcal_active' AND tp.is_active = true
|
|
||||||
WHERE a.kcal_active IS NOT NULL
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
)
|
|
||||||
SELECT a.id, tp.id, NULL, ROUND(a.kcal_resting::numeric)::bigint, NULL, NULL, NOW()
|
|
||||||
FROM activity_log a
|
|
||||||
JOIN training_parameters tp ON tp.key = 'kcal_resting' AND tp.is_active = true
|
|
||||||
WHERE a.kcal_resting IS NOT NULL
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- hr_avg / hr_max → keys avg_hr, max_hr
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
)
|
|
||||||
SELECT a.id, tp.id, NULL, ROUND(a.hr_avg::numeric)::bigint, NULL, NULL, NOW()
|
|
||||||
FROM activity_log a
|
|
||||||
JOIN training_parameters tp ON tp.key = 'avg_hr' AND tp.is_active = true
|
|
||||||
WHERE a.hr_avg IS NOT NULL
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
)
|
|
||||||
SELECT a.id, tp.id, NULL, ROUND(a.hr_max::numeric)::bigint, NULL, NULL, NOW()
|
|
||||||
FROM activity_log a
|
|
||||||
JOIN training_parameters tp ON tp.key = 'max_hr' AND tp.is_active = true
|
|
||||||
WHERE a.hr_max IS NOT NULL
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- rpe
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
)
|
|
||||||
SELECT a.id, tp.id, NULL, a.rpe::bigint, NULL, NULL, NOW()
|
|
||||||
FROM activity_log a
|
|
||||||
JOIN training_parameters tp ON tp.key = 'rpe' AND tp.is_active = true
|
|
||||||
WHERE a.rpe IS NOT NULL
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
|
||||||
|
|
||||||
-- min_hr (Spalte hr_min nach Migration 014)
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
)
|
|
||||||
SELECT a.id, tp.id, NULL, a.hr_min, NULL, NULL, NOW()
|
|
||||||
FROM activity_log a
|
|
||||||
JOIN training_parameters tp ON tp.key = 'min_hr' AND tp.is_active = true
|
|
||||||
WHERE a.hr_min IS NOT NULL
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
)
|
|
||||||
SELECT a.id, tp.id, a.pace_min_per_km::double precision, NULL, NULL, NULL, NOW()
|
|
||||||
FROM activity_log a
|
|
||||||
JOIN training_parameters tp ON tp.key = 'pace_min_per_km' AND tp.is_active = true
|
|
||||||
WHERE a.pace_min_per_km IS NOT NULL
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
)
|
|
||||||
SELECT a.id, tp.id, NULL, a.cadence, NULL, NULL, NOW()
|
|
||||||
FROM activity_log a
|
|
||||||
JOIN training_parameters tp ON tp.key = 'cadence' AND tp.is_active = true
|
|
||||||
WHERE a.cadence IS NOT NULL
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
)
|
|
||||||
SELECT a.id, tp.id, NULL, a.avg_power, NULL, NULL, NOW()
|
|
||||||
FROM activity_log a
|
|
||||||
JOIN training_parameters tp ON tp.key = 'avg_power' AND tp.is_active = true
|
|
||||||
WHERE a.avg_power IS NOT NULL
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
)
|
|
||||||
SELECT a.id, tp.id, NULL, a.elevation_gain, NULL, NULL, NOW()
|
|
||||||
FROM activity_log a
|
|
||||||
JOIN training_parameters tp ON tp.key = 'elevation_gain' AND tp.is_active = true
|
|
||||||
WHERE a.elevation_gain IS NOT NULL
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
)
|
|
||||||
SELECT a.id, tp.id, a.temperature_celsius::double precision, NULL, NULL, NULL, NOW()
|
|
||||||
FROM activity_log a
|
|
||||||
JOIN training_parameters tp ON tp.key = 'temperature_celsius' AND tp.is_active = true
|
|
||||||
WHERE a.temperature_celsius IS NOT NULL
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
)
|
|
||||||
SELECT a.id, tp.id, NULL, a.humidity_percent, NULL, NULL, NOW()
|
|
||||||
FROM activity_log a
|
|
||||||
JOIN training_parameters tp ON tp.key = 'humidity_percent' AND tp.is_active = true
|
|
||||||
WHERE a.humidity_percent IS NOT NULL
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
)
|
|
||||||
SELECT a.id, tp.id, a.avg_hr_percent::double precision, NULL, NULL, NULL, NOW()
|
|
||||||
FROM activity_log a
|
|
||||||
JOIN training_parameters tp ON tp.key = 'avg_hr_percent' AND tp.is_active = true
|
|
||||||
WHERE a.avg_hr_percent IS NOT NULL
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
)
|
|
||||||
SELECT a.id, tp.id, a.kcal_per_km::double precision, NULL, NULL, NULL, NOW()
|
|
||||||
FROM activity_log a
|
|
||||||
JOIN training_parameters tp ON tp.key = 'kcal_per_km' AND tp.is_active = true
|
|
||||||
WHERE a.kcal_per_km IS NOT NULL
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
RAISE NOTICE 'Migration 055: category parameter seed + EAV backfill from activity_log (no row deletes)';
|
|
||||||
END $$;
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
-- Migration 056: kcal_per_km Trigger — manuelles Leeren bei UPDATE erlauben
|
|
||||||
-- Problem: calculate_avg_hr_percent (014) setzte bei jedem UPDATE kcal_per_km aus
|
|
||||||
-- kcal_active/distance_km, sobald beide gesetzt waren — ein bewusst geleertes Feld
|
|
||||||
-- erschien sofort wieder.
|
|
||||||
-- Lösung: automatische Ableitung nur noch bei INSERT (wenn kcal_per_km noch NULL ist).
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION calculate_avg_hr_percent()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
DECLARE
|
|
||||||
user_max_hr INTEGER;
|
|
||||||
BEGIN
|
|
||||||
SELECT hf_max INTO user_max_hr
|
|
||||||
FROM profiles
|
|
||||||
WHERE id = NEW.profile_id;
|
|
||||||
|
|
||||||
IF NEW.hr_avg IS NOT NULL AND user_max_hr IS NOT NULL AND user_max_hr > 0 THEN
|
|
||||||
NEW.avg_hr_percent := (NEW.hr_avg::float / user_max_hr::float) * 100;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF TG_OP = 'INSERT' THEN
|
|
||||||
IF NEW.kcal_active IS NOT NULL AND NEW.distance_km IS NOT NULL AND NEW.distance_km > 0 THEN
|
|
||||||
IF NEW.kcal_per_km IS NULL THEN
|
|
||||||
NEW.kcal_per_km := NEW.kcal_active::float / NEW.distance_km;
|
|
||||||
END IF;
|
|
||||||
END IF;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
RAISE NOTICE '✓ Migration 056: kcal_per_km nur noch bei INSERT auto-abgeleitet';
|
|
||||||
END $$;
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
-- Migration 057: Kanon EAV-primär für erweiterte Trainingsmetriken
|
|
||||||
-- Date: 2026-04-15
|
|
||||||
-- activity_log-Spalten bleiben erhalten (Lesefallback / API); training_parameters.source_field
|
|
||||||
-- wird für diese Keys entfernt. Idempotenter EAV-Backfill aus Spalten (wie 055), dann source_field NULL.
|
|
||||||
-- Siehe: backend/data_layer/activity_data_canon.py
|
|
||||||
|
|
||||||
-- min_hr (Spalte hr_min)
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
)
|
|
||||||
SELECT a.id, tp.id, NULL, a.hr_min, NULL, NULL, NOW()
|
|
||||||
FROM activity_log a
|
|
||||||
JOIN training_parameters tp ON tp.key = 'min_hr' AND tp.is_active = true
|
|
||||||
WHERE a.hr_min IS NOT NULL
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
)
|
|
||||||
SELECT a.id, tp.id, a.pace_min_per_km::double precision, NULL, NULL, NULL, NOW()
|
|
||||||
FROM activity_log a
|
|
||||||
JOIN training_parameters tp ON tp.key = 'pace_min_per_km' AND tp.is_active = true
|
|
||||||
WHERE a.pace_min_per_km IS NOT NULL
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
)
|
|
||||||
SELECT a.id, tp.id, NULL, a.cadence, NULL, NULL, NOW()
|
|
||||||
FROM activity_log a
|
|
||||||
JOIN training_parameters tp ON tp.key = 'cadence' AND tp.is_active = true
|
|
||||||
WHERE a.cadence IS NOT NULL
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
)
|
|
||||||
SELECT a.id, tp.id, NULL, a.avg_power, NULL, NULL, NOW()
|
|
||||||
FROM activity_log a
|
|
||||||
JOIN training_parameters tp ON tp.key = 'avg_power' AND tp.is_active = true
|
|
||||||
WHERE a.avg_power IS NOT NULL
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
)
|
|
||||||
SELECT a.id, tp.id, NULL, a.elevation_gain, NULL, NULL, NOW()
|
|
||||||
FROM activity_log a
|
|
||||||
JOIN training_parameters tp ON tp.key = 'elevation_gain' AND tp.is_active = true
|
|
||||||
WHERE a.elevation_gain IS NOT NULL
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
)
|
|
||||||
SELECT a.id, tp.id, a.temperature_celsius::double precision, NULL, NULL, NULL, NOW()
|
|
||||||
FROM activity_log a
|
|
||||||
JOIN training_parameters tp ON tp.key = 'temperature_celsius' AND tp.is_active = true
|
|
||||||
WHERE a.temperature_celsius IS NOT NULL
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
)
|
|
||||||
SELECT a.id, tp.id, NULL, a.humidity_percent, NULL, NULL, NOW()
|
|
||||||
FROM activity_log a
|
|
||||||
JOIN training_parameters tp ON tp.key = 'humidity_percent' AND tp.is_active = true
|
|
||||||
WHERE a.humidity_percent IS NOT NULL
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
)
|
|
||||||
SELECT a.id, tp.id, a.avg_hr_percent::double precision, NULL, NULL, NULL, NOW()
|
|
||||||
FROM activity_log a
|
|
||||||
JOIN training_parameters tp ON tp.key = 'avg_hr_percent' AND tp.is_active = true
|
|
||||||
WHERE a.avg_hr_percent IS NOT NULL
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
)
|
|
||||||
SELECT a.id, tp.id, a.kcal_per_km::double precision, NULL, NULL, NULL, NOW()
|
|
||||||
FROM activity_log a
|
|
||||||
JOIN training_parameters tp ON tp.key = 'kcal_per_km' AND tp.is_active = true
|
|
||||||
WHERE a.kcal_per_km IS NOT NULL
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
|
|
||||||
|
|
||||||
UPDATE training_parameters
|
|
||||||
SET source_field = NULL
|
|
||||||
WHERE key IN (
|
|
||||||
'min_hr',
|
|
||||||
'pace_min_per_km',
|
|
||||||
'cadence',
|
|
||||||
'avg_power',
|
|
||||||
'elevation_gain',
|
|
||||||
'temperature_celsius',
|
|
||||||
'humidity_percent',
|
|
||||||
'avg_hr_percent',
|
|
||||||
'kcal_per_km'
|
|
||||||
);
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
RAISE NOTICE 'Migration 057: EAV-primary canon — backfill + source_field cleared for extended metrics';
|
|
||||||
END $$;
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
-- EXIF-Aufnahmezeit (optional); Sortierung / Anzeige
|
|
||||||
ALTER TABLE photos ADD COLUMN IF NOT EXISTS taken_at TIMESTAMPTZ;
|
|
||||||
|
|
||||||
COMMENT ON COLUMN photos.taken_at IS 'Aufnahmezeit aus EXIF (DateTimeOriginal o.ä.), Zeitzone siehe PHOTO_EXIF_TIMEZONE';
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
-- Zusätzlicher Umfang: Oberarm entspannt (c_arm = historisch / Oberarm kontrahiert)
|
|
||||||
ALTER TABLE circumference_log ADD COLUMN IF NOT EXISTS c_arm_relaxed NUMERIC(5,2);
|
|
||||||
|
|
||||||
COMMENT ON COLUMN circumference_log.c_arm IS 'Oberarmumfang kontrahiert/angespannt (bestehende Daten)';
|
|
||||||
COMMENT ON COLUMN circumference_log.c_arm_relaxed IS 'Oberarmumfang entspannt';
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
-- Migration 060: Strukturierter Bericht (Profil JSON pro Nutzerprofil, unabhängig vom Dashboard-Layout)
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS report_profiles (
|
|
||||||
profile_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_report_profiles_updated ON report_profiles(updated_at);
|
|
||||||
|
|
||||||
COMMENT ON TABLE report_profiles IS 'Konfigurierbarer PDF-Bericht v1 (Blöcke: section, chart, ai_insight); Rendering serverseitig aus Datenlayer';
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
-- Migration 061: Mehrere benannte PDF-Berichte pro Nutzerprofil; Daten von report_profiles übernehmen.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS report_definitions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
||||||
name TEXT NOT NULL DEFAULT 'Bericht',
|
|
||||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
sort_order INT NOT NULL DEFAULT 0,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_report_definitions_profile_sort
|
|
||||||
ON report_definitions (profile_id, sort_order);
|
|
||||||
|
|
||||||
COMMENT ON TABLE report_definitions IS 'Mehrere strukturierte PDF-Berichte pro Profil (payload = ReportProfilePayload v1)';
|
|
||||||
|
|
||||||
INSERT INTO report_definitions (profile_id, name, payload, sort_order)
|
|
||||||
SELECT rp.profile_id, 'Standard', rp.payload, 0
|
|
||||||
FROM report_profiles rp
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1 FROM report_definitions rd WHERE rd.profile_id = rp.profile_id
|
|
||||||
);
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS report_profiles;
|
|
||||||
|
|
@ -3,9 +3,8 @@ Pydantic Models for Mitai Jinkendo API
|
||||||
|
|
||||||
Data validation schemas for request/response bodies.
|
Data validation schemas for request/response bodies.
|
||||||
"""
|
"""
|
||||||
from typing import Any, List, Optional
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
# ── Profile Models ────────────────────────────────────────────────────────────
|
# ── Profile Models ────────────────────────────────────────────────────────────
|
||||||
|
|
@ -50,7 +49,6 @@ class CircumferenceEntry(BaseModel):
|
||||||
c_thigh: Optional[float] = None
|
c_thigh: Optional[float] = None
|
||||||
c_calf: Optional[float] = None
|
c_calf: Optional[float] = None
|
||||||
c_arm: Optional[float] = None
|
c_arm: Optional[float] = None
|
||||||
c_arm_relaxed: Optional[float] = None
|
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
photo_id: Optional[str] = None
|
photo_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
@ -84,17 +82,8 @@ class ActivityEntry(BaseModel):
|
||||||
kcal_resting: Optional[float] = None
|
kcal_resting: Optional[float] = None
|
||||||
hr_avg: Optional[float] = None
|
hr_avg: Optional[float] = None
|
||||||
hr_max: Optional[float] = None
|
hr_max: Optional[float] = None
|
||||||
hr_min: Optional[int] = None # DB-Spalte hr_min (Parameter min_hr)
|
|
||||||
distance_km: Optional[float] = None
|
distance_km: Optional[float] = None
|
||||||
rpe: Optional[int] = None
|
rpe: Optional[int] = None
|
||||||
pace_min_per_km: Optional[float] = None
|
|
||||||
cadence: Optional[int] = None
|
|
||||||
avg_power: Optional[int] = None
|
|
||||||
elevation_gain: Optional[int] = None
|
|
||||||
temperature_celsius: Optional[float] = None
|
|
||||||
humidity_percent: Optional[int] = None
|
|
||||||
avg_hr_percent: Optional[float] = None
|
|
||||||
kcal_per_km: Optional[float] = None
|
|
||||||
source: Optional[str] = 'manual'
|
source: Optional[str] = 'manual'
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
training_type_id: Optional[int] = None # v9d: Training type categorization
|
training_type_id: Optional[int] = None # v9d: Training type categorization
|
||||||
|
|
@ -102,17 +91,6 @@ class ActivityEntry(BaseModel):
|
||||||
training_subcategory: Optional[str] = None # v9d: Denormalized subcategory
|
training_subcategory: Optional[str] = None # v9d: Denormalized subcategory
|
||||||
|
|
||||||
|
|
||||||
class ActivityMetricValue(BaseModel):
|
|
||||||
parameter_key: str
|
|
||||||
value: Any
|
|
||||||
|
|
||||||
|
|
||||||
class ActivityMetricsReplace(BaseModel):
|
|
||||||
"""Voller Ersatz der EAV-Metriken für eine Session (siehe Agent-Guide)."""
|
|
||||||
|
|
||||||
metrics: List[ActivityMetricValue] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class NutritionDay(BaseModel):
|
class NutritionDay(BaseModel):
|
||||||
date: str
|
date: str
|
||||||
kcal: Optional[float] = None
|
kcal: Optional[float] = None
|
||||||
|
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
"""
|
|
||||||
EXIF-Aufnahmedatum/-zeit aus Bildbytes (JPEG, PNG mit EXIF, …).
|
|
||||||
|
|
||||||
EXIF enthält keine Zeitzone; wir interpretieren die Wandzeit in PHOTO_EXIF_TIMEZONE
|
|
||||||
(Standard Europe/Berlin) und speichern als TIMESTAMPTZ (UTC in PostgreSQL).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from io import BytesIO
|
|
||||||
from typing import Optional
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
EXIF_DATETIME_FMT = "%Y:%m:%d %H:%M:%S"
|
|
||||||
_EXIF_IFD = 0x8769
|
|
||||||
_EXIF_DATETIME_TAGS = (36867, 36868) # DateTimeOriginal, DateTimeDigitized
|
|
||||||
_TAG_DATETIME_MAIN = 306
|
|
||||||
|
|
||||||
|
|
||||||
def extract_taken_at_from_image_bytes(raw: bytes) -> Optional[datetime]:
|
|
||||||
"""
|
|
||||||
Liest DateTimeOriginal (o. ä.) aus EXIF und gibt ein timezone-aware datetime zurück,
|
|
||||||
oder None wenn nicht ermittelbar.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
img = Image.open(BytesIO(raw))
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
naive = _extract_exif_naive_datetime(img)
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
img.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if naive is None:
|
|
||||||
return None
|
|
||||||
tz_name = os.getenv("PHOTO_EXIF_TIMEZONE", "Europe/Berlin")
|
|
||||||
try:
|
|
||||||
tz = ZoneInfo(tz_name)
|
|
||||||
except Exception:
|
|
||||||
tz = ZoneInfo("Europe/Berlin")
|
|
||||||
return naive.replace(tzinfo=tz)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_exif_naive_datetime(img: Image.Image) -> Optional[datetime]:
|
|
||||||
exif = img.getexif()
|
|
||||||
if not exif:
|
|
||||||
return None
|
|
||||||
strings: list[str] = []
|
|
||||||
try:
|
|
||||||
exif_ifd = exif.get_ifd(_EXIF_IFD)
|
|
||||||
except Exception:
|
|
||||||
exif_ifd = None
|
|
||||||
if exif_ifd:
|
|
||||||
for tag in _EXIF_DATETIME_TAGS:
|
|
||||||
v = exif_ifd.get(tag)
|
|
||||||
if isinstance(v, str) and v.strip():
|
|
||||||
strings.append(v)
|
|
||||||
v = exif.get(_TAG_DATETIME_MAIN)
|
|
||||||
if isinstance(v, str) and v.strip():
|
|
||||||
strings.append(v)
|
|
||||||
for s in strings:
|
|
||||||
dt = _parse_exif_datetime_str(s)
|
|
||||||
if dt:
|
|
||||||
return dt
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_exif_datetime_str(s: str) -> Optional[datetime]:
|
|
||||||
s = (s or "").strip()
|
|
||||||
if not s:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return datetime.strptime(s, EXIF_DATETIME_FMT)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def taken_at_from_file_last_modified_ms(ms_raw: Optional[str]) -> Optional[datetime]:
|
|
||||||
"""
|
|
||||||
Browser sendet File.lastModified (ms seit UTC-Epoch), echte Dateirevision auf der Platte.
|
|
||||||
Wird als echter Zeitpunkt interpretiert und nach PHOTO_EXIF_TIMEZONE für Anzeige gelegt
|
|
||||||
(konsistent zu EXIF-Wandzeit).
|
|
||||||
"""
|
|
||||||
if not ms_raw or not str(ms_raw).strip():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
ms = int(str(ms_raw).strip())
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
if ms <= 0:
|
|
||||||
return None
|
|
||||||
instant_utc = datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc)
|
|
||||||
tz_name = os.getenv("PHOTO_EXIF_TIMEZONE", "Europe/Berlin")
|
|
||||||
try:
|
|
||||||
tz = ZoneInfo(tz_name)
|
|
||||||
except Exception:
|
|
||||||
tz = ZoneInfo("Europe/Berlin")
|
|
||||||
return instant_utc.astimezone(tz)
|
|
||||||
|
|
@ -19,7 +19,6 @@ from . import profil_zeitraum
|
||||||
from . import phase_0b_meta_scores
|
from . import phase_0b_meta_scores
|
||||||
from . import phase_0b_ziele_fokus
|
from . import phase_0b_ziele_fokus
|
||||||
from . import korrelationen
|
from . import korrelationen
|
||||||
from . import profile_reference_values
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'nutrition_part_a',
|
'nutrition_part_a',
|
||||||
|
|
@ -36,5 +35,4 @@ __all__ = [
|
||||||
'phase_0b_meta_scores',
|
'phase_0b_meta_scores',
|
||||||
'phase_0b_ziele_fokus',
|
'phase_0b_ziele_fokus',
|
||||||
'korrelationen',
|
'korrelationen',
|
||||||
'profile_reference_values',
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""
|
"""
|
||||||
Activity Metrics Placeholder Registrations
|
Activity Metrics Placeholder Registrations
|
||||||
|
|
||||||
Registers 17 Aktivitäts-Platzhalter hier; 3 weitere Keys in activity_session_insights.py (**20 gesamt** in PLACEHOLDER_MAP).
|
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.
|
Evidence-based metadata with clear tagging of source.
|
||||||
|
|
||||||
|
|
@ -43,7 +43,7 @@ def register_activity_group_1():
|
||||||
category="Aktivität",
|
category="Aktivität",
|
||||||
description="Zusammenfassung der letzten 14 Tage Aktivität",
|
description="Zusammenfassung der letzten 14 Tage Aktivität",
|
||||||
resolver_module="backend/placeholder_resolver.py",
|
resolver_module="backend/placeholder_resolver.py",
|
||||||
resolver_function="get_activity_summary",
|
resolver_function="_format_activity_summary",
|
||||||
data_layer_module=None,
|
data_layer_module=None,
|
||||||
data_layer_function=None,
|
data_layer_function=None,
|
||||||
source_tables=["activity_log", "training_types"],
|
source_tables=["activity_log", "training_types"],
|
||||||
|
|
@ -127,23 +127,16 @@ def register_activity_group_1():
|
||||||
activity_detail_metadata = PlaceholderMetadata(
|
activity_detail_metadata = PlaceholderMetadata(
|
||||||
key="activity_detail",
|
key="activity_detail",
|
||||||
category="Aktivität",
|
category="Aktivität",
|
||||||
description=(
|
description="Detaillierte Liste der letzten 14 Tage Aktivität",
|
||||||
"Letzte 14 Tage: pro Session Kopfzeile (activity_log) plus gemergte Profil-Metriken "
|
|
||||||
"(dynamische Keys je training_category / training_type_id)"
|
|
||||||
),
|
|
||||||
resolver_module="backend/placeholder_resolver.py",
|
resolver_module="backend/placeholder_resolver.py",
|
||||||
resolver_function="get_activity_detail",
|
resolver_function="_format_activity_detail",
|
||||||
data_layer_module="backend/data_layer/activity_metrics.py",
|
data_layer_module=None,
|
||||||
data_layer_function="get_activity_detail_data",
|
data_layer_function=None,
|
||||||
source_tables=["activity_log", "activity_session_metrics", "training_parameters"],
|
source_tables=["activity_log", "training_types"],
|
||||||
semantic_contract=(
|
semantic_contract=(
|
||||||
"Layer 1: get_activity_detail_data lädt Sessions, enrich_sessions_with_metrics fügt "
|
"Liefert eine strukturierte Liste aller Trainingseinheiten der letzten 14 Tage. "
|
||||||
"session_metrics hinzu — effektive Liste aus merge_column_backed_and_eav_metrics: nur "
|
"Jede Einheit: Datum, Trainingstyp, Dauer (Minuten), optional Notizen. "
|
||||||
"Parameter aus dem Attributschema (tcp/ttp), sortiert nach key. "
|
"Sortiert chronologisch absteigend (neueste zuerst)."
|
||||||
"Leseregel Kanon: activity_log-Spalte (source_field, Registry-Feld, Legacy-Spalte für "
|
|
||||||
"EAV-primäre Keys) schlägt EAV, wenn beide Werte liefern. "
|
|
||||||
"Layer 2a: Zeilen mit „| EAV: key=value; …“ nur für nicht-leere session_metrics; "
|
|
||||||
"die Menge der Keys ist admin-/profilabhängig, kein festes Prompt-Schema."
|
|
||||||
),
|
),
|
||||||
business_meaning=(
|
business_meaning=(
|
||||||
"Detaillierte Trainingshistorie für KI-Prompts, die Muster, Progressionen "
|
"Detaillierte Trainingshistorie für KI-Prompts, die Muster, Progressionen "
|
||||||
|
|
@ -154,9 +147,7 @@ def register_activity_group_1():
|
||||||
time_window="14d",
|
time_window="14d",
|
||||||
output_type=OutputType.LIST,
|
output_type=OutputType.LIST,
|
||||||
placeholder_type=PlaceholderType.RAW_DATA,
|
placeholder_type=PlaceholderType.RAW_DATA,
|
||||||
format_hint=(
|
format_hint="Liste von Strings, eine Zeile pro Einheit: 'YYYY-MM-DD: Typ (Dauer min)'",
|
||||||
"Pro Zeile: Datum, Typ, Dauer, kcal, optional HF, optional „| EAV: …“ aus Session-Metriken"
|
|
||||||
),
|
|
||||||
example_output=(
|
example_output=(
|
||||||
"2026-03-28: Krafttraining (45 min)\\n"
|
"2026-03-28: Krafttraining (45 min)\\n"
|
||||||
"2026-03-27: Laufen (30 min)\\n"
|
"2026-03-27: Laufen (30 min)\\n"
|
||||||
|
|
@ -172,17 +163,19 @@ def register_activity_group_1():
|
||||||
legacy_display="Keine Aktivitätsdaten"
|
legacy_display="Keine Aktivitätsdaten"
|
||||||
),
|
),
|
||||||
known_limitations=(
|
known_limitations=(
|
||||||
"Keine Profil-Qualitätsfilterung in dieser Liste. Max. 20 Zeilen im Prompt-Output "
|
"OLD RESOLVER PATTERN: Keine Data Layer Funktion. "
|
||||||
"(Hard-Limit Resolver). session_metrics kann leer sein (kein Typ, kein Profil, keine EAV-Zeilen). "
|
"Formatierung direkt im Resolver. "
|
||||||
"Keys und Anzahl Metriken variieren je Instanz/Admin — nicht von festen Platzhaltern in anderen "
|
"CRITICAL: Keine Qualitätsfilterung - auch ungültige Einheiten (z.B. 0 min) "
|
||||||
"Prompts ausgehen. Nur im effektiven Merge erscheinende Parameter; keine verwaisten EAV-Keys "
|
"werden gelistet. JOIN mit training_types für Typ-Namen."
|
||||||
"außerhalb des Schemas."
|
|
||||||
),
|
),
|
||||||
layer_1_decision="activity_metrics.get_activity_detail_data (+ enrich_sessions_with_metrics)",
|
layer_1_decision="NONE - Old resolver pattern (direct SQL in resolver)",
|
||||||
layer_2a_decision="get_activity_detail (Formatierung)",
|
layer_2a_decision="Placeholder Resolver (formatting + SQL query)",
|
||||||
layer_2b_reuse_possible=True,
|
layer_2b_reuse_possible=False,
|
||||||
architecture_alignment="Phase 0c Layer 1 + EAV-Anreicherung",
|
architecture_alignment=(
|
||||||
issue_53_alignment="Layer 1"
|
"NOT ALIGNED with Phase 0c Multi-Layer Architecture. "
|
||||||
|
"Should be refactored to use data_layer function."
|
||||||
|
),
|
||||||
|
issue_53_alignment="NOT ALIGNED - no layer separation"
|
||||||
)
|
)
|
||||||
|
|
||||||
activity_detail_metadata.set_evidence("key", EvidenceType.CODE_DERIVED)
|
activity_detail_metadata.set_evidence("key", EvidenceType.CODE_DERIVED)
|
||||||
|
|
@ -219,47 +212,56 @@ def register_activity_group_1():
|
||||||
trainingstyp_verteilung_metadata = PlaceholderMetadata(
|
trainingstyp_verteilung_metadata = PlaceholderMetadata(
|
||||||
key="trainingstyp_verteilung",
|
key="trainingstyp_verteilung",
|
||||||
category="Aktivität",
|
category="Aktivität",
|
||||||
description="Verteilung nach training_category (14 Tage): Top 3 als kompakte Prozent-Textzeile",
|
description="Trainingstypen-Verteilung der letzten 14 Tage als JSON",
|
||||||
resolver_module="backend/placeholder_resolver.py",
|
resolver_module="backend/placeholder_resolver.py",
|
||||||
resolver_function="get_trainingstyp_verteilung",
|
resolver_function="_format_trainingstyp_verteilung",
|
||||||
data_layer_module="backend/data_layer/activity_metrics.py",
|
data_layer_module=None,
|
||||||
data_layer_function="get_training_type_distribution_data",
|
data_layer_function=None,
|
||||||
source_tables=["activity_log"],
|
source_tables=["activity_log", "training_types"],
|
||||||
semantic_contract=(
|
semantic_contract=(
|
||||||
"Layer 1: get_training_type_distribution_data — Anteil je training_category am "
|
"Liefert eine JSON-Struktur mit der Verteilung der Trainingstypen über 14 Tage. "
|
||||||
"Gesamt-Session-Count im Fenster (auch unkategorisierte zählen im Nenner). "
|
"Für jeden Trainingstyp: Anzahl Einheiten, Gesamtdauer (Minuten), "
|
||||||
"Layer 2a: Top 3 Kategorien als „Name: p%“ kommagetrennt; bei fehlenden Daten Kurz-Hinweis."
|
"Prozentanteil an Gesamtdauer. Sortiert nach Dauer absteigend."
|
||||||
),
|
),
|
||||||
business_meaning=(
|
business_meaning=(
|
||||||
"Analyse-Placeholder für Trainingsvielfalt und -schwerpunkte. "
|
"Analyse-Placeholder für Trainingsvielfalt und -schwerpunkte. "
|
||||||
"Erlaubt KI-Prompts, Imbalancen zu erkennen (z.B. nur Kraft, keine Ausdauer) "
|
"Erlaubt KI-Prompts, Imbalancen zu erkennen (z.B. nur Kraft, keine Ausdauer) "
|
||||||
"oder Zielkonformität zu prüfen (z.B. 'zu wenig Mobilität')."
|
"oder Zielkonformität zu prüfen (z.B. 'zu wenig Mobilität')."
|
||||||
),
|
),
|
||||||
unit="text",
|
unit="json",
|
||||||
time_window="14d",
|
time_window="14d",
|
||||||
output_type=OutputType.TEXT_SUMMARY,
|
output_type=OutputType.JSON,
|
||||||
placeholder_type=PlaceholderType.INTERPRETED,
|
placeholder_type=PlaceholderType.INTERPRETED,
|
||||||
format_hint="Eine Zeile: bis zu drei „Kategorie: Prozent%“, durch Komma getrennt",
|
format_hint="JSON Object mit Trainingstyp als Key, Value: {count, duration_min, percentage}",
|
||||||
example_output="cardio: 45%, strength: 30%, mobility: 15%",
|
example_output=(
|
||||||
|
'{"Krafttraining": {"count": 5, "duration_min": 180, "percentage": 57}, '
|
||||||
|
'"Ausdauer": {"count": 4, "duration_min": 90, "percentage": 29}, '
|
||||||
|
'"Mobilität": {"count": 3, "duration_min": 45, "percentage": 14}}'
|
||||||
|
),
|
||||||
minimum_data_requirements=None,
|
minimum_data_requirements=None,
|
||||||
quality_filter_policy=None,
|
quality_filter_policy=None,
|
||||||
confidence_logic="Wie get_training_type_distribution_data (calculate_confidence über categorized_count)",
|
confidence_logic="Keine Confidence-Berechnung. Aggregation basiert auf verfügbaren Daten.",
|
||||||
missing_value_policy=MissingValuePolicy(
|
missing_value_policy=MissingValuePolicy(
|
||||||
available=False,
|
available=False,
|
||||||
value_raw=None,
|
value_raw=None,
|
||||||
missing_reason="no_data",
|
missing_reason="no_data",
|
||||||
legacy_display="Keine kategorisierten Trainings"
|
legacy_display="{}"
|
||||||
),
|
),
|
||||||
known_limitations=(
|
known_limitations=(
|
||||||
"Nur Sessions mit gesetztem training_category fließen in die Verteilungsliste; "
|
"OLD RESOLVER PATTERN: Keine Data Layer Funktion. "
|
||||||
"Prozente beziehen sich auf alle Sessions im Fenster (Nenner = total_sessions). "
|
"Aggregation direkt im Resolver. "
|
||||||
"Keine Qualitätsfilterung der Einheiten. Kein drill-down nach training_type_id in diesem Platzhalter."
|
"CRITICAL: Keine Qualitätsfilterung - auch ungültige Einheiten werden aggregiert. "
|
||||||
|
"JOIN mit training_types für Typ-Namen. "
|
||||||
|
"EDGE CASE: Einheiten ohne training_type_id werden ignoriert (LEFT JOIN)."
|
||||||
),
|
),
|
||||||
layer_1_decision="activity_metrics.get_training_type_distribution_data",
|
layer_1_decision="NONE - Old resolver pattern (direct SQL aggregation in resolver)",
|
||||||
layer_2a_decision="get_trainingstyp_verteilung (Top 3 als Text)",
|
layer_2a_decision="Placeholder Resolver (aggregation + JSON formatting)",
|
||||||
layer_2b_reuse_possible=True,
|
layer_2b_reuse_possible=True,
|
||||||
architecture_alignment="Phase 0c — Layer 1 + Formatierung",
|
architecture_alignment=(
|
||||||
issue_53_alignment="Layer 1"
|
"PARTIALLY ALIGNED: JSON output structure suitable for chart endpoints, "
|
||||||
|
"but no data layer separation. Should be refactored."
|
||||||
|
),
|
||||||
|
issue_53_alignment="PARTIALLY ALIGNED - output format good, layer separation missing"
|
||||||
)
|
)
|
||||||
|
|
||||||
trainingstyp_verteilung_metadata.set_evidence("key", EvidenceType.CODE_DERIVED)
|
trainingstyp_verteilung_metadata.set_evidence("key", EvidenceType.CODE_DERIVED)
|
||||||
|
|
|
||||||
|
|
@ -130,22 +130,16 @@ def register_activity_session_insights():
|
||||||
key="training_sessions_recent_json",
|
key="training_sessions_recent_json",
|
||||||
category="Aktivität",
|
category="Aktivität",
|
||||||
description=(
|
description=(
|
||||||
"JSON: ISO-Wochen mit Sessions (activity_log-Kopf) plus session_metrics als kompaktes "
|
"JSON: letzte ISO-Kalenderwochen mit Einheiten (Datum, Art, Dauer, kcal, HF Ø/max, RPE, Kategorie)"
|
||||||
"{key: Wert}-Objekt; Zahlen für Prompts gekürzt. Semantik: {{training_parameters_glossary_md}}."
|
|
||||||
),
|
),
|
||||||
resolver_module="backend/placeholder_resolver.py",
|
resolver_module="backend/placeholder_resolver.py",
|
||||||
resolver_function="_safe_json",
|
resolver_function="_safe_json",
|
||||||
data_layer_module="backend/data_layer/activity_metrics.py",
|
data_layer_module="backend/data_layer/activity_metrics.py",
|
||||||
data_layer_function="get_training_sessions_recent_weeks_data",
|
data_layer_function="get_training_sessions_recent_weeks_data",
|
||||||
source_tables=["activity_log", "training_types", "activity_session_metrics", "training_parameters"],
|
source_tables=["activity_log", "training_types"],
|
||||||
semantic_contract=(
|
semantic_contract=(
|
||||||
"Root: weeks[] mit week_iso; sessions[] pro Einheit u. a. id, date, activity_type, "
|
"Struktur weeks[].week_iso, sessions[] mit Feldern für KI-Auswertung. "
|
||||||
"duration_min, kcal_active, hr_avg, hr_max, rpe, training_category, training_type_name, "
|
"Default 4 ISO-Wochen zurück."
|
||||||
"session_metrics (Objekt key→Wert, keine wiederholten Labels). "
|
|
||||||
"Merge wie merge_column_backed_and_eav_metrics; nur Keys aus Attributschema. "
|
|
||||||
"meta.session_metrics_shape=key_value, meta.metric_semantics_placeholder verweist auf Glossary-Platzhalter. "
|
|
||||||
"Alle JSON-Platzhalter mit _safe_json: Zahlen rekursiv kompakt gerundet. "
|
|
||||||
"Default ca. 4 ISO-Wochen (28 Tage Rohdatenfenster)."
|
|
||||||
),
|
),
|
||||||
business_meaning="Rohkontext für wochenweise Auswertung (Erholung, Intensität) in der KI",
|
business_meaning="Rohkontext für wochenweise Auswertung (Erholung, Intensität) in der KI",
|
||||||
unit="JSON string",
|
unit="JSON string",
|
||||||
|
|
@ -164,12 +158,7 @@ def register_activity_session_insights():
|
||||||
legacy_display="{}",
|
legacy_display="{}",
|
||||||
),
|
),
|
||||||
known_limitations=(
|
known_limitations=(
|
||||||
"Token-Länge bei vielen Sessions. training_type_name nur bei gesetztem training_type_id. "
|
"Token-Länge bei vielen Sessions beachten. training_type_name nur bei gesetztem training_type_id."
|
||||||
"session_metrics oft [] (kein Typ, kein Profil, keine gespeicherten Werte). "
|
|
||||||
"Anzahl und Namen der Metrik-Keys sind instanz-/adminabhängig — JSON nicht als festes Schema "
|
|
||||||
"für Downstream-Parsing harter Logik verwenden. "
|
|
||||||
"Pflicht für Metrik-Bedeutung: {{training_parameters_glossary_md}} (Katalog); im JSON keine Namen/Beschreibungen pro Session. "
|
|
||||||
"Composite-Parameter (JSON in EAV) noch nicht im MVP expandiert; ggf. Roh-value_text in späterer Phase."
|
|
||||||
),
|
),
|
||||||
layer_1_decision="activity_metrics.get_training_sessions_recent_weeks_data",
|
layer_1_decision="activity_metrics.get_training_sessions_recent_weeks_data",
|
||||||
layer_2a_decision="_safe_json('training_sessions_recent_json')",
|
layer_2a_decision="_safe_json('training_sessions_recent_json')",
|
||||||
|
|
@ -191,61 +180,5 @@ def register_activity_session_insights():
|
||||||
_ev(pj, "known_limitations", EvidenceType.MIXED)
|
_ev(pj, "known_limitations", EvidenceType.MIXED)
|
||||||
register_placeholder(pj)
|
register_placeholder(pj)
|
||||||
|
|
||||||
md_gloss = PlaceholderMetadata(
|
|
||||||
key="training_parameters_glossary_md",
|
|
||||||
category="Aktivität",
|
|
||||||
description=(
|
|
||||||
"Markdown-Tabelle: alle aktiven training_parameters (key, DE/EN, Beschreibungen, Typ, Einheit, Kategorie). "
|
|
||||||
"Ergänzung zu training_sessions_recent_json für KI (Bedeutung dynamischer Metrik-Keys)."
|
|
||||||
),
|
|
||||||
resolver_module="backend/placeholder_resolver.py",
|
|
||||||
resolver_function="get_training_parameters_glossary_md",
|
|
||||||
data_layer_module="backend/data_layer/activity_metrics.py",
|
|
||||||
data_layer_function="get_training_parameters_ki_glossary_data",
|
|
||||||
source_tables=["training_parameters"],
|
|
||||||
semantic_contract=(
|
|
||||||
"SELECT auf training_parameters WHERE is_active; sortiert category, key. "
|
|
||||||
"profile_id-Parameter im Resolver reserviert, aktuell globaler Katalog."
|
|
||||||
),
|
|
||||||
business_meaning="KI: Legende zu session_metrics-Keys und Custom-Parametern",
|
|
||||||
unit="Markdown",
|
|
||||||
time_window="n/a (Katalog-Snapshot)",
|
|
||||||
output_type=OutputType.TEXT_SUMMARY,
|
|
||||||
placeholder_type=PlaceholderType.INTERPRETED,
|
|
||||||
format_hint="GitHub-Flavored Markdown-Tabelle",
|
|
||||||
example_output="| Feld (key) | DE | EN | Beschreibung DE | … |",
|
|
||||||
minimum_data_requirements="Optional leer → Kurztext statt Tabelle",
|
|
||||||
quality_filter_policy=None,
|
|
||||||
confidence_logic="Immer verfügbar wenn DB erreichbar",
|
|
||||||
missing_value_policy=MissingValuePolicy(
|
|
||||||
available=False,
|
|
||||||
value_raw=None,
|
|
||||||
missing_reason="no_data",
|
|
||||||
legacy_display="Keine aktiven Trainingsparameter im Katalog.",
|
|
||||||
),
|
|
||||||
known_limitations=(
|
|
||||||
"Keine profil-spezifische Einschränkung auf tatsächlich genutzte Keys (V2). "
|
|
||||||
"Tabellen können bei großem Katalog lang werden."
|
|
||||||
),
|
|
||||||
layer_1_decision="activity_metrics.get_training_parameters_ki_glossary_data",
|
|
||||||
layer_2a_decision="get_training_parameters_glossary_md",
|
|
||||||
layer_2b_reuse_possible=True,
|
|
||||||
architecture_alignment="Phase 0c",
|
|
||||||
issue_53_alignment="Layer 2a",
|
|
||||||
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_gloss, f)
|
|
||||||
_ev(md_gloss, "business_meaning", EvidenceType.DRAFT_DERIVED)
|
|
||||||
_ev(md_gloss, "known_limitations", EvidenceType.MIXED)
|
|
||||||
register_placeholder(md_gloss)
|
|
||||||
|
|
||||||
|
|
||||||
register_activity_session_insights()
|
register_activity_session_insights()
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,9 @@ Body Composition (5):
|
||||||
- waist_hip_ratio
|
- waist_hip_ratio
|
||||||
- recomposition_quadrant
|
- recomposition_quadrant
|
||||||
|
|
||||||
Circumference Deltas (6):
|
Circumference Deltas (5):
|
||||||
- waist_28d_delta
|
- waist_28d_delta
|
||||||
- arm_28d_delta (Oberarm kontrahiert, c_arm)
|
- arm_28d_delta
|
||||||
- arm_relaxed_28d_delta (Oberarm entspannt, c_arm_relaxed)
|
|
||||||
- chest_28d_delta
|
- chest_28d_delta
|
||||||
- hip_28d_delta
|
- hip_28d_delta
|
||||||
- thigh_28d_delta
|
- thigh_28d_delta
|
||||||
|
|
@ -1034,14 +1033,14 @@ def register_body_metrics():
|
||||||
|
|
||||||
arm_28d_delta_metadata = PlaceholderMetadata(
|
arm_28d_delta_metadata = PlaceholderMetadata(
|
||||||
key="arm_28d_delta",
|
key="arm_28d_delta",
|
||||||
description="Oberarm kontrahiert (c_arm): Umfangs-Änderung 28d (cm)",
|
description="Armumfang Änderung 28d (cm)",
|
||||||
resolver_function="_safe_float('arm_28d_delta', decimals=1)",
|
resolver_function="_safe_float('arm_28d_delta', decimals=1)",
|
||||||
data_layer_function="calculate_arm_28d_delta",
|
data_layer_function="calculate_arm_28d_delta",
|
||||||
semantic_contract=(
|
semantic_contract=(
|
||||||
"Veränderung des kontrahierten/angespannten Oberarmumfangs (Spalte c_arm) in cm über 28 Tage. "
|
"Liefert die Veränderung des Armumfangs in Zentimetern über 28 Tage. "
|
||||||
"Entspricht historischen Einträgen „Arm“ vor Einführung des zweiten Messpunkts."
|
"Positive Werte bedeuten Zunahme, negative Werte Reduktion."
|
||||||
),
|
),
|
||||||
business_meaning="Arm-Umfang unter Anspannung (z. B. leicht gebeugter Arm, Bizeps leicht aktiv)",
|
business_meaning="Ergänzender Umfangsindikator für detaillierte Körperentwicklungsanalysen",
|
||||||
unit="cm",
|
unit="cm",
|
||||||
example_output="+0.6",
|
example_output="+0.6",
|
||||||
**circumference_delta_common
|
**circumference_delta_common
|
||||||
|
|
@ -1055,30 +1054,6 @@ def register_body_metrics():
|
||||||
arm_28d_delta_metadata.set_evidence("example_output", EvidenceType.DRAFT_DERIVED)
|
arm_28d_delta_metadata.set_evidence("example_output", EvidenceType.DRAFT_DERIVED)
|
||||||
register_placeholder(arm_28d_delta_metadata)
|
register_placeholder(arm_28d_delta_metadata)
|
||||||
|
|
||||||
# ── arm_relaxed_28d_delta ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
arm_relaxed_28d_delta_metadata = PlaceholderMetadata(
|
|
||||||
key="arm_relaxed_28d_delta",
|
|
||||||
description="Oberarm entspannt (c_arm_relaxed): Umfangs-Änderung 28d (cm)",
|
|
||||||
resolver_function="_safe_float('arm_relaxed_28d_delta', decimals=1)",
|
|
||||||
data_layer_function="calculate_arm_relaxed_28d_delta",
|
|
||||||
semantic_contract=(
|
|
||||||
"Veränderung des entspannten Oberarmumfangs (Spalte c_arm_relaxed) in cm über 28 Tage."
|
|
||||||
),
|
|
||||||
business_meaning="Arm-Umfang bei locker hängendem Arm ohne zusätzliche Muskelanspannung",
|
|
||||||
unit="cm",
|
|
||||||
example_output="+0.3",
|
|
||||||
**circumference_delta_common
|
|
||||||
)
|
|
||||||
arm_relaxed_28d_delta_metadata.evidence.update(circ_delta_evidence)
|
|
||||||
arm_relaxed_28d_delta_metadata.set_evidence("resolver_function", EvidenceType.CODE_DERIVED)
|
|
||||||
arm_relaxed_28d_delta_metadata.set_evidence("data_layer_function", EvidenceType.CODE_DERIVED)
|
|
||||||
arm_relaxed_28d_delta_metadata.set_evidence("semantic_contract", EvidenceType.DRAFT_DERIVED)
|
|
||||||
arm_relaxed_28d_delta_metadata.set_evidence("business_meaning", EvidenceType.DRAFT_DERIVED)
|
|
||||||
arm_relaxed_28d_delta_metadata.set_evidence("unit", EvidenceType.CODE_DERIVED)
|
|
||||||
arm_relaxed_28d_delta_metadata.set_evidence("example_output", EvidenceType.DRAFT_DERIVED)
|
|
||||||
register_placeholder(arm_relaxed_28d_delta_metadata)
|
|
||||||
|
|
||||||
# ── chest_28d_delta ──────────────────────────────────────────────────────
|
# ── chest_28d_delta ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
chest_28d_delta_metadata = PlaceholderMetadata(
|
chest_28d_delta_metadata = PlaceholderMetadata(
|
||||||
|
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
"""
|
|
||||||
Registry: Persönliche Referenzwerte (Profil) — Layer 1 reference_values, JSON-Platzhalter 2a.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from placeholder_registry import (
|
|
||||||
PlaceholderMetadata,
|
|
||||||
MissingValuePolicy,
|
|
||||||
OutputType,
|
|
||||||
PlaceholderType,
|
|
||||||
register_placeholder,
|
|
||||||
)
|
|
||||||
from ._evidence import tag_standard_evidence
|
|
||||||
|
|
||||||
CAT = "Referenzwerte"
|
|
||||||
MVP = lambda reason, disp: MissingValuePolicy(
|
|
||||||
available=False, value_raw=None, missing_reason=reason, legacy_display=disp
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def register_profile_reference_values():
|
|
||||||
for key, dl_fn, desc, sem in [
|
|
||||||
(
|
|
||||||
"reference_values_current_json",
|
|
||||||
"get_profile_reference_values_current_snapshot",
|
|
||||||
"JSON: aktuelle Referenzwerte (jüngster Eintrag pro Typ, Katalog-Reihenfolge)",
|
|
||||||
"reference_values.get_profile_reference_values_current_snapshot(profile_id)",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"reference_values_recent_json",
|
|
||||||
"get_profile_reference_values_recent_snapshot",
|
|
||||||
"JSON: Verlauf — bis zu 5 Einträge pro Referenztyp (neueste zuerst), optional Datumsfilter in Layer-1-API",
|
|
||||||
"reference_values.get_profile_reference_values_recent_snapshot(profile_id, limit_per_type=5)",
|
|
||||||
),
|
|
||||||
]:
|
|
||||||
m = PlaceholderMetadata(
|
|
||||||
key=key,
|
|
||||||
category=CAT,
|
|
||||||
description=desc,
|
|
||||||
resolver_module="backend/placeholder_resolver.py",
|
|
||||||
resolver_function="_safe_json",
|
|
||||||
data_layer_module="backend/data_layer/reference_values.py",
|
|
||||||
data_layer_function=dl_fn,
|
|
||||||
source_tables=["profile_reference_values", "reference_value_types"],
|
|
||||||
semantic_contract=sem,
|
|
||||||
business_meaning="Persönliche Referenzkennwerte für KI-Kontext (Messmethode, Vertrauen, Historie)",
|
|
||||||
unit="JSON",
|
|
||||||
time_window="aktuell bzw. letzte N Einträge pro Typ",
|
|
||||||
output_type=OutputType.JSON,
|
|
||||||
placeholder_type=PlaceholderType.RAW_DATA,
|
|
||||||
format_hint="JSON-String (schema *_v1)",
|
|
||||||
example_output='{"count":0,"items":[]}',
|
|
||||||
minimum_data_requirements="Keine Pflicht — leere Listen möglich",
|
|
||||||
quality_filter_policy=None,
|
|
||||||
confidence_logic="Rohdaten aus Erfassung (confidence-Feld pro Eintrag)",
|
|
||||||
missing_value_policy=MVP("optional_module", "{}"),
|
|
||||||
known_limitations="recent_json: fest 5 pro Typ im Platzhalter; Datumsfilter nur über API/Layer-1-Parameter",
|
|
||||||
layer_1_decision="data_layer.reference_values",
|
|
||||||
layer_2a_decision="_safe_json + compact_json_payload_for_prompts",
|
|
||||||
layer_2b_reuse_possible=True,
|
|
||||||
architecture_alignment="Issue 53 / Phase 0c Layer 1",
|
|
||||||
issue_53_alignment="Strukturierte L1-Daten für Prompts",
|
|
||||||
evidence={},
|
|
||||||
)
|
|
||||||
tag_standard_evidence(m)
|
|
||||||
register_placeholder(m)
|
|
||||||
|
|
||||||
|
|
||||||
register_profile_reference_values()
|
|
||||||
|
|
@ -28,8 +28,6 @@ from data_layer.nutrition_metrics import (
|
||||||
get_nutrition_days_data,
|
get_nutrition_days_data,
|
||||||
get_protein_targets_data
|
get_protein_targets_data
|
||||||
)
|
)
|
||||||
from data_layer.prompt_output_compact import format_scalar_for_prompt_text
|
|
||||||
|
|
||||||
from data_layer.activity_metrics import (
|
from data_layer.activity_metrics import (
|
||||||
get_activity_summary_data,
|
get_activity_summary_data,
|
||||||
get_activity_detail_data,
|
get_activity_detail_data,
|
||||||
|
|
@ -37,7 +35,6 @@ from data_layer.activity_metrics import (
|
||||||
get_training_frequency_by_type_data,
|
get_training_frequency_by_type_data,
|
||||||
get_training_inter_session_gap_data,
|
get_training_inter_session_gap_data,
|
||||||
get_training_sessions_recent_weeks_data,
|
get_training_sessions_recent_weeks_data,
|
||||||
get_training_parameters_ki_glossary_data,
|
|
||||||
)
|
)
|
||||||
from data_layer.recovery_metrics import (
|
from data_layer.recovery_metrics import (
|
||||||
get_sleep_duration_data,
|
get_sleep_duration_data,
|
||||||
|
|
@ -50,12 +47,6 @@ from data_layer.health_metrics import (
|
||||||
get_vo2_max_data
|
get_vo2_max_data
|
||||||
)
|
)
|
||||||
|
|
||||||
from data_layer.prompt_output_compact import compact_json_payload_for_prompts
|
|
||||||
from data_layer.reference_values import (
|
|
||||||
get_profile_reference_values_current_snapshot,
|
|
||||||
get_profile_reference_values_recent_snapshot,
|
|
||||||
)
|
|
||||||
|
|
||||||
from placeholder_registry import build_ai_placeholder_caption, get_registry
|
from placeholder_registry import build_ai_placeholder_caption, get_registry
|
||||||
|
|
||||||
# {{key|d}} — nur description anhängen; {{key|x}} — nur Erklärung (ai_caption / Registry)
|
# {{key|d}} — nur description anhängen; {{key|x}} — nur Erklärung (ai_caption / Registry)
|
||||||
|
|
@ -356,11 +347,7 @@ def get_activity_summary(profile_id: str, days: int = 14) -> str:
|
||||||
if data['confidence'] == 'insufficient':
|
if data['confidence'] == 'insufficient':
|
||||||
return f"Keine Aktivitäten in den letzten {days} Tagen"
|
return f"Keine Aktivitäten in den letzten {days} Tagen"
|
||||||
|
|
||||||
return (
|
return f"{data['activity_count']} Einheiten in {days} Tagen (Ø {data['avg_duration_min']} min/Einheit, {data['total_kcal']} kcal gesamt)"
|
||||||
f"{data['activity_count']} Einheiten in {days} Tagen (Ø "
|
|
||||||
f"{format_scalar_for_prompt_text(data['avg_duration_min'])} min/Einheit, "
|
|
||||||
f"{format_scalar_for_prompt_text(data['total_kcal'])} kcal gesamt)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_age(dob) -> str:
|
def calculate_age(dob) -> str:
|
||||||
|
|
@ -432,27 +419,14 @@ def get_activity_detail(profile_id: str, days: int = 14) -> str:
|
||||||
|
|
||||||
# Format as readable list (max 20 entries to avoid token bloat)
|
# Format as readable list (max 20 entries to avoid token bloat)
|
||||||
lines = []
|
lines = []
|
||||||
for activity in data["activities"][:20]:
|
for activity in data['activities'][:20]:
|
||||||
hr_str = (
|
hr_str = f" HF={activity['hr_avg']}" if activity['hr_avg'] else ""
|
||||||
f", HF={format_scalar_for_prompt_text(activity['hr_avg'])}"
|
|
||||||
if activity.get("hr_avg") is not None
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
eav_parts = []
|
|
||||||
for m in activity.get("session_metrics") or []:
|
|
||||||
k, v = m.get("key"), m.get("value")
|
|
||||||
if k is None or v is None:
|
|
||||||
continue
|
|
||||||
label = m.get("name_de") or m.get("name_en") or k
|
|
||||||
eav_parts.append(f"{label} ({k})={format_scalar_for_prompt_text(v)}")
|
|
||||||
eav_str = f" | EAV: {'; '.join(eav_parts)}" if eav_parts else ""
|
|
||||||
lines.append(
|
lines.append(
|
||||||
f"{activity['date']}: {activity['activity_type']} "
|
f"{activity['date']}: {activity['activity_type']} "
|
||||||
f"({format_scalar_for_prompt_text(activity['duration_min'])}min, "
|
f"({activity['duration_min']}min, {activity['kcal_active']}kcal{hr_str})"
|
||||||
f"{format_scalar_for_prompt_text(activity['kcal_active'])}kcal{hr_str}{eav_str})"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return "\n".join(lines)
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
|
||||||
def get_trainingstyp_verteilung(profile_id: str, days: int = 14) -> str:
|
def get_trainingstyp_verteilung(profile_id: str, days: int = 14) -> str:
|
||||||
|
|
@ -475,45 +449,6 @@ def get_trainingstyp_verteilung(profile_id: str, days: int = 14) -> str:
|
||||||
return ", ".join(parts)
|
return ", ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def get_training_parameters_glossary_md(profile_id: str) -> str:
|
|
||||||
"""
|
|
||||||
Markdown-Tabelle: alle aktiven training_parameters (key, Namen, Beschreibungen, Typ, Einheit).
|
|
||||||
Für KI neben session_metrics / training_sessions_recent_json.
|
|
||||||
"""
|
|
||||||
data = get_training_parameters_ki_glossary_data(profile_id)
|
|
||||||
params = data.get("parameters") or []
|
|
||||||
if not params:
|
|
||||||
return "Keine aktiven Trainingsparameter im Katalog."
|
|
||||||
|
|
||||||
def cell(x: object) -> str:
|
|
||||||
if x is None:
|
|
||||||
return "—"
|
|
||||||
return str(x).replace("|", "·").replace("\n", " ").strip()[:400]
|
|
||||||
|
|
||||||
lines = [
|
|
||||||
"| Feld (key) | DE | EN | Beschreibung DE | Beschreibung EN | Typ | Einheit | Kategorie |",
|
|
||||||
"|---|---|---|---|---|---|---|---|",
|
|
||||||
]
|
|
||||||
for p in params:
|
|
||||||
lines.append(
|
|
||||||
"| "
|
|
||||||
+ " | ".join(
|
|
||||||
[
|
|
||||||
cell(p.get("key")),
|
|
||||||
cell(p.get("name_de")),
|
|
||||||
cell(p.get("name_en")),
|
|
||||||
cell(p.get("description_de")),
|
|
||||||
cell(p.get("description_en")),
|
|
||||||
cell(p.get("data_type")),
|
|
||||||
cell(p.get("unit")),
|
|
||||||
cell(p.get("category")),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
+ " |"
|
|
||||||
)
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def get_training_frequency_by_type_md(profile_id: str, days: int = 28) -> str:
|
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.
|
Markdown-Tabelle: pro Trainingsart (Roh-Label) Ø Sessions/Woche, Dauer, kcal, HF, RPE, kcal/min.
|
||||||
|
|
@ -753,8 +688,7 @@ _SAFE_FLOAT_NONE_REASON: Dict[str, str] = {
|
||||||
"waist_28d_delta": "Taillen-Delta 28 Tage nicht berechenbar (zwei auswertbare Messungen nötig)",
|
"waist_28d_delta": "Taillen-Delta 28 Tage nicht berechenbar (zwei auswertbare Messungen nötig)",
|
||||||
"hip_28d_delta": "Hüft-Delta 28 Tage nicht berechenbar",
|
"hip_28d_delta": "Hüft-Delta 28 Tage nicht berechenbar",
|
||||||
"chest_28d_delta": "Brust-Delta 28 Tage nicht berechenbar",
|
"chest_28d_delta": "Brust-Delta 28 Tage nicht berechenbar",
|
||||||
"arm_28d_delta": "Oberarm kontrahiert: Delta 28 Tage nicht berechenbar",
|
"arm_28d_delta": "Arm-Delta 28 Tage nicht berechenbar",
|
||||||
"arm_relaxed_28d_delta": "Oberarm entspannt: Delta 28 Tage nicht berechenbar",
|
|
||||||
"thigh_28d_delta": "Oberschenkel-Delta 28 Tage nicht berechenbar",
|
"thigh_28d_delta": "Oberschenkel-Delta 28 Tage nicht berechenbar",
|
||||||
"waist_hip_ratio": "Taille-Hüfte-Verhältnis nicht berechenbar",
|
"waist_hip_ratio": "Taille-Hüfte-Verhältnis nicht berechenbar",
|
||||||
"energy_balance_7d": (
|
"energy_balance_7d": (
|
||||||
|
|
@ -814,8 +748,6 @@ _SAFE_JSON_NONE_REASON: Dict[str, str] = {
|
||||||
"active_goals_json": "Aktive Ziele als JSON nicht ermittelbar",
|
"active_goals_json": "Aktive Ziele als JSON nicht ermittelbar",
|
||||||
"focus_areas_weighted_json": "Gewichtete Fokusbereiche JSON nicht ermittelbar",
|
"focus_areas_weighted_json": "Gewichtete Fokusbereiche JSON nicht ermittelbar",
|
||||||
"focus_area_weights_json": "Fokus-Gewichtungen JSON nicht ermittelbar",
|
"focus_area_weights_json": "Fokus-Gewichtungen JSON nicht ermittelbar",
|
||||||
"reference_values_current_json": "Referenzwerte (aktuell) nicht ermittelbar",
|
|
||||||
"reference_values_recent_json": "Referenzwerte (Verlauf) nicht ermittelbar",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -920,7 +852,6 @@ def _safe_float(func_name: str, profile_id: str, decimals: int = 1) -> str:
|
||||||
'hip_28d_delta': body_metrics.calculate_hip_28d_delta,
|
'hip_28d_delta': body_metrics.calculate_hip_28d_delta,
|
||||||
'chest_28d_delta': body_metrics.calculate_chest_28d_delta,
|
'chest_28d_delta': body_metrics.calculate_chest_28d_delta,
|
||||||
'arm_28d_delta': body_metrics.calculate_arm_28d_delta,
|
'arm_28d_delta': body_metrics.calculate_arm_28d_delta,
|
||||||
'arm_relaxed_28d_delta': body_metrics.calculate_arm_relaxed_28d_delta,
|
|
||||||
'thigh_28d_delta': body_metrics.calculate_thigh_28d_delta,
|
'thigh_28d_delta': body_metrics.calculate_thigh_28d_delta,
|
||||||
'waist_hip_ratio': body_metrics.calculate_waist_hip_ratio,
|
'waist_hip_ratio': body_metrics.calculate_waist_hip_ratio,
|
||||||
'energy_balance_7d': nutrition_metrics.calculate_energy_balance_7d,
|
'energy_balance_7d': nutrition_metrics.calculate_energy_balance_7d,
|
||||||
|
|
@ -1031,8 +962,6 @@ def _safe_json(func_name: str, profile_id: str) -> str:
|
||||||
'active_goals_json': lambda pid: _get_active_goals_json(pid),
|
'active_goals_json': lambda pid: _get_active_goals_json(pid),
|
||||||
'focus_areas_weighted_json': lambda pid: _get_focus_areas_weighted_json(pid),
|
'focus_areas_weighted_json': lambda pid: _get_focus_areas_weighted_json(pid),
|
||||||
'focus_area_weights_json': lambda pid: json.dumps(scores.get_user_focus_weights(pid), ensure_ascii=False),
|
'focus_area_weights_json': lambda pid: json.dumps(scores.get_user_focus_weights(pid), ensure_ascii=False),
|
||||||
'reference_values_current_json': get_profile_reference_values_current_snapshot,
|
|
||||||
'reference_values_recent_json': get_profile_reference_values_recent_snapshot,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func = func_map.get(func_name)
|
func = func_map.get(func_name)
|
||||||
|
|
@ -1051,8 +980,8 @@ def _safe_json(func_name: str, profile_id: str) -> str:
|
||||||
# If already string, return it; otherwise convert to JSON
|
# If already string, return it; otherwise convert to JSON
|
||||||
if isinstance(result, str):
|
if isinstance(result, str):
|
||||||
return result
|
return result
|
||||||
compacted = compact_json_payload_for_prompts(result)
|
else:
|
||||||
return json.dumps(compacted, ensure_ascii=False, default=str)
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ERROR] _safe_json({func_name}, {profile_id}): {type(e).__name__}: {e}")
|
print(f"[ERROR] _safe_json({func_name}, {profile_id}): {type(e).__name__}: {e}")
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
@ -1566,7 +1495,6 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = {
|
||||||
'{{hip_28d_delta}}': lambda pid: _safe_float('hip_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),
|
'{{chest_28d_delta}}': lambda pid: _safe_float('chest_28d_delta', pid),
|
||||||
'{{arm_28d_delta}}': lambda pid: _safe_float('arm_28d_delta', pid),
|
'{{arm_28d_delta}}': lambda pid: _safe_float('arm_28d_delta', pid),
|
||||||
'{{arm_relaxed_28d_delta}}': lambda pid: _safe_float('arm_relaxed_28d_delta', pid),
|
|
||||||
'{{thigh_28d_delta}}': lambda pid: _safe_float('thigh_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),
|
'{{waist_hip_ratio}}': lambda pid: _safe_float('waist_hip_ratio', pid, decimals=3),
|
||||||
'{{recomposition_quadrant}}': lambda pid: _safe_str('recomposition_quadrant', pid),
|
'{{recomposition_quadrant}}': lambda pid: _safe_str('recomposition_quadrant', pid),
|
||||||
|
|
@ -1589,7 +1517,7 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = {
|
||||||
'{{intake_volatility}}': lambda pid: _safe_str('intake_volatility', pid),
|
'{{intake_volatility}}': lambda pid: _safe_str('intake_volatility', pid),
|
||||||
'{{nutrition_score}}': lambda pid: _safe_int('nutrition_score', pid),
|
'{{nutrition_score}}': lambda pid: _safe_int('nutrition_score', pid),
|
||||||
|
|
||||||
# Training / Aktivität (20 Keys: 17 activity_metrics + 3 activity_session_insights; activity_score hier, nicht unter Meta Scores)
|
# Training / Aktivität (17 Registry-Keys — gebündelt; activity_score hier, nicht unter Meta Scores)
|
||||||
'{{activity_summary}}': get_activity_summary,
|
'{{activity_summary}}': get_activity_summary,
|
||||||
'{{activity_detail}}': get_activity_detail,
|
'{{activity_detail}}': get_activity_detail,
|
||||||
'{{trainingstyp_verteilung}}': get_trainingstyp_verteilung,
|
'{{trainingstyp_verteilung}}': get_trainingstyp_verteilung,
|
||||||
|
|
@ -1610,7 +1538,6 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = {
|
||||||
'{{training_frequency_by_type_md}}': get_training_frequency_by_type_md,
|
'{{training_frequency_by_type_md}}': get_training_frequency_by_type_md,
|
||||||
'{{training_inter_session_gap_md}}': get_training_inter_session_gap_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),
|
'{{training_sessions_recent_json}}': lambda pid: _safe_json('training_sessions_recent_json', pid),
|
||||||
'{{training_parameters_glossary_md}}': get_training_parameters_glossary_md,
|
|
||||||
|
|
||||||
# Schlaf & Erholung (10 Registry-Keys; recovery_score hier, nicht unter Meta Scores)
|
# 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_duration}}': lambda pid: get_sleep_avg_duration(pid, 7),
|
||||||
|
|
@ -1682,8 +1609,6 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = {
|
||||||
'{{focus_areas_weighted_json}}': lambda pid: _safe_json('focus_areas_weighted_json', pid),
|
'{{focus_areas_weighted_json}}': lambda pid: _safe_json('focus_areas_weighted_json', pid),
|
||||||
'{{focus_areas_weighted_md}}': lambda pid: _safe_str('focus_areas_weighted_md', pid),
|
'{{focus_areas_weighted_md}}': lambda pid: _safe_str('focus_areas_weighted_md', pid),
|
||||||
'{{focus_area_weights_json}}': lambda pid: _safe_json('focus_area_weights_json', pid),
|
'{{focus_area_weights_json}}': lambda pid: _safe_json('focus_area_weights_json', pid),
|
||||||
'{{reference_values_current_json}}': lambda pid: _safe_json('reference_values_current_json', pid),
|
|
||||||
'{{reference_values_recent_json}}': lambda pid: _safe_json('reference_values_recent_json', pid),
|
|
||||||
'{{top_3_focus_areas}}': lambda pid: _safe_str('top_3_focus_areas', pid),
|
'{{top_3_focus_areas}}': lambda pid: _safe_str('top_3_focus_areas', pid),
|
||||||
'{{top_3_goals_behind_schedule}}': lambda pid: _safe_str('top_3_goals_behind_schedule', pid),
|
'{{top_3_goals_behind_schedule}}': lambda pid: _safe_str('top_3_goals_behind_schedule', pid),
|
||||||
'{{top_3_goals_on_track}}': lambda pid: _safe_str('top_3_goals_on_track', pid),
|
'{{top_3_goals_on_track}}': lambda pid: _safe_str('top_3_goals_on_track', pid),
|
||||||
|
|
@ -1798,7 +1723,7 @@ def get_available_placeholders(categories: Optional[List[str]] = None) -> Dict[s
|
||||||
'{{weight_7d_median}}', '{{weight_28d_slope}}', '{{weight_90d_slope}}',
|
'{{weight_7d_median}}', '{{weight_28d_slope}}', '{{weight_90d_slope}}',
|
||||||
'{{fm_28d_change}}', '{{lbm_28d_change}}',
|
'{{fm_28d_change}}', '{{lbm_28d_change}}',
|
||||||
'{{waist_28d_delta}}', '{{hip_28d_delta}}', '{{chest_28d_delta}}',
|
'{{waist_28d_delta}}', '{{hip_28d_delta}}', '{{chest_28d_delta}}',
|
||||||
'{{arm_28d_delta}}', '{{arm_relaxed_28d_delta}}', '{{thigh_28d_delta}}',
|
'{{arm_28d_delta}}', '{{thigh_28d_delta}}',
|
||||||
'{{waist_hip_ratio}}', '{{recomposition_quadrant}}',
|
'{{waist_hip_ratio}}', '{{recomposition_quadrant}}',
|
||||||
'{{body_progress_score}}',
|
'{{body_progress_score}}',
|
||||||
],
|
],
|
||||||
|
|
@ -1817,7 +1742,6 @@ def get_available_placeholders(categories: Optional[List[str]] = None) -> Dict[s
|
||||||
'{{proxy_internal_load_7d}}', '{{monotony_score}}', '{{strain_score}}',
|
'{{proxy_internal_load_7d}}', '{{monotony_score}}', '{{strain_score}}',
|
||||||
'{{rest_day_compliance}}', '{{vo2max_trend_28d}}', '{{activity_score}}',
|
'{{rest_day_compliance}}', '{{vo2max_trend_28d}}', '{{activity_score}}',
|
||||||
'{{training_frequency_by_type_md}}', '{{training_inter_session_gap_md}}', '{{training_sessions_recent_json}}',
|
'{{training_frequency_by_type_md}}', '{{training_inter_session_gap_md}}', '{{training_sessions_recent_json}}',
|
||||||
'{{training_parameters_glossary_md}}',
|
|
||||||
],
|
],
|
||||||
'schlaf': [
|
'schlaf': [
|
||||||
'{{sleep_avg_duration}}', '{{sleep_avg_quality}}', '{{rest_days_count}}',
|
'{{sleep_avg_duration}}', '{{sleep_avg_quality}}', '{{rest_days_count}}',
|
||||||
|
|
@ -1833,10 +1757,6 @@ def get_available_placeholders(categories: Optional[List[str]] = None) -> Dict[s
|
||||||
'zeitraum': [
|
'zeitraum': [
|
||||||
'{{datum_heute}}', '{{zeitraum_7d}}', '{{zeitraum_30d}}', '{{zeitraum_90d}}'
|
'{{datum_heute}}', '{{zeitraum_7d}}', '{{zeitraum_30d}}', '{{zeitraum_90d}}'
|
||||||
],
|
],
|
||||||
'referenzwerte': [
|
|
||||||
'{{reference_values_current_json}}',
|
|
||||||
'{{reference_values_recent_json}}',
|
|
||||||
],
|
|
||||||
'phase0b_meta': [
|
'phase0b_meta': [
|
||||||
'{{goal_progress_score}}', '{{data_quality_score}}',
|
'{{goal_progress_score}}', '{{data_quality_score}}',
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
"""
|
|
||||||
Chart-Daten für Berichts-PDF: dieselbe Logik wie /api/charts/* (Data Layer), ohne HTTP.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
from data_layer.activity_metrics import (
|
|
||||||
build_training_type_distribution_chart_payload,
|
|
||||||
build_training_volume_chart_payload,
|
|
||||||
)
|
|
||||||
from data_layer.body_metrics import get_weight_trend_data
|
|
||||||
from data_layer.nutrition_chart_payloads import build_energy_balance_chart_payload
|
|
||||||
from data_layer.nutrition_metrics import get_nutrition_average_data
|
|
||||||
from data_layer.utils import serialize_dates
|
|
||||||
|
|
||||||
|
|
||||||
def _weight_trend_payload(profile_id: str, days: int) -> dict[str, Any]:
|
|
||||||
d = min(max(days, 7), 365)
|
|
||||||
trend_data = get_weight_trend_data(profile_id, d)
|
|
||||||
if trend_data["confidence"] == "insufficient":
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Nicht genug Daten für Trend-Analyse",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
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",
|
|
||||||
"data": {
|
|
||||||
"labels": labels,
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "Gewicht",
|
|
||||||
"data": values,
|
|
||||||
"borderColor": "#1D9E75",
|
|
||||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"tension": 0.4,
|
|
||||||
"fill": True,
|
|
||||||
"pointRadius": 2,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"metadata": serialize_dates(
|
|
||||||
{
|
|
||||||
"confidence": trend_data["confidence"],
|
|
||||||
"data_points": trend_data["data_points"],
|
|
||||||
"first_value": trend_data["first_value"],
|
|
||||||
"last_value": trend_data["last_value"],
|
|
||||||
"delta": trend_data["delta"],
|
|
||||||
"direction": trend_data["direction"],
|
|
||||||
}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _macro_distribution_payload(profile_id: str, days: int) -> dict[str, Any]:
|
|
||||||
d = min(max(days, 7), 90)
|
|
||||||
macro_data = get_nutrition_average_data(profile_id, d)
|
|
||||||
if macro_data["confidence"] == "insufficient":
|
|
||||||
return {
|
|
||||||
"chart_type": "pie",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {"confidence": "insufficient", "message": "Keine Ernährungsdaten vorhanden"},
|
|
||||||
}
|
|
||||||
protein_kcal = macro_data["protein_avg"] * 4
|
|
||||||
carbs_kcal = macro_data["carbs_avg"] * 4
|
|
||||||
fat_kcal = macro_data["fat_avg"] * 9
|
|
||||||
total_kcal = protein_kcal + carbs_kcal + fat_kcal
|
|
||||||
if total_kcal == 0:
|
|
||||||
return {
|
|
||||||
"chart_type": "pie",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {"confidence": "insufficient", "message": "Keine Makronährstoff-Daten"},
|
|
||||||
}
|
|
||||||
protein_pct = protein_kcal / total_kcal * 100
|
|
||||||
carbs_pct = carbs_kcal / total_kcal * 100
|
|
||||||
fat_pct = fat_kcal / total_kcal * 100
|
|
||||||
return {
|
|
||||||
"chart_type": "pie",
|
|
||||||
"data": {
|
|
||||||
"labels": ["Protein", "Kohlenhydrate", "Fett"],
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"data": [round(protein_pct, 1), round(carbs_pct, 1), round(fat_pct, 1)],
|
|
||||||
"backgroundColor": ["#1D9E75", "#F59E0B", "#EF4444"],
|
|
||||||
"borderWidth": 2,
|
|
||||||
"borderColor": "#fff",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"metadata": {"confidence": macro_data.get("confidence", "high")},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _training_volume_payload(profile_id: str, window_days: int) -> dict[str, Any]:
|
|
||||||
w = max(4, min(52, window_days // 7))
|
|
||||||
return build_training_volume_chart_payload(profile_id, w)
|
|
||||||
|
|
||||||
|
|
||||||
_CHART_FETCHERS: dict[str, Callable[[str, int], dict[str, Any]]] = {
|
|
||||||
"weight_trend": _weight_trend_payload,
|
|
||||||
"energy_balance": lambda pid, d: build_energy_balance_chart_payload(pid, min(max(d, 7), 90)),
|
|
||||||
"macro_distribution": _macro_distribution_payload,
|
|
||||||
"training_volume": _training_volume_payload,
|
|
||||||
"training_type_distribution": lambda pid, d: build_training_type_distribution_chart_payload(
|
|
||||||
pid, min(max(d, 7), 90)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_chart_payload(chart_id: str, profile_id: str, window_days: int) -> dict[str, Any]:
|
|
||||||
fn = _CHART_FETCHERS.get(chart_id)
|
|
||||||
if not fn:
|
|
||||||
raise ValueError(f"Unbekanntes chart_id: {chart_id}")
|
|
||||||
return fn(profile_id, window_days)
|
|
||||||
|
|
||||||
|
|
||||||
CHART_CATALOG_FOR_API: list[dict[str, Any]] = [
|
|
||||||
{"id": "weight_trend", "title": "Gewichtstrend", "default_window_days": 90, "window_max": 365},
|
|
||||||
{"id": "energy_balance", "title": "Energiebilanz", "default_window_days": 28, "window_max": 90},
|
|
||||||
{"id": "macro_distribution", "title": "Makroverteilung (Ø)", "default_window_days": 28, "window_max": 90},
|
|
||||||
{"id": "training_volume", "title": "Trainingsvolumen (Wochen)", "default_window_days": 84, "window_max": 365},
|
|
||||||
{
|
|
||||||
"id": "training_type_distribution",
|
|
||||||
"title": "Trainingsart-Verteilung",
|
|
||||||
"default_window_days": 28,
|
|
||||||
"window_max": 90,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
"""Chart.js-ähnliche Payloads → PNG (Matplotlib). Von PDF- und Bundle-Rendering gemeinsam genutzt."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import io
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import matplotlib
|
|
||||||
|
|
||||||
matplotlib.use("Agg")
|
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
|
|
||||||
|
|
||||||
def _color_to_rgb(hex_or_rgba: str) -> tuple[float, float, float]:
|
|
||||||
s = (hex_or_rgba or "#333333").strip()
|
|
||||||
if s.startswith("#") and len(s) >= 7:
|
|
||||||
try:
|
|
||||||
r = int(s[1:3], 16) / 255.0
|
|
||||||
g = int(s[3:5], 16) / 255.0
|
|
||||||
b = int(s[5:7], 16) / 255.0
|
|
||||||
return (r, g, b)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return (0.12, 0.62, 0.46)
|
|
||||||
|
|
||||||
|
|
||||||
def chart_payload_to_png(payload: dict[str, Any], fig_width_in: float = 6.2, fig_height_in: float = 3.4) -> bytes:
|
|
||||||
chart_type = payload.get("chart_type") or "line"
|
|
||||||
data = payload.get("data") or {}
|
|
||||||
labels = data.get("labels") or []
|
|
||||||
datasets = data.get("datasets") or []
|
|
||||||
|
|
||||||
fig, ax = plt.subplots(figsize=(fig_width_in, fig_height_in), dpi=120)
|
|
||||||
ax.set_facecolor("#fafaf9")
|
|
||||||
fig.patch.set_facecolor("#ffffff")
|
|
||||||
|
|
||||||
if chart_type == "pie" and datasets:
|
|
||||||
ds0 = datasets[0]
|
|
||||||
values = ds0.get("data") or []
|
|
||||||
colors = ds0.get("backgroundColor") or ["#1D9E75", "#378ADD", "#D85A30"]
|
|
||||||
if labels and values and len(labels) == len(values):
|
|
||||||
ax.pie(values, labels=labels, autopct="%1.0f%%", colors=colors[: len(values)], startangle=90)
|
|
||||||
ax.axis("equal")
|
|
||||||
else:
|
|
||||||
ax.text(0.5, 0.5, "Keine Daten", ha="center", va="center", transform=ax.transAxes)
|
|
||||||
|
|
||||||
elif chart_type in ("line", "bar", "scatter") and datasets:
|
|
||||||
x = range(len(labels)) if labels else []
|
|
||||||
for i, ds in enumerate(datasets):
|
|
||||||
y = ds.get("data") or []
|
|
||||||
if not y:
|
|
||||||
continue
|
|
||||||
lab = ds.get("label") or f"Serie {i + 1}"
|
|
||||||
col = _color_to_rgb(str(ds.get("borderColor") or ds.get("backgroundColor") or "#1D9E75"))
|
|
||||||
if chart_type == "bar":
|
|
||||||
yv = y[: len(labels)] if labels else y
|
|
||||||
bg = ds.get("backgroundColor")
|
|
||||||
if isinstance(bg, list):
|
|
||||||
cols = [_color_to_rgb(str(c)) for c in bg[: len(yv)]]
|
|
||||||
else:
|
|
||||||
cols = [_color_to_rgb(str(bg or "#1D9E75"))] * len(yv)
|
|
||||||
ax.bar(list(range(len(yv))), yv, label=lab, color=cols[: len(yv)], alpha=0.88)
|
|
||||||
else:
|
|
||||||
ax.plot(
|
|
||||||
list(x)[: len(y)],
|
|
||||||
y,
|
|
||||||
label=lab,
|
|
||||||
color=col,
|
|
||||||
linewidth=1.6,
|
|
||||||
marker="o",
|
|
||||||
markersize=2,
|
|
||||||
)
|
|
||||||
if labels and chart_type != "bar":
|
|
||||||
step = max(1, len(labels) // 8)
|
|
||||||
ax.set_xticks(list(x)[::step])
|
|
||||||
ax.set_xticklabels([labels[j] for j in range(0, len(labels), step)], rotation=25, fontsize=7)
|
|
||||||
elif labels and chart_type == "bar":
|
|
||||||
ax.set_xticks(list(x))
|
|
||||||
ax.set_xticklabels(labels, rotation=30, fontsize=7)
|
|
||||||
ax.legend(loc="upper right", fontsize=7)
|
|
||||||
ax.grid(True, alpha=0.25)
|
|
||||||
ax.set_xmargin(0.02)
|
|
||||||
|
|
||||||
else:
|
|
||||||
ax.text(0.5, 0.5, "Diagrammtyp nicht unterstützt oder leer", ha="center", va="center", transform=ax.transAxes)
|
|
||||||
|
|
||||||
fig.tight_layout()
|
|
||||||
buf = io.BytesIO()
|
|
||||||
fig.savefig(buf, format="png", bbox_inches="tight", facecolor=fig.get_facecolor())
|
|
||||||
plt.close(fig)
|
|
||||||
buf.seek(0)
|
|
||||||
return buf.read()
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
"""
|
|
||||||
PDF-Bericht aus ReportProfilePayload: ReportLab für Text/Layout, Matplotlib für Chart-Payloads.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import io
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
from xml.sax.saxutils import escape
|
|
||||||
|
|
||||||
from reportlab.lib.pagesizes import A4
|
|
||||||
from reportlab.lib.styles import getSampleStyleSheet
|
|
||||||
from reportlab.lib.units import mm
|
|
||||||
from reportlab.platypus import Image as RLImage
|
|
||||||
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
|
|
||||||
|
|
||||||
from db import get_cursor, get_db
|
|
||||||
from report_chart_fetch import fetch_chart_payload
|
|
||||||
from report_chart_plotting import chart_payload_to_png
|
|
||||||
from report_profile_schema import (
|
|
||||||
AiInsightBlock,
|
|
||||||
ChartBlock,
|
|
||||||
ReportProfilePayload,
|
|
||||||
SectionBlock,
|
|
||||||
VizBundleBlock,
|
|
||||||
)
|
|
||||||
from report_viz_bundle_pdf import append_viz_bundle_to_story
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_CONTENT_TRUNCATE = 12000
|
|
||||||
|
|
||||||
|
|
||||||
def _insight_text(profile_id: str, insight_id: str | None) -> tuple[str, str]:
|
|
||||||
"""Returns (heading, body_text)."""
|
|
||||||
if not insight_id:
|
|
||||||
return (
|
|
||||||
"KI-Auswertung",
|
|
||||||
"(Noch keine Auswahl — in einer späteren Version kannst du hier eine gespeicherte KI-Analyse "
|
|
||||||
"verknüpfen.)",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"SELECT scope, content, created FROM ai_insights WHERE id = %s AND profile_id = %s",
|
|
||||||
(insight_id, profile_id),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return ("KI-Auswertung", "Eintrag nicht gefunden oder keine Berechtigung.")
|
|
||||||
scope = row.get("scope") or "Analyse"
|
|
||||||
content = row.get("content") or ""
|
|
||||||
if len(content) > _CONTENT_TRUNCATE:
|
|
||||||
content = content[:_CONTENT_TRUNCATE] + "\n\n[… gekürzt …]"
|
|
||||||
created = row.get("created")
|
|
||||||
sub = f"{scope}" + (f" · {created}" if created else "")
|
|
||||||
return (sub, content)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("report pdf insight load failed: %s", e)
|
|
||||||
return ("KI-Auswertung", "Fehler beim Laden des Eintrags.")
|
|
||||||
|
|
||||||
|
|
||||||
def build_structured_report_pdf(
|
|
||||||
*,
|
|
||||||
profile_id: str,
|
|
||||||
profile_name: str,
|
|
||||||
payload: ReportProfilePayload,
|
|
||||||
) -> bytes:
|
|
||||||
"""Vollständiges PDF als Bytes (A4)."""
|
|
||||||
buf = io.BytesIO()
|
|
||||||
doc = SimpleDocTemplate(
|
|
||||||
buf,
|
|
||||||
pagesize=A4,
|
|
||||||
leftMargin=14 * mm,
|
|
||||||
rightMargin=14 * mm,
|
|
||||||
topMargin=16 * mm,
|
|
||||||
bottomMargin=16 * mm,
|
|
||||||
)
|
|
||||||
styles = getSampleStyleSheet()
|
|
||||||
story: list[Any] = []
|
|
||||||
|
|
||||||
title = (payload.document_title or "").strip() or f"{profile_name} – Bericht"
|
|
||||||
story.append(Paragraph(escape(title), styles["Title"]))
|
|
||||||
story.append(Spacer(1, 6 * mm))
|
|
||||||
|
|
||||||
for block in payload.blocks:
|
|
||||||
if isinstance(block, SectionBlock):
|
|
||||||
story.append(Spacer(1, 4 * mm))
|
|
||||||
story.append(Paragraph(escape(block.title), styles["Heading2"]))
|
|
||||||
story.append(Spacer(1, 2 * mm))
|
|
||||||
elif isinstance(block, VizBundleBlock):
|
|
||||||
append_viz_bundle_to_story(story, styles, profile_id, block.bundle_id, block.config)
|
|
||||||
elif isinstance(block, ChartBlock):
|
|
||||||
try:
|
|
||||||
chart = fetch_chart_payload(block.chart_id, profile_id, block.window_days)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("chart fetch %s: %s", block.chart_id, e)
|
|
||||||
story.append(Paragraph(f"Diagramm {block.chart_id}: Fehler bei Daten.", styles["Normal"]))
|
|
||||||
continue
|
|
||||||
meta = chart.get("metadata") or {}
|
|
||||||
if meta.get("confidence") == "insufficient":
|
|
||||||
msg = meta.get("message") or "Nicht genug Daten"
|
|
||||||
story.append(Paragraph(f"<i>{block.chart_id}</i>: {msg}", styles["Normal"]))
|
|
||||||
story.append(Spacer(1, 3 * mm))
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
png = chart_payload_to_png(chart)
|
|
||||||
img_buf = io.BytesIO(png)
|
|
||||||
iw = 170 * mm
|
|
||||||
ih = 85 * mm
|
|
||||||
story.append(RLImage(img_buf, width=iw, height=ih))
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("chart render %s: %s", block.chart_id, e)
|
|
||||||
story.append(Paragraph(f"Diagramm {block.chart_id}: Darstellung fehlgeschlagen.", styles["Normal"]))
|
|
||||||
story.append(Spacer(1, 4 * mm))
|
|
||||||
elif isinstance(block, AiInsightBlock):
|
|
||||||
heading, body = _insight_text(profile_id, block.insight_id)
|
|
||||||
if block.title.strip():
|
|
||||||
story.append(Paragraph(escape(block.title), styles["Heading3"]))
|
|
||||||
else:
|
|
||||||
story.append(Paragraph(escape(heading), styles["Heading3"]))
|
|
||||||
for para in body.split("\n\n"):
|
|
||||||
p = (para or "").strip()
|
|
||||||
if p:
|
|
||||||
story.append(Paragraph(escape(p), styles["BodyText"]))
|
|
||||||
story.append(Spacer(1, 4 * mm))
|
|
||||||
|
|
||||||
doc.build(story)
|
|
||||||
return buf.getvalue()
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
"""
|
|
||||||
Konfigurierbarer PDF-Bericht v1: Payload-Schema (unabhängig vom Dashboard-Layout).
|
|
||||||
|
|
||||||
Block-Typen:
|
|
||||||
- section: Überschrift
|
|
||||||
- viz_bundle: Layer-2b-Ver bundles (KPIs, Text, Charts) — gleiche Config wie Dashboard
|
|
||||||
- chart: diagramm via report_chart_fetch (chart_id + window_days)
|
|
||||||
- ai_insight: optional insight_id (UUID), sonst Platzhalter für spätere Auswahl
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Literal, Union
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, model_validator
|
|
||||||
|
|
||||||
from dashboard_widget_config import validate_widget_entry_config
|
|
||||||
|
|
||||||
ALLOWED_CHART_IDS: frozenset[str] = frozenset(
|
|
||||||
{
|
|
||||||
"weight_trend",
|
|
||||||
"energy_balance",
|
|
||||||
"macro_distribution",
|
|
||||||
"training_volume",
|
|
||||||
"training_type_distribution",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
_MAX_BLOCKS = 32
|
|
||||||
|
|
||||||
ALLOWED_VIZ_BUNDLE_IDS: frozenset[str] = frozenset(
|
|
||||||
{
|
|
||||||
"body_history_viz",
|
|
||||||
"nutrition_history_viz",
|
|
||||||
"fitness_history_viz",
|
|
||||||
"recovery_history_viz",
|
|
||||||
"history_overview_viz",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SectionBlock(BaseModel):
|
|
||||||
type: Literal["section"] = "section"
|
|
||||||
title: str = Field(min_length=1, max_length=200)
|
|
||||||
|
|
||||||
|
|
||||||
class ChartBlock(BaseModel):
|
|
||||||
type: Literal["chart"] = "chart"
|
|
||||||
chart_id: str = Field(min_length=1, max_length=64)
|
|
||||||
window_days: int = Field(default=28, ge=7, le=365)
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def _chart_known(self) -> ChartBlock:
|
|
||||||
if self.chart_id not in ALLOWED_CHART_IDS:
|
|
||||||
raise ValueError(f"Unbekanntes chart_id: {self.chart_id!r} (erlaubt: {sorted(ALLOWED_CHART_IDS)})")
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
|
||||||
class AiInsightBlock(BaseModel):
|
|
||||||
type: Literal["ai_insight"] = "ai_insight"
|
|
||||||
title: str = Field(default="", max_length=200)
|
|
||||||
insight_id: str | None = Field(default=None, max_length=48)
|
|
||||||
|
|
||||||
|
|
||||||
class VizBundleBlock(BaseModel):
|
|
||||||
"""Gleiche Layer-2b-Bundles wie im Dashboard; config wie validate_widget_entry_config."""
|
|
||||||
|
|
||||||
type: Literal["viz_bundle"] = "viz_bundle"
|
|
||||||
bundle_id: str = Field(min_length=1, max_length=64)
|
|
||||||
config: dict[str, Any] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def _bundle_config(self) -> VizBundleBlock:
|
|
||||||
if self.bundle_id not in ALLOWED_VIZ_BUNDLE_IDS:
|
|
||||||
raise ValueError(
|
|
||||||
f"Unbekanntes bundle_id: {self.bundle_id!r} (erlaubt: {sorted(ALLOWED_VIZ_BUNDLE_IDS)})"
|
|
||||||
)
|
|
||||||
self.config = validate_widget_entry_config(self.bundle_id, self.config)
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
|
||||||
class ReportProfilePayload(BaseModel):
|
|
||||||
version: Literal[1] = 1
|
|
||||||
document_title: str = Field(default="", max_length=120)
|
|
||||||
blocks: list[Union[SectionBlock, ChartBlock, AiInsightBlock, VizBundleBlock]]
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def _blocks_limit(self) -> ReportProfilePayload:
|
|
||||||
if len(self.blocks) > _MAX_BLOCKS:
|
|
||||||
raise ValueError(f"Maximal {_MAX_BLOCKS} Blöcke erlaubt")
|
|
||||||
if not self.blocks:
|
|
||||||
raise ValueError("Mindestens ein Block erforderlich")
|
|
||||||
return self
|
|
||||||
|
|
||||||
def to_stored_dict(self) -> dict:
|
|
||||||
return {
|
|
||||||
"version": self.version,
|
|
||||||
"document_title": self.document_title,
|
|
||||||
"blocks": [b.model_dump(mode="json") for b in self.blocks],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def default_report_profile_dict() -> dict:
|
|
||||||
"""Standard-Bericht beim ersten Zugriff (ohne DB-Zeile)."""
|
|
||||||
p = ReportProfilePayload(
|
|
||||||
document_title="",
|
|
||||||
blocks=[
|
|
||||||
SectionBlock(title="Verlauf — Körper"),
|
|
||||||
VizBundleBlock(bundle_id="body_history_viz", config={"chart_days": 90}),
|
|
||||||
SectionBlock(title="Verlauf — Ernährung"),
|
|
||||||
VizBundleBlock(bundle_id="nutrition_history_viz", config={"chart_days": 90}),
|
|
||||||
SectionBlock(title="Verlauf — Fitness"),
|
|
||||||
VizBundleBlock(bundle_id="fitness_history_viz", config={"chart_days": 90}),
|
|
||||||
SectionBlock(title="Verlauf — Erholung"),
|
|
||||||
VizBundleBlock(bundle_id="recovery_history_viz", config={"chart_days": 90}),
|
|
||||||
SectionBlock(title="Gesamtübersicht"),
|
|
||||||
VizBundleBlock(bundle_id="history_overview_viz", config={"chart_days": 90}),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
return p.to_stored_dict()
|
|
||||||
|
|
||||||
|
|
||||||
def parse_report_profile(raw: dict | None) -> ReportProfilePayload:
|
|
||||||
if raw is None or raw == {}:
|
|
||||||
return ReportProfilePayload.model_validate(default_report_profile_dict())
|
|
||||||
return ReportProfilePayload.model_validate(raw)
|
|
||||||
|
|
||||||
|
|
@ -1,386 +0,0 @@
|
||||||
"""
|
|
||||||
Layer-2b Verlauf-Bundles → PDF-Abschnitte (KPIs + eingebettete Chart-Payloads).
|
|
||||||
|
|
||||||
Gleiche Datenquellen und Config-Validierung wie Dashboard-Widgets (dashboard_widget_config).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import io
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from reportlab.lib.units import mm
|
|
||||||
from reportlab.platypus import Image as RLImage
|
|
||||||
from reportlab.platypus import Paragraph, Spacer
|
|
||||||
from xml.sax.saxutils import escape
|
|
||||||
|
|
||||||
from dashboard_widget_config import validate_widget_entry_config
|
|
||||||
from data_layer.body_viz import get_body_history_viz_bundle
|
|
||||||
from data_layer.fitness_viz import get_fitness_dashboard_viz_bundle
|
|
||||||
from data_layer.history_overview_viz import get_history_overview_viz_bundle
|
|
||||||
from data_layer.nutrition_viz import get_nutrition_history_viz_bundle
|
|
||||||
from data_layer.recovery_viz import get_recovery_dashboard_viz_bundle
|
|
||||||
from data_layer.utils import safe_float
|
|
||||||
from report_chart_plotting import chart_payload_to_png
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
BUNDLE_HEADINGS: dict[str, str] = {
|
|
||||||
"body_history_viz": "Körper — Kennwerte & Verlauf",
|
|
||||||
"nutrition_history_viz": "Ernährung — Kennwerte & Charts",
|
|
||||||
"fitness_history_viz": "Fitness / Training",
|
|
||||||
"recovery_history_viz": "Erholung & Vitalwerte",
|
|
||||||
"history_overview_viz": "Gesamtübersicht & Korrelationen",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _add_chart_to_story(story: list, styles: dict, payload: dict[str, Any], caption: str | None = None) -> None:
|
|
||||||
meta = payload.get("metadata") or {}
|
|
||||||
if meta.get("confidence") == "insufficient":
|
|
||||||
msg = escape(meta.get("message") or "Keine Daten")
|
|
||||||
story.append(Paragraph(f"<i>{msg}</i>", styles["Normal"]))
|
|
||||||
story.append(Spacer(1, 2 * mm))
|
|
||||||
return
|
|
||||||
if caption:
|
|
||||||
story.append(Paragraph(f"<b>{escape(caption)}</b>", styles["Normal"]))
|
|
||||||
try:
|
|
||||||
png = chart_payload_to_png(payload)
|
|
||||||
story.append(RLImage(io.BytesIO(png), width=170 * mm, height=85 * mm))
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("bundle chart png: %s", e)
|
|
||||||
story.append(Paragraph("Diagramm konnte nicht gerendert werden.", styles["Normal"]))
|
|
||||||
story.append(Spacer(1, 4 * mm))
|
|
||||||
|
|
||||||
|
|
||||||
def _append_interpretation_tiles(story: list, styles: dict, tiles: list[dict[str, Any]]) -> None:
|
|
||||||
if not tiles:
|
|
||||||
return
|
|
||||||
story.append(Paragraph("<b>Einschätzungen</b>", styles["Heading4"]))
|
|
||||||
for t in tiles:
|
|
||||||
cat = escape(str(t.get("category") or t.get("title") or "—"))
|
|
||||||
title = t.get("title")
|
|
||||||
detail = t.get("detail")
|
|
||||||
val = t.get("value")
|
|
||||||
parts = [f"<b>{cat}</b>"]
|
|
||||||
if title and str(title) != str(cat):
|
|
||||||
parts.append(escape(str(title)))
|
|
||||||
if val is not None and val != "":
|
|
||||||
parts.append(f"({escape(str(val))})")
|
|
||||||
story.append(Paragraph(" — ".join(parts), styles["Normal"]))
|
|
||||||
if detail:
|
|
||||||
story.append(Paragraph(escape(str(detail)[:500]), styles["BodyText"]))
|
|
||||||
story.append(Spacer(1, 3 * mm))
|
|
||||||
|
|
||||||
|
|
||||||
def _append_kpi_tiles_fitness_nutreco(story: list, styles: dict, tiles: list[dict[str, Any]], compact: bool) -> None:
|
|
||||||
if not tiles:
|
|
||||||
return
|
|
||||||
use = tiles[:4] if compact else tiles
|
|
||||||
story.append(Paragraph("<b>KPI-Kacheln</b>", styles["Heading4"]))
|
|
||||||
for t in use:
|
|
||||||
cat = escape(str(t.get("category") or t.get("title") or "—"))
|
|
||||||
val = escape(str(t.get("value") or "—"))
|
|
||||||
sub = t.get("sublabel") or t.get("body")
|
|
||||||
line = f"• <b>{cat}</b>: {val}"
|
|
||||||
if sub:
|
|
||||||
line += f" — {escape(str(sub)[:180])}"
|
|
||||||
story.append(Paragraph(line, styles["Normal"]))
|
|
||||||
story.append(Spacer(1, 3 * mm))
|
|
||||||
|
|
||||||
|
|
||||||
def _append_insights_lines(story: list, styles: dict, insights: list[dict[str, Any]], label: str) -> None:
|
|
||||||
if not insights:
|
|
||||||
return
|
|
||||||
story.append(Paragraph(f"<b>{escape(label)}</b>", styles["Heading4"]))
|
|
||||||
for item in insights:
|
|
||||||
title = item.get("title") or item.get("heading")
|
|
||||||
body = item.get("body") or item.get("text")
|
|
||||||
if title:
|
|
||||||
story.append(Paragraph(escape(str(title)), styles["Normal"]))
|
|
||||||
if body:
|
|
||||||
story.append(Paragraph(escape(str(body)[:600]), styles["BodyText"]))
|
|
||||||
story.append(Spacer(1, 2 * mm))
|
|
||||||
|
|
||||||
|
|
||||||
def _weight_series_payload(bundle_weight: dict[str, Any]) -> dict[str, Any] | None:
|
|
||||||
series = bundle_weight.get("series") or []
|
|
||||||
if len(series) < 2:
|
|
||||||
return None
|
|
||||||
labels = [str(p.get("date") or "") for p in series]
|
|
||||||
datasets: list[dict[str, Any]] = [
|
|
||||||
{
|
|
||||||
"label": "Gewicht (kg)",
|
|
||||||
"data": [safe_float(p.get("weight")) for p in series],
|
|
||||||
"borderColor": "#1D9E75",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
if any(p.get("avg7") is not None for p in series):
|
|
||||||
datasets.append(
|
|
||||||
{
|
|
||||||
"label": "Ø 7T",
|
|
||||||
"data": [safe_float(p.get("avg7")) for p in series],
|
|
||||||
"borderColor": "#378ADD",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return {"chart_type": "line", "data": {"labels": labels, "datasets": datasets}, "metadata": {"confidence": "high"}}
|
|
||||||
|
|
||||||
|
|
||||||
def _line_payload_from_points(
|
|
||||||
points: list[dict[str, Any]],
|
|
||||||
x_key: str,
|
|
||||||
y_key: str,
|
|
||||||
label: str,
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
if len(points) < 2:
|
|
||||||
return None
|
|
||||||
labels = [str(p.get(x_key) or "") for p in points]
|
|
||||||
ys = [safe_float(p.get(y_key)) for p in points]
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {
|
|
||||||
"labels": labels,
|
|
||||||
"datasets": [{"label": label, "data": ys, "borderColor": "#1D9E75"}],
|
|
||||||
},
|
|
||||||
"metadata": {"confidence": "high"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _append_body_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None:
|
|
||||||
days = int(cfg.get("chart_days") or 30)
|
|
||||||
bundle = get_body_history_viz_bundle(profile_id, days)
|
|
||||||
story.append(Paragraph(escape(BUNDLE_HEADINGS["body_history_viz"]), styles["Heading2"]))
|
|
||||||
if bundle.get("confidence") == "insufficient":
|
|
||||||
story.append(Paragraph(escape(bundle.get("message") or "Keine Körperdaten"), styles["Normal"]))
|
|
||||||
story.append(Spacer(1, 4 * mm))
|
|
||||||
return
|
|
||||||
summ = bundle.get("summary") or {}
|
|
||||||
if summ:
|
|
||||||
w = summ.get("weight_kg")
|
|
||||||
bf = summ.get("body_fat_pct")
|
|
||||||
parts = []
|
|
||||||
if w is not None:
|
|
||||||
parts.append(f"Gewicht: {w} kg")
|
|
||||||
if bf is not None:
|
|
||||||
parts.append(f"KF%: {bf}")
|
|
||||||
if parts:
|
|
||||||
story.append(Paragraph(escape(" · ".join(parts)), styles["Normal"]))
|
|
||||||
story.append(Spacer(1, 2 * mm))
|
|
||||||
if cfg.get("show_kpis", True):
|
|
||||||
_append_interpretation_tiles(story, styles, bundle.get("interpretation_tiles") or [])
|
|
||||||
w = bundle.get("weight") or {}
|
|
||||||
if cfg.get("show_weight_chart", True):
|
|
||||||
pl = _weight_series_payload(w)
|
|
||||||
if pl:
|
|
||||||
_add_chart_to_story(story, styles, pl, "Gewicht")
|
|
||||||
cal = bundle.get("caliper") or {}
|
|
||||||
if cfg.get("show_body_fat_chart", False):
|
|
||||||
ser = cal.get("series") or []
|
|
||||||
pts = [{"date": p.get("date"), "y": p.get("body_fat_pct")} for p in ser if p.get("body_fat_pct") is not None]
|
|
||||||
pl = _line_payload_from_points(pts, "date", "y", "KF %")
|
|
||||||
if pl:
|
|
||||||
_add_chart_to_story(story, styles, pl, "Körperfett (Caliper)")
|
|
||||||
circ = bundle.get("circumference") or {}
|
|
||||||
if cfg.get("show_proportion_chart", False):
|
|
||||||
prop = circ.get("proportion_series") or []
|
|
||||||
pts = [{"date": p.get("date"), "y": p.get("v_taper_cm")} for p in prop if p.get("v_taper_cm") is not None]
|
|
||||||
pl = _line_payload_from_points(pts, "date", "y", "V-Taper (cm)")
|
|
||||||
if pl:
|
|
||||||
_add_chart_to_story(story, styles, pl, "Proportion (Brust–Taille)")
|
|
||||||
if cfg.get("show_circumference_index_chart", False):
|
|
||||||
idx = circ.get("index_series") or []
|
|
||||||
if len(idx) >= 2:
|
|
||||||
labels = [str(p.get("date") or "") for p in idx]
|
|
||||||
ds: list[dict[str, Any]] = []
|
|
||||||
for key, lab, col in (
|
|
||||||
("waist_idx", "Taille-Index", "#D85A30"),
|
|
||||||
("chest_idx", "Brust-Index", "#1D9E75"),
|
|
||||||
("belly_idx", "Bauch-Index", "#378ADD"),
|
|
||||||
):
|
|
||||||
ys = [safe_float(p.get(key)) for p in idx]
|
|
||||||
if any(v is not None for v in ys):
|
|
||||||
ds.append({"label": lab, "data": ys, "borderColor": col})
|
|
||||||
if ds:
|
|
||||||
pl = {"chart_type": "line", "data": {"labels": labels, "datasets": ds}, "metadata": {"confidence": "high"}}
|
|
||||||
_add_chart_to_story(story, styles, pl, "Umfang-Indizes")
|
|
||||||
story.append(Spacer(1, 2 * mm))
|
|
||||||
|
|
||||||
|
|
||||||
def _append_nutrition_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None:
|
|
||||||
days = int(cfg.get("chart_days") or 30)
|
|
||||||
bundle = get_nutrition_history_viz_bundle(profile_id, days)
|
|
||||||
story.append(Paragraph(escape(BUNDLE_HEADINGS["nutrition_history_viz"]), styles["Heading2"]))
|
|
||||||
if not bundle.get("has_nutrition_entries"):
|
|
||||||
story.append(Paragraph(escape(bundle.get("message") or "Keine Ernährungsdaten"), styles["Normal"]))
|
|
||||||
story.append(Spacer(1, 4 * mm))
|
|
||||||
return
|
|
||||||
compact = cfg.get("kpi_detail") == "compact"
|
|
||||||
if cfg.get("show_kpis", True):
|
|
||||||
_append_kpi_tiles_fitness_nutreco(story, styles, bundle.get("kpi_tiles") or [], compact)
|
|
||||||
if cfg.get("show_heuristics", False):
|
|
||||||
h = bundle.get("nutrition_correlation_heuristics") or []
|
|
||||||
for item in h:
|
|
||||||
t = item.get("text") or item.get("title")
|
|
||||||
if t:
|
|
||||||
story.append(Paragraph(f"• {escape(str(t))}", styles["Normal"]))
|
|
||||||
story.append(Spacer(1, 2 * mm))
|
|
||||||
charts = bundle.get("chart_payloads") or {}
|
|
||||||
if cfg.get("show_calorie_balance_chart", False) or cfg.get("show_energy_protein_charts", False):
|
|
||||||
pl = charts.get("energy_balance")
|
|
||||||
if pl:
|
|
||||||
_add_chart_to_story(story, styles, pl, "Energiebilanz")
|
|
||||||
if cfg.get("show_energy_protein_charts", False) or cfg.get("show_protein_lean_chart", False):
|
|
||||||
pl = charts.get("protein_adequacy")
|
|
||||||
if pl:
|
|
||||||
_add_chart_to_story(story, styles, pl, "Protein-Adäquanz")
|
|
||||||
pl2 = charts.get("nutrition_adherence")
|
|
||||||
if pl2:
|
|
||||||
_add_chart_to_story(story, styles, pl2, "Ernährungs-Adherence")
|
|
||||||
if cfg.get("show_macro_distribution_pair", False) or cfg.get("show_macro_daily_bars", False):
|
|
||||||
wm = bundle.get("weekly_macro_chart")
|
|
||||||
if isinstance(wm, dict) and wm.get("chart_type"):
|
|
||||||
_add_chart_to_story(story, styles, wm, "Makros (wöchentlich)")
|
|
||||||
kw = bundle.get("kcal_vs_weight") or {}
|
|
||||||
if cfg.get("show_kcal_vs_weight", False) and kw.get("points"):
|
|
||||||
pts = kw["points"]
|
|
||||||
if pts:
|
|
||||||
pl = _line_payload_from_points(pts, "date", "kcal", "kcal")
|
|
||||||
if pl:
|
|
||||||
_add_chart_to_story(story, styles, pl, "Kalorien vs. Zeit")
|
|
||||||
story.append(Spacer(1, 2 * mm))
|
|
||||||
|
|
||||||
|
|
||||||
def _append_fitness_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None:
|
|
||||||
days = int(cfg.get("chart_days") or 30)
|
|
||||||
bundle = get_fitness_dashboard_viz_bundle(profile_id, days)
|
|
||||||
story.append(Paragraph(escape(BUNDLE_HEADINGS["fitness_history_viz"]), styles["Heading2"]))
|
|
||||||
if not bundle.get("has_activity_entries"):
|
|
||||||
story.append(Paragraph(escape(bundle.get("message") or "Keine Aktivitätsdaten"), styles["Normal"]))
|
|
||||||
story.append(Spacer(1, 4 * mm))
|
|
||||||
return
|
|
||||||
compact = cfg.get("kpi_detail") == "compact"
|
|
||||||
if cfg.get("show_kpis", True):
|
|
||||||
_append_kpi_tiles_fitness_nutreco(story, styles, bundle.get("kpi_tiles") or [], compact)
|
|
||||||
if cfg.get("show_progress_insights", False):
|
|
||||||
_append_insights_lines(story, styles, bundle.get("progress_insights") or [], "Einschätzungen")
|
|
||||||
charts = bundle.get("charts") or {}
|
|
||||||
if cfg.get("show_chart_training_volume", True) and charts.get("training_volume"):
|
|
||||||
_add_chart_to_story(story, styles, charts["training_volume"], "Trainingsvolumen")
|
|
||||||
if cfg.get("show_chart_training_type_distribution", True) and charts.get("training_type_distribution"):
|
|
||||||
_add_chart_to_story(story, styles, charts["training_type_distribution"], "Trainingsarten")
|
|
||||||
if cfg.get("show_chart_quality_sessions", False) and charts.get("quality_sessions"):
|
|
||||||
_add_chart_to_story(story, styles, charts["quality_sessions"], "Qualitätssessions")
|
|
||||||
if cfg.get("show_chart_load_monitoring", False) and charts.get("load_monitoring"):
|
|
||||||
_add_chart_to_story(story, styles, charts["load_monitoring"], "Last / ACWR")
|
|
||||||
story.append(Spacer(1, 2 * mm))
|
|
||||||
|
|
||||||
|
|
||||||
def _append_recovery_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None:
|
|
||||||
days = int(cfg.get("chart_days") or 30)
|
|
||||||
bundle = get_recovery_dashboard_viz_bundle(profile_id, days)
|
|
||||||
story.append(Paragraph(escape(BUNDLE_HEADINGS["recovery_history_viz"]), styles["Heading2"]))
|
|
||||||
if not bundle.get("has_recovery_data"):
|
|
||||||
story.append(Paragraph(escape(bundle.get("message") or "Keine Erholungsdaten"), styles["Normal"]))
|
|
||||||
story.append(Spacer(1, 4 * mm))
|
|
||||||
return
|
|
||||||
compact = cfg.get("kpi_detail") == "compact"
|
|
||||||
if cfg.get("show_kpis", True):
|
|
||||||
_append_kpi_tiles_fitness_nutreco(story, styles, bundle.get("kpi_tiles") or [], compact)
|
|
||||||
if cfg.get("show_progress_insights", False):
|
|
||||||
_append_insights_lines(story, styles, bundle.get("progress_insights") or [], "Einschätzungen")
|
|
||||||
charts = bundle.get("charts") or {}
|
|
||||||
if cfg.get("show_chart_recovery_score", True) and charts.get("recovery_score"):
|
|
||||||
_add_chart_to_story(story, styles, charts["recovery_score"], "Recovery-Score")
|
|
||||||
if cfg.get("show_chart_hrv_rhr", True) and charts.get("hrv_rhr"):
|
|
||||||
_add_chart_to_story(story, styles, charts["hrv_rhr"], "HRV / RHR")
|
|
||||||
if cfg.get("show_chart_sleep_quality", True) and charts.get("sleep_duration_quality"):
|
|
||||||
_add_chart_to_story(story, styles, charts["sleep_duration_quality"], "Schlaf Dauer & Qualität")
|
|
||||||
if cfg.get("show_chart_sleep_debt", False) and charts.get("sleep_debt"):
|
|
||||||
_add_chart_to_story(story, styles, charts["sleep_debt"], "Schlafschuld")
|
|
||||||
if cfg.get("show_vitals_extra_trends", False):
|
|
||||||
if charts.get("vital_signs_matrix"):
|
|
||||||
_add_chart_to_story(story, styles, charts["vital_signs_matrix"], "Vital-Matrix")
|
|
||||||
if charts.get("vitals_history"):
|
|
||||||
_add_chart_to_story(story, styles, charts["vitals_history"], "Vital-Trends")
|
|
||||||
story.append(Spacer(1, 2 * mm))
|
|
||||||
|
|
||||||
|
|
||||||
def _append_history_overview_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None:
|
|
||||||
days = int(cfg.get("chart_days") or 30)
|
|
||||||
bundle = get_history_overview_viz_bundle(profile_id, days)
|
|
||||||
story.append(Paragraph(escape(BUNDLE_HEADINGS["history_overview_viz"]), styles["Heading2"]))
|
|
||||||
sect_keys = {
|
|
||||||
"body": cfg.get("show_section_body", True),
|
|
||||||
"nutrition": cfg.get("show_section_nutrition", True),
|
|
||||||
"fitness": cfg.get("show_section_fitness", True),
|
|
||||||
"recovery": cfg.get("show_section_recovery", True),
|
|
||||||
}
|
|
||||||
for sec in bundle.get("sections") or []:
|
|
||||||
sid = sec.get("id")
|
|
||||||
if not sect_keys.get(str(sid), True):
|
|
||||||
continue
|
|
||||||
title = escape(str(sec.get("title") or sid))
|
|
||||||
line = escape(str(sec.get("summary_line") or ""))
|
|
||||||
story.append(Paragraph(f"<b>{title}</b>: {line}", styles["Normal"]))
|
|
||||||
for it in sec.get("interpretation_short") or []:
|
|
||||||
t = it.get("title") if isinstance(it, dict) else None
|
|
||||||
if t:
|
|
||||||
story.append(Paragraph(f"• {escape(str(t))}", styles["BodyText"]))
|
|
||||||
for k in sec.get("kpi_short") or []:
|
|
||||||
if isinstance(k, dict):
|
|
||||||
cat = k.get("category") or k.get("title")
|
|
||||||
val = k.get("value")
|
|
||||||
if cat:
|
|
||||||
story.append(Paragraph(f"• {escape(str(cat))}: {escape(str(val or ''))}", styles["BodyText"]))
|
|
||||||
story.append(Spacer(1, 2 * mm))
|
|
||||||
if cfg.get("show_correlation_c1_c3", True) or cfg.get("show_drivers_c4", True):
|
|
||||||
lag = bundle.get("lag_correlations") or {}
|
|
||||||
we = lag.get("weight_energy") or {}
|
|
||||||
if we.get("available") and (we.get("interpretation") or we.get("label")):
|
|
||||||
lab = escape(str(we.get("label") or "C1"))
|
|
||||||
interp = escape(str(we.get("interpretation") or "").strip())
|
|
||||||
if interp:
|
|
||||||
story.append(Paragraph(f"{lab}: {interp}", styles["Normal"]))
|
|
||||||
charts = bundle.get("chart_payloads") or {}
|
|
||||||
if cfg.get("show_correlation_c1_c3", True):
|
|
||||||
for key, cap in (
|
|
||||||
("c1_weight_energy", "Korrelation Gewicht / Energie"),
|
|
||||||
("c2_protein_lbm", "Protein / Magermasse"),
|
|
||||||
("c3_load_vitals", "Last / Vitalwerte"),
|
|
||||||
):
|
|
||||||
pl = charts.get(key)
|
|
||||||
if pl:
|
|
||||||
_add_chart_to_story(story, styles, pl, cap)
|
|
||||||
if cfg.get("show_drivers_c4", True):
|
|
||||||
pl = charts.get("c4_recovery_performance")
|
|
||||||
if pl:
|
|
||||||
_add_chart_to_story(story, styles, pl, "Top-Treiber")
|
|
||||||
drv = (bundle.get("lag_correlations") or {}).get("recovery_performance") or {}
|
|
||||||
for d in (drv.get("drivers") or [])[:12]:
|
|
||||||
if isinstance(d, dict):
|
|
||||||
lab = d.get("label") or d.get("factor")
|
|
||||||
val = d.get("impact") or d.get("score")
|
|
||||||
if lab:
|
|
||||||
story.append(Paragraph(f"• {escape(str(lab))}: {escape(str(val or ''))}", styles["Normal"]))
|
|
||||||
story.append(Spacer(1, 2 * mm))
|
|
||||||
|
|
||||||
|
|
||||||
def append_viz_bundle_to_story(
|
|
||||||
story: list,
|
|
||||||
styles: dict,
|
|
||||||
profile_id: str,
|
|
||||||
bundle_id: str,
|
|
||||||
raw_config: dict[str, Any],
|
|
||||||
) -> None:
|
|
||||||
cfg = validate_widget_entry_config(bundle_id, raw_config)
|
|
||||||
if bundle_id == "body_history_viz":
|
|
||||||
_append_body_bundle(story, styles, profile_id, cfg)
|
|
||||||
elif bundle_id == "nutrition_history_viz":
|
|
||||||
_append_nutrition_bundle(story, styles, profile_id, cfg)
|
|
||||||
elif bundle_id == "fitness_history_viz":
|
|
||||||
_append_fitness_bundle(story, styles, profile_id, cfg)
|
|
||||||
elif bundle_id == "recovery_history_viz":
|
|
||||||
_append_recovery_bundle(story, styles, profile_id, cfg)
|
|
||||||
elif bundle_id == "history_overview_viz":
|
|
||||||
_append_history_overview_bundle(story, styles, profile_id, cfg)
|
|
||||||
else:
|
|
||||||
story.append(Paragraph(escape(f"Unbekanntes Bundle: {bundle_id}"), styles["Normal"]))
|
|
||||||
|
|
@ -9,6 +9,3 @@ bcrypt==4.1.3
|
||||||
slowapi==0.1.9
|
slowapi==0.1.9
|
||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
python-dateutil==2.9.0
|
python-dateutil==2.9.0
|
||||||
tzdata>=2024.1 # ZoneInfo (Europe/Berlin) auch unter Windows
|
|
||||||
matplotlib==3.8.4
|
|
||||||
reportlab==4.2.0
|
|
||||||
|
|
|
||||||
|
|
@ -7,76 +7,16 @@ import csv
|
||||||
import io
|
import io
|
||||||
import uuid
|
import uuid
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
import calendar
|
|
||||||
from datetime import date
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends, Query
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends, Query
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
from auth import require_auth, check_feature_access, increment_feature_usage
|
||||||
from models import ActivityEntry, ActivityMetricsReplace
|
from models import ActivityEntry
|
||||||
from routers.profiles import get_pid
|
from routers.profiles import get_pid
|
||||||
from feature_logger import log_feature_usage
|
from feature_logger import log_feature_usage
|
||||||
from quality_filter import get_quality_filter_sql
|
from quality_filter import get_quality_filter_sql
|
||||||
from data_layer.activity_persistence_orchestrator import (
|
|
||||||
get_mappable_activity_field_catalog,
|
|
||||||
insert_activity_from_entry,
|
|
||||||
run_activity_post_write_hooks,
|
|
||||||
update_activity_from_entry,
|
|
||||||
find_activity_duplicate_id,
|
|
||||||
update_activity_columns,
|
|
||||||
insert_activity_csv_minimal,
|
|
||||||
run_activity_post_write_hooks_import,
|
|
||||||
new_activity_id,
|
|
||||||
)
|
|
||||||
from data_layer.activity_time_normalize import normalize_activity_start
|
|
||||||
from data_layer.activity_session_metrics import enrich_sessions_with_metrics
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/activity", tags=["activity"])
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_MONTH_RE = re.compile(r"^(\d{4})-(\d{2})$")
|
|
||||||
|
|
||||||
|
|
||||||
def _month_date_bounds(ym: str) -> tuple[date, date]:
|
|
||||||
m = _MONTH_RE.match((ym or "").strip())
|
|
||||||
if not m:
|
|
||||||
raise HTTPException(status_code=400, detail="month muss YYYY-MM sein")
|
|
||||||
y, mo = int(m.group(1)), int(m.group(2))
|
|
||||||
if mo < 1 or mo > 12:
|
|
||||||
raise HTTPException(status_code=400, detail="Ungültiger Monat")
|
|
||||||
last = calendar.monthrange(y, mo)[1]
|
|
||||||
return date(y, mo, 1), date(y, mo, last)
|
|
||||||
|
|
||||||
|
|
||||||
_ACTIVITY_DEDUP_WINDOW = """
|
|
||||||
PARTITION BY al.profile_id, al.date,
|
|
||||||
COALESCE(al.activity_type, ''),
|
|
||||||
COALESCE(al.start_time::text, ''),
|
|
||||||
COALESCE(ROUND(al.duration_min::numeric, 1), '-999999'::numeric),
|
|
||||||
COALESCE(ROUND(al.kcal_active::numeric, 1), '-999999'::numeric)
|
|
||||||
ORDER BY al.created DESC NULLS LAST, al.id DESC
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def _activity_rows_after_list_query(cur):
|
|
||||||
rows = []
|
|
||||||
for r in cur.fetchall():
|
|
||||||
d = r2d(r)
|
|
||||||
if not d:
|
|
||||||
continue
|
|
||||||
d.pop("_dup_rn", None)
|
|
||||||
rows.append(d)
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def _return_activity_list_rows(cur, rows: list) -> list:
|
|
||||||
"""Layer-1: gemergte session_metrics wie Detail-Pfad (Batch)."""
|
|
||||||
enrich_sessions_with_metrics(cur, rows)
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
# Evaluation import with error handling (Phase 1.2)
|
# Evaluation import with error handling (Phase 1.2)
|
||||||
try:
|
try:
|
||||||
|
|
@ -87,143 +27,51 @@ except Exception as e:
|
||||||
EVALUATION_AVAILABLE = False
|
EVALUATION_AVAILABLE = False
|
||||||
evaluate_and_save_activity = None
|
evaluate_and_save_activity = None
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/activity", tags=["activity"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def list_activity(
|
def list_activity(
|
||||||
limit: int = Query(200, ge=1, le=50_000),
|
limit: int = Query(200, ge=1, le=50_000),
|
||||||
offset: int = Query(0, ge=0, le=100_000, description="SQL OFFSET für Pagination"),
|
|
||||||
days: Optional[int] = Query(None, ge=1, le=4000, description="Nur Einträge mit date >= HEUTE − days (Kalendertage)"),
|
days: Optional[int] = Query(None, ge=1, le=4000, description="Nur Einträge mit date >= HEUTE − days (Kalendertage)"),
|
||||||
month: Optional[str] = Query(
|
x_profile_id: Optional[str] = Header(default=None),
|
||||||
None,
|
|
||||||
description='Kalendermonat "YYYY-MM" (ganzer Monat; schließt days und offset aus)',
|
|
||||||
),
|
|
||||||
skip_quality_filter: bool = Query(
|
|
||||||
False,
|
|
||||||
description="True = alle Einträge des Profils (ohne quality_label-Filter). Für /activity Erfassung.",
|
|
||||||
),
|
|
||||||
collapse_duplicate_sessions: bool = Query(
|
|
||||||
False,
|
|
||||||
description="True = Sessions mit gleichem Datum/Typ/Startzeit/Dauer/Kcal falten (neueste Zeile behalten).",
|
|
||||||
),
|
|
||||||
session: dict = Depends(require_auth),
|
session: dict = Depends(require_auth),
|
||||||
):
|
):
|
||||||
"""Get activity entries for current profile. Optional *days* filter by calendar window (not the same as *limit*)."""
|
"""Get activity entries for current profile. Optional *days* filter by calendar window (not the same as *limit*)."""
|
||||||
# Immer das Profil der gültigen Session (X-Profile-Id wird hier nicht verwendet).
|
pid = get_pid(x_profile_id)
|
||||||
pid = str(session["profile_id"])
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
# Issue #31: Qualitätsfilter — auf der Erfassungsseite /activity abschaltbar (skip_quality_filter)
|
# Issue #31: Apply global quality filter (profile from DB = saved level)
|
||||||
if skip_quality_filter:
|
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
||||||
quality_filter = ""
|
profile = r2d(cur.fetchone())
|
||||||
quality_filter_al = ""
|
quality_filter = get_quality_filter_sql(profile)
|
||||||
else:
|
|
||||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
|
||||||
profile = r2d(cur.fetchone())
|
|
||||||
quality_filter = get_quality_filter_sql(profile or {}, "")
|
|
||||||
quality_filter_al = get_quality_filter_sql(profile or {}, "al.")
|
|
||||||
|
|
||||||
if month:
|
|
||||||
if days is not None:
|
|
||||||
raise HTTPException(status_code=400, detail="month und days schließen sich aus")
|
|
||||||
if offset != 0:
|
|
||||||
raise HTTPException(status_code=400, detail="month und offset schließen sich aus")
|
|
||||||
d0, d1 = _month_date_bounds(month)
|
|
||||||
if collapse_duplicate_sessions:
|
|
||||||
cur.execute(
|
|
||||||
f"""
|
|
||||||
SELECT d.* FROM (
|
|
||||||
SELECT al.*, ROW_NUMBER() OVER (
|
|
||||||
{_ACTIVITY_DEDUP_WINDOW}
|
|
||||||
) AS _dup_rn
|
|
||||||
FROM activity_log al
|
|
||||||
WHERE al.profile_id = %s
|
|
||||||
{quality_filter_al}
|
|
||||||
AND al.date >= %s AND al.date <= %s
|
|
||||||
) d
|
|
||||||
WHERE d._dup_rn = 1
|
|
||||||
ORDER BY d.date DESC, d.start_time DESC NULLS LAST, d.id DESC
|
|
||||||
LIMIT %s
|
|
||||||
""",
|
|
||||||
(pid, d0, d1, limit),
|
|
||||||
)
|
|
||||||
return _return_activity_list_rows(cur, _activity_rows_after_list_query(cur))
|
|
||||||
cur.execute(
|
|
||||||
f"""
|
|
||||||
SELECT * FROM activity_log
|
|
||||||
WHERE profile_id=%s
|
|
||||||
{quality_filter}
|
|
||||||
AND date >= %s AND date <= %s
|
|
||||||
ORDER BY date DESC, start_time DESC NULLS LAST, id DESC
|
|
||||||
LIMIT %s
|
|
||||||
""",
|
|
||||||
(pid, d0, d1, limit),
|
|
||||||
)
|
|
||||||
return _return_activity_list_rows(
|
|
||||||
cur, [r2d(r) for r in cur.fetchall()]
|
|
||||||
)
|
|
||||||
|
|
||||||
if days is not None:
|
if days is not None:
|
||||||
if collapse_duplicate_sessions:
|
|
||||||
cur.execute(
|
|
||||||
f"""
|
|
||||||
SELECT d.* FROM (
|
|
||||||
SELECT al.*, ROW_NUMBER() OVER (
|
|
||||||
{_ACTIVITY_DEDUP_WINDOW}
|
|
||||||
) AS _dup_rn
|
|
||||||
FROM activity_log al
|
|
||||||
WHERE al.profile_id = %s
|
|
||||||
{quality_filter_al}
|
|
||||||
AND al.date >= (CURRENT_DATE - %s::integer)
|
|
||||||
) d
|
|
||||||
WHERE d._dup_rn = 1
|
|
||||||
ORDER BY d.date DESC, d.start_time DESC NULLS LAST, d.id DESC
|
|
||||||
LIMIT %s OFFSET %s
|
|
||||||
""",
|
|
||||||
(pid, days, limit, offset),
|
|
||||||
)
|
|
||||||
return _return_activity_list_rows(cur, _activity_rows_after_list_query(cur))
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT * FROM activity_log
|
SELECT * FROM activity_log
|
||||||
WHERE profile_id=%s
|
WHERE profile_id=%s
|
||||||
{quality_filter}
|
{quality_filter}
|
||||||
AND date >= (CURRENT_DATE - %s::integer)
|
AND date >= (CURRENT_DATE - %s::integer)
|
||||||
ORDER BY date DESC, start_time DESC NULLS LAST, id DESC
|
ORDER BY date DESC, start_time DESC
|
||||||
LIMIT %s OFFSET %s
|
LIMIT %s
|
||||||
""",
|
""",
|
||||||
(pid, days, limit, offset),
|
(pid, days, limit),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if collapse_duplicate_sessions:
|
|
||||||
cur.execute(
|
|
||||||
f"""
|
|
||||||
SELECT d.* FROM (
|
|
||||||
SELECT al.*, ROW_NUMBER() OVER (
|
|
||||||
{_ACTIVITY_DEDUP_WINDOW}
|
|
||||||
) AS _dup_rn
|
|
||||||
FROM activity_log al
|
|
||||||
WHERE al.profile_id = %s
|
|
||||||
{quality_filter_al}
|
|
||||||
) d
|
|
||||||
WHERE d._dup_rn = 1
|
|
||||||
ORDER BY d.date DESC, d.start_time DESC NULLS LAST, d.id DESC
|
|
||||||
LIMIT %s OFFSET %s
|
|
||||||
""",
|
|
||||||
(pid, limit, offset),
|
|
||||||
)
|
|
||||||
return _return_activity_list_rows(cur, _activity_rows_after_list_query(cur))
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT * FROM activity_log
|
SELECT * FROM activity_log
|
||||||
WHERE profile_id=%s
|
WHERE profile_id=%s
|
||||||
{quality_filter}
|
{quality_filter}
|
||||||
ORDER BY date DESC, start_time DESC NULLS LAST, id DESC
|
ORDER BY date DESC, start_time DESC
|
||||||
LIMIT %s OFFSET %s
|
LIMIT %s
|
||||||
""",
|
""",
|
||||||
(pid, limit, offset),
|
(pid, limit),
|
||||||
)
|
)
|
||||||
return _return_activity_list_rows(cur, [r2d(r) for r in cur.fetchall()])
|
return [r2d(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
@router.post("")
|
||||||
|
|
@ -247,10 +95,37 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default
|
||||||
)
|
)
|
||||||
|
|
||||||
eid = str(uuid.uuid4())
|
eid = str(uuid.uuid4())
|
||||||
|
d = e.model_dump()
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
insert_activity_from_entry(cur, pid, eid, e)
|
cur.execute("""INSERT INTO activity_log
|
||||||
run_activity_post_write_hooks(cur, pid, eid)
|
(id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting,
|
||||||
|
hr_avg,hr_max,distance_km,rpe,source,notes,created)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
|
||||||
|
(eid,pid,d['date'],d['start_time'],d['end_time'],d['activity_type'],d['duration_min'],
|
||||||
|
d['kcal_active'],d['kcal_resting'],d['hr_avg'],d['hr_max'],d['distance_km'],
|
||||||
|
d['rpe'],d['source'],d['notes']))
|
||||||
|
|
||||||
|
# Phase 1.2: Auto-evaluation after INSERT
|
||||||
|
if EVALUATION_AVAILABLE:
|
||||||
|
# Load the activity data to evaluate
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, profile_id, date, training_type_id, duration_min,
|
||||||
|
hr_avg, hr_max, distance_km, kcal_active, kcal_resting,
|
||||||
|
rpe, pace_min_per_km, cadence, elevation_gain
|
||||||
|
FROM activity_log
|
||||||
|
WHERE id = %s
|
||||||
|
""", (eid,))
|
||||||
|
activity_row = cur.fetchone()
|
||||||
|
if activity_row:
|
||||||
|
activity_dict = dict(activity_row)
|
||||||
|
training_type_id = activity_dict.get("training_type_id")
|
||||||
|
if training_type_id:
|
||||||
|
try:
|
||||||
|
evaluate_and_save_activity(cur, eid, activity_dict, training_type_id, pid)
|
||||||
|
logger.info(f"[AUTO-EVAL] Evaluated activity {eid} on INSERT")
|
||||||
|
except Exception as eval_error:
|
||||||
|
logger.error(f"[AUTO-EVAL] Failed to evaluate activity {eid}: {eval_error}")
|
||||||
|
|
||||||
# Phase 2: Increment usage counter (always for new entries)
|
# Phase 2: Increment usage counter (always for new entries)
|
||||||
increment_feature_usage(pid, 'activity_entries')
|
increment_feature_usage(pid, 'activity_entries')
|
||||||
|
|
@ -258,146 +133,36 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default
|
||||||
return {"id":eid,"date":e.date}
|
return {"id":eid,"date":e.date}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
|
||||||
def activity_stats(
|
|
||||||
skip_quality_filter: bool = Query(
|
|
||||||
False,
|
|
||||||
description="True = Statistik-Kacheln ohne Profil-Qualitätsfilter (passend zur /activity-Liste).",
|
|
||||||
),
|
|
||||||
session: dict = Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""Get activity statistics (last 30 entries)."""
|
|
||||||
pid = str(session["profile_id"])
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
if skip_quality_filter:
|
|
||||||
quality_filter = ""
|
|
||||||
else:
|
|
||||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
|
||||||
profile = r2d(cur.fetchone())
|
|
||||||
quality_filter = get_quality_filter_sql(profile or {}, "")
|
|
||||||
cur.execute(
|
|
||||||
f"SELECT COUNT(*)::bigint AS c FROM activity_log WHERE profile_id=%s {quality_filter}",
|
|
||||||
(pid,),
|
|
||||||
)
|
|
||||||
total_in_profile = int(cur.fetchone()["c"])
|
|
||||||
if skip_quality_filter:
|
|
||||||
cur.execute(
|
|
||||||
f"""
|
|
||||||
SELECT d.* FROM (
|
|
||||||
SELECT al.*, ROW_NUMBER() OVER (
|
|
||||||
{_ACTIVITY_DEDUP_WINDOW}
|
|
||||||
) AS _dup_rn
|
|
||||||
FROM activity_log al
|
|
||||||
WHERE al.profile_id = %s
|
|
||||||
) d
|
|
||||||
WHERE d._dup_rn = 1
|
|
||||||
ORDER BY d.date DESC, d.start_time DESC NULLS LAST, d.id DESC
|
|
||||||
LIMIT 30
|
|
||||||
""",
|
|
||||||
(pid,),
|
|
||||||
)
|
|
||||||
rows = _activity_rows_after_list_query(cur)
|
|
||||||
else:
|
|
||||||
cur.execute(
|
|
||||||
f"""
|
|
||||||
SELECT * FROM activity_log
|
|
||||||
WHERE profile_id=%s {quality_filter}
|
|
||||||
ORDER BY date DESC, start_time DESC NULLS LAST, id DESC
|
|
||||||
LIMIT 30
|
|
||||||
""",
|
|
||||||
(pid,),
|
|
||||||
)
|
|
||||||
rows = [r2d(r) for r in cur.fetchall()]
|
|
||||||
if not rows:
|
|
||||||
return {
|
|
||||||
"count": 0,
|
|
||||||
"sample_size": 0,
|
|
||||||
"total_in_profile": total_in_profile,
|
|
||||||
"total_kcal": 0,
|
|
||||||
"total_min": 0,
|
|
||||||
"by_type": {},
|
|
||||||
}
|
|
||||||
total_kcal = sum(float(r.get("kcal_active") or 0) for r in rows)
|
|
||||||
total_min = sum(float(r.get("duration_min") or 0) for r in rows)
|
|
||||||
by_type = {}
|
|
||||||
for r in rows:
|
|
||||||
t = r["activity_type"]
|
|
||||||
by_type.setdefault(t, {"count": 0, "kcal": 0, "min": 0})
|
|
||||||
by_type[t]["count"] += 1
|
|
||||||
by_type[t]["kcal"] += float(r.get("kcal_active") or 0)
|
|
||||||
by_type[t]["min"] += float(r.get("duration_min") or 0)
|
|
||||||
return {
|
|
||||||
"count": len(rows),
|
|
||||||
"sample_size": len(rows),
|
|
||||||
"total_in_profile": total_in_profile,
|
|
||||||
"total_kcal": round(total_kcal),
|
|
||||||
"total_min": round(total_min),
|
|
||||||
"by_type": by_type,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/uncategorized")
|
|
||||||
def list_uncategorized_activities(
|
|
||||||
session: dict = Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""Get activities without assigned training type, grouped by activity_type."""
|
|
||||||
pid = str(session["profile_id"])
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT activity_type, COUNT(*) as count,
|
|
||||||
MIN(date) as first_date, MAX(date) as last_date
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id=%s AND training_type_id IS NULL
|
|
||||||
GROUP BY activity_type
|
|
||||||
ORDER BY count DESC
|
|
||||||
""",
|
|
||||||
(pid,),
|
|
||||||
)
|
|
||||||
return [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/mappable-fields")
|
|
||||||
def get_activity_mappable_fields(session: dict = Depends(require_auth)):
|
|
||||||
"""
|
|
||||||
Vollständiger Katalog für Import-Mappings (activity_log-Kernfelder + alle aktiven training_parameters).
|
|
||||||
Werte für Keys ohne Schema zur konkreten Session werden beim Import ignoriert.
|
|
||||||
"""
|
|
||||||
pid = str(session["profile_id"])
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
return get_mappable_activity_field_catalog(cur, pid)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/attribute-schema")
|
|
||||||
def get_activity_attribute_schema(
|
|
||||||
training_category: Optional[str] = Query(None),
|
|
||||||
training_type_id: Optional[int] = Query(None),
|
|
||||||
session: dict = Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Aufgelöstes Attributprofil (tcp/ttp) für Erfassung ohne bestehende Session —
|
|
||||||
gleiche Logik wie resolve_activity_attribute_schema.
|
|
||||||
"""
|
|
||||||
from data_layer.activity_session_metrics import resolve_activity_attribute_schema
|
|
||||||
|
|
||||||
cat = (training_category or "").strip() or None
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
schema = resolve_activity_attribute_schema(cur, cat, training_type_id)
|
|
||||||
return {"schema": schema}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{eid}")
|
@router.put("/{eid}")
|
||||||
def update_activity(eid: str, e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
def update_activity(eid: str, e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||||
"""Update existing activity entry."""
|
"""Update existing activity entry."""
|
||||||
pid = get_pid(x_profile_id)
|
pid = get_pid(x_profile_id)
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
|
d = e.model_dump()
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
update_activity_from_entry(cur, pid, eid, e)
|
cur.execute(f"UPDATE activity_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s",
|
||||||
run_activity_post_write_hooks(cur, pid, eid)
|
list(d.values())+[eid,pid])
|
||||||
|
|
||||||
|
# Phase 1.2: Auto-evaluation after UPDATE
|
||||||
|
if EVALUATION_AVAILABLE:
|
||||||
|
# Load the updated activity data to evaluate
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, profile_id, date, training_type_id, duration_min,
|
||||||
|
hr_avg, hr_max, distance_km, kcal_active, kcal_resting,
|
||||||
|
rpe, pace_min_per_km, cadence, elevation_gain
|
||||||
|
FROM activity_log
|
||||||
|
WHERE id = %s
|
||||||
|
""", (eid,))
|
||||||
|
activity_row = cur.fetchone()
|
||||||
|
if activity_row:
|
||||||
|
activity_dict = dict(activity_row)
|
||||||
|
training_type_id = activity_dict.get("training_type_id")
|
||||||
|
if training_type_id:
|
||||||
|
try:
|
||||||
|
evaluate_and_save_activity(cur, eid, activity_dict, training_type_id, pid)
|
||||||
|
logger.info(f"[AUTO-EVAL] Re-evaluated activity {eid} on UPDATE")
|
||||||
|
except Exception as eval_error:
|
||||||
|
logger.error(f"[AUTO-EVAL] Failed to re-evaluate activity {eid}: {eval_error}")
|
||||||
|
|
||||||
return {"id":eid}
|
return {"id":eid}
|
||||||
|
|
||||||
|
|
@ -412,66 +177,25 @@ def delete_activity(eid: str, x_profile_id: Optional[str]=Header(default=None),
|
||||||
return {"ok":True}
|
return {"ok":True}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{eid}/metrics")
|
@router.get("/stats")
|
||||||
def replace_activity_metrics(
|
def activity_stats(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||||
eid: str,
|
"""Get activity statistics (last 30 entries)."""
|
||||||
body: ActivityMetricsReplace,
|
pid = get_pid(x_profile_id)
|
||||||
session: dict = Depends(require_auth),
|
with get_db() as conn:
|
||||||
):
|
cur = get_cursor(conn)
|
||||||
"""
|
cur.execute(
|
||||||
Voller Ersatz der EAV-Session-Metriken (siehe ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md).
|
"SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
|
||||||
"""
|
rows = [r2d(r) for r in cur.fetchall()]
|
||||||
from data_layer.activity_session_metrics import (
|
if not rows: return {"count":0,"total_kcal":0,"total_min":0,"by_type":{}}
|
||||||
ActivitySessionMetricsError,
|
total_kcal=sum(float(r.get('kcal_active') or 0) for r in rows)
|
||||||
replace_activity_session_metrics,
|
total_min=sum(float(r.get('duration_min') or 0) for r in rows)
|
||||||
)
|
by_type={}
|
||||||
|
for r in rows:
|
||||||
pid = str(session["profile_id"])
|
t=r['activity_type']; by_type.setdefault(t,{'count':0,'kcal':0,'min':0})
|
||||||
payload = [m.model_dump() for m in body.metrics]
|
by_type[t]['count']+=1
|
||||||
try:
|
by_type[t]['kcal']+=float(r.get('kcal_active') or 0)
|
||||||
with get_db() as conn:
|
by_type[t]['min']+=float(r.get('duration_min') or 0)
|
||||||
cur = get_cursor(conn)
|
return {"count":len(rows),"total_kcal":round(total_kcal),"total_min":round(total_min),"by_type":by_type}
|
||||||
metrics = replace_activity_session_metrics(cur, pid, eid, payload)
|
|
||||||
conn.commit()
|
|
||||||
except ActivitySessionMetricsError as err:
|
|
||||||
raise HTTPException(err.status_code, err.detail) from err
|
|
||||||
return {"id": eid, "metrics": metrics}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{eid}")
|
|
||||||
def get_activity_session(
|
|
||||||
eid: str,
|
|
||||||
use_form_schema: bool = Query(
|
|
||||||
False,
|
|
||||||
description="True: Schema aus Query training_category / training_type_id (Formular), nicht nur DB-Zeile",
|
|
||||||
),
|
|
||||||
training_category: Optional[str] = Query(None),
|
|
||||||
training_type_id: Optional[int] = Query(None),
|
|
||||||
session: dict = Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""Session-Kopf + aufgelöstes Schema + EAV-Metriken (Layer 1)."""
|
|
||||||
from data_layer.activity_session_metrics import (
|
|
||||||
ActivitySessionMetricsError,
|
|
||||||
get_activity_session_logical_unit,
|
|
||||||
)
|
|
||||||
from data_layer.utils import serialize_dates
|
|
||||||
|
|
||||||
pid = str(session["profile_id"])
|
|
||||||
try:
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
unit = get_activity_session_logical_unit(
|
|
||||||
cur,
|
|
||||||
pid,
|
|
||||||
eid,
|
|
||||||
use_form_training_context=use_form_schema,
|
|
||||||
form_training_category=training_category,
|
|
||||||
form_training_type_id=training_type_id,
|
|
||||||
)
|
|
||||||
except ActivitySessionMetricsError as err:
|
|
||||||
raise HTTPException(err.status_code, err.detail) from err
|
|
||||||
unit["header"] = serialize_dates(unit["header"])
|
|
||||||
return unit
|
|
||||||
|
|
||||||
|
|
||||||
def get_training_type_for_activity_with_cursor(cur, activity_type: str, profile_id: str | None = None):
|
def get_training_type_for_activity_with_cursor(cur, activity_type: str, profile_id: str | None = None):
|
||||||
|
|
@ -527,6 +251,23 @@ def get_training_type_for_activity(activity_type: str, profile_id: str = None):
|
||||||
return get_training_type_for_activity_with_cursor(cur, activity_type, profile_id)
|
return get_training_type_for_activity_with_cursor(cur, activity_type, profile_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/uncategorized")
|
||||||
|
def list_uncategorized_activities(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||||
|
"""Get activities without assigned training type, grouped by activity_type."""
|
||||||
|
pid = get_pid(x_profile_id)
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute("""
|
||||||
|
SELECT activity_type, COUNT(*) as count,
|
||||||
|
MIN(date) as first_date, MAX(date) as last_date
|
||||||
|
FROM activity_log
|
||||||
|
WHERE profile_id=%s AND training_type_id IS NULL
|
||||||
|
GROUP BY activity_type
|
||||||
|
ORDER BY count DESC
|
||||||
|
""", (pid,))
|
||||||
|
return [r2d(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/bulk-categorize")
|
@router.post("/bulk-categorize")
|
||||||
def bulk_categorize_activities(
|
def bulk_categorize_activities(
|
||||||
data: dict,
|
data: dict,
|
||||||
|
|
@ -612,10 +353,7 @@ def bulk_categorize_activities(
|
||||||
|
|
||||||
@router.post("/import-csv")
|
@router.post("/import-csv")
|
||||||
async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||||
"""
|
"""Import Apple Health workout CSV with automatic training type mapping."""
|
||||||
Legacy-Upload (Apple Health Workout-CSV-Spaltennamen).
|
|
||||||
Persistenz läuft über activity_persistence_orchestrator — gleiche Schicht wie Universal-CSV.
|
|
||||||
"""
|
|
||||||
pid = get_pid(x_profile_id)
|
pid = get_pid(x_profile_id)
|
||||||
raw = await file.read()
|
raw = await file.read()
|
||||||
try: text = raw.decode('utf-8')
|
try: text = raw.decode('utf-8')
|
||||||
|
|
@ -629,11 +367,9 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional
|
||||||
for row in reader:
|
for row in reader:
|
||||||
wtype = row.get('Workout Type','').strip()
|
wtype = row.get('Workout Type','').strip()
|
||||||
start = row.get('Start','').strip()
|
start = row.get('Start','').strip()
|
||||||
if not wtype or not start:
|
if not wtype or not start: continue
|
||||||
continue
|
try: date = start[:10]
|
||||||
workout_date, workout_start_t = normalize_activity_start(start)
|
except: continue
|
||||||
if not workout_date:
|
|
||||||
continue
|
|
||||||
dur = row.get('Duration','').strip()
|
dur = row.get('Duration','').strip()
|
||||||
duration_min = None
|
duration_min = None
|
||||||
if dur:
|
if dur:
|
||||||
|
|
@ -650,82 +386,106 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional
|
||||||
# Map activity_type to training_type_id using database mappings
|
# Map activity_type to training_type_id using database mappings
|
||||||
training_type_id, training_category, training_subcategory = get_training_type_for_activity(wtype, pid)
|
training_type_id, training_category, training_subcategory = get_training_type_for_activity(wtype, pid)
|
||||||
|
|
||||||
kcal_a = kj(row.get("Aktive Energie (kJ)", ""))
|
|
||||||
kcal_r = kj(row.get("Ruheeinträge (kJ)", ""))
|
|
||||||
hr_av = tf(row.get("Durchschn. Herzfrequenz (count/min)", ""))
|
|
||||||
hr_mx = tf(row.get("Max. Herzfrequenz (count/min)", ""))
|
|
||||||
dist_km = tf(row.get("Distanz (km)", ""))
|
|
||||||
try:
|
try:
|
||||||
existing_id = find_activity_duplicate_id(cur, pid, workout_date, workout_start_t)
|
# Check if entry already exists (duplicate detection by date + start_time)
|
||||||
if existing_id:
|
cur.execute("""
|
||||||
update_activity_columns(
|
SELECT id FROM activity_log
|
||||||
cur,
|
WHERE profile_id = %s AND date = %s AND start_time = %s
|
||||||
pid,
|
""", (pid, date, start))
|
||||||
str(existing_id),
|
existing = cur.fetchone()
|
||||||
{
|
|
||||||
"start_time": workout_start_t,
|
if existing:
|
||||||
"end_time": row.get("End", "") or None,
|
# Update existing entry (e.g., to add training type mapping)
|
||||||
"activity_type": wtype,
|
existing_id = existing['id']
|
||||||
"duration_min": duration_min,
|
cur.execute("""
|
||||||
"kcal_active": kcal_a,
|
UPDATE activity_log
|
||||||
"kcal_resting": kcal_r,
|
SET end_time = %s,
|
||||||
"hr_avg": hr_av,
|
activity_type = %s,
|
||||||
"hr_max": hr_mx,
|
duration_min = %s,
|
||||||
"distance_km": dist_km,
|
kcal_active = %s,
|
||||||
"training_type_id": training_type_id,
|
kcal_resting = %s,
|
||||||
"training_category": training_category,
|
hr_avg = %s,
|
||||||
"training_subcategory": training_subcategory,
|
hr_max = %s,
|
||||||
},
|
distance_km = %s,
|
||||||
)
|
training_type_id = %s,
|
||||||
skipped += 1
|
training_category = %s,
|
||||||
run_activity_post_write_hooks_import(
|
training_subcategory = %s
|
||||||
cur,
|
WHERE id = %s
|
||||||
pid,
|
""", (
|
||||||
str(existing_id),
|
row.get('End',''), wtype, duration_min,
|
||||||
workout_date=workout_date,
|
kj(row.get('Aktive Energie (kJ)','')),
|
||||||
training_type_id=training_type_id,
|
kj(row.get('Ruheeinträge (kJ)','')),
|
||||||
duration_min=duration_min,
|
tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
|
||||||
hr_avg=hr_av,
|
tf(row.get('Max. Herzfrequenz (count/min)','')),
|
||||||
hr_max=hr_mx,
|
tf(row.get('Distanz (km)','')),
|
||||||
distance_km=dist_km,
|
training_type_id, training_category, training_subcategory,
|
||||||
kcal_active=kcal_a,
|
existing_id
|
||||||
kcal_resting=kcal_r,
|
))
|
||||||
)
|
skipped += 1 # Count as skipped (not newly inserted)
|
||||||
|
|
||||||
|
# Phase 1.2: Auto-evaluation after CSV import UPDATE
|
||||||
|
if EVALUATION_AVAILABLE and training_type_id:
|
||||||
|
try:
|
||||||
|
# Build activity dict for evaluation
|
||||||
|
activity_dict = {
|
||||||
|
"id": existing_id,
|
||||||
|
"profile_id": pid,
|
||||||
|
"date": date,
|
||||||
|
"training_type_id": training_type_id,
|
||||||
|
"duration_min": duration_min,
|
||||||
|
"hr_avg": tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
|
||||||
|
"hr_max": tf(row.get('Max. Herzfrequenz (count/min)','')),
|
||||||
|
"distance_km": tf(row.get('Distanz (km)','')),
|
||||||
|
"kcal_active": kj(row.get('Aktive Energie (kJ)','')),
|
||||||
|
"kcal_resting": kj(row.get('Ruheeinträge (kJ)','')),
|
||||||
|
"rpe": None,
|
||||||
|
"pace_min_per_km": None,
|
||||||
|
"cadence": None,
|
||||||
|
"elevation_gain": None
|
||||||
|
}
|
||||||
|
evaluate_and_save_activity(cur, existing_id, activity_dict, training_type_id, pid)
|
||||||
|
logger.debug(f"[AUTO-EVAL] Re-evaluated updated activity {existing_id}")
|
||||||
|
except Exception as eval_error:
|
||||||
|
logger.warning(f"[AUTO-EVAL] Failed to re-evaluate updated activity {existing_id}: {eval_error}")
|
||||||
else:
|
else:
|
||||||
new_id = new_activity_id()
|
# Insert new entry
|
||||||
insert_activity_csv_minimal(
|
new_id = str(uuid.uuid4())
|
||||||
cur,
|
cur.execute("""INSERT INTO activity_log
|
||||||
pid,
|
(id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting,
|
||||||
new_id,
|
hr_avg,hr_max,distance_km,source,training_type_id,training_category,training_subcategory,created)
|
||||||
date_iso=workout_date,
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'apple_health',%s,%s,%s,CURRENT_TIMESTAMP)""",
|
||||||
start_time=workout_start_t,
|
(new_id,pid,date,start,row.get('End',''),wtype,duration_min,
|
||||||
end_time=row.get("End", "") or None,
|
kj(row.get('Aktive Energie (kJ)','')),kj(row.get('Ruheeinträge (kJ)','')),
|
||||||
activity_type=wtype,
|
tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
|
||||||
duration_min=duration_min,
|
tf(row.get('Max. Herzfrequenz (count/min)','')),
|
||||||
kcal_active=kcal_a,
|
tf(row.get('Distanz (km)','')),
|
||||||
kcal_resting=kcal_r,
|
training_type_id,training_category,training_subcategory))
|
||||||
hr_avg=hr_av,
|
inserted+=1
|
||||||
hr_max=hr_mx,
|
|
||||||
distance_km=dist_km,
|
# Phase 1.2: Auto-evaluation after CSV import INSERT
|
||||||
training_type_id=training_type_id,
|
if EVALUATION_AVAILABLE and training_type_id:
|
||||||
training_category=training_category,
|
try:
|
||||||
training_subcategory=training_subcategory,
|
# Build activity dict for evaluation
|
||||||
source="apple_health",
|
activity_dict = {
|
||||||
)
|
"id": new_id,
|
||||||
inserted += 1
|
"profile_id": pid,
|
||||||
run_activity_post_write_hooks_import(
|
"date": date,
|
||||||
cur,
|
"training_type_id": training_type_id,
|
||||||
pid,
|
"duration_min": duration_min,
|
||||||
new_id,
|
"hr_avg": tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
|
||||||
workout_date=workout_date,
|
"hr_max": tf(row.get('Max. Herzfrequenz (count/min)','')),
|
||||||
training_type_id=training_type_id,
|
"distance_km": tf(row.get('Distanz (km)','')),
|
||||||
duration_min=duration_min,
|
"kcal_active": kj(row.get('Aktive Energie (kJ)','')),
|
||||||
hr_avg=hr_av,
|
"kcal_resting": kj(row.get('Ruheeinträge (kJ)','')),
|
||||||
hr_max=hr_mx,
|
"rpe": None,
|
||||||
distance_km=dist_km,
|
"pace_min_per_km": None,
|
||||||
kcal_active=kcal_a,
|
"cadence": None,
|
||||||
kcal_resting=kcal_r,
|
"elevation_gain": None
|
||||||
)
|
}
|
||||||
|
evaluate_and_save_activity(cur, new_id, activity_dict, training_type_id, pid)
|
||||||
|
logger.debug(f"[AUTO-EVAL] Evaluated imported activity {new_id}")
|
||||||
|
except Exception as eval_error:
|
||||||
|
logger.warning(f"[AUTO-EVAL] Failed to evaluate imported activity {new_id}: {eval_error}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Import row failed: {e}")
|
logger.warning(f"Import row failed: {e}")
|
||||||
skipped+=1
|
skipped+=1
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,7 @@ from fastapi import APIRouter, HTTPException, Depends
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
from auth import require_admin, hash_pin
|
from auth import require_admin, hash_pin
|
||||||
from models import AdminProfileUpdate
|
from models import AdminProfileUpdate
|
||||||
from dashboard_layout_schema import (
|
from dashboard_layout_schema import ALLOWED_WIDGET_IDS, DashboardLayoutPayload, product_default_layout_dict
|
||||||
ALLOWED_WIDGET_IDS,
|
|
||||||
DashboardLayoutPayload,
|
|
||||||
merge_missing_catalog_widgets,
|
|
||||||
product_default_layout_dict,
|
|
||||||
)
|
|
||||||
from dashboard_widget_entitlements import widgets_catalog_admin_payload
|
from dashboard_widget_entitlements import widgets_catalog_admin_payload
|
||||||
from widget_catalog import WIDGET_CATALOG
|
from widget_catalog import WIDGET_CATALOG
|
||||||
from widget_feature_requirements_db import (
|
from widget_feature_requirements_db import (
|
||||||
|
|
@ -189,7 +184,7 @@ def admin_get_dashboard_product_default(session: dict = Depends(require_admin)):
|
||||||
"""Aktueller Produkt-Dashboard-Standard (DB oder Code)."""
|
"""Aktueller Produkt-Dashboard-Standard (DB oder Code)."""
|
||||||
_ = session
|
_ = session
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
layout = merge_missing_catalog_widgets(get_product_default_base_dict(conn))
|
layout = get_product_default_base_dict(conn)
|
||||||
from_database = get_stored_product_default_validated(conn) is not None
|
from_database = get_stored_product_default_validated(conn) is not None
|
||||||
code_ref = product_default_layout_dict()
|
code_ref = product_default_layout_dict()
|
||||||
return {
|
return {
|
||||||
|
|
@ -222,7 +217,7 @@ def admin_delete_dashboard_product_default(session: dict = Depends(require_admin
|
||||||
_ = session
|
_ = session
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
delete_product_default_override(conn)
|
delete_product_default_override(conn)
|
||||||
layout = merge_missing_catalog_widgets(get_product_default_base_dict(conn))
|
layout = get_product_default_base_dict(conn)
|
||||||
return {"ok": True, "layout": layout, "from_database": False}
|
return {"ok": True, "layout": layout, "from_database": False}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,266 +0,0 @@
|
||||||
"""
|
|
||||||
Admin: training_category_parameter + training_type_parameter (attribute profiles).
|
|
||||||
|
|
||||||
Agent guide: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md
|
|
||||||
"""
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
from auth import require_admin
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin", tags=["admin", "activity-attribute-profiles"])
|
|
||||||
|
|
||||||
|
|
||||||
class CategoryParameterCreate(BaseModel):
|
|
||||||
training_category: str = Field(..., min_length=1, max_length=50)
|
|
||||||
training_parameter_id: int
|
|
||||||
sort_order: int = 0
|
|
||||||
required: bool = False
|
|
||||||
ui_group: Optional[str] = Field(None, max_length=50)
|
|
||||||
|
|
||||||
|
|
||||||
class TypeParameterCreate(BaseModel):
|
|
||||||
training_type_id: int
|
|
||||||
training_parameter_id: int
|
|
||||||
sort_order: Optional[int] = None
|
|
||||||
required: Optional[bool] = None
|
|
||||||
ui_group: Optional[str] = Field(None, max_length=50)
|
|
||||||
|
|
||||||
|
|
||||||
class CategoryParameterUpdate(BaseModel):
|
|
||||||
sort_order: Optional[int] = None
|
|
||||||
required: Optional[bool] = None
|
|
||||||
ui_group: Optional[str] = Field(None, max_length=50)
|
|
||||||
|
|
||||||
|
|
||||||
class TypeParameterUpdate(BaseModel):
|
|
||||||
sort_order: Optional[int] = None
|
|
||||||
required: Optional[bool] = None
|
|
||||||
ui_group: Optional[str] = Field(None, max_length=50)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/training-category-parameters")
|
|
||||||
def admin_list_category_parameters(
|
|
||||||
category: Optional[str] = Query(None, description="Filter: training_types.category"),
|
|
||||||
session: dict = Depends(require_admin),
|
|
||||||
):
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
if category:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT tcp.*, tp.key AS parameter_key, tp.name_de AS parameter_name_de
|
|
||||||
FROM training_category_parameter tcp
|
|
||||||
JOIN training_parameters tp ON tp.id = tcp.training_parameter_id
|
|
||||||
WHERE tcp.training_category = %s
|
|
||||||
ORDER BY tcp.sort_order, tp.key
|
|
||||||
""",
|
|
||||||
(category.strip(),),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT tcp.*, tp.key AS parameter_key, tp.name_de AS parameter_name_de
|
|
||||||
FROM training_category_parameter tcp
|
|
||||||
JOIN training_parameters tp ON tp.id = tcp.training_parameter_id
|
|
||||||
ORDER BY tcp.training_category, tcp.sort_order, tp.key
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
return [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/training-category-parameters")
|
|
||||||
def admin_add_category_parameter(
|
|
||||||
body: CategoryParameterCreate,
|
|
||||||
session: dict = Depends(require_admin),
|
|
||||||
):
|
|
||||||
cat = body.training_category.strip()
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT id FROM training_parameters WHERE id = %s", (body.training_parameter_id,))
|
|
||||||
if not cur.fetchone():
|
|
||||||
raise HTTPException(404, "training_parameter_id unbekannt")
|
|
||||||
try:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO training_category_parameter (
|
|
||||||
training_category, training_parameter_id, sort_order, required, ui_group
|
|
||||||
) VALUES (%s,%s,%s,%s,%s)
|
|
||||||
RETURNING id
|
|
||||||
""",
|
|
||||||
(cat, body.training_parameter_id, body.sort_order, body.required, body.ui_group),
|
|
||||||
)
|
|
||||||
new_id = cur.fetchone()["id"]
|
|
||||||
conn.commit()
|
|
||||||
except Exception as e:
|
|
||||||
conn.rollback()
|
|
||||||
if "uq_training_category_parameter" in str(e).lower() or "unique" in str(e).lower():
|
|
||||||
raise HTTPException(409, "Zuordnung existiert bereits") from e
|
|
||||||
raise HTTPException(400, str(e)) from e
|
|
||||||
return {"id": new_id}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/training-category-parameters/{link_id}")
|
|
||||||
def admin_update_category_parameter(
|
|
||||||
link_id: int,
|
|
||||||
body: CategoryParameterUpdate,
|
|
||||||
session: dict = Depends(require_admin),
|
|
||||||
):
|
|
||||||
patch = body.model_dump(exclude_unset=True)
|
|
||||||
if not patch:
|
|
||||||
raise HTTPException(400, "Keine Felder zum Aktualisieren")
|
|
||||||
cols: list[str] = []
|
|
||||||
vals: list = []
|
|
||||||
if "sort_order" in patch:
|
|
||||||
cols.append("sort_order = %s")
|
|
||||||
vals.append(patch["sort_order"])
|
|
||||||
if "required" in patch:
|
|
||||||
cols.append("required = %s")
|
|
||||||
vals.append(patch["required"])
|
|
||||||
if "ui_group" in patch:
|
|
||||||
cols.append("ui_group = %s")
|
|
||||||
vals.append(patch["ui_group"].strip() if patch["ui_group"] else None)
|
|
||||||
if not cols:
|
|
||||||
raise HTTPException(400, "Keine Felder zum Aktualisieren")
|
|
||||||
vals.append(link_id)
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
f"UPDATE training_category_parameter SET {', '.join(cols)} WHERE id = %s RETURNING id",
|
|
||||||
vals,
|
|
||||||
)
|
|
||||||
if not cur.fetchone():
|
|
||||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
|
||||||
conn.commit()
|
|
||||||
return {"ok": True, "id": link_id}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/training-category-parameters/{link_id}")
|
|
||||||
def admin_delete_category_parameter(
|
|
||||||
link_id: int,
|
|
||||||
session: dict = Depends(require_admin),
|
|
||||||
):
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"DELETE FROM training_category_parameter WHERE id = %s RETURNING id",
|
|
||||||
(link_id,),
|
|
||||||
)
|
|
||||||
if not cur.fetchone():
|
|
||||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
|
||||||
conn.commit()
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/training-type-parameters")
|
|
||||||
def admin_list_type_parameters(
|
|
||||||
training_type_id: int = Query(..., ge=1),
|
|
||||||
session: dict = Depends(require_admin),
|
|
||||||
):
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT ttp.*, tp.key AS parameter_key, tp.name_de AS parameter_name_de
|
|
||||||
FROM training_type_parameter ttp
|
|
||||||
JOIN training_parameters tp ON tp.id = ttp.training_parameter_id
|
|
||||||
WHERE ttp.training_type_id = %s
|
|
||||||
ORDER BY ttp.sort_order NULLS LAST, tp.key
|
|
||||||
""",
|
|
||||||
(training_type_id,),
|
|
||||||
)
|
|
||||||
return [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/training-type-parameters")
|
|
||||||
def admin_add_type_parameter(
|
|
||||||
body: TypeParameterCreate,
|
|
||||||
session: dict = Depends(require_admin),
|
|
||||||
):
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT id FROM training_types WHERE id = %s", (body.training_type_id,))
|
|
||||||
if not cur.fetchone():
|
|
||||||
raise HTTPException(404, "training_type_id unbekannt")
|
|
||||||
cur.execute("SELECT id FROM training_parameters WHERE id = %s", (body.training_parameter_id,))
|
|
||||||
if not cur.fetchone():
|
|
||||||
raise HTTPException(404, "training_parameter_id unbekannt")
|
|
||||||
try:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO training_type_parameter (
|
|
||||||
training_type_id, training_parameter_id, sort_order, required, ui_group
|
|
||||||
) VALUES (%s,%s,%s,%s,%s)
|
|
||||||
RETURNING id
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
body.training_type_id,
|
|
||||||
body.training_parameter_id,
|
|
||||||
body.sort_order,
|
|
||||||
body.required,
|
|
||||||
body.ui_group,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
new_id = cur.fetchone()["id"]
|
|
||||||
conn.commit()
|
|
||||||
except Exception as e:
|
|
||||||
conn.rollback()
|
|
||||||
if "uq_training_type_parameter" in str(e).lower() or "unique" in str(e).lower():
|
|
||||||
raise HTTPException(409, "Zuordnung existiert bereits") from e
|
|
||||||
raise HTTPException(400, str(e)) from e
|
|
||||||
return {"id": new_id}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/training-type-parameters/{link_id}")
|
|
||||||
def admin_update_type_parameter(
|
|
||||||
link_id: int,
|
|
||||||
body: TypeParameterUpdate,
|
|
||||||
session: dict = Depends(require_admin),
|
|
||||||
):
|
|
||||||
patch = body.model_dump(exclude_unset=True)
|
|
||||||
if not patch:
|
|
||||||
raise HTTPException(400, "Keine Felder zum Aktualisieren")
|
|
||||||
cols: list[str] = []
|
|
||||||
vals: list = []
|
|
||||||
if "sort_order" in patch:
|
|
||||||
cols.append("sort_order = %s")
|
|
||||||
vals.append(patch["sort_order"])
|
|
||||||
if "required" in patch:
|
|
||||||
cols.append("required = %s")
|
|
||||||
vals.append(patch["required"])
|
|
||||||
if "ui_group" in patch:
|
|
||||||
cols.append("ui_group = %s")
|
|
||||||
vals.append(patch["ui_group"].strip() if patch["ui_group"] else None)
|
|
||||||
if not cols:
|
|
||||||
raise HTTPException(400, "Keine Felder zum Aktualisieren")
|
|
||||||
vals.append(link_id)
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
f"UPDATE training_type_parameter SET {', '.join(cols)} WHERE id = %s RETURNING id",
|
|
||||||
vals,
|
|
||||||
)
|
|
||||||
if not cur.fetchone():
|
|
||||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
|
||||||
conn.commit()
|
|
||||||
return {"ok": True, "id": link_id}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/training-type-parameters/{link_id}")
|
|
||||||
def admin_delete_type_parameter(
|
|
||||||
link_id: int,
|
|
||||||
session: dict = Depends(require_admin),
|
|
||||||
):
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"DELETE FROM training_type_parameter WHERE id = %s RETURNING id",
|
|
||||||
(link_id,),
|
|
||||||
)
|
|
||||||
if not cur.fetchone():
|
|
||||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
|
||||||
conn.commit()
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
@ -25,7 +25,6 @@ from csv_parser.import_row_processing import (
|
||||||
)
|
)
|
||||||
from csv_parser.module_registry import get_module_definition
|
from csv_parser.module_registry import get_module_definition
|
||||||
from csv_parser.template_validator import validate_csv_template
|
from csv_parser.template_validator import validate_csv_template
|
||||||
from data_layer.activity_persistence_orchestrator import merge_activity_csv_module_fields
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/csv-templates", tags=["admin", "csv-import"])
|
router = APIRouter(prefix="/api/admin/csv-templates", tags=["admin", "csv-import"])
|
||||||
|
|
||||||
|
|
@ -182,8 +181,6 @@ async def admin_analyze_csv_for_template(
|
||||||
sig = column_signature(headers)
|
sig = column_signature(headers)
|
||||||
|
|
||||||
seed_row: dict | None = None
|
seed_row: dict | None = None
|
||||||
field_mappings: dict = {}
|
|
||||||
type_conversions: dict = {}
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
if seed_template_id is not None:
|
if seed_template_id is not None:
|
||||||
|
|
@ -218,30 +215,15 @@ async def admin_analyze_csv_for_template(
|
||||||
if best and best_key[0] > 0:
|
if best and best_key[0] > 0:
|
||||||
seed_row = best
|
seed_row = best
|
||||||
|
|
||||||
mod_def = get_module_definition(module) or {}
|
seed_fm = (seed_row or {}).get("field_mappings") or {}
|
||||||
eff_fields = dict(mod_def.get("fields") or {})
|
if isinstance(seed_fm, str):
|
||||||
if module == "activity":
|
seed_fm = {}
|
||||||
eff_fields = merge_activity_csv_module_fields(cur, eff_fields)
|
seed_tc = (seed_row or {}).get("type_conversions")
|
||||||
|
if not isinstance(seed_tc, dict):
|
||||||
|
seed_tc = {}
|
||||||
|
|
||||||
seed_fm = (seed_row or {}).get("field_mappings") or {}
|
field_mappings = suggest_field_mappings(headers, module, seed_fm if seed_fm else None)
|
||||||
if isinstance(seed_fm, str):
|
type_conversions = build_type_conversions_for_mapping(module, field_mappings, seed_tc if seed_tc else None)
|
||||||
seed_fm = {}
|
|
||||||
seed_tc = (seed_row or {}).get("type_conversions")
|
|
||||||
if not isinstance(seed_tc, dict):
|
|
||||||
seed_tc = {}
|
|
||||||
|
|
||||||
field_mappings = suggest_field_mappings(
|
|
||||||
headers,
|
|
||||||
module,
|
|
||||||
seed_fm if seed_fm else None,
|
|
||||||
effective_fields=eff_fields,
|
|
||||||
)
|
|
||||||
type_conversions = build_type_conversions_for_mapping(
|
|
||||||
module,
|
|
||||||
field_mappings,
|
|
||||||
seed_tc if seed_tc else None,
|
|
||||||
effective_fields=eff_fields,
|
|
||||||
)
|
|
||||||
|
|
||||||
seed_meta = None
|
seed_meta = None
|
||||||
if seed_row:
|
if seed_row:
|
||||||
|
|
@ -288,16 +270,13 @@ def validate_system_template_dry_run(body: CsvTemplateValidateBody, session: dic
|
||||||
"""
|
"""
|
||||||
if not get_module_definition(body.module):
|
if not get_module_definition(body.module):
|
||||||
raise HTTPException(400, f"Unbekanntes Modul: {body.module}")
|
raise HTTPException(400, f"Unbekanntes Modul: {body.module}")
|
||||||
with get_db() as conn:
|
return validate_csv_template(
|
||||||
cur = get_cursor(conn)
|
body.module,
|
||||||
return validate_csv_template(
|
body.field_mappings,
|
||||||
body.module,
|
body.type_conversions,
|
||||||
body.field_mappings,
|
body.import_row_processing,
|
||||||
body.type_conversions,
|
body.column_signature,
|
||||||
body.import_row_processing,
|
)
|
||||||
body.column_signature,
|
|
||||||
cur=cur,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{template_id}")
|
@router.get("/{template_id}")
|
||||||
|
|
@ -318,19 +297,18 @@ def get_system_template(template_id: int, session: dict = Depends(require_admin)
|
||||||
def create_system_template(body: CsvSystemTemplateCreate, session: dict = Depends(require_admin)):
|
def create_system_template(body: CsvSystemTemplateCreate, session: dict = Depends(require_admin)):
|
||||||
if not get_module_definition(body.module):
|
if not get_module_definition(body.module):
|
||||||
raise HTTPException(400, f"Unbekanntes Modul: {body.module}")
|
raise HTTPException(400, f"Unbekanntes Modul: {body.module}")
|
||||||
|
report = validate_csv_template(
|
||||||
|
body.module,
|
||||||
|
body.field_mappings,
|
||||||
|
body.type_conversions,
|
||||||
|
body.import_row_processing,
|
||||||
|
body.column_signature,
|
||||||
|
)
|
||||||
|
if not report["valid"]:
|
||||||
|
raise HTTPException(status_code=422, detail=report)
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
report = validate_csv_template(
|
|
||||||
body.module,
|
|
||||||
body.field_mappings,
|
|
||||||
body.type_conversions,
|
|
||||||
body.import_row_processing,
|
|
||||||
body.column_signature,
|
|
||||||
cur=cur,
|
|
||||||
)
|
|
||||||
if not report["valid"]:
|
|
||||||
raise HTTPException(status_code=422, detail=report)
|
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO csv_field_mappings (
|
INSERT INTO csv_field_mappings (
|
||||||
|
|
@ -389,7 +367,6 @@ def update_system_template(
|
||||||
tc_eff,
|
tc_eff,
|
||||||
irp_eff,
|
irp_eff,
|
||||||
col_eff if isinstance(col_eff, list) else None,
|
col_eff if isinstance(col_eff, list) else None,
|
||||||
cur=cur,
|
|
||||||
)
|
)
|
||||||
if not report["valid"]:
|
if not report["valid"]:
|
||||||
raise HTTPException(status_code=422, detail=report)
|
raise HTTPException(status_code=422, detail=report)
|
||||||
|
|
|
||||||
|
|
@ -1,215 +0,0 @@
|
||||||
"""
|
|
||||||
Admin: training_parameters catalog (EAV keys for activity session metrics).
|
|
||||||
|
|
||||||
Agent guide: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from psycopg2 import errors as pg_errors
|
|
||||||
from psycopg2.extras import Json
|
|
||||||
|
|
||||||
from auth import require_admin
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin/training-parameters", tags=["admin", "training-parameters"])
|
|
||||||
|
|
||||||
KEY_PATTERN = re.compile(r"^[a-z][a-z0-9_]{0,62}$")
|
|
||||||
|
|
||||||
PARAM_CATEGORY = {"physical", "physiological", "subjective", "environmental", "performance"}
|
|
||||||
DATA_TYPES = {"integer", "float", "string", "boolean"}
|
|
||||||
|
|
||||||
|
|
||||||
class TrainingParameterCreate(BaseModel):
|
|
||||||
key: str = Field(..., min_length=1, max_length=50)
|
|
||||||
name_de: str = Field(..., min_length=1, max_length=100)
|
|
||||||
name_en: str = Field(..., min_length=1, max_length=100)
|
|
||||||
category: str = Field(..., max_length=50)
|
|
||||||
data_type: str = Field(..., max_length=20)
|
|
||||||
unit: Optional[str] = Field(None, max_length=20)
|
|
||||||
description_de: Optional[str] = None
|
|
||||||
description_en: Optional[str] = None
|
|
||||||
source_field: Optional[str] = Field(None, max_length=100)
|
|
||||||
validation_rules: Optional[dict] = None
|
|
||||||
is_active: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class TrainingParameterUpdate(BaseModel):
|
|
||||||
name_de: Optional[str] = Field(None, min_length=1, max_length=100)
|
|
||||||
name_en: Optional[str] = Field(None, min_length=1, max_length=100)
|
|
||||||
category: Optional[str] = Field(None, max_length=50)
|
|
||||||
data_type: Optional[str] = Field(None, max_length=20)
|
|
||||||
unit: Optional[str] = Field(None, max_length=20)
|
|
||||||
description_de: Optional[str] = None
|
|
||||||
description_en: Optional[str] = None
|
|
||||||
source_field: Optional[str] = Field(None, max_length=100)
|
|
||||||
validation_rules: Optional[dict] = None
|
|
||||||
is_active: Optional[bool] = None
|
|
||||||
|
|
||||||
|
|
||||||
def _norm_key(key: str) -> str:
|
|
||||||
k = key.strip().lower()
|
|
||||||
if not KEY_PATTERN.match(k):
|
|
||||||
raise HTTPException(
|
|
||||||
400,
|
|
||||||
"Ungültiger key: nur Kleinbuchstaben, Ziffern, Unterstriche; muss mit Buchstabe beginnen.",
|
|
||||||
)
|
|
||||||
return k
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_category(cat: str) -> str:
|
|
||||||
c = cat.strip()
|
|
||||||
if c not in PARAM_CATEGORY:
|
|
||||||
raise HTTPException(400, f"category muss einer von {sorted(PARAM_CATEGORY)} sein")
|
|
||||||
return c
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_data_type(dt: str) -> str:
|
|
||||||
d = dt.strip().lower()
|
|
||||||
if d not in DATA_TYPES:
|
|
||||||
raise HTTPException(400, f"data_type muss einer von {sorted(DATA_TYPES)} sein")
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
def admin_list_training_parameters(
|
|
||||||
include_inactive: bool = Query(False),
|
|
||||||
session: dict = Depends(require_admin),
|
|
||||||
):
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
if include_inactive:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT * FROM training_parameters
|
|
||||||
ORDER BY category, key
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT * FROM training_parameters
|
|
||||||
WHERE is_active = true
|
|
||||||
ORDER BY category, key
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
return [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
|
||||||
def admin_create_training_parameter(
|
|
||||||
body: TrainingParameterCreate,
|
|
||||||
session: dict = Depends(require_admin),
|
|
||||||
):
|
|
||||||
key = _norm_key(body.key)
|
|
||||||
cat = _validate_category(body.category)
|
|
||||||
dt = _validate_data_type(body.data_type)
|
|
||||||
rules = body.validation_rules if body.validation_rules is not None else {}
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
try:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO training_parameters (
|
|
||||||
key, name_de, name_en, category, data_type, unit,
|
|
||||||
description_de, description_en, source_field, validation_rules, is_active
|
|
||||||
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
|
||||||
RETURNING id
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
key,
|
|
||||||
body.name_de.strip(),
|
|
||||||
body.name_en.strip(),
|
|
||||||
cat,
|
|
||||||
dt,
|
|
||||||
body.unit.strip() if body.unit else None,
|
|
||||||
body.description_de,
|
|
||||||
body.description_en,
|
|
||||||
body.source_field.strip() if body.source_field else None,
|
|
||||||
Json(rules),
|
|
||||||
body.is_active,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
new_id = cur.fetchone()["id"]
|
|
||||||
conn.commit()
|
|
||||||
except pg_errors.UniqueViolation:
|
|
||||||
conn.rollback()
|
|
||||||
raise HTTPException(409, "Parameter-key existiert bereits") from None
|
|
||||||
return {"id": new_id, "key": key}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{param_id}")
|
|
||||||
def admin_update_training_parameter(
|
|
||||||
param_id: int,
|
|
||||||
body: TrainingParameterUpdate,
|
|
||||||
session: dict = Depends(require_admin),
|
|
||||||
):
|
|
||||||
cols: list[str] = []
|
|
||||||
vals: list[Any] = []
|
|
||||||
|
|
||||||
if body.name_de is not None:
|
|
||||||
cols.append("name_de = %s")
|
|
||||||
vals.append(body.name_de.strip())
|
|
||||||
if body.name_en is not None:
|
|
||||||
cols.append("name_en = %s")
|
|
||||||
vals.append(body.name_en.strip())
|
|
||||||
if body.category is not None:
|
|
||||||
cols.append("category = %s")
|
|
||||||
vals.append(_validate_category(body.category))
|
|
||||||
if body.data_type is not None:
|
|
||||||
cols.append("data_type = %s")
|
|
||||||
vals.append(_validate_data_type(body.data_type))
|
|
||||||
if body.unit is not None:
|
|
||||||
cols.append("unit = %s")
|
|
||||||
vals.append(body.unit.strip() or None)
|
|
||||||
if body.description_de is not None:
|
|
||||||
cols.append("description_de = %s")
|
|
||||||
vals.append(body.description_de)
|
|
||||||
if body.description_en is not None:
|
|
||||||
cols.append("description_en = %s")
|
|
||||||
vals.append(body.description_en)
|
|
||||||
if body.source_field is not None:
|
|
||||||
cols.append("source_field = %s")
|
|
||||||
vals.append(body.source_field.strip() or None)
|
|
||||||
if body.validation_rules is not None:
|
|
||||||
cols.append("validation_rules = %s")
|
|
||||||
vals.append(Json(body.validation_rules))
|
|
||||||
if body.is_active is not None:
|
|
||||||
cols.append("is_active = %s")
|
|
||||||
vals.append(body.is_active)
|
|
||||||
|
|
||||||
if not cols:
|
|
||||||
raise HTTPException(400, "Keine Felder zum Aktualisieren")
|
|
||||||
|
|
||||||
vals.append(param_id)
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
f"UPDATE training_parameters SET {', '.join(cols)} WHERE id = %s RETURNING id",
|
|
||||||
vals,
|
|
||||||
)
|
|
||||||
if not cur.fetchone():
|
|
||||||
raise HTTPException(404, "Parameter nicht gefunden")
|
|
||||||
conn.commit()
|
|
||||||
return {"ok": True, "id": param_id}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{param_id}")
|
|
||||||
def admin_deactivate_training_parameter(
|
|
||||||
param_id: int,
|
|
||||||
session: dict = Depends(require_admin),
|
|
||||||
):
|
|
||||||
"""Soft-delete: is_active = false (FK von session_metrics verhindert hartes Löschen)."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE training_parameters SET is_active = false WHERE id = %s RETURNING id",
|
|
||||||
(param_id,),
|
|
||||||
)
|
|
||||||
if not cur.fetchone():
|
|
||||||
raise HTTPException(404, "Parameter nicht gefunden")
|
|
||||||
conn.commit()
|
|
||||||
return {"ok": True, "id": param_id}
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
"""
|
"""
|
||||||
Geschützter App-Bereich: Dashboard-Layout und Widget-Katalog.
|
Geschützter App-Bereich: Dashboard-Lab Layout (kein Produktiv-Dashboard).
|
||||||
|
|
||||||
/api/app/dashboard-layout — nur mit Session + aktivem Profil (X-Profile-Id).
|
/api/app/dashboard-layout — nur mit Session + aktivem Profil (X-Profile-Id).
|
||||||
"""
|
"""
|
||||||
|
|
@ -13,14 +13,13 @@ from dashboard_layout_schema import (
|
||||||
DashboardLayoutPayload,
|
DashboardLayoutPayload,
|
||||||
coalesce_effective_layout,
|
coalesce_effective_layout,
|
||||||
lab_default_layout_dict,
|
lab_default_layout_dict,
|
||||||
merge_missing_catalog_widgets,
|
|
||||||
)
|
)
|
||||||
from dashboard_widget_entitlements import apply_entitlements_to_layout_dict, widgets_catalog_payload
|
from dashboard_widget_entitlements import apply_entitlements_to_layout_dict, widgets_catalog_payload
|
||||||
from db import get_cursor, get_db
|
from db import get_cursor, get_db
|
||||||
from routers.profiles import get_pid
|
from routers.profiles import get_pid
|
||||||
from system_dashboard_product_default import get_product_default_base_dict
|
from system_dashboard_product_default import get_product_default_base_dict
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/app", tags=["app-dashboard"])
|
router = APIRouter(prefix="/api/app", tags=["app-dashboard-lab"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/widgets/catalog")
|
@router.get("/widgets/catalog")
|
||||||
|
|
@ -52,11 +51,9 @@ def get_dashboard_layout(
|
||||||
raw = row["dashboard_layout"] if row else None
|
raw = row["dashboard_layout"] if row else None
|
||||||
custom, effective = coalesce_effective_layout(raw)
|
custom, effective = coalesce_effective_layout(raw)
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
base_product = merge_missing_catalog_widgets(get_product_default_base_dict(conn))
|
base_product = get_product_default_base_dict(conn)
|
||||||
if not custom:
|
if not custom:
|
||||||
effective = base_product
|
effective = base_product
|
||||||
else:
|
|
||||||
effective = merge_missing_catalog_widgets(effective)
|
|
||||||
effective = apply_entitlements_to_layout_dict(effective, pid, conn)
|
effective = apply_entitlements_to_layout_dict(effective, pid, conn)
|
||||||
product_adj = apply_entitlements_to_layout_dict(base_product, pid, conn)
|
product_adj = apply_entitlements_to_layout_dict(base_product, pid, conn)
|
||||||
lab_adj = apply_entitlements_to_layout_dict(lab_default_layout_dict(), pid, conn)
|
lab_adj = apply_entitlements_to_layout_dict(lab_default_layout_dict(), pid, conn)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -67,10 +67,10 @@ def upsert_circ(e: CircumferenceEntry, x_profile_id: Optional[str]=Header(defaul
|
||||||
# INSERT new entry
|
# INSERT new entry
|
||||||
eid = str(uuid.uuid4())
|
eid = str(uuid.uuid4())
|
||||||
cur.execute("""INSERT INTO circumference_log
|
cur.execute("""INSERT INTO circumference_log
|
||||||
(id,profile_id,date,c_neck,c_chest,c_waist,c_belly,c_hip,c_thigh,c_calf,c_arm,c_arm_relaxed,notes,photo_id,created)
|
(id,profile_id,date,c_neck,c_chest,c_waist,c_belly,c_hip,c_thigh,c_calf,c_arm,notes,photo_id,created)
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
|
||||||
(eid,pid,d['date'],d['c_neck'],d['c_chest'],d['c_waist'],d['c_belly'],
|
(eid,pid,d['date'],d['c_neck'],d['c_chest'],d['c_waist'],d['c_belly'],
|
||||||
d['c_hip'],d['c_thigh'],d['c_calf'],d['c_arm'],d.get('c_arm_relaxed'),d['notes'],d['photo_id']))
|
d['c_hip'],d['c_thigh'],d['c_calf'],d['c_arm'],d['notes'],d['photo_id']))
|
||||||
|
|
||||||
# Phase 2: Increment usage counter (only for new entries)
|
# Phase 2: Increment usage counter (only for new entries)
|
||||||
increment_feature_usage(pid, 'circumference_entries')
|
increment_feature_usage(pid, 'circumference_entries')
|
||||||
|
|
|
||||||
|
|
@ -29,13 +29,11 @@ from csv_parser.core import (
|
||||||
iter_csv_dict_rows,
|
iter_csv_dict_rows,
|
||||||
normalize_header_for_signature,
|
normalize_header_for_signature,
|
||||||
parse_csv_sample,
|
parse_csv_sample,
|
||||||
resolve_effective_csv_delimiter,
|
|
||||||
)
|
)
|
||||||
from csv_parser.type_converter import build_row_after_mapping, diagnose_row_mapping
|
from csv_parser.type_converter import build_row_after_mapping, diagnose_row_mapping
|
||||||
from csv_parser.field_units import source_unit_choices_for_field
|
from csv_parser.field_units import source_unit_choices_for_field
|
||||||
from csv_parser.import_errors import enrich_row_error
|
from csv_parser.import_errors import enrich_row_error
|
||||||
from csv_parser.module_registry import get_module_definition, list_modules, validate_field_mappings
|
from csv_parser.module_registry import get_module_definition, list_modules, validate_field_mappings
|
||||||
from data_layer.activity_persistence_orchestrator import merge_activity_csv_module_fields
|
|
||||||
from csv_parser.sleep_apple_import import detect_apple_sleep_csv_format
|
from csv_parser.sleep_apple_import import detect_apple_sleep_csv_format
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/csv", tags=["csv-import"])
|
router = APIRouter(prefix="/api/csv", tags=["csv-import"])
|
||||||
|
|
@ -68,30 +66,25 @@ def _mapping_to_summary(m: dict) -> dict:
|
||||||
def csv_modules(session: dict = Depends(require_auth)):
|
def csv_modules(session: dict = Depends(require_auth)):
|
||||||
"""Unterstützte Import-Module und Felddefinitionen."""
|
"""Unterstützte Import-Module und Felddefinitionen."""
|
||||||
out = []
|
out = []
|
||||||
with get_db() as conn:
|
for mid in list_modules():
|
||||||
cur = get_cursor(conn)
|
d = get_module_definition(mid)
|
||||||
for mid in list_modules():
|
if d:
|
||||||
d = get_module_definition(mid)
|
fields_out = {}
|
||||||
if d:
|
for fname, finfo in (d.get("fields") or {}).items():
|
||||||
field_src = dict(d.get("fields") or {})
|
fd = dict(finfo)
|
||||||
if mid == "activity":
|
opts = source_unit_choices_for_field(mid, fname)
|
||||||
field_src = merge_activity_csv_module_fields(cur, field_src)
|
if opts:
|
||||||
fields_out = {}
|
fd["source_unit_options"] = opts
|
||||||
for fname, finfo in field_src.items():
|
fields_out[fname] = fd
|
||||||
fd = dict(finfo)
|
out.append(
|
||||||
opts = source_unit_choices_for_field(mid, fname)
|
{
|
||||||
if opts:
|
"id": mid,
|
||||||
fd["source_unit_options"] = opts
|
"table": d["table"],
|
||||||
fields_out[fname] = fd
|
"fields": fields_out,
|
||||||
out.append(
|
"import_mode": d.get("import_mode"),
|
||||||
{
|
"import_row_processing_default": d.get("import_row_processing_default"),
|
||||||
"id": mid,
|
}
|
||||||
"table": d["table"],
|
)
|
||||||
"fields": fields_out,
|
|
||||||
"import_mode": d.get("import_mode"),
|
|
||||||
"import_row_processing_default": d.get("import_row_processing_default"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return {"modules": out}
|
return {"modules": out}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -264,10 +257,6 @@ async def analyze_csv(
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
if module == "activity" and mod_def:
|
|
||||||
available_fields = merge_activity_csv_module_fields(
|
|
||||||
cur, dict(mod_def.get("fields") or {})
|
|
||||||
)
|
|
||||||
if module:
|
if module:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
|
|
@ -394,8 +383,7 @@ async def csv_import_diagnose(
|
||||||
tc = m.get("type_conversions")
|
tc = m.get("type_conversions")
|
||||||
if not isinstance(tc, dict):
|
if not isinstance(tc, dict):
|
||||||
tc = {}
|
tc = {}
|
||||||
tpl_delim = str(m.get("delimiter") or ",").strip() or ","
|
delim = str(m.get("delimiter") or ",")
|
||||||
delim = resolve_effective_csv_delimiter(text, tpl_delim)
|
|
||||||
exec_module = str(m["module"])
|
exec_module = str(m["module"])
|
||||||
|
|
||||||
rows_out: list[dict[str, Any]] = []
|
rows_out: list[dict[str, Any]] = []
|
||||||
|
|
@ -420,7 +408,6 @@ async def csv_import_diagnose(
|
||||||
"mapping_id": mapping_id,
|
"mapping_id": mapping_id,
|
||||||
"mapping_name": m.get("mapping_name"),
|
"mapping_name": m.get("mapping_name"),
|
||||||
"module": exec_module,
|
"module": exec_module,
|
||||||
"delimiter_template": tpl_delim,
|
|
||||||
"delimiter_used": delim,
|
"delimiter_used": delim,
|
||||||
"has_header": bool(m.get("has_header", True)),
|
"has_header": bool(m.get("has_header", True)),
|
||||||
"rows_diagnosed": len(rows_out),
|
"rows_diagnosed": len(rows_out),
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,6 @@ from auth import require_auth, check_feature_access, increment_feature_usage
|
||||||
from routers.profiles import get_pid
|
from routers.profiles import get_pid
|
||||||
from feature_logger import log_feature_usage
|
from feature_logger import log_feature_usage
|
||||||
from caliper_composition import enrich_caliper_row_for_response, load_weight_rows
|
from caliper_composition import enrich_caliper_row_for_response, load_weight_rows
|
||||||
from data_layer.activity_session_metrics import enrich_sessions_with_metrics
|
|
||||||
from data_layer.utils import serialize_dates
|
|
||||||
from routers.photos import resolve_photo_path
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/export", tags=["export"])
|
router = APIRouter(prefix="/api/export", tags=["export"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -93,23 +90,10 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D
|
||||||
for r in cur.fetchall():
|
for r in cur.fetchall():
|
||||||
writer.writerow(["Ernährung", r['date'], f"{float(r['kcal'])}kcal", f"Protein:{float(r['protein_g'])}g"])
|
writer.writerow(["Ernährung", r['date'], f"{float(r['kcal'])}kcal", f"Protein:{float(r['protein_g'])}g"])
|
||||||
|
|
||||||
# Activity (Layer-1: gemergte session_metrics in Details)
|
# Activity
|
||||||
cur.execute(
|
cur.execute("SELECT date, activity_type, duration_min, kcal_active FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,))
|
||||||
"SELECT id, date, activity_type, duration_min, kcal_active FROM activity_log WHERE profile_id=%s ORDER BY date",
|
for r in cur.fetchall():
|
||||||
(pid,),
|
writer.writerow(["Training", r['date'], r['activity_type'], f"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal"])
|
||||||
)
|
|
||||||
act_rows = [r2d(r) for r in cur.fetchall()]
|
|
||||||
enrich_sessions_with_metrics(cur, act_rows)
|
|
||||||
for r in act_rows:
|
|
||||||
base = f"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal"
|
|
||||||
eav_parts = []
|
|
||||||
for m in r.get("session_metrics") or []:
|
|
||||||
k, v = m.get("key"), m.get("value")
|
|
||||||
if k is None or v is None:
|
|
||||||
continue
|
|
||||||
eav_parts.append(f"{k}={v}")
|
|
||||||
details = base + (" | " + "; ".join(eav_parts) if eav_parts else "")
|
|
||||||
writer.writerow(["Training", r["date"], r["activity_type"], details])
|
|
||||||
|
|
||||||
output.seek(0)
|
output.seek(0)
|
||||||
|
|
||||||
|
|
@ -164,9 +148,7 @@ def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict=
|
||||||
data['nutrition'] = [r2d(r) for r in cur.fetchall()]
|
data['nutrition'] = [r2d(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
cur.execute("SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,))
|
cur.execute("SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,))
|
||||||
data["activity"] = [r2d(r) for r in cur.fetchall()]
|
data['activity'] = [r2d(r) for r in cur.fetchall()]
|
||||||
enrich_sessions_with_metrics(cur, data["activity"])
|
|
||||||
data["activity"] = serialize_dates(data["activity"])
|
|
||||||
|
|
||||||
cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,))
|
cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,))
|
||||||
data['insights'] = [r2d(r) for r in cur.fetchall()]
|
data['insights'] = [r2d(r) for r in cur.fetchall()]
|
||||||
|
|
@ -261,9 +243,6 @@ Dieser Export kann in Mitai Jinkendo unter
|
||||||
Einstellungen → Import → "Mitai Backup importieren"
|
Einstellungen → Import → "Mitai Backup importieren"
|
||||||
wieder eingespielt werden.
|
wieder eingespielt werden.
|
||||||
|
|
||||||
activity.csv (optional): Spalte session_metrics_json (JSON-Array, Layer-1-merge)
|
|
||||||
wird beim Standard-Import ignoriert; für Vollständigkeit/externe Tools.
|
|
||||||
|
|
||||||
Format-Version 2 (ab v9b):
|
Format-Version 2 (ab v9b):
|
||||||
Alle CSV-Dateien sind UTF-8 mit BOM kodiert.
|
Alle CSV-Dateien sind UTF-8 mit BOM kodiert.
|
||||||
Trennzeichen: Semikolon (;)
|
Trennzeichen: Semikolon (;)
|
||||||
|
|
@ -312,38 +291,14 @@ Datumsformat: YYYY-MM-DD
|
||||||
cur.execute("SELECT id, date, weight, note, source, created FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,))
|
cur.execute("SELECT id, date, weight, note, source, created FROM weight_log WHERE profile_id=%s ORDER BY date", (pid,))
|
||||||
write_csv(zf, "weight.csv", [r2d(r) for r in cur.fetchall()], ['id','date','weight','note','source','created'])
|
write_csv(zf, "weight.csv", [r2d(r) for r in cur.fetchall()], ['id','date','weight','note','source','created'])
|
||||||
|
|
||||||
cur.execute(
|
cur.execute("SELECT id, date, c_waist, c_hip, c_chest, c_neck, c_arm, c_thigh, c_calf, notes, created FROM circumference_log WHERE profile_id=%s ORDER BY date", (pid,))
|
||||||
"SELECT id, date, c_waist, c_hip, c_chest, c_neck, c_arm, c_arm_relaxed, c_thigh, c_calf, notes, created FROM circumference_log WHERE profile_id=%s ORDER BY date",
|
|
||||||
(pid,),
|
|
||||||
)
|
|
||||||
rows = [r2d(r) for r in cur.fetchall()]
|
rows = [r2d(r) for r in cur.fetchall()]
|
||||||
for r in rows:
|
for r in rows:
|
||||||
r['waist'] = r.pop('c_waist', None); r['hip'] = r.pop('c_hip', None)
|
r['waist'] = r.pop('c_waist', None); r['hip'] = r.pop('c_hip', None)
|
||||||
r['chest'] = r.pop('c_chest', None); r['neck'] = r.pop('c_neck', None)
|
r['chest'] = r.pop('c_chest', None); r['neck'] = r.pop('c_neck', None)
|
||||||
r['upper_arm_contracted'] = r.pop('c_arm', None)
|
r['upper_arm'] = r.pop('c_arm', None); r['thigh'] = r.pop('c_thigh', None)
|
||||||
r['upper_arm_relaxed'] = r.pop('c_arm_relaxed', None)
|
|
||||||
r['thigh'] = r.pop('c_thigh', None)
|
|
||||||
r['calf'] = r.pop('c_calf', None); r['forearm'] = None; r['note'] = r.pop('notes', None)
|
r['calf'] = r.pop('c_calf', None); r['forearm'] = None; r['note'] = r.pop('notes', None)
|
||||||
write_csv(
|
write_csv(zf, "circumferences.csv", rows, ['id','date','waist','hip','chest','neck','upper_arm','thigh','calf','forearm','note','created'])
|
||||||
zf,
|
|
||||||
"circumferences.csv",
|
|
||||||
rows,
|
|
||||||
[
|
|
||||||
'id',
|
|
||||||
'date',
|
|
||||||
'waist',
|
|
||||||
'hip',
|
|
||||||
'chest',
|
|
||||||
'neck',
|
|
||||||
'upper_arm_contracted',
|
|
||||||
'upper_arm_relaxed',
|
|
||||||
'thigh',
|
|
||||||
'calf',
|
|
||||||
'forearm',
|
|
||||||
'note',
|
|
||||||
'created',
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
cur.execute("SELECT id, date, sf_chest, sf_abdomen, sf_thigh, sf_triceps, sf_subscap, sf_suprailiac, sf_axilla, sf_method, body_fat_pct, notes, created FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,))
|
cur.execute("SELECT id, date, sf_chest, sf_abdomen, sf_thigh, sf_triceps, sf_subscap, sf_suprailiac, sf_axilla, sf_method, body_fat_pct, notes, created FROM caliper_log WHERE profile_id=%s ORDER BY date", (pid,))
|
||||||
rows = [r2d(r) for r in cur.fetchall()]
|
rows = [r2d(r) for r in cur.fetchall()]
|
||||||
|
|
@ -363,41 +318,13 @@ Datumsformat: YYYY-MM-DD
|
||||||
r['fiber'] = None; r['note'] = ''
|
r['fiber'] = None; r['note'] = ''
|
||||||
write_csv(zf, "nutrition.csv", rows, ['id','date','meal_name','kcal','protein','fat','carbs','fiber','note','source','created'])
|
write_csv(zf, "nutrition.csv", rows, ['id','date','meal_name','kcal','protein','fat','carbs','fiber','note','source','created'])
|
||||||
|
|
||||||
cur.execute(
|
cur.execute("SELECT id, date, activity_type, duration_min, kcal_active, hr_avg, hr_max, distance_km, notes, source, created FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,))
|
||||||
"SELECT id, date, activity_type, duration_min, kcal_active, hr_avg, hr_max, distance_km, notes, source, created FROM activity_log WHERE profile_id=%s ORDER BY date",
|
|
||||||
(pid,),
|
|
||||||
)
|
|
||||||
rows = [r2d(r) for r in cur.fetchall()]
|
rows = [r2d(r) for r in cur.fetchall()]
|
||||||
enrich_sessions_with_metrics(cur, rows)
|
|
||||||
for r in rows:
|
for r in rows:
|
||||||
sm = r.pop("session_metrics", None) or []
|
r['name'] = r['activity_type']; r['type'] = r.pop('activity_type', None)
|
||||||
r["session_metrics_json"] = json.dumps(sm, ensure_ascii=False, default=str)
|
r['kcal'] = r.pop('kcal_active', None); r['heart_rate_avg'] = r.pop('hr_avg', None)
|
||||||
r["name"] = r["activity_type"]
|
r['heart_rate_max'] = r.pop('hr_max', None); r['note'] = r.pop('notes', None)
|
||||||
r["type"] = r.pop("activity_type", None)
|
write_csv(zf, "activity.csv", rows, ['id','date','name','type','duration_min','kcal','heart_rate_avg','heart_rate_max','distance_km','note','source','created'])
|
||||||
r["kcal"] = r.pop("kcal_active", None)
|
|
||||||
r["heart_rate_avg"] = r.pop("hr_avg", None)
|
|
||||||
r["heart_rate_max"] = r.pop("hr_max", None)
|
|
||||||
r["note"] = r.pop("notes", None)
|
|
||||||
write_csv(
|
|
||||||
zf,
|
|
||||||
"activity.csv",
|
|
||||||
rows,
|
|
||||||
[
|
|
||||||
"id",
|
|
||||||
"date",
|
|
||||||
"name",
|
|
||||||
"type",
|
|
||||||
"duration_min",
|
|
||||||
"kcal",
|
|
||||||
"heart_rate_avg",
|
|
||||||
"heart_rate_max",
|
|
||||||
"distance_km",
|
|
||||||
"note",
|
|
||||||
"source",
|
|
||||||
"created",
|
|
||||||
"session_metrics_json",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
# 8. insights/ai_insights.json
|
# 8. insights/ai_insights.json
|
||||||
cur.execute("SELECT id, scope, content, created FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,))
|
cur.execute("SELECT id, scope, content, created FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,))
|
||||||
|
|
@ -417,8 +344,8 @@ Datumsformat: YYYY-MM-DD
|
||||||
cur.execute("SELECT * FROM photos WHERE profile_id=%s ORDER BY date", (pid,))
|
cur.execute("SELECT * FROM photos WHERE profile_id=%s ORDER BY date", (pid,))
|
||||||
photos = [r2d(r) for r in cur.fetchall()]
|
photos = [r2d(r) for r in cur.fetchall()]
|
||||||
for i, photo in enumerate(photos):
|
for i, photo in enumerate(photos):
|
||||||
photo_path = resolve_photo_path(photo.get('path'))
|
photo_path = Path(PHOTOS_DIR) / photo['path']
|
||||||
if photo_path and photo_path.exists():
|
if photo_path.exists():
|
||||||
filename = f"{photo.get('date') or export_date}_{i+1}{photo_path.suffix}"
|
filename = f"{photo.get('date') or export_date}_{i+1}{photo_path.suffix}"
|
||||||
zf.write(photo_path, f"photos/{filename}")
|
zf.write(photo_path, f"photos/{filename}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -112,17 +112,12 @@ async def import_zip(
|
||||||
csv_data = zf.read('data/circumferences.csv').decode('utf-8-sig')
|
csv_data = zf.read('data/circumferences.csv').decode('utf-8-sig')
|
||||||
reader = csv.DictReader(io.StringIO(csv_data), delimiter=';')
|
reader = csv.DictReader(io.StringIO(csv_data), delimiter=';')
|
||||||
for row in reader:
|
for row in reader:
|
||||||
_ua_contr = (
|
|
||||||
row.get('upper_arm_contracted')
|
|
||||||
or row.get('upper_arm')
|
|
||||||
)
|
|
||||||
_ua_rel = row.get('upper_arm_relaxed')
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO circumference_log (
|
INSERT INTO circumference_log (
|
||||||
profile_id, date, c_waist, c_hip, c_chest, c_neck,
|
profile_id, date, c_waist, c_hip, c_chest, c_neck,
|
||||||
c_arm, c_arm_relaxed, c_thigh, c_calf, notes, created
|
c_arm, c_thigh, c_calf, notes, created
|
||||||
)
|
)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
ON CONFLICT (profile_id, date) DO NOTHING
|
ON CONFLICT (profile_id, date) DO NOTHING
|
||||||
""", (
|
""", (
|
||||||
pid,
|
pid,
|
||||||
|
|
@ -131,8 +126,7 @@ async def import_zip(
|
||||||
float(row['hip']) if row.get('hip') else None,
|
float(row['hip']) if row.get('hip') else None,
|
||||||
float(row['chest']) if row.get('chest') else None,
|
float(row['chest']) if row.get('chest') else None,
|
||||||
float(row['neck']) if row.get('neck') else None,
|
float(row['neck']) if row.get('neck') else None,
|
||||||
float(_ua_contr) if _ua_contr not in (None, '') else None,
|
float(row['upper_arm']) if row.get('upper_arm') else None,
|
||||||
float(_ua_rel) if _ua_rel not in (None, '') else None,
|
|
||||||
float(row['thigh']) if row.get('thigh') else None,
|
float(row['thigh']) if row.get('thigh') else None,
|
||||||
float(row['calf']) if row.get('calf') else None,
|
float(row['calf']) if row.get('calf') else None,
|
||||||
row.get('note', ''),
|
row.get('note', ''),
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ from db import get_db, get_cursor, r2d
|
||||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
from auth import require_auth, check_feature_access, increment_feature_usage
|
||||||
from routers.profiles import get_pid
|
from routers.profiles import get_pid
|
||||||
from feature_logger import log_feature_usage
|
from feature_logger import log_feature_usage
|
||||||
from data_layer.nutrition_body_merge import build_merged_daily_nutrition_body_rows
|
from caliper_composition import compute_lean_fat_kg, nearest_weight_kg_from_map
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/nutrition", tags=["nutrition"])
|
router = APIRouter(prefix="/api/nutrition", tags=["nutrition"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -179,9 +179,38 @@ def get_nutrition_by_date(date: str, x_profile_id: Optional[str]=Header(default=
|
||||||
|
|
||||||
@router.get("/correlations")
|
@router.get("/correlations")
|
||||||
def nutrition_correlations(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
def nutrition_correlations(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||||
"""Get nutrition data correlated with weight and body fat (Layer 1 Merge, siehe nutrition_body_merge)."""
|
"""Get nutrition data correlated with weight and body fat."""
|
||||||
pid = get_pid(x_profile_id)
|
pid = get_pid(x_profile_id)
|
||||||
return build_merged_daily_nutrition_body_rows(pid)
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date",(pid,))
|
||||||
|
nutr={r['date']:r2d(r) for r in cur.fetchall()}
|
||||||
|
cur.execute("SELECT date,weight FROM weight_log WHERE profile_id=%s ORDER BY date",(pid,))
|
||||||
|
wlog={r['date']:r['weight'] for r in cur.fetchall()}
|
||||||
|
cur.execute("SELECT date,lean_mass,body_fat_pct FROM caliper_log WHERE profile_id=%s ORDER BY date",(pid,))
|
||||||
|
cals=sorted([r2d(r) for r in cur.fetchall()],key=lambda x:x['date'])
|
||||||
|
all_dates=sorted(set(list(nutr)+list(wlog)))
|
||||||
|
mi,last_cal,cal_by_date=0,{},{}
|
||||||
|
for d in all_dates:
|
||||||
|
while mi<len(cals) and cals[mi]['date']<=d: last_cal=cals[mi]; mi+=1
|
||||||
|
if last_cal: cal_by_date[d]=last_cal
|
||||||
|
result=[]
|
||||||
|
for d in all_dates:
|
||||||
|
if d not in nutr and d not in wlog: continue
|
||||||
|
row={'date':d}
|
||||||
|
if d in nutr: row.update({k:float(nutr[d][k]) if nutr[d][k] is not None else None for k in ['kcal','protein_g','fat_g','carbs_g']})
|
||||||
|
if d in wlog: row['weight']=float(wlog[d])
|
||||||
|
if d in cal_by_date:
|
||||||
|
lm = cal_by_date[d].get('lean_mass')
|
||||||
|
bf = cal_by_date[d].get('body_fat_pct')
|
||||||
|
if bf is not None and lm is None:
|
||||||
|
wkg = nearest_weight_kg_from_map(wlog, d)
|
||||||
|
if wkg is not None:
|
||||||
|
lm, _fat = compute_lean_fat_kg(wkg, float(bf))
|
||||||
|
row['lean_mass'] = float(lm) if lm is not None else None
|
||||||
|
row['body_fat_pct'] = float(bf) if bf is not None else None
|
||||||
|
result.append(row)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/weekly")
|
@router.get("/weekly")
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ Handles progress photo uploads and retrieval.
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
import logging
|
import logging
|
||||||
from datetime import date as date_cls
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
@ -15,7 +14,6 @@ from fastapi.responses import FileResponse
|
||||||
import aiofiles
|
import aiofiles
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
from photo_exif import extract_taken_at_from_image_bytes, taken_at_from_file_last_modified_ms
|
|
||||||
from auth import require_auth, require_auth_flexible, check_feature_access, increment_feature_usage
|
from auth import require_auth, require_auth_flexible, check_feature_access, increment_feature_usage
|
||||||
from routers.profiles import get_pid
|
from routers.profiles import get_pid
|
||||||
from feature_logger import log_feature_usage
|
from feature_logger import log_feature_usage
|
||||||
|
|
@ -27,41 +25,10 @@ PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos"))
|
||||||
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
|
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def resolve_photo_path(stored: Optional[str]) -> Optional[Path]:
|
|
||||||
"""
|
|
||||||
Map DB `path` to an existing file. Supports legacy rows (absolute path or nested)
|
|
||||||
and new rows (filename only under PHOTOS_DIR).
|
|
||||||
"""
|
|
||||||
if not stored or not str(stored).strip():
|
|
||||||
return None
|
|
||||||
raw = str(stored).strip()
|
|
||||||
p = Path(raw)
|
|
||||||
if p.is_absolute() and p.exists():
|
|
||||||
return p
|
|
||||||
cand = PHOTOS_DIR / raw
|
|
||||||
if cand.exists():
|
|
||||||
return cand
|
|
||||||
cand2 = PHOTOS_DIR / Path(raw).name
|
|
||||||
if cand2.exists():
|
|
||||||
return cand2
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
@router.post("")
|
||||||
async def upload_photo(
|
async def upload_photo(file: UploadFile=File(...), date: str=Form(""),
|
||||||
file: UploadFile = File(...),
|
x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||||
date: str = Form(""),
|
"""Upload progress photo."""
|
||||||
skip_exif: str = Form(""),
|
|
||||||
file_last_modified: str = Form(""),
|
|
||||||
x_profile_id: Optional[str] = Header(default=None),
|
|
||||||
session: dict = Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Upload progress photo.
|
|
||||||
|
|
||||||
Reihenfolge (wenn nicht ``skip_exif``): EXIF → Datei-Zeitstempel (Browser ``File.lastModified``)
|
|
||||||
→ Formularfeld ``date`` → heute. Bei ``skip_exif``: nur Formular / heute (weder EXIF noch Datei-Zeit).
|
|
||||||
"""
|
|
||||||
pid = get_pid(x_profile_id)
|
pid = get_pid(x_profile_id)
|
||||||
|
|
||||||
# Phase 4: Check feature access and ENFORCE
|
# Phase 4: Check feature access and ENFORCE
|
||||||
|
|
@ -79,69 +46,35 @@ async def upload_photo(
|
||||||
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset."
|
||||||
)
|
)
|
||||||
|
|
||||||
raw = await file.read()
|
|
||||||
ignore_exif = skip_exif.strip().lower() in ("1", "true", "yes", "on")
|
|
||||||
taken_at = None
|
|
||||||
exif_used = False
|
|
||||||
file_mtime_used = False
|
|
||||||
|
|
||||||
if not ignore_exif:
|
|
||||||
taken_at = extract_taken_at_from_image_bytes(raw)
|
|
||||||
exif_used = taken_at is not None
|
|
||||||
if not taken_at:
|
|
||||||
taken_at = taken_at_from_file_last_modified_ms(file_last_modified)
|
|
||||||
file_mtime_used = taken_at is not None
|
|
||||||
|
|
||||||
if taken_at:
|
|
||||||
photo_date = taken_at.date()
|
|
||||||
elif date and date.strip():
|
|
||||||
photo_date = date_cls.fromisoformat(date.strip()[:10])
|
|
||||||
else:
|
|
||||||
photo_date = date_cls.today()
|
|
||||||
|
|
||||||
fid = str(uuid.uuid4())
|
fid = str(uuid.uuid4())
|
||||||
ext = Path(file.filename).suffix or ".jpg"
|
ext = Path(file.filename).suffix or '.jpg'
|
||||||
fname = f"{fid}{ext}"
|
path = PHOTOS_DIR / f"{fid}{ext}"
|
||||||
path = PHOTOS_DIR / fname
|
async with aiofiles.open(path,'wb') as f: await f.write(await file.read())
|
||||||
async with aiofiles.open(path, "wb") as f:
|
|
||||||
await f.write(raw)
|
# Convert empty string to NULL for date field
|
||||||
|
photo_date = date if date and date.strip() else None
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute(
|
cur.execute("INSERT INTO photos (id,profile_id,date,path,created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)",
|
||||||
"""
|
(fid,pid,photo_date,str(path)))
|
||||||
INSERT INTO photos (id, profile_id, date, path, taken_at, created)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
|
|
||||||
""",
|
|
||||||
(fid, pid, photo_date, fname, taken_at),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Phase 2: Increment usage counter
|
# Phase 2: Increment usage counter
|
||||||
increment_feature_usage(pid, "photos")
|
increment_feature_usage(pid, 'photos')
|
||||||
|
|
||||||
return {
|
return {"id":fid,"date":photo_date}
|
||||||
"id": fid,
|
|
||||||
"date": photo_date.isoformat(),
|
|
||||||
"taken_at": taken_at.isoformat() if taken_at else None,
|
|
||||||
"exif_used": exif_used,
|
|
||||||
"file_mtime_used": file_mtime_used,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{fid}")
|
@router.get("/{fid}")
|
||||||
def get_photo(fid: str, session: dict=Depends(require_auth_flexible)):
|
def get_photo(fid: str, session: dict=Depends(require_auth_flexible)):
|
||||||
"""Get photo by ID. Auth via header or query param ssetoken (for <img> tags)."""
|
"""Get photo by ID. Auth via header or query param (for <img> tags)."""
|
||||||
profile_id = str(session["profile_id"])
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute("SELECT path, profile_id FROM photos WHERE id=%s", (fid,))
|
cur.execute("SELECT path FROM photos WHERE id=%s", (fid,))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if not row:
|
if not row: raise HTTPException(404, "Photo not found")
|
||||||
raise HTTPException(404, "Photo not found")
|
photo_path = Path(PHOTOS_DIR) / row['path']
|
||||||
if str(row["profile_id"]) != profile_id:
|
if not photo_path.exists():
|
||||||
raise HTTPException(404, "Photo not found")
|
|
||||||
photo_path = resolve_photo_path(row["path"])
|
|
||||||
if not photo_path:
|
|
||||||
raise HTTPException(404, "Photo file not found")
|
raise HTTPException(404, "Photo file not found")
|
||||||
return FileResponse(photo_path)
|
return FileResponse(photo_path)
|
||||||
|
|
||||||
|
|
@ -153,38 +86,5 @@ def list_photos(x_profile_id: Optional[str]=Header(default=None), session: dict=
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"SELECT * FROM photos WHERE profile_id=%s ORDER BY created DESC LIMIT 100", (pid,))
|
||||||
SELECT * FROM photos WHERE profile_id=%s
|
|
||||||
ORDER BY COALESCE(taken_at, date::timestamptz, created) DESC NULLS LAST
|
|
||||||
LIMIT 500
|
|
||||||
""",
|
|
||||||
(pid,),
|
|
||||||
)
|
|
||||||
return [r2d(r) for r in cur.fetchall()]
|
return [r2d(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{fid}")
|
|
||||||
def delete_photo(
|
|
||||||
fid: str,
|
|
||||||
x_profile_id: Optional[str] = Header(default=None),
|
|
||||||
session: dict = Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""Delete a photo (DB row + Datei auf der Platte)."""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"SELECT path FROM photos WHERE id=%s AND profile_id=%s",
|
|
||||||
(fid, pid),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(404, "Photo not found")
|
|
||||||
photo_path = resolve_photo_path(row["path"])
|
|
||||||
cur.execute("DELETE FROM photos WHERE id=%s AND profile_id=%s", (fid, pid))
|
|
||||||
if photo_path and photo_path.exists():
|
|
||||||
try:
|
|
||||||
photo_path.unlink()
|
|
||||||
except OSError as e:
|
|
||||||
logger.warning("Could not delete photo file %s: %s", photo_path, e)
|
|
||||||
return {"ok": True, "id": fid}
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Header
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
from auth import require_auth, require_admin, require_auth_flexible
|
from auth import require_auth, require_admin
|
||||||
from models import (
|
from models import (
|
||||||
PromptCreate, PromptUpdate, PromptGenerateRequest,
|
PromptCreate, PromptUpdate, PromptGenerateRequest,
|
||||||
PipelineConfigCreate, PipelineConfigUpdate
|
PipelineConfigCreate, PipelineConfigUpdate
|
||||||
|
|
@ -254,178 +254,6 @@ def import_prompts(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# UNIFIED PROMPT SYSTEM (Issue #28 Phase 2)
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
from prompt_executor import execute_prompt_with_data
|
|
||||||
from models import UnifiedPromptCreate, UnifiedPromptUpdate
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/execute-stream")
|
|
||||||
async def execute_unified_prompt_stream(
|
|
||||||
prompt_slug: str = Query(..., description="Slug of prompt to execute"),
|
|
||||||
debug: bool = Query(False, description="Include debug information (node_states, etc.)"),
|
|
||||||
save: bool = Query(False, description="Save result to ai_insights"),
|
|
||||||
session: dict = Depends(require_auth_flexible)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Execute a unified prompt with Server-Sent Events (SSE) streaming.
|
|
||||||
|
|
||||||
Returns live progress updates during workflow execution:
|
|
||||||
- execution_started: Workflow has begun
|
|
||||||
- node_complete: Each node completes
|
|
||||||
- workflow_graph_finished: Workflow-Graph fertig (Zwischen-Info, kein Endergebnis)
|
|
||||||
- execution_complete: Endergebnis (wie POST /execute, Feld result)
|
|
||||||
- execution_failed: Error occurred
|
|
||||||
|
|
||||||
Use this endpoint for long-running workflows (>30s) to avoid gateway timeouts.
|
|
||||||
"""
|
|
||||||
profile_id = session['profile_id']
|
|
||||||
|
|
||||||
# Use default modules/timeframes (SSE doesn't support complex params)
|
|
||||||
modules = {
|
|
||||||
'körper': True,
|
|
||||||
'ernährung': True,
|
|
||||||
'training': True,
|
|
||||||
'schlaf': True,
|
|
||||||
'vitalwerte': True
|
|
||||||
}
|
|
||||||
|
|
||||||
timeframes = {
|
|
||||||
'körper': 30,
|
|
||||||
'ernährung': 30,
|
|
||||||
'training': 14,
|
|
||||||
'schlaf': 14,
|
|
||||||
'vitalwerte': 7
|
|
||||||
}
|
|
||||||
|
|
||||||
# Wrapper function for OpenRouter calls
|
|
||||||
async def workflow_llm_call(prompt: str, model: str = None) -> str:
|
|
||||||
return await call_openrouter(prompt)
|
|
||||||
|
|
||||||
# SSE Event Generator
|
|
||||||
async def event_stream():
|
|
||||||
"""Generate Server-Sent Events during workflow execution."""
|
|
||||||
import asyncio
|
|
||||||
from asyncio import Queue
|
|
||||||
|
|
||||||
# Event queue for progress updates
|
|
||||||
event_queue = Queue()
|
|
||||||
|
|
||||||
# Flag to track execution completion
|
|
||||||
execution_complete = False
|
|
||||||
|
|
||||||
# Define progress callback for streaming updates
|
|
||||||
async def progress_callback(event_type: str, data: dict):
|
|
||||||
"""Queue SSE event for streaming to client."""
|
|
||||||
event_data = {
|
|
||||||
"type": event_type,
|
|
||||||
**data
|
|
||||||
}
|
|
||||||
await event_queue.put(event_data)
|
|
||||||
|
|
||||||
# Start workflow execution in background task
|
|
||||||
async def execute_workflow_async():
|
|
||||||
nonlocal execution_complete
|
|
||||||
try:
|
|
||||||
# Execute workflow with progress callbacks
|
|
||||||
result = await execute_prompt_with_data(
|
|
||||||
prompt_slug=prompt_slug,
|
|
||||||
profile_id=profile_id,
|
|
||||||
modules=modules,
|
|
||||||
timeframes=timeframes,
|
|
||||||
openrouter_call_func=workflow_llm_call,
|
|
||||||
enable_debug=debug or save,
|
|
||||||
progress_callback=progress_callback
|
|
||||||
)
|
|
||||||
|
|
||||||
# Save to ai_insights if requested (same logic as /execute)
|
|
||||||
if save:
|
|
||||||
if result['type'] == 'pipeline':
|
|
||||||
final_output = result.get('output', {})
|
|
||||||
if isinstance(final_output, dict) and len(final_output) == 1:
|
|
||||||
content = list(final_output.values())[0]
|
|
||||||
else:
|
|
||||||
content = json.dumps(final_output, ensure_ascii=False)
|
|
||||||
elif result['type'] == 'workflow':
|
|
||||||
content = _workflow_user_facing_content(result.get('aggregated_result'))
|
|
||||||
else:
|
|
||||||
content = result.get('output', '')
|
|
||||||
if isinstance(content, dict):
|
|
||||||
content = json.dumps(content, ensure_ascii=False)
|
|
||||||
|
|
||||||
# Save to database (minimal metadata for now)
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""INSERT INTO ai_insights (profile_id, scope, content, metadata, created)
|
|
||||||
VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP)""",
|
|
||||||
(profile_id, prompt_slug, content, json.dumps({"prompt_type": result['type']}))
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
# Pflicht für alle Prompt-Typen: Pipeline/Base rufen keinen progress_callback
|
|
||||||
# mit Abschluss auf — ohne dieses Event endet SSE ohne resolve → „Connection to server lost“.
|
|
||||||
try:
|
|
||||||
sse_payload = json.loads(json.dumps(result, default=str))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
sse_payload = {
|
|
||||||
"type": result.get("type", "unknown"),
|
|
||||||
"error": "result_not_serializable",
|
|
||||||
}
|
|
||||||
await event_queue.put({
|
|
||||||
"type": "execution_complete",
|
|
||||||
"result": sse_payload,
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# Queue error event
|
|
||||||
await event_queue.put({
|
|
||||||
"type": "execution_failed",
|
|
||||||
"error": str(e)
|
|
||||||
})
|
|
||||||
finally:
|
|
||||||
execution_complete = True
|
|
||||||
|
|
||||||
# Start workflow execution in background
|
|
||||||
import asyncio
|
|
||||||
execution_task = asyncio.create_task(execute_workflow_async())
|
|
||||||
|
|
||||||
# Stream events from queue
|
|
||||||
try:
|
|
||||||
while not execution_complete or not event_queue.empty():
|
|
||||||
try:
|
|
||||||
# Wait for event with timeout
|
|
||||||
event = await asyncio.wait_for(event_queue.get(), timeout=0.5)
|
|
||||||
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
# Send keepalive ping
|
|
||||||
yield f": keepalive\n\n"
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Wait for execution task to complete
|
|
||||||
await execution_task
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# Send final error event
|
|
||||||
error_event = {
|
|
||||||
"type": "execution_failed",
|
|
||||||
"error": str(e)
|
|
||||||
}
|
|
||||||
yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n"
|
|
||||||
|
|
||||||
return StreamingResponse(
|
|
||||||
event_stream(),
|
|
||||||
media_type="text/event-stream",
|
|
||||||
headers={
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
"Connection": "keep-alive",
|
|
||||||
"X-Accel-Buffering": "no" # Disable nginx buffering
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{prompt_id}")
|
@router.get("/{prompt_id}")
|
||||||
def get_prompt(prompt_id: str, session: dict=Depends(require_auth)):
|
def get_prompt(prompt_id: str, session: dict=Depends(require_auth)):
|
||||||
"""Get single AI prompt by ID (UUID)."""
|
"""Get single AI prompt by ID (UUID)."""
|
||||||
|
|
@ -1609,6 +1437,177 @@ def reset_prompt_to_default(prompt_id: str, session: dict=Depends(require_admin)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# UNIFIED PROMPT SYSTEM (Issue #28 Phase 2)
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
from prompt_executor import execute_prompt_with_data
|
||||||
|
from models import UnifiedPromptCreate, UnifiedPromptUpdate
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/execute-stream")
|
||||||
|
async def execute_unified_prompt_stream(
|
||||||
|
prompt_slug: str = Query(..., description="Slug of prompt to execute"),
|
||||||
|
token: Optional[str] = Query(None, description="Auth token (temporary solution for SSE)"),
|
||||||
|
modules: Optional[dict] = None,
|
||||||
|
timeframes: Optional[dict] = None,
|
||||||
|
debug: bool = Query(False, description="Include debug information (node_states, etc.)"),
|
||||||
|
save: bool = Query(False, description="Save result to ai_insights")
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Execute a unified prompt with Server-Sent Events (SSE) streaming.
|
||||||
|
|
||||||
|
Returns live progress updates during workflow execution:
|
||||||
|
- execution_started: Workflow has begun
|
||||||
|
- node_complete: Each node completes
|
||||||
|
- execution_complete: Final result ready
|
||||||
|
- execution_failed: Error occurred
|
||||||
|
|
||||||
|
Use this endpoint for long-running workflows (>30s) to avoid gateway timeouts.
|
||||||
|
"""
|
||||||
|
# Manual auth: verify token and get profile_id
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(401, "Missing auth token")
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute("SELECT profile_id FROM sessions WHERE token = %s", (token,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(401, "Invalid or expired token")
|
||||||
|
profile_id = row['profile_id']
|
||||||
|
|
||||||
|
# Use default modules/timeframes if not provided
|
||||||
|
if not modules:
|
||||||
|
modules = {
|
||||||
|
'körper': True,
|
||||||
|
'ernährung': True,
|
||||||
|
'training': True,
|
||||||
|
'schlaf': True,
|
||||||
|
'vitalwerte': True
|
||||||
|
}
|
||||||
|
|
||||||
|
if not timeframes:
|
||||||
|
timeframes = {
|
||||||
|
'körper': 30,
|
||||||
|
'ernährung': 30,
|
||||||
|
'training': 14,
|
||||||
|
'schlaf': 14,
|
||||||
|
'vitalwerte': 7
|
||||||
|
}
|
||||||
|
|
||||||
|
# Wrapper function for OpenRouter calls
|
||||||
|
async def workflow_llm_call(prompt: str, model: str = None) -> str:
|
||||||
|
return await call_openrouter(prompt)
|
||||||
|
|
||||||
|
# SSE Event Generator
|
||||||
|
async def event_stream():
|
||||||
|
"""Generate Server-Sent Events during workflow execution."""
|
||||||
|
import asyncio
|
||||||
|
from asyncio import Queue
|
||||||
|
|
||||||
|
# Event queue for progress updates
|
||||||
|
event_queue = Queue()
|
||||||
|
|
||||||
|
# Flag to track execution completion
|
||||||
|
execution_complete = False
|
||||||
|
|
||||||
|
# Define progress callback for streaming updates
|
||||||
|
async def progress_callback(event_type: str, data: dict):
|
||||||
|
"""Queue SSE event for streaming to client."""
|
||||||
|
event_data = {
|
||||||
|
"type": event_type,
|
||||||
|
**data
|
||||||
|
}
|
||||||
|
await event_queue.put(event_data)
|
||||||
|
|
||||||
|
# Start workflow execution in background task
|
||||||
|
async def execute_workflow_async():
|
||||||
|
nonlocal execution_complete
|
||||||
|
try:
|
||||||
|
# Execute workflow with progress callbacks
|
||||||
|
result = await execute_prompt_with_data(
|
||||||
|
prompt_slug=prompt_slug,
|
||||||
|
profile_id=profile_id,
|
||||||
|
modules=modules,
|
||||||
|
timeframes=timeframes,
|
||||||
|
openrouter_call_func=workflow_llm_call,
|
||||||
|
enable_debug=debug or save,
|
||||||
|
progress_callback=progress_callback
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save to ai_insights if requested (same logic as /execute)
|
||||||
|
if save:
|
||||||
|
if result['type'] == 'pipeline':
|
||||||
|
final_output = result.get('output', {})
|
||||||
|
if isinstance(final_output, dict) and len(final_output) == 1:
|
||||||
|
content = list(final_output.values())[0]
|
||||||
|
else:
|
||||||
|
content = json.dumps(final_output, ensure_ascii=False)
|
||||||
|
elif result['type'] == 'workflow':
|
||||||
|
content = _workflow_user_facing_content(result.get('aggregated_result'))
|
||||||
|
else:
|
||||||
|
content = result.get('output', '')
|
||||||
|
if isinstance(content, dict):
|
||||||
|
content = json.dumps(content, ensure_ascii=False)
|
||||||
|
|
||||||
|
# Save to database (minimal metadata for now)
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
"""INSERT INTO ai_insights (profile_id, scope, content, metadata, created)
|
||||||
|
VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP)""",
|
||||||
|
(profile_id, prompt_slug, content, json.dumps({"prompt_type": result['type']}))
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Queue error event
|
||||||
|
await event_queue.put({
|
||||||
|
"type": "execution_failed",
|
||||||
|
"error": str(e)
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
execution_complete = True
|
||||||
|
|
||||||
|
# Start workflow execution in background
|
||||||
|
import asyncio
|
||||||
|
execution_task = asyncio.create_task(execute_workflow_async())
|
||||||
|
|
||||||
|
# Stream events from queue
|
||||||
|
try:
|
||||||
|
while not execution_complete or not event_queue.empty():
|
||||||
|
try:
|
||||||
|
# Wait for event with timeout
|
||||||
|
event = await asyncio.wait_for(event_queue.get(), timeout=0.5)
|
||||||
|
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
# Send keepalive ping
|
||||||
|
yield f": keepalive\n\n"
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Wait for execution task to complete
|
||||||
|
await execution_task
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Send final error event
|
||||||
|
error_event = {
|
||||||
|
"type": "execution_failed",
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
event_stream(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no" # Disable nginx buffering
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/execute")
|
@router.post("/execute")
|
||||||
async def execute_unified_prompt(
|
async def execute_unified_prompt(
|
||||||
prompt_slug: str = Query(..., description="Slug of prompt to execute"),
|
prompt_slug: str = Query(..., description="Slug of prompt to execute"),
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,6 @@ from psycopg2.extras import Json
|
||||||
from auth import require_auth
|
from auth import require_auth
|
||||||
from data_layer.reference_values import (
|
from data_layer.reference_values import (
|
||||||
fetch_reference_type_by_key,
|
fetch_reference_type_by_key,
|
||||||
get_profile_reference_values_current_snapshot,
|
|
||||||
get_profile_reference_values_recent_snapshot,
|
|
||||||
get_profile_reference_values_summary,
|
get_profile_reference_values_summary,
|
||||||
list_active_reference_value_types_data,
|
list_active_reference_value_types_data,
|
||||||
list_profile_reference_values_for_type,
|
list_profile_reference_values_for_type,
|
||||||
|
|
@ -95,48 +93,6 @@ def profile_reference_values_summary(
|
||||||
return get_profile_reference_values_summary(pid)
|
return get_profile_reference_values_summary(pid)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/profile-reference-values/snapshot-current")
|
|
||||||
def profile_reference_values_snapshot_current(
|
|
||||||
x_profile_id: Optional[str] = Header(default=None),
|
|
||||||
session: dict = Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Layer 1: Alle aktuellen Referenzwerte (jüngster Eintrag pro Typ), wie Platzhalter
|
|
||||||
``{{reference_values_current_json}}``.
|
|
||||||
"""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
return get_profile_reference_values_current_snapshot(pid)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/profile-reference-values/snapshot-recent")
|
|
||||||
def profile_reference_values_snapshot_recent(
|
|
||||||
limit_per_type: int = Query(5, ge=1, le=50, description="Einträge pro Referenztyp (neueste zuerst)"),
|
|
||||||
date_from: Optional[str] = Query(None, description="Filter effective_date >= (YYYY-MM-DD)"),
|
|
||||||
date_to: Optional[str] = Query(None, description="Filter effective_date <= (YYYY-MM-DD)"),
|
|
||||||
x_profile_id: Optional[str] = Header(default=None),
|
|
||||||
session: dict = Depends(require_auth),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Layer 1: Bis zu N Einträge pro Typ (Verlauf), wie Platzhalter ``{{reference_values_recent_json}}``.
|
|
||||||
"""
|
|
||||||
pid = get_pid(x_profile_id)
|
|
||||||
df = date_from
|
|
||||||
dto = date_to
|
|
||||||
for label, raw in (("date_from", df), ("date_to", dto)):
|
|
||||||
if raw is None or not str(raw).strip():
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
datetime.strptime(str(raw).strip(), "%Y-%m-%d")
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(400, f"{label}: Ungültiges Datum, Format YYYY-MM-DD")
|
|
||||||
return get_profile_reference_values_recent_snapshot(
|
|
||||||
pid,
|
|
||||||
limit_per_type=limit_per_type,
|
|
||||||
date_from=df,
|
|
||||||
date_to=dto,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/profile-reference-values")
|
@router.get("/profile-reference-values")
|
||||||
def list_profile_reference_values(
|
def list_profile_reference_values(
|
||||||
type_key: str = Query(..., description="Schlüssel aus reference_value_types.key"),
|
type_key: str = Query(..., description="Schlüssel aus reference_value_types.key"),
|
||||||
|
|
|
||||||
|
|
@ -1,324 +0,0 @@
|
||||||
"""
|
|
||||||
Strukturierter PDF-Bericht: mehrere Definitionen pro Profil, Katalog, PDF-Erzeugung.
|
|
||||||
|
|
||||||
PDF-Zähler: data_export (wie andere Exporte).
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
|
||||||
from fastapi.responses import Response
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from psycopg2.extras import Json
|
|
||||||
|
|
||||||
from auth import check_feature_access, increment_feature_usage, require_auth
|
|
||||||
from db import get_cursor, get_db
|
|
||||||
from feature_logger import log_feature_usage
|
|
||||||
from report_chart_fetch import CHART_CATALOG_FOR_API
|
|
||||||
from report_pdf_render import build_structured_report_pdf
|
|
||||||
from report_profile_schema import (
|
|
||||||
ALLOWED_VIZ_BUNDLE_IDS,
|
|
||||||
ReportProfilePayload,
|
|
||||||
default_report_profile_dict,
|
|
||||||
parse_report_profile,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_MAX_REPORT_DEFINITIONS = 20
|
|
||||||
|
|
||||||
|
|
||||||
class CreateReportDefinitionBody(BaseModel):
|
|
||||||
name: str = Field(default="Neuer Bericht", min_length=1, max_length=120)
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateReportDefinitionBody(BaseModel):
|
|
||||||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
|
||||||
payload: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class GeneratePdfRequest(BaseModel):
|
|
||||||
definition_id: UUID | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _profile_display_name(profile_id: str) -> str:
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT name FROM profiles WHERE id = %s", (profile_id,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return "Profil"
|
|
||||||
return (row.get("name") or "Profil").strip() or "Profil"
|
|
||||||
|
|
||||||
|
|
||||||
def _row_to_definition(row: dict) -> dict:
|
|
||||||
pl = row.get("payload")
|
|
||||||
if not isinstance(pl, dict):
|
|
||||||
pl = {}
|
|
||||||
return {
|
|
||||||
"id": str(row["id"]),
|
|
||||||
"name": (row.get("name") or "Bericht").strip() or "Bericht",
|
|
||||||
"sort_order": int(row.get("sort_order") or 0),
|
|
||||||
"updated_at": row.get("updated_at").isoformat() if row.get("updated_at") else None,
|
|
||||||
"payload": pl,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/catalog")
|
|
||||||
def get_reports_catalog(session: dict = Depends(require_auth)):
|
|
||||||
"""Metadaten für UI: verfügbare Diagramme, Bundles, Zeitraumgrenzen."""
|
|
||||||
viz_titles = {
|
|
||||||
"body_history_viz": "Körper (Verlauf-Bundle)",
|
|
||||||
"nutrition_history_viz": "Ernährung (Verlauf-Bundle)",
|
|
||||||
"fitness_history_viz": "Fitness (Verlauf-Bundle)",
|
|
||||||
"recovery_history_viz": "Erholung (Verlauf-Bundle)",
|
|
||||||
"history_overview_viz": "Gesamtübersicht (Korrelationen)",
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"catalog_version": 3,
|
|
||||||
"chart_days": {"min": 7, "max": 90},
|
|
||||||
"charts": CHART_CATALOG_FOR_API,
|
|
||||||
"viz_bundles": [{"id": bid, "title": viz_titles.get(bid, bid)} for bid in sorted(ALLOWED_VIZ_BUNDLE_IDS)],
|
|
||||||
"block_types": [
|
|
||||||
{"id": "section", "title": "Überschrift"},
|
|
||||||
{"id": "viz_bundle", "title": "Verlauf-Bundle (KPIs & Charts)"},
|
|
||||||
{"id": "chart", "title": "Einzel-Diagramm (Legacy)"},
|
|
||||||
{"id": "ai_insight", "title": "KI-Auswertung"},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/definitions")
|
|
||||||
def list_report_definitions(session: dict = Depends(require_auth)):
|
|
||||||
pid = session["profile_id"]
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, name, sort_order, updated_at, payload
|
|
||||||
FROM report_definitions
|
|
||||||
WHERE profile_id = %s
|
|
||||||
ORDER BY sort_order ASC, name ASC, updated_at DESC
|
|
||||||
""",
|
|
||||||
(pid,),
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
return {"definitions": [_row_to_definition(dict(r)) for r in rows]}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/definitions")
|
|
||||||
def create_report_definition(
|
|
||||||
body: CreateReportDefinitionBody | None = Body(default=None),
|
|
||||||
session: dict = Depends(require_auth),
|
|
||||||
):
|
|
||||||
req = body or CreateReportDefinitionBody()
|
|
||||||
pid = session["profile_id"]
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"SELECT COUNT(*) AS n FROM report_definitions WHERE profile_id = %s",
|
|
||||||
(pid,),
|
|
||||||
)
|
|
||||||
n = int((cur.fetchone() or {}).get("n") or 0)
|
|
||||||
if n >= _MAX_REPORT_DEFINITIONS:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Maximal {_MAX_REPORT_DEFINITIONS} Berichte erlaubt.",
|
|
||||||
)
|
|
||||||
|
|
||||||
payload_dict = default_report_profile_dict()
|
|
||||||
payload_dict["document_title"] = ""
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 AS next_so FROM report_definitions WHERE profile_id = %s",
|
|
||||||
(pid,),
|
|
||||||
)
|
|
||||||
next_so = int((cur.fetchone() or {}).get("next_so") or 0)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO report_definitions (profile_id, name, payload, sort_order, updated_at)
|
|
||||||
VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP)
|
|
||||||
RETURNING id, name, sort_order, updated_at, payload
|
|
||||||
""",
|
|
||||||
(pid, req.name.strip(), Json(payload_dict), next_so),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(status_code=500, detail="Bericht konnte nicht angelegt werden.")
|
|
||||||
return {"definition": _row_to_definition(dict(row))}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/definitions/{definition_id}")
|
|
||||||
def update_report_definition(
|
|
||||||
definition_id: UUID,
|
|
||||||
body: UpdateReportDefinitionBody,
|
|
||||||
session: dict = Depends(require_auth),
|
|
||||||
):
|
|
||||||
pid = session["profile_id"]
|
|
||||||
name = body.name.strip() if body.name else None
|
|
||||||
parsed_payload: ReportProfilePayload | None = None
|
|
||||||
if body.payload is not None:
|
|
||||||
try:
|
|
||||||
parsed_payload = ReportProfilePayload.model_validate(body.payload)
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
if parsed_payload is not None and name is not None:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
UPDATE report_definitions
|
|
||||||
SET name = %s, payload = %s, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = %s AND profile_id = %s
|
|
||||||
RETURNING id, name, sort_order, updated_at, payload
|
|
||||||
""",
|
|
||||||
(name, Json(parsed_payload.to_stored_dict()), str(definition_id), pid),
|
|
||||||
)
|
|
||||||
elif parsed_payload is not None:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
UPDATE report_definitions
|
|
||||||
SET payload = %s, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = %s AND profile_id = %s
|
|
||||||
RETURNING id, name, sort_order, updated_at, payload
|
|
||||||
""",
|
|
||||||
(Json(parsed_payload.to_stored_dict()), str(definition_id), pid),
|
|
||||||
)
|
|
||||||
elif name is not None:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
UPDATE report_definitions
|
|
||||||
SET name = %s, updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = %s AND profile_id = %s
|
|
||||||
RETURNING id, name, sort_order, updated_at, payload
|
|
||||||
""",
|
|
||||||
(name, str(definition_id), pid),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=400, detail="Nichts zu aktualisieren (name oder payload fehlt).")
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(status_code=404, detail="Bericht nicht gefunden.")
|
|
||||||
return {"definition": _row_to_definition(dict(row))}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/definitions/{definition_id}")
|
|
||||||
def delete_report_definition(definition_id: UUID, session: dict = Depends(require_auth)):
|
|
||||||
pid = session["profile_id"]
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"DELETE FROM report_definitions WHERE id = %s AND profile_id = %s RETURNING id",
|
|
||||||
(str(definition_id), pid),
|
|
||||||
)
|
|
||||||
deleted = cur.fetchone()
|
|
||||||
conn.commit()
|
|
||||||
if not deleted:
|
|
||||||
raise HTTPException(status_code=404, detail="Bericht nicht gefunden.")
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_definition_payload(profile_id: str, definition_id: UUID | None) -> tuple[dict, str]:
|
|
||||||
"""Returns (raw_payload_dict, report_label_for_filename)."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
if definition_id is not None:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT payload, name FROM report_definitions
|
|
||||||
WHERE id = %s AND profile_id = %s
|
|
||||||
""",
|
|
||||||
(str(definition_id), profile_id),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(status_code=404, detail="Bericht nicht gefunden.")
|
|
||||||
pl = row.get("payload")
|
|
||||||
label = (row.get("name") or "Bericht").strip() or "Bericht"
|
|
||||||
if not isinstance(pl, dict):
|
|
||||||
raise HTTPException(status_code=400, detail="Ungültige Berichtsdaten.")
|
|
||||||
return pl, label
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT payload, name FROM report_definitions
|
|
||||||
WHERE profile_id = %s
|
|
||||||
ORDER BY sort_order ASC, name ASC, updated_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Kein Bericht angelegt — bitte unter Einstellungen › PDF-Berichte einen Bericht erstellen.",
|
|
||||||
)
|
|
||||||
pl = row.get("payload")
|
|
||||||
label = (row.get("name") or "Bericht").strip() or "Bericht"
|
|
||||||
if not isinstance(pl, dict):
|
|
||||||
raise HTTPException(status_code=400, detail="Ungültige Berichtsdaten.")
|
|
||||||
return pl, label
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/generate-pdf")
|
|
||||||
def generate_structured_report_pdf(
|
|
||||||
body: GeneratePdfRequest | None = Body(default=None),
|
|
||||||
session: dict = Depends(require_auth),
|
|
||||||
):
|
|
||||||
pid = session["profile_id"]
|
|
||||||
req = body or GeneratePdfRequest()
|
|
||||||
|
|
||||||
access = check_feature_access(pid, "data_export")
|
|
||||||
log_feature_usage(pid, "data_export", access, "report_generate_pdf")
|
|
||||||
if not access["allowed"]:
|
|
||||||
logger.warning(
|
|
||||||
"[FEATURE-LIMIT] report pdf blocked: %s used=%s limit=%s",
|
|
||||||
pid,
|
|
||||||
access.get("used"),
|
|
||||||
access.get("limit"),
|
|
||||||
)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403,
|
|
||||||
detail=(
|
|
||||||
"Limit erreicht: Daten-Export nicht möglich "
|
|
||||||
f"({access.get('used')}/{access.get('limit')})."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
raw, report_label = _fetch_definition_payload(pid, req.definition_id)
|
|
||||||
try:
|
|
||||||
payload = parse_report_profile(raw)
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Berichtsprofil ungültig: {e}")
|
|
||||||
|
|
||||||
profile_name = _profile_display_name(pid)
|
|
||||||
try:
|
|
||||||
pdf_bytes = build_structured_report_pdf(profile_id=pid, profile_name=profile_name, payload=payload)
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("report pdf build failed")
|
|
||||||
raise HTTPException(status_code=500, detail=f"PDF-Erzeugung fehlgeschlagen: {e}")
|
|
||||||
|
|
||||||
increment_feature_usage(pid, "data_export")
|
|
||||||
|
|
||||||
doc_title = (payload.document_title or "").strip()
|
|
||||||
base_label = doc_title or report_label
|
|
||||||
safe_name = "".join(c for c in base_label if c.isalnum() or c in (" ", "-", "_")).strip() or "bericht"
|
|
||||||
fn = f"mitai-bericht-{safe_name.replace(' ', '-')}-{datetime.now().strftime('%Y-%m-%d')}.pdf"
|
|
||||||
return Response(
|
|
||||||
content=pdf_bytes,
|
|
||||||
media_type="application/pdf",
|
|
||||||
headers={"Content-Disposition": f'attachment; filename="{fn}"'},
|
|
||||||
)
|
|
||||||
|
|
@ -104,7 +104,6 @@ CREATE TABLE IF NOT EXISTS circumference_log (
|
||||||
c_thigh NUMERIC(5,2),
|
c_thigh NUMERIC(5,2),
|
||||||
c_calf NUMERIC(5,2),
|
c_calf NUMERIC(5,2),
|
||||||
c_arm NUMERIC(5,2),
|
c_arm NUMERIC(5,2),
|
||||||
c_arm_relaxed NUMERIC(5,2),
|
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
photo_id UUID,
|
photo_id UUID,
|
||||||
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
|
@ -181,7 +180,6 @@ CREATE TABLE IF NOT EXISTS photos (
|
||||||
meas_id UUID, -- Legacy: reference to measurement (circumference/caliper)
|
meas_id UUID, -- Legacy: reference to measurement (circumference/caliper)
|
||||||
date DATE,
|
date DATE,
|
||||||
path TEXT NOT NULL,
|
path TEXT NOT NULL,
|
||||||
taken_at TIMESTAMPTZ, -- Aufnahmezeit aus EXIF (optional)
|
|
||||||
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,179 +0,0 @@
|
||||||
"""
|
|
||||||
Diagnose: Was liegt in activity_session_metrics (EAV) vs. activity_log?
|
|
||||||
|
|
||||||
Ausführung (mit gesetzten DB_*-Variablen wie die App, z. B. aus .env):
|
|
||||||
|
|
||||||
cd backend
|
|
||||||
python scripts/inspect_activity_eav.py
|
|
||||||
|
|
||||||
Lokal ohne Docker-Hostname: z. B. ``set DB_HOST=127.0.0.1`` (Windows) / ``export DB_HOST=127.0.0.1``,
|
|
||||||
Port/User/Pass wie in der laufenden Postgres-Instanz.
|
|
||||||
|
|
||||||
Im Backend-Container (Compose-Service meist ``backend``, Arbeitsverzeichnis ``/app``):
|
|
||||||
|
|
||||||
docker compose exec backend python /app/scripts/inspect_activity_eav.py
|
|
||||||
|
|
||||||
Optional:
|
|
||||||
python scripts/inspect_activity_eav.py --limit 30
|
|
||||||
python scripts/inspect_activity_eav.py --profile <uuid>
|
|
||||||
python scripts/inspect_activity_eav.py --activity <activity_log uuid>
|
|
||||||
|
|
||||||
Keine Schreibzugriffe — nur SELECT.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# backend/ als Import-Root
|
|
||||||
_BACKEND_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
||||||
if _BACKEND_ROOT not in sys.path:
|
|
||||||
sys.path.insert(0, _BACKEND_ROOT)
|
|
||||||
|
|
||||||
|
|
||||||
def _val_row(r: dict) -> str | None:
|
|
||||||
dt = r.get("data_type")
|
|
||||||
if dt == "integer":
|
|
||||||
v = r.get("value_int")
|
|
||||||
return str(v) if v is not None else None
|
|
||||||
if dt == "float":
|
|
||||||
v = r.get("value_num")
|
|
||||||
return str(v) if v is not None else None
|
|
||||||
if dt == "string":
|
|
||||||
v = r.get("value_text")
|
|
||||||
return repr(v) if v is not None else None
|
|
||||||
if dt == "boolean":
|
|
||||||
v = r.get("value_bool")
|
|
||||||
return str(v) if v is not None else None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
parser = argparse.ArgumentParser(description="EAV activity_session_metrics inspizieren")
|
|
||||||
parser.add_argument("--limit", type=int, default=40, help="Zeilen Report A/B")
|
|
||||||
parser.add_argument("--profile", type=str, default=None, help="profile_id filtern")
|
|
||||||
parser.add_argument("--activity", type=str, default=None, help="activity_log.id (einzelne Session)")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
|
||||||
|
|
||||||
if args.activity:
|
|
||||||
with get_db() as conn:
|
|
||||||
with get_cursor(conn) as cur:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT al.id, al.profile_id, al.date, al.start_time, al.source,
|
|
||||||
al.training_category, al.training_type_id, al.activity_type,
|
|
||||||
al.duration_min, al.kcal_active, al.hr_avg, al.hr_max, al.distance_km
|
|
||||||
FROM activity_log al
|
|
||||||
WHERE al.id = %s::uuid
|
|
||||||
""",
|
|
||||||
(args.activity,),
|
|
||||||
)
|
|
||||||
h = cur.fetchone()
|
|
||||||
if not h:
|
|
||||||
print("activity_log: keine Zeile für diese id")
|
|
||||||
return
|
|
||||||
print("=== activity_log (Kopfzeile) ===")
|
|
||||||
for k, v in dict(h).items():
|
|
||||||
print(f" {k}: {v}")
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT m.id AS metric_id, tp.key, tp.data_type, tp.source_field,
|
|
||||||
m.value_num, m.value_int, m.value_text, m.value_bool, m.updated_at
|
|
||||||
FROM activity_session_metrics m
|
|
||||||
JOIN training_parameters tp ON tp.id = m.training_parameter_id
|
|
||||||
WHERE m.activity_log_id = %s::uuid
|
|
||||||
ORDER BY tp.key
|
|
||||||
""",
|
|
||||||
(args.activity,),
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
print(f"\n=== activity_session_metrics ({len(rows)} Zeilen) ===")
|
|
||||||
for r in rows:
|
|
||||||
d = dict(r)
|
|
||||||
print(
|
|
||||||
f" {d['key']} ({d['data_type']}) "
|
|
||||||
f"value={_val_row(d)!r} source_field={d.get('source_field')!r} "
|
|
||||||
f"updated_at={d.get('updated_at')}"
|
|
||||||
)
|
|
||||||
if not rows:
|
|
||||||
print(" (keine EAV-Zeilen)")
|
|
||||||
return
|
|
||||||
|
|
||||||
prof_filter = ""
|
|
||||||
if args.profile:
|
|
||||||
prof_filter = " AND al.profile_id = %s::uuid "
|
|
||||||
params_a: tuple = (args.profile, args.limit) if args.profile else (args.limit,)
|
|
||||||
params_b: tuple = (args.profile, args.limit) if args.profile else (args.limit,)
|
|
||||||
|
|
||||||
q_recent_eav = f"""
|
|
||||||
SELECT
|
|
||||||
al.id AS activity_id,
|
|
||||||
al.profile_id,
|
|
||||||
al.date,
|
|
||||||
al.start_time,
|
|
||||||
al.source,
|
|
||||||
al.training_type_id,
|
|
||||||
al.training_category,
|
|
||||||
tp.key AS parameter_key,
|
|
||||||
tp.data_type,
|
|
||||||
tp.source_field AS tp_source_field,
|
|
||||||
m.value_num,
|
|
||||||
m.value_int,
|
|
||||||
m.value_text,
|
|
||||||
m.value_bool,
|
|
||||||
m.updated_at
|
|
||||||
FROM activity_session_metrics m
|
|
||||||
JOIN activity_log al ON al.id = m.activity_log_id
|
|
||||||
JOIN training_parameters tp ON tp.id = m.training_parameter_id
|
|
||||||
WHERE 1=1 {prof_filter}
|
|
||||||
ORDER BY m.updated_at DESC NULLS LAST, al.date DESC, al.start_time DESC
|
|
||||||
LIMIT %s
|
|
||||||
"""
|
|
||||||
|
|
||||||
q_csv_no_eav = f"""
|
|
||||||
SELECT
|
|
||||||
al.id AS activity_id,
|
|
||||||
al.profile_id,
|
|
||||||
al.date,
|
|
||||||
al.start_time,
|
|
||||||
al.source,
|
|
||||||
al.training_type_id,
|
|
||||||
al.training_category,
|
|
||||||
(SELECT COUNT(*) FROM activity_session_metrics m WHERE m.activity_log_id = al.id) AS eav_count
|
|
||||||
FROM activity_log al
|
|
||||||
WHERE al.source = 'csv' {prof_filter}
|
|
||||||
ORDER BY al.date DESC, al.start_time DESC
|
|
||||||
LIMIT %s
|
|
||||||
"""
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
with get_cursor(conn) as cur:
|
|
||||||
print("=== A) Neueste EAV-Zeilen (join activity_log + training_parameters) ===\n")
|
|
||||||
cur.execute(q_recent_eav, params_a)
|
|
||||||
for r in cur.fetchall():
|
|
||||||
d = dict(r)
|
|
||||||
v = _val_row(d)
|
|
||||||
print(
|
|
||||||
f"{d['date']} {d['start_time']} | {d['activity_id']} | src={d['source']!r} | "
|
|
||||||
f"type={d['training_type_id']} cat={d['training_category']!r} | "
|
|
||||||
f"{d['parameter_key']}={v!r} (tp.source_field={d.get('tp_source_field')!r})"
|
|
||||||
)
|
|
||||||
|
|
||||||
print("\n=== B) Neueste CSV-importierte Sessions: EAV-Anzahl pro Zeile ===\n")
|
|
||||||
cur.execute(q_csv_no_eav, params_b)
|
|
||||||
for r in cur.fetchall():
|
|
||||||
d = dict(r)
|
|
||||||
print(
|
|
||||||
f"{d['date']} {d['start_time']} | {d['activity_id']} | "
|
|
||||||
f"type={d['training_type_id']} | eav_count={d['eav_count']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
print("\nFertig. Für eine Session im Detail: --activity <uuid>")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -1,582 +0,0 @@
|
||||||
"""Unit tests for data_layer.activity_session_metrics (no DB for most cases)."""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from data_layer.activity_session_metrics import (
|
|
||||||
_normalize_metric_value_for_read,
|
|
||||||
ActivitySessionMetricsError,
|
|
||||||
enrich_sessions_with_metrics,
|
|
||||||
merge_column_backed_and_eav_metrics,
|
|
||||||
merge_parameter_schema_rows,
|
|
||||||
resolve_activity_attribute_schema,
|
|
||||||
upsert_session_metrics_from_csv_mapped,
|
|
||||||
_row_value_tuple,
|
|
||||||
_validate_single_value,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _tp_row(
|
|
||||||
pid: int,
|
|
||||||
key: str,
|
|
||||||
*,
|
|
||||||
data_type: str = "integer",
|
|
||||||
cat_sort: int = 0,
|
|
||||||
cat_required: bool = False,
|
|
||||||
name_de: str = "X",
|
|
||||||
name_en: str = "X",
|
|
||||||
param_category: str = "physical",
|
|
||||||
unit: str | None = None,
|
|
||||||
validation_rules: dict | None = None,
|
|
||||||
source_field: str | None = None,
|
|
||||||
):
|
|
||||||
return {
|
|
||||||
"training_parameter_id": pid,
|
|
||||||
"cat_sort": cat_sort,
|
|
||||||
"cat_required": cat_required,
|
|
||||||
"cat_ui_group": None,
|
|
||||||
"key": key,
|
|
||||||
"name_de": name_de,
|
|
||||||
"name_en": name_en,
|
|
||||||
"param_category": param_category,
|
|
||||||
"data_type": data_type,
|
|
||||||
"unit": unit,
|
|
||||||
"validation_rules": validation_rules or {},
|
|
||||||
"source_field": source_field,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _ttp_row(
|
|
||||||
pid: int,
|
|
||||||
key: str,
|
|
||||||
*,
|
|
||||||
typ_sort: int | None = None,
|
|
||||||
typ_required: bool | None = None,
|
|
||||||
typ_ui_group: str | None = None,
|
|
||||||
data_type: str = "integer",
|
|
||||||
name_de: str = "X",
|
|
||||||
name_en: str = "X",
|
|
||||||
param_category: str = "physical",
|
|
||||||
unit: str | None = None,
|
|
||||||
validation_rules: dict | None = None,
|
|
||||||
source_field: str | None = None,
|
|
||||||
):
|
|
||||||
return {
|
|
||||||
"training_parameter_id": pid,
|
|
||||||
"typ_sort": typ_sort,
|
|
||||||
"typ_required": typ_required,
|
|
||||||
"typ_ui_group": typ_ui_group,
|
|
||||||
"key": key,
|
|
||||||
"name_de": name_de,
|
|
||||||
"name_en": name_en,
|
|
||||||
"param_category": param_category,
|
|
||||||
"data_type": data_type,
|
|
||||||
"unit": unit,
|
|
||||||
"validation_rules": validation_rules or {},
|
|
||||||
"source_field": source_field,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_category_only_sorted_by_sort_order_then_key():
|
|
||||||
cat = [
|
|
||||||
_tp_row(2, "zebra", cat_sort=10),
|
|
||||||
_tp_row(1, "alpha", cat_sort=5),
|
|
||||||
]
|
|
||||||
merged = merge_parameter_schema_rows(cat, [])
|
|
||||||
assert [m["key"] for m in merged] == ["alpha", "zebra"]
|
|
||||||
assert merged[0]["required"] is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_type_overrides_required_and_sort():
|
|
||||||
cat = [_tp_row(1, "rpe", cat_sort=0, cat_required=False)]
|
|
||||||
typ = [_ttp_row(1, "rpe", typ_sort=99, typ_required=True)]
|
|
||||||
merged = merge_parameter_schema_rows(cat, typ)
|
|
||||||
assert len(merged) == 1
|
|
||||||
assert merged[0]["sort_order"] == 99
|
|
||||||
assert merged[0]["required"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_parameter_schema_includes_descriptions():
|
|
||||||
cat = [
|
|
||||||
{
|
|
||||||
"training_parameter_id": 1,
|
|
||||||
"cat_sort": 0,
|
|
||||||
"cat_required": False,
|
|
||||||
"cat_ui_group": None,
|
|
||||||
"key": "custom_w",
|
|
||||||
"name_de": "Leistung",
|
|
||||||
"name_en": "Watts",
|
|
||||||
"description_de": "Mittlere 5-Min-Leistung",
|
|
||||||
"description_en": "5 min average power",
|
|
||||||
"param_category": "performance",
|
|
||||||
"data_type": "integer",
|
|
||||||
"unit": "W",
|
|
||||||
"validation_rules": {},
|
|
||||||
"source_field": None,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
merged = merge_parameter_schema_rows(cat, [])
|
|
||||||
assert merged[0]["description_de"] == "Mittlere 5-Min-Leistung"
|
|
||||||
assert merged[0]["description_en"] == "5 min average power"
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_eav_float_value_normalized_no_long_tail():
|
|
||||||
"""Layer 1: lange Floats (z. B. kcal_per_km) für Lesepfad kompakt."""
|
|
||||||
schema = [
|
|
||||||
{
|
|
||||||
"training_parameter_id": 1,
|
|
||||||
"key": "kcal_per_km",
|
|
||||||
"data_type": "float",
|
|
||||||
"unit": "kcal/km",
|
|
||||||
"validation_rules": {},
|
|
||||||
"source_field": None,
|
|
||||||
"name_de": "Kcal/km",
|
|
||||||
"name_en": "kcal/km",
|
|
||||||
"description_de": None,
|
|
||||||
"description_en": None,
|
|
||||||
"param_category": "performance",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
eav = [
|
|
||||||
{
|
|
||||||
"training_parameter_id": 1,
|
|
||||||
"key": "kcal_per_km",
|
|
||||||
"data_type": "float",
|
|
||||||
"unit": "kcal/km",
|
|
||||||
"value": 51.5818181818181818,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
out = merge_column_backed_and_eav_metrics({}, schema, eav)
|
|
||||||
assert len(out) == 1
|
|
||||||
v = out[0]["value"]
|
|
||||||
assert "581818" not in repr(v)
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_column_backed_includes_human_labels_from_schema():
|
|
||||||
schema = [
|
|
||||||
{
|
|
||||||
"training_parameter_id": 1,
|
|
||||||
"key": "watts",
|
|
||||||
"data_type": "integer",
|
|
||||||
"unit": "W",
|
|
||||||
"validation_rules": {},
|
|
||||||
"source_field": "avg_power",
|
|
||||||
"name_de": "Leistung",
|
|
||||||
"name_en": "Power",
|
|
||||||
"description_de": "Gerätewert",
|
|
||||||
"description_en": "Device reading",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
out = merge_column_backed_and_eav_metrics({"avg_power": 200}, schema, [])
|
|
||||||
assert len(out) == 1
|
|
||||||
assert out[0]["value"] == 200
|
|
||||||
assert out[0]["name_de"] == "Leistung"
|
|
||||||
assert out[0]["description_en"] == "Device reading"
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_type_adds_parameter_not_in_category():
|
|
||||||
typ = [_ttp_row(7, "cadence", typ_sort=1, typ_required=True, data_type="integer")]
|
|
||||||
merged = merge_parameter_schema_rows([], typ)
|
|
||||||
assert len(merged) == 1
|
|
||||||
assert merged[0]["key"] == "cadence"
|
|
||||||
assert merged[0]["required"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_integer_range():
|
|
||||||
_validate_single_value("integer", 5, {"min": 1, "max": 10})
|
|
||||||
with pytest.raises(ActivitySessionMetricsError) as ei:
|
|
||||||
_validate_single_value("integer", 0, {"min": 1})
|
|
||||||
assert ei.value.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_float_accepts_int():
|
|
||||||
_validate_single_value("float", 3, {"min": 0, "max": 10})
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_boolean_rejects_int():
|
|
||||||
with pytest.raises(ActivitySessionMetricsError):
|
|
||||||
_validate_single_value("boolean", 1, {})
|
|
||||||
|
|
||||||
|
|
||||||
def test_row_value_tuple_mapping():
|
|
||||||
assert _row_value_tuple("integer", 42) == (None, 42, None, None)
|
|
||||||
assert _row_value_tuple("float", 1.5) == (1.5, None, None, None)
|
|
||||||
assert _row_value_tuple("string", "hi") == (None, None, "hi", None)
|
|
||||||
assert _row_value_tuple("boolean", True) == (None, None, None, True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_metric_string_dtype_compacts_numeric_strings():
|
|
||||||
assert _normalize_metric_value_for_read("string", "51.58181818181818") == 52
|
|
||||||
assert _normalize_metric_value_for_read("string", "Freitext") == "Freitext"
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeCursor:
|
|
||||||
"""Sequences fetchone/fetchall for resolve_activity_attribute_schema."""
|
|
||||||
|
|
||||||
def __init__(self, fetchone_chain, fetchall_chain):
|
|
||||||
self._fetchone = list(fetchone_chain)
|
|
||||||
self._fetchall = list(fetchall_chain)
|
|
||||||
self.executes: list[tuple] = []
|
|
||||||
|
|
||||||
def execute(self, sql, params=None):
|
|
||||||
self.executes.append((sql, params))
|
|
||||||
|
|
||||||
def fetchone(self):
|
|
||||||
return self._fetchone.pop(0)
|
|
||||||
|
|
||||||
def fetchall(self):
|
|
||||||
return self._fetchall.pop(0)
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_with_explicit_category_no_type():
|
|
||||||
cur = _FakeCursor(
|
|
||||||
fetchone_chain=[],
|
|
||||||
fetchall_chain=[
|
|
||||||
[
|
|
||||||
_tp_row(1, "rpe", cat_sort=0),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
)
|
|
||||||
out = resolve_activity_attribute_schema(cur, "cardio", None)
|
|
||||||
assert len(out) == 1
|
|
||||||
assert out[0]["key"] == "rpe"
|
|
||||||
assert len(cur.executes) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_loads_category_from_training_type_id():
|
|
||||||
cur = _FakeCursor(
|
|
||||||
fetchone_chain=[{"category": "strength"}],
|
|
||||||
fetchall_chain=[
|
|
||||||
[_tp_row(1, "rpe", cat_sort=0)],
|
|
||||||
[],
|
|
||||||
],
|
|
||||||
)
|
|
||||||
out = resolve_activity_attribute_schema(cur, None, 42)
|
|
||||||
assert len(out) == 1
|
|
||||||
assert cur.executes[0][1] == (42,)
|
|
||||||
|
|
||||||
|
|
||||||
@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema", return_value=[])
|
|
||||||
def test_enrich_sessions_batch(mock_resolve):
|
|
||||||
aid = str(uuid.uuid4())
|
|
||||||
bid = str(uuid.uuid4())
|
|
||||||
|
|
||||||
class _Cur:
|
|
||||||
def __init__(self):
|
|
||||||
self.params = None
|
|
||||||
self._fetch_n = 0
|
|
||||||
|
|
||||||
def execute(self, sql, params=None):
|
|
||||||
self.sql = sql
|
|
||||||
self.params = params
|
|
||||||
|
|
||||||
def fetchall(self):
|
|
||||||
self._fetch_n += 1
|
|
||||||
if self._fetch_n == 1:
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": uuid.UUID(aid),
|
|
||||||
"training_category": None,
|
|
||||||
"training_type_id": None,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": uuid.UUID(bid),
|
|
||||||
"training_category": None,
|
|
||||||
"training_type_id": None,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"activity_log_id": uuid.UUID(aid),
|
|
||||||
"training_parameter_id": 3,
|
|
||||||
"key": "rpe",
|
|
||||||
"data_type": "integer",
|
|
||||||
"unit": None,
|
|
||||||
"value_num": None,
|
|
||||||
"value_int": 7,
|
|
||||||
"value_text": None,
|
|
||||||
"value_bool": None,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
sessions = [{"id": aid}, {"id": bid}]
|
|
||||||
enrich_sessions_with_metrics(_Cur(), sessions)
|
|
||||||
assert sessions[0]["session_metrics"] == []
|
|
||||||
assert sessions[1]["session_metrics"] == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_column_backed_prefers_column_over_stale_eav():
|
|
||||||
schema = [
|
|
||||||
{
|
|
||||||
"training_parameter_id": 1,
|
|
||||||
"key": "hr_avg",
|
|
||||||
"data_type": "float",
|
|
||||||
"unit": "bpm",
|
|
||||||
"validation_rules": {},
|
|
||||||
"source_field": "hr_avg",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
eav = [
|
|
||||||
{
|
|
||||||
"training_parameter_id": 1,
|
|
||||||
"key": "hr_avg",
|
|
||||||
"data_type": "float",
|
|
||||||
"unit": "bpm",
|
|
||||||
"value": 99.0,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
out = merge_column_backed_and_eav_metrics({"hr_avg": 140.0}, schema, eav)
|
|
||||||
assert len(out) == 1
|
|
||||||
assert out[0]["value"] == 140.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_falls_back_to_eav_when_column_empty():
|
|
||||||
schema = [
|
|
||||||
{
|
|
||||||
"training_parameter_id": 1,
|
|
||||||
"key": "hr_avg",
|
|
||||||
"data_type": "float",
|
|
||||||
"unit": "bpm",
|
|
||||||
"validation_rules": {},
|
|
||||||
"source_field": "hr_avg",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
eav = [
|
|
||||||
{
|
|
||||||
"training_parameter_id": 1,
|
|
||||||
"key": "hr_avg",
|
|
||||||
"data_type": "float",
|
|
||||||
"unit": "bpm",
|
|
||||||
"value": 99.0,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
out = merge_column_backed_and_eav_metrics({"hr_avg": None}, schema, eav)
|
|
||||||
assert len(out) == 1
|
|
||||||
assert out[0]["value"] == 99.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_ignores_eav_when_parameter_not_in_schema():
|
|
||||||
"""Nur tcp/ttp-Schema zählt: verwaiste EAV-Zeilen erscheinen nicht in der effektiven Liste."""
|
|
||||||
schema = []
|
|
||||||
eav = [
|
|
||||||
{
|
|
||||||
"training_parameter_id": 2,
|
|
||||||
"key": "custom_param",
|
|
||||||
"data_type": "string",
|
|
||||||
"unit": None,
|
|
||||||
"value": "x",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
out = merge_column_backed_and_eav_metrics({}, schema, eav)
|
|
||||||
assert out == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_eav_primary_falls_back_to_legacy_hr_min_column():
|
|
||||||
"""Kanon: min_hr ohne source_field / ohne EAV — Lesefallback Spalte hr_min."""
|
|
||||||
schema = [
|
|
||||||
{
|
|
||||||
"training_parameter_id": 9,
|
|
||||||
"key": "min_hr",
|
|
||||||
"data_type": "integer",
|
|
||||||
"unit": "bpm",
|
|
||||||
"validation_rules": {},
|
|
||||||
"source_field": None,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
out = merge_column_backed_and_eav_metrics({"hr_min": 88}, schema, [])
|
|
||||||
assert len(out) == 1
|
|
||||||
assert out[0]["key"] == "min_hr"
|
|
||||||
assert out[0]["value"] == 88
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_eav_primary_prefers_legacy_column_over_eav_when_both():
|
|
||||||
"""Kanon: bei min_hr + hr_min und EAV-Zeile gewinnt activity_log (hr_min)."""
|
|
||||||
schema = [
|
|
||||||
{
|
|
||||||
"training_parameter_id": 9,
|
|
||||||
"key": "min_hr",
|
|
||||||
"data_type": "integer",
|
|
||||||
"unit": "bpm",
|
|
||||||
"validation_rules": {},
|
|
||||||
"source_field": None,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
eav = [
|
|
||||||
{
|
|
||||||
"training_parameter_id": 9,
|
|
||||||
"key": "min_hr",
|
|
||||||
"data_type": "integer",
|
|
||||||
"unit": "bpm",
|
|
||||||
"value": 100,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
out = merge_column_backed_and_eav_metrics({"hr_min": 88}, schema, eav)
|
|
||||||
assert len(out) == 1
|
|
||||||
assert out[0]["value"] == 88
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_registry_key_prefers_activity_log_column_over_eav():
|
|
||||||
"""Parameter-Key = Registry-Feld (z. B. duration_min): Spalte vor EAV."""
|
|
||||||
schema = [
|
|
||||||
{
|
|
||||||
"training_parameter_id": 3,
|
|
||||||
"key": "duration_min",
|
|
||||||
"data_type": "float",
|
|
||||||
"unit": "min",
|
|
||||||
"validation_rules": {},
|
|
||||||
"source_field": None,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
eav = [
|
|
||||||
{
|
|
||||||
"training_parameter_id": 3,
|
|
||||||
"key": "duration_min",
|
|
||||||
"data_type": "float",
|
|
||||||
"unit": "min",
|
|
||||||
"value": 99.0,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
out = merge_column_backed_and_eav_metrics({"duration_min": 45.0}, schema, eav)
|
|
||||||
assert len(out) == 1
|
|
||||||
assert out[0]["value"] == 45.0
|
|
||||||
|
|
||||||
|
|
||||||
@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema")
|
|
||||||
def test_upsert_csv_skips_eav_when_source_field_maps_activity_log(mock_schema):
|
|
||||||
"""Parameter mit source_field: kanonisch activity_log — kein doppeltes EAV (z. B. avg_hr → hr_avg)."""
|
|
||||||
mock_schema.return_value = [
|
|
||||||
{
|
|
||||||
"key": "avg_hr",
|
|
||||||
"training_parameter_id": 42,
|
|
||||||
"data_type": "integer",
|
|
||||||
"validation_rules": {"min": 30, "max": 220},
|
|
||||||
"source_field": "hr_avg",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
class Cur:
|
|
||||||
def __init__(self):
|
|
||||||
self.asm_inserts = 0
|
|
||||||
|
|
||||||
def execute(self, sql, params=None):
|
|
||||||
if "INSERT INTO activity_session_metrics" in sql:
|
|
||||||
self.asm_inserts += 1
|
|
||||||
|
|
||||||
def fetchone(self):
|
|
||||||
return {
|
|
||||||
"profile_id": "00000000-0000-0000-0000-000000000001",
|
|
||||||
"hr_avg": 130,
|
|
||||||
}
|
|
||||||
|
|
||||||
cur = Cur()
|
|
||||||
upsert_session_metrics_from_csv_mapped(
|
|
||||||
cur,
|
|
||||||
"00000000-0000-0000-0000-000000000001",
|
|
||||||
"00000000-0000-0000-0000-000000000002",
|
|
||||||
{"avg_hr": 130},
|
|
||||||
"cardio",
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
assert cur.asm_inserts == 0
|
|
||||||
|
|
||||||
|
|
||||||
@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema")
|
|
||||||
def test_upsert_csv_writes_eav_when_source_field_but_column_empty(mock_schema):
|
|
||||||
"""source_field gesetzt, activity_log-Spalte leer — Wert aus mapped nur in EAV (kein Registry-Patch)."""
|
|
||||||
mock_schema.return_value = [
|
|
||||||
{
|
|
||||||
"key": "avg_hr",
|
|
||||||
"training_parameter_id": 42,
|
|
||||||
"data_type": "integer",
|
|
||||||
"validation_rules": {"min": 30, "max": 220},
|
|
||||||
"source_field": "hr_avg",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
class Cur:
|
|
||||||
def __init__(self):
|
|
||||||
self.asm_inserts = 0
|
|
||||||
|
|
||||||
def execute(self, sql, params=None):
|
|
||||||
if "INSERT INTO activity_session_metrics" in sql:
|
|
||||||
self.asm_inserts += 1
|
|
||||||
|
|
||||||
def fetchone(self):
|
|
||||||
return {
|
|
||||||
"profile_id": "00000000-0000-0000-0000-000000000001",
|
|
||||||
"hr_avg": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
cur = Cur()
|
|
||||||
upsert_session_metrics_from_csv_mapped(
|
|
||||||
cur,
|
|
||||||
"00000000-0000-0000-0000-000000000001",
|
|
||||||
"00000000-0000-0000-0000-000000000002",
|
|
||||||
{"avg_hr": 130},
|
|
||||||
"cardio",
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
assert cur.asm_inserts == 1
|
|
||||||
|
|
||||||
|
|
||||||
@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema")
|
|
||||||
def test_upsert_csv_writes_eav_when_no_source_field(mock_schema):
|
|
||||||
mock_schema.return_value = [
|
|
||||||
{
|
|
||||||
"key": "custom_note",
|
|
||||||
"training_parameter_id": 99,
|
|
||||||
"data_type": "string",
|
|
||||||
"validation_rules": {},
|
|
||||||
"source_field": None,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
class Cur:
|
|
||||||
def __init__(self):
|
|
||||||
self.asm_inserts = 0
|
|
||||||
|
|
||||||
def execute(self, sql, params=None):
|
|
||||||
if "INSERT INTO activity_session_metrics" in sql:
|
|
||||||
self.asm_inserts += 1
|
|
||||||
|
|
||||||
def fetchone(self):
|
|
||||||
return {"profile_id": "00000000-0000-0000-0000-000000000001", "hr_avg": None}
|
|
||||||
|
|
||||||
cur = Cur()
|
|
||||||
upsert_session_metrics_from_csv_mapped(
|
|
||||||
cur,
|
|
||||||
"00000000-0000-0000-0000-000000000001",
|
|
||||||
"00000000-0000-0000-0000-000000000002",
|
|
||||||
{"custom_note": "x"},
|
|
||||||
"cardio",
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
assert cur.asm_inserts == 1
|
|
||||||
|
|
||||||
|
|
||||||
@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema", return_value=[])
|
|
||||||
def test_upsert_csv_skips_eav_when_mapped_key_not_in_profile_schema(mock_resolve):
|
|
||||||
"""Import-Mapping allein legt kein EAV an — Key muss in tcp/ttp (resolve) vorkommen."""
|
|
||||||
class Cur:
|
|
||||||
def __init__(self):
|
|
||||||
self.asm_inserts = 0
|
|
||||||
|
|
||||||
def execute(self, sql, params=None):
|
|
||||||
if "INSERT INTO activity_session_metrics" in sql:
|
|
||||||
self.asm_inserts += 1
|
|
||||||
|
|
||||||
def fetchone(self):
|
|
||||||
return {"profile_id": "00000000-0000-0000-0000-000000000001", "hr_avg": None}
|
|
||||||
|
|
||||||
cur = Cur()
|
|
||||||
upsert_session_metrics_from_csv_mapped(
|
|
||||||
cur,
|
|
||||||
"00000000-0000-0000-0000-000000000001",
|
|
||||||
"00000000-0000-0000-0000-000000000002",
|
|
||||||
{"stola": 12},
|
|
||||||
"cardio",
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
assert cur.asm_inserts == 0
|
|
||||||
|
|
@ -249,18 +249,11 @@ def test_run_universal_import_activity_garmin_time_plus_date_columns(monkeypatch
|
||||||
cur = _SeqCursor([None, {"id": new_id}])
|
cur = _SeqCursor([None, {"id": new_id}])
|
||||||
out = run_universal_csv_import(cur, PID, "activity", text, "garmin.csv", mapping)
|
out = run_universal_csv_import(cur, PID, "activity", text, "garmin.csv", mapping)
|
||||||
assert out["rows_imported"] == 1
|
assert out["rows_imported"] == 1
|
||||||
# Duplicate-Check: Datum + TIME, IS NOT DISTINCT FROM (kein String-Vergleich für start_time)
|
# Duplicate-Key muss Datum + kombinierte Startzeit enthalten
|
||||||
dup_sqls = [
|
|
||||||
(_sql, params)
|
|
||||||
for _sql, params in cur.executes
|
|
||||||
if params and "IS NOT DISTINCT FROM" in _sql and "activity_log" in _sql
|
|
||||||
]
|
|
||||||
assert dup_sqls, f"erwarteter Duplicate-SELECT fehlt; executes={cur.executes!r}"
|
|
||||||
assert any(
|
assert any(
|
||||||
len(p) >= 3
|
params and "2024-01-20 08:30:00" in str(params)
|
||||||
and str(p[1]).startswith("2024-01-20")
|
for _sql, params in cur.executes
|
||||||
and (getattr(p[2], "hour", None) == 8 and getattr(p[2], "minute", None) == 30)
|
if params
|
||||||
for _, p in dup_sqls
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ from csv_parser.core import (
|
||||||
headers_signature_rank_metrics,
|
headers_signature_rank_metrics,
|
||||||
get_csv_import_limits,
|
get_csv_import_limits,
|
||||||
iter_csv_dict_rows,
|
iter_csv_dict_rows,
|
||||||
resolve_effective_csv_delimiter,
|
|
||||||
)
|
)
|
||||||
from csv_parser.field_units import source_unit_choices_for_field
|
from csv_parser.field_units import source_unit_choices_for_field
|
||||||
from csv_parser.mapping_suggest import build_type_conversions_for_mapping
|
from csv_parser.mapping_suggest import build_type_conversions_for_mapping
|
||||||
|
|
@ -30,20 +29,6 @@ def test_sniff_delimiter():
|
||||||
assert sniff_delimiter("a,b,c") == ","
|
assert sniff_delimiter("a,b,c") == ","
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_effective_csv_delimiter_semicolon_file_comma_template():
|
|
||||||
"""DE-Apple: «;» in der Datei, englische Vorlage speichert «,»."""
|
|
||||||
header = "Workout Type;Start;End;Duration;Aktive Energie (kJ)"
|
|
||||||
row = "Laufen;2026-04-17 16:25;2026-04-17 17:00;00:30:00;500"
|
|
||||||
text = header + "\n" + row + "\n"
|
|
||||||
assert resolve_effective_csv_delimiter(text, ",") == ";"
|
|
||||||
assert resolve_effective_csv_delimiter(text, None) == ";"
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_effective_csv_delimiter_comma_file_keeps_template():
|
|
||||||
text = "Workout Type,Start,End\nWalk,2026-04-17 16:25,2026-04-17 17:00\n"
|
|
||||||
assert resolve_effective_csv_delimiter(text, ",") == ","
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_csv_sample_header():
|
def test_parse_csv_sample_header():
|
||||||
text = "Date;kcal\n2024-01-01;2000\n"
|
text = "Date;kcal\n2024-01-01;2000\n"
|
||||||
headers, rows, delim = parse_csv_sample(text, delimiter=";", max_data_rows=3)
|
headers, rows, delim = parse_csv_sample(text, delimiter=";", max_data_rows=3)
|
||||||
|
|
@ -53,29 +38,6 @@ def test_parse_csv_sample_header():
|
||||||
assert rows[0]["kcal"] == "2000"
|
assert rows[0]["kcal"] == "2000"
|
||||||
|
|
||||||
|
|
||||||
def test_parse_csv_sample_nbsp_in_header_matches_normal_space_key():
|
|
||||||
"""Excel/Apple: NBSP (U+00A0) im Spaltennamen — gleicher Key wie normales Leerzeichen."""
|
|
||||||
text = "Aufgestiegene\u00a0Höhe (m);Wert\n12;3\n"
|
|
||||||
headers, rows, delim = parse_csv_sample(text, delimiter=";", max_data_rows=3)
|
|
||||||
assert headers == ["Aufgestiegene Höhe (m)", "Wert"]
|
|
||||||
assert rows[0]["Aufgestiegene Höhe (m)"] == "12"
|
|
||||||
|
|
||||||
|
|
||||||
def test_iter_csv_dict_rows_nbsp_header_canonical():
|
|
||||||
text = "col\u00a0one;b\n1;2\n"
|
|
||||||
rows = list(iter_csv_dict_rows(text, ";", has_header=True))
|
|
||||||
assert rows == [{"col one": "1", "b": "2"}]
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_row_field_mapping_space_vs_nbsp_in_csv_header():
|
|
||||||
"""Vorlage (Dialog) mit normalem Leerzeichen, CSV mit NBSP — Zuordnung muss greifen."""
|
|
||||||
csv_row = {"Aufgestiegene\u00a0Höhe (m)": "10"}
|
|
||||||
fm = {"Aufgestiegene Höhe (m)": "stola"}
|
|
||||||
tc = {"stola": {"type": "float", "decimal_separator": ".", "flexible": True}}
|
|
||||||
out = build_row_after_mapping(csv_row, fm, tc, module="activity")
|
|
||||||
assert out.get("stola") == 10.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_column_signature_sorted_unique():
|
def test_column_signature_sorted_unique():
|
||||||
sig = column_signature(["B", "a", "a"])
|
sig = column_signature(["B", "a", "a"])
|
||||||
assert sig == ["a", "b"]
|
assert sig == ["a", "b"]
|
||||||
|
|
@ -221,16 +183,6 @@ def test_build_row_fddb_raw_header_keys_match_normalized_template():
|
||||||
assert out["kcal"] is not None and abs(float(out["kcal"]) - (42000 / 4.184)) < 0.1
|
assert out["kcal"] is not None and abs(float(out["kcal"]) - (42000 / 4.184)) < 0.1
|
||||||
|
|
||||||
|
|
||||||
def test_build_row_apple_workout_elevation_header_prefix_matches_shorter_mapping_key():
|
|
||||||
"""Apple Workouts: „Aufgestiegene Höhe (m)“ normalisiert anders als manuell „aufgestiegene Höhe“."""
|
|
||||||
csv_row = {"Aufgestiegene Höhe (m)": "47.13", "Workout Type": "Outdoor Spaziergang"}
|
|
||||||
fm = {"aufgestiegene Höhe": "stola", "Workout Type": "activity_type"}
|
|
||||||
tc = {"stola": {"type": "string"}}
|
|
||||||
out = build_row_after_mapping(csv_row, fm, tc, module="activity")
|
|
||||||
assert out.get("stola") == "47.13"
|
|
||||||
assert out.get("activity_type") == "Outdoor Spaziergang"
|
|
||||||
|
|
||||||
|
|
||||||
def test_convert_date_ddmm_with_seconds():
|
def test_convert_date_ddmm_with_seconds():
|
||||||
d = convert_value(
|
d = convert_value(
|
||||||
"15.01.2024 14:30:00",
|
"15.01.2024 14:30:00",
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ from dashboard_layout_schema import (
|
||||||
DashboardLayoutPayload,
|
DashboardLayoutPayload,
|
||||||
coalesce_effective_layout,
|
coalesce_effective_layout,
|
||||||
default_layout_dict,
|
default_layout_dict,
|
||||||
merge_missing_catalog_widgets,
|
|
||||||
)
|
)
|
||||||
from widget_catalog import DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS
|
from widget_catalog import DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS
|
||||||
|
|
||||||
|
|
@ -57,19 +56,3 @@ def test_coalesce_valid_raw():
|
||||||
custom, eff = coalesce_effective_layout(raw)
|
custom, eff = coalesce_effective_layout(raw)
|
||||||
assert custom is True
|
assert custom is True
|
||||||
assert eff == raw
|
assert eff == raw
|
||||||
|
|
||||||
|
|
||||||
def test_merge_missing_catalog_widgets_keeps_order_and_fills_ids():
|
|
||||||
raw = {
|
|
||||||
"version": 1,
|
|
||||||
"widgets": [
|
|
||||||
{"id": "kpi_board", "enabled": True, "config": {}},
|
|
||||||
{"id": "welcome", "enabled": False, "config": {}},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
merged = merge_missing_catalog_widgets(raw)
|
|
||||||
assert [w["id"] for w in merged["widgets"][:2]] == ["kpi_board", "welcome"]
|
|
||||||
assert {w["id"] for w in merged["widgets"]} == ALLOWED_WIDGET_IDS
|
|
||||||
extra = [w for w in merged["widgets"] if w["id"] not in ("kpi_board", "welcome")]
|
|
||||||
assert all(w["enabled"] is False for w in extra)
|
|
||||||
DashboardLayoutPayload.model_validate(merged)
|
|
||||||
|
|
|
||||||
|
|
@ -14,196 +14,6 @@ def test_body_chart_days_bounds():
|
||||||
validate_widget_entry_config("body_overview", {"chart_days": 91})
|
validate_widget_entry_config("body_overview", {"chart_days": 91})
|
||||||
|
|
||||||
|
|
||||||
def test_body_history_viz_empty_expands_defaults():
|
|
||||||
d = validate_widget_entry_config("body_history_viz", {})
|
|
||||||
assert d["chart_days"] == 30
|
|
||||||
assert d["show_kpis"] is True
|
|
||||||
assert d["show_weight_chart"] is True
|
|
||||||
assert d["kpi_detail"] == "compact"
|
|
||||||
assert d["show_body_fat_chart"] is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_body_history_viz_chart_days_and_merge():
|
|
||||||
d = validate_widget_entry_config("body_history_viz", {"chart_days": 60})
|
|
||||||
assert d["chart_days"] == 60
|
|
||||||
assert d["show_goals_strip"] is False
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
validate_widget_entry_config("body_history_viz", {"chart_days": 5})
|
|
||||||
|
|
||||||
|
|
||||||
def test_body_history_viz_requires_visible_block():
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
validate_widget_entry_config(
|
|
||||||
"body_history_viz",
|
|
||||||
{"show_kpis": False, "show_weight_chart": False},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_body_history_viz_unknown_key():
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
validate_widget_entry_config("body_history_viz", {"evil": True})
|
|
||||||
|
|
||||||
|
|
||||||
def test_nutrition_history_viz_empty_expands_defaults():
|
|
||||||
d = validate_widget_entry_config("nutrition_history_viz", {})
|
|
||||||
assert d["chart_days"] == 30
|
|
||||||
assert d["show_kpis"] is True
|
|
||||||
assert d["show_kcal_vs_weight"] is True
|
|
||||||
assert d["kpi_detail"] == "compact"
|
|
||||||
assert d["show_calorie_balance_chart"] is False
|
|
||||||
assert d["show_energy_protein_charts"] is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_nutrition_history_viz_chart_days_and_merge():
|
|
||||||
d = validate_widget_entry_config("nutrition_history_viz", {"chart_days": 45})
|
|
||||||
assert d["chart_days"] == 45
|
|
||||||
assert d["show_goals_strip"] is False
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
validate_widget_entry_config("nutrition_history_viz", {"chart_days": 5})
|
|
||||||
|
|
||||||
|
|
||||||
def test_nutrition_history_viz_requires_visible_block():
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
validate_widget_entry_config(
|
|
||||||
"nutrition_history_viz",
|
|
||||||
{"show_kpis": False, "show_kcal_vs_weight": False, "show_macro_daily_bars": False, "show_macro_distribution_pair": False},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_nutrition_history_viz_unknown_key():
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
validate_widget_entry_config("nutrition_history_viz", {"evil": True})
|
|
||||||
|
|
||||||
|
|
||||||
def test_fitness_history_viz_empty_expands_defaults():
|
|
||||||
d = validate_widget_entry_config("fitness_history_viz", {})
|
|
||||||
assert d["chart_days"] == 30
|
|
||||||
assert d["show_kpis"] is True
|
|
||||||
assert d["show_chart_training_volume"] is True
|
|
||||||
assert d["kpi_detail"] == "compact"
|
|
||||||
assert d["show_layer_meta"] is False
|
|
||||||
assert d["show_chart_load_monitoring"] is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_fitness_history_viz_chart_days_and_merge():
|
|
||||||
d = validate_widget_entry_config("fitness_history_viz", {"chart_days": 60})
|
|
||||||
assert d["chart_days"] == 60
|
|
||||||
assert d["show_progress_insights"] is False
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
validate_widget_entry_config("fitness_history_viz", {"chart_days": 5})
|
|
||||||
|
|
||||||
|
|
||||||
def test_fitness_history_viz_requires_visible_block():
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
validate_widget_entry_config(
|
|
||||||
"fitness_history_viz",
|
|
||||||
{
|
|
||||||
"show_kpis": False,
|
|
||||||
"show_progress_insights": False,
|
|
||||||
"show_chart_training_volume": False,
|
|
||||||
"show_chart_training_type_distribution": False,
|
|
||||||
"show_chart_quality_sessions": False,
|
|
||||||
"show_chart_load_monitoring": False,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_fitness_history_viz_unknown_key():
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
validate_widget_entry_config("fitness_history_viz", {"evil": True})
|
|
||||||
|
|
||||||
|
|
||||||
def test_recovery_history_viz_empty_expands_defaults():
|
|
||||||
d = validate_widget_entry_config("recovery_history_viz", {})
|
|
||||||
assert d["chart_days"] == 30
|
|
||||||
assert d["show_kpis"] is True
|
|
||||||
assert d["show_chart_recovery_score"] is True
|
|
||||||
assert d["kpi_detail"] == "compact"
|
|
||||||
assert d["show_heart_context_card"] is False
|
|
||||||
assert d["show_vitals_extra_trends"] is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_recovery_history_viz_chart_days_and_merge():
|
|
||||||
d = validate_widget_entry_config("recovery_history_viz", {"chart_days": 42})
|
|
||||||
assert d["chart_days"] == 42
|
|
||||||
assert d["show_layer_meta"] is False
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
validate_widget_entry_config("recovery_history_viz", {"chart_days": 3})
|
|
||||||
|
|
||||||
|
|
||||||
def test_recovery_history_viz_requires_visible_block():
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
validate_widget_entry_config(
|
|
||||||
"recovery_history_viz",
|
|
||||||
{
|
|
||||||
"show_kpis": False,
|
|
||||||
"show_progress_insights": False,
|
|
||||||
"show_heart_context_card": False,
|
|
||||||
"show_vitals_extra_trends": False,
|
|
||||||
"show_chart_recovery_score": False,
|
|
||||||
"show_chart_sleep_quality": False,
|
|
||||||
"show_chart_sleep_debt": False,
|
|
||||||
"show_chart_hrv_rhr": False,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_recovery_history_viz_unknown_key():
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
validate_widget_entry_config("recovery_history_viz", {"evil": True})
|
|
||||||
|
|
||||||
|
|
||||||
def test_history_overview_viz_empty_expands_defaults():
|
|
||||||
d = validate_widget_entry_config("history_overview_viz", {})
|
|
||||||
assert d["chart_days"] == 30
|
|
||||||
assert d["show_confidence_banner"] is True
|
|
||||||
assert d["show_section_body"] is True
|
|
||||||
assert d["show_section_nutrition"] is True
|
|
||||||
assert d["show_section_fitness"] is True
|
|
||||||
assert d["show_section_recovery"] is True
|
|
||||||
assert d["show_correlation_c1_c3"] is True
|
|
||||||
assert d["show_drivers_c4"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_history_overview_viz_chart_days_and_merge():
|
|
||||||
d = validate_widget_entry_config("history_overview_viz", {"chart_days": 60})
|
|
||||||
assert d["chart_days"] == 60
|
|
||||||
assert d["show_intro_blurb"] is True
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
validate_widget_entry_config("history_overview_viz", {"chart_days": 5})
|
|
||||||
|
|
||||||
|
|
||||||
def test_history_overview_viz_requires_visible_block():
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
validate_widget_entry_config(
|
|
||||||
"history_overview_viz",
|
|
||||||
{
|
|
||||||
"show_confidence_banner": False,
|
|
||||||
"show_section_body": False,
|
|
||||||
"show_section_nutrition": False,
|
|
||||||
"show_section_fitness": False,
|
|
||||||
"show_section_recovery": False,
|
|
||||||
"show_correlation_c1_c3": False,
|
|
||||||
"show_drivers_c4": False,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_history_overview_viz_legacy_show_area_summaries_maps_sections():
|
|
||||||
d = validate_widget_entry_config(
|
|
||||||
"history_overview_viz",
|
|
||||||
{"show_area_summaries": False, "show_correlation_c1_c3": True},
|
|
||||||
)
|
|
||||||
assert d["show_section_body"] is False
|
|
||||||
assert d["show_section_fitness"] is False
|
|
||||||
assert d["show_correlation_c1_c3"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_history_overview_viz_unknown_key():
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
validate_widget_entry_config("history_overview_viz", {"evil": True})
|
|
||||||
|
|
||||||
|
|
||||||
def test_welcome_config_rejected_unknown_key():
|
def test_welcome_config_rejected_unknown_key():
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
validate_widget_entry_config("welcome", {"x": 1})
|
validate_widget_entry_config("welcome", {"x": 1})
|
||||||
|
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
"""Tests für data_layer.prompt_output_compact (KI-Platzhalter, Token)."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from data_layer.prompt_output_compact import (
|
|
||||||
compact_float_for_prompt,
|
|
||||||
compact_json_payload_for_prompts,
|
|
||||||
format_scalar_for_prompt_text,
|
|
||||||
normalize_prompt_number,
|
|
||||||
session_metrics_list_to_key_value_compact,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"x,expected",
|
|
||||||
[
|
|
||||||
(0.0, 0),
|
|
||||||
(123.456, 123),
|
|
||||||
(45.67, 46),
|
|
||||||
(9.876, 9.88),
|
|
||||||
(0.99, 0.99),
|
|
||||||
(0.055, 0.055),
|
|
||||||
(0.01234, 0.012),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_compact_float_for_prompt(x, expected):
|
|
||||||
out = compact_float_for_prompt(x)
|
|
||||||
if isinstance(expected, float):
|
|
||||||
assert abs(float(out) - expected) < 0.0001
|
|
||||||
else:
|
|
||||||
assert out == expected
|
|
||||||
|
|
||||||
|
|
||||||
def test_compact_json_nested():
|
|
||||||
raw = {"a": 12.345678, "b": {"c": 0.0666}, "d": [1.111, 2.0]}
|
|
||||||
out = compact_json_payload_for_prompts(raw)
|
|
||||||
assert out["a"] == 12
|
|
||||||
assert abs(out["b"]["c"] - 0.067) < 0.001
|
|
||||||
assert out["d"][0] == 1.11
|
|
||||||
|
|
||||||
|
|
||||||
def test_format_scalar_no_long_float_tail():
|
|
||||||
s = format_scalar_for_prompt_text(51.5818181818181818)
|
|
||||||
assert "181818" not in s
|
|
||||||
assert len(s) <= 8
|
|
||||||
|
|
||||||
|
|
||||||
def test_format_scalar_numeric_string_no_long_tail():
|
|
||||||
s = format_scalar_for_prompt_text("51.581818181818181818")
|
|
||||||
assert "181818" not in s
|
|
||||||
|
|
||||||
|
|
||||||
def test_session_metrics_string_dtype_compacts_numeric_strings():
|
|
||||||
sm = [
|
|
||||||
{
|
|
||||||
"key": "temp_c",
|
|
||||||
"data_type": "string",
|
|
||||||
"value": "22.333333333333336",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "kcal_per_km",
|
|
||||||
"data_type": "string",
|
|
||||||
"value": "51.581818181818181818",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
out = session_metrics_list_to_key_value_compact(sm)
|
|
||||||
assert out["temp_c"] == 22
|
|
||||||
assert out["kcal_per_km"] == 52
|
|
||||||
|
|
||||||
|
|
||||||
def test_session_metrics_key_value_only():
|
|
||||||
sm = [
|
|
||||||
{
|
|
||||||
"key": "rpe",
|
|
||||||
"data_type": "integer",
|
|
||||||
"value": 7,
|
|
||||||
"name_de": "RPE",
|
|
||||||
"description_de": "lang",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "watts",
|
|
||||||
"data_type": "float",
|
|
||||||
"value": 199.999,
|
|
||||||
"unit": "W",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
out = session_metrics_list_to_key_value_compact(sm)
|
|
||||||
assert out == {"rpe": 7, "watts": 200}
|
|
||||||
assert "name_de" not in str(out)
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
"""Unit tests for data_layer.reference_values (summary assembly, no DB)."""
|
"""Unit tests for data_layer.reference_values (summary assembly, no DB)."""
|
||||||
|
|
||||||
from datetime import date
|
from data_layer.reference_values import build_summary_tiles_from_ranked_rows
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
from data_layer.reference_values import _entry_dict_from_ranked_row, build_summary_tiles_from_ranked_rows
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_summary_tiles_single_type_two_rows():
|
def test_build_summary_tiles_single_type_two_rows():
|
||||||
|
|
@ -49,27 +46,3 @@ def test_build_summary_tiles_multi_type_order():
|
||||||
]
|
]
|
||||||
tiles = build_summary_tiles_from_ranked_rows(raw)
|
tiles = build_summary_tiles_from_ranked_rows(raw)
|
||||||
assert [x["type_key"] for x in tiles] == ["a", "b"]
|
assert [x["type_key"] for x in tiles] == ["a", "b"]
|
||||||
|
|
||||||
|
|
||||||
def test_entry_dict_from_ranked_row_normalizes_decimal():
|
|
||||||
row = {
|
|
||||||
"id": 1,
|
|
||||||
"profile_id": "p",
|
|
||||||
"reference_value_type_id": 9,
|
|
||||||
"effective_date": date(2026, 4, 1),
|
|
||||||
"value_numeric": Decimal("42.5"),
|
|
||||||
"value_text": None,
|
|
||||||
"unit": "bpm",
|
|
||||||
"source": "lab",
|
|
||||||
"confidence": "high",
|
|
||||||
"method": "spiro",
|
|
||||||
"notes": None,
|
|
||||||
"extra": None,
|
|
||||||
"created_at": date(2026, 4, 2),
|
|
||||||
"updated_at": date(2026, 4, 2),
|
|
||||||
"type_key": "hr_max",
|
|
||||||
"type_label": "HF max",
|
|
||||||
}
|
|
||||||
out = _entry_dict_from_ranked_row(row)
|
|
||||||
assert out["value_numeric"] == 42.5
|
|
||||||
assert out["effective_date"] == "2026-04-01"
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user