Compare commits
185 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc2905f9da | |||
| 387ee6840f | |||
| 0638a7d76b | |||
| ed2b457da3 | |||
| 3ab5dae130 | |||
| 62729d0648 | |||
| 141df021c1 | |||
| ddc87ba5ae | |||
| 2dbfd95cca | |||
| 725e7ffe4b | |||
| 97dbb0f80b | |||
| e20b321b64 | |||
| d22e0ba0a7 | |||
| db5557e4aa | |||
| 20f195aca1 | |||
| 01c0d1745f | |||
| 2453da0da1 | |||
| 3eb7ef3ae6 | |||
| 1cf3d5997d | |||
| 1c512b0d0a | |||
| 0365d9eb52 | |||
| 3106ebedae | |||
| 5cac1ededd | |||
| 3f6673b636 | |||
| da1e0410cc | |||
| ba2bd3a4a2 | |||
| 41d809c68c | |||
| 5d67a77a12 | |||
| 7ac9752c3d | |||
| 45fb506a5e | |||
| d66eadf88f | |||
| 6c962bf6e5 | |||
| a1b458d228 | |||
| 61738cecb7 | |||
| 857cc1043a | |||
| ce84f330f0 | |||
| 8cb5ad992f | |||
| e7bcdc3228 | |||
| 6743814904 | |||
| 819914b7cc | |||
| d4868b3797 | |||
| 6ef07a77a5 | |||
| d3cb9d4ad9 | |||
| 33b08a8d82 | |||
| f42d3a9c92 | |||
| bf84e3c2a5 | |||
| 22c5f695c9 | |||
| b5c5f2f612 | |||
| aa6644f44b | |||
| d7304c1a44 | |||
| fc816da335 | |||
| 31fbf33031 | |||
| b96b1931db | |||
| a8eafa8ba4 | |||
| 08b7aa0ca1 | |||
| 8c60601ed1 | |||
| 319a9cfafd | |||
| 8fc7d9c1c4 | |||
| b2175b9018 | |||
| 461c358dc2 | |||
| 157afd10b9 | |||
| 0ab91416d0 | |||
| 42ae796448 | |||
| 5fbaa7cc8b | |||
| df0165bee3 | |||
| 9b53a4ba68 | |||
| 0035d08149 | |||
| 9720cb57ec | |||
| c91317df8e | |||
| 1e1605f878 | |||
| 7676897fda | |||
| 178534e9eb | |||
| 6756dc60f3 | |||
| 7226e04e9c | |||
| 4575bb23ee | |||
| 0ad3ddd627 | |||
| a002781ef9 | |||
| 879a3a58d7 | |||
| 09d1b6f967 | |||
| 35ba2d7fdb | |||
| a5aad0da7e | |||
| ce5b96f373 | |||
| 11fac3d123 | |||
| f0ad900565 | |||
| 36478863a2 | |||
| ec667a75b6 | |||
| f864f9894d | |||
| 73104a1a4c | |||
| d66e68a5df | |||
| d2b4f74cd2 | |||
| 1a826973a9 | |||
| d13e7cda26 | |||
| ec85d5f5f6 | |||
| 1139b00743 | |||
| e9712cef23 | |||
| a62c952097 | |||
| 1220ee54fb | |||
| 92e334dcd2 | |||
| bc8e9fb7fa | |||
| c3be745efa | |||
| 680ecd1c06 | |||
| 38797d687d | |||
| fa3e66fb31 | |||
| cc0f57758a | |||
| 2a6c437a08 | |||
| c9d71c0179 | |||
| 9d5e16455c | |||
| 2a26e4fecf | |||
| 94bb4a8199 | |||
| fd7a2dac6d | |||
| 8d0a6dd487 | |||
| 06f83e2ffc | |||
| 026c51b6b5 | |||
| 7d6fdab812 | |||
| 5cda485458 | |||
| cd29c7d433 | |||
| ca8cee990b | |||
| a7859f0050 | |||
| 58ddde6b1e | |||
| 08eae86ddc | |||
| 9d47c4ef84 | |||
| e4e8c70cd2 | |||
| c570e67a09 | |||
| 574af61349 | |||
| 934b915357 | |||
| c6e8371d5a | |||
| f718785145 | |||
| 9fdb02ff8b | |||
| 1f51c32521 | |||
| 766b64cd64 | |||
| 3296dfca28 | |||
| db9952525a | |||
| 196b6c5cf1 | |||
| cf7379b2f6 | |||
| 48508c164e | |||
| 19a88e4f97 | |||
| 1b01f5e6d0 | |||
| 424270f0e8 | |||
| df8e732709 | |||
| d5325acee6 | |||
| b7062d32bf | |||
| 5fa2ea2e6b | |||
| f97d15288d | |||
| 736dc58d81 | |||
| 0a27533262 | |||
| 7388776b29 | |||
| a515a5d563 | |||
| 12d4d7c63b | |||
| f34e46b04f | |||
| 3664f53c51 | |||
| fb2e0803c0 | |||
| bb01283727 | |||
| bc60b9f5c9 | |||
| fbeabcde97 | |||
| ba474b0a57 | |||
| 790e6df8ef | |||
| 057df0afc8 | |||
| ba04e0c0b6 | |||
| f5ce1ec941 | |||
| 2deb6510f8 | |||
| 0eac40abf6 | |||
| e915d3fb13 | |||
| 60f6cf3c6d | |||
| 46b703e5a2 | |||
| e09cbc112e | |||
| f6b3182a80 | |||
| cb3aa48999 | |||
| 77f1ed14c5 | |||
| 25c4ecfd48 | |||
| 08c9cccdcc | |||
| 4b6e1bed11 | |||
| 72eb94d186 | |||
| 90a27846ca | |||
| 4d81ea2cf3 | |||
| d7cefdd9e9 | |||
| caeed3fbaa | |||
| 4868e44882 | |||
| a9a414b956 | |||
| baeddd7c13 | |||
| 41bf593d4c | |||
| 04e23d8115 | |||
| 052ba195cc | |||
| 2ea5f905c4 | |||
| e9e094c6a4 | |||
| 61a5bb39ae |
|
|
@ -1,6 +1,6 @@
|
|||
# Gitea Issues – Landkarte (Auswertung)
|
||||
|
||||
**Quelle:** Gitea `Lars/mitai-jinkendo`, Stand **2026-04-09** (Abfrage `state=all`, ergänzt: #71).
|
||||
**Quelle:** Gitea `Lars/mitai-jinkendo`, Stand **2026-04-11** (Abfrage `state=all`, ergänzt: #71, #76).
|
||||
**URL:** http://192.168.2.144:3000/Lars/mitai-jinkendo/issues
|
||||
|
||||
Dieses Dokument ist ein **Orientierungs-Index** für Agenten und Entwickler. Verbindliches Tracking bleibt **in Gitea**; hier: Kategorien, Dubletten-Hinweise, grobe Prioritätseinschätzung.
|
||||
|
|
@ -88,6 +88,7 @@ Dieses Dokument ist ein **Orientierungs-Index** für Agenten und Entwickler. Ver
|
|||
| # | Titel |
|
||||
|---|--------|
|
||||
| 15 | [FEAT-002] Quality-Filter für KI-Auswertungen & Charts integrieren |
|
||||
| 76 | Trainings-Qualität: zielbezogene Logik + Listen-Filter statt globalem „Hochwertig“-Hide |
|
||||
| 36 | BUG-009: Trainingstyp-Erstellung führt zu Internal Server Error |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -52,9 +52,10 @@ _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` |
|
||||
| Platzhalter / Registry | `technical/PLACEHOLDER_REGISTRY_FRAMEWORK.md`, `technical/PLACEHOLDER_DEVELOPMENT_GUIDE.md` | `backend/placeholder_registrations/`, `backend/placeholder_resolver.py` |
|
||||
| Dashboard-Lab-Widgets | `technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` | Widget-Katalog + Registrierung (siehe Guide) |
|
||||
| Dashboard-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 |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
|
@ -113,6 +114,12 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
|
|||
| `TRAINING_PROFILE_RESOLVER_LAYER1.md` | Training-Resolver Schicht 1 |
|
||||
| `TRAINING_TYPE_PROFILES_TECHNICAL.md` | Trainingsprofile technisch |
|
||||
| `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) |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -0,0 +1,317 @@
|
|||
# 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.
|
||||
70
.claude/docs/technical/ACTIVITY_LAYER2A_PLACEHOLDER_AUDIT.md
Normal file
70
.claude/docs/technical/ACTIVITY_LAYER2A_PLACEHOLDER_AUDIT.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# 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)
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
# 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.
|
||||
95
.claude/docs/technical/ACTIVITY_SCALAR_KANON_TABLE.md
Normal file
95
.claude/docs/technical/ACTIVITY_SCALAR_KANON_TABLE.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# 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
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
# 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-Lab-Widgets – Anleitung für Coding-Agenten
|
||||
# Dashboard-Widgets – Anleitung für Coding-Agenten
|
||||
|
||||
Ziel: Ein neues Dashboard-Widget **end-to-end** korrekt einbinden (Backend-Katalog, Validierung, API-Layout, Frontend-Registrierung, optional Lab-Editor für `config`).
|
||||
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).
|
||||
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**).
|
||||
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`).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ Kontext: **Dashboard-Lab** unter geschützten Endpoints `GET/PUT /api/app/...` (
|
|||
| 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. |
|
||||
| **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. |
|
||||
| **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. |
|
||||
| **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). |
|
||||
|
||||
|
|
@ -42,8 +42,8 @@ Kontext: **Dashboard-Lab** unter geschützten Endpoints `GET/PUT /api/app/...` (
|
|||
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).
|
||||
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** – `ensurePilotLabWidgetsRegistered()` in `frontend/src/widgetSystem/registerPilotLabWidgets.js`: verbindet jede Katalog-ID mit einer React-Komponente und mappt `ctx.layoutEntry.config` auf Props.
|
||||
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.
|
||||
4. **Frontend** – `ensureDashboardWidgetsRegistered()` in `frontend/src/widgetSystem/registerDashboardWidgets.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.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -52,9 +52,9 @@ Kontext: **Dashboard-Lab** unter geschützten Endpoints `GET/PUT /api/app/...` (
|
|||
| 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`). |
|
||||
| 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 Pilot-Komponente) | React-Komponente implementieren; typischerweise `refreshTick` aus `mapProps` nutzen, um Daten neu zu laden. |
|
||||
| D | `frontend/src/widgetSystem/registerPilotLabWidgets.js` | `import` + `registerDashboardWidget({ id, Component, mapProps })` – `id` **exakt** wie im Katalog. |
|
||||
| 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). |
|
||||
| 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. |
|
||||
| D | `frontend/src/widgetSystem/registerDashboardWidgets.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. |
|
||||
| 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`. |
|
||||
|
|
@ -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.
|
||||
|
||||
### 3.4 Dashboard-Lab-Editor (`DashboardLabPage.jsx`)
|
||||
### 3.4 Layout-Editor (`DashboardConfigurePage.jsx`)
|
||||
|
||||
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 der Datei) + 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 `DashboardConfigurePage.jsx`) + 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.
|
||||
|
||||
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
|
||||
|
||||
- `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` (Dashboard-Lab-Standard).
|
||||
- `GET /api/app/dashboard-layout` – `layout` (effektiv, bereinigt), `custom`, `product_default_layout` (Übersichts-Standard), `lab_default_layout` (Servertemplate für Editor/Reset; Feldname historisch).
|
||||
- `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` |
|
||||
| HTTP | `backend/routers/app_dashboard.py` |
|
||||
| Registry + Render | `frontend/src/widgetSystem/dashboardWidgetRegistry.jsx` |
|
||||
| Pilot/Lab-Registrierung | `frontend/src/widgetSystem/registerPilotLabWidgets.js` |
|
||||
| Lab-UI | `frontend/src/pages/DashboardLabPage.jsx` |
|
||||
| Dashboard-Widget-Registrierung | `frontend/src/widgetSystem/registerDashboardWidgets.js` |
|
||||
| Layout-Editor (Nutzer) | `frontend/src/pages/DashboardConfigurePage.jsx` |
|
||||
|
|
|
|||
|
|
@ -92,16 +92,10 @@ registry = get_registry()
|
|||
|
||||
**Package:** `backend/placeholder_registrations/`
|
||||
|
||||
**Struktur:**
|
||||
```
|
||||
placeholder_registrations/
|
||||
├── __init__.py # Auto-Import aller Registrations
|
||||
├── nutrition_part_a.py # Nutrition Basis-Metriken (4 Placeholder)
|
||||
├── nutrition_part_b.py # Protein-Ziele (5 Placeholder) - TODO
|
||||
├── body_metrics.py # Körper-Metriken - TODO
|
||||
├── activity_metrics.py # Aktivitäts-Metriken - TODO
|
||||
└── ... # Weitere Cluster
|
||||
```
|
||||
**Struktur:** Vollständige Cluster-Module (u. a. Ernährung, Körper, Aktivität, Schlaf,
|
||||
Vitalwerte, Profil/Zeitraum, Phase-0b-Ziele, Korrelationen); siehe `__init__.py` für die
|
||||
Import-Liste. **Anzahl:** 114 Platzhalter, identisch zu `PLACEHOLDER_MAP` in
|
||||
`placeholder_resolver.py`.
|
||||
|
||||
**Auto-Registration:**
|
||||
- Import des Package triggert automatische Registrierung aller Placeholder
|
||||
|
|
|
|||
56
.claude/docs/technical/REPORT_PROFILES_AND_PDF.md
Normal file
56
.claude/docs/technical/REPORT_PROFILES_AND_PDF.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# 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,6 +18,7 @@ Dieses Dokument ist **normativ für Agenten**, die ein neues Import-Zielmodul an
|
|||
| Admin-Systemvorlagen | `backend/routers/admin_csv_templates.py` |
|
||||
| Nutzer-Import (Profil-Mappings) | `backend/routers/csv_import.py` |
|
||||
| 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.
|
||||
|
||||
|
|
|
|||
1480
.claude/docs/technical/functional_concept_composite_data.md
Normal file
1480
.claude/docs/technical/functional_concept_composite_data.md
Normal file
File diff suppressed because it is too large
Load Diff
1202
.claude/docs/working/SHINKAN_PROJECT_SETUP.md
Normal file
1202
.claude/docs/working/SHINKAN_PROJECT_SETUP.md
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -5,7 +5,9 @@ Folgende Ergebnisse des Tests:
|
|||
- In der automatischen Zusammenfassung in der Endnode kommt als Überschrift, z.B. Node 10, anstatt den Node-Name auszugeben.
|
||||
- Alle Änderungen an Nodes scheinen automatisch in den Gesamtflow übernommen zu werden. Diese werden dann nach dem Speichern aktiv. Da muss man sehr vorsichtig sein, bei kurzen Änderungen und dem Ausprobieren.
|
||||
- Der Testlauf "Execute" sollte auf dem aktuellen Workflowstand ausgeführt werden, auch wenn dieser vom gespeicherten Abweicht. Ich würde natürlich vor dem Speichern den Workflow testen können. Prüfe und bewerte diesen Punkt, setze ihn aber noch nicht um.
|
||||
- Die Workflows werden aktuell nicht in Analyse und den verfügbaren KI-Asuwertungen angezeigt. ggf. weil wir sie aktuell noch keinem Bereich zuordnen können. Diesen könnten wir ggf. über die Start-Node im Workflow konfigurieren.
|
||||
- Das löschen von Knoten und Kanten funktioniert aktuell nur über Backspace nicht über entfernen
|
||||
- Wir sollten auch dafür sorgen, dass jeweils nur eine Start-Node, End-Node in einem Workflow existiert, Prüfe ob mehrere End-Nodes sinnvoll sind, da wir ja auch Logik-Pfade abbilden und ggf. auch eine route beschreiten, die ein anderes Ende hat. (Prüfe, ob das heute schon möglich wäre!)
|
||||
- Als zukünftige Ausbaustufe sollten wir überlegen, ob wir auch Trigger implementieren, z.B. um Kurzstatements zu generieren, wenn neue Daten hereinkommen und wir diese Bewertungen aktualisieren wollen
|
||||
- Exportieren aller KI-Prompts/Templates/Workflows im Admin --> KI-Prompts führt zu einem "internal Server Error", Importieren konnte daraufhin nicht getestet werden
|
||||
- Das duplizieren von Workflows funktioniert nicht
|
||||
-
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
## Gesamt-Übersicht
|
||||
|
||||
**Aktuelle Platzhalter:** 116
|
||||
**Aktuelle Platzhalter:** 114 (PLACEHOLDER_MAP / Registry)
|
||||
**Nach Phase 0c Migration:**
|
||||
- ✅ **Bleiben einfach (kein Data Layer):** 8 Platzhalter
|
||||
- 🔄 **Gehen zu Data Layer:** 108 Platzhalter
|
||||
|
|
|
|||
|
|
@ -455,15 +455,15 @@ NIEMALS gegen mitai.jinkendo.de
|
|||
|
||||
---
|
||||
|
||||
## 10. Dashboard-Lab-Widgets und Feature-System
|
||||
## 10. Dashboard-Widgets und Feature-System
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
**Bindend:**
|
||||
|
||||
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.
|
||||
3. **Nutzer-Konfigurator** (z. B. Dashboard-Lab): Widgets **ohne** passende Berechtigung **nicht anzeigen**; alle erlaubten Widgets bleiben verfügbar.
|
||||
3. **Nutzer-Konfigurator** (**Übersicht anpassen** / `DashboardConfigurePage`): 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).
|
||||
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).
|
||||
|
||||
|
|
|
|||
41
CLAUDE.md
41
CLAUDE.md
|
|
@ -10,8 +10,9 @@
|
|||
> | **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`** |
|
||||
> | **GUI / IA / Admin / Nav / PWA-Leiste** | **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`** |
|
||||
> | **Dashboard-Lab-Widgets** (Katalog, Registrierung, `config`) | **`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`** |
|
||||
> | **Dashboard-Widgets** (Katalog, Registrierung, `config`) | **`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`** |
|
||||
> | **Agent-Einstieg** | **`.claude/README.md`** |
|
||||
> | **Activity Session Metrics (EAV, Attributprofile)** | **`.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md`** |
|
||||
|
||||
## Claude Code Verantwortlichkeiten
|
||||
|
||||
|
|
@ -99,12 +100,48 @@ frontend/src/
|
|||
**Branch:** develop
|
||||
**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)
|
||||
|
||||
- **Agent-Leitfaden:** `.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` (Checkliste für neue Import-Module, Executor, Vorlagen, `source=csv`, SAVEPOINT-/Cursor-Regeln)
|
||||
- **Regeln:** Verweise in `.claude/rules/ARCHITECTURE.md` (§3.2 `source`), `.claude/rules/CODING_RULES.md` (§6)
|
||||
- **Follow-ups:** **Gitea #71** – Dry-Run inkl. `import_row_processing`, Nutzer-Mapping-Validierung, Fehler-Hints in der Import-UI ([Issue](http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/71))
|
||||
|
||||
### Updates (11.04.2026 - Placeholder Phase A)
|
||||
|
||||
- **`main.py`:** `import placeholder_registrations` beim Start, damit die Registry (**114 Keys**, deckungsgleich `PLACEHOLDER_MAP`) und `get_placeholder_catalog()` ohne vorherigen Export-Request konsistent sind.
|
||||
- **`placeholder_resolver.py`:** `{{top_goal_progress_pct}}` nutzt `_safe_int` statt `_safe_str` (Verdrahtung zu `scores.get_top_priority_goal` korrigiert).
|
||||
|
||||
### Updates (11.04.2026 - Gitea #75, nutrition_score Registry)
|
||||
|
||||
- **Gitea #75** (offen): Zucker/Ballaststoffe/Lebensmittelqualität, automatisches Lebensmittelprofil, später Mahlzeiten-Timing/Abgleich mit Training — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/75
|
||||
- **`nutrition_score`:** Registry in `backend/placeholder_registrations/nutrition_score.py`, Import in `placeholder_registrations/__init__.py`; Legacy-Duplikat unter „Scores“ im Platzhalter-Katalog entfernt.
|
||||
|
||||
### Updates (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)
|
||||
|
||||
- **`data_layer/nutrition_metrics.py`:** TDEE für Bilanz: primär **Mifflin–St Jeor BMR × PAL 1,55**, wenn Profil (Größe, Geschlecht, DOB) und Gewicht vorhanden; sonst Fallback **kg × 32,5** (`estimate_tdee_kcal_from_latest_weight`). `get_energy_balance_data` / `calculate_energy_balance_7d` nutzen **tägliche kcal-Summen**. **`_score_calorie_adherence`** (Komponente von `calculate_nutrition_score`) wertet die 7-Tage-Bilanz nach **`profiles.goal_mode`** aus (weight_loss vs. strength/recomposition vs. maintenance/health/endurance).
|
||||
- **`routers/charts.py`:** `/charts/energy-balance` und Protein-Timeline nutzen dieselbe TDEE-/Tageslogik; ohne `weight_log` liefert Energiebilanz-Chart eine klare Fehlermeldung. Adherence-Endpoint: Kcal-CV über **Tages-Summen**.
|
||||
- **Doku:** Normative Platzhalter-Zahl **114** (`docs/PLACEHOLDER_*.md`); `placeholder_metadata_complete.py` als **Legacy** gekennzeichnet — maßgeblich `placeholder_registrations/` + `PLACEHOLDER_REGISTRY_FRAMEWORK.md`.
|
||||
|
||||
### GUI / Informationsarchitektur (Abnahme dieser Iteration, 2026-04-05)
|
||||
|
||||
Admin-Bereich (`AdminShell`, Hub-Routen), Hauptnavigation inkl. **Ziele** (`config/appNav.js`), Einstellungen nur aktives Profil + E-Mail, KI-Analyse Ergebnis in rechter Spalte, **PWA** Bottom-Nav inkl. iOS Safe Area. Zentrale Agent-Doku: **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`**. Responsive-Epic **Gitea #30:** Phasenplan `docs/issues/PHASE_PLAN_RESPONSIVE_UI.md` — **P7 Kern erledigt**, **P8** (Regression/Abnahme) ausstehend; Issue bewusst **nicht** geschlossen.
|
||||
|
|
@ -859,7 +896,7 @@ Bottom-Padding Mobile: 80px (Navigation)
|
|||
|Auth-Flow|`.claude/library/AUTH.md`|Sicherheit + Sessions|
|
||||
|API-Referenz|`.claude/library/API\_REFERENCE.md`|Alle Endpoints|
|
||||
|Datenbankschema|`.claude/library/DATABASE.md`|Tabellen + Beziehungen|
|
||||
|Dashboard-Lab-Widgets|`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`|Katalog, Validierung, Frontend-Registry, konfigurierbare `config`|
|
||||
|Dashboard-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|
|
||||
|
||||
> Library-Dateien werden mit `/document` generiert und nach größeren
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import bcrypt
|
|||
|
||||
from db import get_db, get_cursor
|
||||
|
||||
print("[AUTH.PY] Module loaded - require_auth_flexible will be defined")
|
||||
|
||||
|
||||
def hash_pin(pin: str) -> str:
|
||||
"""Hash password with bcrypt. Falls back gracefully from legacy SHA256."""
|
||||
|
|
@ -76,21 +78,24 @@ def require_auth(x_auth_token: Optional[str] = Header(default=None)):
|
|||
return session
|
||||
|
||||
|
||||
def require_auth_flexible(x_auth_token: Optional[str] = Header(default=None), token: Optional[str] = Query(default=None)):
|
||||
def require_auth_flexible(x_auth_token: Optional[str] = Header(default=None), ssetoken: Optional[str] = Query(default=None)):
|
||||
"""
|
||||
FastAPI dependency - auth via header OR query parameter.
|
||||
|
||||
Used for endpoints accessed by <img> tags that can't send headers.
|
||||
Used for endpoints accessed by <img> tags and SSE connections that can't send headers.
|
||||
Query parameter is 'ssetoken' to avoid conflicts with endpoint 'token' parameters.
|
||||
|
||||
Usage:
|
||||
@app.get("/api/photos/{id}")
|
||||
def get_photo(id: str, session: dict = Depends(require_auth_flexible)):
|
||||
...
|
||||
|
||||
Call with: ?ssetoken=XXX or Header: X-Auth-Token: XXX
|
||||
|
||||
Raises:
|
||||
HTTPException 401 if not authenticated
|
||||
"""
|
||||
session = get_session(x_auth_token or token)
|
||||
session = get_session(x_auth_token or ssetoken)
|
||||
if not session:
|
||||
raise HTTPException(401, "Nicht eingeloggt")
|
||||
return session
|
||||
|
|
|
|||
|
|
@ -223,6 +223,11 @@ def calculate_arm_28d_delta(profile_id: str) -> Optional[float]:
|
|||
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]:
|
||||
"""Calculate 28-day thigh circumference change (cm)"""
|
||||
delta = _calculate_circumference_delta(profile_id, 'c_thigh', 28)
|
||||
|
|
|
|||
|
|
@ -509,17 +509,24 @@ def calculate_sleep_quality_7d(profile_id: str) -> Optional[int]:
|
|||
|
||||
quality_scores = []
|
||||
for s in sleep_data:
|
||||
if s['deep_minutes'] and s['rem_minutes']:
|
||||
quality_pct = ((s['deep_minutes'] + s['rem_minutes']) / s['duration_minutes']) * 100
|
||||
# 40-60% deep+REM is good
|
||||
if quality_pct >= 45:
|
||||
quality_scores.append(100)
|
||||
elif quality_pct >= 35:
|
||||
quality_scores.append(75)
|
||||
elif quality_pct >= 25:
|
||||
quality_scores.append(50)
|
||||
else:
|
||||
quality_scores.append(30)
|
||||
dur = s["duration_minutes"]
|
||||
if not dur or dur <= 0:
|
||||
continue
|
||||
d = s["deep_minutes"]
|
||||
r = s["rem_minutes"]
|
||||
if d is None and r is None:
|
||||
continue
|
||||
di, ri = (d or 0), (r or 0)
|
||||
quality_pct = ((di + ri) / dur) * 100
|
||||
# 40-60% deep+REM is good
|
||||
if quality_pct >= 45:
|
||||
quality_scores.append(100)
|
||||
elif quality_pct >= 35:
|
||||
quality_scores.append(75)
|
||||
elif quality_pct >= 25:
|
||||
quality_scores.append(50)
|
||||
else:
|
||||
quality_scores.append(30)
|
||||
|
||||
if not quality_scores:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -47,6 +47,46 @@ def sniff_delimiter(sample_line: str) -> str:
|
|||
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]:
|
||||
lines: List[str] = []
|
||||
for line in text.splitlines():
|
||||
|
|
@ -57,6 +97,18 @@ def _split_first_lines(text: str, max_lines: int = 5) -> List[str]:
|
|||
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(
|
||||
text: str,
|
||||
delimiter: str | None = None,
|
||||
|
|
@ -85,7 +137,7 @@ def parse_csv_sample(
|
|||
return [], [], delim
|
||||
|
||||
if has_header:
|
||||
headers = [h.strip() for h in rows_raw[0]]
|
||||
headers = [canonical_csv_header_label(h) for h in rows_raw[0]]
|
||||
data = rows_raw[1 : 1 + max_data_rows]
|
||||
else:
|
||||
n = len(rows_raw[0])
|
||||
|
|
@ -103,7 +155,7 @@ def parse_csv_sample(
|
|||
|
||||
|
||||
def normalize_header_for_signature(name: str) -> str:
|
||||
s = name.strip().lower()
|
||||
s = canonical_csv_header_label(name).lower()
|
||||
s = re.sub(r"\s+", "_", s)
|
||||
s = re.sub(r"[^a-z0-9_äöüß().%-]+", "_", s)
|
||||
return s.strip("_")
|
||||
|
|
@ -111,7 +163,9 @@ def normalize_header_for_signature(name: str) -> str:
|
|||
|
||||
def column_signature(headers: List[str]) -> List[str]:
|
||||
"""Sortierte normalisierte Spaltennamen für Signatur-Vergleich."""
|
||||
return sorted({normalize_header_for_signature(h) for h in headers if h is not None and str(h).strip()})
|
||||
return sorted(
|
||||
{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:
|
||||
|
|
@ -178,12 +232,6 @@ def get_csv_import_limits(conn_row: dict | None) -> dict[str, int]:
|
|||
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(
|
||||
text: str,
|
||||
delimiter: str,
|
||||
|
|
@ -205,4 +253,8 @@ def iter_csv_dict_rows(
|
|||
continue
|
||||
if not any(v and str(v).strip() for v in row.values()):
|
||||
continue
|
||||
yield {_strip_header_key(k): (v or "").strip() for k, v in row.items() if _strip_header_key(k)}
|
||||
yield {
|
||||
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
|
||||
|
||||
from csv_parser.core import iter_csv_dict_rows
|
||||
from csv_parser.core import iter_csv_dict_rows, resolve_effective_csv_delimiter
|
||||
from csv_parser.import_row_processing import (
|
||||
aggregate_mapped_rows,
|
||||
resolve_import_row_processing,
|
||||
|
|
@ -23,14 +23,6 @@ from csv_parser.type_converter import build_row_after_mapping
|
|||
|
||||
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):
|
||||
"""Lazy import — gleicher DB-Cursor wie der Import (kein verschachteltes get_db / Pool-Deadlock)."""
|
||||
|
|
@ -105,7 +97,8 @@ def run_universal_csv_import(
|
|||
if tc is not None and not isinstance(tc, dict):
|
||||
tc = None
|
||||
|
||||
delim = mapping.get("delimiter") or ","
|
||||
tpl_delim = str(mapping.get("delimiter") or ",").strip() or ","
|
||||
delim = resolve_effective_csv_delimiter(text, tpl_delim)
|
||||
has_header = mapping.get("has_header", True)
|
||||
|
||||
if module == "nutrition":
|
||||
|
|
@ -814,6 +807,17 @@ def _import_activity(
|
|||
error_details: list,
|
||||
affected_ids: dict,
|
||||
) -> 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
|
||||
inserted = 0
|
||||
updated = 0
|
||||
|
|
@ -885,6 +889,7 @@ def _import_activity(
|
|||
|
||||
wtype = str(activity_type).strip()
|
||||
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.
|
||||
cur.execute("SAVEPOINT csv_activity_row")
|
||||
|
|
@ -892,113 +897,79 @@ def _import_activity(
|
|||
training_type_id, training_category, training_subcategory = _resolve_training_type_for_activity(
|
||||
cur, wtype, profile_id
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM activity_log
|
||||
WHERE profile_id = %s AND date = %s AND start_time = %s
|
||||
""",
|
||||
(profile_id, iso, start_key),
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
registry_updates = activity_csv_registry_updates_from_mapped(mapped)
|
||||
existing_id = find_activity_duplicate_id(cur, profile_id, iso, workout_start_t)
|
||||
|
||||
if existing:
|
||||
eid = existing["id"]
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE activity_log
|
||||
SET end_time = %s,
|
||||
activity_type = %s,
|
||||
duration_min = %s,
|
||||
kcal_active = %s,
|
||||
kcal_resting = %s,
|
||||
hr_avg = %s,
|
||||
hr_max = %s,
|
||||
distance_km = %s,
|
||||
training_type_id = %s,
|
||||
training_category = %s,
|
||||
training_subcategory = %s,
|
||||
source = 'csv'
|
||||
WHERE id = %s
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
end_str or None,
|
||||
wtype,
|
||||
duration_min,
|
||||
kcal_a,
|
||||
kcal_r,
|
||||
hr_a,
|
||||
hr_m,
|
||||
dist,
|
||||
training_type_id,
|
||||
training_category,
|
||||
training_subcategory,
|
||||
eid,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if existing_id:
|
||||
upd = {
|
||||
"start_time": workout_start_t,
|
||||
"end_time": end_str or None,
|
||||
"activity_type": wtype,
|
||||
"duration_min": duration_min,
|
||||
"kcal_active": kcal_a,
|
||||
"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",
|
||||
}
|
||||
upd.update(registry_updates)
|
||||
update_activity_columns(cur, profile_id, existing_id, upd)
|
||||
updated += 1
|
||||
if row and row.get("id"):
|
||||
affected_ids["activity_log"].append(str(row["id"]))
|
||||
aid = eid
|
||||
affected_ids["activity_log"].append(str(existing_id))
|
||||
aid = existing_id
|
||||
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,
|
||||
),
|
||||
eid = new_activity_id()
|
||||
insert_activity_csv_minimal(
|
||||
cur,
|
||||
profile_id,
|
||||
eid,
|
||||
date_iso=iso,
|
||||
start_time=workout_start_t,
|
||||
end_time=end_str or None,
|
||||
activity_type=wtype,
|
||||
duration_min=duration_min,
|
||||
kcal_active=kcal_a,
|
||||
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()
|
||||
inserted += 1
|
||||
new_entries += 1
|
||||
if row and row.get("id"):
|
||||
affected_ids["activity_log"].append(str(row["id"]))
|
||||
affected_ids["activity_log"].append(str(eid))
|
||||
aid = eid
|
||||
if registry_updates:
|
||||
update_activity_columns(cur, profile_id, aid, registry_updates)
|
||||
|
||||
if _EVALUATION_AVAILABLE and training_type_id and _evaluate_and_save_activity:
|
||||
try:
|
||||
activity_dict = {
|
||||
"id": aid,
|
||||
"profile_id": profile_id,
|
||||
"date": iso,
|
||||
"training_type_id": training_type_id,
|
||||
"duration_min": duration_min,
|
||||
"hr_avg": hr_a,
|
||||
"hr_max": hr_m,
|
||||
"distance_km": dist,
|
||||
"kcal_active": kcal_a,
|
||||
"kcal_resting": kcal_r,
|
||||
"rpe": None,
|
||||
"pace_min_per_km": None,
|
||||
"cadence": None,
|
||||
"elevation_gain": None,
|
||||
}
|
||||
_evaluate_and_save_activity(cur, aid, activity_dict, training_type_id, profile_id)
|
||||
except Exception as eval_err:
|
||||
logger.warning("[csv activity] Auto-Eval fehlgeschlagen: %s", eval_err)
|
||||
run_activity_post_write_hooks_import(
|
||||
cur,
|
||||
profile_id,
|
||||
str(aid),
|
||||
workout_date=iso,
|
||||
training_type_id=training_type_id,
|
||||
duration_min=duration_min,
|
||||
hr_avg=hr_a,
|
||||
hr_max=hr_m,
|
||||
distance_km=dist,
|
||||
kcal_active=kcal_a,
|
||||
kcal_resting=kcal_r,
|
||||
)
|
||||
upsert_session_metrics_from_csv_mapped(
|
||||
cur,
|
||||
profile_id,
|
||||
str(aid),
|
||||
mapped,
|
||||
training_category,
|
||||
training_type_id,
|
||||
)
|
||||
cur.execute("RELEASE SAVEPOINT csv_activity_row")
|
||||
except Exception as e:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -37,12 +37,16 @@ def validate_import_row_processing(
|
|||
module: str,
|
||||
spec: Mapping[str, Any],
|
||||
field_mappings: Mapping[str, Any],
|
||||
cur=None,
|
||||
) -> None:
|
||||
"""Wirft ValueError bei ungültiger Konfiguration."""
|
||||
mod = get_module_definition(module)
|
||||
if not mod:
|
||||
raise ValueError(f"Unbekanntes Modul: {module}")
|
||||
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")}
|
||||
|
||||
group_by = spec.get("group_by") or []
|
||||
|
|
|
|||
|
|
@ -127,13 +127,19 @@ def _match_seed_to_db_field(header: str, seed_fm: Mapping[str, str]) -> str | No
|
|||
return None
|
||||
|
||||
|
||||
def _alias_suggest(norm: str, module: str, used: set[str]) -> str | None:
|
||||
def _alias_suggest(
|
||||
norm: str,
|
||||
module: str,
|
||||
used: set[str],
|
||||
*,
|
||||
field_order: list[str] | None = None,
|
||||
) -> str | None:
|
||||
aliases = _MODULE_HEADER_ALIASES.get(module, {})
|
||||
mod = get_module_definition(module)
|
||||
if not mod:
|
||||
return None
|
||||
field_order = list(mod["fields"].keys())
|
||||
for db_field in field_order:
|
||||
order = field_order if field_order is not None else list(mod["fields"].keys())
|
||||
for db_field in order:
|
||||
if db_field in used:
|
||||
continue
|
||||
tokens = aliases.get(db_field, frozenset())
|
||||
|
|
@ -152,6 +158,8 @@ def suggest_field_mappings(
|
|||
headers: list[str],
|
||||
module: str,
|
||||
seed_fm: Mapping[str, str] | None = None,
|
||||
*,
|
||||
effective_fields: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Mappt jede CSV-Spalte (Roh-Header als Key) auf DB-Feld oder '-'.
|
||||
|
|
@ -164,13 +172,16 @@ def suggest_field_mappings(
|
|||
if not mod:
|
||||
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}
|
||||
used: set[str] = set()
|
||||
|
||||
if seed_fm:
|
||||
for h in headers:
|
||||
db = _match_seed_to_db_field(h, seed_fm)
|
||||
if db and db not in used:
|
||||
if db and db not in used and db in fields_map:
|
||||
fm[h] = db
|
||||
used.add(db)
|
||||
|
||||
|
|
@ -178,7 +189,7 @@ def suggest_field_mappings(
|
|||
if fm[h] != "-":
|
||||
continue
|
||||
norm = _norm_key(h)
|
||||
db = _alias_suggest(norm, module, used)
|
||||
db = _alias_suggest(norm, module, used, field_order=field_order)
|
||||
if db:
|
||||
fm[h] = db
|
||||
used.add(db)
|
||||
|
|
@ -190,6 +201,8 @@ def build_type_conversions_for_mapping(
|
|||
module: str,
|
||||
field_mappings: Mapping[str, str],
|
||||
seed_tc: Mapping[str, Any] | None = None,
|
||||
*,
|
||||
effective_fields: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""type_conversions nur für zugewiesene Zielfelder; Seed überschreibt Defaults."""
|
||||
if module == "sleep":
|
||||
|
|
@ -198,6 +211,7 @@ def build_type_conversions_for_mapping(
|
|||
defaults = _DEFAULT_TYPE_CONVERSIONS.get(module, {})
|
||||
out: dict[str, Any] = {}
|
||||
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:
|
||||
for k, v in seed_tc.items():
|
||||
|
|
@ -208,6 +222,20 @@ def build_type_conversions_for_mapping(
|
|||
if t not in out and t in defaults:
|
||||
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)
|
||||
return out
|
||||
|
||||
|
|
|
|||
|
|
@ -34,19 +34,39 @@ 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": {
|
||||
"table": "activity_log",
|
||||
"fields": {
|
||||
"date": {"type": "date", "required": False},
|
||||
"start_time": {"type": "datetime", "required": False},
|
||||
"end_time": {"type": "datetime", "required": False},
|
||||
"activity_type": {"type": "string", "required": True},
|
||||
"duration_min": {"type": "float", "required": False, "min": 0},
|
||||
"kcal_active": {"type": "float", "required": False, "unit": "kcal"},
|
||||
"kcal_resting": {"type": "float", "required": False, "unit": "kcal"},
|
||||
"distance_km": {"type": "float", "required": False, "unit": "km"},
|
||||
"hr_avg": {"type": "float", "required": False, "min": 30, "max": 220},
|
||||
"hr_max": {"type": "float", "required": False, "min": 30, "max": 220},
|
||||
"date": {"type": "date", "required": False, "label_de": "Datum"},
|
||||
"start_time": {
|
||||
"type": "datetime",
|
||||
"required": False,
|
||||
"label_de": "Start (Datum/Uhrzeit)",
|
||||
},
|
||||
"end_time": {"type": "datetime", "required": False, "label_de": "Ende (Datum/Uhrzeit)"},
|
||||
"activity_type": {"type": "string", "required": True, "label_de": "Trainingsart / Workout-Typ"},
|
||||
"duration_min": {"type": "float", "required": False, "min": 0, "label_de": "Dauer (Minuten)"},
|
||||
"kcal_active": {"type": "float", "required": False, "unit": "kcal", "label_de": "Kalorien aktiv"},
|
||||
"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",
|
||||
"duplicate_key": ["profile_id", "date", "start_time"],
|
||||
|
|
@ -125,13 +145,16 @@ def list_modules() -> list[str]:
|
|||
return sorted(MODULE_DEFINITIONS.keys())
|
||||
|
||||
|
||||
def validate_field_mappings(module: str, field_mappings: dict) -> None:
|
||||
def validate_field_mappings(module: str, field_mappings: dict, cur=None) -> None:
|
||||
"""Wirft ValueError bei unbekanntem Modul oder unbekanntem DB-Feld."""
|
||||
mod = get_module_definition(module)
|
||||
if not mod:
|
||||
raise ValueError(f"Unbekanntes Modul: {module}")
|
||||
fields = cast(dict, mod["fields"])
|
||||
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:
|
||||
for _csv_col, db_field in field_mappings.items():
|
||||
if db_field not in ("", None, "-", "_skip"):
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from csv_parser.module_registry import (
|
|||
validate_field_mappings,
|
||||
validate_required_field_targets,
|
||||
)
|
||||
from data_layer.activity_persistence_orchestrator import merge_activity_csv_module_fields
|
||||
|
||||
ALLOWED_SPEC_TYPES = frozenset(
|
||||
{"string", "float", "number", "int", "date", "time", "datetime", "duration"}
|
||||
|
|
@ -50,6 +51,8 @@ def validate_csv_template(
|
|||
type_conversions: Mapping[str, Any] | None = None,
|
||||
import_row_processing: Mapping[str, Any] | None = None,
|
||||
column_signature: list[str] | None = None,
|
||||
*,
|
||||
cur=None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Prüft eine Vorlage ohne Datei-Upload.
|
||||
|
|
@ -74,8 +77,12 @@ def validate_csv_template(
|
|||
)
|
||||
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:
|
||||
validate_field_mappings(module, fm)
|
||||
validate_field_mappings(module, fm, cur=cur)
|
||||
except ValueError as e:
|
||||
errors.append(
|
||||
_issue(
|
||||
|
|
@ -100,7 +107,7 @@ def validate_csv_template(
|
|||
|
||||
if import_row_processing:
|
||||
try:
|
||||
validate_import_row_processing_spec(module, import_row_processing, fm)
|
||||
validate_import_row_processing_spec(module, import_row_processing, fm, cur=cur)
|
||||
except ValueError as e:
|
||||
errors.append(
|
||||
_issue(
|
||||
|
|
@ -111,7 +118,6 @@ def validate_csv_template(
|
|||
)
|
||||
)
|
||||
|
||||
field_defs = mod.get("fields") or {}
|
||||
for db_field, spec in tc.items():
|
||||
if db_field not in field_defs:
|
||||
errors.append(
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from typing import Any, Mapping, Sequence
|
|||
|
||||
from dateutil import parser as dateutil_parser
|
||||
|
||||
from csv_parser.core import normalize_header_for_signature
|
||||
from csv_parser.core import canonical_csv_header_label, normalize_header_for_signature
|
||||
from csv_parser.field_units import factor_source_to_canonical
|
||||
|
||||
# Alias → strptime (JSON in Kleinbuchstaben)
|
||||
|
|
@ -66,7 +66,13 @@ def _parse_float_auto(s: str) -> float:
|
|||
"""
|
||||
Heuristik ohne festes Locale: Punkt/Komma als Tausender vs. Dezimal,
|
||||
basierend auf der letzten erkannten Trennstelle und Gruppierung.
|
||||
|
||||
Apple Health u. a. liefern berechnete Mittelwerte mit vielen Nachkommastellen
|
||||
(z. B. «96.874937…») und Energie als «596.668904…» — dabei ist der Punkt
|
||||
immer Dezimaltrenner. Früher wurden lange Nachkommateile fälschlich so
|
||||
behandelt, dass der Punkt entfernt wurde (Tausender-Heuristik).
|
||||
"""
|
||||
raw = s
|
||||
s = _normalize_num_token(s)
|
||||
if not s or s in ("-", "—", "–"):
|
||||
raise ValueError("leer")
|
||||
|
|
@ -90,18 +96,35 @@ def _parse_float_auto(s: str) -> float:
|
|||
s = s.replace(",", "")
|
||||
elif last_comma >= 0:
|
||||
parts = s.split(",")
|
||||
if len(parts) == 2 and len(parts[1]) <= 2:
|
||||
s = parts[0].replace(".", "") + "." + parts[1]
|
||||
elif len(parts) == 2 and len(parts[1]) == 3 and len(parts[0]) <= 3:
|
||||
s = parts[0] + parts[1]
|
||||
if len(parts) == 2:
|
||||
left, right = parts[0], parts[1]
|
||||
if not right:
|
||||
raise ValueError("leer")
|
||||
left_digits = left.replace(".", "")
|
||||
# Langer Nachkommateil → Dezimalkomma; «1.234,56»-Fälle oben mit Punkt+Komma
|
||||
if len(right) > 3 or len(right) <= 2:
|
||||
s = left_digits + "." + right.replace(".", "")
|
||||
elif len(right) == 3 and len(left_digits) <= 3:
|
||||
s = left_digits + right
|
||||
else:
|
||||
s = left_digits + "." + right.replace(".", "")
|
||||
else:
|
||||
s = s.replace(",", "")
|
||||
elif last_dot >= 0:
|
||||
parts = s.split(".")
|
||||
if len(parts) == 2 and len(parts[1]) <= 2:
|
||||
s = parts[0].replace(",", "") + "." + parts[1]
|
||||
elif len(parts) == 2 and len(parts[1]) == 3 and len(parts[0]) <= 3:
|
||||
s = parts[0] + parts[1]
|
||||
if len(parts) == 2:
|
||||
left, right = parts[0], parts[1]
|
||||
if not right:
|
||||
raise ValueError("leer")
|
||||
left_digits = left.replace(",", "")
|
||||
# Genau ein Punkt: viele Nachkommastellen → Apple/US-Dezimalpunkt (nicht „.“ streichen)
|
||||
if len(right) > 3 or len(right) <= 2:
|
||||
s = left_digits + "." + right
|
||||
elif len(right) == 3:
|
||||
if len(left_digits) == 1 and left_digits != "0" and left_digits.isdigit():
|
||||
s = left_digits + right
|
||||
else:
|
||||
s = left_digits + "." + right
|
||||
elif len(parts) > 2:
|
||||
if len(parts[-1]) <= 2:
|
||||
s = "".join(parts[:-1]) + "." + parts[-1]
|
||||
|
|
@ -345,6 +368,18 @@ def _parse_int(raw: str, spec: Mapping[str, Any]) -> int:
|
|||
raise ValueError("leer")
|
||||
v = int(digits)
|
||||
return -v if neg else v
|
||||
# Ohne flexible: «108.0» / «96,8» trotzdem als Zahl mit Nachkommastellen
|
||||
s2 = _normalize_num_token(s)
|
||||
if "," in s2 or "." in s2:
|
||||
dec = spec.get("decimal_separator", ".")
|
||||
try:
|
||||
if dec in (None, "auto"):
|
||||
fv = _parse_float_auto(s2)
|
||||
else:
|
||||
fv = _parse_float(raw, str(dec))
|
||||
return int(round(fv))
|
||||
except (ValueError, InvalidOperation):
|
||||
pass
|
||||
s = re.sub(r"[^\d-]", "", s)
|
||||
if not s:
|
||||
raise ValueError("leer")
|
||||
|
|
@ -442,7 +477,12 @@ 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
|
||||
(wie column_signature). Exakter Treffer, dann Schlüssel nach Normalisierung,
|
||||
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)
|
||||
if v:
|
||||
return v if v not in ("-", "_skip") else None
|
||||
|
|
@ -453,6 +493,27 @@ def _lookup_db_field(csv_col: str, field_mappings: Mapping[str, str]) -> str | N
|
|||
for k, fv in field_mappings.items():
|
||||
if normalize_header_for_signature(str(k)) == norm:
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
"""
|
||||
Dashboard-Layout v1: Validierung, Produkt-Standard (Übersicht) und Lab-Standard.
|
||||
Dashboard-Layout v1: Validierung, Produkt-Standard (Übersicht) und Servertemplate (`lab_default_layout_dict`).
|
||||
|
||||
Erlaubte Widget-IDs und Reihenfolge: widget_catalog.WIDGET_CATALOG.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
|
@ -25,12 +26,13 @@ __all__ = [
|
|||
"coalesce_effective_layout",
|
||||
"default_layout_dict",
|
||||
"lab_default_layout_dict",
|
||||
"merge_missing_catalog_widgets",
|
||||
"product_default_layout_dict",
|
||||
]
|
||||
|
||||
|
||||
def lab_default_layout_dict() -> dict[str, Any]:
|
||||
"""Standard für Dashboard-Lab (Experimentier-Widgets)."""
|
||||
"""Serverseitiges Standardlayout (DEFAULT_LAB_WIDGET_IDS); API-Feld `lab_default_layout`, u. a. für Editor/Reset."""
|
||||
on = DEFAULT_LAB_WIDGET_IDS
|
||||
return {
|
||||
"version": 1,
|
||||
|
|
@ -52,6 +54,25 @@ def default_layout_dict() -> dict[str, Any]:
|
|||
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):
|
||||
id: str = Field(min_length=1, max_length=64)
|
||||
enabled: bool = True
|
||||
|
|
|
|||
|
|
@ -14,12 +14,18 @@ MAX_WIDGET_CONFIG_JSON_BYTES = 3072
|
|||
|
||||
WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({
|
||||
"body_overview",
|
||||
"body_history_viz",
|
||||
"nutrition_history_viz",
|
||||
"fitness_history_viz",
|
||||
"recovery_history_viz",
|
||||
"history_overview_viz",
|
||||
"activity_overview",
|
||||
"kpi_board",
|
||||
"quick_capture",
|
||||
"trend_kcal_weight",
|
||||
"nutrition_detail_charts",
|
||||
"recovery_charts_panel",
|
||||
"report_export",
|
||||
})
|
||||
|
||||
_QUICK_CAPTURE_KEYS: frozenset[str] = frozenset({
|
||||
|
|
@ -32,6 +38,141 @@ _QUICK_CAPTURE_KEYS: frozenset[str] = frozenset({
|
|||
_KPI_TILE_FIXED: frozenset[str] = frozenset({"body_fat", "avg_kcal"})
|
||||
_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:
|
||||
return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8"))
|
||||
|
|
@ -39,19 +180,44 @@ def _config_json_size_bytes(config: dict[str, Any]) -> int:
|
|||
|
||||
def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
|
||||
if raw is None:
|
||||
return {}
|
||||
raw = {}
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"Widget {widget_id}: config muss ein Objekt sein")
|
||||
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)")
|
||||
if not raw:
|
||||
return {}
|
||||
|
||||
if widget_id not in WIDGETS_ALLOWING_CONFIG:
|
||||
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
|
||||
if raw:
|
||||
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":
|
||||
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":
|
||||
return _validate_chart_days_only(raw, label="activity_overview")
|
||||
if widget_id == "kpi_board":
|
||||
|
|
@ -64,6 +230,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
|
|||
return _validate_chart_days_only(raw, label="nutrition_detail_charts")
|
||||
if widget_id == "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")
|
||||
|
||||
|
|
@ -150,6 +318,210 @@ def _parse_chart_days(v: Any, label: str) -> int:
|
|||
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]:
|
||||
allowed = frozenset({"chart_days"})
|
||||
unknown = set(raw) - allowed
|
||||
|
|
@ -163,3 +535,43 @@ def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, A
|
|||
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,9 @@ __all__ = [
|
|||
|
||||
# Body Metrics (Basic)
|
||||
'get_latest_weight_data',
|
||||
'get_bmi_data',
|
||||
'get_profile_goal_weight_data',
|
||||
'get_profile_goal_bf_pct_data',
|
||||
'get_weight_trend_data',
|
||||
'get_body_composition_data',
|
||||
'get_circumference_summary_data',
|
||||
|
|
@ -67,6 +70,7 @@ __all__ = [
|
|||
'calculate_hip_28d_delta',
|
||||
'calculate_chest_28d_delta',
|
||||
'calculate_arm_28d_delta',
|
||||
'calculate_arm_relaxed_28d_delta',
|
||||
'calculate_thigh_28d_delta',
|
||||
'calculate_waist_hip_ratio',
|
||||
'calculate_recomposition_quadrant',
|
||||
|
|
@ -99,6 +103,9 @@ __all__ = [
|
|||
'get_activity_summary_data',
|
||||
'get_activity_detail_data',
|
||||
'get_training_type_distribution_data',
|
||||
'get_training_frequency_by_type_data',
|
||||
'get_training_inter_session_gap_data',
|
||||
'get_training_sessions_recent_weeks_data',
|
||||
|
||||
# Activity Metrics (Calculated)
|
||||
'calculate_training_minutes_week',
|
||||
|
|
|
|||
61
backend/data_layer/activity_data_canon.py
Normal file
61
backend/data_layer/activity_data_canon.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""
|
||||
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",
|
||||
}
|
||||
|
||||
|
|
@ -7,6 +7,10 @@ Functions:
|
|||
- get_activity_summary_data(): Count, total duration, calories, averages
|
||||
- get_activity_detail_data(): Detailed activity log entries
|
||||
- get_training_type_distribution_data(): Training category percentages
|
||||
- get_training_frequency_by_type_data(): Häufigkeit & Intensität pro activity_type
|
||||
- get_training_inter_session_gap_data(): Pausen zwischen Einheiten (Stunden)
|
||||
- get_training_sessions_recent_weeks_data(): Wochen-JSON für KI-Kontext
|
||||
- get_training_parameters_ki_glossary_data(): Parameter-Katalog (Feld, Namen, Beschreibungen) für KI
|
||||
|
||||
All functions return structured data (dict) without formatting.
|
||||
Use placeholder_resolver.py for formatted strings for AI.
|
||||
|
|
@ -15,11 +19,16 @@ Phase 0c: Multi-Layer Architecture
|
|||
Version: 1.0
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
from datetime import datetime, timedelta, date
|
||||
from typing import Dict, List, Optional, Any
|
||||
from datetime import datetime, timedelta, date, time
|
||||
import statistics
|
||||
from db import get_db, get_cursor, r2d
|
||||
from data_layer.utils import calculate_confidence, safe_float, safe_int
|
||||
from data_layer.activity_session_metrics import enrich_sessions_with_metrics
|
||||
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(
|
||||
|
|
@ -120,7 +129,8 @@ def get_activity_detail_data(
|
|||
"duration_min": int,
|
||||
"kcal_active": int,
|
||||
"hr_avg": int | None,
|
||||
"training_category": str | None
|
||||
"training_category": str | None,
|
||||
"session_metrics": list | None, # EAV (enrich_sessions_with_metrics)
|
||||
},
|
||||
...
|
||||
],
|
||||
|
|
@ -139,6 +149,7 @@ def get_activity_detail_data(
|
|||
|
||||
cur.execute(
|
||||
"""SELECT
|
||||
id,
|
||||
date,
|
||||
activity_type,
|
||||
duration_min,
|
||||
|
|
@ -149,7 +160,7 @@ def get_activity_detail_data(
|
|||
WHERE profile_id=%s AND date >= %s
|
||||
ORDER BY date DESC
|
||||
LIMIT %s""",
|
||||
(profile_id, cutoff, limit)
|
||||
(profile_id, cutoff, limit),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
|
|
@ -158,19 +169,24 @@ def get_activity_detail_data(
|
|||
"activities": [],
|
||||
"total_count": 0,
|
||||
"confidence": "insufficient",
|
||||
"days_analyzed": days
|
||||
"days_analyzed": days,
|
||||
}
|
||||
|
||||
activities = []
|
||||
for row in rows:
|
||||
activities.append({
|
||||
"date": row['date'],
|
||||
"activity_type": row['activity_type'],
|
||||
"duration_min": safe_int(row['duration_min']),
|
||||
"kcal_active": safe_int(row['kcal_active']),
|
||||
"hr_avg": safe_int(row['hr_avg']) if row.get('hr_avg') else None,
|
||||
"training_category": row.get('training_category')
|
||||
})
|
||||
activities.append(
|
||||
{
|
||||
"id": str(row["id"]),
|
||||
"date": row["date"],
|
||||
"activity_type": row["activity_type"],
|
||||
"duration_min": safe_int(row["duration_min"]),
|
||||
"kcal_active": safe_int(row["kcal_active"]),
|
||||
"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")
|
||||
|
||||
|
|
@ -178,7 +194,7 @@ def get_activity_detail_data(
|
|||
"activities": activities,
|
||||
"total_count": len(activities),
|
||||
"confidence": confidence,
|
||||
"days_analyzed": days
|
||||
"days_analyzed": days,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -314,24 +330,30 @@ def calculate_training_frequency_7d(profile_id: str) -> Optional[int]:
|
|||
return int(row['session_count']) if row else None
|
||||
|
||||
|
||||
def calculate_quality_sessions_pct(profile_id: str) -> Optional[int]:
|
||||
"""Calculate percentage of quality sessions (good or better) last 28 days"""
|
||||
def calculate_quality_sessions_pct(profile_id: str, days: int = 28) -> Optional[int]:
|
||||
"""Anteil qualitativ guter Sessions (quality_label) im Zeitfenster ``days``."""
|
||||
if days < 1:
|
||||
days = 28
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE quality_label IN ('excellent', 'very_good', 'good')) as quality_count
|
||||
FROM activity_log
|
||||
WHERE profile_id = %s
|
||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
||||
""", (profile_id,))
|
||||
AND date >= %s
|
||||
""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
|
||||
row = cur.fetchone()
|
||||
if not row or row['total'] == 0:
|
||||
if not row or row["total"] == 0:
|
||||
return None
|
||||
|
||||
pct = (row['quality_count'] / row['total']) * 100
|
||||
pct = (row["quality_count"] / row["total"]) * 100
|
||||
return int(pct)
|
||||
|
||||
|
||||
|
|
@ -479,11 +501,12 @@ def calculate_ability_balance_mobility(profile_id: str) -> Optional[int]:
|
|||
# A5: Load Monitoring (Proxy-based)
|
||||
# ============================================================================
|
||||
|
||||
def calculate_proxy_internal_load_7d(profile_id: str) -> Optional[int]:
|
||||
def calculate_proxy_internal_load_window(profile_id: str, days: int = 7) -> Optional[float]:
|
||||
"""
|
||||
Calculate proxy internal load (last 7 days)
|
||||
Formula: duration × intensity_factor × quality_factor
|
||||
Proxy-Last über die letzten ``days`` Kalendertage (gleiche Formel wie bisher nur für 7 Tage).
|
||||
"""
|
||||
if days < 1:
|
||||
days = 7
|
||||
intensity_factors = {'low': 1.0, 'moderate': 1.5, 'high': 2.0}
|
||||
quality_factors = {
|
||||
'excellent': 1.15,
|
||||
|
|
@ -496,12 +519,15 @@ def calculate_proxy_internal_load_7d(profile_id: str) -> Optional[int]:
|
|||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT duration_min, hr_avg, rpe
|
||||
FROM activity_log
|
||||
WHERE profile_id = %s
|
||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
||||
""", (profile_id,))
|
||||
AND date >= CURRENT_DATE - (%s::int * INTERVAL '1 day')
|
||||
""",
|
||||
(profile_id, days),
|
||||
)
|
||||
|
||||
activities = cur.fetchall()
|
||||
|
||||
|
|
@ -538,7 +564,12 @@ def calculate_proxy_internal_load_7d(profile_id: str) -> Optional[int]:
|
|||
load = float(duration) * intensity_factors[intensity] * quality_factors.get(quality, 1.0)
|
||||
total_load += load
|
||||
|
||||
return int(total_load)
|
||||
return float(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]:
|
||||
|
|
@ -601,26 +632,23 @@ def calculate_activity_score(profile_id: str, focus_weights: Optional[Dict] = No
|
|||
from data_layer.scores import get_user_focus_weights
|
||||
focus_weights = get_user_focus_weights(profile_id)
|
||||
|
||||
# Activity-related focus areas (English keys from DB)
|
||||
# Strength training
|
||||
strength = focus_weights.get('strength', 0)
|
||||
strength_endurance = focus_weights.get('strength_endurance', 0)
|
||||
power = focus_weights.get('power', 0)
|
||||
# Activity-related focus areas (English keys from DB); Gewichte float (kein Decimal×float)
|
||||
strength = float(focus_weights.get('strength', 0) or 0)
|
||||
strength_endurance = float(focus_weights.get('strength_endurance', 0) or 0)
|
||||
power = float(focus_weights.get('power', 0) or 0)
|
||||
total_strength = strength + strength_endurance + power
|
||||
|
||||
# Endurance training
|
||||
aerobic = focus_weights.get('aerobic_endurance', 0)
|
||||
anaerobic = focus_weights.get('anaerobic_endurance', 0)
|
||||
cardiovascular = focus_weights.get('cardiovascular_health', 0)
|
||||
aerobic = float(focus_weights.get('aerobic_endurance', 0) or 0)
|
||||
anaerobic = float(focus_weights.get('anaerobic_endurance', 0) or 0)
|
||||
cardiovascular = float(focus_weights.get('cardiovascular_health', 0) or 0)
|
||||
total_cardio = aerobic + anaerobic + cardiovascular
|
||||
|
||||
# Mobility/Coordination
|
||||
flexibility = focus_weights.get('flexibility', 0)
|
||||
mobility = focus_weights.get('mobility', 0)
|
||||
balance = focus_weights.get('balance', 0)
|
||||
reaction = focus_weights.get('reaction', 0)
|
||||
rhythm = focus_weights.get('rhythm', 0)
|
||||
coordination = focus_weights.get('coordination', 0)
|
||||
flexibility = float(focus_weights.get('flexibility', 0) or 0)
|
||||
mobility = float(focus_weights.get('mobility', 0) or 0)
|
||||
balance = float(focus_weights.get('balance', 0) or 0)
|
||||
reaction = float(focus_weights.get('reaction', 0) or 0)
|
||||
rhythm = float(focus_weights.get('rhythm', 0) or 0)
|
||||
coordination = float(focus_weights.get('coordination', 0) or 0)
|
||||
total_ability = flexibility + mobility + balance + reaction + rhythm + coordination
|
||||
|
||||
total_activity_weight = total_strength + total_cardio + total_ability
|
||||
|
|
@ -671,9 +699,9 @@ def calculate_activity_score(profile_id: str, focus_weights: Optional[Dict] = No
|
|||
if not components:
|
||||
return None
|
||||
|
||||
# Weighted average
|
||||
total_score = sum(score * weight for _, score, weight in components)
|
||||
total_weight = sum(weight for _, _, weight in components)
|
||||
# Weighted average (float: DB-Aggregate können Decimal sein)
|
||||
total_score = sum(float(score) * float(weight) for _, score, weight in components)
|
||||
total_weight = sum(float(weight) for _, _, weight in components)
|
||||
|
||||
return int(total_score / total_weight)
|
||||
|
||||
|
|
@ -725,12 +753,13 @@ def _score_cardio_presence(profile_id: str) -> Optional[int]:
|
|||
if not row:
|
||||
return None
|
||||
|
||||
cardio_days = row['cardio_days']
|
||||
cardio_minutes = row['cardio_minutes'] or 0
|
||||
# psycopg2: SUM() → oft Decimal — vor Mix mit float konvertieren
|
||||
cardio_days = int(row['cardio_days'] or 0)
|
||||
cardio_minutes = float(row['cardio_minutes'] or 0)
|
||||
|
||||
# Target: 3-5 days/week, 150+ minutes
|
||||
day_score = min(100, (cardio_days / 4) * 100)
|
||||
minute_score = min(100, (cardio_minutes / 150) * 100)
|
||||
day_score = min(100.0, (cardio_days / 4) * 100)
|
||||
minute_score = min(100.0, (cardio_minutes / 150) * 100)
|
||||
|
||||
return int((day_score + minute_score) / 2)
|
||||
|
||||
|
|
@ -904,3 +933,605 @@ def calculate_activity_data_quality(profile_id: str) -> Dict[str, any]:
|
|||
"quality": int(quality_score)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _session_sort_ts(row: Dict) -> datetime:
|
||||
"""Einheitlicher Zeitstempel für Sortierung und Pausenberechnung."""
|
||||
d = row["date"]
|
||||
if isinstance(d, str):
|
||||
d = datetime.strptime(d[:10], "%Y-%m-%d").date()
|
||||
st = row.get("start_time")
|
||||
if st is None:
|
||||
t = time(12, 0, 0)
|
||||
else:
|
||||
t = st
|
||||
return datetime.combine(d, t)
|
||||
|
||||
|
||||
def get_training_frequency_by_type_data(
|
||||
profile_id: str,
|
||||
days: int = 28,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Pro activity_type (Roh-Label aus Import/Anzeige): Häufigkeit & Intensitätskennzahlen.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"days_analyzed": int,
|
||||
"confidence": str,
|
||||
"by_type": [
|
||||
{
|
||||
"activity_type": str,
|
||||
"session_count": int,
|
||||
"sessions_per_week": float,
|
||||
"avg_duration_min": float | None,
|
||||
"avg_kcal_active": float | None,
|
||||
"avg_hr_avg": float | None,
|
||||
"avg_hr_max": float | None,
|
||||
"avg_rpe": float | None,
|
||||
"avg_kcal_per_min": float | None, # grobe Intensität, wenn kcal & Dauer
|
||||
},
|
||||
...
|
||||
],
|
||||
}
|
||||
"""
|
||||
weeks = max(days / 7.0, 0.01)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
activity_type,
|
||||
COUNT(*)::int AS session_count,
|
||||
AVG(duration_min)::float AS avg_duration_min,
|
||||
AVG(kcal_active)::float AS avg_kcal_active,
|
||||
AVG(hr_avg)::float AS avg_hr_avg,
|
||||
AVG(hr_max)::float AS avg_hr_max,
|
||||
AVG(rpe)::float AS avg_rpe,
|
||||
SUM(COALESCE(duration_min, 0))::float AS sum_duration,
|
||||
SUM(COALESCE(kcal_active, 0))::float AS sum_kcal
|
||||
FROM activity_log
|
||||
WHERE profile_id = %s AND date >= %s
|
||||
GROUP BY activity_type
|
||||
ORDER BY session_count DESC
|
||||
""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
rows = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
if not rows:
|
||||
return {
|
||||
"days_analyzed": days,
|
||||
"confidence": "insufficient",
|
||||
"by_type": [],
|
||||
}
|
||||
|
||||
by_type = []
|
||||
for r in rows:
|
||||
sc = int(r["session_count"])
|
||||
sum_dur = float(r["sum_duration"] or 0)
|
||||
sum_kcal = float(r["sum_kcal"] or 0)
|
||||
kcal_per_min = (sum_kcal / sum_dur) if sum_dur > 0 else None
|
||||
by_type.append(
|
||||
{
|
||||
"activity_type": r["activity_type"],
|
||||
"session_count": sc,
|
||||
"sessions_per_week": round(sc / weeks, 2),
|
||||
"avg_duration_min": r["avg_duration_min"],
|
||||
"avg_kcal_active": r["avg_kcal_active"],
|
||||
"avg_hr_avg": r["avg_hr_avg"],
|
||||
"avg_hr_max": r["avg_hr_max"],
|
||||
"avg_rpe": r["avg_rpe"],
|
||||
"avg_kcal_per_min": round(kcal_per_min, 2) if kcal_per_min is not None else None,
|
||||
}
|
||||
)
|
||||
|
||||
total_sessions = sum(x["session_count"] for x in by_type)
|
||||
confidence = calculate_confidence(total_sessions, days, "general")
|
||||
return {
|
||||
"days_analyzed": days,
|
||||
"confidence": confidence,
|
||||
"by_type": by_type,
|
||||
}
|
||||
|
||||
|
||||
def get_training_inter_session_gap_data(
|
||||
profile_id: str,
|
||||
days: int = 28,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Mittlere/median Pausen zwischen aufeinanderfolgenden Trainingseinheiten (Stunden).
|
||||
|
||||
Sortierung: Datum + start_time (fehlend → 12:00), dann created.
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT date, start_time, created
|
||||
FROM activity_log
|
||||
WHERE profile_id = %s AND date >= %s
|
||||
ORDER BY date ASC, start_time ASC NULLS LAST, created ASC
|
||||
""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
rows = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
if len(rows) < 2:
|
||||
return {
|
||||
"days_analyzed": days,
|
||||
"confidence": "insufficient",
|
||||
"gap_hours_median": None,
|
||||
"gap_hours_mean": None,
|
||||
"gap_hours_min": None,
|
||||
"gaps_count": 0,
|
||||
}
|
||||
|
||||
gaps = []
|
||||
prev_ts = None
|
||||
for r in rows:
|
||||
ts = _session_sort_ts(r)
|
||||
if prev_ts is not None:
|
||||
gaps.append((ts - prev_ts).total_seconds() / 3600.0)
|
||||
prev_ts = ts
|
||||
|
||||
if not gaps:
|
||||
return {
|
||||
"days_analyzed": days,
|
||||
"confidence": "insufficient",
|
||||
"gap_hours_median": None,
|
||||
"gap_hours_mean": None,
|
||||
"gap_hours_min": None,
|
||||
"gaps_count": 0,
|
||||
}
|
||||
|
||||
gaps_sorted = sorted(gaps)
|
||||
mid = len(gaps_sorted) // 2
|
||||
median = (
|
||||
gaps_sorted[mid]
|
||||
if len(gaps_sorted) % 2
|
||||
else (gaps_sorted[mid - 1] + gaps_sorted[mid]) / 2.0
|
||||
)
|
||||
confidence = calculate_confidence(len(rows), days, "general")
|
||||
return {
|
||||
"days_analyzed": days,
|
||||
"confidence": confidence,
|
||||
"gap_hours_median": round(median, 1),
|
||||
"gap_hours_mean": round(statistics.mean(gaps), 1),
|
||||
"gap_hours_min": round(min(gaps), 1),
|
||||
"gaps_count": len(gaps),
|
||||
}
|
||||
|
||||
|
||||
def get_training_sessions_recent_weeks_data(
|
||||
profile_id: str,
|
||||
weeks: int = 4,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Letzte Wochen mit Einzeltrainings für KI-Kontext (Dauer, kcal, HF, Typ).
|
||||
|
||||
weeks: Anzahl zurückliegender ISO-Kalenderwochen (Default 4).
|
||||
|
||||
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)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
a.id,
|
||||
a.date,
|
||||
a.start_time,
|
||||
a.activity_type,
|
||||
a.training_category,
|
||||
a.duration_min,
|
||||
a.kcal_active,
|
||||
a.hr_avg,
|
||||
a.hr_max,
|
||||
a.rpe,
|
||||
tt.name_de AS training_type_name
|
||||
FROM activity_log a
|
||||
LEFT JOIN training_types tt ON tt.id = a.training_type_id
|
||||
WHERE a.profile_id = %s AND a.date >= %s
|
||||
ORDER BY a.date ASC, a.start_time ASC NULLS LAST, a.created ASC
|
||||
""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
rows = [r2d(r) for r in cur.fetchall()]
|
||||
enrich_sessions_with_metrics(cur, rows)
|
||||
|
||||
if not rows:
|
||||
return {
|
||||
"weeks": [],
|
||||
"meta": {
|
||||
"weeks_requested": weeks,
|
||||
"days_loaded": days,
|
||||
"session_count": 0,
|
||||
"confidence": "insufficient",
|
||||
"session_metrics_shape": "key_value",
|
||||
"metric_semantics_placeholder": "{{training_parameters_glossary_md}}",
|
||||
},
|
||||
}
|
||||
|
||||
by_week: Dict[str, List[Dict]] = {}
|
||||
for r in rows:
|
||||
d = r["date"]
|
||||
if isinstance(d, str):
|
||||
d = datetime.strptime(d[:10], "%Y-%m-%d").date()
|
||||
iso = d.isocalendar()
|
||||
wk = f"{iso.year}-W{iso.week:02d}"
|
||||
if wk not in by_week:
|
||||
by_week[wk] = []
|
||||
dur = r.get("duration_min")
|
||||
dur_f = float(dur) if dur is not None else None
|
||||
kcal = r.get("kcal_active")
|
||||
kcal_f = float(kcal) if kcal is not None else None
|
||||
hr_a = r.get("hr_avg")
|
||||
hr_m = r.get("hr_max")
|
||||
sm_compact = session_metrics_list_to_key_value_compact(r.get("session_metrics"))
|
||||
by_week[wk].append(
|
||||
{
|
||||
"id": str(r["id"]),
|
||||
"date": d,
|
||||
"start_time": str(r["start_time"]) if r.get("start_time") is not None else None,
|
||||
"activity_type": r.get("activity_type"),
|
||||
"training_category": r.get("training_category"),
|
||||
"training_type_name": r.get("training_type_name"),
|
||||
"duration_min": normalize_prompt_number(dur_f) if dur_f is not None else None,
|
||||
"kcal_active": normalize_prompt_number(kcal_f) if kcal_f 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,
|
||||
"rpe": int(r["rpe"]) if r.get("rpe") is not None else None,
|
||||
"session_metrics": sm_compact,
|
||||
}
|
||||
)
|
||||
|
||||
week_keys = sorted(by_week.keys())
|
||||
weeks_out = [{"week_iso": wk, "sessions": by_week[wk]} for wk in week_keys]
|
||||
confidence = calculate_confidence(len(rows), days, "general")
|
||||
return serialize_dates(
|
||||
{
|
||||
"weeks": weeks_out,
|
||||
"meta": {
|
||||
"weeks_requested": weeks,
|
||||
"days_loaded": days,
|
||||
"session_count": len(rows),
|
||||
"confidence": confidence,
|
||||
"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",
|
||||
}
|
||||
),
|
||||
}
|
||||
|
|
|
|||
406
backend/data_layer/activity_persistence_orchestrator.py
Normal file
406
backend/data_layer/activity_persistence_orchestrator.py
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
"""
|
||||
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())
|
||||
779
backend/data_layer/activity_session_metrics.py
Normal file
779
backend/data_layer/activity_session_metrics.py
Normal file
|
|
@ -0,0 +1,779 @@
|
|||
"""
|
||||
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
|
||||
]
|
||||
30
backend/data_layer/activity_time_normalize.py
Normal file
30
backend/data_layer/activity_time_normalize.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""
|
||||
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
|
||||
330
backend/data_layer/body_interpretation.py
Normal file
330
backend/data_layer/body_interpretation.py
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
"""
|
||||
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
|
||||
|
|
@ -5,6 +5,9 @@ Provides structured data for body composition and measurements.
|
|||
|
||||
Functions:
|
||||
- get_latest_weight_data(): Most recent weight entry
|
||||
- get_bmi_data(): BMI from latest weight + profile height
|
||||
- get_profile_goal_weight_data(): Zielgewicht (Profilfeld)
|
||||
- get_profile_goal_bf_pct_data(): Ziel-KFA % (Profilfeld)
|
||||
- get_weight_trend_data(): Weight trend with slope and direction
|
||||
- get_body_composition_data(): Body fat percentage and lean mass
|
||||
- get_circumference_summary_data(): Latest circumference measurements
|
||||
|
|
@ -68,6 +71,105 @@ def get_latest_weight_data(
|
|||
}
|
||||
|
||||
|
||||
def get_bmi_data(profile_id: str) -> Dict:
|
||||
"""
|
||||
BMI from latest weight_log entry and profiles.height (cm).
|
||||
|
||||
Returns:
|
||||
{
|
||||
"bmi": float | None,
|
||||
"weight_kg": float | None,
|
||||
"height_cm": float | None,
|
||||
"confidence": "high" | "insufficient",
|
||||
}
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT pr.height,
|
||||
(SELECT wl.weight FROM weight_log wl
|
||||
WHERE wl.profile_id = pr.id
|
||||
ORDER BY wl.date DESC
|
||||
LIMIT 1) AS weight
|
||||
FROM profiles pr
|
||||
WHERE pr.id = %s
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return {
|
||||
"bmi": None,
|
||||
"weight_kg": None,
|
||||
"height_cm": None,
|
||||
"confidence": "insufficient",
|
||||
}
|
||||
|
||||
height_cm = row["height"]
|
||||
weight = row["weight"]
|
||||
if height_cm is None or weight is None:
|
||||
return {
|
||||
"bmi": None,
|
||||
"weight_kg": safe_float(weight) if weight is not None else None,
|
||||
"height_cm": safe_float(height_cm) if height_cm is not None else None,
|
||||
"confidence": "insufficient",
|
||||
}
|
||||
|
||||
h = safe_float(height_cm)
|
||||
w = safe_float(weight)
|
||||
if h <= 0:
|
||||
return {
|
||||
"bmi": None,
|
||||
"weight_kg": w,
|
||||
"height_cm": h,
|
||||
"confidence": "insufficient",
|
||||
}
|
||||
|
||||
height_m = h / 100.0
|
||||
bmi = w / (height_m ** 2)
|
||||
return {
|
||||
"bmi": bmi,
|
||||
"weight_kg": w,
|
||||
"height_cm": h,
|
||||
"confidence": "high",
|
||||
}
|
||||
|
||||
|
||||
def get_profile_goal_weight_data(profile_id: str) -> Dict:
|
||||
"""Strategisches Zielgewicht aus profiles.goal_weight (kg), nicht goals-Tabelle."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"SELECT goal_weight FROM profiles WHERE id=%s",
|
||||
(profile_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row or row.get("goal_weight") is None:
|
||||
return {"goal_weight_kg": None, "confidence": "insufficient"}
|
||||
return {
|
||||
"goal_weight_kg": safe_float(row["goal_weight"]),
|
||||
"confidence": "high",
|
||||
}
|
||||
|
||||
|
||||
def get_profile_goal_bf_pct_data(profile_id: str) -> Dict:
|
||||
"""Strategisches Ziel-KFA aus profiles.goal_bf_pct (%), nicht goals-Tabelle."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"SELECT goal_bf_pct FROM profiles WHERE id=%s",
|
||||
(profile_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row or row.get("goal_bf_pct") is None:
|
||||
return {"goal_bf_pct": None, "confidence": "insufficient"}
|
||||
return {
|
||||
"goal_bf_pct": safe_float(row["goal_bf_pct"]),
|
||||
"confidence": "high",
|
||||
}
|
||||
|
||||
|
||||
def get_weight_trend_data(
|
||||
profile_id: str,
|
||||
days: int = 28
|
||||
|
|
@ -89,7 +191,8 @@ def get_weight_trend_data(
|
|||
"confidence": str,
|
||||
"days_analyzed": int,
|
||||
"first_date": date,
|
||||
"last_date": date
|
||||
"last_date": date,
|
||||
"series": [{"date": date, "weight": float}, ...], # für Charts ohne zweites Query
|
||||
}
|
||||
|
||||
Confidence Rules:
|
||||
|
|
@ -127,7 +230,8 @@ def get_weight_trend_data(
|
|||
"delta": 0.0,
|
||||
"direction": "unknown",
|
||||
"first_date": None,
|
||||
"last_date": None
|
||||
"last_date": None,
|
||||
"series": [],
|
||||
}
|
||||
|
||||
# Extract values
|
||||
|
|
@ -152,7 +256,11 @@ def get_weight_trend_data(
|
|||
"confidence": confidence,
|
||||
"days_analyzed": days,
|
||||
"first_date": rows[0]['date'],
|
||||
"last_date": rows[-1]['date']
|
||||
"last_date": rows[-1]['date'],
|
||||
"series": [
|
||||
{"date": r["date"], "weight": safe_float(r["weight"])}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -262,7 +370,8 @@ def get_circumference_summary_data(
|
|||
('c_hip', 'Hüfte'),
|
||||
('c_thigh', 'Oberschenkel'),
|
||||
('c_calf', 'Wade'),
|
||||
('c_arm', 'Arm')
|
||||
('c_arm', 'Oberarm kontrahiert'),
|
||||
('c_arm_relaxed', 'Oberarm'),
|
||||
]
|
||||
|
||||
measurements = []
|
||||
|
|
@ -293,7 +402,7 @@ def get_circumference_summary_data(
|
|||
})
|
||||
|
||||
# Calculate confidence based on how many points we have
|
||||
confidence = calculate_confidence(len(measurements), 8, "general")
|
||||
confidence = calculate_confidence(len(measurements), 9, "general")
|
||||
|
||||
if not measurements:
|
||||
return {
|
||||
|
|
@ -337,12 +446,16 @@ def calculate_weight_7d_median(profile_id: str) -> Optional[float]:
|
|||
ORDER BY date DESC
|
||||
""", (profile_id,))
|
||||
|
||||
weights = [row['weight'] for row in cur.fetchall()]
|
||||
weights = [
|
||||
safe_float(row['weight'])
|
||||
for row in cur.fetchall()
|
||||
if row['weight'] is not None
|
||||
]
|
||||
|
||||
if len(weights) < 4: # Need at least 4 measurements
|
||||
return None
|
||||
|
||||
return round(statistics.median(weights), 1)
|
||||
return round(float(statistics.median(weights)), 1)
|
||||
|
||||
|
||||
def calculate_weight_28d_slope(profile_id: str) -> Optional[float]:
|
||||
|
|
@ -370,7 +483,11 @@ def _calculate_weight_slope(profile_id: str, days: int) -> Optional[float]:
|
|||
ORDER BY date
|
||||
""", (profile_id, days))
|
||||
|
||||
data = [(row['date'], row['weight']) for row in cur.fetchall()]
|
||||
data = [
|
||||
(row['date'], safe_float(row['weight']))
|
||||
for row in cur.fetchall()
|
||||
if row['weight'] is not None
|
||||
]
|
||||
|
||||
# Need minimum data points based on period
|
||||
min_points = max(18, int(days * 0.6)) # 60% coverage
|
||||
|
|
@ -380,21 +497,21 @@ def _calculate_weight_slope(profile_id: str, days: int) -> Optional[float]:
|
|||
# Convert dates to days since start
|
||||
start_date = data[0][0]
|
||||
x_values = [(date - start_date).days for date, _ in data]
|
||||
y_values = [weight for _, weight in data]
|
||||
y_values = [w for _, w in data]
|
||||
|
||||
# Linear regression
|
||||
# Linear regression (alles float: PostgreSQL numeric → Decimal in Python)
|
||||
n = len(data)
|
||||
x_mean = sum(x_values) / n
|
||||
y_mean = sum(y_values) / n
|
||||
x_mean = float(sum(x_values)) / n
|
||||
y_mean = float(sum(y_values)) / n
|
||||
|
||||
numerator = sum((x - x_mean) * (y - y_mean) for x, y in zip(x_values, y_values))
|
||||
denominator = sum((x - x_mean) ** 2 for x in x_values)
|
||||
numerator = sum(float(x - x_mean) * float(y - y_mean) for x, y in zip(x_values, y_values))
|
||||
denominator = float(sum((x - x_mean) ** 2 for x in x_values))
|
||||
|
||||
if denominator == 0:
|
||||
return None
|
||||
|
||||
slope = numerator / denominator
|
||||
return round(slope, 4) # kg/day
|
||||
return round(float(slope), 4) # kg/day
|
||||
|
||||
|
||||
def calculate_goal_projection_date(profile_id: str, goal_id: str) -> Optional[str]:
|
||||
|
|
@ -486,19 +603,24 @@ def _calculate_body_composition_change(profile_id: str, metric: str, days: int)
|
|||
recent = data[0]
|
||||
oldest = data[-1]
|
||||
|
||||
# Calculate FM and LBM
|
||||
recent_fm = recent['weight'] * (recent['bf_pct'] / 100)
|
||||
recent_lbm = recent['weight'] - recent_fm
|
||||
# Calculate FM and LBM (DB numeric → Decimal; für Regression/Scores nur float)
|
||||
rw = float(safe_float(recent['weight']) or 0)
|
||||
ob = float(safe_float(recent['bf_pct']) or 0)
|
||||
ow = float(safe_float(oldest['weight']) or 0)
|
||||
obf = float(safe_float(oldest['bf_pct']) or 0)
|
||||
|
||||
oldest_fm = oldest['weight'] * (oldest['bf_pct'] / 100)
|
||||
oldest_lbm = oldest['weight'] - oldest_fm
|
||||
recent_fm = rw * (ob / 100)
|
||||
recent_lbm = rw - recent_fm
|
||||
|
||||
oldest_fm = ow * (obf / 100)
|
||||
oldest_lbm = ow - oldest_fm
|
||||
|
||||
if metric == 'fm':
|
||||
change = recent_fm - oldest_fm
|
||||
else:
|
||||
change = recent_lbm - oldest_lbm
|
||||
|
||||
return round(change, 2)
|
||||
return round(float(change), 2)
|
||||
|
||||
|
||||
# ── Circumference Calculations ──────────────────────────────────────────────
|
||||
|
|
@ -519,10 +641,15 @@ def calculate_chest_28d_delta(profile_id: str) -> Optional[float]:
|
|||
|
||||
|
||||
def calculate_arm_28d_delta(profile_id: str) -> Optional[float]:
|
||||
"""Calculate 28-day arm circumference change (cm)"""
|
||||
"""28-Tage-Delta Oberarm kontrahiert (c_arm), cm."""
|
||||
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]:
|
||||
"""Calculate 28-day thigh circumference change (cm)"""
|
||||
delta = _calculate_circumference_delta(profile_id, 'c_thigh', 28)
|
||||
|
|
@ -623,9 +750,9 @@ def calculate_body_progress_score(profile_id: str, focus_weights: Optional[Dict]
|
|||
from data_layer.scores import get_user_focus_weights
|
||||
focus_weights = get_user_focus_weights(profile_id)
|
||||
|
||||
weight_loss = focus_weights.get('weight_loss', 0)
|
||||
muscle_gain = focus_weights.get('muscle_gain', 0)
|
||||
body_recomp = focus_weights.get('body_recomposition', 0)
|
||||
weight_loss = float(focus_weights.get('weight_loss', 0) or 0)
|
||||
muscle_gain = float(focus_weights.get('muscle_gain', 0) or 0)
|
||||
body_recomp = float(focus_weights.get('body_recomposition', 0) or 0)
|
||||
|
||||
total_body_weight = weight_loss + muscle_gain + body_recomp
|
||||
|
||||
|
|
@ -652,8 +779,8 @@ def calculate_body_progress_score(profile_id: str, focus_weights: Optional[Dict]
|
|||
if not components:
|
||||
return None
|
||||
|
||||
total_score = sum(score * weight for _, score, weight in components)
|
||||
total_weight = sum(weight for _, _, weight in components)
|
||||
total_score = sum(float(score) * float(weight) for _, score, weight in components)
|
||||
total_weight = sum(float(weight) for _, _, weight in components)
|
||||
|
||||
return int(total_score / total_weight)
|
||||
|
||||
|
|
|
|||
494
backend/data_layer/body_viz.py
Normal file
494
backend/data_layer/body_viz.py
Normal file
|
|
@ -0,0 +1,494 @@
|
|||
"""
|
||||
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",
|
||||
},
|
||||
}
|
||||
256
backend/data_layer/correlation_chart_payloads.py
Normal file
256
backend/data_layer/correlation_chart_payloads.py
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
"""
|
||||
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,118 +17,403 @@ Phase 0c: Multi-Layer Architecture
|
|||
Version: 1.0
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from datetime import datetime, timedelta, date
|
||||
from db import get_db, get_cursor, r2d
|
||||
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]:
|
||||
"""
|
||||
Calculate lagged correlation between two variables
|
||||
Pearson-Korrelation mit Lag-Sweep (Issue 53, Data-Layer).
|
||||
|
||||
Args:
|
||||
var1: 'energy', 'protein', 'training_load'
|
||||
var2: 'weight', 'lbm', 'hrv', 'rhr'
|
||||
max_lag_days: Maximum lag to test
|
||||
C1: Tagesbilanz (kcal − TDEE wie ``estimate_tdee_kcal_from_latest_weight``) vs. ΔGewicht [t→t+L], L≥1.
|
||||
C2: Protein (g) vs. ΔMager [t→t+L] aus ``build_merged_daily_nutrition_body_rows``, L≥1.
|
||||
C3: Summe ``duration_min`` pro Tag vs. HRV oder Ruhepuls am Tag t+L (L≥0).
|
||||
|
||||
Returns:
|
||||
{
|
||||
'best_lag': X, # days
|
||||
'correlation': 0.XX, # -1 to 1
|
||||
'direction': 'positive'/'negative'/'none',
|
||||
'confidence': 'high'/'medium'/'low',
|
||||
'data_points': N
|
||||
}
|
||||
Rückgabe enthält u. a. ``best_lag`` / ``best_lag_days``, ``correlation``, ``interpretation``,
|
||||
optional ``lag_details`` (r, n je Lag), mindestens ``MIN_PAIRS_LAG_CORR`` Paare am besten Lag.
|
||||
"""
|
||||
if var1 == 'energy' and var2 == 'weight':
|
||||
return _correlate_energy_weight(profile_id, max_lag_days)
|
||||
elif var1 == 'protein' and var2 == 'lbm':
|
||||
return _correlate_protein_lbm(profile_id, max_lag_days)
|
||||
elif var1 == 'training_load' and var2 in ['hrv', 'rhr']:
|
||||
return _correlate_load_vitals(profile_id, var2, max_lag_days)
|
||||
v1 = (var1 or "").strip().lower()
|
||||
if v1 in ("energy", "energy_balance"):
|
||||
v1n = "energy"
|
||||
elif v1 in ("training_load", "load"):
|
||||
v1n = "training_load"
|
||||
elif v1 == "protein":
|
||||
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:
|
||||
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]:
|
||||
"""
|
||||
Correlate energy balance with weight change
|
||||
Test lags: 0, 3, 7, 10, 14 days
|
||||
Pearson: Tagesbilanz (kcal − TDEE wie nutrition_metrics) vs. Gewichtsdifferenz
|
||||
vom Tag t zu Tag t+L (L = 0 … max_lag). Bestes Lag nach maximalem |r|.
|
||||
"""
|
||||
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:
|
||||
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()
|
||||
|
||||
# Get energy balance data (daily calories - estimated TDEE)
|
||||
cur.execute("""
|
||||
SELECT n.date, n.kcal, w.weight
|
||||
FROM nutrition_log n
|
||||
LEFT JOIN weight_log w ON w.profile_id = n.profile_id
|
||||
AND w.date = n.date
|
||||
WHERE n.profile_id = %s
|
||||
AND n.date >= CURRENT_DATE - INTERVAL '90 days'
|
||||
ORDER BY n.date
|
||||
""", (profile_id,))
|
||||
kcal_by: Dict[str, float] = {}
|
||||
for r in kcal_rows:
|
||||
kcal_by[_iso_date_key(r["d"])] = float(r["kcal"] or 0)
|
||||
weight_by: Dict[str, float] = {}
|
||||
for r in w_rows:
|
||||
weight_by[_iso_date_key(r["d"])] = float(r["weight"])
|
||||
|
||||
data = cur.fetchall()
|
||||
balance_by = {d: kcal_by[d] - tdee_f for d in kcal_by}
|
||||
|
||||
if len(data) < 30:
|
||||
return {
|
||||
'best_lag': None,
|
||||
'correlation': None,
|
||||
'direction': 'none',
|
||||
'confidence': 'low',
|
||||
'data_points': len(data),
|
||||
'reason': 'Insufficient data (<30 days)'
|
||||
}
|
||||
best: Optional[Tuple[int, float, int]] = None
|
||||
lag_details: List[Dict[str, Any]] = []
|
||||
|
||||
# Calculate 7d rolling energy balance
|
||||
# (Simplified - actual implementation would need TDEE estimation)
|
||||
max_l = max(0, min(int(max_lag), 28))
|
||||
# Lag 0: ΔGewicht am selben Tag ist immer 0 → sinnvoll erst ab Tag 1
|
||||
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 {
|
||||
'best_lag': 7,
|
||||
'correlation': -0.45, # Placeholder
|
||||
'direction': 'negative', # Higher deficit = lower weight (expected)
|
||||
'confidence': 'medium',
|
||||
'data_points': len(data)
|
||||
"best_lag": lag_b,
|
||||
"correlation": round(r_b, 4),
|
||||
"direction": direction,
|
||||
"confidence": conf,
|
||||
"data_points": n_b,
|
||||
"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]:
|
||||
"""Correlate protein intake with LBM trend"""
|
||||
# TODO: Implement full correlation calculation
|
||||
"""
|
||||
Pearson: Protein (g/Tag) vs. Magermasse-Differenz (kg) vom Tag t zu t+L.
|
||||
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 {
|
||||
'best_lag': 0,
|
||||
'correlation': 0.32, # Placeholder
|
||||
'direction': 'positive',
|
||||
'confidence': 'medium',
|
||||
'data_points': 28
|
||||
"best_lag": lag_b,
|
||||
"correlation": round(r_b, 4),
|
||||
"direction": direction,
|
||||
"confidence": conf,
|
||||
"data_points": n_b,
|
||||
"interpretation": interp,
|
||||
"lag_details": lag_details,
|
||||
}
|
||||
|
||||
|
||||
def _correlate_load_vitals(profile_id: str, vital: str, max_lag: int) -> Optional[Dict]:
|
||||
"""
|
||||
Correlate training load with HRV or RHR
|
||||
Test lags: 1, 2, 3 days
|
||||
Pearson: Tages-Trainingslast (Summe duration_min) vs. Vitals (HRV ms oder Ruhepuls)
|
||||
am Kalendertag t+Lag (typisch: Belastung am Vortag, Vitalwert am Folgetag bei Lag ≥ 1).
|
||||
"""
|
||||
# TODO: Implement full correlation calculation
|
||||
if vital == 'hrv':
|
||||
col = "hrv" if vital == "hrv" else "resting_hr"
|
||||
cutoff = (datetime.now() - timedelta(days=LAG_CORR_LOOKBACK_DAYS)).strftime("%Y-%m-%d")
|
||||
|
||||
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 {
|
||||
'best_lag': 1,
|
||||
'correlation': -0.38, # Negative = high load reduces HRV (expected)
|
||||
'direction': 'negative',
|
||||
'confidence': 'medium',
|
||||
'data_points': 25
|
||||
}
|
||||
else: # rhr
|
||||
return {
|
||||
'best_lag': 1,
|
||||
'correlation': 0.42, # Positive = high load increases RHR (expected)
|
||||
'direction': 'positive',
|
||||
'confidence': 'medium',
|
||||
'data_points': 25
|
||||
"best_lag": None,
|
||||
"correlation": None,
|
||||
"direction": "none",
|
||||
"confidence": "insufficient",
|
||||
"data_points": 0,
|
||||
"interpretation": f"Zu wenige gepaarte Tage mit Training und {vlabel}.",
|
||||
"reason": "insufficient_pairs",
|
||||
"lag_details": lag_details,
|
||||
"vital": vital,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# C4: Sleep vs. Recovery Correlation
|
||||
|
|
|
|||
283
backend/data_layer/fitness_interpretation.py
Normal file
283
backend/data_layer/fitness_interpretation.py
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
"""
|
||||
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
|
||||
157
backend/data_layer/fitness_viz.py
Normal file
157
backend/data_layer/fitness_viz.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"""
|
||||
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",
|
||||
},
|
||||
}
|
||||
251
backend/data_layer/history_overview_viz.py
Normal file
251
backend/data_layer/history_overview_viz.py
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
"""
|
||||
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
|
||||
85
backend/data_layer/nutrition_body_merge.py
Normal file
85
backend/data_layer/nutrition_body_merge.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"""
|
||||
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
|
||||
404
backend/data_layer/nutrition_chart_payloads.py
Normal file
404
backend/data_layer/nutrition_chart_payloads.py
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
"""
|
||||
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,
|
||||
},
|
||||
}
|
||||
323
backend/data_layer/nutrition_interpretation.py
Normal file
323
backend/data_layer/nutrition_interpretation.py
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
"""
|
||||
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,15 +20,100 @@ Phase 0c: Multi-Layer Architecture
|
|||
Version: 1.0
|
||||
"""
|
||||
|
||||
import statistics
|
||||
from typing import Dict, List, Optional
|
||||
from datetime import datetime, timedelta, date
|
||||
from db import get_db, get_cursor, r2d
|
||||
from data_layer.utils import calculate_confidence, safe_float, safe_int
|
||||
|
||||
# Fallback TDEE (kcal/day) when demographics for Mifflin–St Jeor are incomplete.
|
||||
TDEE_KCAL_PER_KG_BODYWEIGHT = 32.5
|
||||
# PAL applied to MSJ BMR when height, sex, dob and weight are available (moderate activity).
|
||||
TDEE_PAL_MODERATE = 1.55
|
||||
|
||||
|
||||
def _age_years_from_dob(dob) -> Optional[int]:
|
||||
if dob is None:
|
||||
return None
|
||||
try:
|
||||
if isinstance(dob, str):
|
||||
birth = datetime.strptime(dob[:10], "%Y-%m-%d").date()
|
||||
else:
|
||||
birth = dob
|
||||
today = date.today()
|
||||
return today.year - birth.year - ((today.month, today.day) < (birth.month, birth.day))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _mifflin_st_jeor_bmr_kcal(
|
||||
weight_kg: float, height_cm: float, age_years: int, sex_is_male: bool
|
||||
) -> float:
|
||||
if sex_is_male:
|
||||
return 10.0 * weight_kg + 6.25 * height_cm - 5.0 * age_years + 5.0
|
||||
return 10.0 * weight_kg + 6.25 * height_cm - 5.0 * age_years - 161.0
|
||||
|
||||
|
||||
def estimate_tdee_kcal_from_latest_weight(profile_id: str) -> Optional[float]:
|
||||
"""
|
||||
Estimated TDEE (kcal/day).
|
||||
|
||||
Primary: Mifflin–St Jeor BMR × TDEE_PAL_MODERATE when latest weight plus
|
||||
profiles.height, profiles.sex, profiles.dob are usable.
|
||||
|
||||
Fallback: latest weight (kg) × TDEE_KCAL_PER_KG_BODYWEIGHT (legacy heuristic).
|
||||
|
||||
Returns None if no weight on record.
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""SELECT weight FROM weight_log
|
||||
WHERE profile_id=%s ORDER BY date DESC LIMIT 1""",
|
||||
(profile_id,),
|
||||
)
|
||||
wrow = cur.fetchone()
|
||||
if not wrow or wrow["weight"] is None:
|
||||
return None
|
||||
weight_kg = float(wrow["weight"])
|
||||
|
||||
cur.execute(
|
||||
"SELECT height, sex, dob FROM profiles WHERE id=%s",
|
||||
(profile_id,),
|
||||
)
|
||||
prow = cur.fetchone()
|
||||
|
||||
if prow and prow.get("height") and prow.get("sex") is not None and prow.get("dob"):
|
||||
height_cm = float(prow["height"])
|
||||
age = _age_years_from_dob(prow["dob"])
|
||||
if age is not None and 10 < age < 120 and height_cm > 50:
|
||||
sex_raw = str(prow["sex"]).strip().lower()
|
||||
sex_is_male = sex_raw in ("m", "male", "männlich", "mann")
|
||||
bmr = _mifflin_st_jeor_bmr_kcal(weight_kg, height_cm, age, sex_is_male)
|
||||
if bmr > 400:
|
||||
return bmr * TDEE_PAL_MODERATE
|
||||
|
||||
return weight_kg * TDEE_KCAL_PER_KG_BODYWEIGHT
|
||||
|
||||
|
||||
def _get_profile_goal_mode(profile_id: str) -> str:
|
||||
"""Strategic goal_mode from profiles (Phase 0a); defaults to health."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT goal_mode FROM profiles WHERE id=%s", (profile_id,))
|
||||
row = cur.fetchone()
|
||||
if row and row.get("goal_mode"):
|
||||
g = str(row["goal_mode"]).strip().lower()
|
||||
if g:
|
||||
return g
|
||||
return "health"
|
||||
|
||||
|
||||
def get_nutrition_average_data(
|
||||
profile_id: str,
|
||||
days: int = 30
|
||||
days: int = 30,
|
||||
*,
|
||||
all_history: bool = False,
|
||||
) -> Dict:
|
||||
"""
|
||||
Get average nutrition values for all macros.
|
||||
|
|
@ -54,22 +139,38 @@ def get_nutrition_average_data(
|
|||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
cutoff = None if all_history else (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
# 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(
|
||||
"""SELECT
|
||||
AVG(kcal) as kcal_avg,
|
||||
AVG(protein_g) as protein_avg,
|
||||
AVG(carbs_g) as carbs_avg,
|
||||
AVG(fat_g) as fat_avg,
|
||||
COUNT(*) as data_points
|
||||
FROM nutrition_log
|
||||
WHERE profile_id=%s AND date >= %s""",
|
||||
(profile_id, cutoff)
|
||||
f"""SELECT
|
||||
AVG(daily_kcal) AS kcal_avg,
|
||||
AVG(daily_protein) AS protein_avg,
|
||||
AVG(daily_carbs) AS carbs_avg,
|
||||
AVG(daily_fat) AS fat_avg,
|
||||
COUNT(*)::int AS day_count
|
||||
FROM (
|
||||
SELECT date,
|
||||
COALESCE(SUM(kcal), 0)::float AS daily_kcal,
|
||||
COALESCE(SUM(protein_g), 0)::float AS daily_protein,
|
||||
COALESCE(SUM(carbs_g), 0)::float AS daily_carbs,
|
||||
COALESCE(SUM(fat_g), 0)::float AS daily_fat
|
||||
FROM nutrition_log
|
||||
{inner_where}
|
||||
GROUP BY date
|
||||
) AS daily""",
|
||||
params,
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row or row['data_points'] == 0:
|
||||
if not row or row["day_count"] == 0:
|
||||
return {
|
||||
"kcal_avg": 0.0,
|
||||
"protein_avg": 0.0,
|
||||
|
|
@ -80,7 +181,7 @@ def get_nutrition_average_data(
|
|||
"days_analyzed": days
|
||||
}
|
||||
|
||||
data_points = row['data_points']
|
||||
data_points = row["day_count"]
|
||||
confidence = calculate_confidence(data_points, days, "general")
|
||||
|
||||
return {
|
||||
|
|
@ -190,79 +291,73 @@ def get_energy_balance_data(
|
|||
days: int = 7
|
||||
) -> Dict:
|
||||
"""
|
||||
Calculate energy balance (intake - estimated expenditure).
|
||||
Energy balance (intake - estimated expenditure), kcal/day.
|
||||
|
||||
Note: This is a simplified calculation.
|
||||
For accurate TDEE, use profile-based calculations.
|
||||
|
||||
Args:
|
||||
profile_id: User profile ID
|
||||
days: Analysis window (default 7)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"energy_balance": float, # kcal/day (negative = deficit)
|
||||
"avg_intake": float,
|
||||
"estimated_tdee": float,
|
||||
"status": str, # "deficit" | "surplus" | "maintenance"
|
||||
"confidence": str,
|
||||
"days_analyzed": int,
|
||||
"data_points": int
|
||||
}
|
||||
Intake: mean of daily total kcal (sum per calendar day).
|
||||
TDEE: estimate_tdee_kcal_from_latest_weight (MSJ × PAL oder kg-Fallback).
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
# Get average intake
|
||||
cur.execute(
|
||||
"""SELECT AVG(kcal) as avg_kcal, COUNT(*) as cnt
|
||||
"""SELECT date, SUM(kcal)::float AS daily_kcal
|
||||
FROM nutrition_log
|
||||
WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL""",
|
||||
(profile_id, cutoff)
|
||||
WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL
|
||||
GROUP BY date
|
||||
ORDER BY date""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row or row['cnt'] == 0:
|
||||
return {
|
||||
"energy_balance": 0.0,
|
||||
"avg_intake": 0.0,
|
||||
"estimated_tdee": 0.0,
|
||||
"status": "unknown",
|
||||
"confidence": "insufficient",
|
||||
"days_analyzed": days,
|
||||
"data_points": 0
|
||||
}
|
||||
|
||||
avg_intake = safe_float(row['avg_kcal'])
|
||||
data_points = row['cnt']
|
||||
|
||||
# Simple TDEE estimation (this should be improved with profile data)
|
||||
# For now, use a rough estimate: 2500 kcal for average adult
|
||||
estimated_tdee = 2500.0 # TODO: Calculate from profile (weight, height, age, activity)
|
||||
|
||||
energy_balance = avg_intake - estimated_tdee
|
||||
|
||||
# Determine status
|
||||
if energy_balance < -200:
|
||||
status = "deficit"
|
||||
elif energy_balance > 200:
|
||||
status = "surplus"
|
||||
else:
|
||||
status = "maintenance"
|
||||
|
||||
confidence = calculate_confidence(data_points, days, "general")
|
||||
daily_rows = cur.fetchall()
|
||||
|
||||
if not daily_rows:
|
||||
return {
|
||||
"energy_balance": energy_balance,
|
||||
"energy_balance": 0.0,
|
||||
"avg_intake": 0.0,
|
||||
"estimated_tdee": 0.0,
|
||||
"status": "unknown",
|
||||
"confidence": "insufficient",
|
||||
"days_analyzed": days,
|
||||
"data_points": 0,
|
||||
}
|
||||
|
||||
daily_totals = [safe_float(r["daily_kcal"]) for r in daily_rows]
|
||||
avg_intake = sum(daily_totals) / len(daily_totals)
|
||||
data_points = len(daily_totals)
|
||||
|
||||
estimated_tdee = estimate_tdee_kcal_from_latest_weight(profile_id)
|
||||
if estimated_tdee is None:
|
||||
return {
|
||||
"energy_balance": 0.0,
|
||||
"avg_intake": avg_intake,
|
||||
"estimated_tdee": estimated_tdee,
|
||||
"status": status,
|
||||
"confidence": confidence,
|
||||
"estimated_tdee": 0.0,
|
||||
"status": "unknown",
|
||||
"confidence": "insufficient",
|
||||
"days_analyzed": days,
|
||||
"data_points": data_points
|
||||
}
|
||||
|
||||
energy_balance = avg_intake - estimated_tdee
|
||||
|
||||
if energy_balance < -200:
|
||||
status = "deficit"
|
||||
elif energy_balance > 200:
|
||||
status = "surplus"
|
||||
else:
|
||||
status = "maintenance"
|
||||
|
||||
confidence = calculate_confidence(data_points, days, "general")
|
||||
|
||||
return {
|
||||
"energy_balance": energy_balance,
|
||||
"avg_intake": avg_intake,
|
||||
"estimated_tdee": estimated_tdee,
|
||||
"status": status,
|
||||
"confidence": confidence,
|
||||
"days_analyzed": days,
|
||||
"data_points": data_points
|
||||
}
|
||||
|
||||
|
||||
def get_protein_adequacy_data(
|
||||
profile_id: str,
|
||||
|
|
@ -291,7 +386,6 @@ def get_protein_adequacy_data(
|
|||
"confidence": str
|
||||
}
|
||||
"""
|
||||
# Get protein targets
|
||||
targets = get_protein_targets_data(profile_id)
|
||||
|
||||
with get_db() as conn:
|
||||
|
|
@ -299,60 +393,55 @@ def get_protein_adequacy_data(
|
|||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
cur.execute(
|
||||
"""SELECT
|
||||
AVG(protein_g) as avg_protein,
|
||||
COUNT(*) as cnt,
|
||||
SUM(CASE WHEN protein_g >= %s AND protein_g <= %s THEN 1 ELSE 0 END) as days_in_target
|
||||
"""SELECT COALESCE(SUM(protein_g), 0)::float AS daily_protein
|
||||
FROM nutrition_log
|
||||
WHERE profile_id=%s AND date >= %s AND protein_g IS NOT NULL""",
|
||||
(targets['protein_target_low'], targets['protein_target_high'], profile_id, cutoff)
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
GROUP BY date""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if not row or row['cnt'] == 0:
|
||||
return {
|
||||
"adequacy_score": 0,
|
||||
"avg_protein_g": 0.0,
|
||||
"target_protein_low": targets['protein_target_low'],
|
||||
"target_protein_high": targets['protein_target_high'],
|
||||
"protein_g_per_kg": 0.0,
|
||||
"days_in_target": 0,
|
||||
"days_with_data": 0,
|
||||
"confidence": "insufficient"
|
||||
}
|
||||
|
||||
avg_protein = safe_float(row['avg_protein'])
|
||||
days_with_data = row['cnt']
|
||||
days_in_target = row['days_in_target']
|
||||
|
||||
protein_g_per_kg = avg_protein / targets['current_weight'] if targets['current_weight'] > 0 else 0.0
|
||||
|
||||
# Calculate adequacy score
|
||||
# 100 = always in target range
|
||||
# Scale based on percentage of days in target + average relative to target
|
||||
target_pct = (days_in_target / days_with_data * 100) if days_with_data > 0 else 0
|
||||
|
||||
# Bonus/penalty for average protein level
|
||||
target_mid = (targets['protein_target_low'] + targets['protein_target_high']) / 2
|
||||
avg_vs_target = (avg_protein / target_mid) if target_mid > 0 else 0
|
||||
|
||||
# Weighted score: 70% target days, 30% average level
|
||||
adequacy_score = int(target_pct * 0.7 + min(avg_vs_target * 100, 100) * 0.3)
|
||||
adequacy_score = max(0, min(100, adequacy_score)) # Clamp to 0-100
|
||||
|
||||
confidence = calculate_confidence(days_with_data, days, "general")
|
||||
rows = cur.fetchall()
|
||||
|
||||
if not rows or targets.get("confidence") == "insufficient" or targets["current_weight"] <= 0:
|
||||
return {
|
||||
"adequacy_score": adequacy_score,
|
||||
"avg_protein_g": avg_protein,
|
||||
"adequacy_score": 0,
|
||||
"avg_protein_g": 0.0,
|
||||
"target_protein_low": targets['protein_target_low'],
|
||||
"target_protein_high": targets['protein_target_high'],
|
||||
"protein_g_per_kg": protein_g_per_kg,
|
||||
"days_in_target": days_in_target,
|
||||
"days_with_data": days_with_data,
|
||||
"confidence": confidence
|
||||
"protein_g_per_kg": 0.0,
|
||||
"days_in_target": 0,
|
||||
"days_with_data": 0,
|
||||
"confidence": "insufficient"
|
||||
}
|
||||
|
||||
daily_totals = [safe_float(r["daily_protein"]) for r in rows]
|
||||
days_with_data = len(daily_totals)
|
||||
low = targets["protein_target_low"]
|
||||
high = targets["protein_target_high"]
|
||||
days_in_target = sum(1 for d in daily_totals if low <= d <= high)
|
||||
|
||||
avg_protein = sum(daily_totals) / days_with_data
|
||||
protein_g_per_kg = avg_protein / targets["current_weight"] if targets["current_weight"] > 0 else 0.0
|
||||
|
||||
target_pct = (days_in_target / days_with_data * 100) if days_with_data > 0 else 0
|
||||
target_mid = (low + high) / 2
|
||||
avg_vs_target = (avg_protein / target_mid) if target_mid > 0 else 0
|
||||
|
||||
adequacy_score = int(target_pct * 0.7 + min(avg_vs_target * 100, 100) * 0.3)
|
||||
adequacy_score = max(0, min(100, adequacy_score))
|
||||
|
||||
confidence = calculate_confidence(days_with_data, days, "general")
|
||||
|
||||
return {
|
||||
"adequacy_score": adequacy_score,
|
||||
"avg_protein_g": avg_protein,
|
||||
"target_protein_low": targets['protein_target_low'],
|
||||
"target_protein_high": targets['protein_target_high'],
|
||||
"protein_g_per_kg": protein_g_per_kg,
|
||||
"days_in_target": days_in_target,
|
||||
"days_with_data": days_with_data,
|
||||
"confidence": confidence
|
||||
}
|
||||
|
||||
|
||||
def get_macro_consistency_data(
|
||||
profile_id: str,
|
||||
|
|
@ -387,16 +476,18 @@ def get_macro_consistency_data(
|
|||
|
||||
cur.execute(
|
||||
"""SELECT
|
||||
protein_g, carbs_g, fat_g, kcal
|
||||
COALESCE(SUM(kcal), 0)::float AS kcal,
|
||||
COALESCE(SUM(protein_g), 0)::float AS protein_g,
|
||||
COALESCE(SUM(carbs_g), 0)::float AS carbs_g,
|
||||
COALESCE(SUM(fat_g), 0)::float AS fat_g
|
||||
FROM nutrition_log
|
||||
WHERE profile_id=%s
|
||||
AND date >= %s
|
||||
AND protein_g IS NOT NULL
|
||||
AND carbs_g IS NOT NULL
|
||||
AND fat_g IS NOT NULL
|
||||
AND kcal > 0
|
||||
ORDER BY date""",
|
||||
(profile_id, cutoff)
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
GROUP BY date
|
||||
HAVING COALESCE(SUM(kcal), 0) > 0
|
||||
AND COALESCE(SUM(protein_g), 0) > 0
|
||||
AND COALESCE(SUM(carbs_g), 0) > 0
|
||||
AND COALESCE(SUM(fat_g), 0) > 0""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
|
|
@ -413,9 +504,6 @@ def get_macro_consistency_data(
|
|||
"data_points": len(rows)
|
||||
}
|
||||
|
||||
# Calculate macro percentages for each day
|
||||
import statistics
|
||||
|
||||
protein_pcts = []
|
||||
carbs_pcts = []
|
||||
fat_pcts = []
|
||||
|
|
@ -425,7 +513,6 @@ def get_macro_consistency_data(
|
|||
if total_kcal == 0:
|
||||
continue
|
||||
|
||||
# Convert grams to kcal (protein=4, carbs=4, fat=9)
|
||||
protein_kcal = safe_float(row['protein_g']) * 4
|
||||
carbs_kcal = safe_float(row['carbs_g']) * 4
|
||||
fat_kcal = safe_float(row['fat_g']) * 9
|
||||
|
|
@ -482,6 +569,200 @@ 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)
|
||||
# ============================================================================
|
||||
|
|
@ -491,50 +772,15 @@ def get_macro_consistency_data(
|
|||
|
||||
def calculate_energy_balance_7d(profile_id: str) -> Optional[float]:
|
||||
"""
|
||||
Calculate 7-day average energy balance (kcal/day)
|
||||
Positive = surplus, Negative = deficit
|
||||
|
||||
Migration from Phase 0b:
|
||||
Used by placeholders that need single balance value
|
||||
7-day mean energy balance (kcal/day), same rules as get_energy_balance_data(..., 7).
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("""
|
||||
SELECT kcal
|
||||
FROM nutrition_log
|
||||
WHERE profile_id = %s
|
||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
||||
ORDER BY date DESC
|
||||
""", (profile_id,))
|
||||
|
||||
calories = [row['kcal'] for row in cur.fetchall()]
|
||||
|
||||
if len(calories) < 4: # Need at least 4 days
|
||||
return None
|
||||
|
||||
avg_intake = float(sum(calories) / len(calories))
|
||||
|
||||
# Get estimated TDEE (simplified - could use Harris-Benedict)
|
||||
# For now, use weight-based estimate
|
||||
cur.execute("""
|
||||
SELECT weight
|
||||
FROM weight_log
|
||||
WHERE profile_id = %s
|
||||
ORDER BY date DESC
|
||||
LIMIT 1
|
||||
""", (profile_id,))
|
||||
|
||||
weight_row = cur.fetchone()
|
||||
if not weight_row:
|
||||
return None
|
||||
|
||||
# Simple TDEE estimate: bodyweight (kg) × 30-35
|
||||
# TODO: Improve with activity level, age, gender
|
||||
estimated_tdee = float(weight_row['weight']) * 32.5
|
||||
|
||||
balance = avg_intake - estimated_tdee
|
||||
|
||||
return round(balance, 0)
|
||||
data = get_energy_balance_data(profile_id, 7)
|
||||
if data["data_points"] < 4:
|
||||
return None
|
||||
tdee = data.get("estimated_tdee") or 0
|
||||
if tdee <= 0:
|
||||
return None
|
||||
return round(float(data["energy_balance"]), 0)
|
||||
|
||||
|
||||
def calculate_energy_deficit_surplus(profile_id: str, days: int = 7) -> Optional[str]:
|
||||
|
|
@ -654,15 +900,14 @@ def calculate_protein_days_in_target(profile_id: str, target_low: float = 1.6, t
|
|||
|
||||
def calculate_protein_adequacy_28d(profile_id: str) -> Optional[int]:
|
||||
"""
|
||||
Protein adequacy score 0-100 (last 28 days)
|
||||
Based on consistency and target achievement
|
||||
Protein adequacy score 0-100 (last 28 days).
|
||||
Uses per-calendar-day total protein vs. average weight in the window (g/kg per day).
|
||||
"""
|
||||
import statistics
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Get average weight (28d)
|
||||
cur.execute("""
|
||||
SELECT AVG(weight) as avg_weight
|
||||
FROM weight_log
|
||||
|
|
@ -676,38 +921,29 @@ def calculate_protein_adequacy_28d(profile_id: str) -> Optional[int]:
|
|||
|
||||
weight = float(weight_row['avg_weight'])
|
||||
|
||||
# Get protein intake (28d)
|
||||
cur.execute("""
|
||||
SELECT protein_g
|
||||
SELECT COALESCE(SUM(protein_g), 0)::float AS daily_protein
|
||||
FROM nutrition_log
|
||||
WHERE profile_id = %s
|
||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
||||
AND protein_g IS NOT NULL
|
||||
GROUP BY date
|
||||
""", (profile_id,))
|
||||
|
||||
protein_values = [float(row['protein_g']) for row in cur.fetchall()]
|
||||
daily_totals = [float(row['daily_protein']) for row in cur.fetchall()]
|
||||
|
||||
if len(protein_values) < 18: # 60% coverage
|
||||
if len(daily_totals) < 18:
|
||||
return None
|
||||
|
||||
# Calculate metrics
|
||||
protein_per_kg_values = [p / weight for p in protein_values]
|
||||
protein_per_kg_values = [p / weight for p in daily_totals]
|
||||
avg_protein_per_kg = sum(protein_per_kg_values) / len(protein_per_kg_values)
|
||||
|
||||
# Target range: 1.6-2.2 g/kg for active individuals
|
||||
target_mid = 1.9
|
||||
|
||||
# Score based on distance from target
|
||||
if 1.6 <= avg_protein_per_kg <= 2.2:
|
||||
base_score = 100
|
||||
elif avg_protein_per_kg < 1.6:
|
||||
# Below target
|
||||
base_score = max(40, 100 - ((1.6 - avg_protein_per_kg) * 40))
|
||||
else:
|
||||
# Above target (less penalty)
|
||||
base_score = max(80, 100 - ((avg_protein_per_kg - 2.2) * 10))
|
||||
|
||||
# Consistency bonus/penalty
|
||||
std_dev = statistics.stdev(protein_per_kg_values)
|
||||
if std_dev < 0.3:
|
||||
consistency_bonus = 10
|
||||
|
|
@ -723,20 +959,24 @@ def calculate_protein_adequacy_28d(profile_id: str) -> Optional[int]:
|
|||
|
||||
def calculate_macro_consistency_score(profile_id: str) -> Optional[int]:
|
||||
"""
|
||||
Macro consistency score 0-100 (last 28 days)
|
||||
Lower variability = higher score
|
||||
Macro consistency score 0-100 (last 28 days).
|
||||
CV of daily totals (kcal and macros), not raw log rows.
|
||||
"""
|
||||
import statistics
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("""
|
||||
SELECT kcal, protein_g, fat_g, carbs_g
|
||||
SELECT
|
||||
COALESCE(SUM(kcal), 0)::float AS dk,
|
||||
COALESCE(SUM(protein_g), 0)::float AS dp,
|
||||
COALESCE(SUM(fat_g), 0)::float AS df,
|
||||
COALESCE(SUM(carbs_g), 0)::float AS dc
|
||||
FROM nutrition_log
|
||||
WHERE profile_id = %s
|
||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
||||
AND kcal IS NOT NULL
|
||||
ORDER BY date DESC
|
||||
GROUP BY date
|
||||
HAVING COALESCE(SUM(kcal), 0) > 0
|
||||
""", (profile_id,))
|
||||
|
||||
data = cur.fetchall()
|
||||
|
|
@ -744,9 +984,7 @@ def calculate_macro_consistency_score(profile_id: str) -> Optional[int]:
|
|||
if len(data) < 18:
|
||||
return None
|
||||
|
||||
# Calculate coefficient of variation for each macro
|
||||
def cv(values):
|
||||
"""Coefficient of variation (std_dev / mean)"""
|
||||
if not values or len(values) < 2:
|
||||
return None
|
||||
mean = sum(values) / len(values)
|
||||
|
|
@ -755,10 +993,10 @@ def calculate_macro_consistency_score(profile_id: str) -> Optional[int]:
|
|||
std_dev = statistics.stdev(values)
|
||||
return std_dev / mean
|
||||
|
||||
calories_cv = cv([d['kcal'] for d in data])
|
||||
protein_cv = cv([d['protein_g'] for d in data if d['protein_g']])
|
||||
fat_cv = cv([d['fat_g'] for d in data if d['fat_g']])
|
||||
carbs_cv = cv([d['carbs_g'] for d in data if d['carbs_g']])
|
||||
calories_cv = cv([d['dk'] for d in data])
|
||||
protein_cv = cv([d['dp'] for d in data if d['dp']])
|
||||
fat_cv = cv([d['df'] for d in data if d['df']])
|
||||
carbs_cv = cv([d['dc'] for d in data if d['dc']])
|
||||
|
||||
cv_values = [v for v in [calories_cv, protein_cv, fat_cv, carbs_cv] if v is not None]
|
||||
|
||||
|
|
@ -767,9 +1005,6 @@ def calculate_macro_consistency_score(profile_id: str) -> Optional[int]:
|
|||
|
||||
avg_cv = sum(cv_values) / len(cv_values)
|
||||
|
||||
# Score: lower CV = higher score
|
||||
# CV < 0.2 = excellent consistency
|
||||
# CV > 0.5 = poor consistency
|
||||
if avg_cv < 0.2:
|
||||
score = 100
|
||||
elif avg_cv < 0.3:
|
||||
|
|
@ -811,14 +1046,16 @@ def calculate_nutrition_score(profile_id: str, focus_weights: Optional[Dict] = N
|
|||
from data_layer.scores import get_user_focus_weights
|
||||
focus_weights = get_user_focus_weights(profile_id)
|
||||
|
||||
# Nutrition-related focus areas (English keys from DB)
|
||||
protein_intake = focus_weights.get('protein_intake', 0)
|
||||
calorie_balance = focus_weights.get('calorie_balance', 0)
|
||||
macro_consistency = focus_weights.get('macro_consistency', 0)
|
||||
meal_timing = focus_weights.get('meal_timing', 0)
|
||||
hydration = focus_weights.get('hydration', 0)
|
||||
# Nutrition-related focus areas (English keys from DB; Gewichte immer float)
|
||||
protein_intake = float(focus_weights.get('protein_intake', 0) or 0)
|
||||
calorie_balance = float(focus_weights.get('calorie_balance', 0) or 0)
|
||||
macro_consistency = float(focus_weights.get('macro_consistency', 0) or 0)
|
||||
meal_timing = float(focus_weights.get('meal_timing', 0) or 0)
|
||||
hydration = float(focus_weights.get('hydration', 0) or 0)
|
||||
|
||||
total_nutrition_weight = protein_intake + calorie_balance + macro_consistency + meal_timing + hydration
|
||||
total_nutrition_weight = (
|
||||
protein_intake + calorie_balance + macro_consistency + meal_timing + hydration
|
||||
)
|
||||
|
||||
if total_nutrition_weight == 0:
|
||||
return None # No nutrition goals
|
||||
|
|
@ -853,40 +1090,66 @@ def calculate_nutrition_score(profile_id: str, focus_weights: Optional[Dict] = N
|
|||
if not components:
|
||||
return None
|
||||
|
||||
# Weighted average
|
||||
total_score = sum(score * weight for _, score, weight in components)
|
||||
total_weight = sum(weight for _, _, weight in components)
|
||||
# Weighted average (float: DB-Werte können Decimal sein)
|
||||
total_score = sum(float(score) * float(weight) for _, score, weight in components)
|
||||
total_weight = sum(float(weight) for _, _, weight in components)
|
||||
|
||||
return int(total_score / total_weight)
|
||||
|
||||
|
||||
def _score_calorie_adherence(profile_id: str) -> Optional[int]:
|
||||
"""Score calorie target adherence (0-100)"""
|
||||
# Check for energy balance goal
|
||||
# For now, use energy balance calculation
|
||||
"""Score calorie target adherence (0–100) using 7d balance vs profiles.goal_mode."""
|
||||
balance = calculate_energy_balance_7d(profile_id)
|
||||
|
||||
if balance is None:
|
||||
return None
|
||||
|
||||
# Score based on whether deficit/surplus aligns with goal
|
||||
# Simplified: assume weight loss goal = deficit is good
|
||||
# TODO: Check actual goal type
|
||||
mode = _get_profile_goal_mode(profile_id)
|
||||
b = float(balance)
|
||||
|
||||
abs_balance = abs(balance)
|
||||
def _weight_loss(x: float) -> int:
|
||||
if -550 <= x <= -250:
|
||||
return 100
|
||||
if x > 450:
|
||||
return 38
|
||||
if -750 <= x < -550 or -250 < x <= 120:
|
||||
return 82
|
||||
if x < -1200:
|
||||
return 52
|
||||
if -950 <= x < -750 or 120 < x <= 350:
|
||||
return 68
|
||||
return 58
|
||||
|
||||
# Moderate deficit/surplus = good
|
||||
if 200 <= abs_balance <= 500:
|
||||
return 100
|
||||
elif 100 <= abs_balance <= 700:
|
||||
return 85
|
||||
elif abs_balance <= 900:
|
||||
return 70
|
||||
elif abs_balance <= 1200:
|
||||
return 55
|
||||
else:
|
||||
def _surplus_friendly(x: float) -> int:
|
||||
if 80 <= x <= 480:
|
||||
return 100
|
||||
if -120 <= x < 80 or 480 < x <= 700:
|
||||
return 86
|
||||
if -380 <= x < -120:
|
||||
return 68
|
||||
if x > 850:
|
||||
return 54
|
||||
if x < -650:
|
||||
return 44
|
||||
return 72
|
||||
|
||||
def _maintenance(x: float) -> int:
|
||||
a = abs(x)
|
||||
if a <= 200:
|
||||
return 100
|
||||
if a <= 400:
|
||||
return 84
|
||||
if a <= 650:
|
||||
return 70
|
||||
if a <= 900:
|
||||
return 55
|
||||
return 40
|
||||
|
||||
if mode == "weight_loss":
|
||||
return _weight_loss(b)
|
||||
if mode in ("strength", "recomposition"):
|
||||
return _surplus_friendly(b)
|
||||
return _maintenance(b)
|
||||
|
||||
|
||||
def _score_macro_balance(profile_id: str) -> Optional[int]:
|
||||
"""Score macro balance (0-100)"""
|
||||
|
|
|
|||
393
backend/data_layer/nutrition_viz.py
Normal file
393
backend/data_layer/nutrition_viz.py
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
"""
|
||||
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",
|
||||
},
|
||||
}
|
||||
152
backend/data_layer/prompt_output_compact.py
Normal file
152
backend/data_layer/prompt_output_compact.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"""
|
||||
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
|
||||
573
backend/data_layer/recovery_chart_payloads.py
Normal file
573
backend/data_layer/recovery_chart_payloads.py
Normal file
|
|
@ -0,0 +1,573 @@
|
|||
"""
|
||||
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.",
|
||||
}
|
||||
),
|
||||
}
|
||||
218
backend/data_layer/recovery_interpretation.py
Normal file
218
backend/data_layer/recovery_interpretation.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
"""
|
||||
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
|
||||
|
|
@ -15,11 +15,54 @@ Phase 0c: Multi-Layer Architecture
|
|||
Version: 1.0
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
import json
|
||||
from typing import Dict, List, Optional, Any
|
||||
from datetime import datetime, timedelta, date
|
||||
from db import get_db, get_cursor, r2d
|
||||
from db import get_db, get_cursor
|
||||
from data_layer.utils import calculate_confidence, safe_float, safe_int
|
||||
|
||||
# ── 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]]:
|
||||
"""JSONB kann dict/list/str sein; ungültig → None."""
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
if not isinstance(raw, list):
|
||||
return None
|
||||
return raw
|
||||
|
||||
|
||||
def _segment_minutes(seg: Any) -> int:
|
||||
if not isinstance(seg, dict):
|
||||
return 0
|
||||
for key in ("duration_min", "duration_minutes", "minutes"):
|
||||
v = seg.get(key)
|
||||
if v is not None:
|
||||
return max(0, safe_int(v))
|
||||
return 0
|
||||
|
||||
|
||||
def _normalize_sleep_phase(seg: dict) -> str:
|
||||
"""Kleinbuchstaben; Apple „Core“-Schlaf wird wie light gewertet."""
|
||||
if not isinstance(seg, dict):
|
||||
return ""
|
||||
p = seg.get("phase")
|
||||
if p is None:
|
||||
return ""
|
||||
s = str(p).strip().lower()
|
||||
if s in ("core", "asleep"):
|
||||
return "light"
|
||||
return s
|
||||
|
||||
|
||||
def get_sleep_duration_data(
|
||||
profile_id: str,
|
||||
|
|
@ -51,7 +94,7 @@ def get_sleep_duration_data(
|
|||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
cur.execute(
|
||||
"""SELECT sleep_segments FROM sleep_log
|
||||
"""SELECT sleep_segments, duration_minutes FROM sleep_log
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
ORDER BY date DESC""",
|
||||
(profile_id, cutoff)
|
||||
|
|
@ -72,12 +115,17 @@ def get_sleep_duration_data(
|
|||
nights_with_data = 0
|
||||
|
||||
for row in rows:
|
||||
segments = row['sleep_segments']
|
||||
night_minutes = 0
|
||||
segments = _parse_sleep_segments(row.get("sleep_segments"))
|
||||
if segments:
|
||||
night_minutes = sum(seg.get('duration_min', 0) for seg in segments)
|
||||
if night_minutes > 0:
|
||||
total_minutes += night_minutes
|
||||
nights_with_data += 1
|
||||
night_minutes = sum(_segment_minutes(seg) for seg in segments)
|
||||
if night_minutes <= 0:
|
||||
dm = row.get("duration_minutes")
|
||||
if dm is not None:
|
||||
night_minutes = max(0, safe_int(dm))
|
||||
if night_minutes > 0:
|
||||
total_minutes += night_minutes
|
||||
nights_with_data += 1
|
||||
|
||||
if nights_with_data == 0:
|
||||
return {
|
||||
|
|
@ -136,7 +184,9 @@ def get_sleep_quality_data(
|
|||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
cur.execute(
|
||||
"""SELECT sleep_segments FROM sleep_log
|
||||
"""SELECT sleep_segments, duration_minutes, deep_minutes, rem_minutes,
|
||||
light_minutes, awake_minutes
|
||||
FROM sleep_log
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
ORDER BY date DESC""",
|
||||
(profile_id, cutoff)
|
||||
|
|
@ -163,15 +213,29 @@ def get_sleep_quality_data(
|
|||
count = 0
|
||||
|
||||
for row in rows:
|
||||
segments = row['sleep_segments']
|
||||
if segments:
|
||||
# Note: segments use 'phase' key, stored lowercase (deep, rem, light, awake)
|
||||
deep_rem_min = sum(s.get('duration_min', 0) for s in segments if s.get('phase') in ['deep', 'rem'])
|
||||
light_min = sum(s.get('duration_min', 0) for s in segments if s.get('phase') == 'light')
|
||||
awake_min = sum(s.get('duration_min', 0) for s in segments if s.get('phase') == 'awake')
|
||||
total_min = sum(s.get('duration_min', 0) for s in segments)
|
||||
deep_rem_min = light_min = awake_min = 0
|
||||
total_min = 0
|
||||
used_segments = False
|
||||
|
||||
segments = _parse_sleep_segments(row.get("sleep_segments"))
|
||||
if segments:
|
||||
total_min = sum(_segment_minutes(s) for s in segments)
|
||||
if total_min > 0:
|
||||
deep_rem_min = sum(
|
||||
_segment_minutes(s)
|
||||
for s in segments
|
||||
if _normalize_sleep_phase(s) in ("deep", "rem")
|
||||
)
|
||||
light_min = sum(
|
||||
_segment_minutes(s)
|
||||
for s in segments
|
||||
if _normalize_sleep_phase(s) == "light"
|
||||
)
|
||||
awake_min = sum(
|
||||
_segment_minutes(s)
|
||||
for s in segments
|
||||
if _normalize_sleep_phase(s) == "awake"
|
||||
)
|
||||
quality_pct = (deep_rem_min / total_min) * 100
|
||||
total_quality += quality_pct
|
||||
total_deep_rem += deep_rem_min
|
||||
|
|
@ -179,6 +243,28 @@ def get_sleep_quality_data(
|
|||
total_awake += awake_min
|
||||
total_all += total_min
|
||||
count += 1
|
||||
used_segments = True
|
||||
|
||||
if not used_segments:
|
||||
d, r, l, a = (
|
||||
row.get("deep_minutes"),
|
||||
row.get("rem_minutes"),
|
||||
row.get("light_minutes"),
|
||||
row.get("awake_minutes"),
|
||||
)
|
||||
if d is not None or r is not None or l is not None:
|
||||
di, ri, li = (d or 0), (r or 0), (l or 0)
|
||||
phase_sum = di + ri + li
|
||||
ai = (a or 0) if a is not None else 0
|
||||
total_min = phase_sum + ai
|
||||
if total_min > 0 and phase_sum > 0:
|
||||
quality_pct = ((di + ri) / total_min) * 100
|
||||
total_quality += quality_pct
|
||||
total_deep_rem += di + ri
|
||||
total_light += li
|
||||
total_awake += ai
|
||||
total_all += total_min
|
||||
count += 1
|
||||
|
||||
if count == 0:
|
||||
return {
|
||||
|
|
@ -351,8 +437,8 @@ def calculate_recovery_score_v2(profile_id: str) -> Optional[int]:
|
|||
return None
|
||||
|
||||
# Weighted average
|
||||
total_score = sum(score * weight for _, score, weight in components)
|
||||
total_weight = sum(weight for _, _, weight in components)
|
||||
total_score = sum(float(score) * float(weight) for _, score, weight in components)
|
||||
total_weight = sum(float(weight) for _, _, weight in components)
|
||||
|
||||
final_score = int(total_score / total_weight)
|
||||
|
||||
|
|
@ -663,34 +749,70 @@ def calculate_sleep_avg_duration_7d(profile_id: str) -> Optional[float]:
|
|||
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]:
|
||||
"""
|
||||
Calculate accumulated sleep debt (hours) last 14 days
|
||||
Assumes 7.5h target per night
|
||||
Aufsummierte Schlafschuld (h) der letzten 14 Kalendertage bis heute —
|
||||
Ziel pro Nacht: SLEEP_DEBT_TARGET_HOURS_DEFAULT (aktuell nicht profilkonfigurierbar).
|
||||
"""
|
||||
target_hours = 7.5
|
||||
|
||||
today = datetime.now().date()
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("""
|
||||
SELECT duration_minutes
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT date, duration_minutes
|
||||
FROM sleep_log
|
||||
WHERE profile_id = %s
|
||||
AND date >= CURRENT_DATE - INTERVAL '14 days'
|
||||
AND date >= %s::date - INTERVAL '14 days'
|
||||
AND date <= %s::date
|
||||
AND duration_minutes IS NOT NULL
|
||||
ORDER BY date DESC
|
||||
""", (profile_id,))
|
||||
""",
|
||||
(profile_id, today, today),
|
||||
)
|
||||
rows = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
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)
|
||||
return sleep_debt_sum_hours_in_window(rows, today)
|
||||
|
||||
|
||||
def calculate_sleep_regularity_proxy(profile_id: str) -> Optional[float]:
|
||||
|
|
@ -783,17 +905,24 @@ def calculate_sleep_quality_7d(profile_id: str) -> Optional[int]:
|
|||
|
||||
quality_scores = []
|
||||
for s in sleep_data:
|
||||
if s['deep_minutes'] and s['rem_minutes']:
|
||||
quality_pct = ((s['deep_minutes'] + s['rem_minutes']) / s['duration_minutes']) * 100
|
||||
# 40-60% deep+REM is good
|
||||
if quality_pct >= 45:
|
||||
quality_scores.append(100)
|
||||
elif quality_pct >= 35:
|
||||
quality_scores.append(75)
|
||||
elif quality_pct >= 25:
|
||||
quality_scores.append(50)
|
||||
else:
|
||||
quality_scores.append(30)
|
||||
dur = s["duration_minutes"]
|
||||
if not dur or dur <= 0:
|
||||
continue
|
||||
d = s["deep_minutes"]
|
||||
r = s["rem_minutes"]
|
||||
if d is None and r is None:
|
||||
continue
|
||||
di, ri = (d or 0), (r or 0)
|
||||
quality_pct = ((di + ri) / dur) * 100
|
||||
# 40-60% deep+REM is good
|
||||
if quality_pct >= 45:
|
||||
quality_scores.append(100)
|
||||
elif quality_pct >= 35:
|
||||
quality_scores.append(75)
|
||||
elif quality_pct >= 25:
|
||||
quality_scores.append(50)
|
||||
else:
|
||||
quality_scores.append(30)
|
||||
|
||||
if not quality_scores:
|
||||
return None
|
||||
|
|
|
|||
120
backend/data_layer/recovery_viz.py
Normal file
120
backend/data_layer/recovery_viz.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
"""
|
||||
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,11 +9,34 @@ Dates are normalized to ISO strings; Decimals to float — suitable for JSON/API
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Any, Optional
|
||||
|
||||
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]:
|
||||
"""Normalize DB row dict for JSON (dates → ISO, Decimal → float)."""
|
||||
|
|
@ -177,3 +200,173 @@ def get_profile_reference_values_summary(profile_id: str) -> dict[str, Any]:
|
|||
|
||||
tiles = build_summary_tiles_from_ranked_rows(raw_rows)
|
||||
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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -202,29 +202,30 @@ def calculate_goal_progress_score(profile_id: str) -> Optional[int]:
|
|||
total_weight = 0.0
|
||||
|
||||
for focus_area_id, weight in focus_weights.items():
|
||||
w = float(weight)
|
||||
component = focus_to_component.get(focus_area_id)
|
||||
|
||||
if component == 'body' and body_score is not None:
|
||||
total_score += body_score * weight
|
||||
total_weight += weight
|
||||
total_score += float(body_score) * w
|
||||
total_weight += w
|
||||
elif component == 'nutrition' and nutrition_score is not None:
|
||||
total_score += nutrition_score * weight
|
||||
total_weight += weight
|
||||
total_score += float(nutrition_score) * w
|
||||
total_weight += w
|
||||
elif component == 'activity' and activity_score is not None:
|
||||
total_score += activity_score * weight
|
||||
total_weight += weight
|
||||
total_score += float(activity_score) * w
|
||||
total_weight += w
|
||||
elif component == 'recovery' and recovery_score is not None:
|
||||
total_score += recovery_score * weight
|
||||
total_weight += weight
|
||||
total_score += float(recovery_score) * w
|
||||
total_weight += w
|
||||
elif component == 'health' and health_risk_score is not None:
|
||||
total_score += health_risk_score * weight
|
||||
total_weight += weight
|
||||
total_score += float(health_risk_score) * w
|
||||
total_weight += w
|
||||
|
||||
if total_weight == 0:
|
||||
return None
|
||||
|
||||
# Normalize to 0-100
|
||||
final_score = total_score / total_weight
|
||||
# Normalize to 0-100 (Explizit float: Zwischensummen können Decimal aus DB sein)
|
||||
final_score = float(total_score) / float(total_weight)
|
||||
|
||||
return int(final_score)
|
||||
|
||||
|
|
@ -282,9 +283,9 @@ def calculate_health_stability_score(profile_id: str) -> Optional[int]:
|
|||
|
||||
activities = cur.fetchall()
|
||||
if activities:
|
||||
total_minutes = sum(a['duration_min'] for a in activities)
|
||||
total_minutes = float(sum(float(a['duration_min'] or 0) for a in activities))
|
||||
# WHO recommends 150-300 min/week moderate activity
|
||||
movement_score = min(100, (total_minutes / 150) * 100)
|
||||
movement_score = min(100.0, (total_minutes / 150) * 100)
|
||||
components.append(('movement', movement_score, 20))
|
||||
|
||||
# 4. Waist circumference risk (15%)
|
||||
|
|
@ -328,8 +329,8 @@ def calculate_health_stability_score(profile_id: str) -> Optional[int]:
|
|||
return None
|
||||
|
||||
# Weighted average
|
||||
total_score = sum(score * weight for _, score, weight in components)
|
||||
total_weight = sum(weight for _, _, weight in components)
|
||||
total_score = sum(float(score) * float(weight) for _, score, weight in components)
|
||||
total_weight = sum(float(weight) for _, _, weight in components)
|
||||
|
||||
return int(total_score / total_weight)
|
||||
|
||||
|
|
@ -532,9 +533,19 @@ def calculate_focus_area_progress(profile_id: str, focus_area_id: str) -> Option
|
|||
if not goals:
|
||||
return None
|
||||
|
||||
# Weighted average by contribution_weight
|
||||
total_progress = sum(g['progress_pct'] * g['contribution_weight'] for g in goals)
|
||||
total_weight = sum(g['contribution_weight'] for g in goals)
|
||||
# Weighted average; progress_pct darf NULL sein (Ziele ohne quantitative Berechnung)
|
||||
parts: List[tuple] = []
|
||||
for g in goals:
|
||||
pct = g['progress_pct']
|
||||
if pct is None:
|
||||
continue
|
||||
parts.append((float(pct), float(g['contribution_weight'])))
|
||||
|
||||
if not parts:
|
||||
return None
|
||||
|
||||
total_progress = sum(p * w for p, w in parts)
|
||||
total_weight = sum(w for _, w in parts)
|
||||
|
||||
return int(total_progress / total_weight) if total_weight > 0 else None
|
||||
|
||||
|
|
|
|||
156
backend/data_layer/vital_signs_assessment.py
Normal file
156
backend/data_layer/vital_signs_assessment.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
"""
|
||||
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
|
||||
400
backend/data_layer/vitals_fitness_insights.py
Normal file
400
backend/data_layer/vitals_fitness_insights.py
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
"""
|
||||
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,7 +29,9 @@ def init_pool():
|
|||
user=os.getenv("DB_USER", "mitai"),
|
||||
password=os.getenv("DB_PASSWORD", "")
|
||||
)
|
||||
print(f"✓ PostgreSQL connection pool initialized ({os.getenv('DB_HOST', 'postgres')}:{os.getenv('DB_PORT', '5432')})")
|
||||
print(
|
||||
f"[OK] PostgreSQL connection pool initialized ({os.getenv('DB_HOST', 'postgres')}:{os.getenv('DB_PORT', '5432')})"
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
|
@ -171,7 +173,7 @@ def init_db():
|
|||
) as table_exists
|
||||
""")
|
||||
if not cur.fetchone()['table_exists']:
|
||||
print("⚠️ ai_prompts table doesn't exist yet - skipping pipeline prompt creation")
|
||||
print("[WARN] ai_prompts table doesn't exist yet - skipping pipeline prompt creation")
|
||||
return
|
||||
|
||||
# Ensure "pipeline" master prompt exists
|
||||
|
|
@ -189,7 +191,7 @@ def init_db():
|
|||
)
|
||||
""")
|
||||
conn.commit()
|
||||
print("✓ Pipeline master prompt created")
|
||||
print("[OK] Pipeline master prompt created")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Could not create pipeline prompt: {e}")
|
||||
print(f"[WARN] Could not create pipeline prompt: {e}")
|
||||
# Don't fail startup - prompt can be created manually
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ from slowapi.errors import RateLimitExceeded
|
|||
|
||||
from db import init_db
|
||||
|
||||
# Placeholder registry: load all register_placeholder() side-effects before any request
|
||||
# so get_placeholder_catalog() and exports see consistent metadata (see Phase A plan).
|
||||
import placeholder_registrations # noqa: F401
|
||||
|
||||
# Import routers
|
||||
from routers import auth, profiles, weight, circumference, caliper
|
||||
from routers import activity, nutrition, photos, insights, prompts
|
||||
|
|
@ -30,8 +34,10 @@ from routers import workflow_questions # Phase 1 Workflow Engine - Question Cat
|
|||
from routers import workflows # Phase 2 Workflow Engine - Execution
|
||||
from routers import reference_values # Persönliche Referenzwerte (Profil)
|
||||
from routers import admin_reference_value_types # Admin: Referenzwert-Typen
|
||||
from routers import app_dashboard # Geschützter App-Bereich: Dashboard-Lab Layout
|
||||
from routers import app_dashboard # Geschützter App-Bereich: Dashboard-Layout + Widget-Katalog
|
||||
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 admin_training_parameters, admin_activity_attribute_profiles # EAV session metrics
|
||||
|
||||
# ── App Configuration ─────────────────────────────────────────────────────────
|
||||
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
||||
|
|
@ -62,7 +68,7 @@ async def startup_event():
|
|||
try:
|
||||
init_db()
|
||||
except Exception as e:
|
||||
print(f"⚠️ init_db() failed (non-fatal): {e}")
|
||||
print(f"[WARN] init_db() failed (non-fatal): {e}")
|
||||
# Don't crash on startup - can be created manually
|
||||
|
||||
# Apply v9c migration if needed
|
||||
|
|
@ -70,7 +76,7 @@ async def startup_event():
|
|||
from apply_v9c_migration import apply_migration
|
||||
apply_migration()
|
||||
except Exception as e:
|
||||
print(f"⚠️ v9c migration failed (non-fatal): {e}")
|
||||
print(f"[WARN] v9c migration failed (non-fatal): {e}")
|
||||
|
||||
# ── Register Routers ──────────────────────────────────────────────────────────
|
||||
app.include_router(auth.router) # /api/auth/*
|
||||
|
|
@ -122,8 +128,11 @@ 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(admin_reference_value_types.router) # /api/admin/reference-value-types
|
||||
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(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 ──────────────────────────────────────────────────────────────
|
||||
@app.get("/")
|
||||
|
|
|
|||
80
backend/migrations/054_activity_session_metrics_eav.sql
Normal file
80
backend/migrations/054_activity_session_metrics_eav.sql
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
-- 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 $$;
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
-- 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 $$;
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
-- 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 $$;
|
||||
115
backend/migrations/057_activity_eav_primary_canon.sql
Normal file
115
backend/migrations/057_activity_eav_primary_canon.sql
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
-- 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 $$;
|
||||
4
backend/migrations/058_photos_taken_at.sql
Normal file
4
backend/migrations/058_photos_taken_at.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
-- 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';
|
||||
5
backend/migrations/059_circumference_c_arm_relaxed.sql
Normal file
5
backend/migrations/059_circumference_c_arm_relaxed.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
-- 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';
|
||||
11
backend/migrations/060_report_profiles.sql
Normal file
11
backend/migrations/060_report_profiles.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
-- 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';
|
||||
24
backend/migrations/061_report_definitions_multi.sql
Normal file
24
backend/migrations/061_report_definitions_multi.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
-- 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,8 +3,9 @@ Pydantic Models for Mitai Jinkendo API
|
|||
|
||||
Data validation schemas for request/response bodies.
|
||||
"""
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ── Profile Models ────────────────────────────────────────────────────────────
|
||||
|
|
@ -49,6 +50,7 @@ class CircumferenceEntry(BaseModel):
|
|||
c_thigh: Optional[float] = None
|
||||
c_calf: Optional[float] = None
|
||||
c_arm: Optional[float] = None
|
||||
c_arm_relaxed: Optional[float] = None
|
||||
notes: Optional[str] = None
|
||||
photo_id: Optional[str] = None
|
||||
|
||||
|
|
@ -82,8 +84,17 @@ class ActivityEntry(BaseModel):
|
|||
kcal_resting: Optional[float] = None
|
||||
hr_avg: 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
|
||||
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'
|
||||
notes: Optional[str] = None
|
||||
training_type_id: Optional[int] = None # v9d: Training type categorization
|
||||
|
|
@ -91,6 +102,17 @@ class ActivityEntry(BaseModel):
|
|||
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):
|
||||
date: str
|
||||
kcal: Optional[float] = None
|
||||
|
|
|
|||
103
backend/photo_exif.py
Normal file
103
backend/photo_exif.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""
|
||||
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)
|
||||
|
|
@ -1,11 +1,10 @@
|
|||
"""
|
||||
Complete Placeholder Metadata Definitions
|
||||
Complete Placeholder Metadata Definitions (Legacy / Normativ v1)
|
||||
|
||||
This module contains manually curated, complete metadata for all 116 placeholders.
|
||||
It combines automatic extraction with manual annotation to ensure 100% normative compliance.
|
||||
|
||||
IMPORTANT: This is the authoritative source for placeholder metadata.
|
||||
All new placeholders MUST be added here with complete metadata.
|
||||
Hinweis (2026-04): **Verbindliche Metadaten-Pflege** erfolgt über
|
||||
`backend/placeholder_registrations/` + `placeholder_registry.py` (114 Keys, deckungsgleich
|
||||
mit `PLACEHOLDER_MAP`). Dieses Modul bleibt für ältere Generator-/Export-Pfade und
|
||||
Tests; neue Platzhalter hier nicht mehr duplizieren.
|
||||
"""
|
||||
from placeholder_metadata import (
|
||||
PlaceholderMetadata,
|
||||
|
|
@ -28,7 +27,7 @@ from typing import List
|
|||
|
||||
def get_all_placeholder_metadata() -> List[PlaceholderMetadata]:
|
||||
"""
|
||||
Returns complete metadata for all 116 placeholders.
|
||||
Returns complete metadata for all 114 placeholders (Registry ist maßgeblich).
|
||||
|
||||
This is the authoritative, manually curated source.
|
||||
"""
|
||||
|
|
@ -476,7 +475,7 @@ def get_all_placeholder_metadata() -> List[PlaceholderMetadata]:
|
|||
notes=["Quadrant-Logik basiert auf FM/LBM Delta-Vorzeichen"],
|
||||
),
|
||||
|
||||
# NOTE: Continuing with all 116 placeholders would make this file very long.
|
||||
# NOTE: Continuing with all 114 placeholders would make this file very long.
|
||||
# For brevity, I'll create a separate generator that fills all remaining placeholders.
|
||||
# The pattern is established above - each placeholder gets full metadata.
|
||||
]
|
||||
|
|
|
|||
|
|
@ -29,14 +29,22 @@ def extract_value_raw(value_display: str, output_type: OutputType, placeholder_t
|
|||
|
||||
Returns: (raw_value, success)
|
||||
"""
|
||||
if not value_display or value_display in ['nicht verfügbar', 'nicht genug Daten']:
|
||||
s = (value_display or "").strip()
|
||||
if (
|
||||
not s
|
||||
or s in ['nicht verfügbar', 'nicht genug Daten']
|
||||
or s.startswith('nicht verfügbar —')
|
||||
):
|
||||
# V2 strict mode: missing/unavailable value is not a successful extraction
|
||||
return None, False
|
||||
|
||||
# JSON output type
|
||||
if output_type == OutputType.JSON:
|
||||
try:
|
||||
return json.loads(value_display), True
|
||||
parsed = json.loads(value_display)
|
||||
if isinstance(parsed, dict) and parsed.get('_available') is False:
|
||||
return None, False
|
||||
return parsed, True
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
# Try to find JSON in string
|
||||
json_match = re.search(r'(\{.*\}|\[.*\])', value_display, re.DOTALL)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,33 @@ Auto-imports all placeholder registrations to populate the global registry.
|
|||
from . import nutrition_part_a
|
||||
from . import nutrition_part_b
|
||||
from . import nutrition_part_c
|
||||
from . import nutrition_score
|
||||
from . import body_metrics
|
||||
from . import body_extras
|
||||
from . import activity_metrics
|
||||
from . import activity_session_insights
|
||||
from . import schlaf_erholung
|
||||
from . import vitalwerte
|
||||
from . import profil_zeitraum
|
||||
from . import phase_0b_meta_scores
|
||||
from . import phase_0b_ziele_fokus
|
||||
from . import korrelationen
|
||||
from . import profile_reference_values
|
||||
|
||||
__all__ = ['nutrition_part_a', 'nutrition_part_b', 'nutrition_part_c', 'body_metrics', 'activity_metrics']
|
||||
__all__ = [
|
||||
'nutrition_part_a',
|
||||
'nutrition_part_b',
|
||||
'nutrition_part_c',
|
||||
'nutrition_score',
|
||||
'body_metrics',
|
||||
'body_extras',
|
||||
'activity_metrics',
|
||||
'activity_session_insights',
|
||||
'schlaf_erholung',
|
||||
'vitalwerte',
|
||||
'profil_zeitraum',
|
||||
'phase_0b_meta_scores',
|
||||
'phase_0b_ziele_fokus',
|
||||
'korrelationen',
|
||||
'profile_reference_values',
|
||||
]
|
||||
|
|
|
|||
19
backend/placeholder_registrations/_evidence.py
Normal file
19
backend/placeholder_registrations/_evidence.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"""Gemeinsames Evidence-Tagging für Registry-Einträge."""
|
||||
|
||||
from placeholder_registry import EvidenceType, PlaceholderMetadata
|
||||
|
||||
STANDARD_FIELDS = (
|
||||
"key", "category", "description", "resolver_module", "resolver_function",
|
||||
"data_layer_module", "data_layer_function", "source_tables", "semantic_contract",
|
||||
"unit", "time_window", "output_type", "placeholder_type", "format_hint",
|
||||
"example_output", "minimum_data_requirements", "confidence_logic",
|
||||
"missing_value_policy", "layer_1_decision", "layer_2a_decision",
|
||||
"layer_2b_reuse_possible", "architecture_alignment", "issue_53_alignment",
|
||||
)
|
||||
|
||||
|
||||
def tag_standard_evidence(meta: PlaceholderMetadata) -> None:
|
||||
for field in STANDARD_FIELDS:
|
||||
meta.set_evidence(field, EvidenceType.CODE_DERIVED)
|
||||
meta.set_evidence("business_meaning", EvidenceType.DRAFT_DERIVED)
|
||||
meta.set_evidence("known_limitations", EvidenceType.MIXED)
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"""
|
||||
Activity Metrics Placeholder Registrations
|
||||
|
||||
Registers all 17 activity-related placeholders in the central placeholder registry.
|
||||
Registers 17 Aktivitäts-Platzhalter hier; 3 weitere Keys in activity_session_insights.py (**20 gesamt** in PLACEHOLDER_MAP).
|
||||
|
||||
Evidence-based metadata with clear tagging of source.
|
||||
|
||||
|
|
@ -10,6 +10,9 @@ Groups:
|
|||
- Basic Metrics (7): training_minutes_week, training_frequency_7d, quality_sessions_pct,
|
||||
proxy_internal_load_7d, monotony_score, strain_score, rest_day_compliance
|
||||
- Advanced Metrics (7): ability_balance_*, vo2max_trend_28d, activity_score
|
||||
|
||||
Resolver: alle Keys gebündelt unter „Training / Aktivität“ in PLACEHOLDER_MAP;
|
||||
activity_score nicht unter „Meta Scores“.
|
||||
"""
|
||||
|
||||
from placeholder_registry import (
|
||||
|
|
@ -40,7 +43,7 @@ def register_activity_group_1():
|
|||
category="Aktivität",
|
||||
description="Zusammenfassung der letzten 14 Tage Aktivität",
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="_format_activity_summary",
|
||||
resolver_function="get_activity_summary",
|
||||
data_layer_module=None,
|
||||
data_layer_function=None,
|
||||
source_tables=["activity_log", "training_types"],
|
||||
|
|
@ -124,16 +127,23 @@ def register_activity_group_1():
|
|||
activity_detail_metadata = PlaceholderMetadata(
|
||||
key="activity_detail",
|
||||
category="Aktivität",
|
||||
description="Detaillierte Liste der letzten 14 Tage Aktivität",
|
||||
description=(
|
||||
"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_function="_format_activity_detail",
|
||||
data_layer_module=None,
|
||||
data_layer_function=None,
|
||||
source_tables=["activity_log", "training_types"],
|
||||
resolver_function="get_activity_detail",
|
||||
data_layer_module="backend/data_layer/activity_metrics.py",
|
||||
data_layer_function="get_activity_detail_data",
|
||||
source_tables=["activity_log", "activity_session_metrics", "training_parameters"],
|
||||
semantic_contract=(
|
||||
"Liefert eine strukturierte Liste aller Trainingseinheiten der letzten 14 Tage. "
|
||||
"Jede Einheit: Datum, Trainingstyp, Dauer (Minuten), optional Notizen. "
|
||||
"Sortiert chronologisch absteigend (neueste zuerst)."
|
||||
"Layer 1: get_activity_detail_data lädt Sessions, enrich_sessions_with_metrics fügt "
|
||||
"session_metrics hinzu — effektive Liste aus merge_column_backed_and_eav_metrics: nur "
|
||||
"Parameter aus dem Attributschema (tcp/ttp), sortiert nach key. "
|
||||
"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=(
|
||||
"Detaillierte Trainingshistorie für KI-Prompts, die Muster, Progressionen "
|
||||
|
|
@ -144,7 +154,9 @@ def register_activity_group_1():
|
|||
time_window="14d",
|
||||
output_type=OutputType.LIST,
|
||||
placeholder_type=PlaceholderType.RAW_DATA,
|
||||
format_hint="Liste von Strings, eine Zeile pro Einheit: 'YYYY-MM-DD: Typ (Dauer min)'",
|
||||
format_hint=(
|
||||
"Pro Zeile: Datum, Typ, Dauer, kcal, optional HF, optional „| EAV: …“ aus Session-Metriken"
|
||||
),
|
||||
example_output=(
|
||||
"2026-03-28: Krafttraining (45 min)\\n"
|
||||
"2026-03-27: Laufen (30 min)\\n"
|
||||
|
|
@ -160,19 +172,17 @@ def register_activity_group_1():
|
|||
legacy_display="Keine Aktivitätsdaten"
|
||||
),
|
||||
known_limitations=(
|
||||
"OLD RESOLVER PATTERN: Keine Data Layer Funktion. "
|
||||
"Formatierung direkt im Resolver. "
|
||||
"CRITICAL: Keine Qualitätsfilterung - auch ungültige Einheiten (z.B. 0 min) "
|
||||
"werden gelistet. JOIN mit training_types für Typ-Namen."
|
||||
"Keine Profil-Qualitätsfilterung in dieser Liste. Max. 20 Zeilen im Prompt-Output "
|
||||
"(Hard-Limit Resolver). session_metrics kann leer sein (kein Typ, kein Profil, keine EAV-Zeilen). "
|
||||
"Keys und Anzahl Metriken variieren je Instanz/Admin — nicht von festen Platzhaltern in anderen "
|
||||
"Prompts ausgehen. Nur im effektiven Merge erscheinende Parameter; keine verwaisten EAV-Keys "
|
||||
"außerhalb des Schemas."
|
||||
),
|
||||
layer_1_decision="NONE - Old resolver pattern (direct SQL in resolver)",
|
||||
layer_2a_decision="Placeholder Resolver (formatting + SQL query)",
|
||||
layer_2b_reuse_possible=False,
|
||||
architecture_alignment=(
|
||||
"NOT ALIGNED with Phase 0c Multi-Layer Architecture. "
|
||||
"Should be refactored to use data_layer function."
|
||||
),
|
||||
issue_53_alignment="NOT ALIGNED - no layer separation"
|
||||
layer_1_decision="activity_metrics.get_activity_detail_data (+ enrich_sessions_with_metrics)",
|
||||
layer_2a_decision="get_activity_detail (Formatierung)",
|
||||
layer_2b_reuse_possible=True,
|
||||
architecture_alignment="Phase 0c Layer 1 + EAV-Anreicherung",
|
||||
issue_53_alignment="Layer 1"
|
||||
)
|
||||
|
||||
activity_detail_metadata.set_evidence("key", EvidenceType.CODE_DERIVED)
|
||||
|
|
@ -209,56 +219,47 @@ def register_activity_group_1():
|
|||
trainingstyp_verteilung_metadata = PlaceholderMetadata(
|
||||
key="trainingstyp_verteilung",
|
||||
category="Aktivität",
|
||||
description="Trainingstypen-Verteilung der letzten 14 Tage als JSON",
|
||||
description="Verteilung nach training_category (14 Tage): Top 3 als kompakte Prozent-Textzeile",
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="_format_trainingstyp_verteilung",
|
||||
data_layer_module=None,
|
||||
data_layer_function=None,
|
||||
source_tables=["activity_log", "training_types"],
|
||||
resolver_function="get_trainingstyp_verteilung",
|
||||
data_layer_module="backend/data_layer/activity_metrics.py",
|
||||
data_layer_function="get_training_type_distribution_data",
|
||||
source_tables=["activity_log"],
|
||||
semantic_contract=(
|
||||
"Liefert eine JSON-Struktur mit der Verteilung der Trainingstypen über 14 Tage. "
|
||||
"Für jeden Trainingstyp: Anzahl Einheiten, Gesamtdauer (Minuten), "
|
||||
"Prozentanteil an Gesamtdauer. Sortiert nach Dauer absteigend."
|
||||
"Layer 1: get_training_type_distribution_data — Anteil je training_category am "
|
||||
"Gesamt-Session-Count im Fenster (auch unkategorisierte zählen im Nenner). "
|
||||
"Layer 2a: Top 3 Kategorien als „Name: p%“ kommagetrennt; bei fehlenden Daten Kurz-Hinweis."
|
||||
),
|
||||
business_meaning=(
|
||||
"Analyse-Placeholder für Trainingsvielfalt und -schwerpunkte. "
|
||||
"Erlaubt KI-Prompts, Imbalancen zu erkennen (z.B. nur Kraft, keine Ausdauer) "
|
||||
"oder Zielkonformität zu prüfen (z.B. 'zu wenig Mobilität')."
|
||||
),
|
||||
unit="json",
|
||||
unit="text",
|
||||
time_window="14d",
|
||||
output_type=OutputType.JSON,
|
||||
output_type=OutputType.TEXT_SUMMARY,
|
||||
placeholder_type=PlaceholderType.INTERPRETED,
|
||||
format_hint="JSON Object mit Trainingstyp als Key, Value: {count, duration_min, percentage}",
|
||||
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}}'
|
||||
),
|
||||
format_hint="Eine Zeile: bis zu drei „Kategorie: Prozent%“, durch Komma getrennt",
|
||||
example_output="cardio: 45%, strength: 30%, mobility: 15%",
|
||||
minimum_data_requirements=None,
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="Keine Confidence-Berechnung. Aggregation basiert auf verfügbaren Daten.",
|
||||
confidence_logic="Wie get_training_type_distribution_data (calculate_confidence über categorized_count)",
|
||||
missing_value_policy=MissingValuePolicy(
|
||||
available=False,
|
||||
value_raw=None,
|
||||
missing_reason="no_data",
|
||||
legacy_display="{}"
|
||||
legacy_display="Keine kategorisierten Trainings"
|
||||
),
|
||||
known_limitations=(
|
||||
"OLD RESOLVER PATTERN: Keine Data Layer Funktion. "
|
||||
"Aggregation direkt im Resolver. "
|
||||
"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)."
|
||||
"Nur Sessions mit gesetztem training_category fließen in die Verteilungsliste; "
|
||||
"Prozente beziehen sich auf alle Sessions im Fenster (Nenner = total_sessions). "
|
||||
"Keine Qualitätsfilterung der Einheiten. Kein drill-down nach training_type_id in diesem Platzhalter."
|
||||
),
|
||||
layer_1_decision="NONE - Old resolver pattern (direct SQL aggregation in resolver)",
|
||||
layer_2a_decision="Placeholder Resolver (aggregation + JSON formatting)",
|
||||
layer_1_decision="activity_metrics.get_training_type_distribution_data",
|
||||
layer_2a_decision="get_trainingstyp_verteilung (Top 3 als Text)",
|
||||
layer_2b_reuse_possible=True,
|
||||
architecture_alignment=(
|
||||
"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"
|
||||
architecture_alignment="Phase 0c — Layer 1 + Formatierung",
|
||||
issue_53_alignment="Layer 1"
|
||||
)
|
||||
|
||||
trainingstyp_verteilung_metadata.set_evidence("key", EvidenceType.CODE_DERIVED)
|
||||
|
|
@ -938,9 +939,9 @@ def register_activity_group_3():
|
|||
description="VO2 Max Trend über 28 Tage",
|
||||
category="Aktivität",
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="get_vo2max_trend_28d",
|
||||
resolver_function="_safe_float",
|
||||
data_layer_module="backend/data_layer/activity_metrics.py",
|
||||
data_layer_function="calculate_vo2max_trend",
|
||||
data_layer_function="calculate_vo2max_trend_28d",
|
||||
source_tables=["vitals_baseline"],
|
||||
time_window="28d",
|
||||
output_type=OutputType.NUMERIC,
|
||||
|
|
@ -977,8 +978,8 @@ def register_activity_group_3():
|
|||
"EDGE CASE: Nur 1 Messung → kein Trend → missing_value. "
|
||||
"EDGE CASE: Große Zeitlücken zwischen Messungen → Trend nicht aussagekräftig."
|
||||
),
|
||||
layer_1_decision="Data Layer (activity_metrics.calculate_vo2max_trend) - QUESTIONABLE",
|
||||
layer_2a_decision="Placeholder Resolver (formatting only)",
|
||||
layer_1_decision="Data Layer (activity_metrics.calculate_vo2max_trend_28d) — Kategorie diskutierbar",
|
||||
layer_2a_decision="Placeholder Resolver (_safe_float)",
|
||||
layer_2b_reuse_possible=True,
|
||||
architecture_alignment="Phase 0c Multi-Layer Architecture conform",
|
||||
issue_53_alignment="Layer separation established"
|
||||
|
|
@ -1020,8 +1021,8 @@ def register_activity_group_3():
|
|||
description="Gesamtaktivitäts-Score (gewichtet)",
|
||||
category="Aktivität",
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="get_activity_score",
|
||||
data_layer_module="backend/data_layer/scores.py",
|
||||
resolver_function="_safe_int",
|
||||
data_layer_module="backend/data_layer/activity_metrics.py",
|
||||
data_layer_function="calculate_activity_score",
|
||||
source_tables=["activity_log", "training_types", "rest_days", "vitals_baseline", "user_focus_area_weights"],
|
||||
time_window="composite (7d, 14d, 28d mixed)",
|
||||
|
|
@ -1065,8 +1066,8 @@ def register_activity_group_3():
|
|||
"QUESTIONABLE: Vermischt Metriken mit unterschiedlicher Verlässlichkeit "
|
||||
"(z.B. quality_sessions_pct hat TO_VERIFY Issues)."
|
||||
),
|
||||
layer_1_decision="Data Layer (scores.calculate_activity_score)",
|
||||
layer_2a_decision="Placeholder Resolver (formatting only)",
|
||||
layer_1_decision="Data Layer (activity_metrics.calculate_activity_score)",
|
||||
layer_2a_decision="Placeholder Resolver (_safe_int)",
|
||||
layer_2b_reuse_possible=False,
|
||||
architecture_alignment="Phase 0c Multi-Layer Architecture conform",
|
||||
issue_53_alignment="Layer separation established"
|
||||
|
|
|
|||
251
backend/placeholder_registrations/activity_session_insights.py
Normal file
251
backend/placeholder_registrations/activity_session_insights.py
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
"""
|
||||
Registry: Trainings-Häufigkeit, Pausen zwischen Einheiten, wöchentliche Session-JSON (KI-Rohkontext).
|
||||
"""
|
||||
|
||||
from placeholder_registry import (
|
||||
PlaceholderMetadata,
|
||||
MissingValuePolicy,
|
||||
EvidenceType,
|
||||
OutputType,
|
||||
PlaceholderType,
|
||||
register_placeholder,
|
||||
)
|
||||
|
||||
|
||||
def _ev(meta: PlaceholderMetadata, field: str, et: EvidenceType = EvidenceType.CODE_DERIVED):
|
||||
meta.set_evidence(field, et)
|
||||
|
||||
|
||||
def register_activity_session_insights():
|
||||
md_freq = PlaceholderMetadata(
|
||||
key="training_frequency_by_type_md",
|
||||
category="Aktivität",
|
||||
description=(
|
||||
"Markdown-Tabelle: pro Trainingsart (activity_type) Sessions, Ø/Woche, "
|
||||
"Dauer, kcal, HF, RPE, kcal/min (Intensitätsproxy)"
|
||||
),
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="get_training_frequency_by_type_md",
|
||||
data_layer_module="backend/data_layer/activity_metrics.py",
|
||||
data_layer_function="get_training_frequency_by_type_data",
|
||||
source_tables=["activity_log"],
|
||||
semantic_contract=(
|
||||
"Aggregat über activity_log gruppiert nach activity_type (Roh-Label). "
|
||||
"sessions_per_week = count / (days/7). avg_kcal_per_min = Summe kcal / Summe min."
|
||||
),
|
||||
business_meaning="KI: Häufigkeit & Belastung pro Sportart, Erholungs-/Überlastungs-Kontext",
|
||||
unit="Markdown",
|
||||
time_window="default 28 Tage",
|
||||
output_type=OutputType.TEXT_SUMMARY,
|
||||
placeholder_type=PlaceholderType.INTERPRETED,
|
||||
format_hint="GitHub-Flavored Markdown-Tabelle",
|
||||
example_output="| Art | n | Ø/Woche | … |",
|
||||
minimum_data_requirements="Mindestens eine Session im Fenster",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="Wie calculate_confidence anhand Session-Anzahl",
|
||||
missing_value_policy=MissingValuePolicy(
|
||||
available=False,
|
||||
value_raw=None,
|
||||
missing_reason="no_data",
|
||||
legacy_display="Keine Trainingsdaten",
|
||||
),
|
||||
known_limitations=(
|
||||
"Gruppierung nach activity_type-String (Import-Namen), nicht nur training_type_id. "
|
||||
"HF/RPE oft NULL je nach Quelle. Pausen-Analyse separater Platzhalter."
|
||||
),
|
||||
layer_1_decision="activity_metrics.get_training_frequency_by_type_data",
|
||||
layer_2a_decision="get_training_frequency_by_type_md",
|
||||
layer_2b_reuse_possible=True,
|
||||
architecture_alignment="Phase 0c",
|
||||
issue_53_alignment="Layer 1",
|
||||
evidence={},
|
||||
)
|
||||
for f in (
|
||||
"key", "category", "description", "resolver_module", "resolver_function",
|
||||
"data_layer_module", "data_layer_function", "source_tables", "semantic_contract",
|
||||
"unit", "time_window", "output_type", "placeholder_type", "format_hint",
|
||||
"example_output", "minimum_data_requirements", "confidence_logic",
|
||||
"missing_value_policy", "layer_1_decision", "layer_2a_decision",
|
||||
"layer_2b_reuse_possible", "architecture_alignment", "issue_53_alignment",
|
||||
):
|
||||
_ev(md_freq, f)
|
||||
_ev(md_freq, "business_meaning", EvidenceType.DRAFT_DERIVED)
|
||||
_ev(md_freq, "known_limitations", EvidenceType.MIXED)
|
||||
register_placeholder(md_freq)
|
||||
|
||||
md_gap = PlaceholderMetadata(
|
||||
key="training_inter_session_gap_md",
|
||||
category="Aktivität",
|
||||
description="Median/Mittel/Min der Stunden zwischen aufeinanderfolgenden Trainingseinheiten",
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="get_training_inter_session_gap_md",
|
||||
data_layer_module="backend/data_layer/activity_metrics.py",
|
||||
data_layer_function="get_training_inter_session_gap_data",
|
||||
source_tables=["activity_log"],
|
||||
semantic_contract=(
|
||||
"Sessions chronologisch; Zeitstempel = date + start_time oder 12:00. "
|
||||
"Lücken in Stunden zwischen aufeinanderfolgenden Starts."
|
||||
),
|
||||
business_meaning="KI: ausreichend Erholung zwischen Belastungen? Doppelbelastung?",
|
||||
unit="Markdown",
|
||||
time_window="default 28 Tage",
|
||||
output_type=OutputType.TEXT_SUMMARY,
|
||||
placeholder_type=PlaceholderType.INTERPRETED,
|
||||
format_hint="Kurzer Markdown-Fließtext",
|
||||
example_output="**Pause zwischen Trainings** …",
|
||||
minimum_data_requirements="Mindestens 2 Sessions",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="calculate_confidence über Session-Anzahl",
|
||||
missing_value_policy=MissingValuePolicy(
|
||||
available=False,
|
||||
value_raw=None,
|
||||
missing_reason="insufficient_data",
|
||||
legacy_display="Zu wenige Trainings",
|
||||
),
|
||||
known_limitations=(
|
||||
"Kein Unterscheidung aktiv/passiv außerhalb activity_log. "
|
||||
"Fehlende Uhrzeit verzerrt Reihenfolge am selben Tag nicht (nur ein künstlicher Mittag)."
|
||||
),
|
||||
layer_1_decision="activity_metrics.get_training_inter_session_gap_data",
|
||||
layer_2a_decision="get_training_inter_session_gap_md",
|
||||
layer_2b_reuse_possible=True,
|
||||
architecture_alignment="Phase 0c",
|
||||
issue_53_alignment="Layer 1",
|
||||
evidence={},
|
||||
)
|
||||
for f in (
|
||||
"key", "category", "description", "resolver_module", "resolver_function",
|
||||
"data_layer_module", "data_layer_function", "source_tables", "semantic_contract",
|
||||
"unit", "time_window", "output_type", "placeholder_type", "format_hint",
|
||||
"example_output", "minimum_data_requirements", "confidence_logic",
|
||||
"missing_value_policy", "layer_1_decision", "layer_2a_decision",
|
||||
"layer_2b_reuse_possible", "architecture_alignment", "issue_53_alignment",
|
||||
):
|
||||
_ev(md_gap, f)
|
||||
_ev(md_gap, "business_meaning", EvidenceType.DRAFT_DERIVED)
|
||||
_ev(md_gap, "known_limitations", EvidenceType.MIXED)
|
||||
register_placeholder(md_gap)
|
||||
|
||||
pj = PlaceholderMetadata(
|
||||
key="training_sessions_recent_json",
|
||||
category="Aktivität",
|
||||
description=(
|
||||
"JSON: ISO-Wochen mit Sessions (activity_log-Kopf) plus session_metrics als kompaktes "
|
||||
"{key: Wert}-Objekt; Zahlen für Prompts gekürzt. Semantik: {{training_parameters_glossary_md}}."
|
||||
),
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="_safe_json",
|
||||
data_layer_module="backend/data_layer/activity_metrics.py",
|
||||
data_layer_function="get_training_sessions_recent_weeks_data",
|
||||
source_tables=["activity_log", "training_types", "activity_session_metrics", "training_parameters"],
|
||||
semantic_contract=(
|
||||
"Root: weeks[] mit week_iso; sessions[] pro Einheit u. a. id, date, activity_type, "
|
||||
"duration_min, kcal_active, hr_avg, hr_max, rpe, training_category, training_type_name, "
|
||||
"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",
|
||||
unit="JSON string",
|
||||
time_window="4 ISO-Wochen (28 Tage Datenfenster)",
|
||||
output_type=OutputType.JSON,
|
||||
placeholder_type=PlaceholderType.RAW_DATA,
|
||||
format_hint="JSON-Objekt als String",
|
||||
example_output='{"weeks":[...],"meta":{...}}',
|
||||
minimum_data_requirements="Optional Sessions; meta.confidence bei leer insufficient",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="meta.confidence aus Session-Anzahl",
|
||||
missing_value_policy=MissingValuePolicy(
|
||||
available=False,
|
||||
value_raw=None,
|
||||
missing_reason="no_data",
|
||||
legacy_display="{}",
|
||||
),
|
||||
known_limitations=(
|
||||
"Token-Länge bei vielen Sessions. 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_2a_decision="_safe_json('training_sessions_recent_json')",
|
||||
layer_2b_reuse_possible=True,
|
||||
architecture_alignment="Phase 0c",
|
||||
issue_53_alignment="Layer 1",
|
||||
evidence={},
|
||||
)
|
||||
for f in (
|
||||
"key", "category", "description", "resolver_module", "resolver_function",
|
||||
"data_layer_module", "data_layer_function", "source_tables", "semantic_contract",
|
||||
"unit", "time_window", "output_type", "placeholder_type", "format_hint",
|
||||
"example_output", "minimum_data_requirements", "confidence_logic",
|
||||
"missing_value_policy", "layer_1_decision", "layer_2a_decision",
|
||||
"layer_2b_reuse_possible", "architecture_alignment", "issue_53_alignment",
|
||||
):
|
||||
_ev(pj, f)
|
||||
_ev(pj, "business_meaning", EvidenceType.DRAFT_DERIVED)
|
||||
_ev(pj, "known_limitations", EvidenceType.MIXED)
|
||||
register_placeholder(pj)
|
||||
|
||||
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()
|
||||
237
backend/placeholder_registrations/body_extras.py
Normal file
237
backend/placeholder_registrations/body_extras.py
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
"""
|
||||
Registry: BMI, Profil-Ziele (goal_weight, goal_bf_pct), body_progress_score.
|
||||
|
||||
Profilfelder sind unabhängig von der goals-Tabelle; operative Ziele über andere Keys.
|
||||
"""
|
||||
|
||||
from placeholder_registry import (
|
||||
PlaceholderMetadata,
|
||||
MissingValuePolicy,
|
||||
EvidenceType,
|
||||
OutputType,
|
||||
PlaceholderType,
|
||||
register_placeholder,
|
||||
)
|
||||
|
||||
|
||||
def register_body_extras():
|
||||
bmi = PlaceholderMetadata(
|
||||
key="bmi",
|
||||
category="Körper",
|
||||
description="Body-Mass-Index aus letztem Gewicht und Profilgröße (cm)",
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="calculate_bmi",
|
||||
data_layer_module="backend/data_layer/body_metrics.py",
|
||||
data_layer_function="get_bmi_data",
|
||||
source_tables=["profiles", "weight_log"],
|
||||
semantic_contract=(
|
||||
"BMI = Gewicht_kg / (Größe_m)² mit Größe_m = profiles.height / 100 "
|
||||
"und Gewicht = jüngster Eintrag in weight_log."
|
||||
),
|
||||
business_meaning="Standard-Körpermaß für Coaching und Risiko-Kontext",
|
||||
unit="kg/m²",
|
||||
time_window="latest weight + aktuelle Profilgröße",
|
||||
output_type=OutputType.NUMERIC,
|
||||
placeholder_type=PlaceholderType.RAW_DATA,
|
||||
format_hint="Eine Dezimalstelle, ohne Einheit im String",
|
||||
example_output="24.3",
|
||||
minimum_data_requirements="Profil mit height > 0 und mindestens ein weight_log",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="high nur wenn BMI berechenbar; sonst insufficient / Anzeige nicht verfügbar",
|
||||
missing_value_policy=MissingValuePolicy(
|
||||
available=False,
|
||||
value_raw=None,
|
||||
missing_reason="no_data",
|
||||
legacy_display="nicht verfügbar",
|
||||
),
|
||||
known_limitations=(
|
||||
"Keine ethnischen Referenzkurven; Profilgröße kann veraltet sein. "
|
||||
"Unterscheidet nicht Muskelmasse vs. Fett."
|
||||
),
|
||||
layer_1_decision="body_metrics.get_bmi_data",
|
||||
layer_2a_decision="placeholder_resolver.calculate_bmi (Format)",
|
||||
layer_2b_reuse_possible=True,
|
||||
architecture_alignment="Phase 0c",
|
||||
issue_53_alignment="Layer 1 als Quelle",
|
||||
evidence={},
|
||||
)
|
||||
for field in (
|
||||
"key", "category", "description", "resolver_module", "resolver_function",
|
||||
"data_layer_module", "data_layer_function", "source_tables",
|
||||
"semantic_contract", "business_meaning", "unit", "time_window",
|
||||
"output_type", "placeholder_type", "format_hint", "example_output",
|
||||
"minimum_data_requirements", "confidence_logic", "missing_value_policy",
|
||||
"known_limitations", "layer_1_decision", "layer_2a_decision",
|
||||
"layer_2b_reuse_possible", "architecture_alignment", "issue_53_alignment",
|
||||
):
|
||||
bmi.set_evidence(field, EvidenceType.CODE_DERIVED)
|
||||
bmi.set_evidence("business_meaning", EvidenceType.DRAFT_DERIVED)
|
||||
bmi.set_evidence("known_limitations", EvidenceType.MIXED)
|
||||
register_placeholder(bmi)
|
||||
|
||||
gw = PlaceholderMetadata(
|
||||
key="goal_weight",
|
||||
category="Körper",
|
||||
description="Zielgewicht aus Profilfeld profiles.goal_weight (kg)",
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="get_goal_weight",
|
||||
data_layer_module="backend/data_layer/body_metrics.py",
|
||||
data_layer_function="get_profile_goal_weight_data",
|
||||
source_tables=["profiles"],
|
||||
semantic_contract=(
|
||||
"Strategisches Soll-Gewicht im Profil; unabhängig von der goals-Tabelle "
|
||||
"(dort detaillierte Ziele mit Fortschritt)."
|
||||
),
|
||||
business_meaning="Schneller Abgleich Prompt vs. Profil-Default-Zielgewicht",
|
||||
unit="kg",
|
||||
time_window="Profil-Snapshot",
|
||||
output_type=OutputType.NUMERIC,
|
||||
placeholder_type=PlaceholderType.RAW_DATA,
|
||||
format_hint="Eine Dezimalstelle oder Text „nicht gesetzt“",
|
||||
example_output="82.0",
|
||||
minimum_data_requirements="profiles.goal_weight IS NOT NULL",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="high wenn gesetzt",
|
||||
missing_value_policy=MissingValuePolicy(
|
||||
available=False,
|
||||
value_raw=None,
|
||||
missing_reason="not_set",
|
||||
legacy_display="nicht gesetzt",
|
||||
),
|
||||
known_limitations="Kann von aktiven goals.weight-Zielen abweichen.",
|
||||
layer_1_decision="body_metrics.get_profile_goal_weight_data",
|
||||
layer_2a_decision="placeholder_resolver.get_goal_weight",
|
||||
layer_2b_reuse_possible=True,
|
||||
architecture_alignment="Phase 0c",
|
||||
issue_53_alignment="Layer 1 als Quelle",
|
||||
evidence={},
|
||||
)
|
||||
for field in (
|
||||
"key", "category", "description", "resolver_module", "resolver_function",
|
||||
"data_layer_module", "data_layer_function", "source_tables",
|
||||
"semantic_contract", "unit", "time_window", "output_type",
|
||||
"placeholder_type", "format_hint", "example_output",
|
||||
"minimum_data_requirements", "confidence_logic", "missing_value_policy",
|
||||
"layer_1_decision", "layer_2a_decision", "layer_2b_reuse_possible",
|
||||
"architecture_alignment", "issue_53_alignment",
|
||||
):
|
||||
gw.set_evidence(field, EvidenceType.CODE_DERIVED)
|
||||
gw.set_evidence("business_meaning", EvidenceType.DRAFT_DERIVED)
|
||||
gw.set_evidence("known_limitations", EvidenceType.MIXED)
|
||||
register_placeholder(gw)
|
||||
|
||||
gbf = PlaceholderMetadata(
|
||||
key="goal_bf_pct",
|
||||
category="Körper",
|
||||
description="Ziel-Körperfettanteil aus Profilfeld profiles.goal_bf_pct (%)",
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="get_goal_bf_pct",
|
||||
data_layer_module="backend/data_layer/body_metrics.py",
|
||||
data_layer_function="get_profile_goal_bf_pct_data",
|
||||
source_tables=["profiles"],
|
||||
semantic_contract="Strategisches Ziel-KFA im Profil.",
|
||||
business_meaning="Prompt-Abgleich mit Profil-Ziel-KFA",
|
||||
unit="%",
|
||||
time_window="Profil-Snapshot",
|
||||
output_type=OutputType.NUMERIC,
|
||||
placeholder_type=PlaceholderType.RAW_DATA,
|
||||
format_hint="Eine Dezimalstelle oder Text „nicht gesetzt“",
|
||||
example_output="15.0",
|
||||
minimum_data_requirements="profiles.goal_bf_pct IS NOT NULL",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="high wenn gesetzt",
|
||||
missing_value_policy=MissingValuePolicy(
|
||||
available=False,
|
||||
value_raw=None,
|
||||
missing_reason="not_set",
|
||||
legacy_display="nicht gesetzt",
|
||||
),
|
||||
known_limitations="Kann von goals body_fat abweichen.",
|
||||
layer_1_decision="body_metrics.get_profile_goal_bf_pct_data",
|
||||
layer_2a_decision="placeholder_resolver.get_goal_bf_pct",
|
||||
layer_2b_reuse_possible=True,
|
||||
architecture_alignment="Phase 0c",
|
||||
issue_53_alignment="Layer 1 als Quelle",
|
||||
evidence={},
|
||||
)
|
||||
for field in (
|
||||
"key", "category", "description", "resolver_module", "resolver_function",
|
||||
"data_layer_module", "data_layer_function", "source_tables",
|
||||
"semantic_contract", "unit", "time_window", "output_type",
|
||||
"placeholder_type", "format_hint", "example_output",
|
||||
"minimum_data_requirements", "confidence_logic", "missing_value_policy",
|
||||
"layer_1_decision", "layer_2a_decision", "layer_2b_reuse_possible",
|
||||
"architecture_alignment", "issue_53_alignment",
|
||||
):
|
||||
gbf.set_evidence(field, EvidenceType.CODE_DERIVED)
|
||||
gbf.set_evidence("business_meaning", EvidenceType.DRAFT_DERIVED)
|
||||
gbf.set_evidence("known_limitations", EvidenceType.MIXED)
|
||||
register_placeholder(gbf)
|
||||
|
||||
bps = PlaceholderMetadata(
|
||||
key="body_progress_score",
|
||||
category="Körper",
|
||||
description="Körper-Fortschritts-Score 0–100, gewichtet nach Focus (Abnehmen, Muskelaufbau, Recomp)",
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="_safe_int",
|
||||
data_layer_module="backend/data_layer/body_metrics.py",
|
||||
data_layer_function="calculate_body_progress_score",
|
||||
source_tables=[
|
||||
"user_focus_area_weights",
|
||||
"focus_area_definitions",
|
||||
"goals",
|
||||
"weight_log",
|
||||
"caliper_log",
|
||||
"circumference_log",
|
||||
],
|
||||
semantic_contract=(
|
||||
"Gewichteter Mittelwert aus bis zu drei Komponenten: Trend vs. Gewichtsziel, "
|
||||
"Körperzusammensetzung (FM/LBM/Recomp-Quadrant), Taille-Trend. "
|
||||
"Komponenten nur aktiv, wenn passende Focus-Gewichte > 0."
|
||||
),
|
||||
business_meaning="Meta-KPI: passt dokumentierter Körperfortschritt zur gewichteten Körper-Priorität?",
|
||||
unit="Score (0–100)",
|
||||
time_window="composite (u. a. 28d Deltas, Ziel-Fortschritt)",
|
||||
output_type=OutputType.NUMERIC,
|
||||
placeholder_type=PlaceholderType.SCORE,
|
||||
format_hint="Ganzzahl oder „nicht verfügbar“",
|
||||
example_output="72",
|
||||
minimum_data_requirements=(
|
||||
"Summe der Körper-Focus-Gewichte (weight_loss + muscle_gain + body_recomposition) > 0 "
|
||||
"und mindestens eine bewertbare Komponente mit Daten."
|
||||
),
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="Kein separates Confidence-Feld; None wenn keine Körper-Gewichtung oder keine Teilscores.",
|
||||
missing_value_policy=MissingValuePolicy(
|
||||
available=False,
|
||||
value_raw=None,
|
||||
missing_reason="not_applicable",
|
||||
legacy_display="nicht verfügbar",
|
||||
),
|
||||
known_limitations=(
|
||||
"Abhängig von user_focus_area_weights und aktiven weight-goals für Gewichts-Teilscore. "
|
||||
"Taille-Score wird mit festem Basisgewicht 20+ eingemischt und kann dominieren."
|
||||
),
|
||||
layer_1_decision="body_metrics.calculate_body_progress_score",
|
||||
layer_2a_decision="placeholder_resolver._safe_int('body_progress_score', …)",
|
||||
layer_2b_reuse_possible=True,
|
||||
architecture_alignment="Phase 0c",
|
||||
issue_53_alignment="Layer 1 als Quelle",
|
||||
evidence={},
|
||||
)
|
||||
for field in (
|
||||
"key", "category", "description", "resolver_module", "resolver_function",
|
||||
"data_layer_module", "data_layer_function", "source_tables",
|
||||
"semantic_contract", "unit", "time_window", "output_type",
|
||||
"placeholder_type", "format_hint", "example_output",
|
||||
"minimum_data_requirements", "confidence_logic", "missing_value_policy",
|
||||
"layer_1_decision", "layer_2a_decision", "layer_2b_reuse_possible",
|
||||
"architecture_alignment", "issue_53_alignment",
|
||||
):
|
||||
bps.set_evidence(field, EvidenceType.CODE_DERIVED)
|
||||
bps.set_evidence("business_meaning", EvidenceType.DRAFT_DERIVED)
|
||||
bps.set_evidence("known_limitations", EvidenceType.MIXED)
|
||||
register_placeholder(bps)
|
||||
|
||||
|
||||
register_body_extras()
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
"""
|
||||
Body Metrics Placeholder Registrations
|
||||
|
||||
Registers 17 body composition and measurement placeholders:
|
||||
Registers 17 Körper-Metriken in diesem Modul; insgesamt 21 Körper-Keys in der Registry
|
||||
(zusätzlich body_extras.py: bmi, goal_weight, goal_bf_pct, body_progress_score).
|
||||
|
||||
Weight & Trends (7):
|
||||
- weight_aktuell
|
||||
|
|
@ -17,9 +18,10 @@ Body Composition (5):
|
|||
- waist_hip_ratio
|
||||
- recomposition_quadrant
|
||||
|
||||
Circumference Deltas (5):
|
||||
Circumference Deltas (6):
|
||||
- waist_28d_delta
|
||||
- arm_28d_delta
|
||||
- arm_28d_delta (Oberarm kontrahiert, c_arm)
|
||||
- arm_relaxed_28d_delta (Oberarm entspannt, c_arm_relaxed)
|
||||
- chest_28d_delta
|
||||
- hip_28d_delta
|
||||
- thigh_28d_delta
|
||||
|
|
@ -29,7 +31,7 @@ Summaries (2):
|
|||
- circ_summary
|
||||
|
||||
Evidence-based metadata with comprehensive formula documentation.
|
||||
Code inspection: backend/data_layer/body_metrics.py (830 lines)
|
||||
Siehe backend/data_layer/body_metrics.py als Layer-1-Implementierung.
|
||||
"""
|
||||
|
||||
from placeholder_registry import (
|
||||
|
|
@ -1032,14 +1034,14 @@ def register_body_metrics():
|
|||
|
||||
arm_28d_delta_metadata = PlaceholderMetadata(
|
||||
key="arm_28d_delta",
|
||||
description="Armumfang Änderung 28d (cm)",
|
||||
description="Oberarm kontrahiert (c_arm): Umfangs-Änderung 28d (cm)",
|
||||
resolver_function="_safe_float('arm_28d_delta', decimals=1)",
|
||||
data_layer_function="calculate_arm_28d_delta",
|
||||
semantic_contract=(
|
||||
"Liefert die Veränderung des Armumfangs in Zentimetern über 28 Tage. "
|
||||
"Positive Werte bedeuten Zunahme, negative Werte Reduktion."
|
||||
"Veränderung des kontrahierten/angespannten Oberarmumfangs (Spalte c_arm) in cm über 28 Tage. "
|
||||
"Entspricht historischen Einträgen „Arm“ vor Einführung des zweiten Messpunkts."
|
||||
),
|
||||
business_meaning="Ergänzender Umfangsindikator für detaillierte Körperentwicklungsanalysen",
|
||||
business_meaning="Arm-Umfang unter Anspannung (z. B. leicht gebeugter Arm, Bizeps leicht aktiv)",
|
||||
unit="cm",
|
||||
example_output="+0.6",
|
||||
**circumference_delta_common
|
||||
|
|
@ -1053,6 +1055,30 @@ def register_body_metrics():
|
|||
arm_28d_delta_metadata.set_evidence("example_output", EvidenceType.DRAFT_DERIVED)
|
||||
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_metadata = PlaceholderMetadata(
|
||||
|
|
|
|||
96
backend/placeholder_registrations/korrelationen.py
Normal file
96
backend/placeholder_registrations/korrelationen.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"""Registry: Korrelations- und Treiber-Metriken (Data Layer correlations)."""
|
||||
|
||||
from placeholder_registry import (
|
||||
PlaceholderMetadata,
|
||||
MissingValuePolicy,
|
||||
OutputType,
|
||||
PlaceholderType,
|
||||
register_placeholder,
|
||||
)
|
||||
from ._evidence import tag_standard_evidence
|
||||
|
||||
CAT = "Korrelationen"
|
||||
MVP = lambda reason, disp: MissingValuePolicy(
|
||||
available=False, value_raw=None, missing_reason=reason, legacy_display=disp
|
||||
)
|
||||
|
||||
|
||||
def register_korrelationen():
|
||||
for key, dl_fn, desc, tables, sem in [
|
||||
(
|
||||
"correlation_energy_weight_lag",
|
||||
"calculate_lag_correlation",
|
||||
"JSON: Lag-Korrelation Energiebilanz ↔ Gewicht",
|
||||
["nutrition_log", "weight_log"],
|
||||
"correlations.calculate_lag_correlation(pid, 'energy', 'weight')",
|
||||
),
|
||||
(
|
||||
"correlation_protein_lbm",
|
||||
"calculate_lag_correlation",
|
||||
"JSON: Lag-Korrelation Protein ↔ Magermasse",
|
||||
["nutrition_log", "weight_log", "caliper_log"],
|
||||
"correlations.calculate_lag_correlation(pid, 'protein', 'lbm')",
|
||||
),
|
||||
(
|
||||
"correlation_load_hrv",
|
||||
"calculate_lag_correlation",
|
||||
"JSON: Lag-Korrelation Trainingslast ↔ HRV",
|
||||
["activity_log", "vitals_baseline"],
|
||||
"correlations.calculate_lag_correlation(pid, 'training_load', 'hrv')",
|
||||
),
|
||||
(
|
||||
"correlation_load_rhr",
|
||||
"calculate_lag_correlation",
|
||||
"JSON: Lag-Korrelation Trainingslast ↔ Ruhepuls",
|
||||
["activity_log", "vitals_baseline"],
|
||||
"correlations.calculate_lag_correlation(pid, 'training_load', 'rhr')",
|
||||
),
|
||||
(
|
||||
"plateau_detected",
|
||||
"calculate_plateau_detected",
|
||||
"JSON: Platten-Erkennung (Gewicht/Körper)",
|
||||
["weight_log", "caliper_log"],
|
||||
"correlations.calculate_plateau_detected",
|
||||
),
|
||||
(
|
||||
"top_drivers",
|
||||
"calculate_top_drivers",
|
||||
"JSON: Top Treiber für Ziel-/Score-Variablen",
|
||||
["weight_log", "nutrition_log", "activity_log", "vitals_baseline", "sleep_log"],
|
||||
"correlations.calculate_top_drivers",
|
||||
),
|
||||
]:
|
||||
m = PlaceholderMetadata(
|
||||
key=key,
|
||||
category=CAT,
|
||||
description=desc,
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="_safe_json",
|
||||
data_layer_module="backend/data_layer/correlations.py",
|
||||
data_layer_function=dl_fn,
|
||||
source_tables=tables,
|
||||
semantic_contract=sem,
|
||||
business_meaning="Strukturierte Korrelationsausgabe für KI",
|
||||
unit="JSON",
|
||||
time_window="funktionsintern",
|
||||
output_type=OutputType.JSON,
|
||||
placeholder_type=PlaceholderType.RAW_DATA,
|
||||
format_hint="JSON-String",
|
||||
example_output="{}",
|
||||
minimum_data_requirements="Ausreichend gekoppelte Zeitreihen",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="Wie correlations.*",
|
||||
missing_value_policy=MVP("insufficient_data", "{}"),
|
||||
known_limitations="Bei wenigen Daten leer oder wenig robust",
|
||||
layer_1_decision=f"correlations.{dl_fn}",
|
||||
layer_2a_decision="_safe_json",
|
||||
layer_2b_reuse_possible=True,
|
||||
architecture_alignment="Phase 0c",
|
||||
issue_53_alignment="Layer 1",
|
||||
evidence={},
|
||||
)
|
||||
tag_standard_evidence(m)
|
||||
register_placeholder(m)
|
||||
|
||||
|
||||
register_korrelationen()
|
||||
|
|
@ -53,6 +53,13 @@ def register_nutrition_part_a():
|
|||
"layer_1_decision": "Data Layer (nutrition_metrics.get_nutrition_average_data)",
|
||||
"layer_2a_decision": "Placeholder Resolver (formatting only)",
|
||||
"architecture_alignment": "Phase 0c Multi-Layer Architecture conform",
|
||||
"minimum_data_requirements": (
|
||||
"Mind. ein Kalendertag mit nutrition_log im Fenster; Mittelwerte aus täglicher Aggregation. "
|
||||
"Confidence über calculate_confidence(day_count, days) in get_nutrition_average_data."
|
||||
),
|
||||
"quality_filter_policy": (
|
||||
"Kein Outlier-Filter auf Tagesaggregaten; leere Tage fehlen in der Aggregation (kein Imputing)."
|
||||
),
|
||||
}
|
||||
|
||||
# Common evidence for shared fields
|
||||
|
|
@ -73,8 +80,8 @@ def register_nutrition_part_a():
|
|||
"layer_2b_reuse_possible": EvidenceType.TO_VERIFY, # not verified in charts
|
||||
"architecture_alignment": EvidenceType.CODE_DERIVED, # imports from data_layer
|
||||
"issue_53_alignment": EvidenceType.MIXED, # layer separation visible, issue conformity derived
|
||||
"minimum_data_requirements": EvidenceType.UNRESOLVED, # not explicit in code
|
||||
"quality_filter_policy": EvidenceType.UNRESOLVED, # not implemented
|
||||
"minimum_data_requirements": EvidenceType.CODE_DERIVED,
|
||||
"quality_filter_policy": EvidenceType.CODE_DERIVED,
|
||||
}
|
||||
|
||||
# ── kcal_avg ──────────────────────────────────────────────────────────────
|
||||
|
|
@ -94,8 +101,6 @@ def register_nutrition_part_a():
|
|||
known_limitations="nur Intake, kein Bedarf; sagt allein nichts über Zielpassung",
|
||||
layer_2b_reuse_possible=None, # to_verify - not checked in chart code
|
||||
issue_53_alignment="Layer separation established",
|
||||
minimum_data_requirements=None, # unresolved
|
||||
quality_filter_policy=None, # unresolved
|
||||
**common_metadata
|
||||
)
|
||||
|
||||
|
|
@ -131,8 +136,6 @@ def register_nutrition_part_a():
|
|||
),
|
||||
layer_2b_reuse_possible=None,
|
||||
issue_53_alignment="Layer separation established",
|
||||
minimum_data_requirements=None,
|
||||
quality_filter_policy=None,
|
||||
**common_metadata
|
||||
)
|
||||
|
||||
|
|
@ -165,8 +168,6 @@ def register_nutrition_part_a():
|
|||
),
|
||||
layer_2b_reuse_possible=None,
|
||||
issue_53_alignment="Layer separation established",
|
||||
minimum_data_requirements=None,
|
||||
quality_filter_policy=None,
|
||||
**common_metadata
|
||||
)
|
||||
|
||||
|
|
@ -196,8 +197,6 @@ def register_nutrition_part_a():
|
|||
known_limitations="meist im Gesamtkontext der Makroverteilung relevant",
|
||||
layer_2b_reuse_possible=None,
|
||||
issue_53_alignment="Layer separation established",
|
||||
minimum_data_requirements=None,
|
||||
quality_filter_policy=None,
|
||||
**common_metadata
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""
|
||||
Placeholder Registrations - Nutrition Part C
|
||||
|
||||
Registers 5 nutrition-related placeholders with complete metadata:
|
||||
Registers 5 nutrition-related placeholders in this file (nutrition_score: siehe nutrition_score.py):
|
||||
- macro_consistency_score
|
||||
- energy_balance_7d
|
||||
- energy_deficit_surplus
|
||||
|
|
@ -113,7 +113,7 @@ energy_balance_metadata = PlaceholderMetadata(
|
|||
resolver_function="_safe_float('energy_balance_7d', pid, decimals=0)",
|
||||
data_layer_module="backend/data_layer/nutrition_metrics.py",
|
||||
data_layer_function="calculate_energy_balance_7d",
|
||||
source_tables=["nutrition_log", "weight_log"],
|
||||
source_tables=["nutrition_log", "weight_log", "profiles"],
|
||||
|
||||
# Semantic
|
||||
semantic_contract="Liefert die geschätzte Energiebilanz über 7 Tage als Differenz zwischen durchschnittlicher Energieaufnahme und geschätztem TDEE (Total Daily Energy Expenditure). Positiver Wert = Überschuss, Negativer Wert = Defizit.",
|
||||
|
|
@ -127,11 +127,14 @@ energy_balance_metadata = PlaceholderMetadata(
|
|||
|
||||
# Quality
|
||||
minimum_data_requirements="Mindestens 4 Tage mit Kalorienerfassung in 7-Tage-Fenster. Aktuelles Gewicht aus weight_log erforderlich.",
|
||||
quality_filter_policy="Unvollständige Intake-Daten und fehlende Gewichtsmessung reduzieren Verlässlichkeit. TDEE-Schätzung ist vereinfacht (weight_kg × 32.5).",
|
||||
quality_filter_policy=(
|
||||
"Unvollständige Intake-Daten und fehlende Gewichtsmessung reduzieren Verlässlichkeit. "
|
||||
"TDEE: Mifflin–St Jeor × PAL 1.55 wenn Höhe, Geschlecht, DOB und Gewicht vorhanden, sonst kg×32.5."
|
||||
),
|
||||
confidence_logic=(
|
||||
"Kombiniert Intake-Abdeckung und Robustheit des Verbrauchsmodells. "
|
||||
"Niedrigere Confidence bei <7 Tagen Daten oder fehlendem Gewicht. "
|
||||
"TDEE-Modell ist vereinfacht → inherent uncertainty."
|
||||
"PAL=1.55 ist ein Festwert (moderate Aktivität), kein individuelles Aktivitätslogging."
|
||||
),
|
||||
missing_value_policy=MissingValuePolicy(
|
||||
available=False,
|
||||
|
|
@ -140,11 +143,10 @@ energy_balance_metadata = PlaceholderMetadata(
|
|||
legacy_display="nicht verfügbar"
|
||||
),
|
||||
known_limitations=(
|
||||
"TDEE-MODELL: Vereinfacht als bodyweight_kg × 32.5 (mittlerer Multiplikator). "
|
||||
"NICHT berücksichtigt: Aktivitätslevel, Alter, Geschlecht, Stoffwechselanpassungen. "
|
||||
"TODO in Code: Harris-Benedict oder Mifflin-St Jeor für präzisere TDEE-Schätzung. "
|
||||
"ACHTUNG: Energiebilanz ist modellbasiert, nicht direkt gemessen. "
|
||||
"Einheit ist kcal/Tag (daily average), NICHT 7d-Total."
|
||||
"TDEE: Bei vollständigem Profil (Größe, Geschlecht, DOB, Gewicht) Mifflin–St Jeor BMR × 1.55; "
|
||||
"sonst Fallback kg×32.5. PAL ist nicht nutzerkonfigurierbar. "
|
||||
"Energiebilanz ist modellbasiert, nicht gemessen. "
|
||||
"Einheit kcal/Tag (Tagesmittel), nicht 7-Tage-Summe."
|
||||
),
|
||||
|
||||
# Architecture
|
||||
|
|
@ -435,8 +437,9 @@ Part C Registration Complete:
|
|||
Total Nutrition Cluster:
|
||||
- Part A: 4 placeholders (kcal_avg, protein_avg, carb_avg, fat_avg)
|
||||
- Part B: 5 placeholders (protein targets + adequacy)
|
||||
- Part C: 5 placeholders (consistency + balance + meta)
|
||||
→ 14 nutrition placeholders total
|
||||
- Part C: 5 placeholders in dieser Datei (consistency + balance + meta)
|
||||
- nutrition_score: eigenes Modul nutrition_score.py
|
||||
→ 15 Ernährungs-Platzhalter gesamt (A+B+C+nutrition_score)
|
||||
|
||||
All registrations follow Phase 0c Multi-Layer Architecture:
|
||||
- Layer 1 (Data Layer): Calculations
|
||||
|
|
|
|||
102
backend/placeholder_registrations/nutrition_score.py
Normal file
102
backend/placeholder_registrations/nutrition_score.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""
|
||||
Placeholder registration: nutrition_score
|
||||
|
||||
Focus-gewichteter Ernährungs-Meta-Score (separates Modul, um nutrition_part_c schlank zu halten).
|
||||
"""
|
||||
|
||||
from placeholder_registry import (
|
||||
PlaceholderMetadata,
|
||||
MissingValuePolicy,
|
||||
EvidenceType,
|
||||
OutputType,
|
||||
PlaceholderType,
|
||||
register_placeholder,
|
||||
)
|
||||
|
||||
nutrition_score_metadata = PlaceholderMetadata(
|
||||
key="nutrition_score",
|
||||
category="Ernährung",
|
||||
description="Ernährungs-Score (0–100), gewichtet nach Focus Areas",
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="_safe_int",
|
||||
data_layer_module="backend/data_layer/nutrition_metrics.py",
|
||||
data_layer_function="calculate_nutrition_score",
|
||||
source_tables=[
|
||||
"nutrition_log",
|
||||
"weight_log",
|
||||
"user_focus_area_weights",
|
||||
"focus_area_definitions",
|
||||
],
|
||||
semantic_contract=(
|
||||
"Gewichteter Score 0–100 aus Komponenten, die nur einfließen, wenn der Nutzer "
|
||||
"passende Ernährungs-Focus-Gewichte gesetzt hat (z. B. protein_intake, "
|
||||
"calorie_balance, macro_consistency). Nutzt u. a. Protein-Adequacy, "
|
||||
"Makro-Konsistenz, Kalorien-Adhärenz (über Energiebilanz) und Makro-Balance."
|
||||
),
|
||||
business_meaning=(
|
||||
"Verdichteter KPI für Prompts: passt die dokumentierte Ernährung zur "
|
||||
"gewichteten strategischen Priorität des Nutzers?"
|
||||
),
|
||||
unit="score (0-100)",
|
||||
time_window="composite (7d / 28d je Komponente)",
|
||||
output_type=OutputType.NUMERIC,
|
||||
placeholder_type=PlaceholderType.SCORE,
|
||||
format_hint="Ganzzahl; bei fehlender Ernährungs-Gewichtung oft nicht verfügbar",
|
||||
example_output="72",
|
||||
minimum_data_requirements=(
|
||||
"Mindestens eine Ernährungs-Focus-Komponente mit Gewicht > 0; "
|
||||
"sowie je nach Komponente ausreichende nutrition_log-/weight_log-Abdeckung."
|
||||
),
|
||||
quality_filter_policy=None,
|
||||
confidence_logic=(
|
||||
"Kein separates Confidence-Feld im Resolver; fehlende Komponenten werden "
|
||||
"aus der Gewichtung ausgeschlossen. total_nutrition_weight == 0 ergibt keinen Score."
|
||||
),
|
||||
missing_value_policy=MissingValuePolicy(
|
||||
available=False,
|
||||
value_raw=None,
|
||||
missing_reason="not_applicable",
|
||||
legacy_display="nicht verfügbar",
|
||||
),
|
||||
known_limitations=(
|
||||
"Abhängig von user_focus_area_weights; ohne Ernährungs-Fokus liefert die "
|
||||
"Funktion None. Kalorien-Adhärenz nutzt 7d-Energiebilanz vs. profiles.goal_mode "
|
||||
"(weight_loss / strength+recomposition / sonst maintenance). "
|
||||
"_score_macro_balance nutzt zeilenbasierte 28d-Abfrage (langfristig an "
|
||||
"Tagesaggregation angleichen)."
|
||||
),
|
||||
layer_1_decision="Data Layer (nutrition_metrics.calculate_nutrition_score)",
|
||||
layer_2a_decision="Placeholder Resolver (_safe_int)",
|
||||
layer_2b_reuse_possible=True,
|
||||
architecture_alignment="Phase 0c: Berechnung in nutrition_metrics",
|
||||
issue_53_alignment="Layer 1 als Quelle; Komponenten nutzen weitere Layer-1-Funktionen",
|
||||
evidence={},
|
||||
)
|
||||
|
||||
nutrition_score_metadata.set_evidence("key", EvidenceType.CODE_DERIVED)
|
||||
nutrition_score_metadata.set_evidence("category", EvidenceType.CODE_DERIVED)
|
||||
nutrition_score_metadata.set_evidence("description", EvidenceType.MIXED)
|
||||
nutrition_score_metadata.set_evidence("resolver_module", EvidenceType.CODE_DERIVED)
|
||||
nutrition_score_metadata.set_evidence("resolver_function", EvidenceType.CODE_DERIVED)
|
||||
nutrition_score_metadata.set_evidence("data_layer_module", EvidenceType.CODE_DERIVED)
|
||||
nutrition_score_metadata.set_evidence("data_layer_function", EvidenceType.CODE_DERIVED)
|
||||
nutrition_score_metadata.set_evidence("source_tables", EvidenceType.CODE_DERIVED)
|
||||
nutrition_score_metadata.set_evidence("semantic_contract", EvidenceType.CODE_DERIVED)
|
||||
nutrition_score_metadata.set_evidence("business_meaning", EvidenceType.DRAFT_DERIVED)
|
||||
nutrition_score_metadata.set_evidence("unit", EvidenceType.CODE_DERIVED)
|
||||
nutrition_score_metadata.set_evidence("time_window", EvidenceType.CODE_DERIVED)
|
||||
nutrition_score_metadata.set_evidence("output_type", EvidenceType.CODE_DERIVED)
|
||||
nutrition_score_metadata.set_evidence("placeholder_type", EvidenceType.CODE_DERIVED)
|
||||
nutrition_score_metadata.set_evidence("format_hint", EvidenceType.CODE_DERIVED)
|
||||
nutrition_score_metadata.set_evidence("example_output", EvidenceType.CODE_DERIVED)
|
||||
nutrition_score_metadata.set_evidence("minimum_data_requirements", EvidenceType.MIXED)
|
||||
nutrition_score_metadata.set_evidence("confidence_logic", EvidenceType.CODE_DERIVED)
|
||||
nutrition_score_metadata.set_evidence("missing_value_policy", EvidenceType.CODE_DERIVED)
|
||||
nutrition_score_metadata.set_evidence("known_limitations", EvidenceType.MIXED)
|
||||
nutrition_score_metadata.set_evidence("layer_1_decision", EvidenceType.CODE_DERIVED)
|
||||
nutrition_score_metadata.set_evidence("layer_2a_decision", EvidenceType.CODE_DERIVED)
|
||||
nutrition_score_metadata.set_evidence("layer_2b_reuse_possible", EvidenceType.TO_VERIFY)
|
||||
nutrition_score_metadata.set_evidence("architecture_alignment", EvidenceType.CODE_DERIVED)
|
||||
nutrition_score_metadata.set_evidence("issue_53_alignment", EvidenceType.MIXED)
|
||||
|
||||
register_placeholder(nutrition_score_metadata)
|
||||
66
backend/placeholder_registrations/phase_0b_meta_scores.py
Normal file
66
backend/placeholder_registrations/phase_0b_meta_scores.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"""Registry: Meta-Scores (Phase 0b) — Ziel-Fortschritt und Datenqualität."""
|
||||
|
||||
from placeholder_registry import (
|
||||
PlaceholderMetadata,
|
||||
MissingValuePolicy,
|
||||
OutputType,
|
||||
PlaceholderType,
|
||||
register_placeholder,
|
||||
)
|
||||
from ._evidence import tag_standard_evidence
|
||||
|
||||
CAT = "Scores (Phase 0b)"
|
||||
MVP = lambda reason, disp: MissingValuePolicy(
|
||||
available=False, value_raw=None, missing_reason=reason, legacy_display=disp
|
||||
)
|
||||
|
||||
|
||||
def register_phase_0b_meta_scores():
|
||||
for key, dl_fn, desc, unit in [
|
||||
(
|
||||
"goal_progress_score",
|
||||
"calculate_goal_progress_score",
|
||||
"Aggregierter Ziel-Fortschritt 0–100",
|
||||
"0–100",
|
||||
),
|
||||
(
|
||||
"data_quality_score",
|
||||
"calculate_data_quality_score",
|
||||
"Datenqualitäts-Score 0–100",
|
||||
"0–100",
|
||||
),
|
||||
]:
|
||||
m = PlaceholderMetadata(
|
||||
key=key,
|
||||
category=CAT,
|
||||
description=desc,
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="_safe_int",
|
||||
data_layer_module="backend/data_layer/scores.py",
|
||||
data_layer_function=dl_fn,
|
||||
source_tables=["goals", "weight_log", "nutrition_log", "activity_log", "profiles"],
|
||||
semantic_contract=f"scores.{dl_fn} (siehe Data Layer).",
|
||||
business_meaning="Meta-KPI für Prompt-Gewichtung",
|
||||
unit=unit,
|
||||
time_window="composite",
|
||||
output_type=OutputType.NUMERIC,
|
||||
placeholder_type=PlaceholderType.SCORE,
|
||||
format_hint="Ganzzahl als String",
|
||||
example_output="72",
|
||||
minimum_data_requirements="Abhängig von Score-Implementierung",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="Wie calculate_* in scores.py",
|
||||
missing_value_policy=MVP("insufficient_data", "nicht verfügbar"),
|
||||
known_limitations="Bei dünnen Daten weniger aussagekräftig",
|
||||
layer_1_decision=f"scores.{dl_fn}",
|
||||
layer_2a_decision="_safe_int",
|
||||
layer_2b_reuse_possible=True,
|
||||
architecture_alignment="Phase 0b",
|
||||
issue_53_alignment="Layer 1",
|
||||
evidence={},
|
||||
)
|
||||
tag_standard_evidence(m)
|
||||
register_placeholder(m)
|
||||
|
||||
|
||||
register_phase_0b_meta_scores()
|
||||
392
backend/placeholder_registrations/phase_0b_ziele_fokus.py
Normal file
392
backend/placeholder_registrations/phase_0b_ziele_fokus.py
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
"""Registry: Ziele, Fokusbereiche, Kategorie-Scores und formatierte Listen (Phase 0b)."""
|
||||
|
||||
from placeholder_registry import (
|
||||
PlaceholderMetadata,
|
||||
MissingValuePolicy,
|
||||
OutputType,
|
||||
PlaceholderType,
|
||||
register_placeholder,
|
||||
)
|
||||
from ._evidence import tag_standard_evidence
|
||||
|
||||
CAT = "Ziele & Fokus (Phase 0b)"
|
||||
MVP = lambda reason, disp: MissingValuePolicy(
|
||||
available=False, value_raw=None, missing_reason=reason, legacy_display=disp
|
||||
)
|
||||
|
||||
|
||||
def register_phase_0b_ziele_fokus():
|
||||
# Top-Ziel / Top-Fokusbereich
|
||||
m = PlaceholderMetadata(
|
||||
key="top_goal_name",
|
||||
category=CAT,
|
||||
description="Name/Typ des höchstpriorisierten Ziels",
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="_safe_str",
|
||||
data_layer_module="backend/data_layer/scores.py",
|
||||
data_layer_function="get_top_priority_goal",
|
||||
source_tables=["goals"],
|
||||
semantic_contract="Feld name oder goal_type aus get_top_priority_goal",
|
||||
business_meaning="Priorisierung für KI-Empfehlungen",
|
||||
unit="text",
|
||||
time_window="aktuell",
|
||||
output_type=OutputType.STRING,
|
||||
placeholder_type=PlaceholderType.INTERPRETED,
|
||||
format_hint="Kurztext",
|
||||
example_output="Gewicht 80kg",
|
||||
minimum_data_requirements="Mindestens ein aktives Ziel",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="scores.get_top_priority_goal",
|
||||
missing_value_policy=MVP("insufficient_data", "nicht verfügbar"),
|
||||
known_limitations=None,
|
||||
layer_1_decision="scores.get_top_priority_goal",
|
||||
layer_2a_decision="_safe_str('top_goal_name')",
|
||||
layer_2b_reuse_possible=False,
|
||||
architecture_alignment="Phase 0b",
|
||||
issue_53_alignment="Layer 1",
|
||||
evidence={},
|
||||
)
|
||||
tag_standard_evidence(m)
|
||||
register_placeholder(m)
|
||||
|
||||
m = PlaceholderMetadata(
|
||||
key="top_goal_progress_pct",
|
||||
category=CAT,
|
||||
description="Fortschritt Top-Ziel (%)",
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="_safe_int",
|
||||
data_layer_module="backend/data_layer/scores.py",
|
||||
data_layer_function="get_top_priority_goal",
|
||||
source_tables=["goals"],
|
||||
semantic_contract="progress_pct aus get_top_priority_goal",
|
||||
business_meaning="Priorisierung für KI-Empfehlungen",
|
||||
unit="%",
|
||||
time_window="aktuell",
|
||||
output_type=OutputType.NUMERIC,
|
||||
placeholder_type=PlaceholderType.SCORE,
|
||||
format_hint="Ganzzahl",
|
||||
example_output="65",
|
||||
minimum_data_requirements="Mindestens ein aktives Ziel",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="scores.get_top_priority_goal",
|
||||
missing_value_policy=MVP("insufficient_data", "nicht verfügbar"),
|
||||
known_limitations=None,
|
||||
layer_1_decision="scores.get_top_priority_goal",
|
||||
layer_2a_decision="_safe_int('top_goal_progress_pct')",
|
||||
layer_2b_reuse_possible=False,
|
||||
architecture_alignment="Phase 0b",
|
||||
issue_53_alignment="Layer 1",
|
||||
evidence={},
|
||||
)
|
||||
tag_standard_evidence(m)
|
||||
register_placeholder(m)
|
||||
|
||||
m = PlaceholderMetadata(
|
||||
key="top_goal_status",
|
||||
category=CAT,
|
||||
description="Status-Label Top-Ziel",
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="_safe_str",
|
||||
data_layer_module="backend/data_layer/scores.py",
|
||||
data_layer_function="get_top_priority_goal",
|
||||
source_tables=["goals"],
|
||||
semantic_contract="status aus get_top_priority_goal",
|
||||
business_meaning="Priorisierung für KI-Empfehlungen",
|
||||
unit="text",
|
||||
time_window="aktuell",
|
||||
output_type=OutputType.STRING,
|
||||
placeholder_type=PlaceholderType.INTERPRETED,
|
||||
format_hint="Kurztext",
|
||||
example_output="active",
|
||||
minimum_data_requirements="Mindestens ein aktives Ziel",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="scores.get_top_priority_goal",
|
||||
missing_value_policy=MVP("insufficient_data", "nicht verfügbar"),
|
||||
known_limitations=None,
|
||||
layer_1_decision="scores.get_top_priority_goal",
|
||||
layer_2a_decision="_safe_str('top_goal_status')",
|
||||
layer_2b_reuse_possible=False,
|
||||
architecture_alignment="Phase 0b",
|
||||
issue_53_alignment="Layer 1",
|
||||
evidence={},
|
||||
)
|
||||
tag_standard_evidence(m)
|
||||
register_placeholder(m)
|
||||
|
||||
m = PlaceholderMetadata(
|
||||
key="top_focus_area_name",
|
||||
category=CAT,
|
||||
description="Bezeichnung des gewichtet stärksten Fokusbereichs",
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="_safe_str",
|
||||
data_layer_module="backend/data_layer/scores.py",
|
||||
data_layer_function="get_top_focus_area",
|
||||
source_tables=["user_focus_area_weights", "focus_area_definitions"],
|
||||
semantic_contract="label aus get_top_focus_area",
|
||||
business_meaning="Priorisierung für KI-Empfehlungen",
|
||||
unit="text",
|
||||
time_window="aktuell",
|
||||
output_type=OutputType.STRING,
|
||||
placeholder_type=PlaceholderType.INTERPRETED,
|
||||
format_hint="Kurztext",
|
||||
example_output="Kraft",
|
||||
minimum_data_requirements="Gewichtete Fokusbereiche",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="scores.get_top_focus_area",
|
||||
missing_value_policy=MVP("insufficient_data", "nicht verfügbar"),
|
||||
known_limitations=None,
|
||||
layer_1_decision="scores.get_top_focus_area",
|
||||
layer_2a_decision="_safe_str('top_focus_area_name')",
|
||||
layer_2b_reuse_possible=False,
|
||||
architecture_alignment="Phase 0b",
|
||||
issue_53_alignment="Layer 1",
|
||||
evidence={},
|
||||
)
|
||||
tag_standard_evidence(m)
|
||||
register_placeholder(m)
|
||||
|
||||
m = PlaceholderMetadata(
|
||||
key="top_focus_area_progress",
|
||||
category=CAT,
|
||||
description="Fortschritt Top-Fokusbereich (%)",
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="_safe_int",
|
||||
data_layer_module="backend/data_layer/scores.py",
|
||||
data_layer_function="get_top_focus_area",
|
||||
source_tables=["user_focus_area_weights", "focus_area_definitions", "goals"],
|
||||
semantic_contract="progress aus get_top_focus_area",
|
||||
business_meaning="Priorisierung für KI-Empfehlungen",
|
||||
unit="%",
|
||||
time_window="aktuell",
|
||||
output_type=OutputType.NUMERIC,
|
||||
placeholder_type=PlaceholderType.SCORE,
|
||||
format_hint="Ganzzahl",
|
||||
example_output="58",
|
||||
minimum_data_requirements="Gewichtete Fokusbereiche",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="scores.get_top_focus_area",
|
||||
missing_value_policy=MVP("insufficient_data", "nicht verfügbar"),
|
||||
known_limitations=None,
|
||||
layer_1_decision="scores.get_top_focus_area",
|
||||
layer_2a_decision="_safe_int('top_focus_area_progress')",
|
||||
layer_2b_reuse_possible=False,
|
||||
architecture_alignment="Phase 0b",
|
||||
issue_53_alignment="Layer 1",
|
||||
evidence={},
|
||||
)
|
||||
tag_standard_evidence(m)
|
||||
register_placeholder(m)
|
||||
|
||||
# Kategorie Progress / Weight (7 Kategorien)
|
||||
for slug in (
|
||||
"körper",
|
||||
"ernährung",
|
||||
"aktivität",
|
||||
"recovery",
|
||||
"vitalwerte",
|
||||
"mental",
|
||||
"lebensstil",
|
||||
):
|
||||
key_p = f"focus_cat_{slug}_progress"
|
||||
key_w = f"focus_cat_{slug}_weight"
|
||||
m_p = PlaceholderMetadata(
|
||||
key=key_p,
|
||||
category=CAT,
|
||||
description=f"Aggregierter Fortschritt Kategorie „{slug}“ (%)",
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="_safe_int",
|
||||
data_layer_module="backend/data_layer/scores.py",
|
||||
data_layer_function="calculate_category_progress",
|
||||
source_tables=["goals", "focus_area_definitions", "user_focus_area_weights"],
|
||||
semantic_contract=f"scores.calculate_category_progress(pid, '{slug}')",
|
||||
business_meaning="Focus-Area-Kategorie-Score",
|
||||
unit="%",
|
||||
time_window="aktuell",
|
||||
output_type=OutputType.NUMERIC,
|
||||
placeholder_type=PlaceholderType.SCORE,
|
||||
format_hint="Ganzzahl",
|
||||
example_output="55",
|
||||
minimum_data_requirements="Gewichtete Bereiche in Kategorie",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="scores.calculate_category_progress",
|
||||
missing_value_policy=MVP("insufficient_data", "nicht verfügbar"),
|
||||
known_limitations=None,
|
||||
layer_1_decision="scores.calculate_category_progress",
|
||||
layer_2a_decision="_safe_int",
|
||||
layer_2b_reuse_possible=True,
|
||||
architecture_alignment="Phase 0b",
|
||||
issue_53_alignment="Layer 1",
|
||||
evidence={},
|
||||
)
|
||||
tag_standard_evidence(m_p)
|
||||
register_placeholder(m_p)
|
||||
|
||||
m_w = PlaceholderMetadata(
|
||||
key=key_w,
|
||||
category=CAT,
|
||||
description=f"Nutzer-Gewichtung Kategorie „{slug}“ (Anteil 0–1)",
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="_safe_float",
|
||||
data_layer_module="backend/data_layer/scores.py",
|
||||
data_layer_function="calculate_category_weight",
|
||||
source_tables=["user_focus_area_weights", "focus_area_definitions"],
|
||||
semantic_contract=f"scores.calculate_category_weight(pid, '{slug}')",
|
||||
business_meaning="Kategorie-Gewichtung im Fokusmodell",
|
||||
unit="0–1",
|
||||
time_window="aktuell",
|
||||
output_type=OutputType.NUMERIC,
|
||||
placeholder_type=PlaceholderType.INTERPRETED,
|
||||
format_hint="Dezimal",
|
||||
example_output="0.25",
|
||||
minimum_data_requirements="user_focus_area_weights",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="scores.calculate_category_weight",
|
||||
missing_value_policy=MVP("insufficient_data", "nicht verfügbar"),
|
||||
known_limitations=None,
|
||||
layer_1_decision="scores.calculate_category_weight",
|
||||
layer_2a_decision="_safe_float",
|
||||
layer_2b_reuse_possible=False,
|
||||
architecture_alignment="Phase 0b",
|
||||
issue_53_alignment="Layer 1",
|
||||
evidence={},
|
||||
)
|
||||
tag_standard_evidence(m_w)
|
||||
register_placeholder(m_w)
|
||||
|
||||
# Strukturierte Ziele / Fokus
|
||||
for key, res_fn, dl_mod, dl_fn, desc, out, ptype in [
|
||||
(
|
||||
"active_goals_json",
|
||||
"_safe_json",
|
||||
"backend/goal_utils.py",
|
||||
"get_active_goals",
|
||||
"Aktive Ziele als JSON",
|
||||
OutputType.JSON,
|
||||
PlaceholderType.RAW_DATA,
|
||||
),
|
||||
(
|
||||
"active_goals_md",
|
||||
"_safe_str",
|
||||
"backend/placeholder_resolver.py",
|
||||
"_format_goals_as_markdown",
|
||||
"Aktive Ziele als Markdown-Tabelle",
|
||||
OutputType.TEXT_SUMMARY,
|
||||
PlaceholderType.INTERPRETED,
|
||||
),
|
||||
(
|
||||
"focus_areas_weighted_json",
|
||||
"_safe_json",
|
||||
"backend/placeholder_resolver.py",
|
||||
"_get_focus_areas_weighted_json",
|
||||
"Gewichtete Fokusbereiche mit Namen (JSON)",
|
||||
OutputType.JSON,
|
||||
PlaceholderType.RAW_DATA,
|
||||
),
|
||||
(
|
||||
"focus_areas_weighted_md",
|
||||
"_safe_str",
|
||||
"backend/placeholder_resolver.py",
|
||||
"_format_focus_areas_as_markdown",
|
||||
"Gewichtete Fokusbereiche als Markdown",
|
||||
OutputType.TEXT_SUMMARY,
|
||||
PlaceholderType.INTERPRETED,
|
||||
),
|
||||
(
|
||||
"focus_area_weights_json",
|
||||
"_safe_json",
|
||||
"backend/data_layer/scores.py",
|
||||
"get_user_focus_weights",
|
||||
"Rohe Gewichtungen key→Anteil (JSON)",
|
||||
OutputType.JSON,
|
||||
PlaceholderType.RAW_DATA,
|
||||
),
|
||||
]:
|
||||
m = PlaceholderMetadata(
|
||||
key=key,
|
||||
category=CAT,
|
||||
description=desc,
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function=res_fn,
|
||||
data_layer_module=dl_mod,
|
||||
data_layer_function=dl_fn,
|
||||
source_tables=["goals", "focus_area_definitions", "user_focus_area_weights"],
|
||||
semantic_contract=f"{dl_fn} (siehe Modul {dl_mod})",
|
||||
business_meaning="Strukturierte Übersicht für Prompts",
|
||||
unit="JSON" if out == OutputType.JSON else "markdown",
|
||||
time_window="aktuell",
|
||||
output_type=out,
|
||||
placeholder_type=ptype,
|
||||
format_hint="String aus Resolver",
|
||||
example_output="[]" if out == OutputType.JSON else "—",
|
||||
minimum_data_requirements="Ziele bzw. Fokusgewichte",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="Resolver + goal_utils / scores",
|
||||
missing_value_policy=MVP("insufficient_data", "[]" if out == OutputType.JSON else "nicht verfügbar"),
|
||||
known_limitations=None,
|
||||
layer_1_decision=dl_fn,
|
||||
layer_2a_decision=res_fn,
|
||||
layer_2b_reuse_possible=False,
|
||||
architecture_alignment="Phase 0b",
|
||||
issue_53_alignment="Layer 1",
|
||||
evidence={},
|
||||
)
|
||||
tag_standard_evidence(m)
|
||||
register_placeholder(m)
|
||||
|
||||
for key, res_fn, dl_fn, desc, ex in [
|
||||
(
|
||||
"top_3_focus_areas",
|
||||
"_safe_str",
|
||||
"_format_top_focus_areas",
|
||||
"Top-3 Fokusbereiche als formatierter Text",
|
||||
"1. Kraft …",
|
||||
),
|
||||
(
|
||||
"top_3_goals_behind_schedule",
|
||||
"_safe_str",
|
||||
"_format_goals_behind",
|
||||
"Bis zu drei Ziele hinter Zeitplan",
|
||||
"—",
|
||||
),
|
||||
(
|
||||
"top_3_goals_on_track",
|
||||
"_safe_str",
|
||||
"_format_goals_on_track",
|
||||
"Bis zu drei Ziele im Plan",
|
||||
"—",
|
||||
),
|
||||
]:
|
||||
m = PlaceholderMetadata(
|
||||
key=key,
|
||||
category=CAT,
|
||||
description=desc,
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function=res_fn,
|
||||
data_layer_module="backend/goal_utils.py",
|
||||
data_layer_function="get_active_goals",
|
||||
source_tables=["goals", "focus_area_definitions"],
|
||||
semantic_contract=f"Resolver {dl_fn}",
|
||||
business_meaning="Kurzlisten für Coaching-Prompts",
|
||||
unit="text",
|
||||
time_window="aktuell",
|
||||
output_type=OutputType.TEXT_SUMMARY,
|
||||
placeholder_type=PlaceholderType.INTERPRETED,
|
||||
format_hint="Freitext / Aufzählung",
|
||||
example_output=ex,
|
||||
minimum_data_requirements="Ziele / Fokusdaten",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic=dl_fn,
|
||||
missing_value_policy=MVP("insufficient_data", "nicht verfügbar"),
|
||||
known_limitations=None,
|
||||
layer_1_decision="goals + focus aggregation",
|
||||
layer_2a_decision=dl_fn,
|
||||
layer_2b_reuse_possible=False,
|
||||
architecture_alignment="Phase 0b",
|
||||
issue_53_alignment="Layer 2a",
|
||||
evidence={},
|
||||
)
|
||||
tag_standard_evidence(m)
|
||||
register_placeholder(m)
|
||||
|
||||
|
||||
register_phase_0b_ziele_fokus()
|
||||
139
backend/placeholder_registrations/profil_zeitraum.py
Normal file
139
backend/placeholder_registrations/profil_zeitraum.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"""
|
||||
Registry: Profil-Stammdaten und statische Zeitraum-Labels für Prompts.
|
||||
"""
|
||||
|
||||
from placeholder_registry import (
|
||||
PlaceholderMetadata,
|
||||
MissingValuePolicy,
|
||||
OutputType,
|
||||
PlaceholderType,
|
||||
register_placeholder,
|
||||
)
|
||||
from ._evidence import tag_standard_evidence
|
||||
|
||||
MVP = lambda reason, disp: MissingValuePolicy(
|
||||
available=False, value_raw=None, missing_reason=reason, legacy_display=disp
|
||||
)
|
||||
|
||||
|
||||
def register_profil_zeitraum():
|
||||
cat_profil = "Profil"
|
||||
for key, desc, res_fn, unit, ptype, out, hint, ex, sem in [
|
||||
(
|
||||
"name",
|
||||
"Anzeigename aus profiles.name",
|
||||
"get_profile_name",
|
||||
"text",
|
||||
PlaceholderType.ATOMIC,
|
||||
OutputType.STRING,
|
||||
"Kurzname",
|
||||
"Max",
|
||||
"profiles.name, Fallback „Nutzer“.",
|
||||
),
|
||||
(
|
||||
"age",
|
||||
"Alter in Jahren aus profiles.dob",
|
||||
"get_profile_age_display",
|
||||
"Jahre",
|
||||
PlaceholderType.ATOMIC,
|
||||
OutputType.STRING,
|
||||
"Ganzzahl oder unbekannt",
|
||||
"42",
|
||||
"Berechnung aus Geburtsdatum; PostgreSQL date oder ISO-String.",
|
||||
),
|
||||
(
|
||||
"height",
|
||||
"Körpergröße (cm) aus profiles.height",
|
||||
"get_profile_height_display",
|
||||
"cm",
|
||||
PlaceholderType.ATOMIC,
|
||||
OutputType.STRING,
|
||||
"Zahl oder unbekannt",
|
||||
"180",
|
||||
"profiles.height.",
|
||||
),
|
||||
(
|
||||
"geschlecht",
|
||||
"Geschlecht (männlich/weiblich) aus profiles.sex",
|
||||
"get_profile_geschlecht_display",
|
||||
"Kategorie",
|
||||
PlaceholderType.ATOMIC,
|
||||
OutputType.STRING,
|
||||
"m/w-Mapping",
|
||||
"männlich",
|
||||
"sex == 'm' → männlich, sonst weiblich.",
|
||||
),
|
||||
]:
|
||||
m = PlaceholderMetadata(
|
||||
key=key,
|
||||
category=cat_profil,
|
||||
description=desc,
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function=res_fn,
|
||||
data_layer_module=None,
|
||||
data_layer_function=None,
|
||||
source_tables=["profiles"],
|
||||
semantic_contract=sem,
|
||||
business_meaning="Profil-Kontext für KI-Prompts",
|
||||
unit=unit,
|
||||
time_window="latest profile row",
|
||||
output_type=out,
|
||||
placeholder_type=ptype,
|
||||
format_hint=hint,
|
||||
example_output=ex,
|
||||
minimum_data_requirements="Profilzeile",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="Row vorhanden",
|
||||
missing_value_policy=MVP("no_data", "unbekannt" if key != "name" else "Nutzer"),
|
||||
known_limitations="Keine diversen Geschlechtsoptionen im Platzhalter",
|
||||
layer_1_decision="profiles",
|
||||
layer_2a_decision=res_fn,
|
||||
layer_2b_reuse_possible=False,
|
||||
architecture_alignment="Phase 0b",
|
||||
issue_53_alignment="Resolver",
|
||||
evidence={},
|
||||
)
|
||||
tag_standard_evidence(m)
|
||||
register_placeholder(m)
|
||||
|
||||
cat_zeit = "Zeitraum"
|
||||
for key, desc, res_fn, sem, ex_out in [
|
||||
("datum_heute", "Heutiges Datum (lokal)", "get_datum_heute", "datetime.now, Format dd.mm.yyyy", "11.04.2026"),
|
||||
("zeitraum_7d", "Label „letzte 7 Tage“", "get_zeitraum_label_7d", "Statisches UI/Prompt-Label", "letzte 7 Tage"),
|
||||
("zeitraum_30d", "Label „letzte 30 Tage“", "get_zeitraum_label_30d", "Statisches UI/Prompt-Label", "letzte 30 Tage"),
|
||||
("zeitraum_90d", "Label „letzte 90 Tage“", "get_zeitraum_label_90d", "Statisches UI/Prompt-Label", "letzte 90 Tage"),
|
||||
]:
|
||||
m = PlaceholderMetadata(
|
||||
key=key,
|
||||
category=cat_zeit,
|
||||
description=desc,
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function=res_fn,
|
||||
data_layer_module=None,
|
||||
data_layer_function=None,
|
||||
source_tables=[],
|
||||
semantic_contract=sem,
|
||||
business_meaning="Zeitlicher Bezug im Prompt ohne Datenabfrage",
|
||||
unit="label",
|
||||
time_window="n/a",
|
||||
output_type=OutputType.STRING,
|
||||
placeholder_type=PlaceholderType.META,
|
||||
format_hint="Kurzdeutsch",
|
||||
example_output=ex_out,
|
||||
minimum_data_requirements=None,
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="Immer verfügbar",
|
||||
missing_value_policy=None,
|
||||
known_limitations="Kein kalender-basierter Datenfilter allein durch Platzhalter",
|
||||
layer_1_decision="n/a",
|
||||
layer_2a_decision=res_fn,
|
||||
layer_2b_reuse_possible=False,
|
||||
architecture_alignment="Phase 0b",
|
||||
issue_53_alignment="Resolver",
|
||||
evidence={},
|
||||
)
|
||||
tag_standard_evidence(m)
|
||||
register_placeholder(m)
|
||||
|
||||
|
||||
register_profil_zeitraum()
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
"""
|
||||
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()
|
||||
236
backend/placeholder_registrations/schlaf_erholung.py
Normal file
236
backend/placeholder_registrations/schlaf_erholung.py
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
"""
|
||||
Registry: Schlaf, Ruhetage, Recovery-Score, Schlaf-Metriken, Schlaf-Erholungs-Korrelation.
|
||||
"""
|
||||
|
||||
from placeholder_registry import (
|
||||
PlaceholderMetadata,
|
||||
MissingValuePolicy,
|
||||
EvidenceType,
|
||||
OutputType,
|
||||
PlaceholderType,
|
||||
register_placeholder,
|
||||
)
|
||||
|
||||
CAT = "Schlaf & Erholung"
|
||||
MVP = lambda reason, disp: MissingValuePolicy(
|
||||
available=False, value_raw=None, missing_reason=reason, legacy_display=disp
|
||||
)
|
||||
|
||||
|
||||
def _tag(m: PlaceholderMetadata):
|
||||
for f in (
|
||||
"key", "category", "description", "resolver_module", "resolver_function",
|
||||
"data_layer_module", "data_layer_function", "source_tables", "semantic_contract",
|
||||
"unit", "time_window", "output_type", "placeholder_type", "format_hint",
|
||||
"example_output", "minimum_data_requirements", "confidence_logic",
|
||||
"missing_value_policy", "layer_1_decision", "layer_2a_decision",
|
||||
"layer_2b_reuse_possible", "architecture_alignment", "issue_53_alignment",
|
||||
):
|
||||
m.set_evidence(f, EvidenceType.CODE_DERIVED)
|
||||
m.set_evidence("business_meaning", EvidenceType.DRAFT_DERIVED)
|
||||
m.set_evidence("known_limitations", EvidenceType.MIXED)
|
||||
|
||||
|
||||
def register_schlaf_erholung():
|
||||
# ── formatierte Schlaf-/Ruhetage-Snapshots ───────────────────────────────
|
||||
m = PlaceholderMetadata(
|
||||
key="sleep_avg_duration",
|
||||
category=CAT,
|
||||
description="Durchschnittliche Schlafdauer (Stunden), formatiert",
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="get_sleep_avg_duration",
|
||||
data_layer_module="backend/data_layer/recovery_metrics.py",
|
||||
data_layer_function="get_sleep_duration_data",
|
||||
source_tables=["sleep_log"],
|
||||
semantic_contract="Mittel aus Schlafphasen im Fenster (siehe get_sleep_duration_data).",
|
||||
business_meaning="KI-Kontext Schlafdauer",
|
||||
unit="h (Anzeige mit Einheit)",
|
||||
time_window="7d default im Resolver",
|
||||
output_type=OutputType.STRING,
|
||||
placeholder_type=PlaceholderType.INTERPRETED,
|
||||
format_hint="z. B. 7.2h",
|
||||
example_output="7.2h",
|
||||
minimum_data_requirements="sleep_log im Fenster",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="data['confidence'] im Layer1",
|
||||
missing_value_policy=MVP("no_data", "nicht verfügbar"),
|
||||
known_limitations="Abhängig von Import/Qualität der Phasen",
|
||||
layer_1_decision="recovery_metrics.get_sleep_duration_data",
|
||||
layer_2a_decision="get_sleep_avg_duration",
|
||||
layer_2b_reuse_possible=True,
|
||||
architecture_alignment="Phase 0c",
|
||||
issue_53_alignment="Layer 1",
|
||||
evidence={},
|
||||
)
|
||||
_tag(m)
|
||||
register_placeholder(m)
|
||||
|
||||
m = PlaceholderMetadata(
|
||||
key="sleep_avg_quality",
|
||||
category=CAT,
|
||||
description="Schlafqualität (Deep+REM %), formatiert",
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="get_sleep_avg_quality",
|
||||
data_layer_module="backend/data_layer/recovery_metrics.py",
|
||||
data_layer_function="get_sleep_quality_data",
|
||||
source_tables=["sleep_log"],
|
||||
semantic_contract="Anteil Deep+REM aus Segmenten (siehe get_sleep_quality_data).",
|
||||
business_meaning="KI-Kontext Schlafqualität",
|
||||
unit="%",
|
||||
time_window="7d default",
|
||||
output_type=OutputType.STRING,
|
||||
placeholder_type=PlaceholderType.INTERPRETED,
|
||||
format_hint="Prozent oder nicht verfügbar",
|
||||
example_output="24%",
|
||||
minimum_data_requirements="sleep_log mit Phasen",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="Layer-1-Confidence",
|
||||
missing_value_policy=MVP("no_data", "nicht verfügbar"),
|
||||
known_limitations="Segment-Schreibweise case-sensitiv normalisiert",
|
||||
layer_1_decision="recovery_metrics.get_sleep_quality_data",
|
||||
layer_2a_decision="get_sleep_avg_quality",
|
||||
layer_2b_reuse_possible=True,
|
||||
architecture_alignment="Phase 0c",
|
||||
issue_53_alignment="Layer 1",
|
||||
evidence={},
|
||||
)
|
||||
_tag(m)
|
||||
register_placeholder(m)
|
||||
|
||||
m = PlaceholderMetadata(
|
||||
key="rest_days_count",
|
||||
category=CAT,
|
||||
description="Anzahl dokumentierter Ruhetage (30d default)",
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="get_rest_days_count",
|
||||
data_layer_module="backend/data_layer/recovery_metrics.py",
|
||||
data_layer_function="get_rest_days_data",
|
||||
source_tables=["rest_days"],
|
||||
semantic_contract="Count rest_days im Zeitraum",
|
||||
business_meaning="Aktive/passive Erholungstags-Übersicht",
|
||||
unit="count",
|
||||
time_window="30d default",
|
||||
output_type=OutputType.STRING,
|
||||
placeholder_type=PlaceholderType.RAW_DATA,
|
||||
format_hint="z. B. 2 Ruhetage",
|
||||
example_output="2 Ruhetage",
|
||||
minimum_data_requirements="rest_days",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="Immer Zählung, 0 möglich",
|
||||
missing_value_policy=MVP("no_data", "0 Ruhetage"),
|
||||
known_limitations="Nur explizit erfasste Ruhetage",
|
||||
layer_1_decision="recovery_metrics.get_rest_days_data",
|
||||
layer_2a_decision="get_rest_days_count",
|
||||
layer_2b_reuse_possible=True,
|
||||
architecture_alignment="Phase 0c",
|
||||
issue_53_alignment="Layer 1",
|
||||
evidence={},
|
||||
)
|
||||
_tag(m)
|
||||
register_placeholder(m)
|
||||
|
||||
m = PlaceholderMetadata(
|
||||
key="recovery_score",
|
||||
category=CAT,
|
||||
description="Recovery-Score 0–100 (v2, komposit)",
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="_safe_int",
|
||||
data_layer_module="backend/data_layer/recovery_metrics.py",
|
||||
data_layer_function="calculate_recovery_score_v2",
|
||||
source_tables=["sleep_log", "vitals_baseline", "activity_log"],
|
||||
semantic_contract="Gewichteter Score aus Schlaf, Vitaltrends, optional Load (siehe Implementierung).",
|
||||
business_meaning="Gesamt-Recovery-KPI für Prompts",
|
||||
unit="0–100",
|
||||
time_window="composite",
|
||||
output_type=OutputType.NUMERIC,
|
||||
placeholder_type=PlaceholderType.SCORE,
|
||||
format_hint="Ganzzahl-String",
|
||||
example_output="72",
|
||||
minimum_data_requirements="Teilkomponenten je nach Gewichtung",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="Wie calculate_recovery_score_v2",
|
||||
missing_value_policy=MVP("insufficient_data", "nicht verfügbar"),
|
||||
known_limitations="Abhängig von Datenabdeckung HF/HRV/Schlaf",
|
||||
layer_1_decision="recovery_metrics.calculate_recovery_score_v2",
|
||||
layer_2a_decision="_safe_int('recovery_score_v2')",
|
||||
layer_2b_reuse_possible=True,
|
||||
architecture_alignment="Phase 0c",
|
||||
issue_53_alignment="Layer 1",
|
||||
evidence={},
|
||||
)
|
||||
_tag(m)
|
||||
register_placeholder(m)
|
||||
|
||||
for key, dl_fn, desc, unit, tbls, res_fn in [
|
||||
("sleep_avg_duration_7d", "calculate_sleep_avg_duration_7d", "Durchschnittliche Schlafdauer 7d (h)", "h", ["sleep_log"], "_safe_float"),
|
||||
("sleep_debt_hours", "calculate_sleep_debt_hours", "Kumulative Schlafschuld (h)", "h", ["sleep_log"], "_safe_float"),
|
||||
("sleep_regularity_proxy", "calculate_sleep_regularity_proxy", "Schlaf-Regularität (Proxy)", "min", ["sleep_log"], "_safe_float"),
|
||||
("recent_load_balance_3d", "calculate_recent_load_balance_3d", "Load-Balance 3d (Score)", "score", ["activity_log"], "_safe_int"),
|
||||
("sleep_quality_7d", "calculate_sleep_quality_7d", "Schlafqualität 7d (0–100)", "0-100", ["sleep_log"], "_safe_int"),
|
||||
]:
|
||||
m = PlaceholderMetadata(
|
||||
key=key,
|
||||
category=CAT,
|
||||
description=desc,
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function=res_fn,
|
||||
data_layer_module="backend/data_layer/recovery_metrics.py",
|
||||
data_layer_function=dl_fn,
|
||||
source_tables=tbls,
|
||||
semantic_contract=f"Berechnung {dl_fn} in recovery_metrics.",
|
||||
business_meaning="Erholungs-Detailmetrik",
|
||||
unit=unit,
|
||||
time_window="siehe Funktion",
|
||||
output_type=OutputType.NUMERIC,
|
||||
placeholder_type=PlaceholderType.INTERPRETED,
|
||||
format_hint="numerischer String",
|
||||
example_output="1.0",
|
||||
minimum_data_requirements="wie Funktion",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="Funktionsintern",
|
||||
missing_value_policy=MVP("insufficient_data", "nicht verfügbar"),
|
||||
known_limitations=None,
|
||||
layer_1_decision=f"recovery_metrics.{dl_fn}",
|
||||
layer_2a_decision="Resolver _safe_float/_safe_int",
|
||||
layer_2b_reuse_possible=True,
|
||||
architecture_alignment="Phase 0c",
|
||||
issue_53_alignment="Layer 1",
|
||||
evidence={},
|
||||
)
|
||||
_tag(m)
|
||||
register_placeholder(m)
|
||||
|
||||
m = PlaceholderMetadata(
|
||||
key="correlation_sleep_recovery",
|
||||
category=CAT,
|
||||
description="JSON: Korrelation Schlaf ↔ Recovery-Indikatoren",
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="_safe_json",
|
||||
data_layer_module="backend/data_layer/correlations.py",
|
||||
data_layer_function="calculate_correlation_sleep_recovery",
|
||||
source_tables=["sleep_log", "vitals_baseline", "activity_log"],
|
||||
semantic_contract="Strukturierte Korrelationsauswertung (siehe correlations).",
|
||||
business_meaning="KI: Zusammenhänge Schlaf und Erholung",
|
||||
unit="JSON",
|
||||
time_window="funktionsabhängig",
|
||||
output_type=OutputType.JSON,
|
||||
placeholder_type=PlaceholderType.RAW_DATA,
|
||||
format_hint="JSON-String",
|
||||
example_output="{}",
|
||||
minimum_data_requirements="Ausreichend gekoppelte Datenpunkte",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="Wie correlation_metrics",
|
||||
missing_value_policy=MVP("insufficient_data", "{}"),
|
||||
known_limitations="Bei wenig Daten leer oder schwach",
|
||||
layer_1_decision="correlations.calculate_correlation_sleep_recovery",
|
||||
layer_2a_decision="_safe_json",
|
||||
layer_2b_reuse_possible=True,
|
||||
architecture_alignment="Phase 0c",
|
||||
issue_53_alignment="Layer 1",
|
||||
evidence={},
|
||||
)
|
||||
_tag(m)
|
||||
register_placeholder(m)
|
||||
|
||||
|
||||
register_schlaf_erholung()
|
||||
180
backend/placeholder_registrations/vitalwerte.py
Normal file
180
backend/placeholder_registrations/vitalwerte.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
"""
|
||||
Registry: Baseline-Vitals (Ruhepuls, HRV, VO2 Max) und Abweichung vs. persönlicher Baseline.
|
||||
"""
|
||||
|
||||
from placeholder_registry import (
|
||||
PlaceholderMetadata,
|
||||
MissingValuePolicy,
|
||||
EvidenceType,
|
||||
OutputType,
|
||||
PlaceholderType,
|
||||
register_placeholder,
|
||||
)
|
||||
|
||||
CAT = "Vitalwerte"
|
||||
MVP = lambda reason, disp: MissingValuePolicy(
|
||||
available=False, value_raw=None, missing_reason=reason, legacy_display=disp
|
||||
)
|
||||
|
||||
|
||||
def _tag(m: PlaceholderMetadata):
|
||||
for f in (
|
||||
"key", "category", "description", "resolver_module", "resolver_function",
|
||||
"data_layer_module", "data_layer_function", "source_tables", "semantic_contract",
|
||||
"unit", "time_window", "output_type", "placeholder_type", "format_hint",
|
||||
"example_output", "minimum_data_requirements", "confidence_logic",
|
||||
"missing_value_policy", "layer_1_decision", "layer_2a_decision",
|
||||
"layer_2b_reuse_possible", "architecture_alignment", "issue_53_alignment",
|
||||
):
|
||||
m.set_evidence(f, EvidenceType.CODE_DERIVED)
|
||||
m.set_evidence("business_meaning", EvidenceType.DRAFT_DERIVED)
|
||||
m.set_evidence("known_limitations", EvidenceType.MIXED)
|
||||
|
||||
|
||||
def register_vitalwerte():
|
||||
m = PlaceholderMetadata(
|
||||
key="vitals_avg_hr",
|
||||
category=CAT,
|
||||
description="Durchschnittlicher Ruhepuls (7d), formatiert",
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="get_vitals_avg_hr",
|
||||
data_layer_module="backend/data_layer/health_metrics.py",
|
||||
data_layer_function="get_resting_heart_rate_data",
|
||||
source_tables=["vitals_baseline"],
|
||||
semantic_contract="Mittel RHR aus vitals_baseline im Fenster (siehe health_metrics).",
|
||||
business_meaning="KI-Kontext kardiovaskuläre Ruhelage",
|
||||
unit="bpm (Anzeige mit Einheit)",
|
||||
time_window="7d default im Resolver",
|
||||
output_type=OutputType.STRING,
|
||||
placeholder_type=PlaceholderType.INTERPRETED,
|
||||
format_hint="z. B. 58 bpm",
|
||||
example_output="58 bpm",
|
||||
minimum_data_requirements="vitals_baseline im Fenster",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="data['confidence'] im Layer1",
|
||||
missing_value_policy=MVP("no_data", "nicht verfügbar"),
|
||||
known_limitations="Nur erfasste Morgen-Baseline-Messungen",
|
||||
layer_1_decision="health_metrics.get_resting_heart_rate_data",
|
||||
layer_2a_decision="get_vitals_avg_hr",
|
||||
layer_2b_reuse_possible=True,
|
||||
architecture_alignment="Phase 0c",
|
||||
issue_53_alignment="Layer 1",
|
||||
evidence={},
|
||||
)
|
||||
_tag(m)
|
||||
register_placeholder(m)
|
||||
|
||||
m = PlaceholderMetadata(
|
||||
key="vitals_avg_hrv",
|
||||
category=CAT,
|
||||
description="Durchschnittliche HRV (7d), formatiert",
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="get_vitals_avg_hrv",
|
||||
data_layer_module="backend/data_layer/health_metrics.py",
|
||||
data_layer_function="get_heart_rate_variability_data",
|
||||
source_tables=["vitals_baseline"],
|
||||
semantic_contract="Mittel HRV aus vitals_baseline im Fenster.",
|
||||
business_meaning="KI-Kontext autonome Regulation / Erholung",
|
||||
unit="ms (Anzeige mit Einheit)",
|
||||
time_window="7d default",
|
||||
output_type=OutputType.STRING,
|
||||
placeholder_type=PlaceholderType.INTERPRETED,
|
||||
format_hint="z. B. 45 ms",
|
||||
example_output="45 ms",
|
||||
minimum_data_requirements="vitals_baseline mit HRV",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="data['confidence'] im Layer1",
|
||||
missing_value_policy=MVP("no_data", "nicht verfügbar"),
|
||||
known_limitations="Geräte-/Messprotokoll kann streuen",
|
||||
layer_1_decision="health_metrics.get_heart_rate_variability_data",
|
||||
layer_2a_decision="get_vitals_avg_hrv",
|
||||
layer_2b_reuse_possible=True,
|
||||
architecture_alignment="Phase 0c",
|
||||
issue_53_alignment="Layer 1",
|
||||
evidence={},
|
||||
)
|
||||
_tag(m)
|
||||
register_placeholder(m)
|
||||
|
||||
m = PlaceholderMetadata(
|
||||
key="vitals_vo2_max",
|
||||
category=CAT,
|
||||
description="Aktueller VO2 Max (letzte Messung), formatiert",
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="get_vitals_vo2_max",
|
||||
data_layer_module="backend/data_layer/health_metrics.py",
|
||||
data_layer_function="get_vo2_max_data",
|
||||
source_tables=["vitals_baseline"],
|
||||
semantic_contract="Jüngster vo2_max aus vitals_baseline.",
|
||||
business_meaning="Ausdauer-/Fitness-Kontext",
|
||||
unit="ml/kg/min",
|
||||
time_window="latest",
|
||||
output_type=OutputType.STRING,
|
||||
placeholder_type=PlaceholderType.INTERPRETED,
|
||||
format_hint="eine Dezimalstelle + Einheit",
|
||||
example_output="42.0 ml/kg/min",
|
||||
minimum_data_requirements="mindestens eine VO2-Messung",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="data['confidence'] im Layer1",
|
||||
missing_value_policy=MVP("no_data", "nicht verfügbar"),
|
||||
known_limitations="Schätzung vs. Labortest je nach Quelle",
|
||||
layer_1_decision="health_metrics.get_vo2_max_data",
|
||||
layer_2a_decision="get_vitals_vo2_max",
|
||||
layer_2b_reuse_possible=True,
|
||||
architecture_alignment="Phase 0c",
|
||||
issue_53_alignment="Layer 1",
|
||||
evidence={},
|
||||
)
|
||||
_tag(m)
|
||||
register_placeholder(m)
|
||||
|
||||
for key, dl_fn, desc, unit, res_fn in [
|
||||
(
|
||||
"hrv_vs_baseline_pct",
|
||||
"calculate_hrv_vs_baseline_pct",
|
||||
"HRV vs. persönlicher Baseline (%)",
|
||||
"%",
|
||||
"_safe_float",
|
||||
),
|
||||
(
|
||||
"rhr_vs_baseline_pct",
|
||||
"calculate_rhr_vs_baseline_pct",
|
||||
"Ruhepuls vs. persönlicher Baseline (%)",
|
||||
"%",
|
||||
"_safe_float",
|
||||
),
|
||||
]:
|
||||
m = PlaceholderMetadata(
|
||||
key=key,
|
||||
category=CAT,
|
||||
description=desc,
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function=res_fn,
|
||||
data_layer_module="backend/data_layer/recovery_metrics.py",
|
||||
data_layer_function=dl_fn,
|
||||
source_tables=["vitals_baseline"],
|
||||
semantic_contract=f"Vergleich aktueller Wert zu Baseline (siehe {dl_fn}).",
|
||||
business_meaning="Erholungs- und Belastungsindikator relativ zur Norm des Nutzers",
|
||||
unit=unit,
|
||||
time_window="funktionsintern",
|
||||
output_type=OutputType.NUMERIC,
|
||||
placeholder_type=PlaceholderType.INTERPRETED,
|
||||
format_hint="numerischer Prozent-String",
|
||||
example_output="5.2",
|
||||
minimum_data_requirements="Ausreichend Baseline-Historie",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="Funktionsintern",
|
||||
missing_value_policy=MVP("insufficient_data", "nicht verfügbar"),
|
||||
known_limitations="Baseline braucht ausreichend Vorlauf",
|
||||
layer_1_decision=f"recovery_metrics.{dl_fn}",
|
||||
layer_2a_decision=f"Resolver {res_fn}",
|
||||
layer_2b_reuse_possible=True,
|
||||
architecture_alignment="Phase 0c",
|
||||
issue_53_alignment="Layer 1",
|
||||
evidence={},
|
||||
)
|
||||
_tag(m)
|
||||
register_placeholder(m)
|
||||
|
||||
|
||||
register_vitalwerte()
|
||||
|
|
@ -258,6 +258,42 @@ class PlaceholderRegistry:
|
|||
return metadata._resolver_func(profile_id)
|
||||
|
||||
|
||||
def build_ai_placeholder_caption(metadata: PlaceholderMetadata, max_len: int = 400) -> str:
|
||||
"""
|
||||
Kurzerklärung / Einordnung für {{key|x}} und Exportfeld ``ai_caption`` (ohne Wert, ohne Einheit).
|
||||
|
||||
Inhalt: business_meaning oder gekürzter semantic_contract; bei SCORE-Zeilen die 0–100-Skala.
|
||||
Nicht enthalten: description (die nur bei {{key|d}} angehängt wird) und keine „Technischer Bezug: …“-Zeile.
|
||||
"""
|
||||
desc = (metadata.description or "").strip()
|
||||
bm = (metadata.business_meaning or "").strip()
|
||||
sc = (metadata.semantic_contract or "").strip()
|
||||
|
||||
chunks: List[str] = []
|
||||
|
||||
interpret = bm
|
||||
if not interpret and sc:
|
||||
interpret = sc if len(sc) <= max_len else sc[: max_len - 1] + "…"
|
||||
|
||||
if interpret:
|
||||
il = interpret.lower()
|
||||
redundant = bool(
|
||||
desc
|
||||
and len(desc) >= 10
|
||||
and desc.lower() in il
|
||||
)
|
||||
if not redundant:
|
||||
chunks.append(interpret)
|
||||
|
||||
if metadata.placeholder_type == PlaceholderType.SCORE:
|
||||
chunks.append("Skala 0–100: höher = im Modell günstiger / besser abgestimmt.")
|
||||
|
||||
out = " ".join(c for c in chunks if c).strip()
|
||||
if len(out) > max_len + 120:
|
||||
out = out[: max_len + 60] + "…"
|
||||
return out
|
||||
|
||||
|
||||
# Global registry instance
|
||||
_global_registry = PlaceholderRegistry()
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -12,14 +12,17 @@ import re
|
|||
from typing import Dict, Any, Optional
|
||||
from db import get_db, get_cursor, r2d
|
||||
from fastapi import HTTPException
|
||||
from placeholder_resolver import get_catalog_row_for_key
|
||||
|
||||
|
||||
def resolve_placeholders(template: str, variables: Dict[str, Any], debug_info: Optional[Dict] = None, catalog: Optional[Dict] = None) -> str:
|
||||
"""
|
||||
Replace {{placeholder}} with values from variables dict.
|
||||
|
||||
Supports modifiers:
|
||||
- {{key|d}} - Include description in parentheses (requires catalog)
|
||||
Modifiers (Katalog aus get_placeholder_catalog empfohlen):
|
||||
- {{key|d}} — Wert — description (kurz)
|
||||
- {{key|x}} — nur Erklärung (Katalogfeld ai_caption), ohne Zahlenwert
|
||||
- {{key|d,x}} — Wert — description — Erklärung
|
||||
|
||||
Args:
|
||||
template: String with {{key}} or {{key|modifiers}} placeholders
|
||||
|
|
@ -40,46 +43,66 @@ def resolve_placeholders(template: str, variables: Dict[str, Any], debug_info: O
|
|||
parts = full_placeholder.split('|')
|
||||
key = parts[0].strip()
|
||||
modifiers = parts[1].strip() if len(parts) > 1 else ''
|
||||
mods = {x.strip().lower() for x in modifiers.split(",") if x.strip()}
|
||||
want_d = "d" in mods
|
||||
want_x = "x" in mods
|
||||
|
||||
if key in variables:
|
||||
value = variables[key]
|
||||
# Convert dict/list to JSON string
|
||||
if isinstance(value, (dict, list)):
|
||||
resolved_value = json.dumps(value, ensure_ascii=False)
|
||||
else:
|
||||
resolved_value = str(value)
|
||||
|
||||
# Apply modifiers
|
||||
if 'd' in modifiers:
|
||||
if catalog:
|
||||
# Add description from catalog
|
||||
description = None
|
||||
for cat_items in catalog.values():
|
||||
matching = [item for item in cat_items if item['key'] == key]
|
||||
if matching:
|
||||
description = matching[0].get('description', '')
|
||||
break
|
||||
|
||||
if description:
|
||||
resolved_value = f"{resolved_value} ({description})"
|
||||
else:
|
||||
# Catalog not available - log warning in debug
|
||||
if debug_info is not None:
|
||||
if 'warnings' not in debug_info:
|
||||
debug_info['warnings'] = []
|
||||
debug_info['warnings'].append(f"Modifier |d used but catalog not available for {key}")
|
||||
|
||||
# Track resolution for debug
|
||||
def _warn(msg: str):
|
||||
if debug_info is not None:
|
||||
resolved[key] = resolved_value[:100] + ('...' if len(resolved_value) > 100 else '')
|
||||
debug_info.setdefault("warnings", []).append(msg)
|
||||
|
||||
return resolved_value
|
||||
else:
|
||||
# Keep placeholder if no value found
|
||||
row = get_catalog_row_for_key(catalog, key) if catalog else None
|
||||
|
||||
if want_x and not want_d:
|
||||
if key not in variables:
|
||||
if debug_info is not None:
|
||||
unresolved.append(key)
|
||||
return match.group(0)
|
||||
expl = (row.get("ai_caption") or "").strip() if row else ""
|
||||
if not expl and catalog is None:
|
||||
_warn(f"Modifier |x für {key}: Katalog fehlt (ai_caption).")
|
||||
out = expl
|
||||
if debug_info is not None:
|
||||
resolved[key] = out[:100] + ("..." if len(out) > 100 else "")
|
||||
return out
|
||||
|
||||
if key not in variables:
|
||||
if debug_info is not None:
|
||||
unresolved.append(key)
|
||||
return match.group(0)
|
||||
|
||||
value = variables[key]
|
||||
if isinstance(value, (dict, list)):
|
||||
resolved_value = json.dumps(value, ensure_ascii=False)
|
||||
else:
|
||||
resolved_value = str(value)
|
||||
|
||||
if not want_d and not want_x:
|
||||
out = resolved_value
|
||||
if debug_info is not None:
|
||||
resolved[key] = out[:100] + ("..." if len(out) > 100 else "")
|
||||
return out
|
||||
|
||||
parts = [resolved_value]
|
||||
if want_d:
|
||||
if row:
|
||||
desc = (row.get("description") or "").strip()
|
||||
if desc:
|
||||
parts.append(desc)
|
||||
else:
|
||||
_warn(f"Modifier |d für {key}: Katalog fehlt (description).")
|
||||
if want_x:
|
||||
expl = (row.get("ai_caption") or "").strip() if row else ""
|
||||
if expl:
|
||||
parts.append(expl)
|
||||
elif catalog is not None:
|
||||
_warn(f"Modifier |x (mit |d) für {key}: ai_caption leer.")
|
||||
|
||||
out = " — ".join(parts)
|
||||
if debug_info is not None:
|
||||
resolved[key] = out[:100] + ("..." if len(out) > 100 else "")
|
||||
return out
|
||||
|
||||
result = re.sub(r'\{\{([^}]+)\}\}', replacer, template)
|
||||
|
||||
# Store debug info
|
||||
|
|
@ -144,7 +167,8 @@ async def execute_prompt(
|
|||
prompt_slug: str,
|
||||
variables: Dict[str, Any],
|
||||
openrouter_call_func,
|
||||
enable_debug: bool = False
|
||||
enable_debug: bool = False,
|
||||
progress_callback = None # NEW: Optional callback für SSE Progress-Updates
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute a single prompt (base or pipeline type).
|
||||
|
|
@ -194,7 +218,7 @@ async def execute_prompt(
|
|||
|
||||
elif prompt_type == 'workflow':
|
||||
# Workflow prompt: graph-based execution (Phase 0: Foundation)
|
||||
return await execute_workflow_prompt(prompt, variables, openrouter_call_func, enable_debug, catalog)
|
||||
return await execute_workflow_prompt(prompt, variables, openrouter_call_func, enable_debug, catalog, progress_callback)
|
||||
|
||||
else:
|
||||
raise HTTPException(400, f"Unknown prompt type: {prompt_type}")
|
||||
|
|
@ -255,7 +279,11 @@ async def execute_base_prompt(
|
|||
|
||||
if enable_debug:
|
||||
debug_info['template'] = template
|
||||
debug_info['final_prompt'] = prompt_text[:500] + ('...' if len(prompt_text) > 500 else '')
|
||||
# Volltext für Test-UI (Admin); sehr große Prompts nur weich begrenzen
|
||||
_max = 512 * 1024
|
||||
debug_info['final_prompt'] = (
|
||||
prompt_text if len(prompt_text) <= _max else prompt_text[:_max] + "\n… [gekürzt, >512KB]"
|
||||
)
|
||||
debug_info['available_variables'] = list(variables.keys())
|
||||
|
||||
# Call AI
|
||||
|
|
@ -374,7 +402,10 @@ async def execute_pipeline_prompt(
|
|||
if enable_debug:
|
||||
prompt_debug['source'] = 'inline'
|
||||
prompt_debug['template'] = template
|
||||
prompt_debug['final_prompt'] = prompt_text[:500] + ('...' if len(prompt_text) > 500 else '')
|
||||
_max = 512 * 1024
|
||||
prompt_debug['final_prompt'] = (
|
||||
prompt_text if len(prompt_text) <= _max else prompt_text[:_max] + "\n… [gekürzt, >512KB]"
|
||||
)
|
||||
prompt_debug.update(placeholder_debug)
|
||||
|
||||
response = await openrouter_call_func(prompt_text)
|
||||
|
|
@ -439,7 +470,8 @@ async def execute_prompt_with_data(
|
|||
modules: Optional[Dict[str, bool]] = None,
|
||||
timeframes: Optional[Dict[str, int]] = None,
|
||||
openrouter_call_func = None,
|
||||
enable_debug: bool = False
|
||||
enable_debug: bool = False,
|
||||
progress_callback = None # NEW: Optional callback für SSE Progress-Updates
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute prompt with data loaded from database.
|
||||
|
|
@ -464,7 +496,7 @@ async def execute_prompt_with_data(
|
|||
'today': datetime.now().strftime('%Y-%m-%d')
|
||||
}
|
||||
|
||||
# Load placeholder catalog for |d modifier support
|
||||
# Load placeholder catalog for |d / |x Modifier
|
||||
try:
|
||||
catalog = get_placeholder_catalog(profile_id)
|
||||
except Exception as e:
|
||||
|
|
@ -575,7 +607,7 @@ async def execute_prompt_with_data(
|
|||
variables['goals_data'] = []
|
||||
|
||||
# Execute prompt
|
||||
return await execute_prompt(prompt_slug, variables, openrouter_call_func, enable_debug)
|
||||
return await execute_prompt(prompt_slug, variables, openrouter_call_func, enable_debug, progress_callback)
|
||||
|
||||
|
||||
async def execute_workflow_prompt(
|
||||
|
|
@ -583,7 +615,8 @@ async def execute_workflow_prompt(
|
|||
variables: Dict[str, Any],
|
||||
openrouter_call_func,
|
||||
enable_debug: bool = False,
|
||||
catalog: Optional[Dict] = None
|
||||
catalog: Optional[Dict] = None,
|
||||
progress_callback = None # NEW: Optional callback für SSE Progress-Updates
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute a workflow-type prompt (graph-based execution).
|
||||
|
|
@ -622,7 +655,8 @@ async def execute_workflow_prompt(
|
|||
profile_id=variables.get('profile_id', 'unknown'), # From context
|
||||
variables=variables,
|
||||
openrouter_call_func=openrouter_call_func,
|
||||
enable_debug=enable_debug
|
||||
enable_debug=enable_debug,
|
||||
progress_callback=progress_callback # NEW: Progress-Callbacks durchreichen
|
||||
)
|
||||
|
||||
# Convert ExecutionResult to dict for API response
|
||||
|
|
|
|||
139
backend/report_chart_fetch.py
Normal file
139
backend/report_chart_fetch.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"""
|
||||
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,
|
||||
},
|
||||
]
|
||||
91
backend/report_chart_plotting.py
Normal file
91
backend/report_chart_plotting.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"""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()
|
||||
130
backend/report_pdf_render.py
Normal file
130
backend/report_pdf_render.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""
|
||||
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()
|
||||
126
backend/report_profile_schema.py
Normal file
126
backend/report_profile_schema.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""
|
||||
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)
|
||||
|
||||
386
backend/report_viz_bundle_pdf.py
Normal file
386
backend/report_viz_bundle_pdf.py
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
"""
|
||||
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,3 +9,6 @@ bcrypt==4.1.3
|
|||
slowapi==0.1.9
|
||||
psycopg2-binary==2.9.9
|
||||
python-dateutil==2.9.0
|
||||
tzdata>=2024.1 # ZoneInfo (Europe/Berlin) auch unter Windows
|
||||
matplotlib==3.8.4
|
||||
reportlab==4.2.0
|
||||
|
|
|
|||
|
|
@ -158,7 +158,8 @@ def parse_decision_questions(section_text: str) -> Dict[str, str]:
|
|||
for pattern in patterns:
|
||||
matches = re.finditer(pattern, section_text, re.MULTILINE | re.IGNORECASE)
|
||||
for match in matches:
|
||||
question_type = match.group(1).strip().lower()
|
||||
# Preserve original case for question IDs (e.g., "qAnalyst" not "qanalyst")
|
||||
question_type = match.group(1).strip()
|
||||
answer = match.group(2).strip()
|
||||
|
||||
# Entferne Klammern und Whitespace
|
||||
|
|
|
|||
|
|
@ -7,16 +7,76 @@ import csv
|
|||
import io
|
||||
import uuid
|
||||
import logging
|
||||
import re
|
||||
import calendar
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends, Query
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
||||
from models import ActivityEntry
|
||||
from models import ActivityEntry, ActivityMetricsReplace
|
||||
from routers.profiles import get_pid
|
||||
from feature_logger import log_feature_usage
|
||||
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)
|
||||
try:
|
||||
|
|
@ -27,51 +87,143 @@ except Exception as e:
|
|||
EVALUATION_AVAILABLE = False
|
||||
evaluate_and_save_activity = None
|
||||
|
||||
router = APIRouter(prefix="/api/activity", tags=["activity"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_activity(
|
||||
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)"),
|
||||
x_profile_id: Optional[str] = Header(default=None),
|
||||
month: Optional[str] = Query(
|
||||
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),
|
||||
):
|
||||
"""Get activity entries for current profile. Optional *days* filter by calendar window (not the same as *limit*)."""
|
||||
pid = get_pid(x_profile_id)
|
||||
# Immer das Profil der gültigen Session (X-Profile-Id wird hier nicht verwendet).
|
||||
pid = str(session["profile_id"])
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Issue #31: Apply global quality filter (profile from DB = saved level)
|
||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
||||
profile = r2d(cur.fetchone())
|
||||
quality_filter = get_quality_filter_sql(profile)
|
||||
# Issue #31: Qualitätsfilter — auf der Erfassungsseite /activity abschaltbar (skip_quality_filter)
|
||||
if skip_quality_filter:
|
||||
quality_filter = ""
|
||||
quality_filter_al = ""
|
||||
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 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(
|
||||
f"""
|
||||
SELECT * FROM activity_log
|
||||
WHERE profile_id=%s
|
||||
{quality_filter}
|
||||
AND date >= (CURRENT_DATE - %s::integer)
|
||||
ORDER BY date DESC, start_time DESC
|
||||
LIMIT %s
|
||||
ORDER BY date DESC, start_time DESC NULLS LAST, id DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(pid, days, limit),
|
||||
(pid, days, limit, offset),
|
||||
)
|
||||
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(
|
||||
f"""
|
||||
SELECT * FROM activity_log
|
||||
WHERE profile_id=%s
|
||||
{quality_filter}
|
||||
ORDER BY date DESC, start_time DESC
|
||||
LIMIT %s
|
||||
ORDER BY date DESC, start_time DESC NULLS LAST, id DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(pid, limit),
|
||||
(pid, limit, offset),
|
||||
)
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
return _return_activity_list_rows(cur, [r2d(r) for r in cur.fetchall()])
|
||||
|
||||
|
||||
@router.post("")
|
||||
|
|
@ -95,37 +247,10 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default
|
|||
)
|
||||
|
||||
eid = str(uuid.uuid4())
|
||||
d = e.model_dump()
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
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,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}")
|
||||
insert_activity_from_entry(cur, pid, eid, e)
|
||||
run_activity_post_write_hooks(cur, pid, eid)
|
||||
|
||||
# Phase 2: Increment usage counter (always for new entries)
|
||||
increment_feature_usage(pid, 'activity_entries')
|
||||
|
|
@ -133,36 +258,146 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default
|
|||
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}")
|
||||
def update_activity(eid: str, e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Update existing activity entry."""
|
||||
pid = get_pid(x_profile_id)
|
||||
with get_db() as conn:
|
||||
d = e.model_dump()
|
||||
cur = get_cursor(conn)
|
||||
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,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}")
|
||||
update_activity_from_entry(cur, pid, eid, e)
|
||||
run_activity_post_write_hooks(cur, pid, eid)
|
||||
|
||||
return {"id":eid}
|
||||
|
||||
|
|
@ -177,25 +412,66 @@ def delete_activity(eid: str, x_profile_id: Optional[str]=Header(default=None),
|
|||
return {"ok":True}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def activity_stats(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Get activity statistics (last 30 entries)."""
|
||||
pid = get_pid(x_profile_id)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
|
||||
rows = [r2d(r) for r in cur.fetchall()]
|
||||
if not rows: return {"count":0,"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),"total_kcal":round(total_kcal),"total_min":round(total_min),"by_type":by_type}
|
||||
@router.put("/{eid}/metrics")
|
||||
def replace_activity_metrics(
|
||||
eid: str,
|
||||
body: ActivityMetricsReplace,
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
"""
|
||||
Voller Ersatz der EAV-Session-Metriken (siehe ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md).
|
||||
"""
|
||||
from data_layer.activity_session_metrics import (
|
||||
ActivitySessionMetricsError,
|
||||
replace_activity_session_metrics,
|
||||
)
|
||||
|
||||
pid = str(session["profile_id"])
|
||||
payload = [m.model_dump() for m in body.metrics]
|
||||
try:
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
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):
|
||||
|
|
@ -251,23 +527,6 @@ 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)
|
||||
|
||||
|
||||
@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")
|
||||
def bulk_categorize_activities(
|
||||
data: dict,
|
||||
|
|
@ -353,7 +612,10 @@ def bulk_categorize_activities(
|
|||
|
||||
@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)):
|
||||
"""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)
|
||||
raw = await file.read()
|
||||
try: text = raw.decode('utf-8')
|
||||
|
|
@ -367,9 +629,11 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional
|
|||
for row in reader:
|
||||
wtype = row.get('Workout Type','').strip()
|
||||
start = row.get('Start','').strip()
|
||||
if not wtype or not start: continue
|
||||
try: date = start[:10]
|
||||
except: continue
|
||||
if not wtype or not start:
|
||||
continue
|
||||
workout_date, workout_start_t = normalize_activity_start(start)
|
||||
if not workout_date:
|
||||
continue
|
||||
dur = row.get('Duration','').strip()
|
||||
duration_min = None
|
||||
if dur:
|
||||
|
|
@ -386,106 +650,82 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional
|
|||
# Map activity_type to training_type_id using database mappings
|
||||
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:
|
||||
# Check if entry already exists (duplicate detection by date + start_time)
|
||||
cur.execute("""
|
||||
SELECT id FROM activity_log
|
||||
WHERE profile_id = %s AND date = %s AND start_time = %s
|
||||
""", (pid, date, start))
|
||||
existing = cur.fetchone()
|
||||
|
||||
if existing:
|
||||
# Update existing entry (e.g., to add training type mapping)
|
||||
existing_id = existing['id']
|
||||
cur.execute("""
|
||||
UPDATE activity_log
|
||||
SET end_time = %s,
|
||||
activity_type = %s,
|
||||
duration_min = %s,
|
||||
kcal_active = %s,
|
||||
kcal_resting = %s,
|
||||
hr_avg = %s,
|
||||
hr_max = %s,
|
||||
distance_km = %s,
|
||||
training_type_id = %s,
|
||||
training_category = %s,
|
||||
training_subcategory = %s
|
||||
WHERE id = %s
|
||||
""", (
|
||||
row.get('End',''), wtype, duration_min,
|
||||
kj(row.get('Aktive Energie (kJ)','')),
|
||||
kj(row.get('Ruheeinträge (kJ)','')),
|
||||
tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
|
||||
tf(row.get('Max. Herzfrequenz (count/min)','')),
|
||||
tf(row.get('Distanz (km)','')),
|
||||
training_type_id, training_category, training_subcategory,
|
||||
existing_id
|
||||
))
|
||||
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}")
|
||||
existing_id = find_activity_duplicate_id(cur, pid, workout_date, workout_start_t)
|
||||
if existing_id:
|
||||
update_activity_columns(
|
||||
cur,
|
||||
pid,
|
||||
str(existing_id),
|
||||
{
|
||||
"start_time": workout_start_t,
|
||||
"end_time": row.get("End", "") or None,
|
||||
"activity_type": wtype,
|
||||
"duration_min": duration_min,
|
||||
"kcal_active": kcal_a,
|
||||
"kcal_resting": kcal_r,
|
||||
"hr_avg": hr_av,
|
||||
"hr_max": hr_mx,
|
||||
"distance_km": dist_km,
|
||||
"training_type_id": training_type_id,
|
||||
"training_category": training_category,
|
||||
"training_subcategory": training_subcategory,
|
||||
},
|
||||
)
|
||||
skipped += 1
|
||||
run_activity_post_write_hooks_import(
|
||||
cur,
|
||||
pid,
|
||||
str(existing_id),
|
||||
workout_date=workout_date,
|
||||
training_type_id=training_type_id,
|
||||
duration_min=duration_min,
|
||||
hr_avg=hr_av,
|
||||
hr_max=hr_mx,
|
||||
distance_km=dist_km,
|
||||
kcal_active=kcal_a,
|
||||
kcal_resting=kcal_r,
|
||||
)
|
||||
else:
|
||||
# Insert new entry
|
||||
new_id = 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,'apple_health',%s,%s,%s,CURRENT_TIMESTAMP)""",
|
||||
(new_id,pid,date,start,row.get('End',''),wtype,duration_min,
|
||||
kj(row.get('Aktive Energie (kJ)','')),kj(row.get('Ruheeinträge (kJ)','')),
|
||||
tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
|
||||
tf(row.get('Max. Herzfrequenz (count/min)','')),
|
||||
tf(row.get('Distanz (km)','')),
|
||||
training_type_id,training_category,training_subcategory))
|
||||
inserted+=1
|
||||
|
||||
# Phase 1.2: Auto-evaluation after CSV import INSERT
|
||||
if EVALUATION_AVAILABLE and training_type_id:
|
||||
try:
|
||||
# Build activity dict for evaluation
|
||||
activity_dict = {
|
||||
"id": new_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, 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}")
|
||||
new_id = new_activity_id()
|
||||
insert_activity_csv_minimal(
|
||||
cur,
|
||||
pid,
|
||||
new_id,
|
||||
date_iso=workout_date,
|
||||
start_time=workout_start_t,
|
||||
end_time=row.get("End", "") or None,
|
||||
activity_type=wtype,
|
||||
duration_min=duration_min,
|
||||
kcal_active=kcal_a,
|
||||
kcal_resting=kcal_r,
|
||||
hr_avg=hr_av,
|
||||
hr_max=hr_mx,
|
||||
distance_km=dist_km,
|
||||
training_type_id=training_type_id,
|
||||
training_category=training_category,
|
||||
training_subcategory=training_subcategory,
|
||||
source="apple_health",
|
||||
)
|
||||
inserted += 1
|
||||
run_activity_post_write_hooks_import(
|
||||
cur,
|
||||
pid,
|
||||
new_id,
|
||||
workout_date=workout_date,
|
||||
training_type_id=training_type_id,
|
||||
duration_min=duration_min,
|
||||
hr_avg=hr_av,
|
||||
hr_max=hr_mx,
|
||||
distance_km=dist_km,
|
||||
kcal_active=kcal_a,
|
||||
kcal_resting=kcal_r,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Import row failed: {e}")
|
||||
skipped+=1
|
||||
|
|
|
|||
|
|
@ -14,7 +14,12 @@ from fastapi import APIRouter, HTTPException, Depends
|
|||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_admin, hash_pin
|
||||
from models import AdminProfileUpdate
|
||||
from dashboard_layout_schema import ALLOWED_WIDGET_IDS, DashboardLayoutPayload, product_default_layout_dict
|
||||
from dashboard_layout_schema import (
|
||||
ALLOWED_WIDGET_IDS,
|
||||
DashboardLayoutPayload,
|
||||
merge_missing_catalog_widgets,
|
||||
product_default_layout_dict,
|
||||
)
|
||||
from dashboard_widget_entitlements import widgets_catalog_admin_payload
|
||||
from widget_catalog import WIDGET_CATALOG
|
||||
from widget_feature_requirements_db import (
|
||||
|
|
@ -184,7 +189,7 @@ def admin_get_dashboard_product_default(session: dict = Depends(require_admin)):
|
|||
"""Aktueller Produkt-Dashboard-Standard (DB oder Code)."""
|
||||
_ = session
|
||||
with get_db() as conn:
|
||||
layout = get_product_default_base_dict(conn)
|
||||
layout = merge_missing_catalog_widgets(get_product_default_base_dict(conn))
|
||||
from_database = get_stored_product_default_validated(conn) is not None
|
||||
code_ref = product_default_layout_dict()
|
||||
return {
|
||||
|
|
@ -217,7 +222,7 @@ def admin_delete_dashboard_product_default(session: dict = Depends(require_admin
|
|||
_ = session
|
||||
with get_db() as conn:
|
||||
delete_product_default_override(conn)
|
||||
layout = get_product_default_base_dict(conn)
|
||||
layout = merge_missing_catalog_widgets(get_product_default_base_dict(conn))
|
||||
return {"ok": True, "layout": layout, "from_database": False}
|
||||
|
||||
|
||||
|
|
|
|||
266
backend/routers/admin_activity_attribute_profiles.py
Normal file
266
backend/routers/admin_activity_attribute_profiles.py
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
"""
|
||||
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}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user