Compare commits
165 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1f45d3ba1 | |||
| 44e3e216ab | |||
| 94ddf84262 | |||
| e452d9a8fe | |||
| 9a0767c8ac | |||
| 8b1e1da5d8 | |||
| d35087ad84 | |||
| 037e8bab47 | |||
| a5adff6196 | |||
| b37005be01 | |||
| 3b46630a37 | |||
| d6838abda7 | |||
| fa542e0fb9 | |||
| a7e5511039 | |||
| 132a364a3a | |||
| c1873a47b1 | |||
| 86f15e6957 | |||
| 4b1be3019d | |||
| a07a668de2 | |||
| 8e964509d7 | |||
| 7ccae33844 | |||
| 9bb89231d0 | |||
| c90dabc032 | |||
| 919c77fcf8 | |||
| 72d101415f | |||
| ae7d0ac1d9 | |||
| 79ec249bc3 | |||
| 67b508f09b | |||
| 532e17c4cd | |||
| d232b11411 | |||
| 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 |
|
|
@ -12,7 +12,8 @@ Dieser Ordner ist der **primäre Orientierungspunkt** für Claude Code / Cursor-
|
|||
| 2 | **`rules/DOCUMENTATION.md`** – Ablage- und Dokumentationsregeln |
|
||||
| 3 | `rules/ARCHITECTURE.md`, `rules/CODING_RULES.md`, `rules/LESSONS_LEARNED.md` |
|
||||
| 4 | Issue-Landkarte: **`.claude/docs/GITEA_ISSUES_INDEX.md`** |
|
||||
| 5 | **Universal CSV Import** (Modul/Executor/Vorlagen): **`docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md`** (unter `.claude/`) |
|
||||
| 5 | **Jinkendo Foundation** (Designprinzipien 1–9): **`docs/jinkendo-foundation/design-principles/README.md`** |
|
||||
| 6 | **Universal CSV Import** (Modul/Executor/Vorlagen): **`docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md`** (unter `.claude/`) |
|
||||
|
||||
Themen mit UI/Nav/PWA: siehe `../docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md` (im **Projekt**-`docs/`, nicht hier).
|
||||
|
||||
|
|
@ -25,6 +26,7 @@ Themen mit UI/Nav/PWA: siehe `../docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md` (im
|
|||
├── README.md ← Diese Datei
|
||||
├── rules/ ← Verbindliche Regeln (versioniert, wenn konfiguriert)
|
||||
├── docs/ ← Spezifikationen + Arbeitspapiere
|
||||
│ ├── jinkendo-foundation/ ← Foundation Produktfamilie (Designprinzipien)
|
||||
│ ├── functional/ ← Fachlich (WAS)
|
||||
│ ├── technical/ ← Technisch (WIE)
|
||||
│ ├── architecture/ ← Querschnitt
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Gitea Issues – Landkarte (Auswertung)
|
||||
|
||||
**Quelle:** Gitea `Lars/mitai-jinkendo`, Stand **2026-04-11** (Abfrage `state=all`, ergänzt: #71, #76).
|
||||
**Quelle:** Gitea `Lars/mitai-jinkendo`, Stand **2026-04-11** (Abfrage `state=all`, ergänzt: #71, #75, #76, #106, 2026-09-12).
|
||||
**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.
|
||||
|
|
@ -82,6 +82,14 @@ Dieses Dokument ist ein **Orientierungs-Index** für Agenten und Entwickler. Ver
|
|||
|---|--------|
|
||||
| 37 | Feature-Enforcement für Activity CSV-Import |
|
||||
| 38 | Feature-Enforcement für Nutrition CSV-Import UI |
|
||||
| 71 | Universal CSV Import: Dry-Run, Mapping-Validierung, Fehler-Hints |
|
||||
|
||||
### Ernährung / BLS
|
||||
|
||||
| # | Titel |
|
||||
|---|--------|
|
||||
| 75 | Ernährung: Zucker/Ballaststoffe, Lebensmittelqualität, Timing (Folge nach #106) |
|
||||
| 106 | BLS-Stammdaten, FDDB-Mapping und Item-Tagebuch |
|
||||
|
||||
### Qualität / Sonstiges
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
|
|||
├── ROADMAP.md ← Strategische Phasen (0–3)
|
||||
├── CLEANUP_PLAN.md ← Historie Bereinigung März 2026
|
||||
├── prompts/ ← Exportierte Prompt-Artefakte (JSON)
|
||||
├── jinkendo-foundation/ ← Foundation Produktfamilie (Designprinzipien)
|
||||
├── functional/ ← Fachliche Spezifikationen (WAS)
|
||||
├── technical/ ← Technische Spezifikationen & Referenz (WIE)
|
||||
├── working/ ← Arbeitspapiere, Analysen, Session-Snapshots
|
||||
|
|
@ -52,9 +53,12 @@ _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` |
|
||||
| BLS / Lebensmittel | `functional/BLS_FOOD_REFERENCE.md`, `technical/BLS_FOOD_REFERENCE.md` | Migration 062, `backend/bls/`, `data_layer/food_mapping.py` |
|
||||
| **Designprinzipien (Produktfamilie)** | **`jinkendo-foundation/design-principles/README.md`** | Querschnittsmuster; Foundation für Schwester-Apps |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
|
@ -91,6 +95,16 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
|
|||
|
||||
## Technische Spezifikationen (`technical/`)
|
||||
|
||||
### Jinkendo Foundation (Produktfamilie)
|
||||
|
||||
| Dokument | Inhalt |
|
||||
|----------|--------|
|
||||
| **[jinkendo-foundation/README.md](jinkendo-foundation/README.md)** | Einstieg Foundation-Ordner |
|
||||
| **[design-principles/README.md](jinkendo-foundation/design-principles/README.md)** | Index: 9 Designprinzipien, Lesereihenfolge, Übergabe-Checkliste |
|
||||
| `design-principles/*_DESIGN_PRINCIPLES.md` | Einzeldokumente (#1–#9) |
|
||||
|
||||
### Referenz & Agent-Guides (`technical/`)
|
||||
|
||||
| Dokument | Thema |
|
||||
|----------|--------|
|
||||
| `AGGREGATION_METHODS.md` | Aggregation |
|
||||
|
|
@ -113,6 +127,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) |
|
||||
|
||||
---
|
||||
|
|
@ -176,4 +196,4 @@ Siehe [`audit/README.md`](./audit/README.md).
|
|||
|
||||
---
|
||||
|
||||
**Letzte Aktualisierung:** 9. April 2026 (Universal CSV Agent-Guide, Abgleich-Tabelle)
|
||||
**Letzte Aktualisierung:** 4. Juli 2026 (Jinkendo Foundation, Designprinzipien nach `jinkendo-foundation/`)
|
||||
|
|
|
|||
59
.claude/docs/functional/BLS_FOOD_REFERENCE.md
Normal file
59
.claude/docs/functional/BLS_FOOD_REFERENCE.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# BLS-Lebensmittelreferenz und FDDB-Mapping
|
||||
|
||||
**Stand:** 2026-09-14 · **Status:** Phase 1 (Umsetzung)
|
||||
|
||||
## WAS
|
||||
|
||||
Optionale Grundlage für verlässliche Nährwerte: offizieller Bundeslebensmittelschlüssel (BLS) 4.0 plus manuelle Katalogerweiterung, lernendes Mapping von FDDB-Bezeichnern, persistierte Tagebuchzeilen. Reine Tagesmakros bleiben First Class.
|
||||
|
||||
## Zuordnung (UX)
|
||||
|
||||
Offene Zuordnungen zeigen **Vorschläge in der Zeile** (z. B. Haferflocken → Hafer Flocken); Bestätigen ohne Dialog. Mehrere nahe Treffer werden gekennzeichnet. Fehlt ein Treffer, **Lebensmittel oder Liste**. Ein Lebensmittel wird ein Katalogeintrag. Eine **Liste / Kombination** öffnet den Dialog: Zutaten per Suche aus bereits gemappten Namen oder dem Katalog (Fettgehalt wie Joghurt 10 %). Jede Zutat hat **Menge + Einheit** (g, ml, EL, TL, Prise, Stück, …). Standardeinheit für Nährwerte ist Gramm; Haushaltsmaße werden umgerechnet (EL 15 g, TL 5 g, Prise 0,3 g, oder Gramm pro Stück am Mapping). Fehlt der Faktor, bleibt die Einheit erhalten und ist später umrechenbar. Katalogtreffer werden beim Speichern zugeordnet. Freitext-Zutaten bleiben offen und erscheinen in der Offene-Liste (Kennzeichnung „Listenzutat“) — dort nur als Lebensmittel zuordenbar. Nicht-Gramm-Einheiten (Stück, EL, TL, …) bekommen ein **Gramm-pro-Einheit**-Feld am Mapping. Vorschläge und Katalogsuche laufen nur für die sichtbare Arbeit. Die Offene-Liste startet bei den **letzten 4 Wochen**; ältere Namen bleiben unter „Alle“. Offene Listenzutaten zählen mit dem Datum der letzten Listen-Nutzung oder der letzten Listenänderung — nicht nur mit einem Tagebuch-Datum der Zutat selbst. FDDB-Listenimport speichert zuerst; Tagesnährwerte folgen im Hintergrund. Zutatenlisten liegen im Tab **Listen** (sichtbar, durchsuchbar, bearbeitbar). Im Listendialog sieht man pro Zutat, ob ein Mapping fehlt, und kann dort bestätigen oder suchen — zusätzlich zum Tab Zuordnen. Die Zutatensuche zeigt zuerst Namen aus importierten/angelegten Listen, dann eigene Lebensmittel, dann den BLS. „Andere Liste wählen“ verknüpft eine Tagebuchzeile mit einer bestehenden Liste; der Dialog zeigt alle Listen, nicht nur Namens-Treffer. Nach dem Verknüpfen verschwindet die Zeile unter Zuordnen — Kennzeichnung „Liste: …“, solange sie noch offen ist.
|
||||
|
||||
## Listen / Kombinationen (Mitai) vs. Gerichte (Tandoor)
|
||||
|
||||
**Mitai speichert nur Roh-Kombinationen** — einen Namen für eine Zutatenliste ohne Kochvorgang (Müsli, Bowl, Smoothie). Summe der Zutatengramm = gegessene Gramm. Tabelle bleibt `food_recipes` (kein Rename).
|
||||
|
||||
**Gekochte Familienrezepte** liegen in **Tandoor**. Kein zweites Rezeptbuch in Mitai. Kochschwund (Wasserverlust beim Köcheln) darf die Rohzutaten nicht 1:1 auf die gegessene Menge skalieren.
|
||||
|
||||
Spätere Ausbeute (nicht in Phase 1):
|
||||
|
||||
- `ingredients_g` = Summe der auf Gramm aufgelösten Zutaten
|
||||
- `cooked_yield_g` = gewogenes Fertiggewicht nach dem Kochen
|
||||
- `yield_factor = cooked_yield_g / ingredients_g` (oft 0,5–0,9 bei langem Köcheln)
|
||||
- Nährwerte **pro 100 g fertig** = `Summe(Zutat_Nährwerte) / cooked_yield_g × 100`
|
||||
- Gegessen: `portion_g / 100 × Werte_pro_100g`
|
||||
|
||||
Ohne Fertiggewicht: nicht raten — FDDB-Makros oder Marke „unvollständig“. Für Listen gilt implizit `cooked_yield_g = ingredients_g` (Faktor 1). Tandoor liefert Zutaten und Schritte; die Ausbeute und die **pro-100-g**-Rechnung führt Mitai. Importiertes Gericht wird ein Katalogeintrag (`catalog_kind` z. B. `recipe_cooked`), keine zweite Wertetabelle.
|
||||
|
||||
## FDDB-Listen
|
||||
|
||||
FDDB-Tagebuchexport fasst selbst angelegte Listen oft zu **einer Zeile** (Listenname + Menge) zusammen. Die Zutaten stehen in einem **separaten Listen-Export** (`lists_*.csv`, Spalte `produkte`). Ablauf: Listen importieren → passende Tagebuchzeilen werden als Liste verknüpft → **Zutaten** zuordnen, nicht die Liste als Ganzes. Unvollständige Zutaten-Mappings fallen auf die FDDB-Makros der Tagebuchzeile zurück.
|
||||
|
||||
Gelernte Zuordnungen und Listen lassen sich als **JSON sichern** und auf einer anderen Instanz (Dev → Prod) wieder einspielen. Offizielle Lebensmittel werden über den BLS-Code gefunden — der BLS-Katalog muss auf dem Ziel bereits importiert sein.
|
||||
|
||||
## Fachliche Regeln
|
||||
|
||||
- BLS-Code (`bls_code`, Stoff-`attr_key`) bleibt die stabile Identität bei Reimports.
|
||||
- BLS 4.0 (~7140 Lebensmittel) ist frei nutzbar (MRI / blsdb.de); Dateien nicht im Git.
|
||||
- Mapping ohne KI: Normalisierung + exakter Lookup (User vor Global) + Bestätigung neuer Namen.
|
||||
- Öl/Supplement mit Etikett (EPA/DHA, Vitamine) ist **ein Katalogeintrag**, keine Zutatenliste. Listen sind nur Rohmischungen aus Lebensmitteln — eine Liste kann keine andere Liste enthalten. Quarkspeise = Liste, Omega-3-Öl = Lebensmittel darin.
|
||||
- Gelernte Zuordnungen bleiben dauerhaft, sind aber änder- und löschbar. Nutzer-Mapping vor globalem Mapping. Globales Mapping nur auf BLS oder zentrale Admin-Foods.
|
||||
- Sichtbarkeit: persönlich (nur Owner) / zentral (alle) / BLS (alle). Vorschläge und Suche filtern danach; bei gleichem Namen gewinnt persönlich vor zentral vor BLS.
|
||||
- Ungemappte / Fertiggerichte: FDDB-Makros, keine erfundenen Mikros.
|
||||
- Fasten und „unvollständig“ sind explizite Marken, kein Auto-Schluss aus fehlendem Import.
|
||||
- Import-Policy (Profil): nachfragen / Katalog überschreiben / FDDB überschreiben / Makros behalten.
|
||||
|
||||
## Manuelle Lebensmittel / Supplemente
|
||||
|
||||
Eigene Einträge des Nutzers sind **persönlich** (`manual_user`). Der Admin legt **zentrale** Einträge für alle an (`manual_admin`). Beide speichern **dieselben Stoffwerte** wie BLS-Lebensmittel (`food_attribute_values`, immer pro 100 g). Beim Anlegen: Makros plus Suche nach weiteren Stoffen (EPA, DHA, Omega-3). Etikett „pro 8 ml“ → Portionsgröße in g angeben, Umrechnung auf 100 g erfolgt serverseitig. Einheit des Stoffs beachten (oft **g**, Etikett in mg: 1100 mg = 1,1 g). Fehlt ein Stoff im Katalog: Nutzer kann ihn selbst anlegen oder Admin → Stoffe & Attribute (Bezeichnung änderbar, Schlüssel fest). In die Makro-Haupttabelle (`nutrition_log`) fließen nur ENERCC→kcal, PROT625→protein_g, FAT→fat_g, CHO→carbs_g (Wert je 100 g × gegessene g / 100). Andere Stoffe gehen nach `nutrition_daily_nutrients`.
|
||||
|
||||
**Nachträglich ändern:** Unter Ernährung → Tab **Lebensmittel** (persönliche Liste + zentrale Admin-Einträge, Suche). Persönliche Einträge legt und ändert der Owner. Zentrale Einträge liest jeder; **ändert nur ein Admin** (gleiche Liste, Zeile oder Bearbeiten). Beim Zuordnen gilt die Reihenfolge persönlich → zentral → BLS. Globale Namens-Mappings (Admin) gelten für alle, dürfen aber nicht auf ein privates Nutzer-Lebensmittel zeigen. Offizielle BLS-Einträge bleiben fest. Beim Speichern gelten die Felder als Werte je 100 g, außer die Portionsgröße wird bewusst auf die Etikett-Portion gesetzt. Betroffene Tagebuch-Tage werden im Hintergrund neu berechnet. Admin → Lebensmittelkatalog → Reiter **Manuell** zeigt **Zentral** und **Persönlich** getrennt; beide sind dort änderbar.
|
||||
|
||||
Weitere Quellen (USDA, Schweizer Nährwertdatenbank) kommen später als zusätzliche Katalogherkunft, nicht als zweite Wertetabelle.
|
||||
|
||||
## Später
|
||||
|
||||
Platzhalter (Registry) für Mikros, Esszeitpunkte (`logged_at`), Fasten; Bezug Gitea #106 (Grundlage) und #75 (Folge).
|
||||
|
||||
**Tandoor:** Familienrezepte bleiben dort. Persönlicher Zugang (URL + API-Token) unter Einstellungen — nicht systemweit; Token nur auf dem Server, nie im Export. Verbindungstest: `GET /api/user/` (Fallback Rezeptliste). Zutaten werden später live gelesen und auf BLS gemappt, kein Zutaten-Sync. Noch kein Rezeptimport; später `cooked_yield_g` und Katalogeintrag Fertiggericht pro 100 g.
|
||||
46
.claude/docs/jinkendo-foundation/README.md
Normal file
46
.claude/docs/jinkendo-foundation/README.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Jinkendo Foundation
|
||||
|
||||
**Stand:** 2026-07-04
|
||||
**Zweck:** Geteilte **Architektur-Foundation** für die Jinkendo-Produktfamilie (Mitai, Miken, Ikigai, Shinkan, …) — unabhängig von app-spezifischer Domänenlogik.
|
||||
|
||||
Dieser Ordner enthält **keine** Mitai-Fachspecs und **keine** operativen Agent-Guides zu einzelnen Features. Die Referenzimplementierung bleibt in **Mitai Jinkendo**; die Foundation beschreibt **übertragbare Muster**.
|
||||
|
||||
---
|
||||
|
||||
## Inhalt
|
||||
|
||||
| Pfad | Beschreibung |
|
||||
|------|--------------|
|
||||
| **[design-principles/README.md](./design-principles/README.md)** | **Einstieg:** Index aller 9 Designprinzipien-Dokumente, Lesereihenfolge, Übergabe-Checkliste |
|
||||
| `design-principles/*_DESIGN_PRINCIPLES.md` | Einzeldokumente (#1 Prompt Engine … #9 Migration & Deploy) |
|
||||
|
||||
---
|
||||
|
||||
## Mitai-spezifische Implementierung
|
||||
|
||||
Agent-Guides, API-Referenz und Domänen-Specs liegen weiterhin unter:
|
||||
|
||||
- `.claude/docs/technical/` — WIE (Implementierung)
|
||||
- `.claude/docs/functional/` — WAS (Fachlich)
|
||||
- `docs/issues/` — Issue-Epics
|
||||
|
||||
Die Designprinzipien verlinken dorthin, wo Mitai als **Beispiel** dient.
|
||||
|
||||
---
|
||||
|
||||
## Apps der Familie
|
||||
|
||||
| Domain | App |
|
||||
|--------|-----|
|
||||
| mitai.jinkendo.de | Körper-Tracking (Referenz) |
|
||||
| miken.jinkendo.de | Meditation |
|
||||
| ikigai.jinkendo.de | Lebenssinn |
|
||||
| shinkan.jinkendo.de | Kampfsport |
|
||||
|
||||
---
|
||||
|
||||
## Pflege
|
||||
|
||||
Neue Querschnittsmuster → neues Dokument unter `design-principles/` + Eintrag im [Index](./design-principles/README.md).
|
||||
|
||||
Regeln zur Ablage: [DOCUMENTATION.md](../../rules/DOCUMENTATION.md)
|
||||
|
|
@ -0,0 +1,326 @@
|
|||
# Auth & Session – Designprinzipien (Extraktion)
|
||||
|
||||
**Status:** Analyse / Arbeitspapier
|
||||
**Stand:** 2026-07-04
|
||||
**Geltungsbereich:** Authentifizierung, Session-Management, rollenbasierte API-Zugriffe — kein Mandanten-/SSO-System, keine Zahlungs-Auth
|
||||
|
||||
**Serie:** Designprinzipien für Produktfamilie · Dokument 5 von n
|
||||
**Vorgänger:** [REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md](./REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md)
|
||||
|
||||
**Kernkomponenten:**
|
||||
|
||||
| Bereich | Pfade |
|
||||
|---------|-------|
|
||||
| Auth-Kern | `backend/auth.py` |
|
||||
| Auth-Endpoints | `backend/routers/auth.py` |
|
||||
| Profile | `backend/routers/profiles.py` |
|
||||
| Frontend | `frontend/src/context/AuthContext.jsx`, `ProfileContext.jsx`, `utils/api.js` |
|
||||
| DB | `profiles`, `sessions` |
|
||||
| Architektur-Regeln | `.claude/rules/ARCHITECTURE.md`, `CLAUDE.md` § Auth |
|
||||
| Vision (nicht implementiert) | `CENTRAL_SUBSCRIPTION_SYSTEM.md` (SSO/JWT) |
|
||||
|
||||
---
|
||||
|
||||
## Modul
|
||||
|
||||
**Auth & Session**
|
||||
|
||||
Server-seitige, token-basierte Authentifizierung mit FastAPI-Dependencies — **Identität und Rolle**, getrennt von Feature-Entitlements und fachlicher Logik.
|
||||
|
||||
---
|
||||
|
||||
## Fachliche Verantwortung
|
||||
|
||||
Das Modul übernimmt:
|
||||
|
||||
1. **Identität** — Wer ist eingeloggt? (`profiles` + Passwort/bcrypt)
|
||||
2. **Session** — Opaque Token in `sessions`, Ablaufzeit, Logout
|
||||
3. **API-Gate** — `require_auth`, `require_admin`, `require_auth_flexible`
|
||||
4. **Passwort-Lifecycle** — Hash, Verify, Reset, Registrierung, E-Mail-Verifizierung
|
||||
5. **Rollen** — `profiles.role`: `user` \| `admin` (grobbinsenartig)
|
||||
|
||||
Es übernimmt **nicht**:
|
||||
|
||||
- Feature-Limits / Tier (→ Feature & Entitlement System, gleiche `auth.py`-Datei aber logisch getrennt)
|
||||
- Mandanten-Isolation / Org-Workspaces
|
||||
- OAuth/SSO/JWT (nur Vision)
|
||||
- Authorization auf Datensatzebene (Row-Level Security)
|
||||
|
||||
### Was Mitai **ist** vs. **nicht ist**
|
||||
|
||||
| Mitai | Produktfamilien-Muster |
|
||||
|-------|------------------------|
|
||||
| 1 Login = 1 Profil (E-Mail) | ✅ Account-Modell |
|
||||
| Historisch Multi-Profil auf einer Instanz | ⚠️ Legacy (`/profiles`, `X-Profile-Id`) |
|
||||
| Self-hosted Einzelinstanz | ✅ Kein Multi-Tenant-SaaS |
|
||||
| Session-Token in DB | ✅ Server-side Session Store |
|
||||
| Zentrale Jinkendo-Auth (Vision) | ❌ nicht gebaut |
|
||||
|
||||
---
|
||||
|
||||
## Administrierte vs. code-definierte Konfiguration
|
||||
|
||||
| Konfiguration | Speicherort | Administrierbar? |
|
||||
|---------------|-------------|------------------|
|
||||
| Nutzer-Stammdaten, Rolle | `profiles` | Admin (User-Verwaltung) / Self-Service |
|
||||
| Session-Laufzeit | `profiles.session_days` (Default 30) | Profil/Admin |
|
||||
| Passwort-Hash | `profiles.pin_hash` | Nutzer (change pin) |
|
||||
| E-Mail-Verifizierung | `email_verified`, Token-Felder | System |
|
||||
| Trial-Ende | `trial_ends_at` | System bei Registrierung |
|
||||
| SMTP | Env (`SMTP_*`, `APP_URL`) | Deploy |
|
||||
| Rate Limits Login/Register | Code (`5/min`, `3/hour`) | Code |
|
||||
|
||||
**Hardcodiert:** bcrypt, Token-Länge (`secrets.token_urlsafe(32)`), Rollen-Enum (`user`/`admin`), Header-Name `X-Auth-Token`.
|
||||
|
||||
---
|
||||
|
||||
## Session- und Auth-Flow
|
||||
|
||||
```
|
||||
Login (email + password)
|
||||
→ verify_pin (bcrypt | legacy SHA256)
|
||||
→ optional bcrypt upgrade
|
||||
→ INSERT sessions (token, profile_id, expires_at)
|
||||
→ Client: localStorage bodytrack_token
|
||||
|
||||
Request
|
||||
→ Header X-Auth-Token (api.js / AuthContext)
|
||||
→ get_session(token) JOIN profiles
|
||||
→ require_auth → session dict (profile_id, role, …)
|
||||
|
||||
Logout
|
||||
→ DELETE sessions WHERE token=…
|
||||
→ Client: localStorage clear
|
||||
```
|
||||
|
||||
**Sonderfall:** `require_auth_flexible` — Token via Header **oder** Query `ssetoken` (SSE, `<img>`, Downloads).
|
||||
|
||||
---
|
||||
|
||||
## Rollen
|
||||
|
||||
| Rolle | Mechanismus | Typische Rechte |
|
||||
|-------|-------------|-----------------|
|
||||
| **user** | `profiles.role = 'user'` | Eigene Daten, Features nach Tier |
|
||||
| **admin** | `require_admin` | Admin-Shell, Prompts, User, System |
|
||||
|
||||
Kein feingranulares RBAC (keine Permission-Matrix). Admin ist Binär-Schalter.
|
||||
|
||||
---
|
||||
|
||||
## Designprinzipien
|
||||
|
||||
### 1. FastAPI-Dependencies als Auth-Gate
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Jeder geschützte Endpoint nutzt `session: dict = Depends(require_auth)` als **separaten** Parameter — nie in `Header()` eingebettet. |
|
||||
| **Begründung** | Verhindert ungeschützte Endpoints durch falsche Parameter-Signatur. |
|
||||
| **Quelle** | `CLAUDE.md` § Kritische Regeln; `auth.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Nicht linter-erzwungen; Legacy-Endpoints existieren. |
|
||||
|
||||
### 2. Server-side opaque Sessions
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Token ist zufällig, in DB gespeichert; Validierung über `sessions` + Ablauf — kein JWT mit Client-Claims. |
|
||||
| **Begründung** | Revocation (Logout), kein Trust in Client-Payload; einfaches Modell für Self-Hosted. |
|
||||
| **Quelle** | `sessions` Tabelle; `make_token()`, `get_session()` |
|
||||
| **Tragfähigkeit** | **hoch** (Single-App, Self-Hosted) |
|
||||
| **Einschränkung** | Skalierung multi-node braucht shared session store; SSO-Familie braucht anderes Modell. |
|
||||
|
||||
### 3. profile_id aus Session, nicht aus Client
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Autoritative Identität für neue Endpoints: `session['profile_id']` — Client darf Profil nicht wählen. |
|
||||
| **Begründung** | Verhindert IDOR (Zugriff auf fremde Profile). |
|
||||
| **Quelle** | `routers/goals.py`, `routers/prompts.py`; Architektur-Intent in `CLAUDE.md` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Legacy `get_pid(x_profile_id)` akzeptiert `X-Profile-Id` **ohne** Session-Abgleich — siehe Nicht übernehmen. |
|
||||
|
||||
### 4. bcrypt mit Legacy-Migration
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Passwörter mit bcrypt; SHA256-Legacy beim Login erkannt und transparent auf bcrypt upgraded. |
|
||||
| **Begründung** | Kein Big-Bang-Migration; sichere Hashes ohne Nutzer-Zwangs-Reset. |
|
||||
| **Quelle** | `verify_pin()`, Login in `routers/auth.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Upgrade nur bei erfolgreichem Login. |
|
||||
|
||||
### 5. Rate Limiting auf Auth-Endpoints
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Login, Register, Forgot-Password, Resend-Verification mit `slowapi`-Limits (IP-basiert). |
|
||||
| **Begründung** | Brute-Force- und Abuse-Schutz. |
|
||||
| **Quelle** | `routers/auth.py` (`5/minute`, `3/hour`); `main.py` Limiter |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | IP-only; kein account-based lockout. |
|
||||
|
||||
### 6. Keine E-Mail-Enumeration bei sensiblen Flows
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Forgot-Password und Resend-Verification liefern generische Erfolgsmeldung, auch wenn E-Mail unbekannt. |
|
||||
| **Begründung** | Privacy; erschwert Account-Scraping. |
|
||||
| **Quelle** | `password_reset_request`, `resend_verification` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Register sagt „E-Mail bereits registriert“ (Enumeration möglich). |
|
||||
|
||||
### 7. E-Mail-Verifizierung vor voller Nutzung
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Self-Register setzt `email_verified=FALSE`; Verify-Endpoint aktiviert + Auto-Login-Session. |
|
||||
| **Begründung** | Valide Kontaktadresse; Spam-Reduktion. |
|
||||
| **Quelle** | `register`, `verify_email` in `routers/auth.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Nicht überall im Backend erzwungen (Login ohne verified check?). |
|
||||
|
||||
### 8. Flexible Auth für technische Clients
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `require_auth_flexible`: gleiche Session-Validierung via Header oder `?ssetoken=` für SSE/Bilder. |
|
||||
| **Begründung** | Browser-APIs ohne Custom Headers. |
|
||||
| **Quelle** | `auth.py`; Prompt SSE `/execute-stream` |
|
||||
| **Tragfähigkeit** | **mittel–hoch** |
|
||||
| **Einschränkung** | Token in URL kann in Logs/Referrer leaken — kurze Sessions / HTTPS Pflicht. |
|
||||
|
||||
### 9. Zentraler API-Client mit Token-Injektion
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Frontend: `api.js` injiziert `X-Auth-Token` automatisch — kein scattered `fetch` ohne Auth. |
|
||||
| **Begründung** | Konsistenz; eine Stelle für Token-Handling. |
|
||||
| **Quelle** | `utils/api.js` → `hdrs()`; `getToken()` aus AuthContext |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Einzelne Komponenten umgehen noch `api.js` (SettingsPage, EmailSettings). |
|
||||
|
||||
### 10. Auth getrennt von Authorization (Features)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `require_auth` = identifiziert; `check_feature_access` = berechtigt für Aktion — nacheinander im Router. |
|
||||
| **Begründung** | Klare Schichten; Auth-Modul nicht mit Tier-Logik vermischen (auch wenn Datei `auth.py` beides enthält). |
|
||||
| **Quelle** | `FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md`; Router-Muster |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Legacy Profil-Flags `ai_enabled`, `export_enabled` parallel zum Feature-System. |
|
||||
|
||||
### 11. Admin-Gate im Frontend und Backend
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Backend: `require_admin`; Frontend: `RequireAdmin` + `isAdmin` aus Session-Rolle. |
|
||||
| **Begründung** | UX-Navigation + API-Sicherheit (Frontend allein reicht nicht). |
|
||||
| **Quelle** | `RequireAdmin.jsx`; `require_admin()` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Einzelne Routen (Workflow-Editor) ohne Frontend-Admin-Gate. |
|
||||
|
||||
### 12. Session-Kontext im Frontend
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `AuthProvider` hält `{ token, profile_id, role, profile }`; App setzt `setProfileId(session.profile_id)` für API. |
|
||||
| **Begründung** | Single React-Tree für Login-State; Re-Validate via `/auth/me` beim Start. |
|
||||
| **Quelle** | `AuthContext.jsx`; `App.jsx` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | `ProfileContext` lädt alle Profile — Multi-Profil-UX Rest; Session-Profil ist Kanon. |
|
||||
|
||||
---
|
||||
|
||||
## Nicht übernehmen
|
||||
|
||||
1. **`get_pid(X-Profile-Id)` ohne Session-Bindung** — Client kann fremde `profile_id` senden; IDOR-Risiko. Kanon: immer `session['profile_id']` oder explizite Admin-Impersonation mit Audit.
|
||||
|
||||
2. **Profile-CRUD nur mit `require_auth`** — `/profiles` listet alle Nutzer für jeden Authentifizierten (Kommentar „admin“, kein `require_admin`). Für Familien-Architektur: strikte Admin-Gates.
|
||||
|
||||
3. **Dual-System Profil-Flags vs. Features** — `ai_enabled`, `export_enabled`, `ai_limit_day` in Session-Query neben v9c Feature-Registry.
|
||||
|
||||
4. **localStorage-Key-Inkonsistenz** — `bodytrack_token` vs. `mitai-jinkendo_active_profile` (historischer App-Name).
|
||||
|
||||
5. **Direktes `fetch` ohne `api.js`** — umgeht Token-/Error-Konvention.
|
||||
|
||||
6. **Reset-Token in `sessions`-Tabelle** — `reset_{token}` mischt Session-Typen in einer Tabelle; OK für MVP, für Familie: getrennte Token-Typen/Tabellen.
|
||||
|
||||
7. **Kein JWT/SSO trotz Produktfamilien-Vision** — `CENTRAL_SUBSCRIPTION_SYSTEM.md` beschreibt `auth.jinkendo.de` — Mitai-Implementierung ist **nicht** das Zielbild für Cross-App-SSO.
|
||||
|
||||
8. **Multi-Profil-Haushalt ohne klares Modell** — Legacy Multi-Profile auf einer Instanz vs. 1 Account = 1 Profil; für neue Apps Modell explizit wählen.
|
||||
|
||||
9. **Role als einziges RBAC** — reicht für Admin/User, nicht für feingranulare Permissions.
|
||||
|
||||
10. **Session-Query mit veralteten Profil-Spalten** — `get_session` SELECT enthält Legacy-Felder statt nur Identität + Rolle.
|
||||
|
||||
11. **Fehlende erzwungene E-Mail-Verified-Prüfung** — Registrierung setzt Flag, Login prüft es nicht offensichtlich.
|
||||
|
||||
12. **Debug-Print in Auth-Modul** — `print("[AUTH.PY] Module loaded…")` in Produktionscode.
|
||||
|
||||
---
|
||||
|
||||
## Abgrenzung zu anderen Serien-Dokumenten
|
||||
|
||||
| Thema | Dokument |
|
||||
|-------|----------|
|
||||
| Tier, Limits, Quotas | [FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md](./FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md) |
|
||||
| Zentrale SSO/Abo-Vision | [CENTRAL_SUBSCRIPTION_SYSTEM.md](../../technical/CENTRAL_SUBSCRIPTION_SYSTEM.md) |
|
||||
| API-First / Router | `ARCHITECTURE.md` §1 |
|
||||
|
||||
---
|
||||
|
||||
## Modul-Inventar (Ist-Stand)
|
||||
|
||||
```
|
||||
backend/
|
||||
├── auth.py # Session, require_*, Feature-Access (v9c)
|
||||
└── routers/
|
||||
├── auth.py # login, logout, register, verify, reset
|
||||
└── profiles.py # CRUD, get_pid (Legacy)
|
||||
|
||||
frontend/src/
|
||||
├── context/AuthContext.jsx
|
||||
├── context/ProfileContext.jsx
|
||||
├── layouts/RequireAdmin.jsx
|
||||
└── utils/api.js # Token-Injektion
|
||||
|
||||
DB:
|
||||
├── profiles # Identität, Rolle, Hash, Tier, Trial
|
||||
└── sessions # token → profile_id, expires_at
|
||||
```
|
||||
|
||||
**Endpoints (Auswahl):**
|
||||
|
||||
| Endpoint | Auth |
|
||||
|----------|------|
|
||||
| `POST /api/auth/login` | Public + Rate limit |
|
||||
| `POST /api/auth/logout` | Token optional |
|
||||
| `GET /api/auth/me` | require_auth |
|
||||
| `POST /api/auth/register` | Public + Rate limit |
|
||||
| `GET /api/auth/verify/{token}` | Public |
|
||||
|
||||
---
|
||||
|
||||
## Verwandte Dokumentation
|
||||
|
||||
- Feature-Entitlements: [FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md](./FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md)
|
||||
- Architektur-Regeln Auth: `CLAUDE.md`, `.claude/rules/ARCHITECTURE.md`
|
||||
- GUI Admin-Guard: `docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`
|
||||
- SSO-Vision: [CENTRAL_SUBSCRIPTION_SYSTEM.md](../../technical/CENTRAL_SUBSCRIPTION_SYSTEM.md)
|
||||
|
||||
---
|
||||
|
||||
## Geplante Folgedokumente (Serie)
|
||||
|
||||
| # | Modul | Status |
|
||||
|---|-------|--------|
|
||||
| 1 | Prompt Engine | ✅ |
|
||||
| 2 | Data Layer | ✅ |
|
||||
| 3 | Feature & Entitlement | ✅ |
|
||||
| 4 | Registry-/Plugin-Muster | ✅ |
|
||||
| 5 | Auth & Session | ✅ dieses Dokument |
|
||||
| 6 | Universal Import | ✅ `UNIVERSAL_IMPORT_DESIGN_PRINCIPLES.md` |
|
||||
| 7 | Dashboard Widgets | ✅ |
|
||||
| 8 | Navigation / IA | ✅ `NAVIGATION_IA_DESIGN_PRINCIPLES.md` |
|
||||
| 9 | Migration & Deploy | ✅ |
|
||||
|
|
@ -0,0 +1,363 @@
|
|||
# Dashboard Widgets – Designprinzipien (Extraktion)
|
||||
|
||||
**Status:** Analyse / Arbeitspapier
|
||||
**Stand:** 2026-07-04
|
||||
**Geltungsbereich:** Konfigurierbare Übersicht (Widget-Katalog, Layout, Entitlements, Frontend-Registry) — keine Chart-/Metrik-Berechnung
|
||||
|
||||
**Serie:** Designprinzipien für Produktfamilie · Dokument 7 von n
|
||||
**Vorgänger:** [UNIVERSAL_IMPORT_DESIGN_PRINCIPLES.md](./UNIVERSAL_IMPORT_DESIGN_PRINCIPLES.md)
|
||||
|
||||
**Kernkomponenten:**
|
||||
|
||||
| Bereich | Pfade |
|
||||
|---------|-------|
|
||||
| Katalog (SSoT) | `backend/widget_catalog.py` |
|
||||
| Layout-Schema | `backend/dashboard_layout_schema.py` |
|
||||
| Config-Validierung | `backend/dashboard_widget_config.py` |
|
||||
| Entitlements | `backend/dashboard_widget_entitlements.py`, `widget_feature_requirements_db.py` |
|
||||
| Produkt-Standard | `backend/system_dashboard_product_default.py` |
|
||||
| HTTP | `backend/routers/app_dashboard.py` |
|
||||
| Frontend-Registry | `frontend/src/widgetSystem/dashboardWidgetRegistry.jsx` |
|
||||
| Registrierung | `frontend/src/widgetSystem/registerDashboardWidgets.js` |
|
||||
| Layout-Editor | `frontend/src/pages/DashboardConfigurePage.jsx` |
|
||||
| Fehler-Isolation | `frontend/src/widgetSystem/WidgetErrorBoundary.jsx` |
|
||||
| Leitfaden | `DASHBOARD_WIDGETS_AGENT_GUIDE.md` |
|
||||
| Registry-Meta | [REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md](./REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md) |
|
||||
|
||||
---
|
||||
|
||||
## Modul
|
||||
|
||||
**Dashboard Widgets**
|
||||
|
||||
Erweiterbares System für **konfigurierbare Startübersicht**: Backend-Katalog definiert erlaubte Widget-IDs; Nutzer speichern Reihenfolge, Ein/Aus und optionale `config` pro Profil; Frontend rendert über eine lokale Komponenten-Registry.
|
||||
|
||||
---
|
||||
|
||||
## Fachliche Verantwortung
|
||||
|
||||
Das Modul übernimmt:
|
||||
|
||||
1. **Widget-Katalog** — IDs, Titel, Beschreibung, optionale Feature-Anforderung (`requires_feature`).
|
||||
2. **Layout-Persistenz** — `profiles.dashboard_layout` (JSON v1: `{ version, widgets[] }`).
|
||||
3. **Validierung** — Erlaubte IDs, keine Duplikate, max. 32 Widgets, mindestens eines aktiv.
|
||||
4. **Pro-Widget-Config** — Whitelist pro Widget-ID; Normalisierung beim Speichern.
|
||||
5. **Standard-Layouts** — Code-Fallback (`DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS`), Admin-Override (`system_config`), Lab-Template (`DEFAULT_LAB_WIDGET_IDS`).
|
||||
6. **Entitlements** — `allowed` im Katalog; Layout bereinigt bei fehlender Berechtigung.
|
||||
7. **Frontend-Rendering** — Registry mappt Katalog-ID → React-Komponente + Props aus `layoutEntry.config`.
|
||||
8. **Nutzer-Konfigurator** — „Übersicht anpassen“ (Sortieren, Toggle, Config-Editoren).
|
||||
|
||||
Es übernimmt **nicht**:
|
||||
|
||||
- Berechnung von KPIs, Charts, Scores (→ Data Layer + Chart-Endpoints, siehe [DATA_LAYER_DESIGN_PRINCIPLES.md](./DATA_LAYER_DESIGN_PRINCIPLES.md))
|
||||
- Tier-/Subscription-Logik in Widgets (→ Feature System, siehe [FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md](./FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md))
|
||||
- Prompt-/KI-Ausführung (Widget zeigt nur UI; Pipeline läuft über eigene API)
|
||||
|
||||
### Datenfluss (Happy Path)
|
||||
|
||||
```
|
||||
WIDGET_CATALOG (Backend)
|
||||
→ GET /api/app/widgets/catalog (+ allowed via check_feature_access)
|
||||
→ GET /api/app/dashboard-layout
|
||||
→ coalesce_effective_layout (Profil oder Standard)
|
||||
→ merge_missing_catalog_widgets (neue IDs anhängen)
|
||||
→ apply_entitlements_to_layout_dict
|
||||
→ Frontend: ensureDashboardWidgetsRegistered()
|
||||
→ WidgetRenderer: enabled widgets → mapProps(layoutEntry.config) → Component
|
||||
→ PUT /api/app/dashboard-layout (Pydantic + Entitlements + speichern)
|
||||
```
|
||||
|
||||
### Layout-Eintrag (Struktur)
|
||||
|
||||
| Feld | Bedeutung |
|
||||
|------|-----------|
|
||||
| `id` | Muss in `WIDGET_CATALOG` existieren |
|
||||
| `enabled` | Sichtbar auf der Übersicht |
|
||||
| `config` | Optional; nur für whitelisted Widgets mit Inhalt erlaubt |
|
||||
|
||||
---
|
||||
|
||||
## Administrierte vs. code-definierte Konfiguration
|
||||
|
||||
| Konfiguration | Speicherort | Wer pflegt? |
|
||||
|---------------|-------------|-------------|
|
||||
| Widget-IDs, Metadaten, Default-Aktivierung | `widget_catalog.py` | Entwickler |
|
||||
| Produkt-Standard-Layout (live) | `system_config.dashboard_product_default` | Admin |
|
||||
| Produkt-Standard (Fallback) | `DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS` | Entwickler |
|
||||
| Lab-/Editor-Standard | `DEFAULT_LAB_WIDGET_IDS` | Entwickler |
|
||||
| Nutzer-Layout | `profiles.dashboard_layout` | Nutzer |
|
||||
| Feature-Gate (Katalog) | `requires_feature` pro Eintrag | Entwickler |
|
||||
| Feature-Gate (Override) | `widget_feature_requirements` + Marker | Admin |
|
||||
| Config-Schema pro Widget | `dashboard_widget_config.py` | Entwickler |
|
||||
| React-Komponente | `registerDashboardWidgets.js` | Entwickler |
|
||||
|
||||
---
|
||||
|
||||
## Designprinzipien
|
||||
|
||||
### 1. Backend-Katalog als Single Source of Truth für IDs
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `WIDGET_CATALOG` ist die einzige autoritative Liste erlaubter Widget-IDs; `ALLOWED_WIDGET_IDS` wird daraus abgeleitet — nicht manuell duplizieren. |
|
||||
| **Begründung** | Layout-Validator, API und Default-Layouts bleiben synchron; unbekannte IDs werden beim PUT abgewiesen. |
|
||||
| **Quelle** | `widget_catalog.py`; Agent-Guide §4 |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Frontend-Registry ist zweite manuelle Bindung (kein Build-Time-Gate). |
|
||||
|
||||
### 2. Dual Registry: Backend-Kanon + Frontend-Komponentenbindung
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Jede Katalog-ID braucht einen Eintrag in `registerDashboardWidget({ id, Component, mapProps })`; idempotent via `ensureDashboardWidgetsRegistered()`. |
|
||||
| **Begründung** | React-Komponenten können nicht im Python-Katalog leben; explizite Zuordnung hält Bundle tree-shakeable. |
|
||||
| **Quelle** | `registerDashboardWidgets.js`, `dashboardWidgetRegistry.jsx` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Fehlende Registrierung → Laufzeit „Unbekanntes Widget“, kein CI-Fail. |
|
||||
|
||||
### 3. Layout als versioniertes Profil-JSON
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Nutzer-Layout in `profiles.dashboard_layout`; Schema `version: 1`, Liste `{ id, enabled, config? }`. |
|
||||
| **Begründung** | Pro Profil anpassbar; Reset auf NULL → System-Standard. |
|
||||
| **Quelle** | `DashboardLayoutPayload`, `app_dashboard.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Nur v1; Schema-Evolution braucht Migrationspfad. |
|
||||
|
||||
### 4. Validierung an der API-Grenze (Pydantic)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Jeder GET/PUT-Pfad normalisiert über `DashboardLayoutPayload`: Duplikat-IDs, unbekannte IDs, leeres Layout (kein enabled) → Fehler. |
|
||||
| **Begründung** | Keine korrupten Layouts in der DB; Frontend kann auf gültige Struktur vertrauen. |
|
||||
| **Quelle** | `dashboard_layout_schema.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Ungültiges gespeichertes Layout → Fallback auf Standard (`coalesce_effective_layout`). |
|
||||
|
||||
### 5. Config nur für explizit whitelisted Widgets
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `WIDGETS_ALLOWING_CONFIG`: Widgets **ohne** Eintrag dürfen nur leere `config` haben; sonst Validierungsfehler. |
|
||||
| **Begründung** | Verhindert unkontrollierte JSON-Blobs und stille Ignorierung unbekannter Keys. |
|
||||
| **Quelle** | `dashboard_widget_config.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Pro Widget heterogene Schemas (chart_days vs. KPI-Tiles vs. show_*-Booleans). |
|
||||
|
||||
### 6. Strikte Config-Keys (Whitelist, Normalisierung)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Unbekannte Keys in `config` werden abgelehnt; bekannte Keys typgeprüft und normalisiert (z. B. `chart_days` 7–90, KPI max. 9 Kacheln). |
|
||||
| **Begründung** | Vorhersagbares Verhalten; Editor und Backend stimmen überein. |
|
||||
| **Quelle** | `_validate_chart_days_only`, `_validate_kpi_board_config`, History-Viz-Defaults |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Frontend-Normalizer (`bodyChartDays.js`, `*VizConfig.js`) teils parallel — Abweichungsrisiko. |
|
||||
|
||||
### 7. Config-Größenlimit
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `MAX_WIDGET_CONFIG_JSON_BYTES` (3072) — keine großen Blobs in Layout-JSON. |
|
||||
| **Begründung** | DB-Spalte und API-Payload bleiben schlank; Config = Präferenzen, nicht Datenspeicher. |
|
||||
| **Quelle** | `dashboard_widget_config.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | — |
|
||||
|
||||
### 8. Katalog-Erweiterung ohne Layout-Reset
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `merge_missing_catalog_widgets` hängt neue Katalog-IDs ans bestehende Layout an (`enabled: false`). |
|
||||
| **Begründung** | Nutzer müssen nach Deploy nicht resetten; „Übersicht anpassen“ zeigt neue Optionen. |
|
||||
| **Quelle** | `dashboard_layout_schema.py`; Agent-Guide |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Reihenfolge neuer Widgets immer am Ende. |
|
||||
|
||||
### 9. Mehrere Standard-Layouts (Produkt vs. Lab vs. Admin)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | **Produkt:** `get_product_default_base_dict` (DB-Override oder `DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS`). **Lab:** `lab_default_layout_dict` für Editor/Reset. **Nutzer:** eigenes JSON oder NULL. |
|
||||
| **Begründung** | Onboarding-Default getrennt von Entwickler-/Lab-Template; Admin kann Produkt-Standard ohne Deploy ändern. |
|
||||
| **Quelle** | `system_dashboard_product_default.py`, `widget_catalog.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Feldname `lab_default_layout` historisch irreführend (Servertemplate, nicht nur Lab). |
|
||||
|
||||
### 10. Entitlements zentral, Widgets konsumieren nur `allowed`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Sichtbarkeit über `check_feature_access` in `widget_id_allowed`; Katalog liefert `allowed` pro Zeile. Widgets/React duplizieren **keine** Tier-Logik. |
|
||||
| **Begründung** | Eine Wahrheit für „darf angezeigt werden“; spätere Feature-Cluster ohne Widget-Refactor. |
|
||||
| **Quelle** | `dashboard_widget_entitlements.py`; Agent-Guide §0 |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Inhalts-Endpoints (Charts, KI) brauchen **eigenes** Feature-Gate (Defense in Depth). |
|
||||
|
||||
### 11. Layout-Persistenz bereinigt nicht erlaubte Widgets
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `apply_entitlements_to_layout_dict`: bei fehlender Berechtigung `enabled: false`; mindestens `welcome` bleibt aktiv. GET und PUT wenden an. |
|
||||
| **Begründung** | Keine „gespeichert aber nie sichtbar“-Zombies; Downgrade/Tier-Wechsel degradieren gracefully. |
|
||||
| **Quelle** | `dashboard_widget_entitlements.py`, `app_dashboard.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Policy ist deaktivieren, nicht entfernen — IDs bleiben im JSON. |
|
||||
|
||||
### 12. DB-Override für Widget-Feature-Anforderungen
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Katalog-`requires_feature` ist Default; Admin kann per `dashboard_widget_requirement_custom` + `widget_feature_requirements` überschreiben (AND-Semantik). |
|
||||
| **Begründung** | Runtime-Anpassung ohne Code-Deploy; Marker-Zeile trennt Custom von Fallback. |
|
||||
| **Quelle** | `widget_feature_requirements_db.py`, Migration 041 |
|
||||
| **Tragfähigkeit** | **mittel–hoch** |
|
||||
| **Einschränkung** | Zwei Quellen (Code + DB) — Dokumentation und Admin-UI nötig. |
|
||||
|
||||
### 13. mapProps: Layout-Config → Komponenten-Props
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Registry-Eintrag mappt `ctx.layoutEntry.config` auf typisierte Props (`chartDays`, `kpiConfig`, `bodyHistoryVizConfig`, …). |
|
||||
| **Begründung** | Widget-Komponenten bleiben layout-agnostisch; Normalisierung an einer Stelle pro ID. |
|
||||
| **Quelle** | `registerDashboardWidgets.js` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Teilweise Normalisierung in Widget statt in `mapProps` (inkonsistent, aber dokumentiert). |
|
||||
|
||||
### 14. Refresh-Koordination über Context
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `refreshTick` + `requestRefresh()` im Render-Context; Widgets laden Daten bei Tick-Änderung neu; Aktionen (z. B. Schnelleingabe) rufen `requestRefresh`. |
|
||||
| **Begründung** | Kein globales State-Monster; gezielte Invalidierung nach Capture. |
|
||||
| **Quelle** | `dashboardWidgetRegistry.jsx`, Widget-Implementierungen |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Kein feingranulares Cache pro Widget. |
|
||||
|
||||
### 15. Fehler-Isolation pro Widget
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `WidgetErrorBoundary` um jede Instanz — Render-Fehler crashen nicht die ganze Übersicht. |
|
||||
| **Begründung** | Robuste PWA; ein defektes Chart blockiert nicht Gewicht-Eingabe. |
|
||||
| **Quelle** | `WidgetErrorBoundary.jsx` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Kein automatisches Retry/Reporting. |
|
||||
|
||||
### 16. Konfigurator filtert nach `allowed`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `DashboardConfigurePage` blendet Widgets mit `allowed === false` aus der bearbeitbaren Liste aus. |
|
||||
| **Begründung** | Nutzer sehen keine Optionen, die sie nicht nutzen dürfen (Agent-Guide A2). |
|
||||
| **Quelle** | `DashboardConfigurePage.jsx` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Bereits gespeicherte disabled Einträge können im JSON verbleiben. |
|
||||
|
||||
### 17. Widgets konsumieren Data Layer, duplizieren keine Logik
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Chart-/KPI-Widgets rufen Chart-Endpoints bzw. API-Fassaden auf; Berechnungen leben in `data_layer/`, nicht in Widget-JS. |
|
||||
| **Begründung** | Gleiche Zahlen wie Verlauf, KI-Platzhalter und Export. |
|
||||
| **Quelle** | Layer-2b `*_history_viz`-Widgets; [DATA_LAYER_DESIGN_PRINCIPLES.md](./DATA_LAYER_DESIGN_PRINCIPLES.md) |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Legacy-Widgets unter `dashboard-widgets-legacy/` teils ältere Fetch-Pfade. |
|
||||
|
||||
### 18. Dedizierte Config-Editoren für komplexe Widgets
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Einfache `chart_days`: Set `CHART_DAYS_WIDGET_IDS` im Layout-Editor; komplexe Config: eigene Editor-Komponenten (`KpiBoardConfigEditor`, `*VizConfigEditor`). |
|
||||
| **Begründung** | UX skaliert mit Config-Komplexität; Backend-Schema und Editor bleiben parallel pflegbar. |
|
||||
| **Quelle** | `widgetSystem/*ConfigEditor.jsx`, Agent-Guide §3.4 |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Jedes neue komplexe Widget = Editor + Validator + Tests. |
|
||||
|
||||
---
|
||||
|
||||
## Nicht übernehmen
|
||||
|
||||
1. **Tier-Logik in React-Widgets** — nur `allowed` aus API; keine hardcodierten Plan-Namen.
|
||||
|
||||
2. **`ALLOWED_WIDGET_IDS` manuell pflegen** — immer aus Katalog ableiten.
|
||||
|
||||
3. **Config ohne Backend-Whitelist** — stille Ignorierung unbekannter Keys in Widgets.
|
||||
|
||||
4. **Nur UI-Gating ohne API-Absicherung** — Chart-/KI-/Export-Endpoints weiterhin `check_feature_access` (403).
|
||||
|
||||
5. **Frontend-Registry vergessen** — Katalog-Eintrag ohne `registerDashboardWidget` → Laufzeitfehler statt Build-Fail.
|
||||
|
||||
6. **Große Daten in `config`** — Layout ist Präferenzspeicher, kein Blob-Store (>3072 Bytes).
|
||||
|
||||
7. **Doppelte Widget-IDs im Layout** — Validator verbietet; Editor muss dasselbe erzwingen.
|
||||
|
||||
8. **Neue Katalog-IDs ohne `merge_missing_catalog_widgets`-Pfad** — Nutzer-Layouts veralten unsichtbar.
|
||||
|
||||
9. **Kompletter Katalog nur in DB** — schwer testbar; Code-Katalog + optionale Feature-Overrides ist das Muster.
|
||||
|
||||
10. **Evidence-Pflicht à la Placeholder-Registry** — 22 Metadatenfelder pro Widget wären Overkill; Tiefe an Risiko anpassen ([REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md](./REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md)).
|
||||
|
||||
11. **Ein Default für alles** — Produkt-Onboarding, Lab-Template und Admin-Override haben unterschiedliche Zwecke; nicht vermischen.
|
||||
|
||||
12. **Fehlender Cross-Check Backend ↔ Frontend IDs** — empfohener Test/Gate fehlt im Ist-Stand; nicht als „optional“ ignorieren.
|
||||
|
||||
13. **Berechnungslogik im Widget** — KPIs/Scores gehören in Data Layer, nicht in `useEffect`-Mathe.
|
||||
|
||||
14. **Entitlements beim Speichern ablehnen statt deaktivieren** — Mitai wählt deaktivieren; Policy bewusst festlegen und dokumentieren.
|
||||
|
||||
---
|
||||
|
||||
## Modul-Inventar (Ist-Stand)
|
||||
|
||||
```
|
||||
backend/
|
||||
├── widget_catalog.py # WIDGET_CATALOG, DEFAULT_*_IDS
|
||||
├── dashboard_layout_schema.py # Pydantic, merge_missing, defaults
|
||||
├── dashboard_widget_config.py # WIDGETS_ALLOWING_CONFIG, Validatoren
|
||||
├── dashboard_widget_entitlements.py # allowed, layout cleanup
|
||||
├── widget_feature_requirements_db.py # Admin-Override
|
||||
├── system_dashboard_product_default.py
|
||||
└── routers/app_dashboard.py
|
||||
|
||||
frontend/src/
|
||||
├── widgetSystem/
|
||||
│ ├── dashboardWidgetRegistry.jsx
|
||||
│ ├── registerDashboardWidgets.js
|
||||
│ ├── layoutEditor.js
|
||||
│ ├── bodyChartDays.js, *VizConfig.js
|
||||
│ └── *ConfigEditor.jsx
|
||||
├── components/dashboard-widgets/ # Produkt-Widgets
|
||||
├── components/dashboard-widgets-legacy/ # ältere Kern-Widgets
|
||||
└── pages/DashboardConfigurePage.jsx
|
||||
|
||||
DB:
|
||||
├── profiles.dashboard_layout
|
||||
├── system_config.dashboard_product_default
|
||||
├── dashboard_widget_requirement_custom
|
||||
└── widget_feature_requirements
|
||||
```
|
||||
|
||||
**Katalog-Umfang:** ~24 Widget-IDs (Stand `widget_catalog.py`); ~13 mit konfigurierbarer `config`.
|
||||
|
||||
---
|
||||
|
||||
## Verwandte Dokumentation
|
||||
|
||||
- Agent-Guide (normativ): [DASHBOARD_WIDGETS_AGENT_GUIDE.md](../../technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md)
|
||||
- Registry-Meta: [REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md](./REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md)
|
||||
- Feature-Gates: [FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md](./FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md)
|
||||
- Datenberechnung: [DATA_LAYER_DESIGN_PRINCIPLES.md](./DATA_LAYER_DESIGN_PRINCIPLES.md)
|
||||
- Architektur §9: `.claude/rules/ARCHITECTURE.md`
|
||||
|
||||
---
|
||||
|
||||
## Geplante Folgedokumente (Serie)
|
||||
|
||||
| # | Modul | Status |
|
||||
|---|-------|--------|
|
||||
| 1–6 | … | ✅ |
|
||||
| 7 | Dashboard Widgets | ✅ dieses Dokument |
|
||||
| 8 | Navigation / IA | ✅ |
|
||||
| 9 | Migration & Deploy | ✅ |
|
||||
|
|
@ -0,0 +1,359 @@
|
|||
# Data Layer – Designprinzipien (Extraktion)
|
||||
|
||||
**Status:** Analyse / Arbeitspapier
|
||||
**Stand:** 2026-07-04
|
||||
**Geltungsbereich:** Multi-Layer Data Architecture (Phase 0c, Issue #53) — keine Mitai-Gesamtarchitektur, keine konkrete Gesundheits-/Ernährungsfachlogik als Produktinhalt
|
||||
|
||||
**Serie:** Designprinzipien für Produktfamilie · Dokument 2 von n
|
||||
**Vorgänger:** [PROMPT_ENGINE_DESIGN_PRINCIPLES.md](./PROMPT_ENGINE_DESIGN_PRINCIPLES.md)
|
||||
|
||||
**Kernkomponenten:**
|
||||
|
||||
| Bereich | Pfade |
|
||||
|---------|-------|
|
||||
| Metriken (Layer 1) | `backend/data_layer/*_metrics.py`, `scores.py`, `correlations.py` |
|
||||
| Utilities | `backend/data_layer/utils.py` |
|
||||
| Visualisierung (Layer 2b) | `*_chart_payloads.py`, `*_viz.py` |
|
||||
| KI-Formatierung (Layer 2a-Hilfe) | `prompt_output_compact.py` |
|
||||
| Persistenz-Orchestrierung | `activity_persistence_orchestrator.py`, `activity_session_metrics.py` |
|
||||
| Konsumenten | `routers/charts.py`, `placeholder_resolver.py`, `routers/exportdata.py` |
|
||||
| Leitfäden | `DATA_LAYER_EXTENSION_GUIDE.md`, `docs/issues/issue-53-phase-0c-multi-layer-architecture.md` |
|
||||
| Architektur-Regel Import-Grenze | `.claude/rules/ARCHITECTURE.md` §8 |
|
||||
|
||||
---
|
||||
|
||||
## Modul
|
||||
|
||||
**Data Layer** (Phase 0c Multi-Layer Architecture, Issue #53)
|
||||
|
||||
Zentrale Schicht für **Datenabruf, Berechnung und strukturierte Aufbereitung** — ohne UI-Formatierung, ohne Prompt-Texte, ohne Chart.js-spezifische Ausgabe in den Kern-Metrik-Modulen.
|
||||
|
||||
---
|
||||
|
||||
## Fachliche Verantwortung
|
||||
|
||||
Der Data Layer ist die **Single Source of Truth für alle abgeleiteten Messwerte und Metriken**. Er übernimmt:
|
||||
|
||||
1. **Datenabruf** — Lesen aus PostgreSQL (profile-scoped), optional mit Quality-Filter.
|
||||
2. **Berechnung** — Trends, Scores, Korrelationen, Aggregationen, Projektionen.
|
||||
3. **Strukturierte Rückgabe** — Dicts/Listen mit numerischen Werten, Datumsfeldern, Metadaten (`confidence`, `data_points`).
|
||||
4. **Konsumenten-Bereitstellung** — Charts (Layer 2b), KI-Platzhalter (Layer 2a via Resolver), Export, Router-Anreicherung.
|
||||
|
||||
Er übernimmt **nicht**:
|
||||
|
||||
- CSV-Parsing und Feld-Mapping (Import-Schicht)
|
||||
- Prompt-Template-Auflösung (Prompt Engine)
|
||||
- React-Rendering oder Frontend-Berechnungen
|
||||
- Autorisierung / Feature-Limits (Auth-Schicht)
|
||||
|
||||
### Schichtenmodell (Multi-Layer)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Layer 0: Persistenz (PostgreSQL) │
|
||||
│ weight_log, nutrition_log, activity_log, sleep_log, … │
|
||||
└──────────────────────────┬──────────────────────────────┘
|
||||
│
|
||||
┌──────────────────────────▼──────────────────────────────┐
|
||||
│ Layer 1: DATA LAYER (Metriken) │
|
||||
│ Strukturierte Daten · confidence · data_points │
|
||||
│ KEINE formatierten Strings · KEINE Chart.js-Objekte │
|
||||
└──────────────┬───────────────────────┬──────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────────────┐ ┌────────────────────────────┐
|
||||
│ Layer 2a: KI / Prompts │ │ Layer 2b: Visualisierung │
|
||||
│ placeholder_resolver │ │ *_chart_payloads, *_viz │
|
||||
│ prompt_output_compact │ │ routers/charts.py │
|
||||
└──────────────────────────┘ └────────────────────────────┘
|
||||
```
|
||||
|
||||
### Administrierte vs. code-definierte Konfiguration
|
||||
|
||||
| Was | Wo | Administrierbar? |
|
||||
|-----|-----|------------------|
|
||||
| Berechnungslogik (Formeln, Fenster) | `data_layer/*.py` | ❌ Code + Review |
|
||||
| Confidence-Schwellen | `data_layer/utils.py` | ❌ Code |
|
||||
| Goal Mode / Focus Weights | DB (`profiles`, `user_focus_area_weights`) | ✅ Nutzer/Admin |
|
||||
| Quality Filter (Profil) | DB (`profiles`) | ✅ Admin |
|
||||
| Chart-Zeitfenster | Query-Parameter an API | ✅ Request |
|
||||
| Referenzwerte (persönlich) | DB + `reference_values.py` | ✅ Nutzer |
|
||||
| EAV Session Metrics | DB (`training_*_parameter`) | ✅ Admin |
|
||||
|
||||
**Bewusst nicht hardcodiert in Routern:** Metrik-Berechnungen — Router delegieren an Data Layer.
|
||||
|
||||
**Hardcodiert (Code):** Domänen-Module, Confidence-Regeln, Schwellen pro Metrik-Typ, TDEE-Fallback-Logik, Chart-Payload-Struktur.
|
||||
|
||||
### Trennung: Metriken · Chart-Payloads · KI-Formatierung · Persistenz
|
||||
|
||||
| Schicht | Module | Verantwortung |
|
||||
|---------|--------|---------------|
|
||||
| **Metriken** | `body_metrics.py`, `nutrition_metrics.py`, … | Reine Berechnung, strukturierte Dicts |
|
||||
| **Chart-Payloads** | `nutrition_chart_payloads.py`, `correlation_chart_payloads.py`, … | Chart.js-kompatible `{ labels, datasets, metadata }` aus Layer-1-Daten |
|
||||
| **Viz-Bundles** | `body_viz.py`, `fitness_viz.py`, … | Zusammengesetzte Dashboard-/History-Pakete für Frontend |
|
||||
| **KI-Kompaktierung** | `prompt_output_compact.py` | Token-sparende Zahlen/JSON für Platzhalter |
|
||||
| **Interpretation** | `*_interpretation.py`, `vital_signs_assessment.py` | Textliche Einordnung (WHO-Klassen etc.) — Grenze zu Layer 2a |
|
||||
| **Persistenz-Orchestrator** | `activity_persistence_orchestrator.py` | Schreibpfade REST/CSV → DB + Nebenwirkungen (EAV, Eval) |
|
||||
|
||||
### Konsumenten (wer ruft den Data Layer auf?)
|
||||
|
||||
| Konsument | Muster |
|
||||
|-----------|--------|
|
||||
| `routers/charts.py` | Layer-1-Funktion + Chart-Payload-Builder |
|
||||
| `placeholder_resolver.py` | Layer-1 → Formatierung/JSON für `{{placeholders}}` |
|
||||
| `routers/exportdata.py` | `enrich_sessions_with_metrics`, `serialize_dates` |
|
||||
| `routers/activity.py`, `csv_import.py` | `activity_persistence_orchestrator` (Schreiben) |
|
||||
| `prompt_executor.execute_prompt_with_data` | ⚠️ teils Roh-SQL parallel zum Data Layer (Legacy) |
|
||||
|
||||
### Rollen
|
||||
|
||||
Der Data Layer hat **keine eigene Admin-UI**. Konfiguration erfolgt indirekt:
|
||||
|
||||
- **Admin:** Training-Parameter, Attributprofile, Referenzwert-Typen, Quality-Filter
|
||||
- **Nutzer:** Profildaten, Referenzwerte, Focus-Area-Gewichte (beeinflussen Scores)
|
||||
- **Entwickler:** Neue Funktionen in `data_layer/` nach Extension Guide
|
||||
|
||||
---
|
||||
|
||||
## Designprinzipien
|
||||
|
||||
### 1. Single Source of Truth für Berechnungen
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Jede Metrik wird **einmal** in `data_layer/` berechnet; Charts, KI und Export konsumieren dieselbe Funktion. |
|
||||
| **Begründung** | Verhindert divergierende Zahlen zwischen Dashboard, Analyse und KI-Ausgabe. |
|
||||
| **Quelle** | Issue #53 Executive Summary; `nutrition_chart_payloads.py` Kommentar „identisch zu GET /api/charts/energy-balance“ |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Nicht alle Pfade migriert (`insights._prepare_template_vars`, `execute_prompt_with_data` Roh-SQL). |
|
||||
|
||||
### 2. Layer 1 liefert strukturierte Daten, keine formatierten Strings
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Kern-Metrik-Funktionen geben Dicts mit `float`/`int`/`date` zurück — **keine** Strings mit Einheiten („86,1 kg“). |
|
||||
| **Begründung** | Formatierung ist konsumentenspezifisch (DE-Locale, Chart-Achsen, KI-Token). |
|
||||
| **Quelle** | `data_layer/__init__.py` Docstring: „NO FORMATTING. NO STRINGS WITH UNITS.“ |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | `placeholder_resolver` und `*_interpretation` Module formatieren teils direkt — Grenze Layer 1/2a nicht überall scharf. |
|
||||
|
||||
### 3. Pflicht-Metadaten: confidence + data_points
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Jede Metrik-Funktion liefert mindestens `confidence` (`high`\|`medium`\|`low`\|`insufficient`) und `data_points`. |
|
||||
| **Begründung** | UI/KI können Datenqualität kommunizieren; Debugging und Monitoring vereinfacht. |
|
||||
| **Quelle** | `DATA_LAYER_EXTENSION_GUIDE.md` § Pflicht-Felder; `calculate_confidence()` in `utils.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Nicht runtime-validiert; Disziplin per Code-Review. |
|
||||
|
||||
### 4. Confidence nach Metrik-Typ und Zeitfenster
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Schwellen unterscheiden `general`, `correlation`, `trend` und Fensterlänge (7d / 28d / 90d). |
|
||||
| **Begründung** | Korrelationen brauchen mehr Paare; Trends messen Abdeckung (% der Tage). |
|
||||
| **Quelle** | `data_layer/utils.py` → `calculate_confidence()` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Schwellen global hardcodiert, nicht pro Metrik konfigurierbar. |
|
||||
|
||||
### 5. Domänen-Module statt Monolith
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Ein Python-Modul pro fachlichem Bereich (`body_metrics`, `nutrition_metrics`, …), max. ~500 Zeilen, dann Split. |
|
||||
| **Begründung** | Wartbarkeit, klare Ownership, parallele Entwicklung. |
|
||||
| **Quelle** | `DATA_LAYER_EXTENSION_GUIDE.md` § Modul-Struktur |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Einige Module deutlich >500 Zeilen (Phase-0c-Wachstum). |
|
||||
|
||||
### 6. Layer 2b: Chart-Payloads als Adapter
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Chart.js-Strukturen leben in dedizierten `*_chart_payloads.py` / `*_viz.py`, nicht in Metrik-Modulen. |
|
||||
| **Begründung** | Gleiche Metrik, verschiedene Visualisierungen; API-Endpoints bleiben dünn. |
|
||||
| **Quelle** | `nutrition_chart_payloads.py`; `routers/charts.py` Imports |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Teilweise noch SQL-Duplikation in Payload-Buildern neben Layer-1-Aufruf. |
|
||||
|
||||
### 7. Layer 2a-Hilfe: KI-spezifische Kompaktierung getrennt
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Token-Reduktion für LLM-Kontext (`compact_float_for_prompt`, `compact_json_payload_for_prompts`) ist eigenes Modul, nicht in Metrik-Kern. |
|
||||
| **Begründung** | KI hat andere Anforderungen als Charts (Präzision vs. Token-Kosten). |
|
||||
| **Quelle** | `prompt_output_compact.py`; Tests in `tests/test_prompt_output_compact.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Nur für KI-Pfad; Charts nutzen eigene Rundung. |
|
||||
|
||||
### 8. Import-Grenze: Ingest vs. Interpretation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | CSV-Import macht Mapping + Typkonvertierung + Duplikatlogik — **keine** fachliche Auswertung beim Insert. |
|
||||
| **Begründung** | Semantik gehört in Layer 1+, sonst versteckte Business-Logik in Import-Adaptern. |
|
||||
| **Quelle** | `ARCHITECTURE.md` §8; Issue #53 |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Legacy-Adapter (Apple-Schlaf-Aggregat, dedizierte Import-Endpoints) noch aktiv. |
|
||||
|
||||
### 9. Persistenz-Orchestrator für Schreibpfade
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Alle Schreibwege eines Domänenobjekts (REST, CSV, Legacy) laufen durch **einen** Orchestrator mit Nebenwirkungen (EAV, Evaluation). |
|
||||
| **Begründung** | Konsistente Duplikat-Erkennung, Registry-Felder, keine divergierenden Insert-Logiken. |
|
||||
| **Quelle** | `activity_persistence_orchestrator.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Bisher vor allem Aktivität; andere Domänen noch direkt in Routern. |
|
||||
|
||||
### 10. Registry als Feld-Kanon (Activity)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Erlaubte persistierbare Felder für CSV/REST leiten sich aus `module_registry` ab, nicht aus Router-Hardcoding. |
|
||||
| **Begründung** | Single Source of Truth für Import-Mappings und DB-Updates. |
|
||||
| **Quelle** | `activity_data_canon.py`, `activity_persistence_orchestrator.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Nur Activity vollständig; andere Module noch klassische Spalten-CRUD. |
|
||||
|
||||
### 11. EAV-Anreicherung als Read-Layer
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Session-Metriken (EAV) werden beim **Lesen** angereichert (`enrich_sessions_with_metrics`), nicht pro Consumer dupliziert. |
|
||||
| **Begründung** | Ein Merge-Kanon für Liste, Detail, Export, Platzhalter. |
|
||||
| **Quelle** | `activity_session_metrics.py`; `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Domänenspezifisch (Training); Muster übertragbar. |
|
||||
|
||||
### 12. Scores als composable Layer
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Composite Scores (`scores.py`) kombinieren Domänen-Metriken mit nutzer-spezifischen Focus Weights — keine Score-Logik in Routern. |
|
||||
| **Begründung** | Goal-Mode-/Focus-abhängige Gewichtung zentral, für KI und Dashboard gleich. |
|
||||
| **Quelle** | `data_layer/scores.py`; Phase-0b-Fokus-System |
|
||||
| **Tragfähigkeit** | **mittel–hoch** |
|
||||
| **Einschränkung** | Eng an Mitai-Zielsystem gekoppelt; Muster „gewichtete Composite Scores“ ist generisch. |
|
||||
|
||||
### 13. API-First: Router delegieren, rechnen nicht
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `routers/charts.py` und ähnliche Endpoints rufen Data-Layer-Funktionen auf und mappen auf HTTP — keine Trend-Berechnung im Router. |
|
||||
| **Begründung** | Testbarkeit; Frontend ohne Business-Logik. |
|
||||
| **Quelle** | `ARCHITECTURE.md` §1.2 API-First |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | `charts.py` ist groß (2246+ Zeilen) — viel Adapter-Code, aber Berechnung delegiert. |
|
||||
|
||||
### 14. serialize_dates / safe_float als Querschnitt
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | JSON/API-Serialisierung (Dates, Decimal) zentral in `utils.py`, nicht pro Modul neu erfunden. |
|
||||
| **Begründung** | PostgreSQL-Typen (DATE, DECIMAL) konsistent für API und Export. |
|
||||
| **Quelle** | `data_layer/utils.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | — |
|
||||
|
||||
### 15. Extension Guide als verbindlicher Entwicklungsvertrag
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Neue Metriken folgen Template (Retrieve → Confidence → Early Return → Calculate → Return) und werden in `__init__.py` exportiert. |
|
||||
| **Begründung** | Einheitliche Struktur für 97+ Funktionen und wachsende Codebase. |
|
||||
| **Quelle** | `DATA_LAYER_EXTENSION_GUIDE.md` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Guide und Ist-Code divergieren teils (Modulgröße, `goals.py` noch nicht in `__init__`). |
|
||||
|
||||
---
|
||||
|
||||
## Nicht übernehmen
|
||||
|
||||
Muster, die sich nicht bewährt haben oder zu produktspezifisch sind:
|
||||
|
||||
1. **Berechnungslogik in `placeholder_resolver.py`** — Phase-0b-Legacy; Resolver soll nur formatieren/aggregieren, nicht rechnen.
|
||||
|
||||
2. **Paralleler Roh-SQL-Kontext in `prompt_executor.execute_prompt_with_data`** — lädt Modul-Rohdaten per SQL, obwohl Layer 1 existiert; zweite Wahrheit.
|
||||
|
||||
3. **Legacy Insights-Pfad (`insights._prepare_template_vars`)** — eigene Variablen-Vorbereitung ohne Data Layer.
|
||||
|
||||
4. **Import mit fachlicher Interpretation** — Apple-Schlaf-Aggregat und ähnliche Adapter verstecken Semantik im Ingest (Gitea #69).
|
||||
|
||||
5. **Monolithische Router mit Inline-Berechnung** — vor Phase 0c; gelegentlich noch Reste in nicht migrierten Pfaden.
|
||||
|
||||
6. **Interpretation vermischt mit Layer 1** — `*_interpretation.py` liefert teils fertige Texte; für Familien-Architektur klar als Layer 2a/2b markieren oder auslagern.
|
||||
|
||||
7. **SQL-Duplikation in Chart-Payloads** — manche Payload-Builder führen eigene Queries statt ausschließlich Layer-1-Ergebnisse zu visualisieren.
|
||||
|
||||
8. **Hardcodierte Confidence global** — funktioniert, aber nicht pro Metrik/Domäne konfigurierbar; Skalierung in Multi-Tenant-Produktfamilie prüfen.
|
||||
|
||||
9. **Domänen-Module als Produktinhalt** — `body_metrics`, TDEE, WHR etc. sind Mitai-spezifisch; **Schichtenmodell** übernehmen, **Formeln** nicht blind kopieren.
|
||||
|
||||
10. **Fehlende runtime-Validierung des Return-Schemas** — `confidence`/`data_points` per Konvention, nicht per TypedDict/Pydantic erzwungen.
|
||||
|
||||
11. **Uneinheitliche Schreib-Orchestrierung** — nur Activity hat `persistence_orchestrator`; andere Domänen noch fragmentiert.
|
||||
|
||||
12. **Riesige Einzeldateien** — einige Metrik-Module >>500 Zeilen widersprechen eigenem Extension Guide.
|
||||
|
||||
---
|
||||
|
||||
## Modul-Inventar (Ist-Stand)
|
||||
|
||||
```
|
||||
backend/data_layer/
|
||||
├── Kern-Metriken (Layer 1)
|
||||
│ ├── body_metrics.py
|
||||
│ ├── nutrition_metrics.py
|
||||
│ ├── activity_metrics.py
|
||||
│ ├── recovery_metrics.py
|
||||
│ ├── health_metrics.py
|
||||
│ ├── scores.py
|
||||
│ └── correlations.py
|
||||
├── Visualisierung (Layer 2b)
|
||||
│ ├── *_chart_payloads.py (nutrition, recovery, correlation)
|
||||
│ └── *_viz.py (body, nutrition, fitness, recovery, history_overview)
|
||||
├── KI / Format (Layer 2a-Nähe)
|
||||
│ ├── prompt_output_compact.py
|
||||
│ └── *_interpretation.py
|
||||
├── Persistenz / EAV
|
||||
│ ├── activity_persistence_orchestrator.py
|
||||
│ ├── activity_session_metrics.py
|
||||
│ └── activity_data_canon.py
|
||||
├── Querschnitt
|
||||
│ ├── utils.py
|
||||
│ ├── reference_values.py
|
||||
│ └── nutrition_body_merge.py
|
||||
└── __init__.py (Exports)
|
||||
```
|
||||
|
||||
**Konsumenten-Endpoints (Auswahl):** 20+ Chart-Endpoints in `routers/charts.py` (E1–E5, A1–A8, R1–R5, C1–C4).
|
||||
|
||||
---
|
||||
|
||||
## Verwandte Dokumentation
|
||||
|
||||
- Issue #53 Abschluss: [issue-53-phase-0c-multi-layer-architecture.md](../../../../docs/issues/issue-53-phase-0c-multi-layer-architecture.md)
|
||||
- Extension Guide: [DATA_LAYER_EXTENSION_GUIDE.md](../../technical/DATA_LAYER_EXTENSION_GUIDE.md)
|
||||
- Fachliche Datenarchitektur: [DATA_ARCHITECTURE.md](../../functional/DATA_ARCHITECTURE.md)
|
||||
- Import-Grenze: [ARCHITECTURE.md](../../../rules/ARCHITECTURE.md) §8
|
||||
- Platzhalter-Anbindung: [PLACEHOLDER_REGISTRY_FRAMEWORK.md](../../technical/PLACEHOLDER_REGISTRY_FRAMEWORK.md)
|
||||
- Prompt Engine (Konsument Layer 2a): [PROMPT_ENGINE_DESIGN_PRINCIPLES.md](./PROMPT_ENGINE_DESIGN_PRINCIPLES.md)
|
||||
- Feature & Entitlement: [FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md](./FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md)
|
||||
|
||||
---
|
||||
|
||||
## Geplante Folgedokumente (Serie)
|
||||
|
||||
| # | Modul | Datei (geplant) |
|
||||
|---|-------|-----------------|
|
||||
| 1 | Prompt Engine | ✅ `PROMPT_ENGINE_DESIGN_PRINCIPLES.md` |
|
||||
| 2 | Data Layer | ✅ dieses Dokument |
|
||||
| 3 | Feature & Entitlement System | ✅ `FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md` |
|
||||
| 4 | Registry-/Plugin-Muster | ✅ `REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md` |
|
||||
| 5 | Auth & Session | ✅ `AUTH_SESSION_DESIGN_PRINCIPLES.md` |
|
||||
| 6 | Universal Import | ✅ `UNIVERSAL_IMPORT_DESIGN_PRINCIPLES.md` |
|
||||
| 7 | Dashboard Widgets | ✅ `DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md` |
|
||||
| 8 | Navigation / IA | ✅ `NAVIGATION_IA_DESIGN_PRINCIPLES.md` |
|
||||
| 9 | Migration & Deploy | ✅ `MIGRATION_DEPLOY_DESIGN_PRINCIPLES.md` |
|
||||
|
|
@ -0,0 +1,370 @@
|
|||
# Feature & Entitlement System – Designprinzipien (Extraktion)
|
||||
|
||||
**Status:** Analyse / Arbeitspapier
|
||||
**Stand:** 2026-07-04
|
||||
**Geltungsbereich:** Membership-, Tier- und Feature-Limit-System (v9c) — keine Mitai-Domänenlogik, kein zentrales SSO/Stripe (Vision)
|
||||
|
||||
**Serie:** Designprinzipien für Produktfamilie · Dokument 3 von n
|
||||
**Vorgänger:** [DATA_LAYER_DESIGN_PRINCIPLES.md](./DATA_LAYER_DESIGN_PRINCIPLES.md)
|
||||
|
||||
**Kernkomponenten:**
|
||||
|
||||
| Bereich | Pfade |
|
||||
|---------|-------|
|
||||
| Entitlement-Auflösung | `backend/auth.py` (`get_effective_tier`, `check_feature_access`, `increment_feature_usage`) |
|
||||
| Monitoring | `backend/feature_logger.py` |
|
||||
| Nutzer-API | `backend/routers/subscription.py`, `backend/routers/features.py` |
|
||||
| Admin | `routers/tiers_mgmt.py`, `tier_limits.py`, `coupons.py`, `access_grants.py`, `user_restrictions.py` |
|
||||
| Widget-Gating | `backend/dashboard_widget_entitlements.py`, `widget_feature_requirements_db.py` |
|
||||
| Frontend | `UsageBadge.jsx`, Feature-Usage in Seiten (z. B. `Analysis.jsx`, `WeightPage`) |
|
||||
| Doku | `MEMBERSHIP_SYSTEM.md`, `FEATURE_ENFORCEMENT.md`, `CENTRAL_SUBSCRIPTION_SYSTEM.md` (Vision) |
|
||||
|
||||
---
|
||||
|
||||
## Modul
|
||||
|
||||
**Feature & Entitlement System** (Membership v9c)
|
||||
|
||||
Zentrale Schicht für **„Darf dieser Nutzer diese Funktion wie oft nutzen?“** — unabhängig von Auth (Identität) und unabhängig von fachlicher Business-Logik in Routern.
|
||||
|
||||
---
|
||||
|
||||
## Fachliche Verantwortung
|
||||
|
||||
Das System übernimmt:
|
||||
|
||||
1. **Feature-Registry** — Deklarative Liste aller limitierbaren Produktfunktionen mit Metadaten.
|
||||
2. **Tier-Auflösung** — Effektiver Tarif eines Profils (Basis-Tier + zeitliche Grants).
|
||||
3. **Limit-Auflösung** — Pro Feature: Override → Tier-Limit → Feature-Default.
|
||||
4. **Usage-Tracking** — Zähler für Count-Features mit optionalem Reset (daily/monthly/never).
|
||||
5. **Enforcement** — HTTP 403 bei Überschreitung; Frontend-Vorschaum via Badges.
|
||||
6. **Beobachtbarkeit** — Strukturiertes JSON-Logging aller Access-Checks.
|
||||
7. **Promotionen** — Coupons → Access Grants (temporäre Tier-Elevation, Pause/Resume).
|
||||
|
||||
Es übernimmt **nicht**:
|
||||
|
||||
- Login, Session, Passwort (Auth-Modul)
|
||||
- Zahlungsabwicklung / Stripe (geplant, `CENTRAL_SUBSCRIPTION_SYSTEM.md`)
|
||||
- Mandanten-Isolation (Org/Workspace) — Entitlements sind **profile-scoped**
|
||||
- Inhaltliche Berechtigung pro Datensatz (nur Feature-Gates)
|
||||
|
||||
### Zwei Entscheidungsebenen
|
||||
|
||||
| Ebene | Frage | Funktion |
|
||||
|-------|--------|----------|
|
||||
| **Tier** | Welcher Tarif gilt? | `get_effective_tier()` |
|
||||
| **Feature** | Darf Feature X genutzt werden (wie oft)? | `check_feature_access()` |
|
||||
|
||||
Tier beeinflusst Feature-Limits über `tier_limits`; User-Overrides können Limits unabhängig vom Tier setzen.
|
||||
|
||||
### Administrierte Konfigurationen
|
||||
|
||||
| Konfiguration | Speicherort | Admin-UI |
|
||||
|---------------|-------------|----------|
|
||||
| Feature-Definitionen | `features` | Admin Features |
|
||||
| Tier-Stufen | `tiers` | Admin Tiers |
|
||||
| Tier × Feature Matrix | `tier_limits` | Admin Tier Limits |
|
||||
| User-Overrides | `user_feature_restrictions` | Admin User Restrictions |
|
||||
| Coupons | `coupons`, `coupon_redemptions` | Admin Coupons |
|
||||
| Temporäre Tier-Grants | `access_grants` | (via Coupon/Admin) |
|
||||
| Widget → Feature Mapping | `widget_feature_requirements`, Katalog | Admin Widget Features |
|
||||
| Usage-Zähler | `user_feature_usage` | (automatisch) |
|
||||
|
||||
**Nicht hardcodiert:** Limits pro Tier, Feature-Metadaten, Coupon-Parameter, User-Overrides.
|
||||
|
||||
**Hardcodiert (Code):** Feature-IDs in Routern (`'ai_calls'`, `'weight_entries'`, …), Reset-Berechnung, 4-Phasen-Muster, 11 initial registrierte Features.
|
||||
|
||||
### Auflösungs-Hierarchien
|
||||
|
||||
**Effektiver Tier** (`get_effective_tier`):
|
||||
|
||||
1. Aktiver `access_grants`-Eintrag (`is_active`, `valid_from`/`valid_until`)
|
||||
2. Fallback: `profiles.tier`
|
||||
|
||||
**Feature-Limit** (`check_feature_access` → `_check_impl`):
|
||||
|
||||
1. `user_feature_restrictions.limit_value` (höchste Priorität)
|
||||
2. `tier_limits` für effektiven Tier
|
||||
3. `features.default_limit`
|
||||
|
||||
**Limit-Semantik:**
|
||||
|
||||
| `limit_type` | Bedeutung |
|
||||
|--------------|-----------|
|
||||
| `count` | Zählbares Kontingent; `used < limit` |
|
||||
| `boolean` | An/Aus; `limit == 1` erlaubt, `0` gesperrt |
|
||||
|
||||
| `limit_value` | Bedeutung |
|
||||
|---------------|-----------|
|
||||
| `NULL` | Unbegrenzt |
|
||||
| `0` | Deaktiviert |
|
||||
| `> 0` | Kontingent oder Boolean „an“ |
|
||||
|
||||
### Rollen
|
||||
|
||||
| Rolle | Darf |
|
||||
|-------|------|
|
||||
| **Admin** | Features/Tiers/Limits/Coupons/Restrictions CRUD; alle Nutzer-Overrides |
|
||||
| **Nutzer** | Eigene Subscription/Usage lesen (`/subscription/me`, `/features/usage`); keine Limit-Änderung |
|
||||
|
||||
Enforcement gilt für alle authentifizierten Nutzer gleich — Admins haben keine automatische Bypass-Logik in `check_feature_access`.
|
||||
|
||||
### Versionierung, Freigabe, Test
|
||||
|
||||
| Mechanismus | Status |
|
||||
|-------------|--------|
|
||||
| 4-Phasen-Rollout (Monitor → UI → Enforce) | ✅ dokumentiert & angewendet |
|
||||
| JSON-Log `feature-usage.log` | ✅ Phase 2 Monitoring |
|
||||
| DB-Migration v9c für Schema | ✅ |
|
||||
| Automatisierte Enforcement-Tests pro Router | ⚠️ teilweise (Widgets getestet) |
|
||||
| Zentrale Policy „jeder Endpoint muss checken“ | ❌ nicht erzwungen |
|
||||
|
||||
---
|
||||
|
||||
## Designprinzipien
|
||||
|
||||
### 1. Feature-Registry statt hardcodierter Limits
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Jedes limitierbare Produkt-Feature ist Zeile in `features` — neue Features ohne Schema-Migration für Limits. |
|
||||
| **Begründung** | Admin-UI, Usage-API und Backend-Checks teilen dieselbe ID und Metadaten. |
|
||||
| **Quelle** | `MEMBERSHIP_SYSTEM.md` § Feature-Registry; `routers/features.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Feature-IDs müssen trotzdem in Router-Code referenziert werden. |
|
||||
|
||||
### 2. Eine Auflösungsfunktion für Entitlements
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Alle Backend- und Widget-Checks rufen `check_feature_access(profile_id, feature_id)` auf. |
|
||||
| **Begründung** | Keine duplizierte Tier/Limit-Logik in Routern, Widgets oder Frontend. |
|
||||
| **Quelle** | `auth.py`; `dashboard_widget_entitlements.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Nicht alle Endpoints nutzen es (z. B. `/prompts/execute` fehlt). |
|
||||
|
||||
### 3. Getrennte Tier- und Feature-Auflösung
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `get_effective_tier()` für Tarif; `check_feature_access()` für konkretes Feature — Tier ist Input, nicht Output der Feature-Prüfung. |
|
||||
| **Begründung** | Temporäre Grants heben Tier an; User-Override kann einzelnes Feature unabhängig anpassen. |
|
||||
| **Quelle** | `auth.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | `get_effective_tier` im Code einfacher als in `MEMBERSHIP_SYSTEM.md` (kein `tier_locked`, Trial nicht in Tier-Funktion). |
|
||||
|
||||
### 4. Prioritäts-Kette für Limits
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | User-Override > Tier-Limit > Feature-Default — explizit und dokumentiert. |
|
||||
| **Begründung** | Support/Beta-Fälle ohne Tier-Wechsel; vorhersehbares Verhalten. |
|
||||
| **Quelle** | `_check_impl()` in `auth.py`; `MEMBERSHIP_SYSTEM.md` § Zugriffs-Hierarchie |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | `user_feature_restrictions.enabled` im Schema, aber nicht in `_check_impl` ausgewertet. |
|
||||
|
||||
### 5. Count vs. Boolean als zwei Feature-Klassen
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Zählbare Aktionen (`count` + Usage) vs. Schalter-Features (`boolean`, kein Counter). |
|
||||
| **Begründung** | Pipeline-An/Aus vs. monatliche KI-Calls — unterschiedliche UX und Backend-Logik. |
|
||||
| **Quelle** | `features.limit_type`; `FEATURE_ENFORCEMENT.md` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Boolean-Features nutzen `limit_value` 0/1 — leicht mit Count zu verwechseln. |
|
||||
|
||||
### 6. Reset-Perioden für Count-Features
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `reset_period`: `never` \| `daily` \| `monthly` — Counter-Reset in `check_feature_access` bei abgelaufenem `reset_at`. |
|
||||
| **Begründung** | Monats-Kontingente vs. Lifetime-Limits in einem Modell. |
|
||||
| **Quelle** | `auth.py` → `_calculate_next_reset()` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Reset beim Check, nicht per Cron — Edge Cases bei seltenem Zugriff. |
|
||||
|
||||
### 7. Usage nur bei neuen Entitäten incrementieren
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `increment_feature_usage()` nur nach **INSERT**, nicht nach UPDATE/Upsert-Deduplikat. |
|
||||
| **Begründung** | Limits messen „neue Nutzung“, nicht Bearbeitung bestehender Daten. |
|
||||
| **Quelle** | `FEATURE_ENFORCEMENT.md` § Wichtige Regeln |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Bulk-Import muss explizit zählen; Fehler anfällig. |
|
||||
|
||||
### 8. Vier-Phasen-Rollout (Observe before Enforce)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Phase 1 Cleanup → 2 Logging → 3 Frontend-Badges → 4 HTTP 403. |
|
||||
| **Begründung** | Limits einführen ohne blind Nutzer zu blockieren; Daten für Limit-Kalibrierung. |
|
||||
| **Quelle** | `FEATURE_ENFORCEMENT.md` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Disziplin pro Feature; kein zentraler Feature-Flag pro Endpoint-Phase. |
|
||||
|
||||
### 9. Strukturiertes Feature-Logging
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Jeder Check: `log_feature_usage(profile_id, feature_id, access, action)` → JSON in `feature-usage.log`. |
|
||||
| **Begründung** | Audit, Debugging, Kalibrierung — auch wenn noch nicht enforced. |
|
||||
| **Quelle** | `feature_logger.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Log-Pfad `/app/logs` container-spezifisch. |
|
||||
|
||||
### 10. Defense in Depth: API 403 + Frontend-Gate
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Backend blockiert autoritativ; Frontend zeigt `UsageBadge`, deaktiviert Buttons, Tooltip bei Limit. |
|
||||
| **Begründung** | UX (frühes Feedback) + Sicherheit (API nicht umgehbar via curl). |
|
||||
| **Quelle** | `FEATURE_ENFORCEMENT.md`; `UsageBadge.jsx` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Frontend-Gate optional pro Seite; nicht generisch erzwungen. |
|
||||
|
||||
### 11. Nutzer-Usage-API ohne Code-Änderung bei neuen Features
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `GET /features/usage` iteriert alle aktiven `features` und ruft `check_feature_access` pro Zeile. |
|
||||
| **Begründung** | Neues DB-Feature erscheint automatisch in Quota-Übersicht. |
|
||||
| **Quelle** | `routers/features.py` → `get_feature_usage()` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Frontend muss Feature-ID kennen, um Badge zu binden. |
|
||||
|
||||
### 12. Access Grants für temporäre Tier-Elevation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Coupons/Admin erzeugen `access_grants`; effektiver Tier steigt zeitlich begrenzt. |
|
||||
| **Begründung** | Promotions, Partner (Wellpass), Trials ohne permanente Tier-Änderung. |
|
||||
| **Quelle** | `access_grants`; `routers/coupons.py` (Pause/Resume) |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Coupon-Stacking-Logik komplex; dokumentiert vs. Code prüfen bei Neuentwicklung. |
|
||||
|
||||
### 13. Entitlements als Querschnitt für UI-Module (Widgets)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Dashboard-Widgets mappen auf `features.id`; Katalog liefert `allowed` pro Profil. |
|
||||
| **Begründung** | Tier-Logik nicht in React-Widgets duplizieren (`DASHBOARD_WIDGETS_AGENT_GUIDE` §0). |
|
||||
| **Quelle** | `dashboard_widget_entitlements.py`; `ARCHITECTURE.md` §9 |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Widget-Sichtbarkeit ≠ API-Schutz — Chart-Endpoints brauchen eigenes Gating (A4). |
|
||||
|
||||
### 14. Admin-konfigurierbare Tier × Feature Matrix
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `tier_limits` trennt Tier-Definition von Limits; Tiers ohne hardcodierte Spalten pro Feature. |
|
||||
| **Begründung** | Neue Tiers/Preise ohne Code-Deploy der Limit-Logik. |
|
||||
| **Quelle** | `MEMBERSHIP_SYSTEM.md` § Tier-System; `tier_limits` Tabelle |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Tier-Namen in Seed-Daten (`free`, `premium`, …) — erweiterbar, aber Konvention. |
|
||||
|
||||
### 15. NULL = unlimited, 0 = disabled
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Einheitliche Semantik für Limit-Werte in allen Schichten. |
|
||||
| **Begründung** | Vermeidet Sonderfälle „-1 means unlimited“; klare Admin-UI. |
|
||||
| **Quelle** | `_check_impl()` in `auth.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | SQL NULL vs. Python None — konsistent, aber in UI erklärungsbedürftig. |
|
||||
|
||||
---
|
||||
|
||||
## Registrierte Features (Referenz)
|
||||
|
||||
| Feature ID | Typ | Reset | Typische Aktion |
|
||||
|------------|-----|-------|-----------------|
|
||||
| `weight_entries` | count | never | Gewicht anlegen |
|
||||
| `circumference_entries` | count | never | Umfang anlegen |
|
||||
| `caliper_entries` | count | never | Caliper anlegen |
|
||||
| `activity_entries` | count | monthly | Training anlegen/import |
|
||||
| `nutrition_entries` | count | monthly | Ernährung anlegen/import |
|
||||
| `photos` | count | monthly | Foto hochladen |
|
||||
| `ai_calls` | count | monthly | KI-Analyse |
|
||||
| `ai_pipeline` | boolean | — | Pipeline-Analyse |
|
||||
| `data_export` | count | monthly | Export/PDF |
|
||||
| `data_import` | count | monthly | ZIP/Universal-Import |
|
||||
|
||||
**Enforcement-Lücken (Ist):** `routers/prompts.py` (`/execute`, `/execute-stream`) ohne `check_feature_access` — Legacy `insights.py` hat Enforcement für `ai_calls`/`ai_pipeline`.
|
||||
|
||||
---
|
||||
|
||||
## Nicht übernehmen
|
||||
|
||||
1. **Dokumentations-Drift** — `MEMBERSHIP_SYSTEM.md` („Enforcement deaktiviert“) vs. `FEATURE_ENFORCEMENT.md` (Phase 4 komplett) vs. Ist-Code; bei Neuentwicklung einen Kanon festlegen.
|
||||
|
||||
2. **Unvollständige Tier-Auflösung** — Doku beschreibt `tier_locked`, Trial-in-Tier; Code nutzt primär Grants + `profiles.tier`. Trial (`trial_ends_at`) eher UI-Banner als Tier-Engine.
|
||||
|
||||
3. **Feature-IDs in Routern verstreut** — kein zentraler Endpoint-Registry-Eintrag „welcher Router prüft welches Feature“.
|
||||
|
||||
4. **Check und Increment nicht atomar** — Race bei parallelen Requests möglich; kein DB-Level Locking.
|
||||
|
||||
5. **Legacy Profil-Spalten parallel** — `ai_enabled`, `ai_limit_day`, `export_enabled` in Sessions-Query neben Feature-System.
|
||||
|
||||
6. **Frontend ohne Backend-Gate** — reine UI-Deaktivierung ohne 403 ist unsicher (manche Seiten nur teilweise umgesetzt).
|
||||
|
||||
7. **Boolean via limit_value 0/1** — funktioniert, aber für Familien-Architektur explizites `enabled`-Flag oder Capability-Tokens erwägen.
|
||||
|
||||
8. **Unused Schema-Felder** — `user_feature_restrictions.enabled` nicht in Auflösung eingebunden.
|
||||
|
||||
9. **App-lokales Abo ohne Zahlungsanbindung** — Stripe/SSO nur Vision (`CENTRAL_SUBSCRIPTION_SYSTEM.md`); nicht als fertiges Familien-Muster übernehmen.
|
||||
|
||||
10. **Profile als Entitlement-Subject** — kein Org/Mandant; Multi-App-Familie braucht separates Identity/Subscription-Boundary.
|
||||
|
||||
11. **Self-hosted Tier als Sonderfall** — `selfhosted` ist Deploy-Modell, kein generisches SaaS-Tier-Muster.
|
||||
|
||||
12. **Increment-Schleifen bei Bulk** — `for _ in range(new_entries): increment_feature_usage()` — ineffizient; batch-Inkrement besser.
|
||||
|
||||
---
|
||||
|
||||
## Modul-Inventar (Ist-Stand)
|
||||
|
||||
```
|
||||
backend/
|
||||
├── auth.py # get_effective_tier, check_feature_access, increment_feature_usage
|
||||
├── feature_logger.py # JSON-Logging
|
||||
├── dashboard_widget_entitlements.py # Widget allowed + Layout-Sanitisierung
|
||||
├── widget_feature_requirements_db.py
|
||||
└── routers/
|
||||
├── subscription.py # /me, /usage, /limits (Nutzer)
|
||||
├── features.py # Admin CRUD + /usage, /check-access
|
||||
├── tiers_mgmt.py, tier_limits.py
|
||||
├── coupons.py, access_grants.py
|
||||
└── user_restrictions.py
|
||||
|
||||
frontend/src/components/
|
||||
└── UsageBadge.jsx # Quota-Anzeige (Phase 3)
|
||||
```
|
||||
|
||||
**DB (v9c):** `features`, `tiers`, `tier_limits`, `user_feature_restrictions`, `user_feature_usage`, `coupons`, `coupon_redemptions`, `access_grants`, `user_activity_log`
|
||||
|
||||
---
|
||||
|
||||
## Verwandte Dokumentation
|
||||
|
||||
- Membership-Detail: [MEMBERSHIP_SYSTEM.md](../../technical/MEMBERSHIP_SYSTEM.md)
|
||||
- Enforcement-Howto: [FEATURE_ENFORCEMENT.md](../../architecture/FEATURE_ENFORCEMENT.md)
|
||||
- Vision Produktfamilie: [CENTRAL_SUBSCRIPTION_SYSTEM.md](../../technical/CENTRAL_SUBSCRIPTION_SYSTEM.md)
|
||||
- Widget-Gating: [DASHBOARD_WIDGETS_AGENT_GUIDE.md](../../technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md) §0
|
||||
- Prompt Engine (Enforcement-Lücke): [PROMPT_ENGINE_DESIGN_PRINCIPLES.md](./PROMPT_ENGINE_DESIGN_PRINCIPLES.md)
|
||||
|
||||
---
|
||||
|
||||
## Geplante Folgedokumente (Serie)
|
||||
|
||||
| # | Modul | Status |
|
||||
|---|-------|--------|
|
||||
| 1 | Prompt Engine | ✅ |
|
||||
| 2 | Data Layer | ✅ |
|
||||
| 3 | Feature & Entitlement | ✅ dieses Dokument |
|
||||
| 4 | Registry-/Plugin-Muster | ✅ |
|
||||
| 5 | Auth & Session | ✅ |
|
||||
| 6 | Universal Import | ✅ |
|
||||
| 7 | Dashboard Widgets | ✅ |
|
||||
| 8 | Navigation / IA | ✅ |
|
||||
| 9 | Migration & Deploy | ✅ `MIGRATION_DEPLOY_DESIGN_PRINCIPLES.md` |
|
||||
|
|
@ -0,0 +1,369 @@
|
|||
# Migration & Deploy – Designprinzipien (Extraktion)
|
||||
|
||||
**Status:** Analyse / Arbeitspapier
|
||||
**Stand:** 2026-07-04
|
||||
**Geltungsbereich:** DB-Migrationen, Container-Startup, CI/CD-Deploy — keine Anwendungsdomäne
|
||||
|
||||
**Serie:** Designprinzipien für Produktfamilie · Dokument 9 von n (Abschluss)
|
||||
**Vorgänger:** [NAVIGATION_IA_DESIGN_PRINCIPLES.md](./NAVIGATION_IA_DESIGN_PRINCIPLES.md)
|
||||
|
||||
**Kernkomponenten:**
|
||||
|
||||
| Bereich | Pfade |
|
||||
|---------|-------|
|
||||
| DB-Init & Migrationen | `backend/db_init.py`, `backend/startup.sh` |
|
||||
| Migrationen | `backend/migrations/XXX_*.sql` |
|
||||
| Basis-Schema (Greenfield) | `backend/schema.sql` |
|
||||
| Tracking | Tabelle `schema_migrations` |
|
||||
| Compose Prod/Dev | `docker-compose.yml`, `docker-compose.dev-env.yml` |
|
||||
| CI/CD | `.gitea/workflows/deploy-dev.yml`, `deploy-prod.yml`, `test.yml` |
|
||||
| Versionierung | `backend/version.py` (`APP_VERSION`, `DB_SCHEMA_VERSION`, `MODULE_VERSIONS`) |
|
||||
| Doku (operativ) | `MIGRATIONS.md` |
|
||||
| Architektur-Regeln | `.claude/rules/ARCHITECTURE.md` §2, §7 |
|
||||
|
||||
---
|
||||
|
||||
## Modul
|
||||
|
||||
**Migration & Deploy**
|
||||
|
||||
Automatische **PostgreSQL-Schema-Evolution** beim Container-Start plus **Git-getriebene Deploy-Pipeline** (develop → Dev, main → Prod) auf selbst-gehosteter Infrastruktur (Docker auf Raspberry Pi).
|
||||
|
||||
---
|
||||
|
||||
## Fachliche Verantwortung
|
||||
|
||||
Das Modul übernimmt:
|
||||
|
||||
1. **Schema-Migrationen** — Nummerierte SQL-Dateien, idempotent wo möglich, getrackt in `schema_migrations`.
|
||||
2. **Startup-Orchestrierung** — Postgres ready → Schema/Migrationen → optional SQLite-Import → Uvicorn.
|
||||
3. **Umgebungstrennung** — Dev (`3099`/`8099`) vs. Prod (`3002`/`8002`), getrennte DBs/Volumes.
|
||||
4. **Deploy-Automatisierung** — Push auf Branch → Runner → `git reset --hard` → `docker compose build --no-cache` → Health-Check.
|
||||
5. **Post-Deploy-Tests** — Pytest/Lint/Frontend-Build gegen **deployed** Container auf dem Runner.
|
||||
6. **Versions-Metadaten** — App-/Modul-Version und dokumentierte `DB_SCHEMA_VERSION`.
|
||||
|
||||
Es übernimmt **nicht**:
|
||||
|
||||
- Fachliche Datenberechnungen (→ Data Layer)
|
||||
- Automatisches Downgrade/Rollback von Schema
|
||||
- Blue-Green oder Multi-Region-Deploy
|
||||
|
||||
### Deploy-Pipeline (Happy Path)
|
||||
|
||||
```
|
||||
Entwickler: commit → push develop
|
||||
→ Gitea Runner: deploy-dev.yml
|
||||
→ cd /home/lars/docker/bodytrack-dev
|
||||
→ git fetch + reset --hard origin/develop
|
||||
→ docker compose -f docker-compose.dev-env.yml build --no-cache && up -d
|
||||
→ backend startup.sh → db_init.py (Migrationen)
|
||||
→ curl localhost:8099/api/auth/status
|
||||
→ test.yml (push + nach Deploy): pytest im Container, py_compile, npm run build
|
||||
|
||||
Prod: PR develop → main → deploy-prod.yml (Port 8002, bodytrack/)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Administrierte vs. code-definierte Konfiguration
|
||||
|
||||
| Konfiguration | Speicherort | Wer pflegt? |
|
||||
|---------------|-------------|-------------|
|
||||
| Migration-SQL | `backend/migrations/` | Entwickler |
|
||||
| Welche Migrationen angewendet | `schema_migrations` (DB) | Automatisch |
|
||||
| Greenfield-Basis | `schema.sql` | Entwickler (selten) |
|
||||
| Compose/Ports/Env | `docker-compose*.yml`, `.env` auf Server | Betrieb |
|
||||
| Deploy-Workflow | `.gitea/workflows/*.yml` | Entwickler |
|
||||
| App-Version / Changelog | `backend/version.py` | Entwickler (pro Release) |
|
||||
| Prod-Geheimnisse | Server-`.env`, nicht im Repo | Betrieb |
|
||||
|
||||
---
|
||||
|
||||
## Designprinzipien
|
||||
|
||||
### 1. Migrationen beim Container-Start ( nicht manuell in Prod)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `startup.sh` ruft `db_init.py` auf **bevor** Uvicorn startet; pending Migrationen werden automatisch angewendet. |
|
||||
| **Begründung** | Kein vergessenes Schema-Update; Deploy und DB-Stand bleiben gekoppelt. |
|
||||
| **Quelle** | `startup.sh`, `db_init.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Fehlgeschlagene Migration blockiert API-Start (`sys.exit(1)`). |
|
||||
|
||||
### 2. Nummeriertes Datei-Pattern als Gate
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Nur `\d{3}_*.sql` wird ausgeführt (z. B. `054_activity_session_metrics_eav.sql`); alles andere wird ignoriert. |
|
||||
| **Begründung** | Sortierbare Reihenfolge; Ad-hoc-Skripte (`check_features.sql`, `v9c_*.sql`) verunreinigen nicht den Lauf. |
|
||||
| **Quelle** | `run_migrations()` Regex |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Legacy-Dateien ohne Nummer liegen noch im Ordner (historischer Ballast). |
|
||||
|
||||
### 3. Tracking-Tabelle als Single Source of „applied“
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `schema_migrations(filename)` — jede erfolgreiche Datei genau einmal eingetragen; Pending = Dateien minus Applied. |
|
||||
| **Begründung** | Idempotenter Startup; wiederholter Container-Start wendet nichts doppelt an. |
|
||||
| **Quelle** | `ensure_migration_table`, `apply_migration` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Kein checksum — geänderte Datei nach Apply wird nicht erneut ausgeführt. |
|
||||
|
||||
### 4. Alphabetische Reihenfolge = Migrations-Reihenfolge
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `sorted(glob)` — dreistellige Präfixe (`001`, `054`, `061`) definieren die Apply-Order. |
|
||||
| **Begründung** | Einfach, git-freundlich, keine separate Migrations-Registry. |
|
||||
| **Quelle** | `run_migrations()` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Nummern-Kollisionen oder nachträgliches Einfügen erfordern Disziplin (immer nächste freie Nummer). |
|
||||
|
||||
### 5. Fail-Fast bei Migrationsfehler
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Schlägt eine Migration fehl → kein Commit in Tracking (bei Exception vor INSERT), Prozess exit 1, Container unhealthy. |
|
||||
| **Begründung** | API läuft nicht mit halb angewendetem Schema. |
|
||||
| **Quelle** | `apply_migration`, `main` in `db_init.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Manueller Recovery-Prozess nötig (siehe MIGRATIONS.md Rollback). |
|
||||
|
||||
### 6. Greenfield: schema.sql, Bestand: nur Migrationen
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Existiert `profiles` nicht → einmalig `schema.sql` laden; danach nur noch nummerierte Migrationen. |
|
||||
| **Begründung** | Frische Instanz schnell bootstrapped; langlebige DBs evolvieren incremental. |
|
||||
| **Quelle** | `check_table_exists`, `load_schema` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | `schema.sql` kann hinter Migrationen zurückfallen wenn nicht gepflegt. |
|
||||
|
||||
### 7. Idempotente DDL bevorzugen
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, defensive UPDATEs — Migration soll mehrfach ausführbar sein ohne Schaden. |
|
||||
| **Begründung** | Recovery nach partiellem Apply; manuelles Re-Run sicherer. |
|
||||
| **Quelle** | `MIGRATIONS.md` Best Practices |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Nicht alle Änderungen sind idempotent (DROP, irreversible Datenmigration). |
|
||||
|
||||
### 8. Kein psql-Meta in Migrationsdateien
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Nur SQL — kein `\echo`, `\i`, `\connect`; Ausführung via psycopg2, nicht interaktiv. |
|
||||
| **Begründung** | Parser/Runner versteht nur SQL-Statements. |
|
||||
| **Quelle** | `MIGRATIONS.md`, `apply_migration` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | — |
|
||||
|
||||
### 9. Schema-Änderung = nummerierte Migration (nie ad-hoc in Prod)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Neue Tabellen/Spalten **nur** via `backend/migrations/XXX_*.sql`; nicht direkt in laufender Prod-DB editieren. |
|
||||
| **Begründung** | Reproduzierbarkeit Dev→Prod; Review im Git-Diff. |
|
||||
| **Quelle** | ARCHITECTURE.md, CLAUDE.md |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Agent-Regel — technisch nicht erzwungen. |
|
||||
|
||||
### 10. DB_SCHEMA_VERSION als dokumentierter Marker
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `backend/version.py` → `DB_SCHEMA_VERSION` bei Schema-Änderung manuell bumpen (Format z. B. `YYYYMMDD` + Suffix). |
|
||||
| **Begründung** | API `/api/version` und Changelog zeigen Schema-Stand unabhängig von App-Minor. |
|
||||
| **Quelle** | ARCHITECTURE.md §2.6 |
|
||||
| **Tragfähigkeit** | **mittel–hoch** |
|
||||
| **Einschränkung** | Nicht automatisch aus `schema_migrations` abgeleitet — Drift möglich. |
|
||||
|
||||
### 11. Branch → Umgebung (develop / main)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `develop` → Dev-Deploy automatisch; `main` → Prod-Deploy automatisch; Prod nur nach expliziter Freigabe/Merge. |
|
||||
| **Begründung** | Klare Promotion; Dev als Integrationsumgebung. |
|
||||
| **Quelle** | Workflows, CLAUDE.md Deployment |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Kein Staging-Branch zwischen Dev und Prod. |
|
||||
|
||||
### 12. Deploy-Arbeitskopie = exakt Remote-Branch
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Runner: `git fetch` + `git reset --hard origin/<branch>` — keine `pull`-Merge-Konflikte, kein schmutziger `package-lock` auf dem Pi. |
|
||||
| **Begründung** | Reproduzierbarer Deploy-Baum; Fix aus GUI-IA-Abnahme 2026-04-05. |
|
||||
| **Quelle** | `deploy-prod.yml`, `deploy-dev.yml` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Lokale Hotfixes auf dem Server werden beim Deploy überschrieben. |
|
||||
|
||||
### 13. Immutabler Build pro Deploy (`--no-cache`)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `docker compose build --no-cache` bei jedem Deploy — frisches Image aus Dockerfile + Repo-Stand. |
|
||||
| **Begründung** | Keine veralteten Layer; Migrationen und Code garantiert im Image. |
|
||||
| **Quelle** | Deploy-Workflows |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Langsamere Deploys; kein Registry-basiertes Image-Promotion. |
|
||||
|
||||
### 14. Health-Check nach Deploy
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Nach `up -d`: kurz warten, dann `curl -sf …/api/auth/status` (8099 Dev / 8002 Prod). |
|
||||
| **Begründung** | Minimale Smoke-Verification dass API antwortet (inkl. DB-Init durchlaufen). |
|
||||
| **Quelle** | Deploy-Workflows |
|
||||
| **Tragfähigkeit** | **mittel–hoch** |
|
||||
| **Einschränkung** | Prüft nicht fachliche Endpoints oder Migration-Inhalt. |
|
||||
|
||||
### 15. Persistente Volumes für Daten und Fotos
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Postgres-Daten, `/app/data`, `/app/photos` in benannten/external Volumes — überleben Container-Rebuild. |
|
||||
| **Begründung** | Deploy = neues Image, nicht Datenverlust. |
|
||||
| **Quelle** | `docker-compose*.yml` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Volume-Backup/Restore ist Betriebsaufgabe außerhalb Repo. |
|
||||
|
||||
### 16. Postgres Healthcheck vor Backend-Start
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `depends_on: condition: service_healthy` — Backend startet erst wenn DB `pg_isready`. |
|
||||
| **Begründung** | `wait_for_postgres` in db_init ist zweite Absicherung; reduziert Race beim ersten Start. |
|
||||
| **Quelle** | Compose-Files |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | — |
|
||||
|
||||
### 17. Tests gegen deployed Stack (Self-Hosted Runner)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `test.yml` führt pytest **im laufenden Backend-Container** auf dem Pi aus, nicht in isolierter GitHub-Cloud. |
|
||||
| **Begründung** | Tests laufen gegen echte Dev/Prod-Compose-Umgebung des Projekts. |
|
||||
| **Quelle** | `.gitea/workflows/test.yml` |
|
||||
| **Tragfähigkeit** | **mittel** |
|
||||
| **Einschränkung** | Prod-Deploy triggert Tests auf Prod-Pfad — Risiko wenn Tests schreibend; `-m 'not slow'` begrenzt Laufzeit. |
|
||||
|
||||
### 18. Feste Ports pro Umgebung
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Dev `3099/8099`, Prod `3002/8002` — nicht ändern (Reverse Proxy/Fritz!Box hängen daran). |
|
||||
| **Begründung** | Externe URLs (`dev.mitai.jinkendo.de`, `mitai.jinkendo.de`) stabil. |
|
||||
| **Quelle** | CLAUDE.md, Compose |
|
||||
| **Tragfähigkeit** | **hoch** (betriebsspezifisch) |
|
||||
| **Einschränkung** | Andere Projekte brauchen eigene Port-Matrix. |
|
||||
|
||||
### 19. Prod-Schutz: Deploy nur über Git
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Keine direkten Prod-Container-/DB-Schreibzugriffe für Automation; Prod-Änderung = Merge `main` → Workflow. |
|
||||
| **Begründung** | Audit-Trail, Review, keine Drift. |
|
||||
| **Quelle** | ARCHITECTURE.md §7.1, `/deploy` Command |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Menschlicher SSH-Zugriff bleibt möglich — Prozess, nicht Technik. |
|
||||
|
||||
### 20. Versions-Bump als Release-Disziplin
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Jede lieferbare Änderung: `APP_VERSION`, betroffene `MODULE_VERSIONS`, `CHANGELOG` in `version.py`; bei Schema auch `DB_SCHEMA_VERSION`. |
|
||||
| **Begründung** | `/api/version`, Support, Korrelation Deploy ↔ Code. |
|
||||
| **Quelle** | ARCHITECTURE.md §2.5, `deploy.md` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Frontend-`version.js` in Spec erwähnt, im Repo teils nicht vorhanden — Dual-Bump unvollständig. |
|
||||
|
||||
---
|
||||
|
||||
## Nicht übernehmen
|
||||
|
||||
1. **Unnummerierte Migrationsdateien** — `v9c_*.sql`, `check_*.sql` werden nicht auto-applied; nicht als Vorbild.
|
||||
|
||||
2. **Migration-Datei nach Apply ändern** — Tracking verhindert Re-Run; neue Nummer statt Edit.
|
||||
|
||||
3. **Automatischer Downgrade** — nicht implementiert; Rollback manuell + Tracking-Eintrag löschen.
|
||||
|
||||
4. **Direktes Schema in Prod** — immer Git-Migration + Deploy.
|
||||
|
||||
5. **Breaking DROP ohne Koordination** — App-Code und Migration in einem Release.
|
||||
|
||||
6. **psql-Metacommands in `.sql`** — bricht Python-Runner.
|
||||
|
||||
7. **`git pull` auf Deploy-Server** — Merge-Schmutz; `reset --hard` ist das Muster.
|
||||
|
||||
8. **Prod-Deploy ohne Dev-Validierung** — develop-First ist implizite Policy.
|
||||
|
||||
9. **Schema-Drift ohne `DB_SCHEMA_VERSION`-Bump** — dokumentarische Lücke.
|
||||
|
||||
10. **Cached Docker-Build als Default** — Mitai wählt Reproduzierbarkeit über Geschwindigkeit.
|
||||
|
||||
11. **Migrationen außerhalb Container-Start vergessen** — manuelles psql in Prod als Normalfall.
|
||||
|
||||
12. **Hardcoded Seed-Daten in Migration** — produktive User/Secrets nicht in SQL.
|
||||
|
||||
13. **Port-Änderung „nebenbei“** — Infrastruktur-Kopplung.
|
||||
|
||||
14. **Tests nur lokal, nie auf Runner-Stack** — Mitai testet bewusst post-deploy im Pi-Container (Trade-off verstehen).
|
||||
|
||||
15. **Transaktionssteuerung in SQL-Datei** — Runner committet pro Datei; komplexe multi-step Rollbacks nicht eingebaut.
|
||||
|
||||
---
|
||||
|
||||
## Modul-Inventar (Ist-Stand)
|
||||
|
||||
```
|
||||
backend/
|
||||
├── db_init.py # wait, schema, run_migrations, sqlite import
|
||||
├── startup.sh # db_init → uvicorn
|
||||
├── schema.sql # Greenfield
|
||||
├── migrations/ # 001–061+ nummeriert (+ Legacy ohne Nummer)
|
||||
└── version.py # APP_VERSION, DB_SCHEMA_VERSION, MODULE_VERSIONS
|
||||
|
||||
docker-compose.yml # Prod: 3002/8002
|
||||
docker-compose.dev-env.yml # Dev: 3099/8099
|
||||
|
||||
.gitea/workflows/
|
||||
├── deploy-dev.yml # push develop
|
||||
├── deploy-prod.yml # push main
|
||||
└── test.yml # pytest, lint, npm build on Pi
|
||||
|
||||
Server (Pi):
|
||||
/home/lars/docker/bodytrack-dev/ # develop
|
||||
/home/lars/docker/bodytrack/ # main
|
||||
```
|
||||
|
||||
**Migrationen (Stand):** 60+ nummerierte Dateien (`001` … `061`); höchste Nummer im Repo prüfen vor neuer Migration.
|
||||
|
||||
---
|
||||
|
||||
## Verwandte Dokumentation
|
||||
|
||||
- Operativ: [MIGRATIONS.md](../../technical/MIGRATIONS.md)
|
||||
- Architektur: `.claude/rules/ARCHITECTURE.md` §2 (Versionierung), §7 (Prod-Schutz)
|
||||
- Deploy-Command: `.claude/commands/deploy.md`, `merge-to-prod.md`
|
||||
- Import/Migration-Grenze: [UNIVERSAL_IMPORT_DESIGN_PRINCIPLES.md](./UNIVERSAL_IMPORT_DESIGN_PRINCIPLES.md)
|
||||
- Auth auf Prod: [AUTH_SESSION_DESIGN_PRINCIPLES.md](./AUTH_SESSION_DESIGN_PRINCIPLES.md)
|
||||
|
||||
---
|
||||
|
||||
## Serie – Übersicht (abgeschlossen)
|
||||
|
||||
| # | Modul | Status |
|
||||
|---|-------|--------|
|
||||
| 1 | Prompt Engine | ✅ `PROMPT_ENGINE_DESIGN_PRINCIPLES.md` |
|
||||
| 2 | Data Layer | ✅ `DATA_LAYER_DESIGN_PRINCIPLES.md` |
|
||||
| 3 | Feature & Entitlement | ✅ `FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md` |
|
||||
| 4 | Registry / Plugin (Meta) | ✅ `REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md` |
|
||||
| 5 | Auth & Session | ✅ `AUTH_SESSION_DESIGN_PRINCIPLES.md` |
|
||||
| 6 | Universal Import | ✅ `UNIVERSAL_IMPORT_DESIGN_PRINCIPLES.md` |
|
||||
| 7 | Dashboard Widgets | ✅ `DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md` |
|
||||
| 8 | Navigation / IA | ✅ `NAVIGATION_IA_DESIGN_PRINCIPLES.md` |
|
||||
| 9 | Migration & Deploy | ✅ dieses Dokument |
|
||||
|
|
@ -0,0 +1,346 @@
|
|||
# Navigation & Informationsarchitektur – Designprinzipien (Extraktion)
|
||||
|
||||
**Status:** Analyse / Arbeitspapier
|
||||
**Stand:** 2026-07-04
|
||||
**Geltungsbereich:** App-Navigation, Bereichs-Shells, Admin-IA, Responsive Shell — keine Seiteninhalte oder Domänenlogik
|
||||
|
||||
**Serie:** Designprinzipien für Produktfamilie · Dokument 8 von n
|
||||
**Vorgänger:** [DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md](./DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md)
|
||||
|
||||
**Kernkomponenten:**
|
||||
|
||||
| Bereich | Pfade |
|
||||
|---------|-------|
|
||||
| Hauptnavigation | `frontend/src/config/appNav.js` |
|
||||
| Erfassung | `frontend/src/config/captureNav.js`, `layouts/CaptureShell.jsx` |
|
||||
| Einstellungen | `frontend/src/config/settingsNav.js`, `layouts/SettingsShell.jsx` |
|
||||
| Admin | `frontend/src/config/adminNav.js`, `layouts/AdminShell.jsx`, `RequireAdmin.jsx` |
|
||||
| KI-Analyse (Kategorien) | `frontend/src/config/analysisCategories.js`, `pages/Analysis.jsx` |
|
||||
| Routing | `frontend/src/App.jsx` |
|
||||
| Desktop-Sidebar | `frontend/src/components/DesktopSidebar.jsx` |
|
||||
| Responsive CSS | `frontend/src/app.css` (`--nav-h`, `.bottom-nav`, `.analysis-split`, `.desktop-sidebar`) |
|
||||
| Abnahme-Doku | `docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md` |
|
||||
| Responsive-Spec | `.claude/docs/functional/RESPONSIVE_UI.md` |
|
||||
|
||||
---
|
||||
|
||||
## Modul
|
||||
|
||||
**Navigation & Informationsarchitektur (IA)**
|
||||
|
||||
Schichtenmodell für die PWA: **eine primäre Hauptnavigation** (6–7 Bereiche), darunter **Bereichs-Shells** mit eigener Sub-Navigation, getrennte **Admin-Realm**, **Auth-Gates** und **ein Breakpoint** für Mobile vs. Desktop.
|
||||
|
||||
---
|
||||
|
||||
## Fachliche Verantwortung
|
||||
|
||||
Das Modul übernimmt:
|
||||
|
||||
1. **Hauptnav-SSoT** — Reihenfolge, Labels, Icons, Admin-Sichtbarkeit (`getMainNavItems`).
|
||||
2. **Routing-Struktur** — Welche URL gehört zu welchem Bereich (Übersicht, Erfassen, Verlauf, Ziele, Analyse, Einstellungen, Admin).
|
||||
3. **Sub-Navigation pro Bereich** — Capture-Hub, Settings-Tabs, Admin-Gruppen, Analyse-Kategorien.
|
||||
4. **Layout-Muster** — Bottom-Nav (mobil), Sidebar (Desktop), `analysis-split` für tiefe Bereiche.
|
||||
5. **Zugriffskontrolle (UI)** — `RequireAdmin`, Admin-Link nur bei `role === 'admin'`.
|
||||
6. **Active-State** — Nested Routes (Erfassung unter `/capture`, Admin unter `/admin/*`).
|
||||
7. **PWA-Tauglichkeit** — Safe Area, Scrollbare Bottom-Nav, Content-Padding.
|
||||
|
||||
Es übernimmt **nicht**:
|
||||
|
||||
- Backend-Autorisierung (→ [AUTH_SESSION_DESIGN_PRINCIPLES.md](./AUTH_SESSION_DESIGN_PRINCIPLES.md))
|
||||
- Feature-Entitlements in der Nav (Tier-Gates an Endpoints/Widgets, nicht an jedem NavLink)
|
||||
- Inhaltliche Tab-Logik innerhalb von Verlauf/Analyse (Seiten concern)
|
||||
|
||||
### IA-Modell (Nutzerperspektive)
|
||||
|
||||
| Ebene | Mental Model | Beispiel-Routen |
|
||||
|-------|--------------|-----------------|
|
||||
| **Primär** | Wo bin ich in der App? | `/`, `/capture`, `/history`, `/goals`, `/analysis`, `/settings` |
|
||||
| **Sekundär (Shell)** | Was mache ich in diesem Bereich? | `/weight`, `/admin/g/features`, `/settings/dashboard-layout` |
|
||||
| **Tertiär (Seite)** | Tabs/Filter innerhalb einer Maske | Verlauf-Tabs, Analyse-Kategorien |
|
||||
|
||||
### Strategisch vs. taktisch (Ziele)
|
||||
|
||||
| Ebene | Ort | Zweck |
|
||||
|-------|-----|-------|
|
||||
| **Strategisch** | `/goals` (Hauptnav) | Ziele definieren, Prioritäten, Focus Areas |
|
||||
| **Taktisch** | `/custom-goals` (Erfassung) | Tägliche Ist-Werte für eigene Ziele |
|
||||
| **Auswertung** | `/history` | Trends, Charts, Vergleiche |
|
||||
|
||||
---
|
||||
|
||||
## Administrierte vs. code-definierte Konfiguration
|
||||
|
||||
| Konfiguration | Speicherort | Wer pflegt? |
|
||||
|---------------|-------------|-------------|
|
||||
| Hauptnav-Reihenfolge & Labels | `appNav.js` | Entwickler |
|
||||
| Erfassungs-Kacheln & Shell-Nav | `captureNav.js` | Entwickler |
|
||||
| Admin-Gruppen & Hub-Karten | `adminNav.js` | Entwickler |
|
||||
| Settings-Subnav | `settingsNav.js` | Entwickler |
|
||||
| Analyse-Kategorie-Reihenfolge | `analysisCategories.js` | Entwickler |
|
||||
| KI-Prompt-Kategorien (Runtime) | DB `ai_prompts.category` | Admin (Prompts) |
|
||||
| React-Routes | `App.jsx` | Entwickler (muss zu Nav-Configs passen) |
|
||||
|
||||
---
|
||||
|
||||
## Designprinzipien
|
||||
|
||||
### 1. Eine Quelle für die Hauptnavigation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `getMainNavItems(isAdmin)` liefert dieselbe Item-Liste für **Bottom-Nav** und **Desktop-Sidebar** — keine parallelen Hardcodings. |
|
||||
| **Begründung** | Reihenfolge und Labels bleiben synchron; Admin-Conditional an einer Stelle. |
|
||||
| **Quelle** | `appNav.js`, `App.jsx`, `DesktopSidebar.jsx` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Active-State-Logik ist in zwei Dateien dupliziert (`navItemActive` / `sidebarLinkActive`). |
|
||||
|
||||
### 2. Feste primäre IA-Reihenfolge (Produkt-Story)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Übersicht → Erfassen → Verlauf → **Ziele** → Analyse → Einstellungen → [Admin] — spiegelt Nutzerfluss: sehen → eingeben → auswerten → steuern → interpretieren → konfigurieren. |
|
||||
| **Begründung** | Ziele als eigener Hauptpunkt (nicht unter Analyse versteckt); klare Trennung Capture vs. History vs. Analysis. |
|
||||
| **Quelle** | `GUI_IA_ADMIN_NAV_2026-04-05.md`; `appNav.js` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Produkt-spezifisch; andere Apps können andere Reihenfolge brauchen. |
|
||||
|
||||
### 3. Config-Dateien pro Bereich (Nav-as-Data)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Sub-Navigation lebt in dedizierten Config-Modulen (`captureNav`, `adminNav`, `settingsNav`, `analysisCategories`) — Shell-Komponenten iterieren nur. |
|
||||
| **Begründung** | Neue Erfassungsmaske = Eintrag in Config + Route; kein Nav-HTML in jeder Page. |
|
||||
| **Quelle** | `captureNav.js` Kommentar „Pfade müssen mit Routes übereinstimmen“ |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Kein Build-Time-Check Config ↔ Routes. |
|
||||
|
||||
### 4. Bereichs-Shells für tiefe Navigation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Capture, Settings und Admin nutzen **Shell-Layouts** mit `<Outlet />`; Nutzer wechselt Sub-Bereiche ohne Hauptnav zu verlassen. |
|
||||
| **Begründung** | Erfassung hat 12+ Masken — wären als Hauptnav-Einträge unbrauchbar. |
|
||||
| **Quelle** | `CaptureShell`, `SettingsShell`, `AdminShell` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Verlauf und Analyse haben eigene Tab-Muster (kein gemeinsames Shell-Config). |
|
||||
|
||||
### 5. Wiederverwendbares `analysis-split`-Layout
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Admin, Settings und KI-Analyse teilen CSS-Muster: mobil horizontale Chips, Desktop linke Spalte + `__main` für Inhalt. |
|
||||
| **Begründung** | Ein visuelles Muster für „Kategorie links, Arbeit rechts“; weniger UI-Drift. |
|
||||
| **Quelle** | `AdminShell.jsx`, `SettingsShell.jsx`, `Analysis.jsx`, `app.css` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Capture nutzt eigenes `capture-shell` (Emoji-Icons, Hub-Kacheln). |
|
||||
|
||||
### 6. Admin: Gruppen in der Shell, Seiten über Hub
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Shell-Nav zeigt nur **Admin-Gruppen** (+ Übersicht); konkrete Seiten als Karten auf `/admin/g/:groupId`. |
|
||||
| **Begründung** | Skaliert bei wachsender Admin-Oberfläche; keine 20er-Sidebar. |
|
||||
| **Quelle** | `adminNav.js` (`ADMIN_GROUPS`, `getAdminShellNavEntries`) |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Ein Klick mehr als flache Nav; bewusster Trade-off. |
|
||||
|
||||
### 7. Admin als eigener Realm
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `/admin/*` hinter `RequireAdmin`; kein Admin-Block mehr in Einstellungen; Profil-Anlage nur Admin → Benutzerverwaltung. |
|
||||
| **Begründung** | Trennung Nutzer- vs. Betreiber-Kontext; weniger Verwechslung. |
|
||||
| **Quelle** | `RequireAdmin.jsx`, `GUI_IA_ADMIN_NAV_2026-04-05.md` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | UI-Guard ersetzt nicht Backend-`require_admin` auf APIs. |
|
||||
|
||||
### 8. Route-Guard mit Nutzer-Feedback
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Nicht-Admin auf `/admin` → Redirect `/` mit `state.adminDenied`; Dashboard zeigt Hinweis. |
|
||||
| **Begründung** | Stilles Scheitern vermeiden; klare Erwartung. |
|
||||
| **Quelle** | `RequireAdmin.jsx`, `Dashboard.jsx` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | — |
|
||||
|
||||
### 9. Nested Active-State für Section-Prefixes
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Custom Active-Logik: `/capture` aktiv bei allen Erfassungs-Pfaden; `/admin` bei gesamten Admin-Baum; `/goals` mit `end: true` (exakt). |
|
||||
| **Begründung** | React-Router `end` allein reicht für Section-Gruppen nicht. |
|
||||
| **Quelle** | `navItemActive`, `adminShellEntryIsActive` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Neue Section-Prefixes brauchen explizite Regel. |
|
||||
|
||||
### 10. Erfassungs-Hub + direkte Deep-Links
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `/capture` = Kachel-Hub; jede Maske auch direkt erreichbar (`/weight`, …); Shell-Nav immer sichtbar. |
|
||||
| **Begründung** | Onboarding über Hub; Power-User/Dashboard-Links springen direkt. |
|
||||
| **Quelle** | `CaptureHub`, `CAPTURE_HUB_TILES` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Hub und Shell-Nav listen dieselben Ziele (Doppelpflege). |
|
||||
|
||||
### 11. Einstellungen: nur aktives Profil
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Settings = Self-Service für **aktives** Profil (Name, E-Mail, Avatar, Quality-Filter); keine Profil-Liste für Endnutzer. |
|
||||
| **Begründung** | Multi-Profil-Verwaltung ist Admin-Aufgabe; reduziert Komplexität. |
|
||||
| **Quelle** | `SettingsPage.jsx`, IA-Doku |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Session-bound Profile-Id-Schwäche bleibt Backend-Thema. |
|
||||
|
||||
### 12. Settings-Subnav für Layout & Export
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Konfiguration schwerer Features (Dashboard-Layout, PDF-Berichte, Referenzwerte) als eigene Settings-Routen unter Shell — nicht in „Allgemein“ verstecken. |
|
||||
| **Begründung** | Entspricht [DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md](./DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md) (Nutzer-Konfigurator). |
|
||||
| **Quelle** | `settingsNav.js` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Admin-Dashboard-Default liegt unter `/admin/...` (getrennte Rolle). |
|
||||
|
||||
### 13. KI-Analyse: Ergebnis im Hauptspalt
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Neue Analyse-Ergebnisse rendern in `analysis-split__main`, nicht in der Kategorie-Nav — Nav bleibt wählbar. |
|
||||
| **Begründung** | Lange Ergebnisse verdrängen sonst die Prompt-Auswahl (Mobile). |
|
||||
| **Quelle** | `GUI_IA_ADMIN_NAV_2026-04-05.md`, `Analysis.jsx` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | — |
|
||||
|
||||
### 14. Ein Breakpoint Mobile / Desktop (1024px)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `< 1024px`: Bottom-Nav + Mobile-Header; `≥ 1024px`: Desktop-Sidebar, Bottom-Nav ausgeblendet, breiterer Content. **Kein** separates Tablet-Layout. |
|
||||
| **Begründung** | Einfache Spec, PWA-first; iPad im Portrait = Mobile-Verhalten. |
|
||||
| **Quelle** | `RESPONSIVE_UI.md`, `app.css` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Große Phones und kleine Tablets identisch behandelt. |
|
||||
|
||||
### 15. PWA Safe Area für Bottom-Navigation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `--nav-h`, `--nav-pad-top`, `env(safe-area-inset-bottom)` auf `.bottom-nav`; Content-`padding-bottom` inkl. Nav-Höhe; horizontal scrollbare Nav bei vielen Items. |
|
||||
| **Begründung** | iPhone Home-Indicator und Notch — kein Clipping, kein verdeckter Content. |
|
||||
| **Quelle** | `app.css`, IA-Doku |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Safe Area nur auf Nav/Content-Padding, nicht global überall. |
|
||||
|
||||
### 16. Auth-Routen außerhalb der App-Shell
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Login, Register, Verify, Reset-Password rendern **ohne** Bottom-Nav/Sidebar — minimale Vollbild-Cards. |
|
||||
| **Begründung** | Keine Navigation ohne Session; klarer Fokus. |
|
||||
| **Quelle** | `App.jsx` (early returns vor `AppShell`) |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Public Routes nicht zentral in einer Route-Config. |
|
||||
|
||||
### 17. Rollen-sichtbare Nav-Einträge (UI only)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Admin-Link erscheint nur wenn `isAdmin`; Backend schützt APIs separat. |
|
||||
| **Begründung** | Progressive disclosure; normale Nutzer sehen keinen toten Link. |
|
||||
| **Quelle** | `getMainNavItems(isAdmin)` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Security nicht durch Ausblenden ersetzt. |
|
||||
|
||||
### 18. Deep-Link-State für Verlauf
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Nav zu `/history` setzt optional `state: { tab: 'overview' }` — konsistenter Einstieg von Hauptnav. |
|
||||
| **Begründung** | Verlauf merkt sich Tabs; Hauptnav soll nicht zufälligen alten Tab öffnen. |
|
||||
| **Quelle** | `App.jsx`, `DesktopSidebar.jsx`, `History.jsx` |
|
||||
| **Tragfähigkeit** | **mittel** |
|
||||
| **Einschränkung** | Nur für History implementiert, nicht app-weit. |
|
||||
|
||||
---
|
||||
|
||||
## Nicht übernehmen
|
||||
|
||||
1. **Hauptnav an mehreren Stellen hardcoden** — immer `appNav.js`.
|
||||
|
||||
2. **Admin-Funktionen in Einstellungen** — eigener `/admin`-Bereich.
|
||||
|
||||
3. **Alle Erfassungsmasken in die Bottom-Nav** — Shell + Hub skaliert.
|
||||
|
||||
4. **Alle Admin-Seiten in der Shell-Sidebar** — Hub-Gruppen-Muster beibehalten.
|
||||
|
||||
5. **Nav-Config ohne Route-Pflege** — jeder neue Pfad: Config + `App.jsx` + ggf. Active-State.
|
||||
|
||||
6. **UI-Admin-Guard ohne Backend-Guard** — `RequireAdmin` ist UX, APIs brauchen `require_admin`.
|
||||
|
||||
7. **Zwei Tablet-/Desktop-Breakpoints** — Mitai: ein Cut bei 1024px.
|
||||
|
||||
8. **Safe Area ignorieren** — PWA auf iOS bricht sonst an Bottom-Nav.
|
||||
|
||||
9. **Profil-Liste für Endnutzer in Settings** — Multi-Profil = Admin.
|
||||
|
||||
10. **Feature-Tier-Logik in Nav-Komponenten** — Entitlements an Widgets/APIs ([FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md](./FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md)).
|
||||
|
||||
11. **Inkonsistente Layout-Muster pro Bereich** — wo `analysis-split` passt, nicht neues Ad-hoc-Layout erfinden.
|
||||
|
||||
12. **Orphan-Routes ohne Nav-Ergänzung** — z. B. `/subscription`, `/workflow-editor/:id` existieren außerhalb Haupt-IA; bewusst dokumentieren, nicht unkontrolliert multiply.
|
||||
|
||||
13. **Active-State nur per Router-Default** — Section-Prefixes (`/capture/*`, `/admin/*`) brauchen explizite Regeln.
|
||||
|
||||
14. **Analyse-Ergebnisse in der Nav-Spalte** — verdrängt Prompt-Auswahl auf Mobile.
|
||||
|
||||
---
|
||||
|
||||
## Modul-Inventar (Ist-Stand)
|
||||
|
||||
```
|
||||
frontend/src/config/
|
||||
├── appNav.js # Hauptnav (6 + Admin)
|
||||
├── captureNav.js # Erfassungs-Hub + Shell
|
||||
├── settingsNav.js # Settings-Subnav
|
||||
├── adminNav.js # ADMIN_GROUPS, Shell-Entries
|
||||
└── analysisCategories.js # KI-Analyse-Gruppen
|
||||
|
||||
frontend/src/layouts/
|
||||
├── CaptureShell.jsx
|
||||
├── SettingsShell.jsx
|
||||
├── AdminShell.jsx
|
||||
└── RequireAdmin.jsx
|
||||
|
||||
frontend/src/components/
|
||||
└── DesktopSidebar.jsx
|
||||
|
||||
frontend/src/App.jsx # Routes + Bottom-Nav + Auth-Gates
|
||||
frontend/src/app.css # Shell, split, safe-area, 1024px breakpoint
|
||||
```
|
||||
|
||||
**Hauptnav (7 Einträge mit Admin):** Übersicht · Erfassen · Verlauf · Ziele · Analyse · Einstellungen · Admin
|
||||
|
||||
**Admin-Gruppen (8):** users · features · subscription · training · goals · prompts · system
|
||||
|
||||
---
|
||||
|
||||
## Verwandte Dokumentation
|
||||
|
||||
- Abnahme-Stand: [GUI_IA_ADMIN_NAV_2026-04-05.md](../../../../docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md)
|
||||
- Responsive-Spec: [RESPONSIVE_UI.md](../../functional/RESPONSIVE_UI.md)
|
||||
- Auth/Session: [AUTH_SESSION_DESIGN_PRINCIPLES.md](./AUTH_SESSION_DESIGN_PRINCIPLES.md)
|
||||
- Dashboard-Konfigurator: [DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md](./DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md)
|
||||
- Gitea #30 (Responsive UI, teilweise erledigt)
|
||||
|
||||
---
|
||||
|
||||
## Geplante Folgedokumente (Serie)
|
||||
|
||||
| # | Modul | Status |
|
||||
|---|-------|--------|
|
||||
| 1–7 | … | ✅ |
|
||||
| 8 | Navigation / IA | ✅ dieses Dokument |
|
||||
| 9 | Migration & Deploy | ✅ |
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
# Prompt Engine – Designprinzipien (Extraktion)
|
||||
|
||||
**Status:** Analyse / Arbeitspapier
|
||||
**Stand:** 2026-07-04
|
||||
**Geltungsbereich:** Modul „Prompt Engine“ (Unified Prompt System, Issue #28) — keine Mitai-Gesamtarchitektur, keine Domänenlogik (Gesundheit, Ernährung, Messwerte)
|
||||
|
||||
**Serie:** Designprinzipien für Produktfamilie · Dokument 1 von n
|
||||
|
||||
**Kernkomponenten:**
|
||||
|
||||
| Bereich | Pfade |
|
||||
|---------|-------|
|
||||
| Executor | `backend/prompt_executor.py`, `backend/workflow_executor.py` |
|
||||
| Platzhalter | `backend/placeholder_resolver.py`, `backend/placeholder_registry.py`, `backend/placeholder_registrations/` |
|
||||
| API | `backend/routers/prompts.py`, `backend/routers/workflows.py` |
|
||||
| Admin-UI | `frontend/src/pages/AdminPromptsPage.jsx`, `UnifiedPromptModal.jsx`, `WorkflowEditorPage.jsx` |
|
||||
| Fachliche Spec | `.claude/docs/functional/AI_PROMPTS.md` |
|
||||
| Platzhalter-Governance | `.claude/docs/technical/PLACEHOLDER_REGISTRY_FRAMEWORK.md`, `docs/PLACEHOLDER_GOVERNANCE.md` |
|
||||
|
||||
---
|
||||
|
||||
## Modul
|
||||
|
||||
**Prompt Engine** (Unified Prompt System, Issue #28)
|
||||
|
||||
Backend-Kern: `prompt_executor.py`, `placeholder_resolver.py`, `placeholder_registry` / `placeholder_registrations/`, `workflow_executor.py`
|
||||
API: `routers/prompts.py`
|
||||
Admin-UI: `AdminPromptsPage`, `UnifiedPromptModal`, `WorkflowEditorPage`
|
||||
|
||||
---
|
||||
|
||||
## Fachliche Verantwortung
|
||||
|
||||
Die Prompt Engine ist die **zentrale Ausführungs- und Konfigurationsschicht für KI-Analysen**. Sie übernimmt:
|
||||
|
||||
1. **Prompt-Orchestrierung** — Laden aktiver Prompt-Definitionen aus der DB und Ausführung als `base` (Einzelprompt), `pipeline` (mehrstufig) oder `workflow` (Graph).
|
||||
2. **Kontextaufbereitung** — Befüllen von Platzhaltern mit profilbezogenen Daten (Resolver → Data Layer).
|
||||
3. **LLM-Aufruf** — Einheitlicher OpenRouter-Call über injizierte Callback-Funktion.
|
||||
4. **Ergebnisbehandlung** — JSON-Validierung, strukturierte Container (Fragenergänzungen), Debug-Metadaten, optionales Speichern in `ai_insights`.
|
||||
5. **Admin-Konfiguration** — CRUD für Prompts/Workflows, Import/Export, Vorschau und Test ohne Produktions-Ausführung.
|
||||
|
||||
### Administrierte Konfigurationen
|
||||
|
||||
| Konfiguration | Speicherort | Inhalt |
|
||||
|---------------|-------------|--------|
|
||||
| Prompt-Metadaten | `ai_prompts` | `name`, `slug`, `category`, `active`, `sort_order`, `display_name` |
|
||||
| Templates | `ai_prompts` | `template` |
|
||||
| Pipeline-Stages | `ai_prompts.stages` (JSONB) | Stages mit `inline` / `reference` |
|
||||
| Workflow-Graphen | `ai_prompts.graph_data` | Knoten, Kanten, Metadaten |
|
||||
| Output-Regeln | `ai_prompts` | `output_format`, `output_schema` |
|
||||
| Fragenergänzungen | `ai_prompts.question_augmentations` | Optionale Standard-Fragen (Hybridmodell: Knoten > Prompt) |
|
||||
| System-Reset | `ai_prompts` | `is_system_default`, `default_template` |
|
||||
| Legacy-Pipeline-Configs | `pipeline_configs` | Module, Zeiträume, Stage-Slugs (parallel zum Unified System) |
|
||||
| Workflow-Fragenkatalog | `workflow_question_catalog` | Fragetypen, Templates, Normalisierung |
|
||||
|
||||
### Bewusst nicht hardcodiert
|
||||
|
||||
- Prompt-Texte, Pipeline-Zusammensetzung, Workflow-Topologie
|
||||
- Kategorie, Sichtbarkeit (`active`), Sortierung
|
||||
- Output-Format und Schema pro Prompt
|
||||
- Referenz vs. Inline in Pipeline-Stages
|
||||
|
||||
### Hardcodiert (Code / Env)
|
||||
|
||||
- Platzhalter-Definitionen und Resolver (`PLACEHOLDER_MAP`, Registry)
|
||||
- LLM-Modell (`OPENROUTER_MODEL`)
|
||||
- Default-Module und -Zeiträume in `/prompts/execute`
|
||||
- Domänen-Kategorien im Frontend (`analysisCategories.js`)
|
||||
- Meta-Prompts für Generate/Optimize (Admin-Tooling)
|
||||
|
||||
### Trennung: Template · Platzhalter · Kontext · Workflow
|
||||
|
||||
| Schicht | Ort | Rolle |
|
||||
|---------|-----|-------|
|
||||
| **Templates** | `ai_prompts.template`, `stages`, Knoten-Templates im Graph | Was an die KI geht |
|
||||
| **Platzhalter** | `placeholder_resolver` + Registry | Semantische API-Keys `{{key}}`, Resolver-Funktionen |
|
||||
| **Kontextdaten** | `execute_prompt_with_data` + Resolver → `data_layer/` | Werte für Platzhalter |
|
||||
| **Workflows** | `graph_data` + `workflow_executor` | Ausführungsgraph, Verzweigung, Join, Aggregation |
|
||||
|
||||
### Durchsetzung: keine Sonderlogik außerhalb der Engine
|
||||
|
||||
**Konzeptionell:** Ein Executor (`execute_prompt` → `execute_prompt_with_data`) als Single Entry Point.
|
||||
|
||||
**Praktisch unvollständig:** Legacy-Pfade in `insights.py` (`/insights/run`, `/insights/pipeline`) mit eigener Template-Logik (`_prepare_template_vars`, `_render_template`) und direkten LLM-Calls; `History.jsx` nutzt noch `runInsight`. Kein technischer Guard (Lint/Policy), nur Konvention.
|
||||
|
||||
### Rollen und Berechtigungen
|
||||
|
||||
| Rolle | Darf |
|
||||
|-------|------|
|
||||
| **Admin** (`require_admin`) | Prompts/Workflows/Pipeline-Configs CRUD, Import/Export, Reset-to-default, Generate/Optimize, Platzhalter-Metadaten-ZIP |
|
||||
| **Nutzer** (`require_auth`) | Aktive Prompts listen (ohne Pipeline-Slugs), ausführen (`/prompts/execute`), Preview, Platzhalter-Katalog, eigene Werte exportieren |
|
||||
|
||||
Workflow-Editor-Route (`/workflow-editor/:id`) ist nicht hinter `RequireAdmin`; Schreib-APIs sind admin-geschützt.
|
||||
|
||||
### Versionierung, Freigabe, Test
|
||||
|
||||
| Mechanismus | Status |
|
||||
|-------------|--------|
|
||||
| Prompt-Versionsverlauf in DB | ❌ Overwrite |
|
||||
| Reset-to-default für System-Prompts | ✅ `is_system_default` + `default_template` |
|
||||
| JSON Import/Export (Dev→Prod) | ✅ `/export-all`, `/import` |
|
||||
| Admin-Test mit Debug | ✅ `debug=true`, UnifiedPromptModal |
|
||||
| Preview ohne LLM | ✅ `POST /preview` |
|
||||
| Platzhalter-Deprecation-Prozess | 📄 dokumentiert, nicht runtime-erzwungen |
|
||||
| Formales Freigabe-Workflow | ❌ |
|
||||
| Executor-E2E-Tests | ⚠️ punktuell (Modifier, Output-Compact) |
|
||||
|
||||
---
|
||||
|
||||
## Designprinzipien
|
||||
|
||||
### 1. Single Executor für Prompt-Ausführung
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Alle KI-Analysen laufen über `execute_prompt` / `execute_prompt_with_data`. |
|
||||
| **Begründung** | Einheitliche Platzhalter-Auflösung, Debug, JSON-Validierung, Speicher-Metadaten. |
|
||||
| **Quelle** | `backend/prompt_executor.py`; `POST /api/prompts/execute`; `Analysis.jsx` → `executeUnifiedPromptStream` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Legacy `insights.py` und teils `History.jsx` umgehen den Executor noch. |
|
||||
|
||||
### 2. Konfigurierbare Prompt-Bibliothek statt fest verdrahteter Texte
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Prompt-Inhalte und Workflows liegen in `ai_prompts`, nicht im Anwendungscode. |
|
||||
| **Begründung** | Admins können Analysen anpassen, duplizieren, deaktivieren, ohne Deploy. |
|
||||
| **Quelle** | Migration 020; `UnifiedPromptCreate`/`Update` in `models.py`; `AdminPromptsPage.jsx` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Parallel existieren noch `pipeline_configs` und hardcodierte Default-Module/Zeiträume. |
|
||||
|
||||
### 3. Drei Prompt-Typen mit klarer Verantwortung
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `base` = wiederverwendbarer Baustein; `pipeline` = sequenzielle Stages; `workflow` = Graph mit Verzweigung. |
|
||||
| **Begründung** | Komposition ohne Copy-Paste; Reference-Prompts in Pipelines (`source: 'reference'`). |
|
||||
| **Quelle** | `execute_prompt()` Typ-Verzweigung; `StagePromptCreate` in `models.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Pipeline-Stages laufen sequentiell, obwohl konzeptionell „parallel“; `workflow_definitions` und `ai_prompts.graph_data` doppelt. |
|
||||
|
||||
### 4. Platzhalter als API-Verträge (Registry)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Platzhalter sind registrierte, dokumentierte Verträge — keine freien Prompt-Hilfsvariablen. |
|
||||
| **Begründung** | Konsistenz für Injektion, GUI-Picker, Export, Validierung. |
|
||||
| **Quelle** | `PLACEHOLDER_REGISTRY_FRAMEWORK.md`; `docs/PLACEHOLDER_GOVERNANCE.md`; `import placeholder_registrations` in `main.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Duplikat `PLACEHOLDER_MAP` in `placeholder_resolver.py` neben Registry; Metadaten teils noch Legacy. |
|
||||
|
||||
### 5. Trennung Template (Was) vs. Resolver (Daten)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Templates enthalten nur `{{keys}}`; Berechnung liegt in Resolver/Data Layer. |
|
||||
| **Begründung** | Prompt-Autoren ändern Text, nicht Berechnungslogik. |
|
||||
| **Quelle** | `resolve_placeholders()` in `prompt_executor.py`; Registry-Felder `resolver_function`, `data_layer_function` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | `execute_prompt_with_data` lädt zusätzlich Roh-SQL pro Modul — zweite Kontext-Schicht. |
|
||||
|
||||
### 6. Layer-1-Daten vs. Layer-2a-Prompt-Injektion
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Berechnungen in `data_layer/`; Prompt Engine konsumiert nur formatierte Werte. |
|
||||
| **Begründung** | Single Source of Truth für Charts, Platzhalter, KI. |
|
||||
| **Quelle** | Phase-0c-Architektur; Registry-Felder `data_layer_module` / `layer_1_decision` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Legacy `_prepare_template_vars` in `insights.py` umgeht Data Layer. |
|
||||
|
||||
### 7. Transparenz durch Debug- und Preview-Modus
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Aufgelöste/unaufgelöste Platzhalter, Final-Prompt und Stage-Outputs sind inspizierbar; Preview ohne LLM. |
|
||||
| **Begründung** | Admin kann Prompts testen und Wertetabelle/Expertenmodus speisen. |
|
||||
| **Quelle** | `debug`-Parameter; `/preview`; `UnifiedPromptModal` Test-Button; `ai_insights.metadata` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Debug-Daten in Responses können groß/sensibel sein; kein separates Staging. |
|
||||
|
||||
### 8. Wiederverwendbare Base-Prompts via Reference
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Pipeline-Stages referenzieren Slugs statt Templates zu duplizieren. |
|
||||
| **Begründung** | Ein Baustein, mehrere Workflows; zentral wartbar. |
|
||||
| **Quelle** | `source == 'reference'` in `execute_pipeline_prompt()` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Keine Referenz-Versionierung; Änderung am Base-Prompt wirkt sofort auf alle Referenzen. |
|
||||
|
||||
### 9. Strukturierte LLM-Ausgaben per Output-Format
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Pro Prompt/Prompt-Def: `output_format: text\|json`, optional `output_schema`; Pipeline-Outputs als Stage-Keys im Kontext. |
|
||||
| **Begründung** | Maschinenlesbare Zwischenergebnisse für Multi-Stage und Wertetabelle. |
|
||||
| **Quelle** | `validate_json_output()`; Stage `output_key` in Pipeline |
|
||||
| **Tragfähigkeit** | **mittel** |
|
||||
| **Einschränkung** | JSON-Schema-Validierung ist TODO (`jsonschema`); Markdown-Unwrap als Heuristik. |
|
||||
|
||||
### 10. Admin-only Konfiguration, User-only Ausführung
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Schreibende Prompt-/Workflow-Operationen nur mit `require_admin`. |
|
||||
| **Begründung** | Produktions-Prompts sind Systemkonfiguration, nicht Nutzerdaten. |
|
||||
| **Quelle** | `require_admin` in `routers/prompts.py`; `RequireAdmin` für `/admin/prompts` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | `/workflow-editor/:id` ohne Frontend-Admin-Gate; `/prompts/execute` ohne `check_feature_access` (Legacy-Pfad in `insights.py` hat Enforcement). |
|
||||
|
||||
### 11. Import/Export als Umgebungs-Sync
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Prompt-Sätze als JSON exportierbar/importierbar (Dev→Prod). |
|
||||
| **Begründung** | Konfiguration versionierbar in Git, nicht in der App-DB. |
|
||||
| **Quelle** | `GET /export-all`, `POST /import` in `routers/prompts.py`; Admin-UI Buttons |
|
||||
| **Tragfähigkeit** | **mittel** |
|
||||
| **Einschränkung** | Kein Diff, keine Merge-Strategie, kein Rollback; Overwrite-Flag manuell. |
|
||||
|
||||
### 12. Workflow-Erweiterung: Graph + Fragenergänzungen + Signale
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Workflows als Knoten/Kanten-Graph; optionale Fragen am Knoten; Normalisierung/Logic/Join als Engine-Schicht. |
|
||||
| **Begründung** | Bedingte, verzweigte Analysen jenseits linearer Pipelines. |
|
||||
| **Quelle** | `workflow_executor.py`; Migration 034; `question_augmenter.py` |
|
||||
| **Tragfähigkeit** | **mittel** |
|
||||
| **Einschränkung** | Hohe Komplexität; zwei Speicherorte (`graph_data` vs. `workflow_definitions`); Jinja2 im Workflow-Pfad zusätzlich zu `{{}}`-Resolver. |
|
||||
|
||||
### 13. Platzhalter-Modifier für KI-Kontext
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `{{key\|d}}` (Wert + Beschreibung), `{{key\|x}}` (Erklärung ohne Zahl) über Katalog-Metadaten. |
|
||||
| **Begründung** | Prompts können Kontext für das Modell reichhaltiger machen ohne Template-Duplikate. |
|
||||
| **Quelle** | `resolve_placeholders()` Modifier-Logik; `get_placeholder_catalog()` |
|
||||
| **Tragfähigkeit** | **mittel** |
|
||||
| **Einschränkung** | Modifier-Syntax ad hoc; Katalog-Pflicht für sinnvolle `\|x`-Nutzung. |
|
||||
|
||||
### 14. System-Prompt-Reset statt DB-Versionierung
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Shipped Prompts mit `is_system_default` + `default_template`; Admin-Reset auf Original. |
|
||||
| **Begründung** | Schutz vor irreversiblen Fehlkonfigurationen ohne vollständiges Versionsmodell. |
|
||||
| **Quelle** | Migration 019; `POST /{prompt_id}/reset-to-default` |
|
||||
| **Tragfähigkeit** | **mittel** |
|
||||
| **Einschränkung** | Nur ein Default-Snapshot; keine Historie benutzerdefinierter Änderungen. |
|
||||
|
||||
### 15. Governance für Platzhalter-Änderungen
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Breaking Changes nur über Deprecation + Replacement; semantische Verträge dokumentiert. |
|
||||
| **Begründung** | Prompts in Produktion brechen nicht still. |
|
||||
| **Quelle** | `docs/PLACEHOLDER_GOVERNANCE.md` §4 |
|
||||
| **Tragfähigkeit** | **mittel** (prozessual) |
|
||||
| **Einschränkung** | Prozess in Doku, nicht im Runtime erzwungen; Checkliste verweist noch auf Legacy-Dateien. |
|
||||
|
||||
---
|
||||
|
||||
## Nicht übernehmen
|
||||
|
||||
Muster, die sich nicht bewährt haben oder zu produktspezifisch sind — bei Neuentwicklung vermeiden:
|
||||
|
||||
1. **Parallele Ausführungspfade** — Legacy `insights.py` (`/insights/run`, `/insights/pipeline`) mit eigener Template-Engine und LLM-Calls neben `prompt_executor`; Frontend-Split (`Analysis` vs. `History`).
|
||||
|
||||
2. **Doppelte Metadaten für Platzhalter** — `PLACEHOLDER_MAP`, Registry, `placeholder_metadata_complete.py` und Katalog-Fallbacks parallel; erzeugt Sync-Risiko (114 Keys müssen deckungsgleich bleiben).
|
||||
|
||||
3. **Zwei Pipeline-Modelle gleichzeitig** — `pipeline_configs` (3 fixe Stages) und Unified `type=pipeline` in `ai_prompts`; Migration 020 migriert, Tabelle bleibt aktiv.
|
||||
|
||||
4. **Zwei Workflow-Speicher** — `workflow_definitions.graph` und `ai_prompts.graph_data`; unklare Single Source of Truth.
|
||||
|
||||
5. **Roh-SQL-Kontextladung im Executor** — `execute_prompt_with_data` lädt Modul-Rohdaten per SQL, obwohl Resolver/Data Layer existieren; Domänenwissen in der Engine.
|
||||
|
||||
6. **Hardcodierte Execute-Defaults** — Module/Zeiträume in `/execute` fest verdrahtet statt aus Prompt-/Pipeline-Konfiguration.
|
||||
|
||||
7. **Fehlende Feature-Enforcement-Konsistenz** — `check_feature_access` auf Legacy-Insights, nicht auf `/prompts/execute`.
|
||||
|
||||
8. **„Parallel“ als sequentiell implementiert** — Pipeline-Stages kommentiert als parallel, Code sequentiell; irreführendes Modell.
|
||||
|
||||
9. **Unvollständige Output-Validierung** — JSON-Parse + Markdown-Unwrap, Schema-Check auskommentiert/TODO.
|
||||
|
||||
10. **Workflow-Editor ohne klares Admin-Gate in Routing** — Schreib-API geschützt, UI-Route für alle Authentifizierten erreichbar.
|
||||
|
||||
11. **Domänen-spezifische Hardcodings in der Engine** — Kategorien (`körper`, `ernährung`, …), deutsche Meta-Prompts für Generate/Optimize, Fitness-Kontext in Prompt-Generator.
|
||||
|
||||
12. **Kein integriertes Prompt-Versions- und Freigabemodell** — Overwrite + JSON-Export ersetzt keine Revision/Review/Publish-Pipeline.
|
||||
|
||||
13. **Issue #51 (Seitenzuordnung) nicht umgesetzt** — Prompt-Verfügbarkeit kontextuell nicht konfigurierbar; alles über zentrale Analyse-Seite.
|
||||
|
||||
14. **Globales LLM-Modell per Env** — `workflow_executor` übergibt Modell pro Call, `call_openrouter` ignoriert es; keine prompt-spezifische Modellwahl trotz API-Shape.
|
||||
|
||||
---
|
||||
|
||||
## Verwandte Dokumentation
|
||||
|
||||
- Fachliche Spec: [AI_PROMPTS.md](../../functional/AI_PROMPTS.md)
|
||||
- Platzhalter-Registry: [PLACEHOLDER_REGISTRY_FRAMEWORK.md](../../technical/PLACEHOLDER_REGISTRY_FRAMEWORK.md)
|
||||
- Platzhalter-Governance: [PLACEHOLDER_GOVERNANCE.md](../../../../docs/PLACEHOLDER_GOVERNANCE.md)
|
||||
- Issue #28 (Unified Prompt System): abgeschlossen, siehe `CLAUDE.md`
|
||||
- Issue #51 (Prompt-Seitenzuordnung): [issue-51-prompt-page-assignment.md](../../../../docs/issues/issue-51-prompt-page-assignment.md)
|
||||
- **Serie (abgeschlossen):** [Index](./README.md) · [Jinkendo Foundation](../README.md) · Dokumente #1–#9
|
||||
136
.claude/docs/jinkendo-foundation/design-principles/README.md
Normal file
136
.claude/docs/jinkendo-foundation/design-principles/README.md
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
# Designprinzipien – Index (Jinkendo Foundation)
|
||||
|
||||
**Teil von:** [Jinkendo Foundation](../README.md)
|
||||
|
||||
**Status:** Arbeitspapier / Übergabe
|
||||
**Stand:** 2026-07-04
|
||||
**Zweck:** Zentraler Einstieg für die aus Mitai extrahierte Serie **tragfähiger Designprinzipien** — für neue Apps der Jinkendo-Produktfamilie oder vergleichbare Self-Hosted-PWAs.
|
||||
|
||||
**Nicht enthalten:** Mitai-Gesamtarchitektur, Domänenlogik (Gesundheit/Ernährung), Kairo-/Framework-Empfehlungen.
|
||||
|
||||
**Ablage:** `.claude/docs/jinkendo-foundation/design-principles/` · Regeln: [DOCUMENTATION.md](../../../rules/DOCUMENTATION.md)
|
||||
|
||||
---
|
||||
|
||||
## Wofür diese Serie?
|
||||
|
||||
Mitai Jinkendo implementiert wiederkehrende **Querschnittsmuster** (Prompt-Ausführung, Data Layer, Entitlements, Registries, Auth, Import, Dashboard, Navigation, Deploy). Die neun Dokumente destillieren daraus:
|
||||
|
||||
- **Was** übertragbar ist (Prinzip + Begründung + Tragfähigkeit)
|
||||
- **Was** bewusst nicht kopiert werden soll („Nicht übernehmen“)
|
||||
- **Wo** im Code nachgeschaut werden kann (Pfade, Agent-Guides)
|
||||
|
||||
Jedes Dokument ist **eigenständig lesbar**; dieser Index ordnet Abhängigkeiten und Lese-Reihenfolge.
|
||||
|
||||
---
|
||||
|
||||
## Dokumente (9/9)
|
||||
|
||||
| # | Modul | Datei | Kernidee (1 Satz) |
|
||||
|---|-------|-------|-------------------|
|
||||
| 1 | Prompt Engine | [PROMPT_ENGINE_DESIGN_PRINCIPLES.md](./PROMPT_ENGINE_DESIGN_PRINCIPLES.md) | Ein Executor, Pipeline/Workflow-Typen, Platzhalter über Registry — keine Raw-Template-Ausführung. |
|
||||
| 2 | Data Layer | [DATA_LAYER_DESIGN_PRINCIPLES.md](./DATA_LAYER_DESIGN_PRINCIPLES.md) | Layer 0→1→2: Single Source of Truth für Berechnungen; Charts und KI nutzen dieselbe Schicht. |
|
||||
| 3 | Feature & Entitlement | [FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md](./FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md) | Zentrale `check_feature_access`, DB-Registry, 4-Phasen-Rollout — Enforcement an der API. |
|
||||
| 4 | Registry / Plugin (Meta) | [REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md](./REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md) | Drei Registry-Muster (Platzhalter, Widgets, CSV): SSoT, Validierung an der Grenze, Runtime-Registrierung. |
|
||||
| 5 | Auth & Session | [AUTH_SESSION_DESIGN_PRINCIPLES.md](./AUTH_SESSION_DESIGN_PRINCIPLES.md) | Server-Sessions, `require_auth` als Depends — bekannte Schwäche: Profile-Header ohne Session-Bindung. |
|
||||
| 6 | Universal Import | [UNIVERSAL_IMPORT_DESIGN_PRINCIPLES.md](./UNIVERSAL_IMPORT_DESIGN_PRINCIPLES.md) | Modul-Registry + Executor + Vorlagen; Ingest ≠ Interpretation; SAVEPOINT pro Zeile. |
|
||||
| 7 | Dashboard Widgets | [DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md](./DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md) | Backend-Katalog, Profil-Layout, Config-Whitelist, Entitlements — Dual Registry mit Frontend. |
|
||||
| 8 | Navigation / IA | [NAVIGATION_IA_DESIGN_PRINCIPLES.md](./NAVIGATION_IA_DESIGN_PRINCIPLES.md) | `appNav` als SSoT, Shells für tiefe Bereiche, Admin-Hub, ein Breakpoint 1024px. |
|
||||
| 9 | Migration & Deploy | [MIGRATION_DEPLOY_DESIGN_PRINCIPLES.md](./MIGRATION_DEPLOY_DESIGN_PRINCIPLES.md) | `XXX_*.sql` beim Container-Start; develop/main → Dev/Prod; fail-fast, kein Auto-Rollback. |
|
||||
|
||||
---
|
||||
|
||||
## Empfohlene Lesereihenfolge
|
||||
|
||||
### Schnellüberblick (30 Min)
|
||||
|
||||
1. [REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md](./REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md) — Meta-Muster für viele Module
|
||||
2. [DATA_LAYER_DESIGN_PRINCIPLES.md](./DATA_LAYER_DESIGN_PRINCIPLES.md) — Daten vs. Darstellung
|
||||
3. [MIGRATION_DEPLOY_DESIGN_PRINCIPLES.md](./MIGRATION_DEPLOY_DESIGN_PRINCIPLES.md) — Betrieb & Schema-Evolution
|
||||
|
||||
### Vollständige Implementierung (neues Produkt)
|
||||
|
||||
```
|
||||
Foundation: (9) Migration & Deploy → (5) Auth → (3) Feature & Entitlement
|
||||
Daten: (2) Data Layer → (6) Universal Import
|
||||
Erweiterung: (4) Registry Meta → (1) Prompt Engine → (7) Dashboard Widgets
|
||||
Oberfläche: (8) Navigation / IA
|
||||
```
|
||||
|
||||
### Nur ein Modul nachbauen
|
||||
|
||||
| Ziel | Lese zuerst | Dann |
|
||||
|------|-------------|------|
|
||||
| KI-Analysen | #1 Prompt Engine | #2 Data Layer, #4 Registry |
|
||||
| Charts & KPIs | #2 Data Layer | #7 Dashboard Widgets |
|
||||
| Freemium / Limits | #3 Feature & Entitlement | #7 Widgets (Gating) |
|
||||
| CSV-Import | #6 Universal Import | #2 Data Layer, #4 Registry |
|
||||
| Admin-PWA | #8 Navigation / IA | #3, #5 |
|
||||
|
||||
---
|
||||
|
||||
## Querschnittsthemen (über alle Docs)
|
||||
|
||||
| Thema | Primär | Ergänzend |
|
||||
|-------|--------|-----------|
|
||||
| Single Source of Truth | #2 Data Layer | #4 Registry, #7 Widget-Katalog, #6 Modul-Registry |
|
||||
| Validierung an der Grenze | #4 Registry | #7 Layout-Pydantic, #6 Template-Validator |
|
||||
| Feature-Gating | #3 Entitlement | #7 Widget `allowed`, #6 Import-Limits |
|
||||
| Dual Registry (Backend + Frontend) | #4 Registry | #7 `registerDashboardWidgets`, Platzhalter-UI |
|
||||
| Idempotenz / Recovery | #9 Migration | #6 SAVEPOINT, Migration `IF NOT EXISTS` |
|
||||
| Bekannte Lücken dokumentieren | alle „Nicht übernehmen“ | z. B. IDOR Profile-Header (#5), fehlender Cross-Check Widget-IDs (#7) |
|
||||
|
||||
---
|
||||
|
||||
## Verwandte normative Docs (Mitai-spezifisch)
|
||||
|
||||
Diese Agent-Guides sind **Implementierungsdetail**; die Designprinzipien-Serie ist **extrahiertes Muster**:
|
||||
|
||||
| Thema | Agent-Guide / Spec |
|
||||
|-------|-------------------|
|
||||
| Platzhalter | [PLACEHOLDER_REGISTRY_FRAMEWORK.md](../../technical/PLACEHOLDER_REGISTRY_FRAMEWORK.md) |
|
||||
| Data Layer erweitern | [DATA_LAYER_EXTENSION_GUIDE.md](../../technical/DATA_LAYER_EXTENSION_GUIDE.md) |
|
||||
| CSV Import | [UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md](../../technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md) |
|
||||
| Dashboard Widgets | [DASHBOARD_WIDGETS_AGENT_GUIDE.md](../../technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md) |
|
||||
| GUI / Admin / Nav | [GUI_IA_ADMIN_NAV_2026-04-05.md](../../../../docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md) |
|
||||
| Migrationen (operativ) | [MIGRATIONS.md](../../technical/MIGRATIONS.md) |
|
||||
| Membership | [MEMBERSHIP_SYSTEM.md](../../technical/MEMBERSHIP_SYSTEM.md), [FEATURE_ENFORCEMENT.md](../../architecture/FEATURE_ENFORCEMENT.md) |
|
||||
| Architektur-Regeln | [ARCHITECTURE.md](../../../rules/ARCHITECTURE.md) |
|
||||
|
||||
---
|
||||
|
||||
## Übergabe-Checkliste (neues Produkt in der Familie)
|
||||
|
||||
Nutze diese Liste beim Start eines Schwester-Projekts — **Prinzipien ja, Mitai-Pfade nein**:
|
||||
|
||||
```
|
||||
[ ] Deploy: nummerierte SQL-Migrationen + Tracking-Tabelle + Startup vor App
|
||||
[ ] Auth: Session server-side; Depends(require_auth); Admin-Route-Guard
|
||||
[ ] Entitlements: eine check_feature_access-Funktion; keine Tier-Logik in UI-Widgets
|
||||
[ ] Data Layer: Berechnungen nicht in Router/React duplizieren
|
||||
[ ] Registry: neue erweiterbare IDs nur über zentralen Katalog + Validierung
|
||||
[ ] Import (falls CSV): Modul-Registry + Vorlagen + Import-Grenze (keine Scores beim Insert)
|
||||
[ ] Dashboard (falls konfigurierbar): Backend-Katalog + Layout-JSON + allowed-Flag
|
||||
[ ] Navigation: eine appNav-SSoT; Shells für >6 Top-Level-Bereiche
|
||||
[ ] Prompt/KI (falls): ein Executor; Platzhalter-Registry; kein Raw-Template an LLM
|
||||
[ ] Dokumentieren: pro Modul „Nicht übernehmen“ aus Mitai-Lücken mitnehmen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pflege
|
||||
|
||||
| Aktion | Wo |
|
||||
|--------|-----|
|
||||
| Neues Querschnittsmodul extrahiert | Neues `*_DESIGN_PRINCIPLES.md` + Zeile in Tabelle oben |
|
||||
| Mitai-Implementierung ändert Muster | Betroffenes Einzeldokument + ggf. Querschnittstabelle hier |
|
||||
| Nur Mitai-Bugfix ohne Muster-Änderung | Agent-Guides / Code; Designprinzipien optional |
|
||||
|
||||
---
|
||||
|
||||
## Changelog Index
|
||||
|
||||
| Datum | Änderung |
|
||||
|-------|----------|
|
||||
| 2026-07-04 | Index angelegt; Serie 1–9 abgeschlossen |
|
||||
| 2026-07-04 | Nach `jinkendo-foundation/design-principles/` verschoben |
|
||||
|
|
@ -0,0 +1,315 @@
|
|||
# Registry- & Plugin-Muster – Designprinzipien (Extraktion)
|
||||
|
||||
**Status:** Analyse / Arbeitspapier
|
||||
**Stand:** 2026-07-04
|
||||
**Geltungsbereich:** Querschnittsmuster für erweiterbare Registries — drei Implementierungen in Mitai (Platzhalter, Dashboard-Widgets, CSV-Import)
|
||||
|
||||
**Serie:** Designprinzipien für Produktfamilie · Dokument 4 von n
|
||||
**Vorgänger:** [FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md](./FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md)
|
||||
|
||||
**Die drei Registries:**
|
||||
|
||||
| Registry | Backend-Kanon | Frontend-/Runtime-Registry | Leitfaden |
|
||||
|----------|---------------|----------------------------|-----------|
|
||||
| **Platzhalter** | `placeholder_registry.py` + `placeholder_registrations/` | `PLACEHOLDER_MAP` in `placeholder_resolver.py` | `PLACEHOLDER_REGISTRY_FRAMEWORK.md` |
|
||||
| **Dashboard-Widgets** | `widget_catalog.py` | `registerDashboardWidgets.js` → `dashboardWidgetRegistry.jsx` | `DASHBOARD_WIDGETS_AGENT_GUIDE.md` |
|
||||
| **CSV-Import-Module** | `csv_parser/module_registry.py` | — (Executor + Admin-UI konsumieren API) | `UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` |
|
||||
|
||||
---
|
||||
|
||||
## Modul
|
||||
|
||||
**Registry- & Plugin-Muster** (Meta-Schicht)
|
||||
|
||||
Wiederkehrendes Architekturmuster: **Zentral registrierte, ID-basierte Erweiterungspunkte** mit Metadaten, Validierung und getrennten Konsumenten (GUI, API, Batch). Kein einzelnes Runtime-Modul — ein **Familien-Designpattern**, das in Mitai dreimal konkret umgesetzt ist.
|
||||
|
||||
---
|
||||
|
||||
## Fachliche Verantwortung
|
||||
|
||||
Registries übernehmen:
|
||||
|
||||
1. **Autoritative ID-Liste** — Was existiert, was ist erlaubt, in welcher Reihenfolge (optional).
|
||||
2. **Metadaten für Mensch & Maschine** — Titel, Beschreibung, Typen, Abhängigkeiten, semantische Verträge.
|
||||
3. **Validierung** — Unbekannte IDs werden abgelehnt; Konfigurationen gegen Kanon geprüft.
|
||||
4. **Entkopplung** — Implementierung (Resolver, React-Komponente, Import-Executor) registriert sich an den Kanon, nicht umgekehrt.
|
||||
5. **Erweiterbarkeit ohne Schema-Explosion** — Neue Einträge über Code-Registrierung (+ ggf. DB-Overrides), nicht über neue DB-Spalten pro Feature.
|
||||
|
||||
Registries übernehmen **nicht**:
|
||||
|
||||
- Fachliche Berechnung (→ Data Layer)
|
||||
- Entitlement-Auflösung (→ Feature System; Widgets *referenzieren* Features)
|
||||
- Auth / Mandanten
|
||||
|
||||
### Gemeinsames Strukturschema
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ REGISTRY (Kanon) │
|
||||
│ ID + Metadaten + optionale Policies/Abhängigkeiten │
|
||||
└────────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────┼───────────────┐
|
||||
▼ ▼ ▼
|
||||
Implementierung Validierung Konsumenten
|
||||
(Resolver/ (Schema/ (GUI-Picker,
|
||||
Component/ Tests) API, Executor)
|
||||
Executor)
|
||||
```
|
||||
|
||||
### Vergleich der drei Implementierungen
|
||||
|
||||
| Aspekt | Platzhalter | Dashboard-Widgets | CSV-Import |
|
||||
|--------|-------------|-------------------|------------|
|
||||
| **Primär-ID** | `key` (snake_case) | `id` (snake_case) | Modul-Name (`nutrition`, `activity`, …) |
|
||||
| **Kanon-Speicher** | Python Singleton + Cluster-Module | Python-Liste `WIDGET_CATALOG` | Python-Dict `MODULE_DEFINITIONS` |
|
||||
| **Metadaten-Tiefe** | Sehr hoch (22+ Felder, Evidence) | Mittel (title, description, requires_feature) | Hoch (fields, types, duplicate_key, aggregates) |
|
||||
| **Runtime-Registry** | `register_placeholder()` beim Import | `registerDashboardWidget()` idempotent | Keine — Executor liest Dict |
|
||||
| **Frontend-Spiegel** | PlaceholderPicker, Admin-Prompt-Modal | `ensureDashboardWidgetsRegistered()` | Admin CSV Template Editor |
|
||||
| **Validierung** | `metadata.validate()`, Governance-Docs | Pydantic Layout + `validate_widget_entry_config` | `validate_field_mappings`, `validate_csv_template` |
|
||||
| **Tests** | `test_placeholder_metadata.py`, … | `test_widget_catalog.py` | `test_template_validator.py`, … |
|
||||
| **DB-Override** | Nein (nur Code) | Ja (`widget_feature_requirements`, Layout pro Profil) | Ja (Vorlagen, Nutzer-Mappings) |
|
||||
| **Entitlements** | Indirekt (Data/Features) | `requires_feature` → `check_feature_access` | Feature-Limits beim Import |
|
||||
|
||||
---
|
||||
|
||||
## Designprinzipien (übergreifend)
|
||||
|
||||
### 1. Single Source of Truth für erlaubte IDs
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Jede erweiterbare Einheit hat **eine** autoritative ID-Liste; Router und UI duplizieren keine Feld-/Widget-/Platzhalter-Listen. |
|
||||
| **Begründung** | Verhindert „funktioniert in der UI, scheitert in der API“ und umgekehrt. |
|
||||
| **Quelle** | `module_registry.py` Kommentar; `widget_catalog.py`; `PLACEHOLDER_REGISTRY_FRAMEWORK.md` §2.3 |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Platzhalter: paralleles `PLACEHOLDER_MAP` neben Registry. |
|
||||
|
||||
### 2. ID-Stabilität als Vertrag
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | IDs/Keys sind stabile API-Verträge; Umbenennung = neuer Key + Deprecation, nicht stilles Rename. |
|
||||
| **Begründung** | Prompts, Layouts und CSV-Vorlagen referenzieren IDs persistent. |
|
||||
| **Quelle** | `docs/PLACEHOLDER_GOVERNANCE.md` §4.2–4.3; Widget-Layout in `profiles.dashboard_layout` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Nicht überall runtime-erzwungen (Platzhalter-Governance prozessual). |
|
||||
|
||||
### 3. Metadaten getrennt von Implementierung
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Registry speichert **Was** (ID, Beschreibung, Typ, Policies); Implementierung lebt in separaten Modulen. |
|
||||
| **Begründung** | GUI, Export, Validierung und Docs können Metadaten nutzen ohne Resolver/Component zu laden. |
|
||||
| **Quelle** | `PlaceholderMetadata` Dataclass; `WidgetCatalogEntry`; `MODULE_DEFINITIONS.fields` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Platzhalter bindet `_resolver_func` optional an Metadata-Objekt. |
|
||||
|
||||
### 4. Zwei-Phasen-Registrierung (Backend-Kanon + Runtime-Binding)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Phase A: Kanon definiert IDs und Metadaten. Phase B: Implementierung registriert sich (Platzhalter-Cluster-Import, `registerDashboardWidget`, Executor nutzt Modul-Def). |
|
||||
| **Begründung** | Backend bleibt autoritativ; Frontend/plugins können nachziehen, Tests können Lücken finden. |
|
||||
| **Quelle** | `import placeholder_registrations` in `main.py`; `ensureDashboardWidgetsRegistered()` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Fehlende Frontend-Registrierung zeigt „Unbekanntes Widget“ — kein Build-Time-Fail. |
|
||||
|
||||
### 5. Auto-Registration via Package-Import
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Side-Effect-Import eines Packages triggert vollständige Registrierung (`placeholder_registrations/__init__.py`). |
|
||||
| **Begründung** | Keine vergessene manuelle Registrierungsliste in `main.py` pro Eintrag. |
|
||||
| **Quelle** | `placeholder_registrations/__init__.py`; `main.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Import-Reihenfolge und zirkuläre Imports beachten. |
|
||||
|
||||
### 6. Validierung am Registry-Rand
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Unbekannte Keys/Widget-IDs/Feld-Mappings werden an Registry-Grenzen abgewiesen, nicht erst in der Business-Logik. |
|
||||
| **Begründung** | Frühes, klares Fehlerbild für Admins und Entwickler. |
|
||||
| **Quelle** | `DashboardWidgetEntry` + `ALLOWED_WIDGET_IDS`; `validate_field_mappings()`; `get_unknown_placeholders()` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Prompt-Templates können unbekannte Platzhalter erst zur Laufzeit offenbaren. |
|
||||
|
||||
### 7. Konsumenten-Agnostik
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Dieselbe Registry bedient mehrere Konsumenten (KI, Charts, Export, Admin-Browser) ohne duplizierte Metadaten. |
|
||||
| **Begründung** | DRY für Beschreibungen, Kategorien, Beispielwerte. |
|
||||
| **Quelle** | `PLACEHOLDER_REGISTRY_FRAMEWORK.md` §2.2; `get_placeholder_catalog()` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Legacy-Export-Pfade mergen noch „Registry + Legacy“. |
|
||||
|
||||
### 8. Erweiterungs-Checkliste statt Ad-hoc
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Jedes Registry hat dokumentierte Schritte A→G (Katalog-Eintrag, Validierung, Tests, Version-Bump). |
|
||||
| **Begründung** | Agenten und Menschen erweitern konsistent; Review an Checkliste. |
|
||||
| **Quelle** | `DASHBOARD_WIDGETS_AGENT_GUIDE.md` §2; `UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` §2; `PLACEHOLDER_DEVELOPMENT_GUIDE.md` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Drei separate Guides — kein unified „Registry Agent Guide“. |
|
||||
|
||||
### 9. Tests auf Katalog-Konsistenz
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Automatisierte Tests prüfen Eindeutigkeit, Reihenfolge, Payload-Shape, ID-Abgleich Kanon ↔ abgeleitete Sets. |
|
||||
| **Begründung** | Regression wenn Katalog wächst but Registry/Layout nicht mitzieht. |
|
||||
| **Quelle** | `test_widget_catalog.py`; Placeholder-Metadata-Tests |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Kein Cross-Registry-Test „Frontend widget IDs == backend catalog“. |
|
||||
|
||||
### 10. Optionale Entitlement-Referenz im Kanon
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Registry-Einträge **referenzieren** Feature-IDs (`requires_feature`), lösen Entitlements aber nicht selbst auf. |
|
||||
| **Begründung** | Tier-Logik bleibt in `check_feature_access`; Katalog bleibt deklarativ. |
|
||||
| **Quelle** | `WidgetCatalogEntry.requires_feature`; `dashboard_widget_entitlements.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Platzhalter haben kein direktes `requires_feature` — Gating nur indirekt. |
|
||||
|
||||
---
|
||||
|
||||
## Designprinzipien (spezifisch pro Registry)
|
||||
|
||||
### Platzhalter-Registry
|
||||
|
||||
| # | Prinzip | Tragfähigkeit | Schwäche |
|
||||
|---|---------|---------------|----------|
|
||||
| P1 | **Semantischer Vertrag** (`semantic_contract`) pro Key — Platzhalter sind API, nicht Prompt-Hilfe | hoch | Viele Legacy-Keys mit schwachem Vertrag |
|
||||
| P2 | **Evidence-Tagging** — Herkunft jedes Metadatenfelds nachvollziehbar | mittel | Pflegeaufwand |
|
||||
| P3 | **Cluster-Module** — Registrierung nach Domäne (`nutrition_part_a`, `body_metrics`, …) | hoch | 114 Keys Sync mit `PLACEHOLDER_MAP` |
|
||||
| P4 | **Data-Layer-Referenz** in Metadata (`data_layer_function`) — Bindung an Layer 1 | hoch | Nicht alle Keys vollständig verknüpft |
|
||||
| P5 | **Singleton** `get_registry()` — globaler Kanon | hoch | Test-Isolation braucht Disziplin |
|
||||
|
||||
### Dashboard-Widget-Registry
|
||||
|
||||
| # | Prinzip | Tragfähigkeit | Schwäche |
|
||||
|---|---------|---------------|----------|
|
||||
| W1 | **Backend-Katalog = ALLOWED_WIDGET_IDS** — Layout-Schema leitet ab | hoch | Frontend-Registry manuell parallel |
|
||||
| W2 | **`merge_missing_catalog_widgets`** — neue Katalog-IDs erscheinen im Layout ohne User-Reset | hoch | — |
|
||||
| W3 | **Strikte `config`-Validierung** nur für whitelisted Widgets (`WIDGETS_ALLOWING_CONFIG`) | hoch | Config-Schemas pro Widget heterogen |
|
||||
| W4 | **Graceful Degradation** — unregistrierte ID → Fehler-Karte, nicht Crash | mittel | Maskiert Deploy-Fehler |
|
||||
| W5 | **WidgetErrorBoundary** pro Instanz | hoch | — |
|
||||
|
||||
### CSV-Modul-Registry
|
||||
|
||||
| # | Prinzip | Tragfähigkeit | Schwäche |
|
||||
|---|---------|---------------|----------|
|
||||
| C1 | **`MODULE_DEFINITIONS` = einzige Feldliste** — Router duplizieren nicht | hoch | Activity erweitert dynamisch um `training_parameters` |
|
||||
| C2 | **Deklarative Duplikat-Strategie** (`duplicate_key`, `update`/`skip`) | hoch | Modul-spezifische Executor-Sonderfälle |
|
||||
| C3 | **`import_row_processing`** — Aggregation in Registry, nicht im Router | hoch | Legacy-Defaults in Modul-Def |
|
||||
| C4 | **`validate_field_mappings`** vor Persistenz | hoch | Nutzer-Mappings teils ohne volle Validator-Parität (#71) |
|
||||
| C5 | **Persistenz-Orchestrator** liest Registry-Felder (`activity_persistence_orchestrator`) | hoch | Nur Activity voll ausgebaut |
|
||||
|
||||
---
|
||||
|
||||
## Anti-Pattern: Doppel-Registry
|
||||
|
||||
Mitai zeigt an **Platzhaltern** das Risiko explizit:
|
||||
|
||||
```
|
||||
placeholder_registrations/ ──register──► PlaceholderRegistry (Metadata)
|
||||
│ ▲
|
||||
└── resolver in code ──► PLACEHOLDER_MAP (Runtime, 114 Keys)
|
||||
```
|
||||
|
||||
**Regel für Produktfamilie:** Runtime-Auflösung soll Metadata-Registry **lesen**, nicht spiegeln.
|
||||
|
||||
Widgets sind näher am Ideal: Backend `WIDGET_CATALOG` ist Kanon; Frontend muss IDs manuell in `registerDashboardWidgets.js` binden — akzeptabel, aber testbar machen (Cross-Check).
|
||||
|
||||
---
|
||||
|
||||
## Nicht übernehmen
|
||||
|
||||
1. **Parallele Runtime-Maps** — `PLACEHOLDER_MAP` + Registry; eine Quelle für Keys und Resolver-Pfad.
|
||||
|
||||
2. **Frontend-Registry ohne Build-/Test-Gate** — fehlende `registerDashboardWidget`-Einträge erst zur Laufzeit sichtbar.
|
||||
|
||||
3. **Metadaten-Duplikation in Export-Code** — hardcodierte Beschreibungen außerhalb der Registry.
|
||||
|
||||
4. **Registry-Einträge ohne Validierungs-Tests** — besonders bei 100+ Platzhaltern.
|
||||
|
||||
5. **Import-Feldlisten in Routern** — alles über `module_registry` (Mitai-Zielbild, noch nicht überall).
|
||||
|
||||
6. **Evidence-/Metadata-Pflicht für einfache Plugins** — 22 Felder für Widgets wären Overkill; **Metadaten-Tiefe an Risiko anpassen**.
|
||||
|
||||
7. **Dynamische Registry aus DB ohne Versionierung** — Widget-Feature-Overrides OK; kompletter Kanon nur in DB wäre schwer testbar.
|
||||
|
||||
8. **Registry ohne Deprecation-Pfad** — Breaking Key-Changes still (Platzhalter-Governance existiert, durchsetzen).
|
||||
|
||||
9. **Entitlements in Registry auflösen** — Widgets richtig: referenzieren; nicht Tier-Logik im Katalog.
|
||||
|
||||
10. **Schlaf-Modul leeres `fields: {}`** — Sondermodus (`import_mode`) statt sauberem Registry-Eintrag; technische Schuld.
|
||||
|
||||
---
|
||||
|
||||
## Entscheidungsmatrix: Welche Registry-Tiefe?
|
||||
|
||||
| Wenn … | dann Metadaten-Tiefe … | Beispiel |
|
||||
|--------|------------------------|----------|
|
||||
| Externe Verträge / KI / Compliance | Hoch (Vertrag, Evidence, Missing-Policy) | Platzhalter |
|
||||
| UI-Plugin mit optionaler Config | Mittel (ID, title, config schema, feature ref) | Widgets |
|
||||
| Daten-Ingest / Schema-Mapping | Hoch (Typen, keys, constraints) | CSV-Module |
|
||||
| Internes Hilfsmodul | Minimal (ID + Handler-Ref) | — |
|
||||
|
||||
---
|
||||
|
||||
## Modul-Inventar (Querschnitt)
|
||||
|
||||
```
|
||||
backend/
|
||||
├── placeholder_registry.py
|
||||
├── placeholder_registrations/ # Auto-import Cluster
|
||||
├── placeholder_resolver.py # PLACEHOLDER_MAP (Legacy-Spiegel)
|
||||
├── placeholder_registry_export.py
|
||||
├── widget_catalog.py
|
||||
├── dashboard_layout_schema.py
|
||||
├── dashboard_widget_config.py
|
||||
├── dashboard_widget_entitlements.py
|
||||
├── widget_feature_requirements_db.py
|
||||
└── csv_parser/
|
||||
└── module_registry.py
|
||||
|
||||
frontend/src/
|
||||
├── widgetSystem/
|
||||
│ ├── registerDashboardWidgets.js
|
||||
│ └── dashboardWidgetRegistry.jsx
|
||||
└── components/workflow/panels/PlaceholderPicker.jsx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verwandte Dokumentation
|
||||
|
||||
- Platzhalter: [PLACEHOLDER_REGISTRY_FRAMEWORK.md](../../technical/PLACEHOLDER_REGISTRY_FRAMEWORK.md), [PLACEHOLDER_GOVERNANCE.md](../../../../docs/PLACEHOLDER_GOVERNANCE.md)
|
||||
- Widgets: [DASHBOARD_WIDGETS_AGENT_GUIDE.md](../../technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md)
|
||||
- Import: [UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md](../../technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md)
|
||||
- Entitlements (Widget-Gating): [FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md](./FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md)
|
||||
- Data Layer (Platzhalter-Berechnung): [DATA_LAYER_DESIGN_PRINCIPLES.md](./DATA_LAYER_DESIGN_PRINCIPLES.md)
|
||||
- Prompt Engine (Platzhalter-Konsument): [PROMPT_ENGINE_DESIGN_PRINCIPLES.md](./PROMPT_ENGINE_DESIGN_PRINCIPLES.md)
|
||||
|
||||
---
|
||||
|
||||
## Geplante Folgedokumente (Serie)
|
||||
|
||||
| # | Modul | Status |
|
||||
|---|-------|--------|
|
||||
| 1 | Prompt Engine | ✅ |
|
||||
| 2 | Data Layer | ✅ |
|
||||
| 3 | Feature & Entitlement | ✅ |
|
||||
| 4 | Registry-/Plugin-Muster | ✅ dieses Dokument |
|
||||
| 5 | Auth & Session | ✅ `AUTH_SESSION_DESIGN_PRINCIPLES.md` |
|
||||
| 6 | Universal Import | ✅ |
|
||||
| 7 | Dashboard Widgets | ✅ |
|
||||
| 8 | Navigation / IA | ✅ |
|
||||
| 9 | Migration & Deploy | ✅ |
|
||||
|
||||
*Hinweis:* Dokumente 6 und 7 vertiefen Einzel-Registries; dieses Meta-Dokument ist die übergreifende Extraktion.
|
||||
|
|
@ -0,0 +1,325 @@
|
|||
# Universal CSV Import – Designprinzipien (Extraktion)
|
||||
|
||||
**Status:** Analyse / Arbeitspapier
|
||||
**Stand:** 2026-07-04
|
||||
**Geltungsbereich:** Universal CSV Import (Issue #21) — Ingest/Mapping/Persistenz, keine Auswertungslogik
|
||||
|
||||
**Serie:** Designprinzipien für Produktfamilie · Dokument 6 von n
|
||||
**Vorgänger:** [AUTH_SESSION_DESIGN_PRINCIPLES.md](./AUTH_SESSION_DESIGN_PRINCIPLES.md)
|
||||
|
||||
**Kernkomponenten:**
|
||||
|
||||
| Bereich | Pfade |
|
||||
|---------|-------|
|
||||
| Modul-Kanon | `backend/csv_parser/module_registry.py` |
|
||||
| Ausführung | `backend/csv_parser/executor.py` |
|
||||
| Parsing/Typen | `core.py`, `type_converter.py`, `field_units.py` |
|
||||
| Aggregation | `import_row_processing.py` |
|
||||
| Validierung | `template_validator.py` |
|
||||
| Fehler-Hints | `import_errors.py` |
|
||||
| Mapping-Vorschläge | `mapping_suggest.py` |
|
||||
| Nutzer-API | `backend/routers/csv_import.py` |
|
||||
| Admin-Vorlagen | `backend/routers/admin_csv_templates.py` |
|
||||
| Persistenz-Orchestrator | `data_layer/activity_persistence_orchestrator.py` |
|
||||
| Leitfaden | `UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` |
|
||||
| Import-Grenze | `.claude/rules/ARCHITECTURE.md` §8 |
|
||||
|
||||
---
|
||||
|
||||
## Modul
|
||||
|
||||
**Universal CSV Import**
|
||||
|
||||
Konfigurierbare Pipeline: **CSV-Datei → Feld-Mapping → Typkonvertierung → (optional Aggregation) → DB-Upsert** — mit Vorlagen, Audit-Log und row-level Fehlertoleranz.
|
||||
|
||||
---
|
||||
|
||||
## Fachliche Verantwortung
|
||||
|
||||
Das Modul übernimmt:
|
||||
|
||||
1. **Modul-Registry** — Welche Zieltabellen/Felder importierbar sind (Typen, Duplikat-Keys, Strategien).
|
||||
2. **Vorlagen (Mappings)** — System-Templates (Admin) + Nutzer-Kopien (`csv_field_mappings`).
|
||||
3. **Analyse** — Delimiter-Erkennung, Spalten-Signatur, Mapping-Vorschläge, Diagnose einzelner Zeilen.
|
||||
4. **Ausführung** — Upsert pro Modul, `source=csv`, Statistik, `affected_ids`.
|
||||
5. **Fehlertransparenz** — Row-level Errors mit `code`/`hint`; kein Silent-Fail der ganzen Transaktion.
|
||||
6. **Audit** — `csv_import_log` mit Status, Counts, betroffenen IDs.
|
||||
7. **Limits** — Dateigröße/Zeilen aus `system_config`; Feature-Entitlements pro Modul.
|
||||
|
||||
Es übernimmt **nicht**:
|
||||
|
||||
- Fachliche Metriken / Scores (→ Data Layer, siehe [DATA_LAYER_DESIGN_PRINCIPLES.md](./DATA_LAYER_DESIGN_PRINCIPLES.md))
|
||||
- Prompt-/KI-Logik
|
||||
- Vollständiger Ersatz aller Legacy-Import-Endpoints (noch parallel)
|
||||
|
||||
### Pipeline (Happy Path)
|
||||
|
||||
```
|
||||
Upload CSV
|
||||
→ decode_raw_bytes + resolve_effective_csv_delimiter
|
||||
→ Vorlage laden (csv_field_mappings)
|
||||
→ validate (optional Admin) / feature check
|
||||
→ run_universal_csv_import(cur, …) // eine Transaktion
|
||||
→ build_row_after_mapping (type_converter)
|
||||
→ aggregate_mapped_rows (import_row_processing)
|
||||
→ UPSERT / activity_persistence_orchestrator
|
||||
→ csv_import_log UPDATE + increment_feature_usage
|
||||
```
|
||||
|
||||
### Unterstützte Module (Registry)
|
||||
|
||||
| Modul | Zieltabelle | Besonderheit |
|
||||
|-------|-------------|--------------|
|
||||
| `nutrition` | `nutrition_log` | Tages-Aggregation |
|
||||
| `weight` | `weight_log` | Duplikat: profile + date |
|
||||
| `activity` | `activity_log` | SAVEPOINT pro Zeile; EAV via Orchestrator |
|
||||
| `vitals_baseline` | `vitals_baseline` | Tages-Aggregation |
|
||||
| `blood_pressure` | `blood_pressure_log` | Composite `measured_at` |
|
||||
| `sleep` | `sleep_log` | Legacy-Adapter `import_mode: apple_sleep_aggregate` |
|
||||
|
||||
---
|
||||
|
||||
## Administrierte vs. code-definierte Konfiguration
|
||||
|
||||
| Konfiguration | Speicherort | Wer pflegt? |
|
||||
|---------------|-------------|-------------|
|
||||
| Zielfelder, Typen, Duplikat-Keys | `MODULE_DEFINITIONS` | Entwickler (Code) |
|
||||
| System-Vorlagen | `csv_field_mappings` (`is_system=true`) | Admin (+ Migration Seeds) |
|
||||
| Nutzer-Mappings | `csv_field_mappings` (`profile_id`) | Nutzer (Kopie/Anpassung) |
|
||||
| `field_mappings`, `type_conversions`, `import_row_processing` | JSONB in Vorlage | Admin/Nutzer |
|
||||
| Import-Limits | `system_config.csv_import` | Admin |
|
||||
| Delimiter-Sniffing-Heuristik | `core.py` | Code |
|
||||
| Header-Aliases (Vorschläge) | `mapping_suggest.py` | Code |
|
||||
|
||||
**Bewusst nicht in Routern hardcodiert:** Feldlisten, Duplikat-Logik — nur Registry + Executor.
|
||||
|
||||
---
|
||||
|
||||
## Designprinzipien
|
||||
|
||||
### 1. Module Registry als Single Source of Truth
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Alle erlaubten Zielfelder, Typen und Duplikat-Keys leben in `MODULE_DEFINITIONS` — Router duplizieren nicht. |
|
||||
| **Begründung** | Admin-UI, Validator, Executor und `/api/csv/modules` bleiben synchron. |
|
||||
| **Quelle** | `module_registry.py`; Agent-Guide §1 |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Activity erweitert Felder dynamisch aus `training_parameters` (DB). |
|
||||
|
||||
### 2. Ingest vs. Interpretation (Import-Grenze)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Import: Mapping + Typ/Einheit + Duplikat/Upsert. **Keine** fachliche Auswertung beim Insert. |
|
||||
| **Begründung** | Semantik gehört in Data Layer; Import bleibt austauschbar und testbar. |
|
||||
| **Quelle** | `ARCHITECTURE.md` §8; [DATA_LAYER_DESIGN_PRINCIPLES.md](./DATA_LAYER_DESIGN_PRINCIPLES.md) |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | `sleep_apple_import.py` ist Legacy-Adapter mit quellenspezifischer Logik. |
|
||||
|
||||
### 3. Vorlagen trennen Struktur von Datei
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `csv_field_mappings` speichert Modul, Delimiter, Header-Flag, Mappings, Conversions, Row-Processing — unabhängig vom Upload. |
|
||||
| **Begründung** | Wiederverwendung (Apple Health, Omron, …); Nutzer wählt Vorlage statt jedes Mal neu zu mappen. |
|
||||
| **Quelle** | Migration 042; Admin + User APIs |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Nutzer-Kopien nicht immer durch `validate_csv_template` (#71). |
|
||||
|
||||
### 4. Effektives Trennzeichen aus Datei, nicht blind aus Vorlage
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `resolve_effective_csv_delimiter` — DE-Export (`;`) vs. EN-Vorlage (`,`) wird aus Header-Feldanzahl erkannt. |
|
||||
| **Begründung** | Regionale CSV-Exporte brechen sonst das gesamte Mapping (eine Spalte). |
|
||||
| **Quelle** | `core.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Heuristik, kein 100%-Garant für exotische Formate. |
|
||||
|
||||
### 5. Typ- und Einheiten-Konvertierung deklarativ
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `type_conversions` + `source_unit` in Vorlage; Logik in `type_converter` / `field_units`. |
|
||||
| **Begründung** | kJ→kcal, Datumsformate, Dezimal-Komma ohne Code pro Quelle. |
|
||||
| **Quelle** | `type_converter.py`, `field_units.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Falsche `source_unit` → DB-Overflow; `enrich_row_error` hilft nachträglich. |
|
||||
|
||||
### 6. Zeilen-Aggregation vor Upsert
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `import_row_processing` (group_by + aggregates) fasst mehrere CSV-Zeilen pro logischem Tag/Datensatz zusammen. |
|
||||
| **Begründung** | Ernährung/Vitals: viele Rohzeilen → ein Tageseintrag. |
|
||||
| **Quelle** | `import_row_processing.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Modul-Default als Legacy-Fallback wenn Vorlage leer; Admin „Format prüfen“ kann Processing auslassen. |
|
||||
|
||||
### 7. Ein Cursor, eine Transaktion, SAVEPOINT pro Zeile
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `run_universal_csv_import(cur, …)` nutzt **bestehenden** Cursor; bei Row-Fehlern SAVEPOINT + ROLLBACK TO, nicht ganze Xact abbrechen. |
|
||||
| **Begründung** | PostgreSQL „transaction aborted“; partielle Imports mit Fehlerliste. |
|
||||
| **Quelle** | `executor.py` (activity, vitals); `csv_import.py` SAVEPOINT `csv_import_exec` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Nicht alle Module gleich implementiert; Disziplin pro Modul. |
|
||||
|
||||
### 8. Kein verschachteltes get_db im Importpfad
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | FK-Auflösung (z. B. Trainingstyp) und Activity-Persistenz mit **demselben** `cur` wie der Import. |
|
||||
| **Begründung** | Pool-Deadlocks, konsistente Transaktion. |
|
||||
| **Quelle** | `_resolve_training_type_for_activity`; Agent-Guide §2 |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Lazy-Import aus Router in Executor (Kopplung). |
|
||||
|
||||
### 9. Strukturierte Fehler mit Hints
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `enrich_row_error()` mappt DB-/Parse-Fehler auf `code` + menschenlesbaren `hint`. |
|
||||
| **Begründung** | Nutzer/Admin können Vorlagen korrigieren ohne PostgreSQL-Kenntnis. |
|
||||
| **Quelle** | `import_errors.py`; Import-Response `error_details` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Heuristische String-Matches, nicht vollständig. |
|
||||
|
||||
### 10. Vorlagen-Validierung vor Persistenz (Admin)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `validate_csv_template` → `{ valid, errors[], warnings[] }`; Admin Create/Update → HTTP 422 bei Fehlern. |
|
||||
| **Begründung** | Fehler früh, nicht erst beim Nutzer-Import. |
|
||||
| **Quelle** | `template_validator.py`; `admin_csv_templates.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Dry-Run / User-Mappings Lücken (#71). |
|
||||
|
||||
### 11. System- vs. User-Mappings (Permissions)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `is_system=true`: nur Admin editierbar; Nutzer kopiert und passt eigene Zeile an. |
|
||||
| **Begründung** | Shipped Templates schützen; Individualisierung erlauben. |
|
||||
| **Quelle** | `permissions.py`; DB CHECK + Unique Indexes |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | — |
|
||||
|
||||
### 12. Import-Audit und Rollback-Vorbereitung
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Jeder Lauf schreibt `csv_import_log` mit Counts, `error_details`, `affected_ids` (PKs pro Tabelle). |
|
||||
| **Begründung** | Nachvollziehbarkeit, spätere Bereinigung/Rollback, Erfolgsrate pro Vorlage. |
|
||||
| **Quelle** | Migration 042; `csv_import_execute` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Automatischer Rollback-Button nicht überall umgesetzt. |
|
||||
|
||||
### 13. Feature-Entitlements an Import gebunden
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `data_import` global + modulspezifisch (`nutrition_entries`, …); Increment nur für **neue** Zeilen. |
|
||||
| **Begründung** | Konsistent mit Membership-System. |
|
||||
| **Quelle** | `csv_import.py` `_check_module_feature_access`, `increment_feature_usage` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Bulk-Increment-Schleife ineffizient (wie Feature-Doc). |
|
||||
|
||||
### 14. Mapping-Vorschläge (Heuristik, nicht Autorität)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `mapping_suggest.py` schlägt Spalten-Zuordnung aus Header-Aliases vor — Admin bestätigt. |
|
||||
| **Begründung** | Schneller Editor-Start; Kanon bleibt menschlich/administrativ freigegeben. |
|
||||
| **Quelle** | `_MODULE_HEADER_ALIASES` |
|
||||
| **Tragfähigkeit** | **mittel–hoch** |
|
||||
| **Einschränkung** | Domänenspezifische Aliases hardcodiert (DE/EN). |
|
||||
|
||||
### 15. Persistenz-Orchestrator für komplexe Domänen
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Activity: nach Registry-Mapping → `activity_persistence_orchestrator` (Upsert + EAV + Eval-Hook). |
|
||||
| **Begründung** | Gleiche Schreiblogik wie REST-API; kein divergierender CSV-Pfad. |
|
||||
| **Quelle** | `activity_persistence_orchestrator.py`; [DATA_LAYER_DESIGN_PRINCIPLES.md](./DATA_LAYER_DESIGN_PRINCIPLES.md) §9 |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Nur Activity vollständig; andere Module direkt im Executor. |
|
||||
|
||||
---
|
||||
|
||||
## Nicht übernehmen
|
||||
|
||||
1. **Parallele Legacy-Import-Endpoints** — `/api/nutrition/import-csv`, `/api/activity/import-csv` neben Universal-Pfad; neue Quellen nur über Universal + Vorlage (ARCHITECTURE §8.2).
|
||||
|
||||
2. **Quellenspezifische Aggregat-Logik im Import** — `sleep_apple_import` als Dauerlösung; Ziel: mapping-nah + Layer 1 (Gitea #69).
|
||||
|
||||
3. **Feldlisten in Routern** — jede neue Spalte nur via `module_registry` + Migration.
|
||||
|
||||
4. **Verschachtelte DB-Connections im Executor** — Pool-Risiko; immer Caller-`cur` durchreichen.
|
||||
|
||||
5. **Transaktion ohne SAVEPOINT bei Multi-Row-Import** — ein Fehler killt gesamten Import + opaque „transaction aborted“.
|
||||
|
||||
6. **Blindes Vorlagen-Delimiter** — regionaler Export bricht Mapping.
|
||||
|
||||
7. **Nutzer-Mappings ohne Validierung** — #71; Copy-from-System muss durch Validator.
|
||||
|
||||
8. **Dry-Run ohne `import_row_processing`** — Admin „Format prüfen“ unvollständig vs. echter Import.
|
||||
|
||||
9. **`source`-CHECK in DB vergessen** — Import setzt `csv`, Constraint muss Migration sein.
|
||||
|
||||
10. **NUMERIC-Overflow durch falsche Einheit** — Schema + `source_unit` + Migrationbreite gemeinsam planen.
|
||||
|
||||
11. **Interpretation/Auswertung beim Import** — Scores, TDEE, Training-Quality nicht in `executor.py`.
|
||||
|
||||
12. **Executor-Monolith ohne Modul-Split** — `executor.py` wächst pro Modul; langfristig Executor-Strategie pro Registry-Key.
|
||||
|
||||
---
|
||||
|
||||
## Modul-Inventar (Ist-Stand)
|
||||
|
||||
```
|
||||
backend/csv_parser/
|
||||
├── module_registry.py # MODULE_DEFINITIONS
|
||||
├── executor.py # run_universal_csv_import
|
||||
├── core.py # decode, delimiter, limits
|
||||
├── type_converter.py
|
||||
├── field_units.py
|
||||
├── import_row_processing.py
|
||||
├── template_validator.py
|
||||
├── import_errors.py
|
||||
├── mapping_suggest.py
|
||||
├── permissions.py
|
||||
└── sleep_apple_import.py # Legacy-Adapter
|
||||
|
||||
backend/routers/
|
||||
├── csv_import.py # Nutzer: modules, analyze, import, mappings
|
||||
└── admin_csv_templates.py # Admin: CRUD + validate
|
||||
|
||||
DB:
|
||||
├── csv_field_mappings
|
||||
└── csv_import_log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verwandte Dokumentation
|
||||
|
||||
- Agent-Guide (normativ): [UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md](../../technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md)
|
||||
- Registry-Meta: [REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md](./REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md)
|
||||
- Import-Grenze: [ARCHITECTURE.md](../../../rules/ARCHITECTURE.md) §8
|
||||
- Feature-Limits: [FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md](./FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md)
|
||||
- Gitea #71: Dry-Run, User-Mapping-Validierung
|
||||
|
||||
---
|
||||
|
||||
## Geplante Folgedokumente (Serie)
|
||||
|
||||
| # | Modul | Status |
|
||||
|---|-------|--------|
|
||||
| 1–5 | … | ✅ |
|
||||
| 6 | Universal Import | ✅ dieses Dokument |
|
||||
| 7 | Dashboard Widgets | ✅ `DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md` |
|
||||
| 8 | Navigation / IA | ✅ |
|
||||
| 9 | Migration & Deploy | ✅ |
|
||||
|
|
@ -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 abgeschlossen (Consumer-Audit 2026-04-16). **Phase C** Schreibpfad entschlackt (Sync abgestellt, Orchestrator als SSoT; Review 2026-04-16 + Regression `test_activity_insert_sql.py`). Nächster Schritt: **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.
|
||||
51
.claude/docs/technical/BLS_FOOD_REFERENCE.md
Normal file
51
.claude/docs/technical/BLS_FOOD_REFERENCE.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# BLS Food Reference – technische Spec
|
||||
|
||||
**Stand:** 2026-09-14 · Migration **062**–**069**
|
||||
|
||||
## Tabellen
|
||||
|
||||
- `food_attributes` — dynamischer Stoff-/Merkmalskatalog (`attr_key`, `data_type`, `origin`)
|
||||
- `food_catalog` — Lebensmittel (`bls_code` UNIQUE bei official_bls; manuell ohne BLS-Code)
|
||||
- `food_attribute_values` — typisiertes EAV
|
||||
- `food_name_mappings` — FDDB-Name → `food_id` (User / global)
|
||||
- `nutrition_items` — Tagebuchzeilen inkl. `logged_at`
|
||||
- `nutrition_daily_nutrients` — Tages-Rollup numerischer Attribute
|
||||
- `nutrition_day_marks` — `fasting` | `incomplete`
|
||||
- `food_recipes` / `food_recipe_ingredients` — Mitai-Listen/Kombinationen (Rohmischung, kein Kochschwund); `nutrition_items.recipe_id`. Kein Tabellen-Rename. Gekochte Gerichte später Tandoor + `cooked_yield_g`, nicht hier.
|
||||
|
||||
## Layer 1
|
||||
|
||||
- `data_layer/food_mapping.py` — Normalisierung, Lookup, Learn, Apply, Delete
|
||||
- `data_layer/nutrition_items.py` — Ingest, drei Makro-Summen, Policy, `resolve_*_attributes`
|
||||
- `data_layer/food_recipes.py` — Listen-Upsert, Link auf Tagebuchzeilen, Listen-Makros (Skala: gegessen_g / Summe Zutaten, sonst 1/Portionen). Diese Skala gilt nur für Roh-Kombinationen, nicht für gekochte Gerichte.
|
||||
- `bls/recipe_parser.py` — `produkte`-Feld: Split nur vor nächstem `\d+ (g|kg|ml|l)` (Kommas im Namen bleiben)
|
||||
|
||||
## Import
|
||||
|
||||
BLS: Admin-Upload Components + Daten-XLSX (`backend/bls/parser.py`). Upsert über `bls_code` / `attr_key`, nie Delete+Insert offizieller Zeilen.
|
||||
|
||||
FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy / Bestätigung.
|
||||
|
||||
## Router
|
||||
|
||||
- `/api/bls/*` — Suche, Attribute-Suche, eigene Foods (`attributes` + `serving_g`), eigene Mappings; `POST /bls/attributes` legt Extension-Stoff an (Name + Einheit), `GET /bls/attributes?keys=EPA,DHA`
|
||||
- `GET /bls/foods/mine` — eigene `manual_user`-Einträge; `GET /bls/foods/shared` — zentrale `manual_admin`; `GET /bls/foods/{id}` — Editor-Payload inkl. `visibility` und `editable` (Owner persönlich; Admin auch zentral); `PUT /bls/foods/manual/{id}` — nur Owner. Zentrale Updates: `PUT /admin/bls/foods/manual/{id}` (Admin). Suche `?scope=global` blendet persönliche Foods aus (Admin-Mappings).
|
||||
- Sichtbarkeit: `data_layer/food_visibility.py`. Lookup-Mapping: User-Zeile nur wenn Food sichtbar; globale Mapping-Zeile nur `owner_profile_id IS NULL`. `upsert_food_mapping` erzwingt dasselbe. Vorschläge sortieren persönlich → zentral → BLS.
|
||||
- `GET /admin/bls/foods?kind=manual` — `manual_user`/`manual_admin`. `POST /admin/bls/foods/manual` — zentrale Foods. `PUT /admin/bls/foods/manual/{id}` — zentral **und** persönlich (`attributes` + `serving_g`). `GET /admin/bls/foods/{id}?editor=true` — Editor-Payload. Migration **069**: Owner-Check (persönlich hat Owner, BLS/Admin nicht).
|
||||
- Migration **065** — Extension-Keys EPA/DHA/DPA/ALA/OMEGA3/OMEGA6 falls BLS sie nicht unter diesem Key hat
|
||||
- `/api/admin/bls/*` — Import, Katalog, Attribute. `GET /admin/bls/attributes` liefert `group_key`/`group_label` (BLS-Gruppen, sortiert). `PUT /admin/bls/attributes/{id}` ändert Name/Einheit/Gruppe (nicht `attr_key`). `POST /admin/bls/attributes/fill-labels` füllt leere Namen und fehlende Gruppen aus BLS-4.0-Glossar (`bls/attribute_labels.py`). Lebensmittel-Import schreibt fehlende Namen aus Spaltenköpfen. Makro-Rollup: `catalog_macros_for_item` liest nur ENERCC/PROT625/FAT/CHO → `nutrition_log`; `rebuild_daily_nutrients` summiert alle `num_per_100g` nach `nutrition_daily_nutrients`.
|
||||
- `/api/admin/food-mappings` — Admin-CRUD
|
||||
- `/api/nutrition/*` — Items, Unmapped, Bulk-Map, Marken, Konflikt-Resolve
|
||||
- `GET/POST /api/nutrition/recipes`, `PUT/DELETE /api/nutrition/recipes/{id}`, `POST …/import-fddb-lists`, `POST …/{id}/apply`
|
||||
- `PUT /api/admin/food-mappings/{id}` — Ziel-Lebensmittel einer bestehenden Zuordnung wechseln
|
||||
- Unmapped = Tagebuchzeilen ohne `food_id`/`recipe_id` **plus** Listenzutaten ohne Mapping
|
||||
- Frontend: Inline-Vorschläge auf Zuordnen (`food_suggest.py`: Collapse-Key ohne Leerzeichen, Index 5 Min. Cache, Fett-%-Zahlen 9,5/10), `FoodSearchModal` nur noch Zusatzsuche (Abort + Debounce). Eigene Foods: Tab **Lebensmittel** (`MyFoodsPanel` + `FoodEditDialog`). Listen-UI: `FoodRecipeDialog` (Suche gelernt + Katalog; Katalogpick upsertet Mapping). Offene Zutaten: `kind=recipe_ingredient` (API-Wert unverändert, UI: Listenzutat).
|
||||
- Tandoor (Modul `tandoor`): Migration **067** `profile_tandoor_settings`; `GET/PUT /api/tandoor/settings` (Token nur `token_set` / `token_hint`); `POST /api/tandoor/test` (10/min, kein Redirect-Follow). Client: `data_layer/tandoor_client.py`. Import und `cooked_yield_g` später.
|
||||
- Migration **066** — `food_recipe_ingredients.quantity_amount` + `source_unit`; `quantity_g` = Umrechnung (`resolve_quantity` in `food_mapping.py`). `GET /bls/units` = Einheitenkatalog (EL/TL/Prise/Stück, default_g).
|
||||
- `GET /nutrition/unmapped?since_days=28` — nur Namen mit `last_date` im Fenster; `count_only` liefert `{count, total, since_days}`; `meta=true` liefert `{items, count, total, since_days}`. Listenzutaten-Datum: letzte `recipe_id`-Nutzung oder `food_recipes.updated_at`/`created_at` — kein Join über den Listennamen auf alle Tagebuchzeilen. Migration **068**: Partial-Index unmapped-open + `(profile_id, recipe_id, date)`.
|
||||
- `GET /nutrition/recipes?brief=true` — Listen ohne Zutaten, nur `ingredient_count`. `GET /nutrition/recipes/{id}` lädt eine Liste. FDDB-Listenimport antwortet nach Upsert; `rebuild_daily_nutrients` im Hintergrund (`rebuild_pending`).
|
||||
- Listen-Verknüpfung (`apply` / `link_recipes_to_items`) matcht über `names_match_list` (Mengen-Prefix in gespeichertem `source_name_normalized` zählt). Unmapped-Zeilen bekommen `matching_recipe_id` + `matching_recipe_name`. Frontend: Tab Ernährung → Listen.
|
||||
- `GET /nutrition/ingredient-names` — Distinct-Zutatennamen aller Listen inkl. Mapping-Status. Listendialog mappt manuell (`upsertMyFoodMapping`) an der Zeile.
|
||||
- Mapping-Schreiben und Nährwert-Rebuild sind getrennte Transaktionen; Rebuild läuft nach der API-Antwort im Hintergrund (UI bleibt bedienbar)
|
||||
- `food_name_mappings.grams_per_unit` / `source_unit` (Migration **064**)
|
||||
- `GET/POST /api/nutrition/food-knowledge` — portable JSON (`mitai-food-knowledge` v1): manuelle Foods, Mappings (über `bls_code` / Name, keine UUIDs), Listen. Import löst Katalog auf dem Zielsystem auf (BLS muss dort importiert sein).
|
||||
|
|
@ -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` |
|
||||
|
|
|
|||
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.
|
||||
|
||||
|
|
@ -45,10 +46,12 @@ Dieses Dokument ist **normativ für Agenten**, die ein neues Import-Zielmodul an
|
|||
|
||||
---
|
||||
|
||||
## 4. Bekannte Einschränkungen (Follow-up in Gitea)
|
||||
## 4. Validierung & Dry-Run (Stand 2026-07-23, Gitea #71)
|
||||
|
||||
- Admin **„Format prüfen“** kann `import_row_processing` derzeit weglassen; volle Parität mit dem gespeicherten Template erst beim Speichern / echten Import.
|
||||
- Nutzer-Mappings (Copy aus Systemvorlage) laufen nicht automatisch durch **`validate_csv_template`** – Tracking: **Gitea #71** (http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/71).
|
||||
- Admin **„Format prüfen“** sendet dieselbe `import_row_processing`-Spec wie Speichern (`AdminCsvTemplateEditorPage` → `POST /api/admin/csv-templates/validate`).
|
||||
- **Profil-Mappings:** `POST /api/csv/mappings/{id}/copy` und `POST /api/csv/import` prüfen vor dem Schreiben mit **`validate_csv_template`** (HTTP 422 bei Fehlern).
|
||||
- **Diagnose:** `GET /api/csv/mappings/{id}/validate` — Strukturprüfung ohne Import.
|
||||
- **Nutzer-UI:** `UniversalCsvImportPage` zeigt `error_details` mit Zeile, `code` und `hint` (`CsvImportErrorDetails`).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
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
10
.claude/docs/working/gitea-close-2026-07-23/comment-25.md
Normal file
10
.claude/docs/working/gitea-close-2026-07-23/comment-25.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
**Automatischer Audit (Code + Playwright, 2026-07-23)**
|
||||
|
||||
Ziele-System ist im Repo umgesetzt:
|
||||
- Backend: `backend/routers/goals.py`, Focus Areas, Goal Types, Progress
|
||||
- Frontend: `frontend/src/pages/GoalsPage.jsx`, Nav `/goals` in `config/appNav.js`
|
||||
- Spec: `docs/issues/issue-50-phase-0a-goal-system.md`
|
||||
|
||||
Playwright auf dev.mitai.jinkendo.de: `/goals` rendert ohne Fehler.
|
||||
|
||||
Verbleibende KI-Goal-Erweiterungen ggf. als neues Issue.
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
**Automatischer Audit (Code, 2026-07-23)**
|
||||
|
||||
Deprecated Tabelle `subscriptions` ist aus dem aktiven Schema entfernt:
|
||||
- Kein Treffer in `backend/schema.sql`, `backend/migrations/`, Backend-Python
|
||||
- Membership nutzt `access_grants`, `tier_limits`, etc. (Migration v9c)
|
||||
|
||||
Doku-Hinweis deprecated: `.claude/docs/technical/DATABASE.md`
|
||||
|
||||
Optional Prod-Check: `\dt subscriptions` — sollte leer/nicht vorhanden sein.
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
**Automatischer Audit (Code, 2026-07-23)**
|
||||
|
||||
Bug behoben: JSONB-Insert für `abilities`/`profile` nutzt `psycopg2.extras.Json()`.
|
||||
|
||||
- Fix-Commit: `2977050` — wrap abilities dict with Json() for JSONB insert
|
||||
- Aktuell: `backend/routers/admin_training_types.py` (`create_training_type`, Zeilen 111–112)
|
||||
|
||||
Admin-UI: `frontend/src/pages/AdminTrainingTypesPage.jsx`
|
||||
23
.claude/docs/working/gitea-close-2026-07-23/comment-37.md
Normal file
23
.claude/docs/working/gitea-close-2026-07-23/comment-37.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
**Umsetzung 2026-07-23 (nach Architektur-Abgleich)**
|
||||
|
||||
## Architektur-Check vor Implementierung
|
||||
|
||||
- **Phase C (Schreibpfad):** laut `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` bereits erledigt (Sync abgestellt, Orchestrator als SSoT, keine Aufrufer von `sync_column_backed_session_metrics`). Keine Doppel-Implementierung.
|
||||
- **Feature-ID:** Issue-Vorgabe `activity_entries` (nicht separates `activity_import`) — konsistent mit `create_activity` und Universal-CSV-Modul-Check in `csv_import.py`.
|
||||
- **Legacy-Endpoint bleibt:** Frontend nutzt weiterhin `POST /api/activity/import-csv` (`ActivityPage` Apple-Health-Panel); Universal-Pfad ist parallel (ARCH §8.2).
|
||||
|
||||
## Änderungen
|
||||
|
||||
**Backend** (`routers/activity.py`):
|
||||
- `check_feature_access(pid, "activity_entries")` vor Legacy-Import
|
||||
- HTTP 403 bei Limit (wie `nutrition/import-csv`)
|
||||
- `increment_feature_usage` pro neu eingefügter Zeile (`inserted`)
|
||||
|
||||
**Frontend** (`ActivityPage.jsx` ImportPanel):
|
||||
- `UsageBadge` am Import-Titel
|
||||
- Drop-Zone deaktiviert bei Limit
|
||||
- Usage-Reload nach Import
|
||||
|
||||
**Tests:** `tests/test_activity_import_feature_enforcement.py` (403 + Increment)
|
||||
|
||||
Regression INSERT-SQL: `tests/test_activity_insert_sql.py` (grün)
|
||||
22
.claude/docs/working/gitea-close-2026-07-23/comment-38.md
Normal file
22
.claude/docs/working/gitea-close-2026-07-23/comment-38.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
**Umsetzung 2026-07-23 (UI-Teil; Backend war bereits erledigt)**
|
||||
|
||||
## Ist-Zustand vorher
|
||||
|
||||
- Backend: `POST /api/nutrition/import-csv` mit `check_feature_access('nutrition_entries')` und HTTP 403 — **bereits implementiert**
|
||||
- Frontend: FDDB-Import ohne Usage-Anzeige, Drop-Zone blieb bei Limit klickbar
|
||||
|
||||
## Änderungen
|
||||
|
||||
**Frontend** (`NutritionPage.jsx`, ImportPanel):
|
||||
- `getFeatureUsage()` → `nutrition_entries`
|
||||
- `UsageBadge` am Import-Titel
|
||||
- Drop-Zone und Paste-Import deaktiviert bei Limit
|
||||
- Usage-Reload nach erfolgreichem Import
|
||||
|
||||
Analog zu Activity #37 (geschlossen 2026-07-23).
|
||||
|
||||
**Playwright:** `tests/issue-audit.spec.js` — Badge am FDDB-Panel
|
||||
|
||||
## Hinweis
|
||||
|
||||
Universal-CSV (`/api/csv-import/import`) prüft zusätzlich `data_import` — Legacy FDDB-Pfad folgt dem Nutrition-Muster (nur `nutrition_entries`), konsistent mit Architektur §8.2.
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
**Automatischer Audit (Code + Playwright, 2026-07-23)**
|
||||
|
||||
Logout-Button im Mobile-Header neben Avatar implementiert:
|
||||
- `frontend/src/App.jsx` — Button mit `title="Abmelden"`, Icon `LogOut`
|
||||
- Zusätzlich Desktop: `frontend/src/components/DesktopSidebar.jsx`
|
||||
|
||||
Playwright auf dev.mitai.jinkendo.de: Button sichtbar und klickbar.
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23**
|
||||
|
||||
Inhalt identisch mit **#42** (Enhanced Debug/Prompt Analysis UI).
|
||||
|
||||
Teilumsetzung existiert bereits (`Analysis.jsx` Experten-Modus, `WorkflowDebugPanel`). Weiterverfolgung unter #42.
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23**
|
||||
|
||||
Inhalt identisch mit **#55** (Placeholder Registry: UNRESOLVED & TO_VERIFY Metadaten).
|
||||
|
||||
Bitte weiterverfolgen unter #55.
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23**
|
||||
|
||||
Inhalt identisch mit **#56** (Body Cluster — Restarbeiten & Metadaten-Verifizierung).
|
||||
|
||||
Bitte weiterverfolgen unter #56.
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23**
|
||||
|
||||
Inhalt identisch mit **#56** (Body Cluster — Restarbeiten & Metadaten-Verifizierung).
|
||||
|
||||
Bitte weiterverfolgen unter #56.
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
**Automatischer Audit (Code + Playwright, 2026-07-23)**
|
||||
|
||||
Nutzer-konfigurierbares Dashboard umgesetzt:
|
||||
- Migration `039_dashboard_layout.sql` — `profiles.dashboard_layout`
|
||||
- API: `backend/routers/app_dashboard.py` — GET/PUT/reset `/api/app/dashboard-layout`
|
||||
- Frontend: `DashboardConfigurePage.jsx`, Widget-Registry `registerDashboardWidgets.js`
|
||||
- Tests: `test_dashboard_layout_schema.py`, `test_widget_catalog.py`
|
||||
|
||||
Playwright: `/settings/dashboard-layout` erreichbar, Widget/Layout-UI sichtbar.
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
**Automatischer Audit (Code, 2026-07-23)**
|
||||
|
||||
Feature-Gate-Zuordnung aus Admin/DB implementiert:
|
||||
- Migration `041_widget_feature_requirements.sql`
|
||||
- Logik: `backend/widget_feature_requirements_db.py`, `dashboard_widget_entitlements.py`
|
||||
- Admin-UI: `AdminWidgetFeatureAssignmentsPage.jsx`, Route `/admin/widget-features`
|
||||
- Changelog: `backend/version.py` (Admin Widgets × Features)
|
||||
|
||||
Hardcodierter Katalog wird durch DB-Overrides ergänzt (Hybrid-Modell).
|
||||
20
.claude/docs/working/gitea-close-2026-07-23/comment-71.md
Normal file
20
.claude/docs/working/gitea-close-2026-07-23/comment-71.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
**Implementierung abgeschlossen (2026-07-24)**
|
||||
|
||||
### 1. Admin „Format prüfen“ inkl. `import_row_processing`
|
||||
- `AdminCsvTemplateEditorPage`: `resolveEditorImportRowProcessing()` — dieselbe Spec wie beim Speichern
|
||||
- Dry-Run/Format-Check nutzt konsistente Vorlage inkl. Aggregation (`group_by` / `aggregates`)
|
||||
|
||||
### 2. Profil-Mappings validieren
|
||||
- `csv_import.py`: `_validate_mapping_config` / `_ensure_mapping_valid` bei **Copy** und **Import**
|
||||
- Neuer Endpoint: `GET /api/csv/mappings/{id}/validate`
|
||||
- `api.js`: `validateCsvMapping()`; `formatFastApiDetail` für verschachtelte Validierungsfehler
|
||||
|
||||
### 3. Nutzer-UI: strukturierte Fehler
|
||||
- Neue Komponente `CsvImportErrorDetails.jsx` (Zeile, code, hint)
|
||||
- `UniversalCsvImportPage` zeigt `error_details` statt JSON-Dump
|
||||
|
||||
### Tests & Doku
|
||||
- `backend/tests/test_csv_mapping_validation.py` — pytest grün
|
||||
- Agent-Guide: `.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` §4 aktualisiert
|
||||
|
||||
**Betroffene Dateien:** `backend/routers/csv_import.py`, `frontend/src/pages/AdminCsvTemplateEditorPage.jsx`, `frontend/src/pages/UniversalCsvImportPage.jsx`, `frontend/src/components/CsvImportErrorDetails.jsx`, `frontend/src/utils/api.js`
|
||||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
|-----|------|----------------|
|
||||
| **Fachliche Spec (WAS)** | `.claude/docs/functional/` | Domäne, Use Cases, UX-Ziele, fachliche Datenarchitektur. **Keine** reine API-Parameterliste (→ technical). |
|
||||
| **Technische Spec (WIE)** | `.claude/docs/technical/` | API-, DB-, Implementierungsmuster, Agent-Guides, Migrationen. |
|
||||
| **Produktfamilie / Foundation** | `.claude/docs/jinkendo-foundation/` | Übertragbare Designprinzipien (nicht Mitai-Domäne); Einstieg `design-principles/README.md`. |
|
||||
| **Architektur-Querschnitt** | `.claude/docs/architecture/` | Kurze Überblicke (z. B. Frontend-Baum), ergänzend zu technical. |
|
||||
| **Arbeitspapier / Zwischenstand** | `.claude/docs/working/` | Analysen, Sessions, Migration-Notizen, **keine** langfristige Norm. Kann veraltet sein → Datum im Dokument. **Nicht** als alleinige „Wahrheit“ für Produkt zitieren. |
|
||||
| **Audits & Matrizen** | `.claude/docs/audit/` | Zeitlich begrenzte Reviews, Reconciliation, Gitea-Vorlagen. |
|
||||
|
|
@ -63,6 +64,7 @@ Diese Ordner sind **kein** Ersatz für `working/` oder `docs/issues/`, wenn das
|
|||
```
|
||||
.claude/README.md ← Einstieg Agent/Human
|
||||
.claude/docs/README.md ← Spec-Katalog
|
||||
.claude/docs/jinkendo-foundation/ ← Foundation Produktfamilie (Designprinzipien)
|
||||
.claude/docs/functional/ ← WAS
|
||||
.claude/docs/technical/ ← WIE
|
||||
.claude/docs/working/ ← Arbeitspapiere / Analysen
|
||||
|
|
|
|||
|
|
@ -176,6 +176,20 @@ known_limitations=(
|
|||
**Problem:** Versuch, `.claude/task/` Files zu committen
|
||||
**Lösung:** Nur `backend/` Code committen, `.claude/` ist local docs
|
||||
|
||||
## 9. Unmapped-Query: Kreuzjoin Liste × Tagebuch (12.09.2026)
|
||||
|
||||
**Was:** Offene Lebensmittel über `food_recipes` mit `nutrition_items` per `recipe_id OR name_normalized` verbinden, um Zutaten ein Datum zu geben.
|
||||
**Problem:** Nach FDDB-Listenimport Minuten bis Timeout; Zutaten ohne `last_date` fielen im 28-Tage-Filter weg — leere Offene-Liste.
|
||||
**Lösung:** Nur Items mit `recipe_id` gruppieren; Fallback `recipe.updated_at`. Listen-GET `brief=true`. Rebuild nach Import im Hintergrund.
|
||||
|
||||
**Regel:** Kein Join „Name der Liste gegen alle Tagebuchzeilen“. Datum aus der bereits gesetzten FK oder aus dem Eltern-Datensatz.
|
||||
|
||||
## 10. `from __future__ import annotations` + FastAPI-Limiter (13.09.2026)
|
||||
|
||||
**Was:** `tandoor.py` mit future-annotations und `body: TandoorProbeIn | None` unter `@limiter.limit`.
|
||||
**Problem:** Pydantic löst den Typ nicht auf → Import-Crash → Backend startet nicht → Login bekommt HTML (`Unexpected token '<'`).
|
||||
**Lösung:** Kein future-annotations in Routern; `Optional[TandoorProbeIn]`. Import-Test für den Router.
|
||||
|
||||
---
|
||||
|
||||
**Zusammenfassung Nutrition Cluster:**
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -58,6 +58,9 @@ coverage/
|
|||
# Temp
|
||||
tmp/
|
||||
*.tmp
|
||||
test-results/
|
||||
screenshots/
|
||||
tests/.auth/
|
||||
|
||||
# Claude: nur ausgewählte Bereiche versionieren (siehe .claude/rules/DOCUMENTATION.md)
|
||||
.claude/**
|
||||
|
|
|
|||
39
CLAUDE.md
39
CLAUDE.md
|
|
@ -10,8 +10,10 @@
|
|||
> | **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`** |
|
||||
> | **Designprinzipien Produktfamilie** (Serie 1–9, Foundation) | **`.claude/docs/jinkendo-foundation/design-principles/README.md`** |
|
||||
> | **Agent-Einstieg** | **`.claude/README.md`** |
|
||||
> | **Activity Session Metrics (EAV, Attributprofile)** | **`.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md`** |
|
||||
|
||||
## Claude Code Verantwortlichkeiten
|
||||
|
||||
|
|
@ -99,6 +101,11 @@ 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)
|
||||
|
|
@ -110,11 +117,39 @@ frontend/src/
|
|||
- **`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 (12.09.2026 - BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch)
|
||||
|
||||
- **Migration 062:** `food_catalog` (BLS-Code bleibt Identität), dynamische `food_attributes` + EAV, `food_name_mappings`, `nutrition_items`, `nutrition_daily_nutrients`, `nutrition_day_marks`, Import-Policy am Profil.
|
||||
- **Admin:** Gruppe Ernährung — BLS-Import, Katalog, Attribute, Mappings.
|
||||
- **Nutzer:** Einzelerfassung unverändert; Tab Zuordnen mit Namenssuche (Popup); FDDB-Listen/Kombinationen; JSON-Export/Import der Zuordnungen; Fasten/Lücke; Import-Abgleich.
|
||||
- **Zuordnen-Performance:** Katalog-Index im Prozess (5 Min.), Vorschläge nur für sichtbare Zeilen (`POST /bls/foods/suggest-batch`), Suche mit Abort; nach Bestätigen kein Reload der ganzen Ernährungseite. Unmapped ohne Kreuzjoin Liste×Tagebuch; Listenzutaten-Datum aus `recipe.updated_at` oder letzter `recipe_id`-Nutzung. Listen-API `?brief=true`; FDDB-Listenimport rechnet Tageswerte im Hintergrund. Migration **068**.
|
||||
- **Zuordnen-Zeitraum:** Standard letzte 4 Wochen (`since_days`); ältere ungemappte Namen (z. B. Getreide nach Glutenverzicht) bleiben unter „Alle“.
|
||||
- **Katalogsuche:** Fettgehalt mitsuchen (`Joghurt 10%` / `9,5`); Dezimal-Komma bleibt erhalten.
|
||||
- **Manuelle Foods:** Persönlich (`manual_user` + Owner, nur dieser Nutzer) oder zentral (`manual_admin`, alle Nutzer). Stoffe über EAV. Nutzer: Tab Ernährung → **Lebensmittel** (persönlich ändern; zentral nur Admin). Admin: Katalog → Manuell — Listen **Zentral** und **Persönlich**, beide änderbar. Stoffe & Attribute: Bezeichnung editierbar, Schlüssel fest; Makros nur ENERCC/PROT625/FAT/CHO → `nutrition_log`. Mapping: Nutzer-Mapping darf sichtbare Foods; globales Mapping nur BLS/zentral. Offizielle BLS-Zeilen nicht editierbar. Öl/Supplement = ein Lebensmittel, keine Liste.
|
||||
- **Listen / Kombinationen (Mitai):** Tab **Listen** (anlegen, bearbeiten, FDDB-Import). Im Listendialog Zutaten direkt BLS-zuordnen (Status offen/zugeordnet); Suche zuerst Listen-Namen und eigene Foods, dann BLS. **Tandoor:** User-Einstellungen URL + Token, Verbindungstest (`/api/tandoor/*`). Gerichte bleiben in Tandoor; später Mapping + `cooked_yield_g`. Admin-Mappings: `PUT /admin/food-mappings/{id}`.
|
||||
- **Gitea #106:** BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/106
|
||||
- **Doku:** `.claude/docs/functional/BLS_FOOD_REFERENCE.md`, `.claude/docs/technical/BLS_FOOD_REFERENCE.md`, `docs/issues/issue-bls-food-mapping.md`. Folge #75.
|
||||
|
||||
### 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).
|
||||
|
|
@ -875,7 +910,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
|
||||
|
|
|
|||
1
backend/bls/__init__.py
Normal file
1
backend/bls/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""BLS 4.0 ingest (official MRI XLSX)."""
|
||||
321
backend/bls/attribute_labels.py
Normal file
321
backend/bls/attribute_labels.py
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
"""Official BLS 4.0 component labels (MRI documentation) and placeholder repair.
|
||||
|
||||
``attr_key`` stays the identity. These names only fill empty or key-equal ``name_de``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# Keys that feed nutrition_log kcal / protein_g / fat_g / carbs_g
|
||||
MACRO_KEYS = ("ENERCC", "PROT625", "FAT", "CHO")
|
||||
|
||||
GROUP_META = (
|
||||
("energy", "Energie"),
|
||||
("macro", "Makronährstoffe"),
|
||||
("carbs", "Kohlenhydrate"),
|
||||
("fiber", "Ballaststoffe"),
|
||||
("fatty_acids", "Fettsäuren"),
|
||||
("amino_acids", "Aminosäuren"),
|
||||
("vitamins", "Vitamine"),
|
||||
("minerals", "Mineralstoffe"),
|
||||
("organic_acids", "Organische Säuren"),
|
||||
("polyols", "Zuckeralkohole"),
|
||||
("other", "Sonstige"),
|
||||
("extension", "Eigene Erweiterungen"),
|
||||
("unknown", "Ohne Gruppe"),
|
||||
)
|
||||
GROUP_LABELS = {key: label for key, label in GROUP_META}
|
||||
GROUP_RANK = {key: i for i, (key, _) in enumerate(GROUP_META)}
|
||||
|
||||
# Code → (name_de, unit, category) from BLS 4.0 Referenzhandbuch (Jan 2026)
|
||||
_OFFICIAL: dict[str, tuple[str, str, str]] = {
|
||||
"ENERCJ": ("Energie (Kilojoule)", "kJ", "energy"),
|
||||
"ENERCC": ("Energie (Kilokalorien)", "kcal", "energy"),
|
||||
"WATER": ("Wasser", "g", "macro"),
|
||||
"PROT625": ("Protein (N×6,25)", "g", "macro"),
|
||||
"FAT": ("Fett", "g", "macro"),
|
||||
"CHO": ("Kohlenhydrate, verfügbar", "g", "macro"),
|
||||
"FIBT": ("Ballaststoffe, gesamt", "g", "fiber"),
|
||||
"ALC": ("Alkohol (Ethanol)", "g", "macro"),
|
||||
"OA": ("Organische Säuren, gesamt", "g", "organic_acids"),
|
||||
"ASH": ("Rohasche", "g", "macro"),
|
||||
"POLYL": ("Zuckeralkohole, gesamt", "g", "polyols"),
|
||||
"GLUS": ("Glucose", "g", "carbs"),
|
||||
"FRUS": ("Fructose", "g", "carbs"),
|
||||
"GALS": ("Galactose", "g", "carbs"),
|
||||
"MNSAC": ("Monosaccharide, gesamt", "g", "carbs"),
|
||||
"SUCS": ("Saccharose", "g", "carbs"),
|
||||
"MALS": ("Maltose", "g", "carbs"),
|
||||
"LACS": ("Lactose", "g", "carbs"),
|
||||
"DISAC": ("Disaccharide, gesamt", "g", "carbs"),
|
||||
"SUGAR": ("Zucker, gesamt", "g", "carbs"),
|
||||
"OLSAC": ("Oligosaccharide, verfügbar", "g", "carbs"),
|
||||
"STARCH": ("Stärke", "g", "carbs"),
|
||||
"FIBLMW": ("Ballaststoffe, niedermolekular", "g", "fiber"),
|
||||
"FIBHMW": ("Ballaststoffe, hochmolekular", "g", "fiber"),
|
||||
"FIBINS": ("Ballaststoffe, wasserunlöslich", "g", "fiber"),
|
||||
"FIBSOL": ("Ballaststoffe, wasserlöslich", "g", "fiber"),
|
||||
"FIBHMWS": ("Ballaststoffe, hochmolekular, wasserlöslich", "g", "fiber"),
|
||||
"FIBHMWI": ("Ballaststoffe, hochmolekular, wasserunlöslich", "g", "fiber"),
|
||||
"FASAT": ("Fettsäuren, gesättigt, gesamt", "g", "fatty_acids"),
|
||||
"F4:0": ("Buttersäure (C4:0)", "g", "fatty_acids"),
|
||||
"F6:0": ("Capronsäure (C6:0)", "g", "fatty_acids"),
|
||||
"F8:0": ("Caprylsäure (C8:0)", "g", "fatty_acids"),
|
||||
"F10:0": ("Caprinsäure (C10:0)", "g", "fatty_acids"),
|
||||
"F12:0": ("Laurinsäure (C12:0)", "g", "fatty_acids"),
|
||||
"F14:0": ("Myristinsäure (C14:0)", "g", "fatty_acids"),
|
||||
"F15:0": ("Pentadecylsäure (C15:0)", "g", "fatty_acids"),
|
||||
"F16:0": ("Palmitinsäure (C16:0)", "g", "fatty_acids"),
|
||||
"F17:0": ("Margarinsäure (C17:0)", "g", "fatty_acids"),
|
||||
"F18:0": ("Stearinsäure (C18:0)", "g", "fatty_acids"),
|
||||
"F20:0": ("Arachinsäure (C20:0)", "g", "fatty_acids"),
|
||||
"F22:0": ("Behensäure (C22:0)", "g", "fatty_acids"),
|
||||
"F24:0": ("Lignocerinsäure (C24:0)", "g", "fatty_acids"),
|
||||
"FAMS": ("Fettsäuren, einfach ungesättigt, gesamt", "g", "fatty_acids"),
|
||||
"F14:1CN5": ("Myristoleinsäure (C14:1 n-5 cis)", "g", "fatty_acids"),
|
||||
"F16:1CN7": ("Palmitoleinsäure (C16:1 n-7 cis)", "g", "fatty_acids"),
|
||||
"F18:1CN7": ("Vaccensäure (C18:1 n-7 cis)", "g", "fatty_acids"),
|
||||
"F18:1CN9": ("Ölsäure (C18:1 n-9 cis)", "g", "fatty_acids"),
|
||||
"F20:1CN9": ("Gondosäure (C20:1 n-9 cis)", "g", "fatty_acids"),
|
||||
"F22:1CN9": ("Erucasäure (C22:1 n-9 cis)", "g", "fatty_acids"),
|
||||
"FAPU": ("Fettsäuren, mehrfach ungesättigt, gesamt", "g", "fatty_acids"),
|
||||
"FAPUN3": ("Omega-3-Fettsäuren, gesamt", "g", "fatty_acids"),
|
||||
"FAPUN6": ("Omega-6-Fettsäuren, gesamt", "g", "fatty_acids"),
|
||||
"F18:2CN6": ("Linolsäure (C18:2 n-6 cis)", "g", "fatty_acids"),
|
||||
"F18:2C9T11": ("Konjugierte Linolsäure (C18:2 cis-9, trans-11)", "g", "fatty_acids"),
|
||||
"F18:3CN3": ("Alpha-Linolensäure (C18:3 n-3)", "g", "fatty_acids"),
|
||||
"F18:3CN6": ("Gamma-Linolensäure (C18:3 n-6)", "g", "fatty_acids"),
|
||||
"F18:4CN3": ("Stearidonsäure (C18:4 n-3)", "g", "fatty_acids"),
|
||||
"F20:2CN6": ("Eicosadiensäure (C20:2 n-6)", "g", "fatty_acids"),
|
||||
"F20:3CN6": ("Dihomo-Gamma-Linolensäure (C20:3 n-6)", "g", "fatty_acids"),
|
||||
"F20:4CN6": ("Arachidonsäure (C20:4 n-6)", "g", "fatty_acids"),
|
||||
"F20:5CN3": ("Eicosapentaensäure EPA (C20:5 n-3)", "g", "fatty_acids"),
|
||||
"F22:5CN3": ("Docosapentaensäure DPA (C22:5 n-3)", "g", "fatty_acids"),
|
||||
"F22:6CN3": ("Docosahexaensäure DHA (C22:6 n-3)", "g", "fatty_acids"),
|
||||
"FAX": ("Fettsäuren, sonstige", "g", "fatty_acids"),
|
||||
"AAE9": ("Aminosäuren, unentbehrlich, gesamt", "g", "amino_acids"),
|
||||
"ILE": ("Isoleucin", "g", "amino_acids"),
|
||||
"LEU": ("Leucin", "g", "amino_acids"),
|
||||
"LYS": ("Lysin", "g", "amino_acids"),
|
||||
"MET": ("Methionin", "g", "amino_acids"),
|
||||
"PHE": ("Phenylalanin", "g", "amino_acids"),
|
||||
"THR": ("Threonin", "g", "amino_acids"),
|
||||
"TRP": ("Tryptophan", "g", "amino_acids"),
|
||||
"VAL": ("Valin", "g", "amino_acids"),
|
||||
"HIS": ("Histidin", "g", "amino_acids"),
|
||||
"ALA": ("Alanin", "g", "amino_acids"),
|
||||
"ARG": ("Arginin", "g", "amino_acids"),
|
||||
"ASP": ("Asparaginsäure inkl. Asparagin", "g", "amino_acids"),
|
||||
"CYS": ("Cystein", "g", "amino_acids"),
|
||||
"GLU": ("Glutaminsäure inkl. Glutamin", "g", "amino_acids"),
|
||||
"GLY": ("Glycin", "g", "amino_acids"),
|
||||
"PRO": ("Prolin", "g", "amino_acids"),
|
||||
"SER": ("Serin", "g", "amino_acids"),
|
||||
"TYR": ("Tyrosin", "g", "amino_acids"),
|
||||
"VITA": ("Vitamin A, Retinol-Äquivalent (RE)", "µg", "vitamins"),
|
||||
"VITAA": ("Vitamin A, Retinol-Aktivitätsäquivalent (RAE)", "µg", "vitamins"),
|
||||
"RETOL": ("Retinol", "µg", "vitamins"),
|
||||
"CARTB": ("Beta-Carotin", "µg", "vitamins"),
|
||||
"CAROTPAXB": ("Carotinoide außer Beta-Carotin", "µg", "vitamins"),
|
||||
"VITD": ("Vitamin D", "µg", "vitamins"),
|
||||
"ERGCAL": ("Vitamin D2 (Ergocalciferol)", "µg", "vitamins"),
|
||||
"CHOCAL": ("Vitamin D3 (Cholecalciferol)", "µg", "vitamins"),
|
||||
"VITE": ("Vitamin E (Alpha-Tocopherol)", "mg", "vitamins"),
|
||||
"TOCPHA": ("Alpha-Tocopherol", "mg", "vitamins"),
|
||||
"TOCPHB": ("Beta-Tocopherol", "mg", "vitamins"),
|
||||
"TOCPHG": ("Gamma-Tocopherol", "mg", "vitamins"),
|
||||
"TOCPHD": ("Delta-Tocopherol", "mg", "vitamins"),
|
||||
"TOCTRA": ("Alpha-Tocotrienol", "mg", "vitamins"),
|
||||
"VITK": ("Vitamin K", "µg", "vitamins"),
|
||||
"VITK1": ("Vitamin K1 (Phyllochinon)", "µg", "vitamins"),
|
||||
"VITK2": ("Vitamin K2 (Menachinone)", "µg", "vitamins"),
|
||||
"THIA": ("Vitamin B1 (Thiamin)", "mg", "vitamins"),
|
||||
"RIBF": ("Vitamin B2 (Riboflavin)", "mg", "vitamins"),
|
||||
"NIA": ("Niacin", "mg", "vitamins"),
|
||||
"NIAEQ": ("Niacin-Äquivalent", "mg", "vitamins"),
|
||||
"PANTAC": ("Pantothensäure", "mg", "vitamins"),
|
||||
"VITB6": ("Vitamin B6", "µg", "vitamins"),
|
||||
"BIOT": ("Biotin", "µg", "vitamins"),
|
||||
"FOL": ("Folat-Äquivalent", "µg", "vitamins"),
|
||||
"FOLFD": ("Folat", "µg", "vitamins"),
|
||||
"FOLAC": ("Folsäure, synthetisch", "µg", "vitamins"),
|
||||
"VITB12": ("Vitamin B12 (Cobalamine)", "µg", "vitamins"),
|
||||
"VITC": ("Vitamin C", "mg", "vitamins"),
|
||||
"NACL": ("Salz (Natriumchlorid)", "g", "minerals"),
|
||||
"NA": ("Natrium", "mg", "minerals"),
|
||||
"CLD": ("Chlorid", "mg", "minerals"),
|
||||
"K": ("Kalium", "mg", "minerals"),
|
||||
"CA": ("Calcium", "mg", "minerals"),
|
||||
"MG": ("Magnesium", "mg", "minerals"),
|
||||
"P": ("Phosphor", "mg", "minerals"),
|
||||
"S": ("Schwefel", "mg", "minerals"),
|
||||
"FE": ("Eisen", "mg", "minerals"),
|
||||
"ZN": ("Zink", "mg", "minerals"),
|
||||
"ID": ("Iodid", "µg", "minerals"),
|
||||
"CU": ("Kupfer", "µg", "minerals"),
|
||||
"MN": ("Mangan", "µg", "minerals"),
|
||||
"FD": ("Fluorid", "µg", "minerals"),
|
||||
"CR": ("Chrom", "µg", "minerals"),
|
||||
"MO": ("Molybdän", "µg", "minerals"),
|
||||
"ACEAC": ("Essigsäure", "g", "organic_acids"),
|
||||
"CITAC": ("Zitronensäure", "g", "organic_acids"),
|
||||
"LACAC": ("Milchsäure", "g", "organic_acids"),
|
||||
"MALAC": ("Äpfelsäure", "g", "organic_acids"),
|
||||
"TARAC": ("Weinsäure", "g", "organic_acids"),
|
||||
"MANTL": ("Mannit", "g", "polyols"),
|
||||
"SORTL": ("Sorbit", "g", "polyols"),
|
||||
"XYLTL": ("Xylit", "g", "polyols"),
|
||||
"CHORL": ("Cholesterin", "mg", "other"),
|
||||
"NT": ("Stickstoff, gesamt", "g", "other"),
|
||||
"EPA": ("Eicosapentaensäure (EPA)", "g", "fatty_acids"),
|
||||
"DHA": ("Docosahexaensäure (DHA)", "g", "fatty_acids"),
|
||||
"DPA": ("Docosapentaensäure (DPA)", "g", "fatty_acids"),
|
||||
"ALA": ("Alpha-Linolensäure (ALA)", "g", "fatty_acids"),
|
||||
"OMEGA3": ("Omega-3-Fettsäuren gesamt", "g", "fatty_acids"),
|
||||
"OMEGA6": ("Omega-6-Fettsäuren gesamt", "g", "fatty_acids"),
|
||||
}
|
||||
|
||||
|
||||
def lookup_key(raw: str | None) -> str:
|
||||
return str(raw or "").strip().upper()
|
||||
|
||||
|
||||
def resolve_group(attr_key: str | None, category: str | None = None, origin: str | None = None) -> tuple[str, str]:
|
||||
cat = (category or "").strip().lower()
|
||||
if cat in GROUP_LABELS:
|
||||
return cat, GROUP_LABELS[cat]
|
||||
lab = official_label(attr_key)
|
||||
if lab and lab["category"] in GROUP_LABELS:
|
||||
return lab["category"], GROUP_LABELS[lab["category"]]
|
||||
if (origin or "") == "extension":
|
||||
return "extension", GROUP_LABELS["extension"]
|
||||
return "unknown", GROUP_LABELS["unknown"]
|
||||
|
||||
|
||||
def annotate_attribute(row: dict[str, Any]) -> dict[str, Any]:
|
||||
key = row.get("attr_key")
|
||||
group_key, group_label = resolve_group(key, row.get("category"), row.get("origin"))
|
||||
row["is_macro"] = key in MACRO_KEYS
|
||||
row["needs_label"] = label_is_placeholder(row.get("name_de"), key)
|
||||
row["group_key"] = group_key
|
||||
row["group_label"] = group_label
|
||||
row["group_rank"] = GROUP_RANK[group_key]
|
||||
return row
|
||||
|
||||
|
||||
def sort_attributes(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
def key_of(row: dict[str, Any]):
|
||||
return (
|
||||
row.get("group_rank", 99),
|
||||
0 if row.get("is_macro") else 1,
|
||||
row.get("sort_order") if row.get("sort_order") is not None else 0,
|
||||
(row.get("name_de") or row.get("attr_key") or "").casefold(),
|
||||
row.get("attr_key") or "",
|
||||
)
|
||||
|
||||
return sorted(rows, key=key_of)
|
||||
|
||||
|
||||
def official_label(attr_key: str | None) -> dict[str, str] | None:
|
||||
key = lookup_key(attr_key)
|
||||
hit = _OFFICIAL.get(key)
|
||||
if not hit:
|
||||
hit = _OFFICIAL.get(key.replace("_", ":"))
|
||||
if not hit:
|
||||
return None
|
||||
name_de, unit, category = hit
|
||||
return {"name_de": name_de, "unit": unit, "category": category}
|
||||
|
||||
|
||||
def label_is_placeholder(name_de: str | None, attr_key: str | None) -> bool:
|
||||
name = (name_de or "").strip()
|
||||
key = lookup_key(attr_key)
|
||||
return (not name) or name.upper() == key
|
||||
|
||||
|
||||
def apply_official_name(attr_key: str | None, name_de: str | None) -> str:
|
||||
if not label_is_placeholder(name_de, attr_key):
|
||||
return (name_de or "").strip()
|
||||
lab = official_label(attr_key)
|
||||
if lab:
|
||||
return lab["name_de"]
|
||||
return (name_de or "").strip() or lookup_key(attr_key)
|
||||
|
||||
|
||||
def enrich_attributes_in_memory(attributes: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
for a in attributes:
|
||||
key = a.get("attr_key")
|
||||
lab = official_label(key)
|
||||
if label_is_placeholder(a.get("name_de"), key) and lab:
|
||||
a["name_de"] = lab["name_de"]
|
||||
a["unit"] = a.get("unit") or lab["unit"]
|
||||
a["category"] = a.get("category") or lab["category"]
|
||||
return attributes
|
||||
|
||||
|
||||
def fill_placeholder_labels(cur) -> dict[str, int]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, attr_key, name_de, unit, category
|
||||
FROM food_attributes
|
||||
WHERE is_active = true
|
||||
"""
|
||||
)
|
||||
updated = 0
|
||||
for row in cur.fetchall():
|
||||
rec = dict(row)
|
||||
lab = official_label(rec.get("attr_key"))
|
||||
if not lab:
|
||||
continue
|
||||
need_name = label_is_placeholder(rec.get("name_de"), rec.get("attr_key"))
|
||||
need_cat = not (rec.get("category") or "").strip()
|
||||
if not need_name and not need_cat:
|
||||
continue
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE food_attributes
|
||||
SET name_de = %s,
|
||||
unit = COALESCE(NULLIF(BTRIM(COALESCE(unit, '')), ''), %s),
|
||||
category = COALESCE(NULLIF(BTRIM(COALESCE(category, '')), ''), %s),
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(
|
||||
lab["name_de"] if need_name else rec.get("name_de"),
|
||||
lab["unit"],
|
||||
lab["category"],
|
||||
rec["id"],
|
||||
),
|
||||
)
|
||||
updated += 1
|
||||
return {"updated": updated}
|
||||
|
||||
|
||||
def enrich_labels_from_food_headers(cur, headers: list[dict[str, Any]] | None) -> int:
|
||||
updated = 0
|
||||
for h in headers or []:
|
||||
key = h.get("attr_key")
|
||||
name = (h.get("name_de") or "").strip()
|
||||
unit = (h.get("unit") or "").strip() or None
|
||||
if not key or not name or label_is_placeholder(name, key):
|
||||
lab = official_label(key)
|
||||
if not lab:
|
||||
continue
|
||||
name = lab["name_de"]
|
||||
unit = unit or lab["unit"]
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE food_attributes
|
||||
SET name_de = %s,
|
||||
unit = COALESCE(%s, unit),
|
||||
updated_at = NOW()
|
||||
WHERE attr_key = %s
|
||||
AND (
|
||||
name_de IS NULL OR BTRIM(name_de) = '' OR name_de = attr_key
|
||||
)
|
||||
""",
|
||||
(name, unit, key),
|
||||
)
|
||||
if cur.rowcount:
|
||||
updated += cur.rowcount
|
||||
return updated
|
||||
153
backend/bls/import_service.py
Normal file
153
backend/bls/import_service.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"""Apply parsed BLS 4.0 data: upsert attributes and foods, never delete official rows."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from psycopg2.extras import execute_values
|
||||
|
||||
VALUE_PAGE = 2000
|
||||
FOOD_PAGE = 500
|
||||
|
||||
|
||||
def should_persist_value(val: dict[str, Any]) -> bool:
|
||||
if val.get("is_trace"):
|
||||
return True
|
||||
return val.get("value_num") is not None
|
||||
|
||||
|
||||
def upsert_attributes(cur, attributes: list[dict[str, Any]]) -> dict[str, int]:
|
||||
from bls.attribute_labels import enrich_attributes_in_memory
|
||||
|
||||
attributes = enrich_attributes_in_memory(list(attributes))
|
||||
inserted = updated = 0
|
||||
for a in attributes:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO food_attributes
|
||||
(attr_key, name_de, name_en, unit, category, data_type, origin, sort_order, updated_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, 'official_bls', %s, NOW())
|
||||
ON CONFLICT (attr_key) DO UPDATE SET
|
||||
name_de = EXCLUDED.name_de,
|
||||
name_en = COALESCE(EXCLUDED.name_en, food_attributes.name_en),
|
||||
unit = COALESCE(EXCLUDED.unit, food_attributes.unit),
|
||||
category = COALESCE(EXCLUDED.category, food_attributes.category),
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
updated_at = NOW()
|
||||
WHERE food_attributes.origin = 'official_bls'
|
||||
RETURNING (xmax = 0) AS inserted
|
||||
""",
|
||||
(
|
||||
a["attr_key"], a["name_de"], a.get("name_en"), a.get("unit"),
|
||||
a.get("category"), a.get("data_type") or "num_per_100g",
|
||||
a.get("sort_order") or 0,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and row.get("inserted"):
|
||||
inserted += 1
|
||||
else:
|
||||
updated += 1
|
||||
return {"inserted": inserted, "updated": updated, "total": len(attributes)}
|
||||
|
||||
|
||||
def upsert_foods(
|
||||
cur,
|
||||
foods: list[dict[str, Any]],
|
||||
bls_version: str = "4.0",
|
||||
attribute_headers: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, int]:
|
||||
if attribute_headers:
|
||||
from bls.attribute_labels import enrich_labels_from_food_headers
|
||||
enrich_labels_from_food_headers(cur, attribute_headers)
|
||||
cur.execute("SELECT attr_key, id FROM food_attributes")
|
||||
attr_ids = {r["attr_key"]: r["id"] for r in cur.fetchall()}
|
||||
codes = [f["bls_code"] for f in foods if f.get("bls_code")]
|
||||
existing: set[str] = set()
|
||||
if codes:
|
||||
cur.execute("SELECT bls_code FROM food_catalog WHERE bls_code = ANY(%s)", (codes,))
|
||||
existing = {r["bls_code"] for r in cur.fetchall()}
|
||||
inserted = sum(1 for c in codes if c not in existing)
|
||||
updated = len(codes) - inserted
|
||||
food_rows = [
|
||||
(
|
||||
f["bls_code"], f["name_de"], f.get("name_en"), f.get("food_group"),
|
||||
"official_bls", bls_version, "bls_4.0",
|
||||
)
|
||||
for f in foods if f.get("bls_code")
|
||||
]
|
||||
if food_rows:
|
||||
execute_values(
|
||||
cur,
|
||||
"""
|
||||
INSERT INTO food_catalog
|
||||
(bls_code, name_de, name_en, food_group, catalog_kind, bls_version, source)
|
||||
VALUES %s
|
||||
ON CONFLICT (bls_code) DO UPDATE SET
|
||||
name_de = EXCLUDED.name_de,
|
||||
name_en = EXCLUDED.name_en,
|
||||
food_group = EXCLUDED.food_group,
|
||||
bls_version = EXCLUDED.bls_version,
|
||||
catalog_kind = 'official_bls',
|
||||
source = 'bls_4.0',
|
||||
is_active = true,
|
||||
updated_at = NOW()
|
||||
WHERE food_catalog.catalog_kind = 'official_bls'
|
||||
""",
|
||||
food_rows,
|
||||
page_size=FOOD_PAGE,
|
||||
)
|
||||
|
||||
food_ids: dict[str, Any] = {}
|
||||
if codes:
|
||||
cur.execute("SELECT bls_code, id FROM food_catalog WHERE bls_code = ANY(%s)", (codes,))
|
||||
food_ids = {r["bls_code"]: r["id"] for r in cur.fetchall()}
|
||||
|
||||
value_rows = []
|
||||
for f in foods:
|
||||
food_id = food_ids.get(f.get("bls_code"))
|
||||
if not food_id:
|
||||
continue
|
||||
for val in f.get("values") or []:
|
||||
if not should_persist_value(val):
|
||||
continue
|
||||
aid = attr_ids.get(val["attr_key"])
|
||||
if not aid:
|
||||
continue
|
||||
value_rows.append((
|
||||
food_id,
|
||||
aid,
|
||||
None if val.get("is_trace") else val.get("value_num"),
|
||||
bool(val.get("is_trace")),
|
||||
val.get("origin_code"),
|
||||
val.get("reference_text"),
|
||||
))
|
||||
|
||||
if value_rows:
|
||||
execute_values(
|
||||
cur,
|
||||
"""
|
||||
INSERT INTO food_attribute_values
|
||||
(food_id, attribute_id, value_num, is_trace, origin_code, reference_text, updated_at)
|
||||
VALUES %s
|
||||
ON CONFLICT (food_id, attribute_id) DO UPDATE SET
|
||||
value_num = EXCLUDED.value_num,
|
||||
is_trace = EXCLUDED.is_trace,
|
||||
origin_code = EXCLUDED.origin_code,
|
||||
reference_text = EXCLUDED.reference_text,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
value_rows,
|
||||
template="(%s, %s, %s, %s, %s, %s, NOW())",
|
||||
page_size=VALUE_PAGE,
|
||||
)
|
||||
|
||||
from data_layer.food_suggest import invalidate_suggest_index
|
||||
invalidate_suggest_index()
|
||||
return {
|
||||
"inserted": inserted,
|
||||
"updated": updated,
|
||||
"foods_inserted": inserted,
|
||||
"foods_updated": updated,
|
||||
"values_written": len(value_rows),
|
||||
"foods_total": len(foods),
|
||||
}
|
||||
176
backend/bls/jobs.py
Normal file
176
backend/bls/jobs.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
"""In-memory BLS import jobs so HTTP requests stay short (avoid proxy 504)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from bls.import_service import upsert_attributes, upsert_foods
|
||||
from bls.parser import parse_components_xlsx, parse_foods_xlsx
|
||||
from db import get_cursor, get_db
|
||||
|
||||
FOOD_CHUNK = 300
|
||||
JOB_TTL_S = 2 * 60 * 60
|
||||
MAX_JOBS = 12
|
||||
|
||||
_lock = threading.Lock()
|
||||
_jobs: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _public(job: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": job["id"],
|
||||
"kind": job["kind"],
|
||||
"status": job["status"],
|
||||
"check": job.get("check"),
|
||||
"progress": job.get("progress"),
|
||||
"result": job.get("result"),
|
||||
"error": job.get("error"),
|
||||
}
|
||||
|
||||
|
||||
def _purge_locked(now: float) -> None:
|
||||
stale = [jid for jid, job in _jobs.items() if now - job["created_at"] > JOB_TTL_S]
|
||||
for jid in stale:
|
||||
_jobs.pop(jid, None)
|
||||
if len(_jobs) <= MAX_JOBS:
|
||||
return
|
||||
oldest = sorted(_jobs.values(), key=lambda j: j["created_at"])
|
||||
for job in oldest[: max(0, len(_jobs) - MAX_JOBS)]:
|
||||
if job["status"] in ("checking", "applying"):
|
||||
continue
|
||||
_jobs.pop(job["id"], None)
|
||||
|
||||
|
||||
def get_job(job_id: str) -> dict[str, Any] | None:
|
||||
with _lock:
|
||||
job = _jobs.get(job_id)
|
||||
return _public(job) if job else None
|
||||
|
||||
|
||||
def create_and_check(kind: str, raw: bytes) -> str:
|
||||
if kind not in ("components", "foods"):
|
||||
raise ValueError("kind muss components oder foods sein")
|
||||
job_id = str(uuid.uuid4())
|
||||
now = time.time()
|
||||
with _lock:
|
||||
_purge_locked(now)
|
||||
_jobs[job_id] = {
|
||||
"id": job_id,
|
||||
"kind": kind,
|
||||
"status": "checking",
|
||||
"created_at": now,
|
||||
"raw": raw,
|
||||
"parsed": None,
|
||||
"check": None,
|
||||
"progress": None,
|
||||
"result": None,
|
||||
"error": None,
|
||||
}
|
||||
threading.Thread(target=_run_check, args=(job_id,), daemon=True).start()
|
||||
return job_id
|
||||
|
||||
|
||||
def start_apply(job_id: str) -> None:
|
||||
with _lock:
|
||||
job = _jobs.get(job_id)
|
||||
if job is None:
|
||||
raise KeyError(job_id)
|
||||
if job["status"] != "checked":
|
||||
raise ValueError("Zuerst die Prüfung abwarten")
|
||||
if job.get("parsed") is None:
|
||||
raise ValueError("Geparste Datei nicht mehr vorhanden — Datei neu wählen")
|
||||
job["status"] = "applying"
|
||||
job["error"] = None
|
||||
job["progress"] = {"current": 0, "total": 0}
|
||||
threading.Thread(target=_run_apply, args=(job_id,), daemon=True).start()
|
||||
|
||||
|
||||
def _update(job_id: str, **fields: Any) -> None:
|
||||
with _lock:
|
||||
job = _jobs.get(job_id)
|
||||
if not job:
|
||||
return
|
||||
job.update(fields)
|
||||
|
||||
|
||||
def _run_check(job_id: str) -> None:
|
||||
with _lock:
|
||||
job = _jobs.get(job_id)
|
||||
if not job:
|
||||
return
|
||||
kind = job["kind"]
|
||||
raw = job["raw"]
|
||||
try:
|
||||
if kind == "components":
|
||||
attrs = parse_components_xlsx(raw)
|
||||
_update(
|
||||
job_id,
|
||||
parsed=attrs,
|
||||
raw=None,
|
||||
check={"attributes": len(attrs)},
|
||||
status="checked",
|
||||
)
|
||||
return
|
||||
parsed = parse_foods_xlsx(raw)
|
||||
_update(
|
||||
job_id,
|
||||
parsed=parsed,
|
||||
raw=None,
|
||||
check={
|
||||
"foods": len(parsed["foods"]),
|
||||
"attribute_columns": len(parsed["attribute_headers"]),
|
||||
},
|
||||
status="checked",
|
||||
)
|
||||
except Exception as e:
|
||||
_update(job_id, status="error", error=str(e), raw=None, parsed=None)
|
||||
|
||||
|
||||
def _run_apply(job_id: str) -> None:
|
||||
with _lock:
|
||||
job = _jobs.get(job_id)
|
||||
if not job:
|
||||
return
|
||||
kind = job["kind"]
|
||||
parsed = job["parsed"]
|
||||
try:
|
||||
if kind == "components":
|
||||
with get_db() as conn:
|
||||
stats = upsert_attributes(get_cursor(conn), parsed)
|
||||
_update(job_id, status="done", result=stats, parsed=None, progress={"current": stats.get("total", 0), "total": stats.get("total", 0)})
|
||||
return
|
||||
|
||||
foods = parsed["foods"]
|
||||
total = len(foods)
|
||||
inserted = updated = values_written = 0
|
||||
_update(job_id, progress={"current": 0, "total": total})
|
||||
headers = parsed.get("attribute_headers")
|
||||
for i in range(0, total, FOOD_CHUNK):
|
||||
chunk = foods[i : i + FOOD_CHUNK]
|
||||
with get_db() as conn:
|
||||
stats = upsert_foods(
|
||||
get_cursor(conn),
|
||||
chunk,
|
||||
attribute_headers=headers if i == 0 else None,
|
||||
)
|
||||
inserted += stats.get("inserted", 0)
|
||||
updated += stats.get("updated", 0)
|
||||
values_written += stats.get("values_written", 0)
|
||||
_update(job_id, progress={"current": min(i + FOOD_CHUNK, total), "total": total})
|
||||
_update(
|
||||
job_id,
|
||||
status="done",
|
||||
parsed=None,
|
||||
result={
|
||||
"inserted": inserted,
|
||||
"updated": updated,
|
||||
"foods_inserted": inserted,
|
||||
"foods_updated": updated,
|
||||
"values_written": values_written,
|
||||
"foods_total": total,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
_update(job_id, status="error", error=str(e))
|
||||
168
backend/bls/parser.py
Normal file
168
backend/bls/parser.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"""Parse official BLS 4.0 XLSX files without a hardcoded nutrient code list."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
# Header like: "ENERCJ Energie (Kilojoule) [kJ/100g]"
|
||||
ATTR_HEADER_RE = re.compile(
|
||||
r"^([A-Z][A-Z0-9:]{1,20})\s+(.+?)(?:\s*\[([^\]]+)\])?\s*$"
|
||||
)
|
||||
|
||||
|
||||
def _cell(v: Any) -> str:
|
||||
if v is None:
|
||||
return ""
|
||||
return str(v).strip()
|
||||
|
||||
|
||||
def parse_components_xlsx(data: bytes) -> list[dict[str, Any]]:
|
||||
wb = load_workbook(filename=BytesIO(data), read_only=True, data_only=True)
|
||||
ws = wb.active
|
||||
rows = ws.iter_rows(values_only=True)
|
||||
header = [_cell(c) for c in next(rows)]
|
||||
idx = {h.lower(): i for i, h in enumerate(header) if h}
|
||||
|
||||
def col(*names: str, exclude: tuple[str, ...] = ()) -> int | None:
|
||||
for n in names:
|
||||
if n.lower() in idx:
|
||||
return idx[n.lower()]
|
||||
for key, i in idx.items():
|
||||
if any(ex in key for ex in exclude):
|
||||
continue
|
||||
for n in names:
|
||||
if n.lower() in key:
|
||||
return i
|
||||
return None
|
||||
|
||||
i_code = col(
|
||||
"component code", "komponentencode", "attr_key", "schlüssel", "schluessel",
|
||||
"code", "komponente",
|
||||
)
|
||||
i_de = col(
|
||||
"name_de", "bezeichnung_de", "bezeichnung deutsch", "name de",
|
||||
"deutsch", "german", "bezeichnung",
|
||||
exclude=("name_en", "bezeichnung_en", "english", "englisch", "name en"),
|
||||
)
|
||||
i_en = col("name_en", "bezeichnung_en", "name en", "english", "englisch")
|
||||
i_unit = col("unit", "einheit")
|
||||
i_cat = col("category", "kategorie", "gruppe")
|
||||
if i_code is None:
|
||||
i_code = 0
|
||||
|
||||
from bls.attribute_labels import apply_official_name
|
||||
|
||||
out = []
|
||||
sort_order = 0
|
||||
for raw in rows:
|
||||
if not raw:
|
||||
continue
|
||||
code = _cell(raw[i_code] if i_code < len(raw) else "")
|
||||
if not code or code.lower() in ("code", "schlüssel", "schluessel", "component code"):
|
||||
continue
|
||||
raw_de = _cell(raw[i_de] if i_de is not None and i_de < len(raw) else "")
|
||||
name_de = apply_official_name(code, raw_de)
|
||||
name_en = _cell(raw[i_en] if i_en is not None and i_en < len(raw) else "") or None
|
||||
unit = _cell(raw[i_unit] if i_unit is not None and i_unit < len(raw) else "") or None
|
||||
category = _cell(raw[i_cat] if i_cat is not None and i_cat < len(raw) else "") or None
|
||||
sort_order += 1
|
||||
out.append({
|
||||
"attr_key": code,
|
||||
"name_de": name_de,
|
||||
"name_en": name_en,
|
||||
"unit": unit,
|
||||
"category": category,
|
||||
"data_type": "num_per_100g",
|
||||
"origin": "official_bls",
|
||||
"sort_order": sort_order,
|
||||
})
|
||||
wb.close()
|
||||
return out
|
||||
|
||||
|
||||
def _parse_value_header(title: str) -> tuple[str | None, str, str | None]:
|
||||
t = title.strip()
|
||||
m = ATTR_HEADER_RE.match(t)
|
||||
if m:
|
||||
return m.group(1), m.group(2).strip(), m.group(3)
|
||||
# Fallback: first token
|
||||
parts = t.split()
|
||||
if parts and re.match(r"^[A-Z][A-Z0-9:]{1,20}$", parts[0]):
|
||||
return parts[0], " ".join(parts[1:]) or parts[0], None
|
||||
return None, t, None
|
||||
|
||||
|
||||
def parse_foods_xlsx(data: bytes) -> dict[str, Any]:
|
||||
wb = load_workbook(filename=BytesIO(data), read_only=True, data_only=True)
|
||||
ws = wb.active
|
||||
rows = ws.iter_rows(values_only=True)
|
||||
header = [_cell(c) for c in next(rows)]
|
||||
if len(header) < 3:
|
||||
wb.close()
|
||||
raise ValueError("BLS-Datendatei: erwartet mindestens BLS-Code, Name DE, Name EN")
|
||||
|
||||
triples: list[dict[str, Any]] = []
|
||||
i = 3
|
||||
while i < len(header):
|
||||
code, name_de, unit = _parse_value_header(header[i])
|
||||
origin_i = i + 1 if i + 1 < len(header) else None
|
||||
ref_i = i + 2 if i + 2 < len(header) else None
|
||||
triples.append({
|
||||
"attr_key": code or f"COL{i}",
|
||||
"name_de": name_de,
|
||||
"unit": unit,
|
||||
"value_col": i,
|
||||
"origin_col": origin_i,
|
||||
"ref_col": ref_i,
|
||||
})
|
||||
i += 3 if (origin_i is not None and ref_i is not None) else 1
|
||||
|
||||
foods = []
|
||||
for raw in rows:
|
||||
if not raw:
|
||||
continue
|
||||
code = _cell(raw[0] if len(raw) else "")
|
||||
if not code:
|
||||
continue
|
||||
name_de = _cell(raw[1] if len(raw) > 1 else "") or code
|
||||
name_en = _cell(raw[2] if len(raw) > 2 else "") or None
|
||||
values = []
|
||||
for t in triples:
|
||||
vc = t["value_col"]
|
||||
raw_v = raw[vc] if vc < len(raw) else None
|
||||
is_trace = False
|
||||
num = None
|
||||
if raw_v is None or raw_v == "" or raw_v == "-":
|
||||
num = None
|
||||
elif str(raw_v).strip().upper() in ("TR", "TRACE", "SPUREN"):
|
||||
is_trace = True
|
||||
else:
|
||||
try:
|
||||
num = float(str(raw_v).replace(",", "."))
|
||||
except (TypeError, ValueError):
|
||||
num = None
|
||||
origin = ""
|
||||
if t["origin_col"] is not None and t["origin_col"] < len(raw):
|
||||
origin = _cell(raw[t["origin_col"]])
|
||||
ref = ""
|
||||
if t["ref_col"] is not None and t["ref_col"] < len(raw):
|
||||
ref = _cell(raw[t["ref_col"]])
|
||||
values.append({
|
||||
"attr_key": t["attr_key"],
|
||||
"value_num": num,
|
||||
"is_trace": is_trace,
|
||||
"origin_code": origin or None,
|
||||
"reference_text": ref or None,
|
||||
})
|
||||
foods.append({
|
||||
"bls_code": code,
|
||||
"name_de": name_de,
|
||||
"name_en": name_en,
|
||||
"food_group": code[0] if code else None,
|
||||
"values": values,
|
||||
})
|
||||
wb.close()
|
||||
return {"attribute_headers": triples, "foods": foods}
|
||||
79
backend/bls/recipe_parser.py
Normal file
79
backend/bls/recipe_parser.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""Parse FDDB lists_*.csv (name;…;produkte) into recipe + ingredients."""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from data_layer.food_mapping import normalize_food_name, parse_quantity_g
|
||||
|
||||
ING_RE = re.compile(
|
||||
r"(?P<qty>\d+(?:[.,]\d+)?)\s*(?P<unit>g|kg|ml|l)\b\s*(?P<name>.+?)"
|
||||
r"(?=,\s*\d+(?:[.,]\d+)?\s*(?:g|kg|ml|l)\b|$)",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def parse_fddb_produkte(text: str) -> list[dict[str, Any]]:
|
||||
raw = (text or "").strip().strip('"')
|
||||
if not raw:
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
for i, m in enumerate(ING_RE.finditer(raw)):
|
||||
qty = float(m.group("qty").replace(",", "."))
|
||||
unit = m.group("unit").lower()
|
||||
name = re.sub(r"\s+", " ", m.group("name")).strip(" ,;")
|
||||
if not name:
|
||||
continue
|
||||
grams = qty
|
||||
if unit == "kg":
|
||||
grams = qty * 1000.0
|
||||
elif unit == "l":
|
||||
grams = qty * 1000.0
|
||||
elif unit == "ml":
|
||||
grams = qty
|
||||
out.append({
|
||||
"source_name_raw": name,
|
||||
"source_name_normalized": normalize_food_name(name),
|
||||
"quantity_raw": f"{m.group('qty').replace(',', '.')} {unit}",
|
||||
"quantity_g": round(grams, 3),
|
||||
"sort_order": i,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def parse_fddb_lists_csv(text: str) -> list[dict[str, Any]]:
|
||||
if text.startswith("\ufeff"):
|
||||
text = text[1:]
|
||||
reader = csv.DictReader(io.StringIO(text), delimiter=";")
|
||||
recipes = []
|
||||
for row in reader:
|
||||
name = (row.get("name") or "").strip().strip('"')
|
||||
if not name:
|
||||
continue
|
||||
try:
|
||||
portions = float(str(row.get("anzahl_portionen") or "1").replace(",", "."))
|
||||
except ValueError:
|
||||
portions = 1.0
|
||||
if portions <= 0:
|
||||
portions = 1.0
|
||||
ingredients = parse_fddb_produkte(row.get("produkte") or "")
|
||||
if not ingredients:
|
||||
leftover = (row.get("produkte") or "").strip().strip('"')
|
||||
if leftover:
|
||||
ingredients = [{
|
||||
"source_name_raw": leftover,
|
||||
"source_name_normalized": normalize_food_name(leftover),
|
||||
"quantity_raw": None,
|
||||
"quantity_g": parse_quantity_g(leftover),
|
||||
"sort_order": 0,
|
||||
}]
|
||||
recipes.append({
|
||||
"name_raw": name,
|
||||
"name_normalized": normalize_food_name(name),
|
||||
"portions": portions,
|
||||
"description": (row.get("beschreibung") or "").strip() or None,
|
||||
"ingredients": ingredients,
|
||||
})
|
||||
return recipes
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,31 @@ from typing import Any
|
|||
|
||||
import logging
|
||||
|
||||
from csv_parser.core import iter_csv_dict_rows
|
||||
from csv_parser.core import iter_csv_dict_rows, normalize_header_for_signature, resolve_effective_csv_delimiter
|
||||
|
||||
_FOOD_NAME_HEADERS = frozenset({
|
||||
"bezeichnung", "lebensmittel", "food", "food_name", "name", "gericht",
|
||||
})
|
||||
_QTY_HEADERS = frozenset({
|
||||
"menge", "quantity", "quantity_raw", "portion", "amount", "gramm",
|
||||
})
|
||||
|
||||
|
||||
def guess_nutrition_item_fields(csv_row: dict[str, Any] | None) -> tuple[str | None, str | None]:
|
||||
"""FDDB-Spalten auch ohne Vorlagen-Mapping auf food_name / menge erkennen."""
|
||||
name = qty = None
|
||||
for key, val in (csv_row or {}).items():
|
||||
if val is None:
|
||||
continue
|
||||
text = str(val).strip().strip('"')
|
||||
if not text:
|
||||
continue
|
||||
norm = normalize_header_for_signature(str(key))
|
||||
if name is None and (norm in _FOOD_NAME_HEADERS or "bezeichnung" in norm):
|
||||
name = text
|
||||
if qty is None and (norm in _QTY_HEADERS or norm.startswith("menge")):
|
||||
qty = text
|
||||
return name, qty
|
||||
from csv_parser.import_row_processing import (
|
||||
aggregate_mapped_rows,
|
||||
resolve_import_row_processing,
|
||||
|
|
@ -23,14 +47,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 +121,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":
|
||||
|
|
@ -188,6 +205,8 @@ def run_universal_csv_import(
|
|||
"error_details": error_details[:50],
|
||||
"new_entries": stats.get("new_entries", stats.get("inserted", 0)),
|
||||
"affected_ids": dict(affected_ids),
|
||||
"items_written": stats.get("items_written", 0),
|
||||
"unmapped_names": stats.get("unmapped_names", 0),
|
||||
}
|
||||
return out
|
||||
|
||||
|
|
@ -210,6 +229,11 @@ def _import_nutrition(
|
|||
for csv_row in iter_csv_dict_rows(text, delim, has_header=has_header):
|
||||
rows_total += 1
|
||||
mapped = build_row_after_mapping(csv_row, fm, tc, module="nutrition")
|
||||
guessed_name, guessed_qty = guess_nutrition_item_fields(csv_row)
|
||||
if not mapped.get("food_name") and guessed_name:
|
||||
mapped["food_name"] = guessed_name
|
||||
if not mapped.get("quantity_raw") and guessed_qty:
|
||||
mapped["quantity_raw"] = guessed_qty
|
||||
d = coerce_date(mapped.get("date"))
|
||||
if d is None:
|
||||
error_details.append({"row": rows_total, "error": "Datum fehlt oder ungültig"})
|
||||
|
|
@ -230,6 +254,50 @@ def _import_nutrition(
|
|||
|
||||
skipped_groups = sum(n.get("rows_in_group", 0) for n in (agg_notes or []) if n.get("error") == "mehrere_zeilen_pro_schluessel")
|
||||
|
||||
item_rows = []
|
||||
for mapped in mapped_rows:
|
||||
name = mapped.get("food_name")
|
||||
if not name:
|
||||
continue
|
||||
d_item = coerce_date(mapped.get("date"))
|
||||
if d_item is None:
|
||||
continue
|
||||
item_rows.append({
|
||||
"date": d_item.isoformat(),
|
||||
"logged_at": mapped.get("logged_at") or mapped.get("date"),
|
||||
"food_name": str(name).strip(),
|
||||
"quantity_raw": mapped.get("quantity_raw"),
|
||||
"kcal": mapped.get("kcal"),
|
||||
"protein_g": mapped.get("protein_g"),
|
||||
"fat_g": mapped.get("fat_g"),
|
||||
"carbs_g": mapped.get("carbs_g"),
|
||||
})
|
||||
if item_rows:
|
||||
from data_layer.nutrition_items import get_import_policy, replace_csv_items_for_dates
|
||||
|
||||
policy = get_import_policy(cur, profile_id)
|
||||
ingest = replace_csv_items_for_dates(cur, profile_id, item_rows, policy=policy)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT source_name_normalized) AS n
|
||||
FROM nutrition_items
|
||||
WHERE profile_id = %s AND food_id IS NULL
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
unmapped_row = cur.fetchone() or {}
|
||||
return {
|
||||
"rows_total": rows_total,
|
||||
"inserted": ingest.get("new_log_days", 0),
|
||||
"updated": max(0, ingest.get("days_written", 0) - ingest.get("new_log_days", 0)),
|
||||
"skipped": skipped_groups,
|
||||
"new_entries": ingest.get("new_log_days", 0),
|
||||
"items_written": ingest.get("items_written", 0),
|
||||
"unmapped_names": int(unmapped_row.get("n") or 0),
|
||||
"conflicts": ingest.get("conflicts") or [],
|
||||
"policy": ingest.get("policy"),
|
||||
}
|
||||
|
||||
inserted = 0
|
||||
updated = 0
|
||||
new_entries = 0
|
||||
|
|
@ -814,6 +882,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 +964,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 +972,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()
|
||||
updated += 1
|
||||
if row and row.get("id"):
|
||||
affected_ids["activity_log"].append(str(row["id"]))
|
||||
aid = eid
|
||||
else:
|
||||
eid = str(uuid.uuid4())
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO activity_log (
|
||||
id, profile_id, date, start_time, end_time, activity_type, duration_min,
|
||||
kcal_active, kcal_resting, hr_avg, hr_max, distance_km,
|
||||
source, training_type_id, training_category, training_subcategory, created
|
||||
)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'csv',%s,%s,%s,CURRENT_TIMESTAMP)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
eid,
|
||||
profile_id,
|
||||
iso,
|
||||
start_key,
|
||||
end_str or None,
|
||||
wtype,
|
||||
duration_min,
|
||||
kcal_a,
|
||||
kcal_r,
|
||||
hr_a,
|
||||
hr_m,
|
||||
dist,
|
||||
training_type_id,
|
||||
training_category,
|
||||
training_subcategory,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
inserted += 1
|
||||
new_entries += 1
|
||||
if row and row.get("id"):
|
||||
affected_ids["activity_log"].append(str(row["id"]))
|
||||
aid = eid
|
||||
|
||||
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,
|
||||
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,
|
||||
"kcal_active": kcal_a,
|
||||
"kcal_resting": kcal_r,
|
||||
"rpe": None,
|
||||
"pace_min_per_km": None,
|
||||
"cadence": None,
|
||||
"elevation_gain": None,
|
||||
"training_type_id": training_type_id,
|
||||
"training_category": training_category,
|
||||
"training_subcategory": training_subcategory,
|
||||
"source": "csv",
|
||||
}
|
||||
_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)
|
||||
upd.update(registry_updates)
|
||||
update_activity_columns(cur, profile_id, existing_id, upd)
|
||||
updated += 1
|
||||
affected_ids["activity_log"].append(str(existing_id))
|
||||
aid = existing_id
|
||||
else:
|
||||
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",
|
||||
)
|
||||
inserted += 1
|
||||
new_entries += 1
|
||||
affected_ids["activity_log"].append(str(eid))
|
||||
aid = eid
|
||||
if registry_updates:
|
||||
update_activity_columns(cur, profile_id, aid, registry_updates)
|
||||
|
||||
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 []
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ _MODULE_HEADER_ALIASES: dict[str, dict[str, frozenset[str]]] = {
|
|||
"protein_g": frozenset({"protein", "eiwei", "eiweiss"}),
|
||||
"fat_g": frozenset({"fett", "fat", "lipid"}),
|
||||
"carbs_g": frozenset({"kh", "carb", "kohlenhydr", "carbs", "sugar", "zucker"}),
|
||||
"food_name": frozenset({"bezeichnung", "lebensmittel", "food", "gericht"}),
|
||||
"quantity_raw": frozenset({"menge", "quantity", "portion", "gramm"}),
|
||||
},
|
||||
"weight": {
|
||||
"date": frozenset({"datum", "date", "tag", "day", "zeit"}),
|
||||
|
|
@ -127,13 +129,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 +160,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 +174,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 +191,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 +203,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 +213,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 +224,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
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ MODULE_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
|||
"protein_g": {"type": "float", "required": False, "min": 0, "unit": "g"},
|
||||
"fat_g": {"type": "float", "required": False, "min": 0, "unit": "g"},
|
||||
"carbs_g": {"type": "float", "required": False, "min": 0, "unit": "g"},
|
||||
"food_name": {"type": "string", "required": False, "label_de": "Lebensmittel"},
|
||||
"quantity_raw": {"type": "string", "required": False, "label_de": "Menge"},
|
||||
},
|
||||
"duplicate_key": ["profile_id", "date"],
|
||||
"duplicate_strategy": "update",
|
||||
|
|
@ -34,19 +36,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 +147,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)
|
||||
|
|
@ -477,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
|
||||
|
|
@ -488,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:
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -70,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',
|
||||
|
|
|
|||
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",
|
||||
}
|
||||
|
||||
|
|
@ -10,6 +10,7 @@ Functions:
|
|||
- 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.
|
||||
|
|
@ -22,7 +23,12 @@ 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.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(
|
||||
|
|
@ -123,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)
|
||||
},
|
||||
...
|
||||
],
|
||||
|
|
@ -142,6 +149,7 @@ def get_activity_detail_data(
|
|||
|
||||
cur.execute(
|
||||
"""SELECT
|
||||
id,
|
||||
date,
|
||||
activity_type,
|
||||
duration_min,
|
||||
|
|
@ -152,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()
|
||||
|
||||
|
|
@ -161,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")
|
||||
|
||||
|
|
@ -181,7 +194,7 @@ def get_activity_detail_data(
|
|||
"activities": activities,
|
||||
"total_count": len(activities),
|
||||
"confidence": confidence,
|
||||
"days_analyzed": days
|
||||
"days_analyzed": days,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -317,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)
|
||||
|
||||
|
||||
|
|
@ -482,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,
|
||||
|
|
@ -499,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()
|
||||
|
||||
|
|
@ -541,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]:
|
||||
|
|
@ -1085,6 +1113,10 @@ def get_training_sessions_recent_weeks_data(
|
|||
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:
|
||||
|
|
@ -1112,6 +1144,7 @@ def get_training_sessions_recent_weeks_data(
|
|||
(profile_id, cutoff),
|
||||
)
|
||||
rows = [r2d(r) for r in cur.fetchall()]
|
||||
enrich_sessions_with_metrics(cur, rows)
|
||||
|
||||
if not rows:
|
||||
return {
|
||||
|
|
@ -1121,6 +1154,8 @@ def get_training_sessions_recent_weeks_data(
|
|||
"days_loaded": days,
|
||||
"session_count": 0,
|
||||
"confidence": "insufficient",
|
||||
"session_metrics_shape": "key_value",
|
||||
"metric_semantics_placeholder": "{{training_parameters_glossary_md}}",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -1139,18 +1174,21 @@ def get_training_sessions_recent_weeks_data(
|
|||
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": dur_f,
|
||||
"kcal_active": kcal_f,
|
||||
"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,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -1165,6 +1203,335 @@ def get_training_sessions_recent_weeks_data(
|
|||
"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,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
|
||||
|
|
@ -370,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 = []
|
||||
|
|
@ -401,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 {
|
||||
|
|
@ -640,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)
|
||||
|
|
|
|||
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,116 +17,401 @@ 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|.
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# 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,))
|
||||
|
||||
data = cur.fetchall()
|
||||
|
||||
if len(data) < 30:
|
||||
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': 'low',
|
||||
'data_points': len(data),
|
||||
'reason': 'Insufficient data (<30 days)'
|
||||
"best_lag": None,
|
||||
"correlation": None,
|
||||
"direction": "none",
|
||||
"confidence": "insufficient",
|
||||
"data_points": 0,
|
||||
"interpretation": "Keine TDEE-Schätzung möglich (Gewicht/Demografie).",
|
||||
"reason": "no_tdee",
|
||||
}
|
||||
|
||||
# Calculate 7d rolling energy balance
|
||||
# (Simplified - actual implementation would need TDEE estimation)
|
||||
tdee_f = float(tdee)
|
||||
cutoff = (datetime.now() - timedelta(days=LAG_CORR_LOOKBACK_DAYS)).strftime("%Y-%m-%d")
|
||||
|
||||
# For now, return placeholder
|
||||
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()
|
||||
|
||||
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"])
|
||||
|
||||
balance_by = {d: kcal_by[d] - tdee_f for d in kcal_by}
|
||||
|
||||
best: Optional[Tuple[int, float, int]] = None
|
||||
lag_details: List[Dict[str, Any]] = []
|
||||
|
||||
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': 7,
|
||||
'correlation': -0.45, # Placeholder
|
||||
'direction': 'negative', # Higher deficit = lower weight (expected)
|
||||
'confidence': 'medium',
|
||||
'data_points': len(data)
|
||||
"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."
|
||||
)
|
||||
|
||||
return {
|
||||
"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': 0,
|
||||
'correlation': 0.32, # Placeholder
|
||||
'direction': 'positive',
|
||||
'confidence': 'medium',
|
||||
'data_points': 28
|
||||
"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": 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
|
||||
"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,
|
||||
}
|
||||
else: # rhr
|
||||
|
||||
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': 1,
|
||||
'correlation': 0.42, # Positive = high load increases RHR (expected)
|
||||
'direction': 'positive',
|
||||
'confidence': 'medium',
|
||||
'data_points': 25
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
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",
|
||||
},
|
||||
}
|
||||
238
backend/data_layer/food_attributes.py
Normal file
238
backend/data_layer/food_attributes.py
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
"""Catalog attribute definitions and numeric values (BLS EAV + extensions)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_ATTR_KEY_RE = re.compile(r"[^A-Z0-9]+")
|
||||
_ALLOWED_UNITS = {"g": "g", "mg": "mg", "µg": "µg", "ug": "µg", "mcg": "µg", "kcal": "kcal", "kj": "kJ"}
|
||||
|
||||
MACRO_TO_KEY = {
|
||||
"kcal": "ENERCC",
|
||||
"protein_g": "PROT625",
|
||||
"fat_g": "FAT",
|
||||
"carbs_g": "CHO",
|
||||
}
|
||||
|
||||
|
||||
def scale_to_per_100g(value: float, serving_g: float | None) -> float:
|
||||
if not serving_g or serving_g <= 0 or serving_g == 100:
|
||||
return float(value)
|
||||
return float(value) * (100.0 / float(serving_g))
|
||||
|
||||
|
||||
def list_numeric_attributes(
|
||||
cur, query: str = "", limit: int = 40, keys: list[str] | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
lim = min(max(int(limit or 40), 1), 80)
|
||||
wanted = [str(k).strip() for k in (keys or []) if str(k).strip()]
|
||||
if wanted:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, attr_key, name_de, name_en, unit, category, origin
|
||||
FROM food_attributes
|
||||
WHERE is_active = true AND data_type = 'num_per_100g'
|
||||
AND attr_key = ANY(%s)
|
||||
ORDER BY sort_order, attr_key
|
||||
""",
|
||||
(wanted,),
|
||||
)
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
q = (query or "").strip()
|
||||
if q:
|
||||
like = f"%{q}%"
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, attr_key, name_de, name_en, unit, category, origin
|
||||
FROM food_attributes
|
||||
WHERE is_active = true AND data_type = 'num_per_100g'
|
||||
AND (
|
||||
attr_key ILIKE %s OR name_de ILIKE %s
|
||||
OR COALESCE(name_en, '') ILIKE %s
|
||||
)
|
||||
ORDER BY
|
||||
CASE WHEN attr_key ILIKE %s THEN 0
|
||||
WHEN name_de ILIKE %s THEN 1
|
||||
ELSE 2 END,
|
||||
sort_order, attr_key
|
||||
LIMIT %s
|
||||
""",
|
||||
(like, like, like, q, f"{q}%", lim),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, attr_key, name_de, name_en, unit, category, origin
|
||||
FROM food_attributes
|
||||
WHERE is_active = true AND data_type = 'num_per_100g'
|
||||
ORDER BY sort_order, attr_key
|
||||
LIMIT %s
|
||||
""",
|
||||
(lim,),
|
||||
)
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
def write_numeric_attributes(cur, food_id: str, values: dict[str, Any] | None) -> int:
|
||||
if not values:
|
||||
return 0
|
||||
written = 0
|
||||
for raw_key, raw_val in values.items():
|
||||
key = str(raw_key or "").strip()
|
||||
if not key or raw_val is None or raw_val == "":
|
||||
continue
|
||||
try:
|
||||
num = float(raw_val)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM food_attributes
|
||||
WHERE attr_key = %s AND is_active = true AND data_type = 'num_per_100g'
|
||||
""",
|
||||
(key,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
continue
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO food_attribute_values (food_id, attribute_id, value_num, is_trace)
|
||||
VALUES (%s, %s, %s, false)
|
||||
ON CONFLICT (food_id, attribute_id)
|
||||
DO UPDATE SET value_num = EXCLUDED.value_num, updated_at = NOW()
|
||||
""",
|
||||
(food_id, row["id"], num),
|
||||
)
|
||||
written += 1
|
||||
return written
|
||||
|
||||
|
||||
def replace_numeric_attributes(cur, food_id: str, values: dict[str, Any] | None) -> int:
|
||||
cur.execute("DELETE FROM food_attribute_values WHERE food_id = %s", (food_id,))
|
||||
return write_numeric_attributes(cur, food_id, values)
|
||||
|
||||
|
||||
KEY_TO_MACRO = {v: k for k, v in MACRO_TO_KEY.items()}
|
||||
|
||||
|
||||
def editor_payload_for_food(cur, food: dict[str, Any], *, editable: bool) -> dict[str, Any]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT a.attr_key, a.name_de, a.name_en, a.unit, a.category, a.origin, v.value_num
|
||||
FROM food_attribute_values v
|
||||
JOIN food_attributes a ON a.id = v.attribute_id
|
||||
WHERE v.food_id = %s AND a.data_type = 'num_per_100g'
|
||||
AND v.value_num IS NOT NULL AND v.is_trace = false
|
||||
ORDER BY a.sort_order, a.attr_key
|
||||
""",
|
||||
(food["id"],),
|
||||
)
|
||||
macros = {"kcal": 0, "protein_g": 0, "fat_g": 0, "carbs_g": 0}
|
||||
extras = []
|
||||
for row in cur.fetchall():
|
||||
rec = dict(row)
|
||||
field = KEY_TO_MACRO.get(rec["attr_key"])
|
||||
if field:
|
||||
macros[field] = float(rec["value_num"])
|
||||
else:
|
||||
extras.append({
|
||||
"attr": {
|
||||
"attr_key": rec["attr_key"],
|
||||
"name_de": rec["name_de"],
|
||||
"name_en": rec["name_en"],
|
||||
"unit": rec["unit"],
|
||||
"category": rec["category"],
|
||||
"origin": rec["origin"],
|
||||
},
|
||||
"value": float(rec["value_num"]),
|
||||
})
|
||||
return {
|
||||
"id": str(food["id"]),
|
||||
"name_de": food.get("name_de") or "",
|
||||
"name_en": food.get("name_en"),
|
||||
"catalog_kind": food.get("catalog_kind"),
|
||||
"editable": editable,
|
||||
"macros_per_100g": macros,
|
||||
"attributes": extras,
|
||||
"serving_g": 100,
|
||||
}
|
||||
|
||||
|
||||
def macros_and_attributes_to_values(
|
||||
macros: dict[str, Any] | None,
|
||||
attributes: dict[str, Any] | None,
|
||||
serving_g: float | None = None,
|
||||
) -> dict[str, float]:
|
||||
out: dict[str, float] = {}
|
||||
for field, key in MACRO_TO_KEY.items():
|
||||
if not macros or field not in macros or macros[field] is None or macros[field] == "":
|
||||
continue
|
||||
try:
|
||||
out[key] = scale_to_per_100g(float(macros[field]), serving_g)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
for key, raw in (attributes or {}).items():
|
||||
if raw is None or raw == "":
|
||||
continue
|
||||
try:
|
||||
out[str(key)] = scale_to_per_100g(float(raw), serving_g)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def extra_attributes_for_food(cur, food_id: str) -> dict[str, float]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT a.attr_key, v.value_num
|
||||
FROM food_attribute_values v
|
||||
JOIN food_attributes a ON a.id = v.attribute_id
|
||||
WHERE v.food_id = %s AND a.data_type = 'num_per_100g'
|
||||
AND v.value_num IS NOT NULL AND v.is_trace = false
|
||||
AND NOT (a.attr_key = ANY(%s))
|
||||
ORDER BY a.sort_order, a.attr_key
|
||||
""",
|
||||
(food_id, list(MACRO_TO_KEY.values())),
|
||||
)
|
||||
return {r["attr_key"]: float(r["value_num"]) for r in cur.fetchall()}
|
||||
|
||||
|
||||
def normalize_attr_key(raw: str | None) -> str:
|
||||
key = _ATTR_KEY_RE.sub("_", (raw or "").strip().upper()).strip("_")
|
||||
return key[:64]
|
||||
|
||||
|
||||
def normalize_attr_unit(raw: str | None) -> str:
|
||||
return _ALLOWED_UNITS.get((raw or "g").strip().lower(), "g")
|
||||
|
||||
|
||||
def create_extension_attribute(cur, name_de: str, unit: str = "g", attr_key: str | None = None) -> dict[str, Any]:
|
||||
name = (name_de or "").strip()
|
||||
if not name:
|
||||
raise ValueError("Name fehlt")
|
||||
key = normalize_attr_key(attr_key or name)
|
||||
if not key:
|
||||
raise ValueError("attr_key fehlt")
|
||||
unit_n = normalize_attr_unit(unit)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, attr_key, name_de, name_en, unit, category, origin
|
||||
FROM food_attributes
|
||||
WHERE attr_key = %s AND is_active = true
|
||||
""",
|
||||
(key,),
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
if existing:
|
||||
return dict(existing)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO food_attributes
|
||||
(attr_key, name_de, name_en, unit, category, data_type, origin, sort_order)
|
||||
VALUES (%s, %s, %s, %s, 'extension', 'num_per_100g', 'extension', 9100)
|
||||
RETURNING id, attr_key, name_de, name_en, unit, category, origin
|
||||
""",
|
||||
(key, name, None, unit_n),
|
||||
)
|
||||
return dict(cur.fetchone())
|
||||
265
backend/data_layer/food_knowledge.py
Normal file
265
backend/data_layer/food_knowledge.py
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
"""Portable JSON for user food mappings, manual foods, and FDDB lists."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from data_layer.food_mapping import (
|
||||
apply_mapping_to_items,
|
||||
apply_quantities_to_items,
|
||||
normalize_food_name,
|
||||
upsert_food_mapping,
|
||||
)
|
||||
from data_layer.food_recipes import list_recipes, upsert_recipes
|
||||
from data_layer.nutrition_items import catalog_macros_for_item, dates_for_normalized_name, rebuild_daily_nutrients
|
||||
|
||||
BUNDLE_FORMAT = "mitai-food-knowledge"
|
||||
BUNDLE_VERSION = 1
|
||||
|
||||
|
||||
def _jsonable(value: Any) -> Any:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
if isinstance(value, UUID):
|
||||
return str(value)
|
||||
if isinstance(value, dict):
|
||||
return {str(k): _jsonable(v) for k, v in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_jsonable(v) for v in value]
|
||||
if hasattr(value, "isoformat"):
|
||||
return value.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
def parse_food_knowledge_bundle(data: Any) -> dict[str, Any]:
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Die Datei ist kein JSON-Objekt")
|
||||
if data.get("format") != BUNDLE_FORMAT:
|
||||
raise ValueError("Keine Mitai-Zuordnungsdatei — bitte die exportierte JSON verwenden")
|
||||
try:
|
||||
version = int(data.get("version") or 0)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("Unbekannte Dateiversion") from exc
|
||||
if version != BUNDLE_VERSION:
|
||||
raise ValueError(f"Nicht unterstützte Dateiversion {version}")
|
||||
return data
|
||||
|
||||
|
||||
def portable_mapping(row: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"source_system": row.get("source_system") or "fddb",
|
||||
"source_name_raw": row.get("source_name_raw"),
|
||||
"source_name_normalized": row.get("source_name_normalized"),
|
||||
"bls_code": row.get("bls_code"),
|
||||
"food_name_de": row.get("food_name_de") or row.get("name_de"),
|
||||
"catalog_kind": row.get("catalog_kind"),
|
||||
"external_key": row.get("external_key"),
|
||||
"grams_per_unit": _jsonable(row.get("grams_per_unit")),
|
||||
"source_unit": row.get("source_unit"),
|
||||
}
|
||||
|
||||
|
||||
def resolve_catalog_food(cur, profile_id: str, ref: dict[str, Any]) -> str | None:
|
||||
bls = (ref.get("bls_code") or "").strip()
|
||||
if bls:
|
||||
cur.execute(
|
||||
"SELECT id FROM food_catalog WHERE bls_code = %s AND is_active = true",
|
||||
(bls,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return str(row["id"]) if row else None
|
||||
kind = ref.get("catalog_kind") or "manual_user"
|
||||
name = (ref.get("food_name_de") or ref.get("name_de") or "").strip()
|
||||
key = (ref.get("external_key") or "").strip()
|
||||
if kind == "manual_user":
|
||||
if key:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM food_catalog
|
||||
WHERE owner_profile_id = %s AND external_key = %s AND is_active = true
|
||||
LIMIT 1
|
||||
""",
|
||||
(profile_id, key),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
return str(row["id"])
|
||||
if name:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM food_catalog
|
||||
WHERE owner_profile_id = %s AND catalog_kind = 'manual_user'
|
||||
AND lower(name_de) = lower(%s) AND is_active = true
|
||||
LIMIT 1
|
||||
""",
|
||||
(profile_id, name),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
return str(row["id"])
|
||||
return None
|
||||
if kind == "manual_admin" and name:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM food_catalog
|
||||
WHERE catalog_kind = 'manual_admin' AND lower(name_de) = lower(%s)
|
||||
AND is_active = true
|
||||
LIMIT 1
|
||||
""",
|
||||
(name,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return str(row["id"]) if row else None
|
||||
return None
|
||||
|
||||
|
||||
def export_food_knowledge(cur, profile_id: str) -> dict[str, Any]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name_de, name_en, catalog_kind, external_key
|
||||
FROM food_catalog
|
||||
WHERE owner_profile_id = %s AND catalog_kind = 'manual_user' AND is_active = true
|
||||
ORDER BY lower(name_de)
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
manuals = []
|
||||
for food in cur.fetchall():
|
||||
macros = catalog_macros_for_item(cur, food["id"], 100.0)
|
||||
from data_layer.food_attributes import extra_attributes_for_food
|
||||
manuals.append({
|
||||
"name_de": food["name_de"],
|
||||
"name_en": food.get("name_en"),
|
||||
"catalog_kind": food["catalog_kind"],
|
||||
"external_key": food.get("external_key"),
|
||||
"macros_per_100g": macros,
|
||||
"attributes": extra_attributes_for_food(cur, food["id"]),
|
||||
})
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT m.source_system, m.source_name_raw, m.source_name_normalized,
|
||||
m.grams_per_unit, m.source_unit,
|
||||
f.bls_code, f.name_de AS food_name_de, f.catalog_kind, f.external_key
|
||||
FROM food_name_mappings m
|
||||
JOIN food_catalog f ON f.id = m.food_id
|
||||
WHERE m.profile_id = %s
|
||||
ORDER BY m.source_name_normalized
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
mappings = [portable_mapping(dict(r)) for r in cur.fetchall()]
|
||||
recipes = []
|
||||
for rec in list_recipes(cur, profile_id):
|
||||
recipes.append({
|
||||
"name_raw": rec.get("name_raw"),
|
||||
"name_normalized": rec.get("name_normalized"),
|
||||
"portions": _jsonable(rec.get("portions")),
|
||||
"description": rec.get("description"),
|
||||
"source": rec.get("source") or "fddb_list",
|
||||
"ingredients": [
|
||||
{
|
||||
"source_name_raw": ing.get("source_name_raw"),
|
||||
"source_name_normalized": ing.get("source_name_normalized"),
|
||||
"quantity_raw": ing.get("quantity_raw"),
|
||||
"quantity_g": _jsonable(ing.get("quantity_g")),
|
||||
"quantity_amount": _jsonable(ing.get("quantity_amount")),
|
||||
"source_unit": ing.get("source_unit"),
|
||||
"sort_order": ing.get("sort_order") or 0,
|
||||
}
|
||||
for ing in rec.get("ingredients") or []
|
||||
],
|
||||
})
|
||||
return _jsonable({
|
||||
"format": BUNDLE_FORMAT,
|
||||
"version": BUNDLE_VERSION,
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"manual_foods": manuals,
|
||||
"mappings": mappings,
|
||||
"recipes": recipes,
|
||||
})
|
||||
|
||||
|
||||
def _upsert_manual_food(cur, profile_id: str, food: dict[str, Any]) -> str | None:
|
||||
name = (food.get("name_de") or "").strip()
|
||||
if not name:
|
||||
return None
|
||||
existing = resolve_catalog_food(cur, profile_id, {**food, "food_name_de": name, "catalog_kind": "manual_user"})
|
||||
from data_layer.food_attributes import macros_and_attributes_to_values, write_numeric_attributes
|
||||
values = macros_and_attributes_to_values(food.get("macros_per_100g"), food.get("attributes"))
|
||||
if existing:
|
||||
write_numeric_attributes(cur, existing, values)
|
||||
return existing
|
||||
key = (food.get("external_key") or "").strip() or f"man-user-{normalize_food_name(name)[:40]}"
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO food_catalog
|
||||
(name_de, name_en, catalog_kind, owner_profile_id, source, external_key)
|
||||
VALUES (%s, %s, 'manual_user', %s, 'import', %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(name, food.get("name_en"), profile_id, key),
|
||||
)
|
||||
food_id = str(cur.fetchone()["id"])
|
||||
write_numeric_attributes(cur, food_id, values)
|
||||
return food_id
|
||||
|
||||
|
||||
def import_food_knowledge(cur, profile_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||
bundle = parse_food_knowledge_bundle(data)
|
||||
foods_upserted = 0
|
||||
for food in bundle.get("manual_foods") or []:
|
||||
if _upsert_manual_food(cur, profile_id, food):
|
||||
foods_upserted += 1
|
||||
mappings_ok = mappings_skipped = items_updated = 0
|
||||
skipped: list[str] = []
|
||||
dates: set[str] = set()
|
||||
for raw in bundle.get("mappings") or []:
|
||||
name = (raw.get("source_name_raw") or "").strip()
|
||||
if not name:
|
||||
mappings_skipped += 1
|
||||
continue
|
||||
food_id = resolve_catalog_food(cur, profile_id, raw)
|
||||
if not food_id:
|
||||
mappings_skipped += 1
|
||||
label = raw.get("bls_code") or raw.get("food_name_de") or name
|
||||
skipped.append(str(label))
|
||||
continue
|
||||
mid = upsert_food_mapping(
|
||||
cur,
|
||||
source_name_raw=name,
|
||||
food_id=food_id,
|
||||
profile_id=profile_id,
|
||||
source="import",
|
||||
source_system=raw.get("source_system") or "fddb",
|
||||
grams_per_unit=raw.get("grams_per_unit"),
|
||||
source_unit=raw.get("source_unit"),
|
||||
)
|
||||
norm = normalize_food_name(name)
|
||||
items_updated += apply_mapping_to_items(cur, profile_id, norm, food_id, mid)
|
||||
apply_quantities_to_items(cur, profile_id, norm, raw.get("grams_per_unit"))
|
||||
dates.update(dates_for_normalized_name(cur, profile_id, norm))
|
||||
mappings_ok += 1
|
||||
recipe_stats = {"inserted": 0, "updated": 0, "ingredients": 0, "items_linked": 0}
|
||||
recipes = bundle.get("recipes") or []
|
||||
if recipes:
|
||||
recipe_stats = upsert_recipes(cur, profile_id, recipes)
|
||||
dates.update(recipe_stats.pop("dates_linked", []) or [])
|
||||
for day in dates:
|
||||
rebuild_daily_nutrients(cur, profile_id, day)
|
||||
from data_layer.food_suggest import invalidate_suggest_index
|
||||
invalidate_suggest_index(profile_id)
|
||||
return {
|
||||
"ok": True,
|
||||
"manual_foods": foods_upserted,
|
||||
"mappings": mappings_ok,
|
||||
"mappings_skipped": mappings_skipped,
|
||||
"items_updated": items_updated,
|
||||
"skipped_foods": skipped[:20],
|
||||
**recipe_stats,
|
||||
}
|
||||
666
backend/data_layer/food_mapping.py
Normal file
666
backend/data_layer/food_mapping.py
Normal file
|
|
@ -0,0 +1,666 @@
|
|||
"""FDDB → food_catalog mapping: normalize, lookup (user then global), learn, apply."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from datetime import date, timedelta
|
||||
from typing import Any
|
||||
|
||||
LEADING_QTY_RE = re.compile(
|
||||
r"^\s*\d+(?:[.,]\d+)?\s*(?:g|kg|ml|l|stück|stk|st\.?|portion(?:en)?)\b[\s,.:\-–]*",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
MULTISPACE_RE = re.compile(r"\s+")
|
||||
DECIMAL_IN_NAME_RE = re.compile(r"(\d),(\d)")
|
||||
|
||||
|
||||
def strip_leading_quantity(raw: str | None) -> str:
|
||||
if not raw:
|
||||
return ""
|
||||
s = unicodedata.normalize("NFKC", str(raw)).strip().strip('"').strip("'")
|
||||
s = s.lstrip("!")
|
||||
s = LEADING_QTY_RE.sub("", s)
|
||||
return MULTISPACE_RE.sub(" ", s).strip()
|
||||
|
||||
|
||||
def normalize_food_name(raw: str | None) -> str:
|
||||
s = strip_leading_quantity(raw)
|
||||
if not s:
|
||||
return ""
|
||||
s = DECIMAL_IN_NAME_RE.sub(r"\1.\2", s)
|
||||
return s.lower()
|
||||
|
||||
|
||||
def item_name_keys(raw: str | None, stored_norm: str | None = None) -> set[str]:
|
||||
"""Compare diary names to list names despite stored quantity prefixes."""
|
||||
keys: set[str] = set()
|
||||
for v in (raw, stored_norm):
|
||||
if not v:
|
||||
continue
|
||||
text = str(v).strip()
|
||||
if text:
|
||||
keys.add(text.lower())
|
||||
norm = normalize_food_name(text)
|
||||
if norm:
|
||||
keys.add(norm)
|
||||
return keys
|
||||
|
||||
|
||||
def names_match_list(raw: str | None, stored_norm: str | None, list_norm: str | None) -> bool:
|
||||
target = normalize_food_name(list_norm) if list_norm else ""
|
||||
if not target:
|
||||
return False
|
||||
return target in item_name_keys(raw, stored_norm)
|
||||
|
||||
|
||||
_LIST_COMMA_RE = re.compile(r",(?!\s*\d)")
|
||||
|
||||
|
||||
def primary_search_query(query: str | None) -> str:
|
||||
"""Use the name before a list-comma, but keep decimal commas (9,5 %)."""
|
||||
q = (query or "").strip()
|
||||
if not q:
|
||||
return ""
|
||||
return _LIST_COMMA_RE.split(q, maxsplit=1)[0].strip()
|
||||
|
||||
|
||||
def merge_unmapped_rows(rows: list[dict]) -> list[dict]:
|
||||
merged: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
raw = row.get("source_name_raw") or ""
|
||||
key = normalize_food_name(raw) or row.get("source_name_normalized") or raw.lower()
|
||||
if not key:
|
||||
continue
|
||||
display = strip_leading_quantity(raw) or raw
|
||||
count = int(row.get("count") or 0)
|
||||
if key not in merged:
|
||||
item = dict(row)
|
||||
item["source_name_normalized"] = key
|
||||
item["source_name_raw"] = display
|
||||
item["count"] = count
|
||||
item["variant_count"] = 1
|
||||
merged[key] = item
|
||||
continue
|
||||
cur = merged[key]
|
||||
cur["count"] = int(cur.get("count") or 0) + count
|
||||
cur["variant_count"] = int(cur.get("variant_count") or 1) + 1
|
||||
if display and (not cur.get("source_name_raw") or len(display) < len(cur["source_name_raw"])):
|
||||
cur["source_name_raw"] = display
|
||||
if row.get("matching_recipe_id") and not cur.get("matching_recipe_id"):
|
||||
cur["matching_recipe_id"] = row["matching_recipe_id"]
|
||||
if row.get("sample_quantity_raw") and not cur.get("sample_quantity_raw"):
|
||||
cur["sample_quantity_raw"] = row["sample_quantity_raw"]
|
||||
first, last = row.get("first_date"), row.get("last_date")
|
||||
if first and (not cur.get("first_date") or str(first) < str(cur["first_date"])):
|
||||
cur["first_date"] = first
|
||||
if last and (not cur.get("last_date") or str(last) > str(cur["last_date"])):
|
||||
cur["last_date"] = last
|
||||
return list(merged.values())
|
||||
|
||||
|
||||
def as_iso_date(value: Any) -> str | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if hasattr(value, "isoformat"):
|
||||
return str(value.isoformat())[:10]
|
||||
text = str(value).strip()
|
||||
return text[:10] if len(text) >= 10 else None
|
||||
|
||||
|
||||
def filter_unmapped_since(rows: list[dict], since_days: int, today: date | None = None) -> list[dict]:
|
||||
"""Keep names last eaten in the window. Rows without last_date drop out (old recipe leftovers)."""
|
||||
days = int(since_days or 0)
|
||||
if days <= 0:
|
||||
return list(rows)
|
||||
cutoff = ((today or date.today()) - timedelta(days=days)).isoformat()
|
||||
out = []
|
||||
for row in rows:
|
||||
last = as_iso_date(row.get("last_date"))
|
||||
if last and last >= cutoff:
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def fetch_unmapped_rows(cur, profile_id: str) -> list[dict[str, Any]]:
|
||||
"""Diary lines without food/recipe plus list ingredients without mapping.
|
||||
|
||||
Ingredient dates come from recipe.updated_at or last diary use of that list
|
||||
(items that already have recipe_id) — not a join of every recipe name to
|
||||
every diary row.
|
||||
"""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT source_name_normalized
|
||||
FROM food_name_mappings
|
||||
WHERE profile_id = %s
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
mapped = {r["source_name_normalized"] for r in cur.fetchall()}
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT i.source_name_raw, i.source_name_normalized,
|
||||
COUNT(*) AS count, MIN(i.date) AS first_date, MAX(i.date) AS last_date,
|
||||
MIN(r.id::text) AS matching_recipe_id,
|
||||
MIN(i.quantity_raw) AS sample_quantity_raw
|
||||
FROM nutrition_items i
|
||||
LEFT JOIN food_recipes r
|
||||
ON r.profile_id = i.profile_id AND r.name_normalized = i.source_name_normalized
|
||||
WHERE i.profile_id = %s AND i.food_id IS NULL AND i.recipe_id IS NULL
|
||||
GROUP BY i.source_name_raw, i.source_name_normalized
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
diary = [dict(r) | {"kind": "diary"} for r in cur.fetchall()]
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name_raw, name_normalized
|
||||
FROM food_recipes
|
||||
WHERE profile_id = %s
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
lists = [dict(r) for r in cur.fetchall()]
|
||||
for row in diary:
|
||||
keys = item_name_keys(row.get("source_name_raw"), row.get("source_name_normalized"))
|
||||
hit = next(
|
||||
(
|
||||
rec
|
||||
for rec in lists
|
||||
if rec.get("name_normalized") in keys
|
||||
or normalize_food_name(rec.get("name_raw")) in keys
|
||||
),
|
||||
None,
|
||||
)
|
||||
if hit:
|
||||
row["matching_recipe_id"] = str(hit["id"])
|
||||
row["matching_recipe_name"] = hit["name_raw"]
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT i.source_name_raw, i.source_name_normalized,
|
||||
COUNT(*) AS count,
|
||||
MIN(COALESCE(u.last_used, r.updated_at::date, r.created_at::date)) AS first_date,
|
||||
MAX(COALESCE(u.last_used, r.updated_at::date, r.created_at::date)) AS last_date,
|
||||
MIN(i.quantity_raw) AS sample_quantity_raw
|
||||
FROM food_recipe_ingredients i
|
||||
JOIN food_recipes r ON r.id = i.recipe_id
|
||||
LEFT JOIN (
|
||||
SELECT recipe_id, MIN(date) AS first_used, MAX(date) AS last_used
|
||||
FROM nutrition_items
|
||||
WHERE profile_id = %s AND recipe_id IS NOT NULL
|
||||
GROUP BY recipe_id
|
||||
) u ON u.recipe_id = r.id
|
||||
WHERE r.profile_id = %s
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM food_name_mappings m
|
||||
WHERE m.profile_id = r.profile_id
|
||||
AND m.source_name_normalized = i.source_name_normalized
|
||||
)
|
||||
GROUP BY i.source_name_raw, i.source_name_normalized
|
||||
""",
|
||||
(profile_id, profile_id),
|
||||
)
|
||||
ings = [dict(r) | {"kind": "recipe_ingredient"} for r in cur.fetchall()]
|
||||
out = []
|
||||
for row in merge_unmapped_rows(diary + ings):
|
||||
key = row.get("source_name_normalized") or normalize_food_name(row.get("source_name_raw"))
|
||||
if not key or key in mapped:
|
||||
continue
|
||||
row["last_date"] = as_iso_date(row.get("last_date"))
|
||||
row["first_date"] = as_iso_date(row.get("first_date"))
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def list_unmapped_payload(
|
||||
cur,
|
||||
profile_id: str,
|
||||
since_days: int = 0,
|
||||
count_only: bool = False,
|
||||
meta: bool = False,
|
||||
) -> Any:
|
||||
days = max(0, min(int(since_days or 0), 3650))
|
||||
out = fetch_unmapped_rows(cur, profile_id)
|
||||
items = sort_unmapped_rows(filter_unmapped_since(out, days) if days else out, days)
|
||||
if count_only:
|
||||
return {"count": len(items), "total": len(out), "since_days": days}
|
||||
if meta:
|
||||
return {"items": items, "count": len(items), "total": len(out), "since_days": days}
|
||||
return items
|
||||
|
||||
|
||||
def sort_unmapped_rows(rows: list[dict], since_days: int = 0) -> list[dict]:
|
||||
rows = list(rows)
|
||||
if int(since_days or 0) > 0:
|
||||
rows.sort(
|
||||
key=lambda x: (
|
||||
as_iso_date(x.get("last_date")) or "",
|
||||
int(x.get("count") or 0),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
else:
|
||||
rows.sort(key=lambda x: (-int(x.get("count") or 0), x.get("source_name_normalized") or ""))
|
||||
return rows
|
||||
|
||||
|
||||
UNIT_ALIASES = {
|
||||
"g": "g", "gr": "g", "gramm": "g",
|
||||
"kg": "kg",
|
||||
"ml": "ml",
|
||||
"l": "l", "liter": "l", "lt": "l",
|
||||
"stück": "stück", "stk": "stück", "st": "stück", "st.": "stück", "pcs": "stück",
|
||||
"el": "el", "esslöffel": "el",
|
||||
"tl": "tl", "teelöffel": "tl",
|
||||
"prise": "prise",
|
||||
"scheibe": "scheibe",
|
||||
"portion": "portion", "portionen": "portion",
|
||||
"becher": "becher",
|
||||
"tasse": "tasse",
|
||||
"msp": "msp", "msp.": "msp",
|
||||
}
|
||||
MASS_VOLUME_TO_G = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "l": 1000.0}
|
||||
DEFAULT_UNIT_G = {"el": 15.0, "tl": 5.0, "prise": 0.3, "msp": 1.0}
|
||||
COUNT_UNITS = frozenset({"stück", "scheibe", "portion", "becher", "tasse"})
|
||||
UNIT_LABELS = {
|
||||
"g": "g", "kg": "kg", "ml": "ml", "l": "l",
|
||||
"el": "EL", "tl": "TL", "prise": "Prise", "msp": "Msp.",
|
||||
"stück": "Stück", "scheibe": "Scheibe", "portion": "Portion",
|
||||
"becher": "Becher", "tasse": "Tasse",
|
||||
}
|
||||
UNIT_ORDER = (
|
||||
"g", "ml", "el", "tl", "prise", "msp",
|
||||
"stück", "scheibe", "portion", "becher", "tasse", "kg", "l",
|
||||
)
|
||||
QTY_PARSE_RE = re.compile(
|
||||
r"^\s*(\d+(?:[.,]\d+)?)\s*([a-zA-ZäöüÄÖÜß.]+)?\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _canon_unit(raw: str | None) -> str | None:
|
||||
if not raw:
|
||||
return None
|
||||
return UNIT_ALIASES.get(raw.strip().lower().rstrip("."))
|
||||
|
||||
|
||||
def parse_quantity(raw: str | None, grams_per_unit: float | None = None) -> dict[str, Any]:
|
||||
empty = {"value": None, "unit": None, "quantity_g": None, "needs_unit_map": False}
|
||||
if raw is None or str(raw).strip() == "":
|
||||
return empty
|
||||
text = str(raw).strip().replace(",", ".")
|
||||
m = QTY_PARSE_RE.match(text)
|
||||
if not m:
|
||||
return empty
|
||||
value = float(m.group(1))
|
||||
unit = _canon_unit(m.group(2))
|
||||
if unit is None:
|
||||
return {"value": value, "unit": "g", "quantity_g": round(value, 3), "needs_unit_map": False}
|
||||
if unit in MASS_VOLUME_TO_G:
|
||||
return {
|
||||
"value": value,
|
||||
"unit": unit,
|
||||
"quantity_g": round(value * MASS_VOLUME_TO_G[unit], 3),
|
||||
"needs_unit_map": False,
|
||||
}
|
||||
factor = grams_per_unit if grams_per_unit is not None else DEFAULT_UNIT_G.get(unit)
|
||||
if factor is not None:
|
||||
return {
|
||||
"value": value,
|
||||
"unit": unit,
|
||||
"quantity_g": round(value * float(factor), 3),
|
||||
"needs_unit_map": unit in COUNT_UNITS,
|
||||
}
|
||||
return {"value": value, "unit": unit, "quantity_g": None, "needs_unit_map": True}
|
||||
|
||||
|
||||
def parse_quantity_g(raw: str | None, grams_per_unit: float | None = None) -> float | None:
|
||||
return parse_quantity(raw, grams_per_unit)["quantity_g"]
|
||||
|
||||
|
||||
def detect_quantity_unit(*texts: str | None) -> str | None:
|
||||
for text in texts:
|
||||
unit = parse_quantity(text).get("unit")
|
||||
if unit and unit not in MASS_VOLUME_TO_G:
|
||||
return unit
|
||||
return None
|
||||
|
||||
|
||||
def format_quantity_raw(value: float | None, unit: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
label = UNIT_LABELS.get(unit or "g", unit or "g")
|
||||
num = int(value) if float(value) == int(value) else value
|
||||
return f"{num} {label}".replace(".", ",")
|
||||
|
||||
|
||||
def list_quantity_units() -> list[dict[str, Any]]:
|
||||
out = []
|
||||
for uid in UNIT_ORDER:
|
||||
default_g = MASS_VOLUME_TO_G.get(uid)
|
||||
if default_g is None:
|
||||
default_g = DEFAULT_UNIT_G.get(uid)
|
||||
out.append({
|
||||
"id": uid,
|
||||
"label": UNIT_LABELS[uid],
|
||||
"default_g": default_g,
|
||||
"needs_unit_map": uid in COUNT_UNITS,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def resolve_quantity(
|
||||
*,
|
||||
quantity_raw: str | None = None,
|
||||
quantity_amount: float | None = None,
|
||||
source_unit: str | None = None,
|
||||
quantity_g: float | None = None,
|
||||
grams_per_unit: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Amount + unit → quantity_g. Standard unit is grams; conversion uses mapping or defaults."""
|
||||
amount = None
|
||||
if quantity_amount not in (None, ""):
|
||||
try:
|
||||
amount = float(quantity_amount)
|
||||
except (TypeError, ValueError):
|
||||
amount = None
|
||||
explicit_g = None
|
||||
if quantity_g not in (None, ""):
|
||||
try:
|
||||
explicit_g = float(quantity_g)
|
||||
except (TypeError, ValueError):
|
||||
explicit_g = None
|
||||
if amount is not None:
|
||||
unit = _canon_unit(source_unit) or "g"
|
||||
raw = format_quantity_raw(amount, unit)
|
||||
parsed = parse_quantity(raw.replace(",", "."), grams_per_unit)
|
||||
elif quantity_raw:
|
||||
parsed = parse_quantity(quantity_raw, grams_per_unit)
|
||||
elif explicit_g is not None:
|
||||
parsed = {"value": explicit_g, "unit": "g", "quantity_g": explicit_g, "needs_unit_map": False}
|
||||
else:
|
||||
parsed = {"value": None, "unit": _canon_unit(source_unit), "quantity_g": None, "needs_unit_map": False}
|
||||
qty_g = parsed.get("quantity_g")
|
||||
if qty_g is None and explicit_g is not None:
|
||||
qty_g = explicit_g
|
||||
unit = parsed.get("unit")
|
||||
value = parsed.get("value")
|
||||
return {
|
||||
"quantity_amount": value,
|
||||
"source_unit": unit,
|
||||
"quantity_raw": format_quantity_raw(value, unit) or (str(quantity_raw).strip() if quantity_raw else None),
|
||||
"quantity_g": qty_g,
|
||||
"needs_unit_map": bool(parsed.get("needs_unit_map")),
|
||||
}
|
||||
|
||||
|
||||
def get_food_mapping_with_cursor(
|
||||
cur,
|
||||
source_name: str,
|
||||
profile_id: str | None = None,
|
||||
source_system: str = "fddb",
|
||||
) -> dict[str, Any] | None:
|
||||
norm = normalize_food_name(source_name)
|
||||
if not norm:
|
||||
return None
|
||||
if profile_id:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT m.id AS mapping_id, m.food_id, m.profile_id, m.source,
|
||||
m.grams_per_unit, m.source_unit,
|
||||
f.bls_code, f.name_de, f.catalog_kind, f.owner_profile_id
|
||||
FROM food_name_mappings m
|
||||
JOIN food_catalog f ON f.id = m.food_id
|
||||
WHERE m.source_system = %s AND m.source_name_normalized = %s
|
||||
AND m.profile_id = %s
|
||||
AND f.is_active = true
|
||||
AND (f.owner_profile_id IS NULL OR f.owner_profile_id = %s)
|
||||
LIMIT 1
|
||||
""",
|
||||
(source_system, norm, profile_id, profile_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT m.id AS mapping_id, m.food_id, m.profile_id, m.source,
|
||||
m.grams_per_unit, m.source_unit,
|
||||
f.bls_code, f.name_de, f.catalog_kind, f.owner_profile_id
|
||||
FROM food_name_mappings m
|
||||
JOIN food_catalog f ON f.id = m.food_id
|
||||
WHERE m.source_system = %s AND m.source_name_normalized = %s
|
||||
AND m.profile_id IS NULL
|
||||
AND f.is_active = true
|
||||
AND f.owner_profile_id IS NULL
|
||||
LIMIT 1
|
||||
""",
|
||||
(source_system, norm),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def upsert_food_mapping(
|
||||
cur,
|
||||
*,
|
||||
source_name_raw: str,
|
||||
food_id: str,
|
||||
profile_id: str | None,
|
||||
source: str = "bulk",
|
||||
source_system: str = "fddb",
|
||||
grams_per_unit: float | None = None,
|
||||
source_unit: str | None = None,
|
||||
) -> int:
|
||||
from data_layer.food_visibility import require_mapping_target
|
||||
|
||||
require_mapping_target(cur, food_id, profile_id)
|
||||
norm = normalize_food_name(source_name_raw)
|
||||
if not norm:
|
||||
raise ValueError("Leerer Lebensmittelname")
|
||||
if profile_id:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM food_name_mappings
|
||||
WHERE source_system = %s AND source_name_normalized = %s AND profile_id = %s
|
||||
""",
|
||||
(source_system, norm, profile_id),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM food_name_mappings
|
||||
WHERE source_system = %s AND source_name_normalized = %s AND profile_id IS NULL
|
||||
""",
|
||||
(source_system, norm),
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
raw = source_name_raw.strip()
|
||||
unit = _canon_unit(source_unit) if source_unit else None
|
||||
if existing:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE food_name_mappings
|
||||
SET food_id = %s, source_name_raw = %s, source = %s,
|
||||
grams_per_unit = %s, source_unit = %s, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(food_id, raw, source, grams_per_unit, unit, existing["id"]),
|
||||
)
|
||||
return int(existing["id"])
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO food_name_mappings
|
||||
(source_system, source_name_raw, source_name_normalized, food_id, profile_id,
|
||||
source, grams_per_unit, source_unit, updated_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
RETURNING id
|
||||
""",
|
||||
(source_system, raw, norm, food_id, profile_id, source, grams_per_unit, unit),
|
||||
)
|
||||
return int(cur.fetchone()["id"])
|
||||
|
||||
|
||||
def apply_quantities_to_items(cur, profile_id: str, source_name_normalized: str, grams_per_unit: float | None) -> int:
|
||||
if not grams_per_unit:
|
||||
return 0
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, quantity_raw, source_name_raw
|
||||
FROM nutrition_items
|
||||
WHERE profile_id = %s AND source_name_normalized = %s
|
||||
""",
|
||||
(profile_id, source_name_normalized),
|
||||
)
|
||||
n = 0
|
||||
for row in cur.fetchall():
|
||||
qty = parse_quantity_g(row.get("quantity_raw") or row.get("source_name_raw"), grams_per_unit)
|
||||
if qty is None:
|
||||
continue
|
||||
cur.execute(
|
||||
"UPDATE nutrition_items SET quantity_g = %s, updated_at = NOW() WHERE id = %s",
|
||||
(qty, row["id"]),
|
||||
)
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def apply_mapping_to_items(cur, profile_id: str, source_name_normalized: str, food_id: str, mapping_id: int) -> int:
|
||||
origin = _value_origin_for_food(cur, food_id)
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE nutrition_items
|
||||
SET food_id = %s, mapping_id = %s, value_origin = %s, updated_at = NOW()
|
||||
WHERE profile_id = %s AND source_name_normalized = %s
|
||||
AND recipe_id IS NULL
|
||||
""",
|
||||
(food_id, mapping_id, origin, profile_id, source_name_normalized),
|
||||
)
|
||||
n = cur.rowcount or 0
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, source_name_raw
|
||||
FROM nutrition_items
|
||||
WHERE profile_id = %s AND recipe_id IS NULL AND food_id IS NULL
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
extra = [
|
||||
row["id"] for row in cur.fetchall()
|
||||
if normalize_food_name(row.get("source_name_raw")) == source_name_normalized
|
||||
]
|
||||
if extra:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE nutrition_items
|
||||
SET food_id = %s, mapping_id = %s, value_origin = %s, updated_at = NOW()
|
||||
WHERE id = ANY(%s)
|
||||
""",
|
||||
(food_id, mapping_id, origin, extra),
|
||||
)
|
||||
n += cur.rowcount or 0
|
||||
return n
|
||||
|
||||
|
||||
def clear_mapping_from_items(cur, profile_id: str, source_name_normalized: str) -> int:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE nutrition_items
|
||||
SET food_id = NULL, mapping_id = NULL, value_origin = 'fddb', updated_at = NOW()
|
||||
WHERE profile_id = %s AND source_name_normalized = %s
|
||||
""",
|
||||
(profile_id, source_name_normalized),
|
||||
)
|
||||
return cur.rowcount or 0
|
||||
|
||||
|
||||
def _value_origin_for_food(cur, food_id: str) -> str:
|
||||
cur.execute("SELECT catalog_kind FROM food_catalog WHERE id = %s", (food_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return "fddb"
|
||||
kind = row["catalog_kind"]
|
||||
if kind == "official_bls":
|
||||
return "bls"
|
||||
return "manual_catalog"
|
||||
|
||||
|
||||
_SEARCH_NUM_RE = re.compile(r"\d+(?:[.,]\d+)?")
|
||||
|
||||
|
||||
def _catalog_must_tokens(query: str) -> list[list[str]]:
|
||||
"""AND-groups for catalog search: first word plus each number (with 9,5/10 aliases)."""
|
||||
text = normalize_food_name(primary_search_query(query))
|
||||
nums = [m.replace(",", ".") for m in _SEARCH_NUM_RE.findall(text)]
|
||||
words = [t for t in re.split(r"[^a-z0-9äöüß]+", _SEARCH_NUM_RE.sub(" ", text)) if len(t) >= 2]
|
||||
groups: list[list[str]] = []
|
||||
if words:
|
||||
groups.append([words[0]])
|
||||
for num in nums[:3]:
|
||||
variants = {num, num.replace(".", ",")}
|
||||
try:
|
||||
value = float(num)
|
||||
except ValueError:
|
||||
value = None
|
||||
if value is not None and (abs(value - 10) <= 0.6 or abs(value - 9.5) <= 0.6):
|
||||
variants.update({"10", "9.5", "9,5"})
|
||||
groups.append(list(variants))
|
||||
return groups
|
||||
|
||||
|
||||
def suggest_catalog_foods(cur, query: str, profile_id: str | None, limit: int = 8) -> list[dict]:
|
||||
q = (query or "").strip()
|
||||
if not q:
|
||||
return []
|
||||
primary = primary_search_query(q) or q
|
||||
like_full = f"%{q}%"
|
||||
like_primary = f"%{primary}%"
|
||||
prefix = f"{primary}%"
|
||||
norm = normalize_food_name(primary)
|
||||
extra_sql = ""
|
||||
extra_params: list[str] = []
|
||||
must = _catalog_must_tokens(q)
|
||||
if len(must) >= 2:
|
||||
parts = []
|
||||
for group in must:
|
||||
ors = " OR ".join(["name_de ILIKE %s"] * len(group))
|
||||
parts.append(f"({ors})")
|
||||
extra_params.extend(f"%{v}%" for v in group)
|
||||
extra_sql = " OR (" + " AND ".join(parts) + ")"
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT id, bls_code, name_de, name_en, catalog_kind, food_group, owner_profile_id
|
||||
FROM food_catalog
|
||||
WHERE is_active = true
|
||||
AND (
|
||||
owner_profile_id IS NULL
|
||||
OR owner_profile_id = %s
|
||||
)
|
||||
AND (
|
||||
name_de ILIKE %s OR name_de ILIKE %s
|
||||
OR COALESCE(name_en, '') ILIKE %s OR COALESCE(name_en, '') ILIKE %s
|
||||
OR COALESCE(bls_code, '') ILIKE %s
|
||||
OR lower(name_de) = %s
|
||||
{extra_sql}
|
||||
)
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN lower(name_de) = %s THEN 0
|
||||
WHEN name_de ILIKE %s THEN 1
|
||||
WHEN COALESCE(bls_code, '') ILIKE %s THEN 2
|
||||
ELSE 3 END,
|
||||
CASE catalog_kind
|
||||
WHEN 'manual_user' THEN 0
|
||||
WHEN 'manual_admin' THEN 1
|
||||
ELSE 2 END,
|
||||
name_de
|
||||
LIMIT %s
|
||||
""",
|
||||
(
|
||||
profile_id,
|
||||
like_full, like_primary, like_full, like_primary, like_full, norm,
|
||||
*extra_params,
|
||||
norm, prefix, q, limit,
|
||||
),
|
||||
)
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
431
backend/data_layer/food_recipes.py
Normal file
431
backend/data_layer/food_recipes.py
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
"""FDDB recipe lists: upsert, link to diary items, catalog macros via ingredients."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from data_layer.food_mapping import (
|
||||
get_food_mapping_with_cursor,
|
||||
names_match_list,
|
||||
normalize_food_name,
|
||||
resolve_quantity,
|
||||
)
|
||||
from data_layer.nutrition_items import catalog_macros_for_item, _f
|
||||
|
||||
|
||||
def _resolved_ingredient_qty(cur, profile_id: str, source_name_raw: str, ing: dict[str, Any]) -> dict[str, Any]:
|
||||
mapping = get_food_mapping_with_cursor(cur, source_name_raw, profile_id)
|
||||
gpu = ing.get("grams_per_unit")
|
||||
if gpu in (None, ""):
|
||||
gpu = mapping.get("grams_per_unit") if mapping else None
|
||||
try:
|
||||
gpu = float(gpu) if gpu not in (None, "") else None
|
||||
except (TypeError, ValueError):
|
||||
gpu = None
|
||||
return resolve_quantity(
|
||||
quantity_raw=ing.get("quantity_raw"),
|
||||
quantity_amount=ing.get("quantity_amount"),
|
||||
source_unit=ing.get("source_unit"),
|
||||
quantity_g=ing.get("quantity_g"),
|
||||
grams_per_unit=gpu,
|
||||
)
|
||||
|
||||
|
||||
def upsert_recipes(cur, profile_id: str, recipes: list[dict[str, Any]]) -> dict[str, int]:
|
||||
inserted = updated = ingredients = 0
|
||||
for rec in recipes:
|
||||
norm = rec.get("name_normalized") or normalize_food_name(rec.get("name_raw"))
|
||||
if not norm:
|
||||
continue
|
||||
cur.execute(
|
||||
"SELECT id FROM food_recipes WHERE profile_id = %s AND name_normalized = %s",
|
||||
(profile_id, norm),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
rid = row["id"]
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE food_recipes
|
||||
SET name_raw=%s, portions=%s, description=%s, updated_at=NOW()
|
||||
WHERE id=%s
|
||||
""",
|
||||
(rec["name_raw"], rec.get("portions") or 1, rec.get("description"), rid),
|
||||
)
|
||||
cur.execute("DELETE FROM food_recipe_ingredients WHERE recipe_id = %s", (rid,))
|
||||
updated += 1
|
||||
else:
|
||||
rid = str(uuid.uuid4())
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO food_recipes
|
||||
(id, profile_id, name_raw, name_normalized, portions, description, source)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,'fddb_list')
|
||||
""",
|
||||
(rid, profile_id, rec["name_raw"], norm, rec.get("portions") or 1, rec.get("description")),
|
||||
)
|
||||
inserted += 1
|
||||
for ing in rec.get("ingredients") or []:
|
||||
inorm = ing.get("source_name_normalized") or normalize_food_name(ing.get("source_name_raw"))
|
||||
if not inorm:
|
||||
continue
|
||||
raw_name = ing.get("source_name_raw") or inorm
|
||||
qty = resolve_quantity(
|
||||
quantity_raw=ing.get("quantity_raw"),
|
||||
quantity_amount=ing.get("quantity_amount"),
|
||||
source_unit=ing.get("source_unit"),
|
||||
quantity_g=ing.get("quantity_g"),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO food_recipe_ingredients
|
||||
(id, recipe_id, source_name_raw, source_name_normalized,
|
||||
quantity_raw, quantity_g, quantity_amount, source_unit, sort_order)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
""",
|
||||
(
|
||||
str(uuid.uuid4()), rid, raw_name, inorm,
|
||||
qty["quantity_raw"], qty["quantity_g"], qty["quantity_amount"], qty["source_unit"],
|
||||
ing.get("sort_order") or 0,
|
||||
),
|
||||
)
|
||||
ingredients += 1
|
||||
linked, dates = link_recipes_to_items(cur, profile_id)
|
||||
return {
|
||||
"inserted": inserted,
|
||||
"updated": updated,
|
||||
"ingredients": ingredients,
|
||||
"items_linked": linked,
|
||||
"dates_linked": dates,
|
||||
}
|
||||
|
||||
|
||||
def link_recipes_to_items(cur, profile_id: str) -> tuple[int, list[str]]:
|
||||
cur.execute(
|
||||
"SELECT id, name_raw, name_normalized FROM food_recipes WHERE profile_id = %s",
|
||||
(profile_id,),
|
||||
)
|
||||
recipes = [dict(r) for r in cur.fetchall()]
|
||||
if not recipes:
|
||||
return 0, []
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, date::text AS date, source_name_raw, source_name_normalized
|
||||
FROM nutrition_items
|
||||
WHERE profile_id = %s AND food_id IS NULL AND recipe_id IS NULL
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
by_rid: dict[str, list[str]] = {}
|
||||
dates: set[str] = set()
|
||||
for item in cur.fetchall():
|
||||
rid = next(
|
||||
(
|
||||
str(rec["id"])
|
||||
for rec in recipes
|
||||
if names_match_list(item.get("source_name_raw"), item.get("source_name_normalized"), rec.get("name_normalized"))
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not rid:
|
||||
continue
|
||||
by_rid.setdefault(rid, []).append(str(item["id"]))
|
||||
if item.get("date"):
|
||||
dates.add(str(item["date"]))
|
||||
linked = 0
|
||||
for rid, ids in by_rid.items():
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE nutrition_items
|
||||
SET recipe_id = %s, updated_at = NOW()
|
||||
WHERE id = ANY(%s::uuid[])
|
||||
""",
|
||||
(rid, ids),
|
||||
)
|
||||
linked += cur.rowcount or 0
|
||||
return linked, list(dates)
|
||||
|
||||
|
||||
def list_known_ingredient_names(cur, profile_id: str, limit: int = 2000) -> list[dict[str, Any]]:
|
||||
"""Distinct list-ingredient names, with mapping if already learned."""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT i.source_name_raw, i.source_name_normalized,
|
||||
MIN(m.food_id::text) AS food_id,
|
||||
MIN(f.name_de) AS food_name_de,
|
||||
MIN(f.bls_code) AS bls_code,
|
||||
BOOL_OR(m.id IS NOT NULL) AS mapped
|
||||
FROM food_recipe_ingredients i
|
||||
JOIN food_recipes r ON r.id = i.recipe_id
|
||||
LEFT JOIN food_name_mappings m
|
||||
ON m.profile_id = r.profile_id
|
||||
AND m.source_name_normalized = i.source_name_normalized
|
||||
LEFT JOIN food_catalog f ON f.id = m.food_id
|
||||
WHERE r.profile_id = %s
|
||||
GROUP BY i.source_name_raw, i.source_name_normalized
|
||||
ORDER BY i.source_name_normalized
|
||||
LIMIT %s
|
||||
""",
|
||||
(profile_id, limit),
|
||||
)
|
||||
out = []
|
||||
for row in cur.fetchall():
|
||||
rec = dict(row)
|
||||
rec["mapped"] = bool(rec.get("mapped"))
|
||||
out.append(rec)
|
||||
return out
|
||||
|
||||
|
||||
def list_recipes(cur, profile_id: str, with_ingredients: bool = True) -> list[dict[str, Any]]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name_raw, name_normalized, portions, description, source
|
||||
FROM food_recipes
|
||||
WHERE profile_id = %s
|
||||
ORDER BY name_normalized
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
recipes = [dict(r) for r in cur.fetchall()]
|
||||
if not recipes:
|
||||
return []
|
||||
ids = [str(r["id"]) for r in recipes]
|
||||
if not with_ingredients:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT recipe_id, COUNT(*) AS n
|
||||
FROM food_recipe_ingredients
|
||||
WHERE recipe_id = ANY(%s::uuid[])
|
||||
GROUP BY recipe_id
|
||||
""",
|
||||
(ids,),
|
||||
)
|
||||
counts = {str(r["recipe_id"]): int(r["n"]) for r in cur.fetchall()}
|
||||
for rec in recipes:
|
||||
rec["id"] = str(rec["id"])
|
||||
rec["ingredient_count"] = counts.get(rec["id"], 0)
|
||||
rec["ingredients"] = []
|
||||
return recipes
|
||||
by_r = _ingredients_by_recipe(cur, ids)
|
||||
for rec in recipes:
|
||||
rec["id"] = str(rec["id"])
|
||||
rec["ingredients"] = by_r.get(rec["id"], [])
|
||||
rec["ingredient_count"] = len(rec["ingredients"])
|
||||
return recipes
|
||||
|
||||
|
||||
def _ingredients_by_recipe(cur, recipe_ids: list[str]) -> dict[str, list]:
|
||||
if not recipe_ids:
|
||||
return {}
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT recipe_id, source_name_raw, source_name_normalized,
|
||||
quantity_raw, quantity_g, quantity_amount, source_unit, sort_order
|
||||
FROM food_recipe_ingredients
|
||||
WHERE recipe_id = ANY(%s::uuid[])
|
||||
ORDER BY sort_order, source_name_raw
|
||||
""",
|
||||
(recipe_ids,),
|
||||
)
|
||||
by_r: dict[str, list] = {str(i): [] for i in recipe_ids}
|
||||
for row in cur.fetchall():
|
||||
by_r.setdefault(str(row["recipe_id"]), []).append(dict(row))
|
||||
return by_r
|
||||
|
||||
|
||||
def get_recipe(cur, profile_id: str, recipe_id: str) -> dict[str, Any] | None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name_raw, name_normalized, portions, description, source
|
||||
FROM food_recipes
|
||||
WHERE profile_id = %s AND id = %s
|
||||
""",
|
||||
(profile_id, recipe_id),
|
||||
)
|
||||
rec = cur.fetchone()
|
||||
if not rec:
|
||||
return None
|
||||
rec = dict(rec)
|
||||
rec["id"] = str(rec["id"])
|
||||
rec["ingredients"] = _ingredients_by_recipe(cur, [rec["id"]]).get(rec["id"], [])
|
||||
rec["ingredient_count"] = len(rec["ingredients"])
|
||||
return rec
|
||||
|
||||
|
||||
def save_recipe(cur, profile_id: str, rec: dict[str, Any], recipe_id: str | None = None) -> dict[str, Any]:
|
||||
name_raw = (rec.get("name_raw") or "").strip()
|
||||
norm = rec.get("name_normalized") or normalize_food_name(name_raw)
|
||||
if not name_raw or not norm:
|
||||
raise ValueError("Rezeptname fehlt")
|
||||
try:
|
||||
portions = float(rec.get("portions") or 1)
|
||||
except (TypeError, ValueError):
|
||||
portions = 1.0
|
||||
if portions <= 0:
|
||||
portions = 1.0
|
||||
description = rec.get("description")
|
||||
source = (rec.get("source") or "manual").strip() or "manual"
|
||||
if recipe_id:
|
||||
cur.execute(
|
||||
"SELECT id FROM food_recipes WHERE id = %s AND profile_id = %s",
|
||||
(recipe_id, profile_id),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise KeyError("Rezept nicht gefunden")
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM food_recipes
|
||||
WHERE profile_id = %s AND name_normalized = %s AND id <> %s
|
||||
""",
|
||||
(profile_id, norm, recipe_id),
|
||||
)
|
||||
if cur.fetchone():
|
||||
raise ValueError("Ein Rezept mit diesem Namen existiert bereits")
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE food_recipes
|
||||
SET name_raw=%s, name_normalized=%s, portions=%s, description=%s, updated_at=NOW()
|
||||
WHERE id=%s AND profile_id=%s
|
||||
""",
|
||||
(name_raw, norm, portions, description, recipe_id, profile_id),
|
||||
)
|
||||
cur.execute("DELETE FROM food_recipe_ingredients WHERE recipe_id = %s", (recipe_id,))
|
||||
rid = recipe_id
|
||||
else:
|
||||
cur.execute(
|
||||
"SELECT id FROM food_recipes WHERE profile_id = %s AND name_normalized = %s",
|
||||
(profile_id, norm),
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
if existing:
|
||||
return save_recipe(cur, profile_id, rec, str(existing["id"]))
|
||||
rid = str(uuid.uuid4())
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO food_recipes
|
||||
(id, profile_id, name_raw, name_normalized, portions, description, source)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
||||
""",
|
||||
(rid, profile_id, name_raw, norm, portions, description, source),
|
||||
)
|
||||
for i, ing in enumerate(rec.get("ingredients") or []):
|
||||
raw = (ing.get("source_name_raw") or "").strip()
|
||||
inorm = ing.get("source_name_normalized") or normalize_food_name(raw)
|
||||
if not inorm:
|
||||
continue
|
||||
qty = _resolved_ingredient_qty(cur, profile_id, raw or inorm, ing)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO food_recipe_ingredients
|
||||
(id, recipe_id, source_name_raw, source_name_normalized,
|
||||
quantity_raw, quantity_g, quantity_amount, source_unit, sort_order)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
""",
|
||||
(
|
||||
str(uuid.uuid4()), rid, raw or inorm, inorm,
|
||||
qty["quantity_raw"], qty["quantity_g"], qty["quantity_amount"], qty["source_unit"], i,
|
||||
),
|
||||
)
|
||||
link_recipes_to_items(cur, profile_id)
|
||||
saved = get_recipe(cur, profile_id, rid)
|
||||
if not saved:
|
||||
raise ValueError("Rezept konnte nicht gelesen werden")
|
||||
return saved
|
||||
|
||||
|
||||
def delete_recipe(cur, profile_id: str, recipe_id: str) -> None:
|
||||
cur.execute(
|
||||
"DELETE FROM food_recipes WHERE id = %s AND profile_id = %s RETURNING id",
|
||||
(recipe_id, profile_id),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise KeyError("Rezept nicht gefunden")
|
||||
|
||||
|
||||
def apply_recipe_to_items(cur, profile_id: str, source_name_normalized: str, recipe_id: str) -> int:
|
||||
target = normalize_food_name(source_name_normalized) or (source_name_normalized or "")
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, source_name_raw, source_name_normalized
|
||||
FROM nutrition_items
|
||||
WHERE profile_id = %s AND food_id IS NULL
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
ids = [
|
||||
str(row["id"])
|
||||
for row in cur.fetchall()
|
||||
if names_match_list(row.get("source_name_raw"), row.get("source_name_normalized"), target)
|
||||
]
|
||||
if not ids:
|
||||
return 0
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE nutrition_items
|
||||
SET recipe_id = %s, food_id = NULL, mapping_id = NULL, value_origin = 'fddb', updated_at = NOW()
|
||||
WHERE id = ANY(%s::uuid[])
|
||||
""",
|
||||
(recipe_id, ids),
|
||||
)
|
||||
return cur.rowcount or 0
|
||||
|
||||
|
||||
def mapped_ingredient_quantities(
|
||||
cur, profile_id: str, recipe_id: str, eaten_qty_g: float | None
|
||||
) -> list[dict[str, Any]] | None:
|
||||
cur.execute(
|
||||
"SELECT portions FROM food_recipes WHERE id = %s AND profile_id = %s",
|
||||
(recipe_id, profile_id),
|
||||
)
|
||||
rec = cur.fetchone()
|
||||
if not rec:
|
||||
return None
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT source_name_raw, quantity_g, quantity_raw, quantity_amount, source_unit
|
||||
FROM food_recipe_ingredients
|
||||
WHERE recipe_id = %s
|
||||
ORDER BY sort_order
|
||||
""",
|
||||
(recipe_id,),
|
||||
)
|
||||
ings = cur.fetchall()
|
||||
if not ings:
|
||||
return None
|
||||
resolved_ings = []
|
||||
for ing in ings:
|
||||
mapping = get_food_mapping_with_cursor(cur, ing["source_name_raw"], profile_id)
|
||||
if not mapping:
|
||||
return None
|
||||
qty = resolve_quantity(
|
||||
quantity_raw=ing.get("quantity_raw"),
|
||||
quantity_amount=ing.get("quantity_amount"),
|
||||
source_unit=ing.get("source_unit"),
|
||||
quantity_g=ing.get("quantity_g"),
|
||||
grams_per_unit=mapping.get("grams_per_unit"),
|
||||
)
|
||||
grams = _f(qty.get("quantity_g"))
|
||||
if grams <= 0:
|
||||
return None
|
||||
resolved_ings.append({"food_id": mapping["food_id"], "quantity_g": grams})
|
||||
total_g = sum(i["quantity_g"] for i in resolved_ings)
|
||||
if eaten_qty_g and eaten_qty_g > 0 and total_g > 0:
|
||||
scale = float(eaten_qty_g) / total_g
|
||||
else:
|
||||
portions = float(rec.get("portions") or 1) or 1.0
|
||||
scale = 1.0 / portions
|
||||
return [{**i, "quantity_g": i["quantity_g"] * scale} for i in resolved_ings]
|
||||
|
||||
|
||||
def catalog_macros_for_recipe(cur, profile_id: str, recipe_id: str, eaten_qty_g: float | None) -> dict[str, float] | None:
|
||||
parts = mapped_ingredient_quantities(cur, profile_id, recipe_id, eaten_qty_g)
|
||||
if not parts:
|
||||
return None
|
||||
acc = {"kcal": 0.0, "protein_g": 0.0, "fat_g": 0.0, "carbs_g": 0.0}
|
||||
for part in parts:
|
||||
cat = catalog_macros_for_item(cur, part["food_id"], part["quantity_g"])
|
||||
if not cat:
|
||||
return None
|
||||
for k in acc:
|
||||
acc[k] += cat[k]
|
||||
return acc
|
||||
319
backend/data_layer/food_suggest.py
Normal file
319
backend/data_layer/food_suggest.py
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
"""In-memory catalog suggestions: Haferflocken → Hafer Flocken, without opening a dialog."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from data_layer.food_mapping import normalize_food_name, primary_search_query
|
||||
|
||||
_INDEX_TTL_SEC = 300
|
||||
_index_cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||
MAX_CANDIDATES = 60
|
||||
MAX_BUCKET = 40
|
||||
|
||||
SPLIT_RE = re.compile(r"[^a-z0-9äöüß]+")
|
||||
COLLAPSE_RE = re.compile(r"[^a-z0-9äöüß]")
|
||||
NUM_RE = re.compile(r"\d+(?:[.,]\d+)?")
|
||||
NUM_TOKEN_RE = re.compile(r"^\d+(?:\.\d+)?$")
|
||||
MIN_SCORE = 45
|
||||
FAT_CLOSE = 0.6
|
||||
|
||||
|
||||
def collapse_key(raw: str | None) -> str:
|
||||
return COLLAPSE_RE.sub("", normalize_food_name(raw))
|
||||
|
||||
|
||||
def number_tokens(raw: str | None) -> list[str]:
|
||||
return [m.replace(",", ".") for m in NUM_RE.findall(normalize_food_name(raw))]
|
||||
|
||||
|
||||
def is_number_token(tok: str) -> bool:
|
||||
return bool(NUM_TOKEN_RE.fullmatch(tok or ""))
|
||||
|
||||
|
||||
def number_search_aliases(tok: str) -> list[str]:
|
||||
"""9.5 and 10 are the same fat class in dairy; keep both searchable."""
|
||||
if not is_number_token(tok):
|
||||
return [tok]
|
||||
aliases = {tok}
|
||||
try:
|
||||
value = float(tok)
|
||||
except ValueError:
|
||||
return [tok]
|
||||
if abs(value - 10) <= FAT_CLOSE or abs(value - 9.5) <= FAT_CLOSE:
|
||||
aliases.update({"9.5", "10"})
|
||||
if value == int(value):
|
||||
aliases.add(str(int(value)))
|
||||
return list(aliases)
|
||||
|
||||
|
||||
def numbers_compatible(query_nums: list[str], name_nums: list[str]) -> bool | None:
|
||||
if not query_nums:
|
||||
return None
|
||||
if not name_nums:
|
||||
return False
|
||||
for qn in query_nums:
|
||||
try:
|
||||
qv = float(qn)
|
||||
except ValueError:
|
||||
continue
|
||||
for nn in name_nums:
|
||||
try:
|
||||
if abs(qv - float(nn)) <= FAT_CLOSE:
|
||||
return True
|
||||
except ValueError:
|
||||
if qn == nn:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def name_tokens(raw: str | None) -> list[str]:
|
||||
text = normalize_food_name(raw)
|
||||
nums = number_tokens(text)
|
||||
words = [t for t in SPLIT_RE.split(NUM_RE.sub(" ", text)) if len(t) >= 2]
|
||||
return words + nums
|
||||
|
||||
|
||||
def score_name_match(query: str, name_de: str, name_en: str | None = None) -> int:
|
||||
qn = normalize_food_name(primary_search_query(query))
|
||||
nn = normalize_food_name(name_de)
|
||||
if not qn or not nn:
|
||||
return 0
|
||||
qc, nc = collapse_key(qn), collapse_key(nn)
|
||||
q_nums, n_nums = number_tokens(qn), number_tokens(nn)
|
||||
fat_ok = numbers_compatible(q_nums, n_nums)
|
||||
if qn == nn:
|
||||
return 100
|
||||
if qc and qc == nc:
|
||||
return 95
|
||||
qt, nt = set(name_tokens(qn)), set(name_tokens(nn))
|
||||
q_words = {t for t in qt if not is_number_token(t)}
|
||||
n_words = {t for t in nt if not is_number_token(t)}
|
||||
words_overlap = bool(q_words and n_words and (q_words & n_words or q_words <= n_words))
|
||||
if fat_ok and words_overlap:
|
||||
return 96
|
||||
if fat_ok is False:
|
||||
base = 0
|
||||
if qc and nc.startswith(qc) and len(qc) >= 4:
|
||||
base = 82
|
||||
elif nc and qc.startswith(nc) and len(nc) >= 4:
|
||||
base = 78
|
||||
elif qt and qt <= nt:
|
||||
base = 72
|
||||
elif nt and nt <= qt:
|
||||
base = 68
|
||||
elif qt and nt:
|
||||
overlap = len(qt & nt) / len(qt | nt)
|
||||
if overlap >= 0.5:
|
||||
base = 50 + int(overlap * 20)
|
||||
elif qc and nc and len(qc) >= 4 and (qc in nc or nc in qc):
|
||||
base = 55 if abs(len(qc) - len(nc)) <= 8 else 46
|
||||
return min(base, 52) if base else 0
|
||||
if qc and nc.startswith(qc) and len(qc) >= 4:
|
||||
return 82
|
||||
if nc and qc.startswith(nc) and len(nc) >= 4:
|
||||
return 78
|
||||
if qt and qt <= nt:
|
||||
return 72
|
||||
if nt and nt <= qt:
|
||||
return 68
|
||||
if qt and nt:
|
||||
overlap = len(qt & nt) / len(qt | nt)
|
||||
if overlap >= 0.5:
|
||||
return 50 + int(overlap * 20)
|
||||
if qc and nc and len(qc) >= 4 and (qc in nc or nc in qc):
|
||||
return 55 if abs(len(qc) - len(nc)) <= 8 else 46
|
||||
en = normalize_food_name(name_en or "")
|
||||
if en and (qn == en or collapse_key(en) == qc):
|
||||
return 88
|
||||
return 0
|
||||
|
||||
|
||||
def _public(food: dict[str, Any], score: int) -> dict[str, Any]:
|
||||
from data_layer.food_visibility import visibility_of
|
||||
|
||||
return {
|
||||
"id": str(food["id"]),
|
||||
"bls_code": food.get("bls_code"),
|
||||
"name_de": food.get("name_de"),
|
||||
"name_en": food.get("name_en"),
|
||||
"catalog_kind": food.get("catalog_kind"),
|
||||
"food_group": food.get("food_group"),
|
||||
"visibility": visibility_of(food),
|
||||
"score": score,
|
||||
}
|
||||
|
||||
|
||||
def invalidate_suggest_index(profile_id: str | None = None) -> None:
|
||||
if profile_id is None:
|
||||
_index_cache.clear()
|
||||
return
|
||||
_index_cache.pop(str(profile_id), None)
|
||||
_index_cache.pop("global", None)
|
||||
|
||||
|
||||
def get_suggest_index(cur, profile_id: str | None) -> dict[str, Any]:
|
||||
key = str(profile_id or "global")
|
||||
hit = _index_cache.get(key)
|
||||
if hit and (time.monotonic() - hit[0]) < _INDEX_TTL_SEC:
|
||||
return hit[1]
|
||||
index = load_suggest_index(cur, profile_id)
|
||||
_index_cache[key] = (time.monotonic(), index)
|
||||
return index
|
||||
|
||||
|
||||
def load_suggest_index(cur, profile_id: str | None) -> dict[str, Any]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, bls_code, name_de, name_en, catalog_kind, food_group, owner_profile_id
|
||||
FROM food_catalog
|
||||
WHERE is_active = true
|
||||
AND (owner_profile_id IS NULL OR owner_profile_id = %s)
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
foods = [dict(r) for r in cur.fetchall()]
|
||||
by_collapse: dict[str, list] = {}
|
||||
by_token: dict[str, list] = {}
|
||||
by_prefix: dict[str, list] = {}
|
||||
by_suffix: dict[str, list] = {}
|
||||
for food in foods:
|
||||
food["_c"] = collapse_key(food.get("name_de"))
|
||||
food["_t"] = name_tokens(food.get("name_de"))
|
||||
if food["_c"]:
|
||||
by_collapse.setdefault(food["_c"], []).append(food)
|
||||
by_prefix.setdefault(food["_c"][:4], []).append(food)
|
||||
if len(food["_c"]) >= 4:
|
||||
by_suffix.setdefault(food["_c"][-4:], []).append(food)
|
||||
for tok in food["_t"]:
|
||||
by_token.setdefault(tok, []).append(food)
|
||||
return {
|
||||
"foods": foods,
|
||||
"by_collapse": by_collapse,
|
||||
"by_token": by_token,
|
||||
"by_prefix": by_prefix,
|
||||
"by_suffix": by_suffix,
|
||||
}
|
||||
|
||||
|
||||
def _candidate_foods(index: dict[str, Any], query: str) -> list[dict[str, Any]]:
|
||||
qc = collapse_key(query)
|
||||
seen: set[str] = set()
|
||||
out: list[dict[str, Any]] = []
|
||||
|
||||
def add(food: dict[str, Any]) -> None:
|
||||
fid = str(food["id"])
|
||||
if fid in seen:
|
||||
return
|
||||
seen.add(fid)
|
||||
out.append(food)
|
||||
|
||||
if qc:
|
||||
for food in index["by_collapse"].get(qc, []):
|
||||
add(food)
|
||||
if len(qc) >= 4:
|
||||
prefix_hits = index["by_prefix"].get(qc[:4], [])
|
||||
if len(prefix_hits) > MAX_BUCKET:
|
||||
prefix_hits = [
|
||||
food for food in prefix_hits
|
||||
if (food.get("_c") or "").startswith(qc) or qc.startswith(food.get("_c") or "")
|
||||
]
|
||||
for food in prefix_hits[:MAX_CANDIDATES]:
|
||||
add(food)
|
||||
suffix_hits = index["by_suffix"].get(qc[-4:], [])
|
||||
if len(suffix_hits) <= MAX_BUCKET:
|
||||
for food in suffix_hits:
|
||||
add(food)
|
||||
q_words = [t for t in name_tokens(query) if not is_number_token(t)]
|
||||
seen_tok: set[str] = set()
|
||||
for tok in name_tokens(query):
|
||||
probes = number_search_aliases(tok) if is_number_token(tok) else [tok]
|
||||
for probe in probes:
|
||||
if probe in seen_tok:
|
||||
continue
|
||||
seen_tok.add(probe)
|
||||
token_hits = index["by_token"].get(probe, [])
|
||||
if is_number_token(probe) and q_words:
|
||||
token_hits = [
|
||||
food for food in token_hits
|
||||
if set(q_words) & set(food.get("_t") or [])
|
||||
]
|
||||
elif not is_number_token(probe) and len(token_hits) > MAX_BUCKET:
|
||||
token_hits = sorted(token_hits, key=lambda f: len(f.get("name_de") or ""))[:MAX_BUCKET]
|
||||
for food in token_hits:
|
||||
add(food)
|
||||
if len(out) >= MAX_CANDIDATES and not any(is_number_token(t) for t in name_tokens(query)):
|
||||
break
|
||||
return out[:MAX_CANDIDATES] if not number_tokens(query) else out
|
||||
|
||||
|
||||
def _vis_rank(food: dict[str, Any]) -> int:
|
||||
from data_layer.food_visibility import visibility_rank
|
||||
return visibility_rank(food)
|
||||
|
||||
|
||||
def suggest_for_name(index: dict[str, Any], query: str, limit: int = 3) -> dict[str, Any]:
|
||||
q = (query or "").strip()
|
||||
scored: list[tuple[int, int, dict]] = []
|
||||
for food in _candidate_foods(index, q):
|
||||
score = score_name_match(q, food.get("name_de") or "", food.get("name_en"))
|
||||
if score < MIN_SCORE:
|
||||
continue
|
||||
scored.append((score, len(food.get("name_de") or ""), food))
|
||||
scored.sort(key=lambda x: (-x[0], _vis_rank(x[2]), x[1], x[2].get("name_de") or ""))
|
||||
top = [_public(food, score) for score, _nlen, food in scored[: max(limit, 3)]]
|
||||
ambiguous = False
|
||||
if len(top) >= 2 and top[0]["score"] - top[1]["score"] <= 8 and top[1]["score"] >= 60:
|
||||
ambiguous = True
|
||||
elif len(top) >= 2 and top[0]["score"] < 90:
|
||||
ambiguous = True
|
||||
return {
|
||||
"suggestions": top[:limit],
|
||||
"suggestion_count": len(top),
|
||||
"ambiguous": ambiguous,
|
||||
}
|
||||
|
||||
|
||||
def attach_suggestions(index: dict[str, Any], rows: list[dict[str, Any]], limit: int = 3) -> list[dict[str, Any]]:
|
||||
for row in rows:
|
||||
q = row.get("source_name_raw") or row.get("source_name_normalized") or ""
|
||||
packed = suggest_for_name(index, q, limit=limit)
|
||||
row["suggestions"] = packed["suggestions"]
|
||||
row["suggestion_count"] = packed["suggestion_count"]
|
||||
row["ambiguous"] = packed["ambiguous"]
|
||||
return rows
|
||||
|
||||
|
||||
def suggest_catalog_foods_ranked(cur, query: str, profile_id: str | None, limit: int = 8) -> list[dict]:
|
||||
q = (query or "").strip()
|
||||
if len(q) < 2:
|
||||
return []
|
||||
index = get_suggest_index(cur, profile_id)
|
||||
packed = suggest_for_name(index, q, limit=max(limit, 8))
|
||||
hits = list(packed["suggestions"])
|
||||
from data_layer.food_mapping import suggest_catalog_foods
|
||||
if number_tokens(q) or not hits:
|
||||
seen = {str(h["id"]) for h in hits}
|
||||
for row in suggest_catalog_foods(cur, q, profile_id, limit=max(limit, 20)):
|
||||
fid = str(row["id"])
|
||||
if fid in seen:
|
||||
continue
|
||||
seen.add(fid)
|
||||
score = score_name_match(q, row.get("name_de") or "", row.get("name_en"))
|
||||
if score < MIN_SCORE and not number_tokens(q):
|
||||
continue
|
||||
hits.append(_public(row, score or 40))
|
||||
hits.sort(key=lambda h: (-int(h.get("score") or 0), _vis_rank(h), h.get("name_de") or ""))
|
||||
return hits[:limit]
|
||||
|
||||
|
||||
def suggest_batch(cur, profile_id: str | None, names: list[str], limit: int = 3) -> dict[str, dict[str, Any]]:
|
||||
index = get_suggest_index(cur, profile_id)
|
||||
out = {}
|
||||
for raw in names[:100]:
|
||||
key = (raw or "").strip()
|
||||
if not key or key in out:
|
||||
continue
|
||||
out[key] = suggest_for_name(index, key, limit=limit)
|
||||
return out
|
||||
121
backend/data_layer/food_visibility.py
Normal file
121
backend/data_layer/food_visibility.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""Who may see and map a catalog food.
|
||||
|
||||
Scopes:
|
||||
- personal — ``manual_user`` with ``owner_profile_id`` (only that profile)
|
||||
- global — ``manual_admin`` (all users; no owner)
|
||||
- bls — official BLS (all users; no owner)
|
||||
|
||||
Name-mappings: a *user* mapping may target any food visible to that user.
|
||||
A *global* mapping (``profile_id IS NULL``) may only target global/BLS foods,
|
||||
never another user's private entry.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
VIS_PERSONAL = "personal"
|
||||
VIS_GLOBAL = "global"
|
||||
VIS_BLS = "bls"
|
||||
|
||||
SQL_VISIBLE_TO_PROFILE = "(owner_profile_id IS NULL OR owner_profile_id = %s)"
|
||||
SQL_GLOBAL_ONLY = "owner_profile_id IS NULL"
|
||||
|
||||
_KIND_VIS = {
|
||||
"official_bls": VIS_BLS,
|
||||
"manual_admin": VIS_GLOBAL,
|
||||
"manual_user": VIS_PERSONAL,
|
||||
}
|
||||
|
||||
_VIS_RANK = {VIS_PERSONAL: 0, VIS_GLOBAL: 1, VIS_BLS: 2}
|
||||
|
||||
|
||||
def same_id(a, b) -> bool:
|
||||
return str(a or "").replace("-", "").lower() == str(b or "").replace("-", "").lower()
|
||||
|
||||
|
||||
def visibility_of(food: dict[str, Any] | None) -> str:
|
||||
if not food:
|
||||
return VIS_BLS
|
||||
kind = food.get("catalog_kind")
|
||||
if kind in _KIND_VIS:
|
||||
return _KIND_VIS[kind]
|
||||
if food.get("owner_profile_id"):
|
||||
return VIS_PERSONAL
|
||||
return VIS_GLOBAL
|
||||
|
||||
|
||||
def visibility_rank(food: dict[str, Any] | None) -> int:
|
||||
return _VIS_RANK.get(visibility_of(food), 3)
|
||||
|
||||
|
||||
def is_active(food: dict[str, Any] | None) -> bool:
|
||||
if not food:
|
||||
return False
|
||||
if "is_active" not in food:
|
||||
return True
|
||||
return bool(food.get("is_active"))
|
||||
|
||||
|
||||
def is_visible_to(food: dict[str, Any] | None, profile_id: str | None) -> bool:
|
||||
if not is_active(food):
|
||||
return False
|
||||
owner = food.get("owner_profile_id")
|
||||
if owner is None:
|
||||
return True
|
||||
return profile_id is not None and same_id(owner, profile_id)
|
||||
|
||||
|
||||
def is_global_mapping_target(food: dict[str, Any] | None) -> bool:
|
||||
return is_active(food) and food.get("owner_profile_id") is None
|
||||
|
||||
|
||||
def load_catalog_food(cur, food_id: str) -> dict[str, Any] | None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name_de, catalog_kind, owner_profile_id, is_active, bls_code
|
||||
FROM food_catalog
|
||||
WHERE id = %s
|
||||
""",
|
||||
(food_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def require_visible_food(cur, food_id: str, profile_id: str) -> dict[str, Any]:
|
||||
food = load_catalog_food(cur, food_id)
|
||||
if not is_visible_to(food, profile_id):
|
||||
raise LookupError("Lebensmittel nicht gefunden")
|
||||
return food
|
||||
|
||||
|
||||
def require_global_mapping_target(cur, food_id: str) -> dict[str, Any]:
|
||||
food = load_catalog_food(cur, food_id)
|
||||
if not food or not is_active(food):
|
||||
raise LookupError("Lebensmittel nicht gefunden")
|
||||
if not is_global_mapping_target(food):
|
||||
raise PermissionError("Globales Mapping darf nur auf zentrale oder BLS-Lebensmittel zeigen")
|
||||
return food
|
||||
|
||||
|
||||
def require_mapping_target(cur, food_id: str, mapping_profile_id: str | None) -> dict[str, Any]:
|
||||
if mapping_profile_id:
|
||||
return require_visible_food(cur, food_id, mapping_profile_id)
|
||||
return require_global_mapping_target(cur, food_id)
|
||||
|
||||
|
||||
def can_edit_food(food: dict[str, Any] | None, profile_id: str | None, *, is_admin: bool = False) -> bool:
|
||||
"""Owner ändert persönlich; Admin ändert zentral (und im Admin-Katalog auch fremde Manuals)."""
|
||||
if not is_active(food):
|
||||
return False
|
||||
kind = food.get("catalog_kind")
|
||||
if kind == "manual_user":
|
||||
return same_id(food.get("owner_profile_id"), profile_id) or bool(is_admin)
|
||||
if kind == "manual_admin":
|
||||
return bool(is_admin)
|
||||
return False
|
||||
|
||||
|
||||
def attach_visibility(row: dict[str, Any]) -> dict[str, Any]:
|
||||
row["visibility"] = visibility_of(row)
|
||||
return row
|
||||
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
|
||||
531
backend/data_layer/nutrition_items.py
Normal file
531
backend/data_layer/nutrition_items.py
Normal file
|
|
@ -0,0 +1,531 @@
|
|||
"""Nutrition diary items, three macro sums, import policy, attribute resolve."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from data_layer.food_mapping import (
|
||||
get_food_mapping_with_cursor,
|
||||
normalize_food_name,
|
||||
parse_quantity_g,
|
||||
)
|
||||
|
||||
MACRO_ATTR_KEYS = {
|
||||
"kcal": "ENERCC",
|
||||
"protein_g": "PROT625",
|
||||
"fat_g": "FAT",
|
||||
"carbs_g": "CHO",
|
||||
}
|
||||
|
||||
POLICIES = frozenset({"prompt", "overwrite_catalog", "overwrite_fddb", "keep_existing"})
|
||||
|
||||
|
||||
def _f(v: Any) -> float:
|
||||
if v is None or v == "":
|
||||
return 0.0
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _round_macros(d: dict[str, float]) -> dict[str, float]:
|
||||
return {
|
||||
"kcal": round(_f(d.get("kcal")), 1),
|
||||
"protein_g": round(_f(d.get("protein_g")), 1),
|
||||
"fat_g": round(_f(d.get("fat_g")), 1),
|
||||
"carbs_g": round(_f(d.get("carbs_g")), 1),
|
||||
}
|
||||
|
||||
|
||||
def macros_differ(a: dict[str, float], b: dict[str, float]) -> bool:
|
||||
aa, bb = _round_macros(a), _round_macros(b)
|
||||
return any(aa[k] != bb[k] for k in aa)
|
||||
|
||||
|
||||
def get_import_policy(cur, profile_id: str) -> str:
|
||||
cur.execute(
|
||||
"SELECT nutrition_import_conflict_policy FROM profiles WHERE id = %s",
|
||||
(profile_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return "prompt"
|
||||
pol = row.get("nutrition_import_conflict_policy") or "prompt"
|
||||
return pol if pol in POLICIES else "prompt"
|
||||
|
||||
|
||||
def catalog_macros_for_item(cur, food_id: str | None, quantity_g: float | None) -> dict[str, float] | None:
|
||||
if not food_id or quantity_g is None or quantity_g <= 0:
|
||||
return None
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT a.attr_key, v.value_num, v.is_trace
|
||||
FROM food_attribute_values v
|
||||
JOIN food_attributes a ON a.id = v.attribute_id
|
||||
WHERE v.food_id = %s AND a.attr_key = ANY(%s) AND a.data_type = 'num_per_100g'
|
||||
""",
|
||||
(food_id, list(MACRO_ATTR_KEYS.values())),
|
||||
)
|
||||
by_key = {r["attr_key"]: r for r in cur.fetchall()}
|
||||
if not by_key:
|
||||
return None
|
||||
out = {}
|
||||
factor = float(quantity_g) / 100.0
|
||||
missing = False
|
||||
for field, key in MACRO_ATTR_KEYS.items():
|
||||
row = by_key.get(key)
|
||||
if not row or row.get("is_trace") or row.get("value_num") is None:
|
||||
missing = True
|
||||
break
|
||||
out[field] = float(row["value_num"]) * factor
|
||||
return None if missing else out
|
||||
|
||||
|
||||
def compute_day_macro_sums(cur, profile_id: str, day: date | str) -> dict[str, Any]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT kcal, protein_g, fat_g, carbs_g, macro_origin, has_items
|
||||
FROM nutrition_log WHERE profile_id = %s AND date = %s
|
||||
""",
|
||||
(profile_id, day),
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
existing_macros = (
|
||||
_round_macros(existing)
|
||||
if existing
|
||||
else None
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT food_id, recipe_id, quantity_g, fddb_kcal, fddb_protein_g, fddb_fat_g, fddb_carbs_g, value_origin
|
||||
FROM nutrition_items
|
||||
WHERE profile_id = %s AND date = %s
|
||||
""",
|
||||
(profile_id, day),
|
||||
)
|
||||
items = cur.fetchall()
|
||||
fddb = {"kcal": 0.0, "protein_g": 0.0, "fat_g": 0.0, "carbs_g": 0.0}
|
||||
catalog = {"kcal": 0.0, "protein_g": 0.0, "fat_g": 0.0, "carbs_g": 0.0}
|
||||
mapped = unmapped = 0
|
||||
used_bls = used_fddb = False
|
||||
for it in items:
|
||||
fddb["kcal"] += _f(it.get("fddb_kcal"))
|
||||
fddb["protein_g"] += _f(it.get("fddb_protein_g"))
|
||||
fddb["fat_g"] += _f(it.get("fddb_fat_g"))
|
||||
fddb["carbs_g"] += _f(it.get("fddb_carbs_g"))
|
||||
if it.get("recipe_id") and not it.get("food_id"):
|
||||
from data_layer.food_recipes import catalog_macros_for_recipe
|
||||
cat = catalog_macros_for_recipe(cur, profile_id, it["recipe_id"], it.get("quantity_g"))
|
||||
else:
|
||||
cat = catalog_macros_for_item(cur, it.get("food_id"), it.get("quantity_g"))
|
||||
if cat:
|
||||
mapped += 1
|
||||
used_bls = True
|
||||
for k in catalog:
|
||||
catalog[k] += cat[k]
|
||||
else:
|
||||
unmapped += 1
|
||||
used_fddb = True
|
||||
catalog["kcal"] += _f(it.get("fddb_kcal"))
|
||||
catalog["protein_g"] += _f(it.get("fddb_protein_g"))
|
||||
catalog["fat_g"] += _f(it.get("fddb_fat_g"))
|
||||
catalog["carbs_g"] += _f(it.get("fddb_carbs_g"))
|
||||
origin = "mixed"
|
||||
if used_bls and not used_fddb:
|
||||
origin = "bls"
|
||||
elif used_fddb and not used_bls:
|
||||
origin = "fddb"
|
||||
if not items:
|
||||
origin = "manual"
|
||||
return {
|
||||
"existing": existing_macros,
|
||||
"fddb": _round_macros(fddb) if items else None,
|
||||
"catalog": _round_macros(catalog) if items else None,
|
||||
"mapped_item_count": mapped,
|
||||
"unmapped_item_count": unmapped,
|
||||
"has_items": bool(items),
|
||||
"catalog_origin": origin,
|
||||
"has_log": existing is not None,
|
||||
"macro_origin": existing["macro_origin"] if existing else None,
|
||||
}
|
||||
|
||||
|
||||
def apply_nutrition_day_macros(
|
||||
cur,
|
||||
profile_id: str,
|
||||
day: date | str,
|
||||
macros: dict[str, float],
|
||||
*,
|
||||
macro_origin: str,
|
||||
source: str = "csv",
|
||||
confirm: bool = False,
|
||||
) -> str:
|
||||
m = _round_macros(macros)
|
||||
cur.execute("SELECT id FROM nutrition_log WHERE profile_id = %s AND date = %s", (profile_id, day))
|
||||
row = cur.fetchone()
|
||||
counts = compute_day_macro_sums(cur, profile_id, day)
|
||||
extra = (
|
||||
counts["mapped_item_count"],
|
||||
counts["unmapped_item_count"],
|
||||
counts["has_items"],
|
||||
)
|
||||
confirmed = datetime.utcnow() if confirm else None
|
||||
if row:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE nutrition_log
|
||||
SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s, source=%s,
|
||||
macro_origin=%s, mapped_item_count=%s, unmapped_item_count=%s,
|
||||
has_items=%s, last_import_at=NOW(), macros_confirmed_at=COALESCE(%s, macros_confirmed_at)
|
||||
WHERE profile_id=%s AND date=%s
|
||||
""",
|
||||
(
|
||||
m["kcal"], m["protein_g"], m["fat_g"], m["carbs_g"], source,
|
||||
macro_origin, extra[0], extra[1], extra[2], confirmed, profile_id, day,
|
||||
),
|
||||
)
|
||||
return "updated"
|
||||
eid = str(uuid.uuid4())
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO nutrition_log (
|
||||
id, profile_id, date, kcal, protein_g, fat_g, carbs_g, source,
|
||||
macro_origin, mapped_item_count, unmapped_item_count, has_items,
|
||||
last_import_at, macros_confirmed_at, created
|
||||
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,NOW(),%s,CURRENT_TIMESTAMP)
|
||||
""",
|
||||
(
|
||||
eid, profile_id, day, m["kcal"], m["protein_g"], m["fat_g"], m["carbs_g"], source,
|
||||
macro_origin, extra[0], extra[1], extra[2], confirmed,
|
||||
),
|
||||
)
|
||||
return "created"
|
||||
|
||||
|
||||
def _accumulate_food_qty(cur, acc: dict[int, list[float]], food_id: str, quantity_g: float) -> None:
|
||||
if not food_id or not quantity_g or quantity_g <= 0:
|
||||
return
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT v.attribute_id, v.value_num, v.is_trace, a.data_type
|
||||
FROM food_attribute_values v
|
||||
JOIN food_attributes a ON a.id = v.attribute_id
|
||||
WHERE v.food_id = %s AND a.data_type = 'num_per_100g'
|
||||
AND v.value_num IS NOT NULL AND v.is_trace = false
|
||||
""",
|
||||
(food_id,),
|
||||
)
|
||||
factor = float(quantity_g) / 100.0
|
||||
for row in cur.fetchall():
|
||||
acc.setdefault(row["attribute_id"], []).append(float(row["value_num"]) * factor)
|
||||
|
||||
|
||||
def schedule_daily_nutrient_rebuild(profile_id: str, dates: list[str], context: str) -> None:
|
||||
days = [str(d) for d in (dates or []) if d]
|
||||
if not days:
|
||||
return
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
from db import get_cursor, get_db
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
for day in days:
|
||||
rebuild_daily_nutrients(cur, profile_id, day)
|
||||
except Exception:
|
||||
logger.exception("Nährwert-Rebuild nach %s fehlgeschlagen", context)
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name="nutrition-rebuild").start()
|
||||
|
||||
|
||||
def rebuild_daily_nutrients(cur, profile_id: str, day: date | str) -> None:
|
||||
cur.execute(
|
||||
"DELETE FROM nutrition_daily_nutrients WHERE profile_id = %s AND date = %s",
|
||||
(profile_id, day),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT food_id, recipe_id, quantity_g
|
||||
FROM nutrition_items
|
||||
WHERE profile_id = %s AND date = %s
|
||||
""",
|
||||
(profile_id, day),
|
||||
)
|
||||
acc: dict[int, list[float]] = {}
|
||||
from data_layer.food_recipes import mapped_ingredient_quantities
|
||||
for it in cur.fetchall():
|
||||
if it.get("food_id"):
|
||||
_accumulate_food_qty(cur, acc, it["food_id"], _f(it.get("quantity_g")))
|
||||
continue
|
||||
if it.get("recipe_id"):
|
||||
parts = mapped_ingredient_quantities(cur, profile_id, it["recipe_id"], it.get("quantity_g"))
|
||||
if not parts:
|
||||
continue
|
||||
for part in parts:
|
||||
_accumulate_food_qty(cur, acc, part["food_id"], part["quantity_g"])
|
||||
for attr_id, vals in acc.items():
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO nutrition_daily_nutrients
|
||||
(profile_id, date, attribute_id, value, contributing_item_count, updated_at)
|
||||
VALUES (%s, %s, %s, %s, %s, NOW())
|
||||
""",
|
||||
(profile_id, day, attr_id, round(sum(vals), 6), len(vals)),
|
||||
)
|
||||
|
||||
|
||||
def _item_value_origin(mapping: dict | None) -> str:
|
||||
if not mapping:
|
||||
return "fddb"
|
||||
kind = mapping.get("catalog_kind")
|
||||
if kind == "official_bls":
|
||||
return "bls"
|
||||
if kind in ("manual_admin", "manual_user"):
|
||||
return "manual_catalog"
|
||||
return "fddb"
|
||||
|
||||
|
||||
def replace_csv_items_for_dates(
|
||||
cur,
|
||||
profile_id: str,
|
||||
rows: list[dict[str, Any]],
|
||||
*,
|
||||
policy: str,
|
||||
policy_override: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Replace csv-sourced items for the dates present in rows.
|
||||
Returns conflicts when policy is prompt and existing macros differ.
|
||||
"""
|
||||
effective = policy_override if policy_override in POLICIES else policy
|
||||
by_date: dict[str, list[dict]] = {}
|
||||
for row in rows:
|
||||
d = row.get("date")
|
||||
if hasattr(d, "isoformat"):
|
||||
iso = d.isoformat()
|
||||
else:
|
||||
iso = str(d)[:10]
|
||||
if not iso:
|
||||
continue
|
||||
by_date.setdefault(iso, []).append(row)
|
||||
|
||||
conflicts = []
|
||||
days_written = 0
|
||||
items_written = 0
|
||||
new_log_days = 0
|
||||
|
||||
for iso, day_rows in by_date.items():
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM nutrition_items
|
||||
WHERE profile_id = %s AND date = %s AND source = 'csv'
|
||||
""",
|
||||
(profile_id, iso),
|
||||
)
|
||||
for raw in day_rows:
|
||||
name = (raw.get("food_name") or raw.get("source_name_raw") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
qty_raw = raw.get("quantity_raw")
|
||||
qty_g = parse_quantity_g(
|
||||
qty_raw if qty_raw is not None else name,
|
||||
mapping.get("grams_per_unit") if mapping else None,
|
||||
)
|
||||
mapping = get_food_mapping_with_cursor(cur, name, profile_id)
|
||||
logged_at = raw.get("logged_at")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO nutrition_items (
|
||||
id, profile_id, date, logged_at, source_name_raw, source_name_normalized,
|
||||
source_system, quantity_raw, quantity_g,
|
||||
fddb_kcal, fddb_protein_g, fddb_fat_g, fddb_carbs_g,
|
||||
food_id, mapping_id, value_origin, source, recipe_id
|
||||
) VALUES (
|
||||
%s,%s,%s,%s,%s,%s,'fddb',%s,%s,%s,%s,%s,%s,%s,%s,%s,'csv',%s
|
||||
)
|
||||
""",
|
||||
(
|
||||
str(uuid.uuid4()),
|
||||
profile_id,
|
||||
iso,
|
||||
logged_at,
|
||||
name,
|
||||
normalize_food_name(name),
|
||||
str(qty_raw) if qty_raw is not None else None,
|
||||
qty_g,
|
||||
_f(raw.get("fddb_kcal") if raw.get("fddb_kcal") is not None else raw.get("kcal")),
|
||||
_f(raw.get("fddb_protein_g") if raw.get("fddb_protein_g") is not None else raw.get("protein_g")),
|
||||
_f(raw.get("fddb_fat_g") if raw.get("fddb_fat_g") is not None else raw.get("fat_g")),
|
||||
_f(raw.get("fddb_carbs_g") if raw.get("fddb_carbs_g") is not None else raw.get("carbs_g")),
|
||||
mapping["food_id"] if mapping else None,
|
||||
mapping["mapping_id"] if mapping else None,
|
||||
_item_value_origin(mapping),
|
||||
None,
|
||||
),
|
||||
)
|
||||
items_written += 1
|
||||
from data_layer.food_recipes import link_recipes_to_items
|
||||
link_recipes_to_items(cur, profile_id)
|
||||
days_written += 1
|
||||
sums = compute_day_macro_sums(cur, profile_id, iso)
|
||||
rebuild_daily_nutrients(cur, profile_id, iso)
|
||||
if not sums["has_items"]:
|
||||
continue
|
||||
if not sums["has_log"]:
|
||||
apply_nutrition_day_macros(
|
||||
cur, profile_id, iso, sums["catalog"],
|
||||
macro_origin=sums["catalog_origin"], source="csv",
|
||||
)
|
||||
new_log_days += 1
|
||||
continue
|
||||
existing = sums["existing"]
|
||||
catalog = sums["catalog"]
|
||||
fddb = sums["fddb"]
|
||||
differ = macros_differ(existing, catalog) or macros_differ(existing, fddb)
|
||||
if effective == "keep_existing":
|
||||
_touch_item_counts(cur, profile_id, iso, sums)
|
||||
continue
|
||||
if effective == "overwrite_catalog":
|
||||
apply_nutrition_day_macros(
|
||||
cur, profile_id, iso, catalog,
|
||||
macro_origin=sums["catalog_origin"], source="csv",
|
||||
)
|
||||
continue
|
||||
if effective == "overwrite_fddb":
|
||||
apply_nutrition_day_macros(
|
||||
cur, profile_id, iso, fddb, macro_origin="fddb", source="csv",
|
||||
)
|
||||
continue
|
||||
# prompt
|
||||
if differ:
|
||||
conflicts.append({
|
||||
"date": iso,
|
||||
"existing": existing,
|
||||
"fddb": fddb,
|
||||
"catalog": catalog,
|
||||
"catalog_origin": sums["catalog_origin"],
|
||||
"mapped_item_count": sums["mapped_item_count"],
|
||||
"unmapped_item_count": sums["unmapped_item_count"],
|
||||
})
|
||||
else:
|
||||
apply_nutrition_day_macros(
|
||||
cur, profile_id, iso, catalog,
|
||||
macro_origin=sums["catalog_origin"], source="csv",
|
||||
)
|
||||
|
||||
return {
|
||||
"days_written": days_written,
|
||||
"items_written": items_written,
|
||||
"new_log_days": new_log_days,
|
||||
"conflicts": conflicts,
|
||||
"policy": effective,
|
||||
}
|
||||
|
||||
|
||||
def _touch_item_counts(cur, profile_id: str, day: str, sums: dict) -> None:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE nutrition_log
|
||||
SET mapped_item_count=%s, unmapped_item_count=%s, has_items=%s, last_import_at=NOW()
|
||||
WHERE profile_id=%s AND date=%s
|
||||
""",
|
||||
(
|
||||
sums["mapped_item_count"],
|
||||
sums["unmapped_item_count"],
|
||||
sums["has_items"],
|
||||
profile_id,
|
||||
day,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def resolve_choice_macros(sums: dict[str, Any], choice: str) -> tuple[dict[str, float], str]:
|
||||
if choice == "existing":
|
||||
return sums["existing"], "user_confirmed"
|
||||
if choice == "fddb":
|
||||
return sums["fddb"], "fddb"
|
||||
if choice == "catalog":
|
||||
return sums["catalog"], sums.get("catalog_origin") or "mixed"
|
||||
raise ValueError("Ungültige Wahl (existing|fddb|catalog)")
|
||||
|
||||
|
||||
def resolve_food_attributes(cur, food_id: str) -> list[dict]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT a.attr_key, a.name_de, a.unit, a.category, a.data_type, a.origin AS attr_origin,
|
||||
v.value_num, v.value_bool, v.value_text, v.is_trace, v.origin_code
|
||||
FROM food_attributes a
|
||||
LEFT JOIN food_attribute_values v
|
||||
ON v.attribute_id = a.id AND v.food_id = %s
|
||||
WHERE a.is_active = true
|
||||
ORDER BY a.sort_order, a.attr_key
|
||||
""",
|
||||
(food_id,),
|
||||
)
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
def dates_for_normalized_name(cur, profile_id: str, source_name_normalized: str) -> list[str]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT date::text AS date
|
||||
FROM nutrition_items
|
||||
WHERE profile_id = %s AND source_name_normalized = %s
|
||||
UNION
|
||||
SELECT DISTINCT i.date::text AS date
|
||||
FROM nutrition_items i
|
||||
JOIN food_recipe_ingredients ri ON ri.recipe_id = i.recipe_id
|
||||
WHERE i.profile_id = %s AND ri.source_name_normalized = %s
|
||||
""",
|
||||
(profile_id, source_name_normalized, profile_id, source_name_normalized),
|
||||
)
|
||||
return [r["date"] for r in cur.fetchall()]
|
||||
|
||||
|
||||
def dates_using_food(cur, profile_id: str, food_id: str) -> list[str]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT date::text AS date
|
||||
FROM nutrition_items
|
||||
WHERE profile_id = %s AND food_id = %s
|
||||
UNION
|
||||
SELECT DISTINCT i.date::text AS date
|
||||
FROM nutrition_items i
|
||||
JOIN food_recipe_ingredients ri ON ri.recipe_id = i.recipe_id
|
||||
JOIN food_name_mappings m
|
||||
ON m.profile_id = i.profile_id
|
||||
AND m.source_name_normalized = ri.source_name_normalized
|
||||
AND m.food_id = %s
|
||||
WHERE i.profile_id = %s AND i.recipe_id IS NOT NULL
|
||||
""",
|
||||
(profile_id, food_id, food_id, profile_id),
|
||||
)
|
||||
return [r["date"] for r in cur.fetchall() if r.get("date")]
|
||||
|
||||
|
||||
def profiles_dates_using_food(cur, food_id: str) -> list[tuple[str, str]]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT profile_id::text AS profile_id, date::text AS date
|
||||
FROM nutrition_items
|
||||
WHERE food_id = %s
|
||||
UNION
|
||||
SELECT DISTINCT i.profile_id::text AS profile_id, i.date::text AS date
|
||||
FROM nutrition_items i
|
||||
JOIN food_recipe_ingredients ri ON ri.recipe_id = i.recipe_id
|
||||
JOIN food_name_mappings m
|
||||
ON m.profile_id = i.profile_id
|
||||
AND m.source_name_normalized = ri.source_name_normalized
|
||||
AND m.food_id = %s
|
||||
WHERE i.recipe_id IS NOT NULL
|
||||
""",
|
||||
(food_id, food_id),
|
||||
)
|
||||
return [(r["profile_id"], r["date"]) for r in cur.fetchall() if r.get("profile_id") and r.get("date")]
|
||||
|
|
@ -20,6 +20,7 @@ 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
|
||||
|
|
@ -110,7 +111,9 @@ def _get_profile_goal_mode(profile_id: str) -> str:
|
|||
|
||||
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.
|
||||
|
|
@ -136,11 +139,18 @@ 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
|
||||
f"""SELECT
|
||||
AVG(daily_kcal) AS kcal_avg,
|
||||
AVG(daily_protein) AS protein_avg,
|
||||
AVG(daily_carbs) AS carbs_avg,
|
||||
|
|
@ -153,10 +163,10 @@ def get_nutrition_average_data(
|
|||
COALESCE(SUM(carbs_g), 0)::float AS daily_carbs,
|
||||
COALESCE(SUM(fat_g), 0)::float AS daily_fat
|
||||
FROM nutrition_log
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
{inner_where}
|
||||
GROUP BY date
|
||||
) AS daily""",
|
||||
(profile_id, cutoff),
|
||||
params,
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
|
|
@ -494,8 +504,6 @@ def get_macro_consistency_data(
|
|||
"data_points": len(rows)
|
||||
}
|
||||
|
||||
import statistics
|
||||
|
||||
protein_pcts = []
|
||||
carbs_pcts = []
|
||||
fat_pcts = []
|
||||
|
|
@ -561,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)
|
||||
# ============================================================================
|
||||
|
|
|
|||
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
|
||||
|
|
@ -21,6 +21,11 @@ from datetime import datetime, timedelta, date
|
|||
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."""
|
||||
|
|
@ -744,34 +749,70 @@ def calculate_sleep_avg_duration_7d(profile_id: str) -> Optional[float]:
|
|||
return round(avg_hours, 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
|
||||
"""
|
||||
target_hours = 7.5
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("""
|
||||
SELECT duration_minutes
|
||||
FROM sleep_log
|
||||
WHERE profile_id = %s
|
||||
AND date >= CURRENT_DATE - INTERVAL '14 days'
|
||||
AND duration_minutes IS NOT NULL
|
||||
ORDER BY date DESC
|
||||
""", (profile_id,))
|
||||
|
||||
sleep_data = [row['duration_minutes'] for row in cur.fetchall()]
|
||||
|
||||
if len(sleep_data) < 10: # Need at least 10 days
|
||||
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
|
||||
|
||||
# Calculate cumulative debt
|
||||
total_debt_min = sum(max(0, (target_hours * 60) - sleep_min) for sleep_min in sleep_data)
|
||||
debt_hours = total_debt_min / 60
|
||||
|
||||
return round(debt_hours, 1)
|
||||
def 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]:
|
||||
"""
|
||||
Aufsummierte Schlafschuld (h) der letzten 14 Kalendertage bis heute —
|
||||
Ziel pro Nacht: SLEEP_DEBT_TARGET_HOURS_DEFAULT (aktuell nicht profilkonfigurierbar).
|
||||
"""
|
||||
today = datetime.now().date()
|
||||
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::date - INTERVAL '14 days'
|
||||
AND date <= %s::date
|
||||
AND duration_minutes IS NOT NULL
|
||||
ORDER BY date DESC
|
||||
""",
|
||||
(profile_id, today, today),
|
||||
)
|
||||
rows = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
return sleep_debt_sum_hours_in_window(rows, today)
|
||||
|
||||
|
||||
def calculate_sleep_regularity_proxy(profile_id: str) -> Optional[float]:
|
||||
|
|
|
|||
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,
|
||||
}
|
||||
|
|
|
|||
120
backend/data_layer/tandoor_client.py
Normal file
120
backend/data_layer/tandoor_client.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
"""HTTP client for a user's Tandoor instance. Never logs the token."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import ssl
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
|
||||
class TandoorError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def normalize_base_url(raw: str | None) -> str:
|
||||
url = (raw or "").strip().rstrip("/")
|
||||
if not url:
|
||||
raise TandoorError("Tandoor-URL fehlt")
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("https", "http") or not parsed.netloc:
|
||||
raise TandoorError("Tandoor-URL muss mit https:// oder http:// beginnen")
|
||||
if parsed.scheme == "http" and parsed.hostname not in ("localhost", "127.0.0.1"):
|
||||
raise TandoorError("Tandoor-URL muss https verwenden")
|
||||
return url
|
||||
|
||||
|
||||
def token_hint(token: str | None) -> str | None:
|
||||
t = (token or "").strip()
|
||||
if len(t) < 4:
|
||||
return "gesetzt" if t else None
|
||||
return f"…{t[-4:]}"
|
||||
|
||||
|
||||
def public_settings(row: dict[str, Any] | None) -> dict[str, Any]:
|
||||
if not row:
|
||||
return {
|
||||
"configured": False,
|
||||
"base_url": "",
|
||||
"token_set": False,
|
||||
"token_hint": None,
|
||||
"last_ok_at": None,
|
||||
"last_error": None,
|
||||
}
|
||||
return {
|
||||
"configured": True,
|
||||
"base_url": row.get("base_url") or "",
|
||||
"token_set": bool((row.get("api_token") or "").strip()),
|
||||
"token_hint": token_hint(row.get("api_token")),
|
||||
"last_ok_at": row.get("last_ok_at").isoformat() if row.get("last_ok_at") else None,
|
||||
"last_error": row.get("last_error"),
|
||||
}
|
||||
|
||||
|
||||
def _request(base_url: str, token: str, path: str, timeout: float = 10.0) -> tuple[int, str, str]:
|
||||
url = urljoin(base_url.rstrip("/") + "/", path.lstrip("/"))
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"Authorization": f"Token {token.strip()}",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "Mitai-Jinkendo/tandoor-connector",
|
||||
},
|
||||
method="GET",
|
||||
)
|
||||
ctx = ssl.create_default_context()
|
||||
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ARG002
|
||||
return None
|
||||
|
||||
opener = urllib.request.build_opener(_NoRedirect, urllib.request.HTTPSHandler(context=ctx))
|
||||
try:
|
||||
with opener.open(req, timeout=timeout) as resp:
|
||||
body = resp.read().decode("utf-8", errors="replace")
|
||||
ctype = resp.headers.get("Content-Type", "")
|
||||
return resp.status, body, ctype
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="replace") if e.fp else ""
|
||||
ctype = e.headers.get("Content-Type", "") if e.headers else ""
|
||||
return e.code, body, ctype
|
||||
except TimeoutError as e:
|
||||
raise TandoorError("Tandoor antwortet nicht (Zeitüberschreitung)") from e
|
||||
except urllib.error.URLError as e:
|
||||
raise TandoorError(f"Tandoor nicht erreichbar: {e.reason}") from e
|
||||
|
||||
|
||||
def probe_connection(base_url: str, token: str) -> dict[str, Any]:
|
||||
url = normalize_base_url(base_url)
|
||||
tok = (token or "").strip()
|
||||
if not tok:
|
||||
raise TandoorError("API-Token fehlt")
|
||||
status, body, ctype = _request(url, tok, "api/user/")
|
||||
if status in (301, 302, 303, 307, 308) or "text/html" in (ctype or "") and status < 400:
|
||||
raise TandoorError("Tandoor hat nicht mit JSON geantwortet — URL oder Token prüfen")
|
||||
if status in (401, 403):
|
||||
raise TandoorError("Token ungültig oder ohne Rechte")
|
||||
if status == 404:
|
||||
status, body, ctype = _request(url, tok, "api/recipe/?page=1&page_size=1")
|
||||
if status in (401, 403):
|
||||
raise TandoorError("Token ungültig oder ohne Rechte")
|
||||
if status >= 400:
|
||||
raise TandoorError(f"Tandoor antwortete mit HTTP {status}")
|
||||
if "json" not in (ctype or "") and not (body or "").lstrip().startswith(("{", "[")):
|
||||
raise TandoorError("Tandoor hat nicht mit JSON geantwortet — URL oder Token prüfen")
|
||||
try:
|
||||
data = json.loads(body) if body else {}
|
||||
except json.JSONDecodeError as e:
|
||||
raise TandoorError("Tandoor-Antwort war kein JSON") from e
|
||||
name = None
|
||||
if isinstance(data, dict):
|
||||
results = data.get("results")
|
||||
if isinstance(results, list) and results and isinstance(results[0], dict):
|
||||
name = results[0].get("display_name") or results[0].get("username") or results[0].get("name")
|
||||
else:
|
||||
name = data.get("display_name") or data.get("username") or data.get("name")
|
||||
count = data.get("count")
|
||||
else:
|
||||
count = None
|
||||
return {"ok": True, "user": name, "recipe_count": count}
|
||||
67
backend/data_layer/tandoor_settings.py
Normal file
67
backend/data_layer/tandoor_settings.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""Persist per-profile Tandoor credentials. Token never returned in full."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from data_layer.tandoor_client import public_settings
|
||||
|
||||
|
||||
def get_settings(cur, profile_id: str) -> dict[str, Any] | None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT profile_id, base_url, api_token, last_ok_at, last_error, updated_at
|
||||
FROM profile_tandoor_settings
|
||||
WHERE profile_id = %s
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def get_public_settings(cur, profile_id: str) -> dict[str, Any]:
|
||||
return public_settings(get_settings(cur, profile_id))
|
||||
|
||||
|
||||
def upsert_settings(cur, profile_id: str, base_url: str, token: str | None, *, clear_token: bool = False) -> dict[str, Any]:
|
||||
existing = get_settings(cur, profile_id)
|
||||
if clear_token:
|
||||
cur.execute("DELETE FROM profile_tandoor_settings WHERE profile_id = %s", (profile_id,))
|
||||
return public_settings(None)
|
||||
stored = (token or "").strip() or ((existing or {}).get("api_token") or "")
|
||||
if not stored:
|
||||
raise ValueError("API-Token fehlt")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO profile_tandoor_settings (profile_id, base_url, api_token, last_error, updated_at)
|
||||
VALUES (%s, %s, %s, NULL, NOW())
|
||||
ON CONFLICT (profile_id) DO UPDATE SET
|
||||
base_url = EXCLUDED.base_url,
|
||||
api_token = EXCLUDED.api_token,
|
||||
last_error = NULL,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(profile_id, base_url, stored),
|
||||
)
|
||||
return get_public_settings(cur, profile_id)
|
||||
|
||||
|
||||
def record_probe(cur, profile_id: str, ok: bool, error: str | None = None) -> None:
|
||||
if ok:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE profile_tandoor_settings
|
||||
SET last_ok_at = NOW(), last_error = NULL, updated_at = NOW()
|
||||
WHERE profile_id = %s
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE profile_tandoor_settings
|
||||
SET last_error = %s, updated_at = NOW()
|
||||
WHERE profile_id = %s
|
||||
""",
|
||||
((error or "")[:300], profile_id),
|
||||
)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -34,8 +34,12 @@ 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
|
||||
from routers import bls, admin_bls, admin_food_mappings # BLS catalog + FDDB mapping
|
||||
from routers import tandoor # per-user Tandoor connection
|
||||
|
||||
# ── App Configuration ─────────────────────────────────────────────────────────
|
||||
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
||||
|
|
@ -66,7 +70,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
|
||||
|
|
@ -74,7 +78,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/*
|
||||
|
|
@ -126,8 +130,15 @@ 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
|
||||
app.include_router(bls.router) # /api/bls/*
|
||||
app.include_router(admin_bls.router) # /api/admin/bls/*
|
||||
app.include_router(admin_food_mappings.router) # /api/admin/food-mappings
|
||||
app.include_router(tandoor.router) # /api/tandoor/*
|
||||
|
||||
# ── 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 $$;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user