Compare commits
No commits in common. "develop" and "WF_Endnote" have entirely different histories.
develop
...
WF_Endnote
|
|
@ -12,8 +12,6 @@ Dieser Ordner ist der **primäre Orientierungspunkt** für Claude Code / Cursor-
|
||||||
| 2 | **`rules/DOCUMENTATION.md`** – Ablage- und Dokumentationsregeln |
|
| 2 | **`rules/DOCUMENTATION.md`** – Ablage- und Dokumentationsregeln |
|
||||||
| 3 | `rules/ARCHITECTURE.md`, `rules/CODING_RULES.md`, `rules/LESSONS_LEARNED.md` |
|
| 3 | `rules/ARCHITECTURE.md`, `rules/CODING_RULES.md`, `rules/LESSONS_LEARNED.md` |
|
||||||
| 4 | Issue-Landkarte: **`.claude/docs/GITEA_ISSUES_INDEX.md`** |
|
| 4 | Issue-Landkarte: **`.claude/docs/GITEA_ISSUES_INDEX.md`** |
|
||||||
| 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).
|
Themen mit UI/Nav/PWA: siehe `../docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md` (im **Projekt**-`docs/`, nicht hier).
|
||||||
|
|
||||||
|
|
@ -26,7 +24,6 @@ Themen mit UI/Nav/PWA: siehe `../docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md` (im
|
||||||
├── README.md ← Diese Datei
|
├── README.md ← Diese Datei
|
||||||
├── rules/ ← Verbindliche Regeln (versioniert, wenn konfiguriert)
|
├── rules/ ← Verbindliche Regeln (versioniert, wenn konfiguriert)
|
||||||
├── docs/ ← Spezifikationen + Arbeitspapiere
|
├── docs/ ← Spezifikationen + Arbeitspapiere
|
||||||
│ ├── jinkendo-foundation/ ← Foundation Produktfamilie (Designprinzipien)
|
|
||||||
│ ├── functional/ ← Fachlich (WAS)
|
│ ├── functional/ ← Fachlich (WAS)
|
||||||
│ ├── technical/ ← Technisch (WIE)
|
│ ├── technical/ ← Technisch (WIE)
|
||||||
│ ├── architecture/ ← Querschnitt
|
│ ├── architecture/ ← Querschnitt
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Gitea Issues – Landkarte (Auswertung)
|
# Gitea Issues – Landkarte (Auswertung)
|
||||||
|
|
||||||
**Quelle:** Gitea `Lars/mitai-jinkendo`, Stand **2026-04-11** (Abfrage `state=all`, ergänzt: #71, #75, #76, #106, 2026-09-12).
|
**Quelle:** Gitea `Lars/mitai-jinkendo`, Stand **2026-04-08** (Abfrage `state=all`).
|
||||||
**URL:** http://192.168.2.144:3000/Lars/mitai-jinkendo/issues
|
**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.
|
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,21 +82,13 @@ Dieses Dokument ist ein **Orientierungs-Index** für Agenten und Entwickler. Ver
|
||||||
|---|--------|
|
|---|--------|
|
||||||
| 37 | Feature-Enforcement für Activity CSV-Import |
|
| 37 | Feature-Enforcement für Activity CSV-Import |
|
||||||
| 38 | Feature-Enforcement für Nutrition CSV-Import UI |
|
| 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
|
### Qualität / Sonstiges
|
||||||
|
|
||||||
| # | Titel |
|
| # | Titel |
|
||||||
|---|--------|
|
|---|--------|
|
||||||
| 15 | [FEAT-002] Quality-Filter für KI-Auswertungen & Charts integrieren |
|
| 15 | [FEAT-002] Quality-Filter für KI-Auswertungen & Charts integrieren |
|
||||||
| 76 | Trainings-Qualität: zielbezogene Logik + Listen-Filter statt globalem „Hochwertig“-Hide |
|
| 21 | [FEATURE] Universeller CSV-Parser mit lernbarem Feldmapping |
|
||||||
| 36 | BUG-009: Trainingstyp-Erstellung führt zu Internal Server Error |
|
| 36 | BUG-009: Trainingstyp-Erstellung führt zu Internal Server Error |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
|
||||||
├── ROADMAP.md ← Strategische Phasen (0–3)
|
├── ROADMAP.md ← Strategische Phasen (0–3)
|
||||||
├── CLEANUP_PLAN.md ← Historie Bereinigung März 2026
|
├── CLEANUP_PLAN.md ← Historie Bereinigung März 2026
|
||||||
├── prompts/ ← Exportierte Prompt-Artefakte (JSON)
|
├── prompts/ ← Exportierte Prompt-Artefakte (JSON)
|
||||||
├── jinkendo-foundation/ ← Foundation Produktfamilie (Designprinzipien)
|
|
||||||
├── functional/ ← Fachliche Spezifikationen (WAS)
|
├── functional/ ← Fachliche Spezifikationen (WAS)
|
||||||
├── technical/ ← Technische Spezifikationen & Referenz (WIE)
|
├── technical/ ← Technische Spezifikationen & Referenz (WIE)
|
||||||
├── working/ ← Arbeitspapiere, Analysen, Session-Snapshots
|
├── working/ ← Arbeitspapiere, Analysen, Session-Snapshots
|
||||||
|
|
@ -53,12 +52,8 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
|
||||||
|--------|-------------|-------------------|
|
|--------|-------------|-------------------|
|
||||||
| Data Layer / Charts (Phase 0c) | `functional/DATA_ARCHITECTURE.md`, `technical/DATA_LAYER_EXTENSION_GUIDE.md` | `backend/data_layer/`, `backend/routers/charts.py` |
|
| Data Layer / Charts (Phase 0c) | `functional/DATA_ARCHITECTURE.md`, `technical/DATA_LAYER_EXTENSION_GUIDE.md` | `backend/data_layer/`, `backend/routers/charts.py` |
|
||||||
| Platzhalter / Registry | `technical/PLACEHOLDER_REGISTRY_FRAMEWORK.md`, `technical/PLACEHOLDER_DEVELOPMENT_GUIDE.md` | `backend/placeholder_registrations/`, `backend/placeholder_resolver.py` |
|
| Platzhalter / Registry | `technical/PLACEHOLDER_REGISTRY_FRAMEWORK.md`, `technical/PLACEHOLDER_DEVELOPMENT_GUIDE.md` | `backend/placeholder_registrations/`, `backend/placeholder_resolver.py` |
|
||||||
| Dashboard-Widgets | `technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` | Widget-Katalog + Registrierung (siehe Guide) |
|
| Dashboard-Lab-Widgets | `technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` | Widget-Katalog + Registrierung (siehe Guide) |
|
||||||
| Training Profiler / Resolver | `technical/TRAINING_PROFILE_RESOLVER_LAYER1.md`, `functional/TRAINING_TYPE_PROFILES.md` | Resolver-Module wie im Guide genannt |
|
| Training Profiler / Resolver | `technical/TRAINING_PROFILE_RESOLVER_LAYER1.md`, `functional/TRAINING_TYPE_PROFILES.md` | Resolver-Module wie im Guide genannt |
|
||||||
| Universal CSV Import | `technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | `backend/csv_parser/`, `routers/csv_import.py`, `routers/admin_csv_templates.py` |
|
|
||||||
| 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 |
|
| Mitgliedschaft / Features | `technical/MEMBERSHIP_SYSTEM.md`, `architecture/FEATURE_ENFORCEMENT.md` | `backend/auth.py`, Feature-Logging, Router mit Enforcement |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -95,16 +90,6 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
|
||||||
|
|
||||||
## Technische Spezifikationen (`technical/`)
|
## 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 |
|
| Dokument | Thema |
|
||||||
|----------|--------|
|
|----------|--------|
|
||||||
| `AGGREGATION_METHODS.md` | Aggregation |
|
| `AGGREGATION_METHODS.md` | Aggregation |
|
||||||
|
|
@ -126,13 +111,6 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
|
||||||
| `PROFILE_REFERENCE_VALUES.md` | Profil-Referenzwerte |
|
| `PROFILE_REFERENCE_VALUES.md` | Profil-Referenzwerte |
|
||||||
| `TRAINING_PROFILE_RESOLVER_LAYER1.md` | Training-Resolver Schicht 1 |
|
| `TRAINING_PROFILE_RESOLVER_LAYER1.md` | Training-Resolver Schicht 1 |
|
||||||
| `TRAINING_TYPE_PROFILES_TECHNICAL.md` | Trainingsprofile technisch |
|
| `TRAINING_TYPE_PROFILES_TECHNICAL.md` | Trainingsprofile technisch |
|
||||||
| `UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | Universal CSV: Registry, Executor, Vorlagen, Agent-Checkliste |
|
|
||||||
| `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` | Session-Metriken EAV, Attributprofile, Layer-1, Prod-Migration |
|
|
||||||
| `ACTIVITY_COMPOSITE_METRICS_IMPLEMENTATION_CONCEPT.md` | Composite-Metriken in EAV (JSONB), Archetypen, CSV-Slots, Layer-1-Expand, Migration/Test-Checkliste |
|
|
||||||
| `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` | **Zielarchitektur** Aktivität (Spine/EAV/Composites/Import/Layer 1–2) + **Phasenplan A–F** Produktionsreife |
|
|
||||||
| `ACTIVITY_LAYER2A_PLACEHOLDER_AUDIT.md` | Issue #53: Aktivitäts-Platzhalter Layer 1 ↔ 2a (Audit Schritt 1) |
|
|
||||||
| `ACTIVITY_SCALAR_KANON_TABLE.md` | **Skalar-Kanon** Aktivität (eine Semantik → eine Quelle); Phase A |
|
|
||||||
| *(Code)* `backend/data_layer/activity_data_canon.py` | **Kanon** activity CSV-Modul vs. EAV-primär; Legacy-Lesefallback |
|
|
||||||
| `V9D_PHASE2_VITALS_SLEEP.md` | v9d Vitalwerte/Schlaf (Release-Bezug) |
|
| `V9D_PHASE2_VITALS_SLEEP.md` | v9d Vitalwerte/Schlaf (Release-Bezug) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -196,4 +174,4 @@ Siehe [`audit/README.md`](./audit/README.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Letzte Aktualisierung:** 4. Juli 2026 (Jinkendo Foundation, Designprinzipien nach `jinkendo-foundation/`)
|
**Letzte Aktualisierung:** 8. April 2026 (Struktur-Index, Duplikatbereinigung, Abgleich-Hinweise)
|
||||||
|
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
# BLS-Lebensmittelreferenz und FDDB-Mapping
|
|
||||||
|
|
||||||
**Stand:** 2026-09-12 · **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“.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
- Gelernte Zuordnungen bleiben dauerhaft, sind aber änder- und löschbar.
|
|
||||||
- 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 (z. B. Norsan Omega-3 + EPA) 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: Admin → Stoffe & Attribute.
|
|
||||||
|
|
||||||
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. Später: Import/Verknüpfung, Zutaten gegen Mitai-Mappings, `cooked_yield_g`, Katalogeintrag Fertiggericht pro 100 g. Kein Connector in Phase 1.
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
# 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)
|
|
||||||
|
|
@ -1,326 +0,0 @@
|
||||||
# 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 | ✅ |
|
|
||||||
|
|
@ -1,363 +0,0 @@
|
||||||
# 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 | ✅ |
|
|
||||||
|
|
@ -1,359 +0,0 @@
|
||||||
# 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` |
|
|
||||||
|
|
@ -1,370 +0,0 @@
|
||||||
# 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` |
|
|
||||||
|
|
@ -1,369 +0,0 @@
|
||||||
# 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 |
|
|
||||||
|
|
@ -1,346 +0,0 @@
|
||||||
# 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 | ✅ |
|
|
||||||
|
|
@ -1,305 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
# 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 |
|
|
||||||
|
|
@ -1,315 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,325 +0,0 @@
|
||||||
# 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 | ✅ |
|
|
||||||
|
|
@ -1,317 +0,0 @@
|
||||||
# Activity Session Metrics: Composite-Daten (EAV) – Umsetzungskonzept
|
|
||||||
|
|
||||||
**Stand:** 2026-04-16
|
|
||||||
**Status:** Normatives Konzept zur nahtlosen Weiterarbeit durch Code-Agenten
|
|
||||||
**Bezieht sich auf:** `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` (§2.3–2.4, Phasen D–E), `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md`, Issue #53 (Layer-1-Prinzip: Auswertungen nur über `data_layer`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Ziel und Abgrenzung
|
|
||||||
|
|
||||||
### 1.1 Ziel
|
|
||||||
|
|
||||||
- **Composite-Messgrößen** (strukturierte Werte mit mehreren benannten Slots) werden wie **normale Trainingsparameter** im Katalog geführt, **Kategorie-/Typ-Profilen** zugeordnet und pro Session in der **EAV-Tabelle** persistiert.
|
|
||||||
- **Persistenz:** ein JSON-Dokument pro Session und `training_parameter_id` (kanonisch **JSONB**), kompatibel mit der bestehenden „eine Zeile pro Parameter“-Semantik.
|
|
||||||
- **Import:** CSV liefert typischerweise **eine Spalte pro atomarem Slot**; das Mapping verweist auf **`(Parameter-Key, Slot-Key)`** (stabile Strings, nicht Spaltenreihenfolge).
|
|
||||||
- **Layer 1:** liefert für Consumer weiterhin **eine konsistente API**: Rohdokument **und** optional **aufgelöste Einzelwerte** (flach oder namenspaced), ohne dass Charts/Platzhalter direkt JSON parsen müssen.
|
|
||||||
|
|
||||||
### 1.2 Nicht-Ziele (explizit)
|
|
||||||
|
|
||||||
- Kein „freies“ JSON-Schema im Admin ohne Archetyp-Bindung (verhindert Datenmüll und nicht validierbare Dokumente).
|
|
||||||
- Keine Abschwächung bestehender **Skalar-Parameter** (`integer`, `float`, `string`, `boolean`): alle bisherigen Pfade bleiben gültig.
|
|
||||||
- Kein Ersatz für `activity_log`-**Spine** oder Session-Qualitätsblobs (`evaluation`, …).
|
|
||||||
|
|
||||||
### 1.3 Kompatibilitätsgarantie („keine Regression“)
|
|
||||||
|
|
||||||
| Bereich | Maßnahme |
|
|
||||||
|---------|----------|
|
|
||||||
| DB | Nur **additive** Migrationen; bestehende `CHECK`-Regeln für Skalare bleiben für Zeilen **ohne** Composite erhalten bzw. werden zu einer **Oder-Verknüpfung** erweitert (siehe §4). |
|
|
||||||
| `training_parameters` | Neuer `data_type`-Wert **`composite`** zusätzlich zu den vier bestehenden; bestehende CHECK-Constraint muss erweitert werden (Migration). |
|
|
||||||
| `activity_session_metrics` | Skalare Zeilen unverändert; Composite-Zeilen nutzen **`value_json`** (neu), alle `value_*` NULL. |
|
|
||||||
| Layer 1 | `resolve_activity_attribute_schema`, Merge, Replace: Composite erscheint als **ein** Schema-Eintrag; Lese-/Schreibpfade erweitern, nicht ersetzen. |
|
|
||||||
| CSV | Bestehende Map-Ziele auf Skalare/Registry unverändert; neue Zielnotation nur für Composites. |
|
|
||||||
| Admin | tcp/ttp-UI: gleiche Zuordnung wie heute; Zusatzfelder nur bei `data_type === composite`. |
|
|
||||||
|
|
||||||
### 1.4 Abgleich mit `functional_concept_composite_data.md` (fachliches Konzept)
|
|
||||||
|
|
||||||
Das **fachliche Konzeptpapier** (Composite Scalar/Layer-Trennung) und dieses **Umsetzungskonzept** sind **vereinbar**, wenn die Rollen klar getrennt bleiben:
|
|
||||||
|
|
||||||
| Thema | Fachliches Konzept (`functional_concept_composite_data.md`) | Dieses Umsetzungskonzept (technisch) |
|
|
||||||
|--------|-------------------------------------------------------------|--------------------------------------|
|
|
||||||
| **Speicher in der DB** | Einheitlicher Store; Composite = `jsonb` mit **kleinem Basisschema** (`v`, `kind`, `domain`, `items`, optional `basis`, `meta`) | `activity_session_metrics.value_json`; CHECK Skalar vs. Composite |
|
|
||||||
| **Technische Container** | Genau **vier** `kind`-Werte: `group_set`, `distribution_set`, `sequence_set`, `model_set` | Layer-1-Validierung **muss** diese Hülle durchsetzen; kein freies JSON ohne `kind`/`v`/`items` |
|
|
||||||
| **„Archetypen“** | **Fachliche** Ausprägungen werden in **Layer 2a** aus L1-Objekten abgeleitet | Benannte **Preset-/Validierungsprofile** im Code (z. B. Zonenverteilung HF) sind **kein** zweites Persistenz-Schema: sie legen fest, *welches* der vier `kind`-Muster, *welches* `domain`, *welche* Item-Keys/Typen erlaubt sind — inkl. CSV-Slot-Mapping |
|
|
||||||
| **Layer 1** | Validiert, minimal normalisiert, **keine** Scores/Bewertungen/KI-Texte | Validator + Merge + optional `expand_*` (**technische** Flachstellung für Consumer, z. B. `param.slot` → Skalar) |
|
|
||||||
| **Layer 2** | Diagramme, Kennzahlen, KI-Platzhalter-**Formulierung** | unverändert; konsumiert L1 (und ggf. L2a) |
|
|
||||||
|
|
||||||
**Konsequenz für die Registry:** Statt „8 freie JSON-Archetypen“ implementiert die Code-Registry **Validierungs-Presets**, die alle auf die **vier technischen `kind`-Formen** abbilden. Die Tabelle in §3 beschreibt weiterhin **fachlich benannte MVP-Anker** — technisch übersetzen sie sich in `(kind, domain, Item-Regeln, v)`.
|
|
||||||
|
|
||||||
**Konsequenz für Platzhalter:** Roh-JSON aus der DB **nicht** ungefiltert in Prompts; L2b nutzt L1/L2a-Aufbereitung (wie im fachlichen Konzept).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Begriffe
|
|
||||||
|
|
||||||
| Begriff | Bedeutung |
|
|
||||||
|---------|-----------|
|
|
||||||
| **Archetyp** | Im **Repo versionierte** Strukturvorlage (erlaubte Slots, Typen, Pflichtfelder, Validator, Version). **7–8** Stück geplant; Erweiterung nur per Code-Release. |
|
|
||||||
| **Slot** | Benanntes Teilfeld innerhalb des Composite-Dokuments, z. B. `z1_sec`, `z2_sec`, `avg_cadence`. |
|
|
||||||
| **Parameter-Instanz** | Eine Zeile in `training_parameters` mit `data_type = composite` und Metadaten, **welcher** Archetyp gilt (siehe §5). |
|
|
||||||
| **Dokument** | Ein JSON-Objekt, das alle Slots abbildet; gespeichert in `activity_session_metrics.value_json`. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Archetypen-Katalog (Planungsstand) — fachliche Namen → technische `kind`-Presets
|
|
||||||
|
|
||||||
Die **konkrete** Slot-Liste und Validierung wird im Code als **Registry** geführt (z. B. `backend/data_layer/activity_composite_archetypes.py`). Jedes Preset **mappt** auf genau eines von **`group_set` | `distribution_set` | `sequence_set` | `model_set`** und erfüllt das **Basisschema** aus `functional_concept_composite_data.md` §7.
|
|
||||||
|
|
||||||
Inhaltlich orientiert an `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` §2.4.
|
|
||||||
|
|
||||||
**Beispielhafte fachliche MVP-Anker** (8 Kandidaten; im Code als Preset-Key + `kind`/`domain` abbilden):
|
|
||||||
|
|
||||||
| `archetype_key` (stabil) | Kurzbeschreibung | Typische Slots (Beispiel) |
|
|
||||||
|--------------------------|------------------|---------------------------|
|
|
||||||
| `hr_zone_distribution` | Zeit-/Anteil je HF-Zone | `z1_sec`…`z5_sec` oder `zones[]` |
|
|
||||||
| `power_zone_distribution` | Leistungszonen | analog |
|
|
||||||
| `pace_band_profile` | Pace-Bänder / Histogramm | bucket-Struktur |
|
|
||||||
| `interval_block_summary` | Intervallblöcke aggregiert | `blocks[]` mit Dauer, Ziel, Ist |
|
|
||||||
| `event_marker_sequence` | Ereignisse mit Zeitstempel | `events[]` |
|
|
||||||
| `coupling_efficiency_profile` | Kopplungs-/Effizienzmetriken | sportabhängig |
|
|
||||||
| `model_parameter_profile` | Modell-/Schwellenparameter | key-value-ähnlich, validiert |
|
|
||||||
| `readiness_recovery_snapshot` | optional: kurzes Multi-Signal-Bundle | nur wenn fachlich gewünscht |
|
|
||||||
|
|
||||||
**Regel:** Jeder Archetyp hat `version` (Integer). Validator lehnt Dokumente mit falscher/fehlender Version ab oder migriert definiert (nur wenn spezifiziert).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Datenmodell-Erweiterungen
|
|
||||||
|
|
||||||
### 4.1 `training_parameters`
|
|
||||||
|
|
||||||
**Migration (additiv):**
|
|
||||||
|
|
||||||
1. `CHECK (data_type IN (...))` erweitern um **`composite`**.
|
|
||||||
2. Optional eigene Spalte **`composite_archetype_key` `VARCHAR(64)`** (NOT NULL wenn `data_type = composite`, sonst NULL) — **oder** ausschließlich in `validation_rules` speichern (siehe unten).
|
|
||||||
**Empfehlung:** Spalte `composite_archetype_key` + `composite_archetype_version INT` für einfache Admin-Queries und klare Semantik; `validation_rules` für archetyp-spezifische Feinheiten (z. B. erlaubte Zonenanzahl).
|
|
||||||
|
|
||||||
**Konsistenz-Constraint (DB oder App):**
|
|
||||||
|
|
||||||
- Wenn `data_type = composite`: `composite_archetype_key` gesetzt, `source_field` typischerweise **NULL** (kein `activity_log`-Skalar-Shadowing).
|
|
||||||
- `unit` am Parameter: optional für „Anzeige-Einheit“ des Gesamtwerts oder leer; Slots haben Einheiten im Archetyp oder in Slot-Metadaten.
|
|
||||||
|
|
||||||
### 4.2 `activity_session_metrics`
|
|
||||||
|
|
||||||
**Migration (additiv):**
|
|
||||||
|
|
||||||
```text
|
|
||||||
value_json JSONB NULL
|
|
||||||
```
|
|
||||||
|
|
||||||
**CHECK-Constraint ersetzen/erweitern** (Konzept):
|
|
||||||
|
|
||||||
- **Modus Skalar:** genau eine der Spalten `value_num`, `value_int`, `value_text`, `value_bool` ist NOT NULL; `value_json` IS NULL.
|
|
||||||
- **Modus Composite:** `value_json` IS NOT NULL; alle vier Skalar-Spalten IS NULL.
|
|
||||||
|
|
||||||
Damit bleibt die bestehende Semantik „eine Zeile = ein Parameter“ erhalten.
|
|
||||||
|
|
||||||
**Kommentar:** Tabelle trägt weiterhin „EAV“; Composites sind **keine** zusätzlichen Zeilen pro Slot.
|
|
||||||
|
|
||||||
### 4.3 Profil-Zuordnung (tcp / ttp)
|
|
||||||
|
|
||||||
**Keine** Tabellenänderung: `training_category_parameter` und `training_type_parameter` verweisen weiter nur auf `training_parameter_id`. Composite-Parameter verhalten sich wie Skalare in Bezug auf **Zuordnung**, **sort_order**, **required**, **ui_group**.
|
|
||||||
|
|
||||||
**`required`:** bedeutet „Dokument muss nach Validator vollständig sein“, nicht „jede CSV-Spalte muss in jeder Zeile vorkommen“.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Metadaten pro Composite-Parameter
|
|
||||||
|
|
||||||
Minimal in der DB (Beispiel):
|
|
||||||
|
|
||||||
| Feld | Zweck |
|
|
||||||
|------|--------|
|
|
||||||
| `data_type` | `composite` |
|
|
||||||
| `composite_archetype_key` | Verweis auf Code-Registry |
|
|
||||||
| `composite_archetype_version` | Schema-Version |
|
|
||||||
| `validation_rules` | optional: Overrides (z. B. `max_zones`, sport-spezifisch) — nur was der Validator explizit auswertet |
|
|
||||||
|
|
||||||
**Admin-API:** bestehende Endpoints erweitern (Payload-Validierung): bei `composite` müssen Archetyp + Version gesetzt sein und in der **Registry** existieren.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Layer 1 – Kontrakt (`activity_session_metrics.py` + Helfer)
|
|
||||||
|
|
||||||
### 6.1 Schema-Auflösung
|
|
||||||
|
|
||||||
`resolve_activity_attribute_schema` liefert pro Composite **einen** Eintrag wie bei Skalaren, mit:
|
|
||||||
|
|
||||||
- `data_type: "composite"`
|
|
||||||
- `composite_archetype_key`, `composite_archetype_version` (aus DB oder Join)
|
|
||||||
- ggf. `composite_slot_catalog`: **nur wenn** für Admin/UI gewünscht — alternativ separater Endpoint `GET .../composite-archetypes` (read-only) aus Registry, um Bundle-Größe klein zu halten.
|
|
||||||
|
|
||||||
### 6.2 Lesen / Merge
|
|
||||||
|
|
||||||
- `fetch_activity_session_metrics`: SELECT inkl. `value_json`.
|
|
||||||
- `merge_column_backed_and_eav_metrics`: Composites **nur** aus EAV (`value_json`), kein `activity_log`-Shadowing (außer später explizit im Kanon — Standard: nein).
|
|
||||||
- Ausgabe in `metrics`-Liste: ein Eintrag pro Parameter mit z. B.
|
|
||||||
`value: { "_composite": true, "document": { ... } }` **oder** kanonisch getrennt: `value_document` + `value` null — **festlegen beim Implementieren** und in API-Doku halten; Empfehlung: **`value` = deserialisiertes Objekt (dict)** für Composites, damit Frontend dieselbe Struktur wie Speicher hat.
|
|
||||||
|
|
||||||
### 6.3 „Einzelwerte für Layer 1 / Issue 53“
|
|
||||||
|
|
||||||
Neue **pure** Funktion (kein SQL im Router), z. B.:
|
|
||||||
|
|
||||||
```text
|
|
||||||
expand_composite_metrics_for_session(
|
|
||||||
schema: list[dict],
|
|
||||||
metrics: list[dict],
|
|
||||||
) -> dict[str, Any]
|
|
||||||
```
|
|
||||||
|
|
||||||
- Input: effektives Schema + gemergte Metriken.
|
|
||||||
- Output: flaches Dict **`slot_path → typisierter Wert`**, z. B.
|
|
||||||
`hr_zones.z1_sec → 1200`, oder namespaced Keys `training_param_key.slot_key` zur Kollisionssicherheit.
|
|
||||||
- Nutzung: `activity_metrics`, Chart-Builder, später Platzhalter-Registry (`data_layer_function`), **ohne** JSON-Parsing in Layer 2.
|
|
||||||
|
|
||||||
**Wichtig:** Skalare Parameter erscheinen im expandierten Dict mit ihrem `parameter_key` wie bisher (kein Breaking Change für Consumer, die nur Skalare erwarten).
|
|
||||||
|
|
||||||
### 6.4 Validierung / Schreiben
|
|
||||||
|
|
||||||
- **`replace_activity_session_metrics`:** Payload-Item für Composite: `value` ist **Objekt** (dict) oder JSON-String — Server normalisiert zu dict, validiert mit Archetyp-Validator, speichert als `value_json`.
|
|
||||||
- **`upsert_session_metrics_from_csv_mapped`:** siehe §7 (Zusammenbau aus Partial-Updates pro Zeile).
|
|
||||||
|
|
||||||
**Pflicht:** Keine Teil-Updates in DB, die ein halbes Dokument hinterlassen, ohne Validierung — außer explizit als „Draft“-Modus spezifiziert (nicht Teil dieses Konzepts).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. CSV / Universal Import
|
|
||||||
|
|
||||||
### 7.1 Map-Ziel-Notation
|
|
||||||
|
|
||||||
Stabiles Muster (Vorschlag, im Import-Modul zentral parsen):
|
|
||||||
|
|
||||||
```text
|
|
||||||
"<parameter_key>.<slot_key>"
|
|
||||||
```
|
|
||||||
|
|
||||||
Beispiel: `my_hr_zones.z1_sec` → nach Import-Zusammenfügung in den Parameter `my_hr_zones` unter Slot `z1_sec`.
|
|
||||||
|
|
||||||
**Alternative:** explizites Präfix `composite:` in der Vorlage — nur nötig, wenn Kollisionen mit normalen Keys befürchtet werden; sonst Punkt-Notation reicht.
|
|
||||||
|
|
||||||
### 7.2 Executor-Flow (Konzept)
|
|
||||||
|
|
||||||
1. `build_row_after_mapping` liefert flache Keys inkl. `param.slot`.
|
|
||||||
2. Nach Schreiben von `activity_log` / Skalar-EAV: **Composite-Accumulator** pro `activity_log_id` und `parameter_key`:
|
|
||||||
- Sammelt alle Slot-Werte aus der Zeile.
|
|
||||||
3. Vor Commit der Zeile (oder am Ende der Datei — **pro Zeile empfohlen**, damit SAVEPOINT pro Row funktioniert):
|
|
||||||
- Dokument aus Slots bauen → Validator → Upsert `activity_session_metrics` mit `value_json`.
|
|
||||||
|
|
||||||
**Teilbefüllung:** Validator entscheidet (Archetyp: optional vs. required Slots). CSV darf nur Teilmengen liefern, wenn Archetyp erlaubt.
|
|
||||||
|
|
||||||
### 7.3 Typkonvertierung
|
|
||||||
|
|
||||||
Pro **Slot** im Archetyp: definierter skalarer Typ (`float`, `int`, …). Converter wie bei Skalaren (Executor / zentrale Converter), **keine** Parallel-Logik in Routern.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Admin-UI / Mapping-UX
|
|
||||||
|
|
||||||
### 8.1 Parameter anlegen
|
|
||||||
|
|
||||||
- Auswahl **Datentyp „Composite“** → Dropdown **Archetyp** (aus Registry-API), Version readonly oder wählbar gemäß Policy.
|
|
||||||
- Rest wie Skalar: Name, Kategorie (`training_parameters.category`), Aktiv-Flag.
|
|
||||||
|
|
||||||
### 8.2 Profil zuordnen
|
|
||||||
|
|
||||||
Unverändert: Kategorie-/Typ-Matrix wie heute.
|
|
||||||
|
|
||||||
### 8.3 Universal-CSV-Vorlage
|
|
||||||
|
|
||||||
- Mapping-Ziele: neben bisherigen Keys **Slot-Ziele** `parameter_key.slot_key`.
|
|
||||||
- UI-Gruppierung: optisch **Composite-Block** (wie in `ACTIVITY_PRODUCTION_ARCHITECTURE` §2.5 angedeutet), um Verwechslung mit Spine-Spalten zu vermeiden.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. API-Oberflächen (Erweiterungen)
|
|
||||||
|
|
||||||
| Bereich | Änderung |
|
|
||||||
|---------|-----------|
|
|
||||||
| `GET /api/activity/{id}` | `metrics` enthält Composite-Werte als Objekt; `schema` kennzeichnet `data_type: composite`. |
|
|
||||||
| `PUT /api/activity/{id}/metrics` | Eintrag `{ parameter_key, value: { ... } }` für Composites. |
|
|
||||||
| Admin `training-parameters` | Create/Update mit Composite-Feldern. |
|
|
||||||
| Optional | `GET /api/admin/composite-archetypes` | Registry export für UI (Keys, Slot-Liste, Version). |
|
|
||||||
|
|
||||||
**Rückwärtskompatibilität:** Clients, die nur Skalare senden, unverändert.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Frontend (Kurz)
|
|
||||||
|
|
||||||
- `ActivityPage` / Session-Metrik-Editor: für `data_type === composite` **strukturierte Teilfelder** aus Slot-Katalog rendern (oder JSON-Editor nur als Entwickler-Fallback — Produkt: strukturierte Felder).
|
|
||||||
- Sortierung/Gruppierung: bestehende `param_category` / `ui_group` / `sort_order` gelten unverändert.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Tests (pytest)
|
|
||||||
|
|
||||||
| Test | Beschreibung |
|
|
||||||
|------|----------------|
|
|
||||||
| Archetyp-Validator | gültige / ungültige Dokumente je Version |
|
|
||||||
| DB-Constraint | Skalar vs. Composite Ausschluss |
|
|
||||||
| `expand_composite_metrics_for_session` | flache Keys, Kollisionen |
|
|
||||||
| CSV-Zusammenbau | mehrere Spalten → ein `value_json` |
|
|
||||||
| Regression | bestehende `test_activity_session_metrics.py` unverändert grün halten |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Rollout-Phasen (operativ)
|
|
||||||
|
|
||||||
Stimmt mit `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` überein:
|
|
||||||
|
|
||||||
1. **Phase D – MVP:** ein Preset (z. B. HF-Zonen → `distribution_set`, `domain: heart_rate`), Migration `value_json` + `composite` data_type, Validator gegen Basisschema §7, Import 3–5 Spalten → `items`, GET/PUT, minimale Admin-Anbindung.
|
|
||||||
2. **Phase E:** weitere Presets / `kind`-Varianten, Mapping-UX, `expand_*` für ausgewählte Layer-1-Consumer.
|
|
||||||
3. **Phase F:** Observability, Performance, Doku, Gitea-Issues schließen.
|
|
||||||
|
|
||||||
### 12.1 Empfohlene Reihenfolge: Skalar-Pipeline vs. Composite-Speicherung
|
|
||||||
|
|
||||||
**Frage:** Zuerst Skalar-EAV vollständig bis Platzhalter/Orchestrator abschließen, oder zuerst Composite-Speicherung?
|
|
||||||
|
|
||||||
| Option | Vorteil | Risiko |
|
|
||||||
|--------|---------|--------|
|
|
||||||
| **A: Nur Skalar zuerst** (Kanon, L1-Härtung, Platzhalter aus EAV/L1) | Eine klare, end-to-end **Referenzpipeline**; weniger gleichzeitige Variablen | Composite-Datenstrome verzögern sich |
|
|
||||||
| **B: Composite-Speicher zuerst** | JSON landet früh in der DB | Platzhalter/Charts nutzen noch **alte** Pfade → **zwei Wahrheiten** (Detail-API vs. KI) bis L1 vereinheitlicht ist |
|
|
||||||
| **C (Empfehlung): Skalar L1 + Platzhalter-Orchestrierung *vor* Composite-MVP**, oder **eng parallel** mit gemeinsamem L1-Einstieg | `get_activity_session_logical_unit` / `activity_metrics` werden **kanonisch**; Platzhalter lesen **dieselbe** Schicht; Composite wird **additiv** (`value_json` + Validator + später `expand_*`) | Erfordert kurze Planungsdisziplin: Composite-MVP **ohne** sofort alle KI-Platzhalter |
|
|
||||||
|
|
||||||
**Konkrete Empfehlung**
|
|
||||||
|
|
||||||
1. **`ACTIVITY_PRODUCTION` Phase A–B** nicht überspringen: Kanon „eine Semantik / eine Quelle“ + alle relevanten Consumer über **Layer 1** (mind. Session-Detail, Listen-Anreicherung, erste Platzhalter-Pfade für **Skalare**).
|
|
||||||
2. **Dann Phase D (Composite-MVP):** Migration + Speichern/Lesen mit **Basisschema** (`kind`/`items`/…); L1 liefert dasselbe API-Objekt wie Skalare, nur `value` als strukturiertes Dokument.
|
|
||||||
3. **Platzhalter für Composite:** erst **nach** L1 liefert stabil `value_json` **und** optional `expand_composite_metrics_*` — ein Orchestrator-Endpoint bzw. Resolver-Aufruf, der **eine** L1-Funktion nutzt, vermeidet doppelte Logik für Skalar vs. Composite.
|
|
||||||
|
|
||||||
**Kurz:** Composite **persistieren** kann kurz nach stabiler **Skalar-Lese-/Merge-API** folgen; **KI/Platzhalter für Composite** sinnvoll **gemeinsam** mit der erweiterten L1-Ausgabe bauen, nicht gegen eine noch nicht vereinheitlichte Skalar-Pipeline.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Checkliste für den nächsten Agenten
|
|
||||||
|
|
||||||
- [ ] Migration: `value_json`, erweiterte CHECKs, `training_parameters.data_type` + ggf. `composite_archetype_*` Spalten.
|
|
||||||
- [ ] Registry-Modul: Archetypen + Versionen + Slot-Metadaten + Validator-Einstieg.
|
|
||||||
- [ ] `activity_session_metrics.py`: Fetch/Merge/Replace/Upsert-Integration; keine Regression für Skalare.
|
|
||||||
- [ ] Optional: `expand_composite_metrics_for_session` + erste Nutzung in einem Layer-1-Consumer (Tests).
|
|
||||||
- [ ] CSV: Parser für `parameter_key.slot_key`, Row-Accumulator, Fehler melden wie bestehender Import.
|
|
||||||
- [ ] Admin-API + UI: Composite anlegen, tcp/ttp unverändert nutzbar.
|
|
||||||
- [ ] Doku: dieses Dokument mit **festgelegter** JSON-Beispielstruktur pro MVP-Archetyp ergänzen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Referenzen
|
|
||||||
|
|
||||||
- `functional_concept_composite_data.md` – **fachliches** Schichtenmodell, vier technische `kind`-Container, Basisschema JSON
|
|
||||||
- `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` – Zielbild, Phasen A–F
|
|
||||||
- `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` – Ist-Layer-1, APIs
|
|
||||||
- `UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` – Executor, Vorlagen
|
|
||||||
- Migration `054_activity_session_metrics_eav.sql` – Ist-Constraint Skalar
|
|
||||||
- Migration `013_training_parameters.sql` – Ist-`data_type`-Enum
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Version:** 1.1 · Abgleich mit fachlichem Konzept (§1.4, §3, §12.1); MVP auf `distribution_set` o. ä. konkretisieren.
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
# Aktivität: Layer-2a-Platzhalter — Audit Schritt 1 (Issue #53)
|
|
||||||
|
|
||||||
**Stand:** 2026-04-16
|
|
||||||
**Bezug:** [Issue #53 — Multi-Layer Architecture](../../../docs/issues/issue-53-phase-0c-multi-layer-architecture.md): Layer 1 = strukturierte Daten, Layer 2a = KI-Formatierung (keine parallele Domänen-Logik im Resolver).
|
|
||||||
|
|
||||||
**Ziel dieses Dokuments:** Jeder Aktivitäts-Platzhalter hat genau eine **Layer‑1‑Quelle** (`data_layer/activity_metrics.py`); `placeholder_resolver.py` formatiert oder serialisiert nur noch.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Ergebnisübersicht
|
|
||||||
|
|
||||||
| Kategorie | Anzahl | Resolver-SQL für Aktivität? |
|
|
||||||
|-----------|--------|------------------------------|
|
|
||||||
| Gebündelt in `PLACEHOLDER_MAP` (Training/Aktivität) | 20 | **Nein** |
|
|
||||||
| Abweichungen / offene Punkte | 0 | — |
|
|
||||||
|
|
||||||
**Hinweis:** `{{rest_days_count}}` steht in der Karte unter „Schlaf & Erholung“ und nutzt `recovery_metrics.get_rest_days_data` — nicht in dieser Tabelle.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Platzhalter → Layer 1 → Layer 2a
|
|
||||||
|
|
||||||
| Key | Layer 1 (`activity_metrics`) | Layer 2a (`placeholder_resolver`) | Bemerkung |
|
|
||||||
|-----|------------------------------|-------------------------------------|-----------|
|
|
||||||
| `activity_summary` | `get_activity_summary_data` | `get_activity_summary` | String-Zusammenfassung |
|
|
||||||
| `activity_detail` | `get_activity_detail_data` (+ `enrich_sessions_with_metrics`) | `get_activity_detail` | Dynamische `session_metrics[]` pro Zeile (Profil/EAV) |
|
|
||||||
| `trainingstyp_verteilung` | `get_training_type_distribution_data` | `get_trainingstyp_verteilung` | Ausgabe: Top-3-Text (kein JSON); Registry 2026-04 an Ist angeglichen |
|
|
||||||
| `training_minutes_week` | `calculate_training_minutes_week` | `_safe_int` | |
|
|
||||||
| `training_frequency_7d` | `calculate_training_frequency_7d` | `_safe_int` | |
|
|
||||||
| `quality_sessions_pct` | `calculate_quality_sessions_pct` | `_safe_int` | |
|
|
||||||
| `proxy_internal_load_7d` | `calculate_proxy_internal_load_7d` | `_safe_int` | |
|
|
||||||
| `monotony_score` | `calculate_monotony_score` | `_safe_float` | |
|
|
||||||
| `strain_score` | `calculate_strain_score` | `_safe_int` | |
|
|
||||||
| `rest_day_compliance` | `calculate_rest_day_compliance` | `_safe_int` | |
|
|
||||||
| `ability_balance_strength` | `calculate_ability_balance_strength` | `_safe_int` | abilities in `activity_log` |
|
|
||||||
| `ability_balance_endurance` | `calculate_ability_balance_endurance` | `_safe_int` | |
|
|
||||||
| `ability_balance_mental` | `calculate_ability_balance_mental` | `_safe_int` | |
|
|
||||||
| `ability_balance_coordination` | `calculate_ability_balance_coordination` | `_safe_int` | |
|
|
||||||
| `ability_balance_mobility` | `calculate_ability_balance_mobility` | `_safe_int` | |
|
|
||||||
| `vo2max_trend_28d` | `calculate_vo2max_trend_28d` | `_safe_float` | |
|
|
||||||
| `activity_score` | `calculate_activity_score` | `_safe_int` | |
|
|
||||||
| `training_frequency_by_type_md` | `get_training_frequency_by_type_data` | `get_training_frequency_by_type_md` | Markdown-Tabelle |
|
|
||||||
| `training_inter_session_gap_md` | `get_training_inter_session_gap_data` | `get_training_inter_session_gap_md` | Markdown-Text |
|
|
||||||
| `training_sessions_recent_json` | `get_training_sessions_recent_weeks_data` (+ `enrich_sessions_with_metrics`) | `_safe_json('training_sessions_recent_json')` | JSON inkl. `session_metrics[]` pro Session |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Schichten-Disziplin (Checkliste)
|
|
||||||
|
|
||||||
- [x] Kein `SELECT` auf `activity_log` / `activity_session_metrics` in den **Layer‑2a**-Funktionen oben — nur Aufrufe in Layer 1 bzw. `_safe_*`-Wrapper.
|
|
||||||
- [x] `get_activity_detail` / `get_training_sessions_recent_json` liefern EAV nur über **bereits gemergte** `session_metrics` (Merge-Kanon: `activity_log` vor EAV).
|
|
||||||
- [x] Registry-Metadaten: `data_layer_module` / `data_layer_function` pro Key in `placeholder_registrations/activity_metrics.py` und `activity_session_insights.py`.
|
|
||||||
- [x] Korrektur Registry: `activity_summary.resolver_function` = `get_activity_summary` (war veraltet: `_format_activity_summary`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Nächste Schritte (Roadmap)
|
|
||||||
|
|
||||||
2. ~~**Registry-Texte:** `semantic_contract` / `known_limitations` für dynamische `session_metrics` (tcp/ttp) und Merge-Kanon — **erledigt** (`activity_detail`, `training_sessions_recent_json`); dazu **`trainingstyp_verteilung`**-Metadaten von veraltetem „JSON/Resolver-SQL“ auf Ist (**Layer 1 + Top-3-Text**) korrigiert.~~
|
|
||||||
3. **History / Layer 2b:** EAV-Zeitreihen nicht über Platzhalter, sondern dedizierte Layer‑1-/Chart-Pfade.
|
|
||||||
4. **Optional:** Gitea-Issue „Activity Layer 2a“ bei Änderungen an `activity_metrics` pflegen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Referenzen
|
|
||||||
|
|
||||||
- `backend/placeholder_resolver.py` — `PLACEHOLDER_MAP` (Training/Aktivität)
|
|
||||||
- `backend/placeholder_registrations/activity_metrics.py`
|
|
||||||
- `backend/placeholder_registrations/activity_session_insights.py`
|
|
||||||
- `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` §2.1a (Navigation Read vs. Berechnen)
|
|
||||||
|
|
@ -1,215 +0,0 @@
|
||||||
# Aktivität: Zielarchitektur & Phasenplan (Produktionsreife)
|
|
||||||
|
|
||||||
**Stand:** 2026-04-16
|
|
||||||
**Status:** Normative Zielrichtung für `activity_log`, EAV, Composites, Import, Layer 1/2.
|
|
||||||
**Ergänzt:** `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` (Ist-Modell, APIs, Tests).
|
|
||||||
**Phase A:** abgeschlossen — Kanon-Tabelle [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md).
|
|
||||||
**Phase B:** in Arbeit — Consumer-Audit und Lesepfad-Härtung (siehe §4 Phase B).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Leitprinzipien
|
|
||||||
|
|
||||||
| Prinzip | Bedeutung |
|
|
||||||
|---------|-----------|
|
|
||||||
| **Layer 1 = Single Source of Truth** | Alle Auswertungen (Charts, Scores, strukturierte Platzhalter) lesen **nur** über `data_layer` (kanonische Funktionen). Keine parallele SQL-Logik in Routern oder im Placeholder-Resolver für Aktivität. |
|
|
||||||
| **Eine semantische Größe, eine kanonische Quelle** | Kein Dauer-Sync derselben Bedeutung in `activity_log`-Spalte **und** EAV. Übergang: dokumentierte Abschaltung, nicht implizites Driften. |
|
|
||||||
| **Spine vs. Parameter** | `activity_log` trägt Identität, Zeit, Typ, Notizen, Audit + **heiße** universelle Skalare (siehe §2.2). Alles Typ-/Admin-Dynamische über EAV. |
|
|
||||||
| **Composites = Archetyp im Code, Konfiguration in der DB** | Struktur (7+2 Archetypen) und Validierung **versioniert im Repo**; Admin **wählt** Archetyp, **benennt** Slots, **bindet** Sportarten, **mappt** CSV → `(parameter_id, slot_key)`. Kein freies JSON-Schema im Admin. |
|
|
||||||
| **Import explizit** | Jede CSV-Spalte hat ein klares Ziel: Spine-Spalte, skalarer Parameter oder **Slot** eines Composite-Parameters. Typkonvertierung zentral (Executor / Converter), nicht verteilt. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Zielarchitektur (Gesamtbild)
|
|
||||||
|
|
||||||
### 2.1 Schichtenmodell
|
|
||||||
|
|
||||||
```
|
|
||||||
[CSV / UI / API Write]
|
|
||||||
↓
|
|
||||||
Orchestrator & Router (Auth, Transaktionen, Feature-Checks)
|
|
||||||
↓
|
|
||||||
Persistenz: activity_log (Spine + heiße Skalare) + activity_session_metrics (EAV)
|
|
||||||
↓
|
|
||||||
Layer 1: data_layer (activity_session_metrics.py, activity_metrics.py, …)
|
|
||||||
↓
|
|
||||||
Layer 2a/2b: Platzhalter-Resolver (Formatierung), Chart-Endpoints (Chart.js-Shapes)
|
|
||||||
↓
|
|
||||||
KI / UI / Export
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Orchestrator:** Schreibpfad, Konsistenz nach Write (kein zweites „Lesen der Wahrheit“ neben Layer 1; optional nur Post-Write-Hooks).
|
|
||||||
- **Resolver:** für Aktivität **kein** direkter DB-Zugriff; nur Aufruf von Layer 1.
|
|
||||||
|
|
||||||
### 2.1a Navigationsregel: wo nachsehen (ohne Datei-Zwang)
|
|
||||||
|
|
||||||
Die **physische** Aufteilung ist dreigeteilt: **`activity_log`** (Spine + heiße Spalten), **EAV-Skalare** (`activity_session_metrics` + numerische/textuelle `value_*`), **EAV-Composites** (ein Parameter, Nutzlast z. B. JSON/JSONB im EAV-Datensatz). **Fachlich** soll nach außen **eine homogene Session-Sicht** entstehen — Consumer sollen nicht selbst entscheiden, aus welcher Tabelle/Welche Form ein Wert kommt.
|
|
||||||
|
|
||||||
| Thema | Wo nachsehen (Ist; Ziel: Schnittstelle stabil, Datei optional splittbar) |
|
|
||||||
|--------|--------------------------------------------------------------------------|
|
|
||||||
| **Homogene Session lesen** (Merge Spalte + EAV-Skalare + später Composite-Payload) | `data_layer/activity_session_metrics.py` — u. a. `get_activity_session_logical_unit`, `enrich_sessions_with_metrics`, `merge_column_backed_and_eav_metrics` |
|
|
||||||
| **Schreiben / Import / API-Persistenz** | `data_layer/activity_persistence_orchestrator.py` (+ Router) |
|
|
||||||
| **Berechnungen, Aggregationen, Scores** über viele Sessions oder Zeitfenster | `data_layer/activity_metrics.py` — arbeitet auf der **vereinheitlichten** Session-Datenlage (über die Read-Funktionen oben), nicht durch paralleles Mergen der drei Quellen im Caller |
|
|
||||||
|
|
||||||
**Hinweis:** Orchestrator und Read-Merge **müssen nicht** in derselben Datei stehen. Entscheidend ist, dass es **genau eine dokumentierte Read-Fassade** für „Session inkl. aller effektiven Metriken“ gibt und Layer‑1‑Berechnungen **nur** diese Fassade (oder deren Ergebnisstrukturen) nutzen. Eine spätere Umbenennung oder Auslagerung in z. B. `activity_read_gateway.py` ändert die Rolle nicht — nur der **eine Einstieg** muss in dieser Doku und im Code auffindbar bleiben.
|
|
||||||
|
|
||||||
### 2.2 `activity_log` (Spine + heiße Skalare)
|
|
||||||
|
|
||||||
**Maschinenlesbarer Kanon:** `backend/data_layer/activity_data_canon.py` (`ACTIVITY_MODULE_REGISTRY_FIELD_KEYS`, `ACTIVITY_EAV_PRIMARY_PARAMETER_KEYS`, Legacy-Lesefallback für EAV-primäre Parameter).
|
|
||||||
|
|
||||||
**Immer (fachlich minimal + listenfähig):** `id`, `profile_id`, Kalender-/Zeitfenster (`date`, `started_at`/`ended_at`, ggf. `start_time`/`end_time` bis Konsolidierung), `duration_min`, `training_type_id` (+ ggf. denormalisierte Kategorie), Legacy `activity_type`, `notes`, `source`, `created`.
|
|
||||||
|
|
||||||
**Heiße Skalare (CSV-Modul + `source_field` nach Migration 057):** u. a. `kcal_active`, `kcal_resting`, `distance_km`, `hr_avg`/`hr_max` (Parameter `avg_hr`/`max_hr`), `duration_min`, `rpe` – für Listen und Standard-Aggregate ohne EAV-Join.
|
|
||||||
|
|
||||||
**EAV-primär (erweiterte Metriken):** z. B. Kadenz, Pace, Leistung, Höhe, Umgebung — `training_parameters.source_field` = NULL; Import schreibt EAV; bei leerem EAV optional Lesefallback auf bestehende `activity_log`-Spalte (Migration 057 + Merge-Logik).
|
|
||||||
|
|
||||||
**Session-Qualität / Auswertungsblob:** z. B. `evaluation`, `quality_label`, `overall_score` – **kein** EAV-Parameter-Raster; semantisch „Ergebnis der Einheit“.
|
|
||||||
|
|
||||||
**Nicht dauerhaft doppelt:** dieselbe Semantik nicht parallel pflegen; siehe entfallener Spalte→EAV-Schreib-Sync, Lesepfad `merge_column_backed_and_eav_metrics`.
|
|
||||||
|
|
||||||
### 2.3 EAV (`activity_session_metrics`)
|
|
||||||
|
|
||||||
- **Skalare:** ein `training_parameter`, genau eine `value_*`-Spalte (wie heute).
|
|
||||||
- **Composites:** ein `training_parameter` pro Composite-Instanz, **ein** gespeichertes Dokument pro Session (serialisiert z. B. in `value_text` als JSON **oder** künftig dedizierte JSONB-Spalte – technische Entscheidung in eigener Migration, Vertrag im Archetyp).
|
|
||||||
- **Merge-/Schema-Logik:** weiterhin zentral in `activity_session_metrics.py` (effektives Schema aus Kategorie + Typ-Overrides).
|
|
||||||
|
|
||||||
### 2.4 Composite-Metamodell (Ziel)
|
|
||||||
|
|
||||||
**Archetypen (Code, begrenzte Menge):** u. a. Band-/Zonenverteilung, Sequenz-/Übergangsprofil, Intervallblock-, Ereignis-/Aktions-, Kopplungs-/Effizienz-, Modellparameter-Profil; optional Technik-/Zyklus-, Readiness-/Recovery-Profil.
|
|
||||||
|
|
||||||
**Pro Archetyp:** feste strukturelle Regeln (erlaubte Slots, Typen, Pflicht/Optional), Validator + Version.
|
|
||||||
|
|
||||||
**In der DB (Admin):** Zuordnung „Parameter X hat Archetyp A“, Slot-Labels (DE/EN), Einheiten, Aktivierung pro Sportart/Kategorie, Sortierung.
|
|
||||||
|
|
||||||
**Import:** CSV-Spalten → `(training_parameter_id, slot_key)` mit stabilen Keys (`z1_sec`, …), nie nur „Spaltenreihenfolge“.
|
|
||||||
|
|
||||||
### 2.5 Universal CSV & Admin
|
|
||||||
|
|
||||||
- Vorlagen: Mapping inkl. **Composite-Slots** und Typkonvertierung (vollständige Matrix Ziel).
|
|
||||||
- UI: Trennung **Kern activity_log** vs. **Parameter/EAV** vs. **Composite-Blöcke** (optisch/UX), um Doppel-Tabellen-Chaos zu vermeiden.
|
|
||||||
|
|
||||||
### 2.6 Layer 2 (Platzhalter & Diagramme)
|
|
||||||
|
|
||||||
- Datenbezug **nur** Layer 1.
|
|
||||||
- Registry-Einträge: `data_layer_module` / `data_layer_function` pflegen; Composite-Auswertung ggf. über Hilfsfunktionen, die JSON → normierte Struktur für Prompts/Charts liefern.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Ist → Soll (Kurz)
|
|
||||||
|
|
||||||
| Bereich | Ist (typisch) | Soll |
|
|
||||||
|---------|----------------|------|
|
|
||||||
| Schreibpfad | Teilweise Doppelhaltung Spalte ↔ EAV, Sync-Hooks | Kanon + gezielte Abschaltung; eine Quelle pro Semantik |
|
|
||||||
| Lesepfad | Layer 1 wächst; Legacy-Spalten noch relevant | `get_activity_session_logical_unit` / `activity_metrics` als alleinige Wahrheit für Consumer |
|
|
||||||
| Composites | Noch nicht im Einklang mit EAV-Metamodell | Archetypen + Slot-Admin + ein Dokument pro Parameter/Session |
|
|
||||||
| Import | Mapping teilweise; Typkonvertierung lückenhaft | Vollständige Konvertierung + Composite-Zusammenbau |
|
|
||||||
| Resolver | Aktivität sauber über Layer 1 | Profil/Focus ggf. später ebenfalls aus Layer 1 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Vorgehensmodell (Phasen)
|
|
||||||
|
|
||||||
Phasen sind **sequentiell** wo „Abhängigkeit“ steht; Teile können parallel (z. B. UI-Polish) laufen, wenn der Kanon steht.
|
|
||||||
|
|
||||||
### Phase A – Kanon & Abschaltplan (Grundlage) ✅
|
|
||||||
|
|
||||||
**Inhalt:** Schriftliche **Kanon-Tabelle**: pro Messgröße genau eine Quelle (`activity_log` | `eav_scalar` | `eav_composite` | `session_quality`). Liste der Keys, für die **Sync/Spiegelung** endet.
|
|
||||||
|
|
||||||
**Definition of Done:** Review im Team; Referenz in diesem Dokument oder Verweis auf Gitea-Kommentar; keine Code-Änderung zwingend.
|
|
||||||
|
|
||||||
**Erledigt (2026-04-16):** [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md) — eine Semantik pro Zeile, verlinkt mit `activity_data_canon.py` und Merge-Logik.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase B – Lesepfad härten (Layer 1) 🔄
|
|
||||||
|
|
||||||
**Inhalt:** Sicherstellen, dass **alle** relevanten Consumer (mind. `activity_metrics` für Platzhalter/Charts, Activity-Detail-API) dieselbe Merge-/Fallback-Logik nutzen; Legacy-Spalten nur noch als dokumentierter Fallback bis Enddatum.
|
|
||||||
|
|
||||||
**Definition of Done:** Kurze Audit-Liste „Router/Resolver greifen nicht an Aktivität vorbei“; Tests oder manuelle Stichprobe für Detail + ein Chart + 2 Platzhalter.
|
|
||||||
|
|
||||||
**Abhängigkeit:** Phase A für „welche Spalten noch Fallback sind“.
|
|
||||||
|
|
||||||
**Audit-Stand (2026-04-16, ergänzt Export):**
|
|
||||||
|
|
||||||
| Consumer | Nutzt Layer-1-Merge (`enrich_sessions_with_metrics` / `get_activity_session_logical_unit`) | Anmerkung |
|
|
||||||
|----------|---------------------------------------------------------------------------------------------|-----------|
|
|
||||||
| `GET /api/activity/{eid}` | ✅ `get_activity_session_logical_unit` | Referenz-Detail |
|
|
||||||
| `GET /api/activity` (Liste) | ✅ seit 2026-04-16 `enrich_sessions_with_metrics` auf jeder Listen-Antwort | vorher nur Roh-Spalten |
|
|
||||||
| `activity_metrics.get_activity_detail_data` | ✅ | Platzhalter `{{activity_detail}}` |
|
|
||||||
| `activity_metrics.get_training_sessions_recent_weeks_data` | ✅ | KI-Kontext |
|
|
||||||
| `placeholder_resolver` (Aktivität) | ✅ nur `activity_metrics` | kein paralleles SQL |
|
|
||||||
| `GET /api/export/json` (`activity`) | ✅ `enrich_sessions_with_metrics` + `serialize_dates` | `session_metrics` pro Zeile |
|
|
||||||
| `GET /api/export/csv` (Training-Zeilen) | ✅ `enrich_sessions_with_metrics` | gemergte EAV in Spalte „Details“ |
|
|
||||||
| `GET /api/export/zip` (`data/activity.csv`) | ✅ `enrich_sessions_with_metrics` | Zusatzspalte `session_metrics_json` (Import ignoriert sie) |
|
|
||||||
| `get_activity_summary_data` | n. a. | rein aggregiert (`SUM`/`COUNT`), keine Session-EAV |
|
|
||||||
| `routers/charts.py` (A1–A8) | Spalten-Aggregate | bewusst: Dauer/RPE/HF aus **`activity_log`**-Kanon; kein EAV-Join nötig für definierte Charts |
|
|
||||||
| `activity_stats` (`GET /api/activity/stats`) | nur Spalten | Kacheln: `kcal`/`duration` aus Kernspalten |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase C – Schreibpfad entschlacken
|
|
||||||
|
|
||||||
**Inhalt:** Orchestrierung/CSV: kein Schreiben derselben Semantik an zwei Orten; `sync_column_backed_session_metrics` (o. ä.) **stufig abschalten** oder auf Notfall-Flag; Import schreibt gemäß Kanon.
|
|
||||||
|
|
||||||
**Definition of Done:** Deploy auf Prod mit Monitoring; Stichprobe Import + manuelle Bearbeitung; keine Regression in Listenansicht.
|
|
||||||
|
|
||||||
**Abhängigkeit:** Phase A + B (sonst Lücken beim Lesen).
|
|
||||||
|
|
||||||
**Analyse (2026-04-16, nur Ist-Review):** Es gibt **keinen aktiven** Schreibpfad mehr, der `activity_log`-Spalten für `source_field`-Parameter **dauerhaft nach EAV spiegelt**.
|
|
||||||
|
|
||||||
| Prüfpunkt | Ergebnis |
|
|
||||||
|-----------|----------|
|
|
||||||
| `sync_column_backed_session_metrics` | Nur noch **Definition** in `activity_session_metrics.py`, als veraltet markiert; **keine Aufrufer** im Repo (grep). Laufzeit-Sync: **abgestellt**. |
|
|
||||||
| `run_activity_post_write_hooks` / `…_import` | Nur **Auto-Eval** (optional); Kommentar: **kein** Spalte→EAV-Sync. |
|
|
||||||
| Universal-CSV (`executor.py`) | Kernfelder → `activity_log` (`activity_csv_registry_updates_from_mapped` + `update_activity_columns` / Insert); EAV → `upsert_session_metrics_from_csv_mapped`. Registry-Keys werden **nicht** nach EAV geschrieben; bei `source_field` wird EAV **übersprungen**, wenn die Spalte **bereits befüllt** ist — vermeidet bewusst doppelte Speicherung. |
|
|
||||||
| REST `PUT /metrics` | Kommentar in Code: **kein** `sync_column_backed` nach EAV-Ersatz. |
|
|
||||||
| Migrationen 055 / 057 | **Einmaliger** Backfill/Schwenk, kein fortlaufender Sync. |
|
|
||||||
|
|
||||||
**Lesepfad (2026-04-16):** `merge_column_backed_and_eav_metrics` bevorzugt **immer** `activity_log`, wenn ein kanonischer Spaltenwert existiert: zuerst `source_field`, dann Registry-Spalte gleichen Keys, dann Legacy-Spalten für EAV-primäre Parameter, zuletzt EAV. Doppelte physische Schreiborte sind damit in der effektiven Sicht **ohne EAV-Vorrang** behoben.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase D – Composite MVP
|
|
||||||
|
|
||||||
**Inhalt:** Ein Archetyp end-to-end (z. B. **Band-/Zonenverteilung**): Code-Validator, DB-Binding (Parameter + Slots), Admin-UI minimal, Import **5 Spalten → ein JSON-Dokument** mit festen Keys, Layer-1-Read (Roh + optional `expand_*`).
|
|
||||||
|
|
||||||
**Definition of Done:** Eine Sportart/Kategorie befüllbar; Dokumentation des JSON-Vertrags im Repo; pytest für Validator/Zusammenbau wo möglich.
|
|
||||||
|
|
||||||
**Abhängigkeit:** Phase A (Kanon „Composites nur als Dokument, nicht doppelt in Spalten“).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase E – Composite-Ausbau & Typkonvertierung Import
|
|
||||||
|
|
||||||
**Inhalt:** Weitere Archetypen nach Priorität; Universal-CSV **vollständige** Typkonvertierung für alle gemappten Ziele; Dialog-/Mapping-Konzept (Kern vs. Parameter vs. Composite).
|
|
||||||
|
|
||||||
**Definition of Done:** Matrix „Zieltyp × Converter“ gepflegt; Admin-Flow reviewt.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase F – Produktionshärtung
|
|
||||||
|
|
||||||
**Inhalt:** Performance-Indizes bei Bedarf; Observability (Import-Fehler, Validierungs-Fails); Resolver/Profil optional komplett ohne `get_db` für domänische Daten; Doku + Gitea-Issues geschlossen/aktualisiert.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Was zuerst?
|
|
||||||
|
|
||||||
**Erledigt:** Phase A — [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md).
|
|
||||||
|
|
||||||
**Aktuell:** Phase B 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.
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
# Aktivität: Skalar-Kanon (eine Semantik → eine Quelle)
|
|
||||||
|
|
||||||
**Stand:** 2026-04-16
|
|
||||||
**Normativer Code:** `backend/data_layer/activity_data_canon.py`
|
|
||||||
**Kontext:** `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` (Phase A abgeschlossen)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Spine & Identität (`activity_log`, nicht EAV)
|
|
||||||
|
|
||||||
Diese Felder sind **keine** `training_parameters`-Skalare. Sie gehören zur Session-Zeile.
|
|
||||||
|
|
||||||
| Semantik | DB / API | Kanonische Quelle | Lesefallback | Sync Spalte↔EAV |
|
|
||||||
|----------|----------|-------------------|--------------|-----------------|
|
|
||||||
| Primärschlüssel | `activity_log.id` | `activity_log` | — | — |
|
|
||||||
| Profil | `profile_id` | `activity_log` | — | — |
|
|
||||||
| Kalendertag | `date` | `activity_log` | — | — |
|
|
||||||
| Start / Ende (Zeit) | `start_time`, `end_time`, `started_at`, `ended_at` | `activity_log` | — | — |
|
|
||||||
| Trainingsart (Freitext/Legacy) | `activity_type` | `activity_log` | — | — |
|
|
||||||
| Referenz Trainingstyp | `training_type_id`, `training_category`, … | `activity_log` (+ `training_types`) | — | — |
|
|
||||||
| Notiz | `notes` | `activity_log` | — | — |
|
|
||||||
| Quelle / Import | `source`, `created`, … | `activity_log` | — | — |
|
|
||||||
| Session-Auswertung | `evaluation`, `quality_label`, `overall_score`, … | `activity_log` (Blob/Ergebnis) | — | Kein EAV-Raster |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Kernfelder CSV-Modul `activity` (= „heiße“ Skalare)
|
|
||||||
|
|
||||||
Abgeleitet aus `csv_parser.module_registry.MODULE_DEFINITIONS["activity"].fields` — maschinenlesbar über `ACTIVITY_MODULE_REGISTRY_FIELD_KEYS` in `activity_data_canon.py`.
|
|
||||||
|
|
||||||
| Semantik | Key (Registry/API) | Kanonische Quelle | Lesefallback | Bemerkung |
|
|
||||||
|----------|-------------------|-------------------|--------------|-----------|
|
|
||||||
| Dauer | `duration_min` | **`activity_log`** | — | Aggregates, Listen |
|
|
||||||
| Aktive Energie | `kcal_active` | **`activity_log`** | — | |
|
|
||||||
| Ruhe-Energie | `kcal_resting` | **`activity_log`** | — | |
|
|
||||||
| Distanz | `distance_km` | **`activity_log`** | — | |
|
|
||||||
| Ø HF | `hr_avg` (Parameter oft `avg_hr` in EAV-Schema) | **`activity_log`** | EAV nur wenn `source_field` / Profil-Schema | `merge_column_backed_and_eav_metrics`: Spalte schlägt EAV |
|
|
||||||
| Max-HF | `hr_max` | **`activity_log`** | analog | |
|
|
||||||
| RPE | `rpe` | **`activity_log`** | analog | |
|
|
||||||
|
|
||||||
Schreibpfad: Universal-CSV und API sollen diese Keys auf **`activity_log`** mappen, sofern nicht ausdrücklich ein EAV-primärer Parameter (§3) gewählt ist.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. EAV-primäre Parameter (erweiterte Skalare)
|
|
||||||
|
|
||||||
`ACTIVITY_EAV_PRIMARY_PARAMETER_KEYS` in `activity_data_canon.py`. **`training_parameters.source_field`** = NULL (nach Kanon / Migration 057): kanonischer Speicher ist **`activity_session_metrics`**.
|
|
||||||
|
|
||||||
| Parameter-Key (`training_parameters.key`) | Legacy-Spalte `activity_log` | Schreib-Kanon (Ziel) |
|
|
||||||
|-------------------------------------------|------------------------------|------------------------|
|
|
||||||
| `min_hr` | `hr_min` | **EAV** |
|
|
||||||
| `pace_min_per_km` | `pace_min_per_km` | **EAV** |
|
|
||||||
| `cadence` | `cadence` | **EAV** |
|
|
||||||
| `avg_power` | `avg_power` | **EAV** |
|
|
||||||
| `elevation_gain` | `elevation_gain` | **EAV** |
|
|
||||||
| `temperature_celsius` | `temperature_celsius` | **EAV** |
|
|
||||||
| `humidity_percent` | `humidity_percent` | **EAV** |
|
|
||||||
| `avg_hr_percent` | `avg_hr_percent` | **EAV** |
|
|
||||||
| `kcal_per_km` | `kcal_per_km` | **EAV** |
|
|
||||||
|
|
||||||
**Lesen:** `merge_column_backed_and_eav_metrics` — wenn Legacy-Spalte **und** EAV einen Wert haben, **gewinnt die Spalte** (kanonische `activity_log`-Sicht). EAV nur, wenn die Spalte leer/nicht koerzierbar ist.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Profil-/Typ-dynamische Skalare (EAV, nicht in Registry-Kernliste)
|
|
||||||
|
|
||||||
| Semantik | Kanonische Quelle | Lesefallback |
|
|
||||||
|----------|-------------------|--------------|
|
|
||||||
| Admin-definierte Parameter (Attributprofil Kategorie/Typ) | **`activity_session_metrics`** + `training_parameters` | — |
|
|
||||||
| Parameter mit `source_field` → Spalte | **`activity_log`** (Spalte) | EAV ergänzend; Leseregel: Spalte bevorzugt (kein veraltetes EAV) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Composites (Zielbild, noch nicht Kanon-Zeile pro Slot)
|
|
||||||
|
|
||||||
| Semantik | Kanonische Quelle (Ziel) |
|
|
||||||
|----------|---------------------------|
|
|
||||||
| Strukturierte Composite-Dokumente (z. B. Zonen/Bänder) | **EAV** ein Dokument pro Parameter/Session (siehe `ACTIVITY_COMPOSITE_METRICS_IMPLEMENTATION_CONCEPT.md`) |
|
|
||||||
|
|
||||||
Kein dauerhaftes Spiegeln derselben Semantik in `activity_log`-Spalten.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Sync & Übergang
|
|
||||||
|
|
||||||
- **Kein** automatischer Dauer-Sync „Spalte → EAV“ für dieselbe Semantik; Lesepfad vereinheitlicht die Sicht (`merge_column_backed_and_eav_metrics`).
|
|
||||||
- Optionale **Backfill**-Migration/Skript (idempotent) nur nach fachlicher Freigabe — siehe EAV-Agent-Guide §6.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Referenzen
|
|
||||||
|
|
||||||
- `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` — Phasen A–F
|
|
||||||
- `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` — APIs, Tests
|
|
||||||
- `activity_data_canon.py` — `ACTIVITY_LOG_PATCHABLE_COLUMNS`, Legacy-Map
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
# Activity Session Metrics (EAV) – Umsetzungs- & Agent-Guide
|
|
||||||
|
|
||||||
**Stand:** 2026-04-14
|
|
||||||
**Status:** Kern-Backend (Migration 054, Layer 1, Admin- & Nutzer-API) umgesetzt; Admin-UI & CSV-Mapping folgen.
|
|
||||||
**Ziel:** Sportspezifische **Attributprofile** (Kategorie + optional Trainingstyp-Override) administrierbar; Messwerte pro Session in **EAV**; **alle Auswertungen** sollen künftig über **Layer 1** (`data_layer`) laufen.
|
|
||||||
|
|
||||||
**Zielarchitektur, Phasenplan (Produktionsreife):** [`ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md`](./ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md) – Kanon `activity_log`/EAV, Composites, Import, Layer 1/2, Reihenfolge A–F.
|
|
||||||
|
|
||||||
**Composite-Parameter (EAV, JSONB, Archetypen):** detailliertes Umsetzungskonzept für Agenten: [`ACTIVITY_COMPOSITE_METRICS_IMPLEMENTATION_CONCEPT.md`](./ACTIVITY_COMPOSITE_METRICS_IMPLEMENTATION_CONCEPT.md).
|
|
||||||
|
|
||||||
**Kanon (Code):** `backend/data_layer/activity_data_canon.py` (Repo-Root) — CSV-Modul `activity` vs. EAV-primär; Migration **057**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Produktions-Migrationen (Pflicht)
|
|
||||||
|
|
||||||
- **Nur additive Änderungen** bis zur Stabilisierung: neue Tabellen/Spalten **nullable**, kein `DROP COLUMN` / `DELETE` von Altbestand in derselben Story.
|
|
||||||
- Neue Migrationen: **`backend/migrations/054_*.sql`** (nächste freie Nummer nach 053 einhalten).
|
|
||||||
- **Prod-Checkliste vor Deploy:**
|
|
||||||
1. Backup / Snapshot der DB.
|
|
||||||
2. Migration auf **Kopie** der Prod-DB laufen lassen; Container-Start (`db_init`) verifizieren.
|
|
||||||
3. Stichprobe: `activity_log`-Zeilen unverändert; neue Tabellen leer oder nur Seed.
|
|
||||||
- **Datenhaltung:** Bestehende Spalten in `activity_log` bleiben **Quelle für Alt-Daten**; EAV (`activity_session_metrics`) ist der **kanonische Ort für konfigurierte Session-Metriken**, sobald geschrieben. Backfill Altspalten → EAV ist **separater Schritt** (siehe §6).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Datenmodell (Ist nach Migration 054)
|
|
||||||
|
|
||||||
| Tabelle | Zweck |
|
|
||||||
|---------|--------|
|
|
||||||
| `training_parameters` | Katalog messbarer Größen (`key`, `data_type`, `unit`, `validation_rules`, …) – bereits Migration 013; Admin-API ergänzt. |
|
|
||||||
| `training_category_parameter` | Welche Parameter für welche **`training_types.category`** (z. B. `cardio`) gelten: `sort_order`, `required`, `ui_group`. |
|
|
||||||
| `training_type_parameter` | Zusatzparameter oder **Overrides** pro **`training_types.id`**: `sort_order`, `required`, `ui_group` (NULL = von Kategorie erben). |
|
|
||||||
| `activity_session_metrics` | EAV: `(activity_log_id, training_parameter_id)` eindeutig; genau eine Wertspalte `value_num` / `value_int` / `value_text` / `value_bool`. |
|
|
||||||
| `activity_log` | **Neu:** `started_at`, `ended_at` (`TIMESTAMPTZ`, nullable) – für spätere Dedupe/Intervalle; **kein** Pflichtfeld in v1. |
|
|
||||||
|
|
||||||
**Merge-Logik effektives Schema** (Layer 1, eine Funktion):
|
|
||||||
|
|
||||||
1. Kategorie ermitteln: aus Zeile `training_category` oder aus `training_types.category` via `training_type_id`.
|
|
||||||
2. Basis = alle Zeilen `training_category_parameter` für diese Kategorie, Join auf `training_parameters` (aktiv).
|
|
||||||
3. Für jeden Eintrag in `training_type_parameter` zum gewählten Typ: gleiche `training_parameter_id` → Overrides anwenden; nur im Typ vorhanden → anhängen.
|
|
||||||
4. Sortierung: `sort_order` aufsteigend, dann `key`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Layer 1 – Kanonische Module
|
|
||||||
|
|
||||||
| Modul | Pfad | Aufgabe |
|
|
||||||
|-------|------|---------|
|
|
||||||
| Session-Metriken & Schema | `backend/data_layer/activity_session_metrics.py` | `resolve_activity_attribute_schema`, `fetch_activity_session_metrics`, `replace_activity_session_metrics`, `get_activity_session_logical_unit`, `enrich_sessions_with_metrics`, `merge_column_backed_and_eav_metrics`. |
|
|
||||||
|
|
||||||
**Spalten vs. EAV (Lesepfad):** `merge_column_backed_and_eav_metrics` / `get_activity_session_logical_unit` / `enrich_sessions_with_metrics` werten Parameter mit `source_field` **primär aus `activity_log`** aus; EAV ist Fallback (z. B. Legacy) oder für Parameter ohne Spalte. **Kein** automatischer Spalte→EAV-Schreib-Sync mehr in `run_activity_post_write_hooks` / Import-Hooks (vermeidet Doppelhaltung).
|
|
||||||
|
|
||||||
**Regeln für Agenten:**
|
|
||||||
|
|
||||||
- **Keine** zweite Implementierung derselben Merge- oder Validierungslogik in Routern.
|
|
||||||
- Platzhalter / Charts, die Session-Details brauchen: **nur** diese Layer-1-Helfer erweitern oder aufrufen (z. B. `activity_metrics.get_training_sessions_recent_weeks_data` nutzt `enrich_sessions_with_metrics`).
|
|
||||||
- Router: `get_db`, `get_cursor`, Auth; Business-Validierung delegieren an `activity_session_metrics`.
|
|
||||||
|
|
||||||
**KI-Kontext:** In `training_sessions_recent_json` enthält jedes Element von `session_metrics` neben `key`/`value` die Felder `name_de`, `name_en`, `description_de`, `description_en` (aus dem effektiven Schema). Für nicht selbsterklärende Keys soll im Katalog `training_parameters.description_*` gepflegt werden (Admin). Ergänzend liefert der Platzhalter `{{training_parameters_glossary_md}}` die gesamte aktive Parameter-Legende als Markdown-Tabelle (`get_training_parameters_ki_glossary_data` → `get_training_parameters_glossary_md`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. API (Ist / geplant)
|
|
||||||
|
|
||||||
### Admin (`require_admin`)
|
|
||||||
|
|
||||||
| Methode | Pfad | Beschreibung |
|
|
||||||
|---------|------|--------------|
|
|
||||||
| GET/POST | `/api/admin/training-parameters` | Katalog lesen / Parameter anlegen |
|
|
||||||
| PUT/DELETE | `/api/admin/training-parameters/{id}` | Aktualisieren / Soft-deaktivieren (`is_active`) |
|
|
||||||
| GET | `/api/admin/training-category-parameters?category=` | Zuordnungen Kategorie |
|
|
||||||
| POST | `/api/admin/training-category-parameters` | Zuordnung anlegen |
|
|
||||||
| DELETE | `/api/admin/training-category-parameters/{id}` | Zuordnung entfernen |
|
|
||||||
| GET | `/api/admin/training-type-parameters?training_type_id=` | Zuordnungen Typ |
|
|
||||||
| POST | `/api/admin/training-type-parameters` | Zuordnung anlegen |
|
|
||||||
| DELETE | `/api/admin/training-type-parameters/{id}` | Zuordnung entfernen |
|
|
||||||
|
|
||||||
Router: `backend/routers/admin_training_parameters.py`, `backend/routers/admin_activity_attribute_profiles.py`.
|
|
||||||
|
|
||||||
### Nutzer (`require_auth`)
|
|
||||||
|
|
||||||
| Methode | Pfad | Beschreibung |
|
|
||||||
|---------|------|--------------|
|
|
||||||
| GET | `/api/activity/{eid}` | Session-Kopf + `schema` + `metrics` (Layer 1) |
|
|
||||||
| PUT | `/api/activity/{eid}/metrics` | **Voller Ersatz** der EAV-Metriken für diese Session (Liste `{parameter_key, value}`) |
|
|
||||||
|
|
||||||
`ActivityEntry` unverändert für bestehende Create/Update-Routen; optionale Erweiterung um `started_at`/`ended_at` in späterem Schritt.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Agent-Checkliste (nächste Iterationen)
|
|
||||||
|
|
||||||
**Layer 2a (Platzhalter Aktivität):** Abgleich Registry ↔ Resolver ↔ Layer 1 — [`ACTIVITY_LAYER2A_PLACEHOLDER_AUDIT.md`](./ACTIVITY_LAYER2A_PLACEHOLDER_AUDIT.md) (Issue #53). **Schritt 2:** `semantic_contract` / `known_limitations` für dynamische `session_metrics` und Korrektur `trainingstyp_verteilung` in der Registry.
|
|
||||||
|
|
||||||
Siehe **Phasen A–F** in [`ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md`](./ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md). Kurz:
|
|
||||||
|
|
||||||
- [x] **Phase A:** Kanon-Tabelle (eine Quelle pro Semantik) — [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md).
|
|
||||||
- [ ] **Phase B:** Lesepfad Layer 1 härten (Consumer-Audit fortlaufend — siehe `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` §4 Phase B).
|
|
||||||
- [ ] **Phase C:** Schreibpfad: Doppelhaltung / Sync stufenweise abschalten.
|
|
||||||
- [ ] **Phase D:** Composite-MVP (ein Archetyp E2E).
|
|
||||||
- [ ] **Phase E:** Archetypen ausbauen + CSV-Typkonvertierung vollständig + Mapping-UX.
|
|
||||||
- [ ] **Phase F:** Härtung Prod (Indizes, Observability, Doku).
|
|
||||||
|
|
||||||
Legacy-Punkte:
|
|
||||||
|
|
||||||
- [x] Admin-UI: `frontend/src/pages/AdminActivityAttributeProfilesPage.jsx`, Route `/admin/activity-attribute-profiles`, Admin-Nav-Gruppe „Trainingstypen“.
|
|
||||||
- [x] `/activity` Frontend: Bearbeiten lädt `GET /api/activity/{id}`, dynamische Felder + `PUT /api/activity/{id}/metrics`.
|
|
||||||
- [ ] Universal CSV: Mapping inkl. EAV/Composite-Ziele + Executor (fortlaufend).
|
|
||||||
- [ ] Optional: Backfill / Abschluss `source_field`-Pfad nach Kanon (Phase A/C).
|
|
||||||
- [ ] Dedupe Polar/Apple: nach stabilen `started_at`/`ended_at` + Policy (eigenes Issue).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Backfill (nicht in Migration 054)
|
|
||||||
|
|
||||||
Separates Skript oder Migration **055+**, wenn fachlich freigegeben:
|
|
||||||
|
|
||||||
- Pro aktivem `training_parameter` mit gesetztem `source_field`: Wert aus `activity_log` lesen, in EAV schreiben, wenn noch keine Zeile existiert.
|
|
||||||
- Idempotent (`ON CONFLICT DO NOTHING` oder Upsert-Regel dokumentieren).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Automatische Tests (pytest, ohne DB)
|
|
||||||
|
|
||||||
Aus **`backend/`**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m pytest tests/test_activity_session_metrics.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Abdeckung: reine Merge-Logik (`merge_parameter_schema_rows`), Validierung (`_validate_single_value`), `resolve_activity_attribute_schema` mit Mock-Cursor, `enrich_sessions_with_metrics` mit Mock-Cursor.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Referenzen
|
|
||||||
|
|
||||||
- Migration 013: `training_parameters`
|
|
||||||
- Migration 004/014: `training_types`, `activity_log`-Erweiterungen
|
|
||||||
- Pattern Admin-Katalog: `routers/admin_reference_value_types.py`
|
|
||||||
- Platzhalter Session-JSON: `data_layer/activity_metrics.py` → `get_training_sessions_recent_weeks_data`
|
|
||||||
- KI-Legende: `get_training_parameters_ki_glossary_data`, Platzhalter `{{training_parameters_glossary_md}}`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Version:** 1.1 · Bei Schema- oder API-Änderungen dieses Dokument und ggf. `CLAUDE.md` Kurzverweis aktualisieren.
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
# BLS Food Reference – technische Spec
|
|
||||||
|
|
||||||
**Stand:** 2026-09-12 · Migration **062** + **063**
|
|
||||||
|
|
||||||
## 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
|
|
||||||
- Migration **065** — Extension-Keys EPA/DHA/DPA/ALA/OMEGA3/OMEGA6 falls BLS sie nicht unter diesem Key hat
|
|
||||||
- `/api/admin/bls/*` — Import, Katalog, Attribute
|
|
||||||
- `/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). Listen-UI: `FoodRecipeDialog` (Suche gelernt + Katalog; Katalogpick upsertet Mapping). Offene Zutaten: `kind=recipe_ingredient` (API-Wert unverändert, UI: Listenzutat). Tandoor-Import und `cooked_yield_g` später, kein Connector in Phase 1.
|
|
||||||
- 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}`; `POST /bls/foods/suggest-batch` für sichtbare Zeilen (max. 80)
|
|
||||||
- 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-Widgets – Anleitung für Coding-Agenten
|
# Dashboard-Lab-Widgets – Anleitung für Coding-Agenten
|
||||||
|
|
||||||
Ziel: Ein neues Dashboard-Widget **end-to-end** korrekt einbinden (Backend-Katalog, Validierung, API-Layout, Frontend-Registrierung, optional Editor für `config` in **Übersicht anpassen**).
|
Ziel: Ein neues Dashboard-Widget **end-to-end** korrekt einbinden (Backend-Katalog, Validierung, API-Layout, Frontend-Registrierung, optional Lab-Editor für `config`).
|
||||||
Kontext: Geschützte Endpoints `GET/PUT /api/app/...` (siehe `backend/routers/app_dashboard.py`). Layout liegt pro Profil in `profiles.dashboard_layout` (JSON). Nutzer-Oberfläche: `frontend/src/pages/DashboardConfigurePage.jsx` (Route z. B. `/settings/dashboard-layout`).
|
Kontext: **Dashboard-Lab** unter geschützten Endpoints `GET/PUT /api/app/...` (siehe `backend/routers/app_dashboard.py`). Layout liegt pro Profil in `profiles.dashboard_layout` (JSON).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -23,7 +23,7 @@ Kontext: Geschützte Endpoints `GET/PUT /api/app/...` (siehe `backend/routers/ap
|
||||||
| Anforderung | Beschreibung |
|
| Anforderung | Beschreibung |
|
||||||
|-------------|--------------|
|
|-------------|--------------|
|
||||||
| **A1 – Zentrale Auflösung** | Backend ermittelt pro Profil (effektiver Tier + Restrictions), welche Widget-IDs **erlaubt** sind – idealerweise in **einer** Stelle (Erweiterung des Katalog-Endpoints oder dedizierter Entitlements-Teil der Response). Intern: `check_feature_access` und später ggf. Mapping Widget-ID → Feature-ID(n) / Cluster. |
|
| **A1 – Zentrale Auflösung** | Backend ermittelt pro Profil (effektiver Tier + Restrictions), welche Widget-IDs **erlaubt** sind – idealerweise in **einer** Stelle (Erweiterung des Katalog-Endpoints oder dedizierter Entitlements-Teil der Response). Intern: `check_feature_access` und später ggf. Mapping Widget-ID → Feature-ID(n) / Cluster. |
|
||||||
| **A2 – Nutzer-Konfigurator** | Im Layout-Konfigurator (**Übersicht anpassen**): Widgets **ohne Berechtigung nicht anbieten** (ausgeblendet oder gar nicht in der Liste). Alle **erlaubten** Widgets bleiben wie heute wählbar. |
|
| **A2 – Nutzer-Konfigurator** | Im Dashboard-Lab (und jedem späteren Layout-Konfigurator): Widgets **ohne Berechtigung nicht anbieten** (ausgeblendet oder gar nicht in der Liste). Alle **erlaubten** Widgets bleiben wie heute wählbar. |
|
||||||
| **A3 – Layout-Persistenz** | `PUT /api/app/dashboard-layout`: Layout darf **keine** nicht erlaubten Widgets dauerhaft speichern – entweder **ablehnen** (422) oder **beim Speichern entfernen/deaktivieren** (Policy festlegen und dokumentieren). Verhindert „gespeichert, aber nie sichtbar“-Zombies. |
|
| **A3 – Layout-Persistenz** | `PUT /api/app/dashboard-layout`: Layout darf **keine** nicht erlaubten Widgets dauerhaft speichern – entweder **ablehnen** (422) oder **beim Speichern entfernen/deaktivieren** (Policy festlegen und dokumentieren). Verhindert „gespeichert, aber nie sichtbar“-Zombies. |
|
||||||
| **A4 – API-/Datenschutz** | Sichtbarkeit im UI reicht nicht: Endpoints, die **Inhalte** für gated Widgets liefern (Charts, KI, …), müssen weiterhin wie heute **eigenständig** über Features abgesichert sein (`check_feature_access`, 403). |
|
| **A4 – API-/Datenschutz** | Sichtbarkeit im UI reicht nicht: Endpoints, die **Inhalte** für gated Widgets liefern (Charts, KI, …), müssen weiterhin wie heute **eigenständig** über Features abgesichert sein (`check_feature_access`, 403). |
|
||||||
|
|
||||||
|
|
@ -42,8 +42,8 @@ Kontext: Geschützte Endpoints `GET/PUT /api/app/...` (siehe `backend/routers/ap
|
||||||
1. **`backend/widget_catalog.py`** – `WIDGET_CATALOG`: erlaubte Widget-IDs, Reihenfolge, Titel/Beschreibung für API und Default-Layout.
|
1. **`backend/widget_catalog.py`** – `WIDGET_CATALOG`: erlaubte Widget-IDs, Reihenfolge, Titel/Beschreibung für API und Default-Layout.
|
||||||
2. **`backend/dashboard_layout_schema.py`** – `DashboardLayoutPayload`: jede Zeile hat `id`, `enabled`, optional `config`. IDs müssen in `ALLOWED_WIDGET_IDS` sein (aus dem Katalog abgeleitet).
|
2. **`backend/dashboard_layout_schema.py`** – `DashboardLayoutPayload`: jede Zeile hat `id`, `enabled`, optional `config`. IDs müssen in `ALLOWED_WIDGET_IDS` sein (aus dem Katalog abgeleitet).
|
||||||
3. **`backend/dashboard_widget_config.py`** – `validate_widget_entry_config`: **nur** Widgets in `WIDGETS_ALLOWING_CONFIG` dürfen **nicht-leere** `config` haben; Keys werden streng validiert (unbekannte Keys → Fehler).
|
3. **`backend/dashboard_widget_config.py`** – `validate_widget_entry_config`: **nur** Widgets in `WIDGETS_ALLOWING_CONFIG` dürfen **nicht-leere** `config` haben; Keys werden streng validiert (unbekannte Keys → Fehler).
|
||||||
4. **Frontend** – `ensureDashboardWidgetsRegistered()` in `frontend/src/widgetSystem/registerDashboardWidgets.js`: verbindet jede Katalog-ID mit einer React-Komponente und mappt `ctx.layoutEntry.config` auf Props.
|
4. **Frontend** – `ensurePilotLabWidgetsRegistered()` in `frontend/src/widgetSystem/registerPilotLabWidgets.js`: verbindet jede Katalog-ID mit einer React-Komponente und mappt `ctx.layoutEntry.config` auf Props.
|
||||||
5. **Layout-Editor (Produkt)** – `frontend/src/pages/DashboardConfigurePage.jsx`: Umsortieren, Ein/Aus, Speichern; **zusätzliche** UI nur nötig, wenn das Widget konfigurierbare Felder braucht.
|
5. **Dashboard-Lab-UI** – `frontend/src/pages/DashboardLabPage.jsx`: Umsortieren, Ein/Aus, Speichern; **zusätzliche** UI nur nötig, wenn das Widget konfigurierbare Felder braucht.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -52,9 +52,9 @@ Kontext: Geschützte Endpoints `GET/PUT /api/app/...` (siehe `backend/routers/ap
|
||||||
| Schritt | Datei | Aktion |
|
| Schritt | Datei | Aktion |
|
||||||
|--------|--------|--------|
|
|--------|--------|--------|
|
||||||
| A | `backend/widget_catalog.py` | Neuen Eintrag `{ "id", "title", "description" }` in `WIDGET_CATALOG` einfügen (Reihenfolge = Default-Reihenfolge im Layout). Optional `"requires_feature": "<features.id>"` für Tarif-Gating (`dashboard_widget_entitlements`). |
|
| A | `backend/widget_catalog.py` | Neuen Eintrag `{ "id", "title", "description" }` in `WIDGET_CATALOG` einfügen (Reihenfolge = Default-Reihenfolge im Layout). Optional `"requires_feature": "<features.id>"` für Tarif-Gating (`dashboard_widget_entitlements`). |
|
||||||
| B | `backend/widget_catalog.py` | Optional: ID zu `DEFAULT_LAB_WIDGET_IDS` hinzufügen, wenn es im Server-Standardlayout **aktiv** sein soll (Feld `lab_default_layout` in der Layout-API). |
|
| B | `backend/widget_catalog.py` | Optional: ID zu `DEFAULT_LAB_WIDGET_IDS` hinzufügen, wenn es im Standard-Lab **aktiv** sein soll. |
|
||||||
| C | `frontend/src/components/dashboard-widgets/MyWidget.jsx` (oder Legacy-Widget unter `dashboard-widgets-legacy/`) | React-Komponente implementieren; typischerweise `refreshTick` aus `mapProps` nutzen, um Daten neu zu laden. |
|
| C | `frontend/src/components/dashboard-widgets/MyWidget.jsx` (oder Pilot-Komponente) | React-Komponente implementieren; typischerweise `refreshTick` aus `mapProps` nutzen, um Daten neu zu laden. |
|
||||||
| D | `frontend/src/widgetSystem/registerDashboardWidgets.js` | `import` + `registerDashboardWidget({ id, Component, mapProps })` – `id` **exakt** wie im Katalog. |
|
| D | `frontend/src/widgetSystem/registerPilotLabWidgets.js` | `import` + `registerDashboardWidget({ id, Component, mapProps })` – `id` **exakt** wie im Katalog. |
|
||||||
| E | `backend/tests/test_widget_catalog.py` | Läuft implizit mit; bei Strukturänderungen Katalog-Tests beachten. |
|
| E | `backend/tests/test_widget_catalog.py` | Läuft implizit mit; bei Strukturänderungen Katalog-Tests beachten. |
|
||||||
| F | `backend/version.py` | `MODULE_VERSIONS["app_dashboard"]` MINOR erhöhen und kurz kommentieren. |
|
| F | `backend/version.py` | `MODULE_VERSIONS["app_dashboard"]` MINOR erhöhen und kurz kommentieren. |
|
||||||
| G | Build/Tests | `pytest` (z. B. `tests/test_dashboard_layout_schema.py`, `test_widget_catalog.py`); `npm run build` im `frontend`. |
|
| G | Build/Tests | `pytest` (z. B. `tests/test_dashboard_layout_schema.py`, `test_widget_catalog.py`); `npm run build` im `frontend`. |
|
||||||
|
|
@ -110,11 +110,11 @@ mapProps: (ctx) => ({
|
||||||
|
|
||||||
**Abgleich mit Chart-Zeitraum:** Für `chart_days` existiert `frontend/src/widgetSystem/bodyChartDays.js` (`BODY_CHART_DAYS_MIN/MAX`, `normalizeBodyChartDays`). Entweder in `mapProps` normalisieren (wie `body_overview`) oder rohen Wert durchreichen und in der Widget-Komponente normalisieren (wie `nutrition_detail_charts` / `TrendKcalWeightWidget`) – **beides** ist im Projekt vertreten; wichtig ist Konsistenz mit der Backend-Grenze 7–90.
|
**Abgleich mit Chart-Zeitraum:** Für `chart_days` existiert `frontend/src/widgetSystem/bodyChartDays.js` (`BODY_CHART_DAYS_MIN/MAX`, `normalizeBodyChartDays`). Entweder in `mapProps` normalisieren (wie `body_overview`) oder rohen Wert durchreichen und in der Widget-Komponente normalisieren (wie `nutrition_detail_charts` / `TrendKcalWeightWidget`) – **beides** ist im Projekt vertreten; wichtig ist Konsistenz mit der Backend-Grenze 7–90.
|
||||||
|
|
||||||
### 3.4 Layout-Editor (`DashboardConfigurePage.jsx`)
|
### 3.4 Dashboard-Lab-Editor (`DashboardLabPage.jsx`)
|
||||||
|
|
||||||
Ohne UI-Änderung bleibt `config` beim Nutzer `{}` – konfigurierbare Widgets brauchen **Editor-Controls**:
|
Ohne UI-Änderung bleibt `config` beim Nutzer `{}` – konfigurierbare Widgets brauchen **Editor-Controls**:
|
||||||
|
|
||||||
- **Einfaches Zahlfeld `chart_days`:** Eintrag in `CHART_DAYS_WIDGET_IDS` (Set oben in `DashboardConfigurePage.jsx`) + bestehendes Label/`aria-label`-Pattern für die Zeitraum-Zeile erweitern (siehe `body_overview`, `nutrition_detail_charts`).
|
- **Einfaches Zahlfeld `chart_days`:** Eintrag in `CHART_DAYS_WIDGET_IDS` (Set oben in der Datei) + bestehendes Label/`aria-label`-Pattern für die Zeitraum-Zeile erweitern (siehe `body_overview`, `nutrition_detail_charts`).
|
||||||
- **Strukturierte Config (Listen, mehrere Booleans):** Eigenes Editor-Komponenten-File nach Vorbild `KpiBoardConfigEditor.jsx` / `QuickCaptureConfigEditor.jsx` einbinden und `setLayout` + `normalizeLayoutForEditor` wie bei den bestehenden Blöcken verwenden.
|
- **Strukturierte Config (Listen, mehrere Booleans):** Eigenes Editor-Komponenten-File nach Vorbild `KpiBoardConfigEditor.jsx` / `QuickCaptureConfigEditor.jsx` einbinden und `setLayout` + `normalizeLayoutForEditor` wie bei den bestehenden Blöcken verwenden.
|
||||||
|
|
||||||
Nach Speichern ruft die Seite `api.putAppDashboardLayout(layout)` auf; das Backend validiert über `DashboardLayoutPayload` → `validate_widget_entry_config`.
|
Nach Speichern ruft die Seite `api.putAppDashboardLayout(layout)` auf; das Backend validiert über `DashboardLayoutPayload` → `validate_widget_entry_config`.
|
||||||
|
|
@ -137,7 +137,7 @@ Nach Speichern ruft die Seite `api.putAppDashboardLayout(layout)` auf; das Backe
|
||||||
## 5. API zum Prüfen
|
## 5. API zum Prüfen
|
||||||
|
|
||||||
- `GET /api/app/widgets/catalog` – Katalog inkl. `allowed` je Widget (Auth + `X-Profile-Id` wie andere App-Endpoints).
|
- `GET /api/app/widgets/catalog` – Katalog inkl. `allowed` je Widget (Auth + `X-Profile-Id` wie andere App-Endpoints).
|
||||||
- `GET /api/app/dashboard-layout` – `layout` (effektiv, bereinigt), `custom`, `product_default_layout` (Übersichts-Standard), `lab_default_layout` (Servertemplate für Editor/Reset; Feldname historisch).
|
- `GET /api/app/dashboard-layout` – `layout` (effektiv, bereinigt), `custom`, `product_default_layout` (Übersichts-Standard), `lab_default_layout` (Dashboard-Lab-Standard).
|
||||||
- `PUT /api/app/dashboard-layout` – Body `{ "version": 1, "widgets": [ ... ] }` (unerlaubte Widgets werden auf `enabled: false` gesetzt).
|
- `PUT /api/app/dashboard-layout` – Body `{ "version": 1, "widgets": [ ... ] }` (unerlaubte Widgets werden auf `enabled: false` gesetzt).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -159,5 +159,5 @@ Nach Speichern ruft die Seite `api.putAppDashboardLayout(layout)` auf; das Backe
|
||||||
| Layout-Pydantic | `backend/dashboard_layout_schema.py` |
|
| Layout-Pydantic | `backend/dashboard_layout_schema.py` |
|
||||||
| HTTP | `backend/routers/app_dashboard.py` |
|
| HTTP | `backend/routers/app_dashboard.py` |
|
||||||
| Registry + Render | `frontend/src/widgetSystem/dashboardWidgetRegistry.jsx` |
|
| Registry + Render | `frontend/src/widgetSystem/dashboardWidgetRegistry.jsx` |
|
||||||
| Dashboard-Widget-Registrierung | `frontend/src/widgetSystem/registerDashboardWidgets.js` |
|
| Pilot/Lab-Registrierung | `frontend/src/widgetSystem/registerPilotLabWidgets.js` |
|
||||||
| Layout-Editor (Nutzer) | `frontend/src/pages/DashboardConfigurePage.jsx` |
|
| Lab-UI | `frontend/src/pages/DashboardLabPage.jsx` |
|
||||||
|
|
|
||||||
|
|
@ -92,10 +92,16 @@ registry = get_registry()
|
||||||
|
|
||||||
**Package:** `backend/placeholder_registrations/`
|
**Package:** `backend/placeholder_registrations/`
|
||||||
|
|
||||||
**Struktur:** Vollständige Cluster-Module (u. a. Ernährung, Körper, Aktivität, Schlaf,
|
**Struktur:**
|
||||||
Vitalwerte, Profil/Zeitraum, Phase-0b-Ziele, Korrelationen); siehe `__init__.py` für die
|
```
|
||||||
Import-Liste. **Anzahl:** 114 Platzhalter, identisch zu `PLACEHOLDER_MAP` in
|
placeholder_registrations/
|
||||||
`placeholder_resolver.py`.
|
├── __init__.py # Auto-Import aller Registrations
|
||||||
|
├── nutrition_part_a.py # Nutrition Basis-Metriken (4 Placeholder)
|
||||||
|
├── nutrition_part_b.py # Protein-Ziele (5 Placeholder) - TODO
|
||||||
|
├── body_metrics.py # Körper-Metriken - TODO
|
||||||
|
├── activity_metrics.py # Aktivitäts-Metriken - TODO
|
||||||
|
└── ... # Weitere Cluster
|
||||||
|
```
|
||||||
|
|
||||||
**Auto-Registration:**
|
**Auto-Registration:**
|
||||||
- Import des Package triggert automatische Registrierung aller Placeholder
|
- Import des Package triggert automatische Registrierung aller Placeholder
|
||||||
|
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
# Berichtsprofile & PDF (technisch)
|
|
||||||
|
|
||||||
**Stand:** 2026-04-29
|
|
||||||
|
|
||||||
## Begriffe
|
|
||||||
|
|
||||||
| Begriff | Bedeutung |
|
|
||||||
|--------|-----------|
|
|
||||||
| **Layout-Snapshot** | PDF aus gerasteter DOM-Übersicht (`html2canvas` + `jspdf`), optional Widget `report_export`. |
|
|
||||||
| **Strukturierter Bericht** | Profil mit Blöcken (`section`, `chart`, `ai_insight`), PDF serverseitig via Data Layer + Matplotlib + ReportLab. |
|
|
||||||
|
|
||||||
Die beiden Wege sind bewusst getrennt, damit das Dashboard nicht die einzige „Wahrheit“ für Dokumente wird.
|
|
||||||
|
|
||||||
## Datenbank
|
|
||||||
|
|
||||||
- Tabelle `report_profiles` (Migration `060_report_profiles.sql`): `profile_id` PK → `profiles`, `payload` JSONB, `updated_at`.
|
|
||||||
|
|
||||||
Ohne Zeile gilt ein **Code-Standard** (`default_report_profile_dict` in `report_profile_schema.py`).
|
|
||||||
|
|
||||||
## API (`/api/reports`)
|
|
||||||
|
|
||||||
| Methode | Pfad | Zweck |
|
|
||||||
|--------|------|--------|
|
|
||||||
| GET | `/catalog` | Diagramm-Katalog + Blocktypen für UI |
|
|
||||||
| GET | `/profile` | `{ stored, profile }` |
|
|
||||||
| PUT | `/profile` | Vollständiges Profil-JSON (Pydantic-validiert) |
|
|
||||||
| DELETE | `/profile` | DB-Zeile löschen → wieder Standard |
|
|
||||||
| POST | `/generate-pdf` | PDF-Download; `data_export`-Kontingent + `increment_feature_usage` |
|
|
||||||
|
|
||||||
## Schema v1 (`report_profile_schema.py`)
|
|
||||||
|
|
||||||
- `version`: nur `1`
|
|
||||||
- `document_title`: optional
|
|
||||||
- `blocks`: Liste mit Union:
|
|
||||||
- `section`: `title`
|
|
||||||
- `chart`: `chart_id` ∈ `ALLOWED_CHART_IDS`, `window_days` 7–365
|
|
||||||
- `ai_insight`: optional `insight_id` (UUID, `ai_insights.id`), optional `title`
|
|
||||||
|
|
||||||
## Diagrammdaten
|
|
||||||
|
|
||||||
`report_chart_fetch.fetch_chart_payload` ruft dieselben Bausteine auf wie `/api/charts` (ohne HTTP). Erweiterung: Eintrag in `ALLOWED_CHART_IDS`, Fetcher in `_CHART_FETCHERS`, Zeile in `CHART_CATALOG_FOR_API`.
|
|
||||||
|
|
||||||
## PDF-Rendering
|
|
||||||
|
|
||||||
`report_pdf_render.build_structured_report_pdf`: ReportLab-Flowable-Kette, Diagramme als PNG aus Chart-Payload (Matplotlib, Agg-Backend).
|
|
||||||
|
|
||||||
## Frontend
|
|
||||||
|
|
||||||
- **Einstellungen:** Karte „PDF-Bericht (strukturiert)“ — Blöcke bearbeiten, speichern, Standard, PDF erzeugen.
|
|
||||||
- **Dashboard:** Widget bleibt optionaler **Schnappschuss**; Hinweis verweist auf Einstellungen.
|
|
||||||
|
|
||||||
## Nächste sinnvolle Erweiterungen
|
|
||||||
|
|
||||||
- Dashboard-Layout → Berichtsprofil **einmalig importieren** (Mapping-Tabelle Widget-ID → chart_id).
|
|
||||||
- KI: Insights-Auswahl in der UI statt manueller UUID.
|
|
||||||
- Weitere `chart_id`-Werte / multipage Feintuning (Seitenumbrüche pro Block).
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
# Universal CSV Import – Agent-Leitfaden
|
|
||||||
|
|
||||||
**Stand:** 2026-04-09 · **Kontext:** Issue #21 (Universeller CSV-Parser), Prod-Migrationen u. a. 051–053.
|
|
||||||
|
|
||||||
Dieses Dokument ist **normativ für Agenten**, die ein neues Import-Zielmodul anlegen oder bestehende Import-Pfade (Executor, Vorlagen, DB) ändern.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Architektur (Kurz)
|
|
||||||
|
|
||||||
| Komponente | Pfad / Rolle |
|
|
||||||
|------------|----------------|
|
|
||||||
| Modul-Definitionen | `backend/csv_parser/module_registry.py` (`MODULE_DEFINITIONS`) |
|
|
||||||
| Typ-/Einheiten-Konvertierung | `backend/csv_parser/type_converter.py`, `field_units.py` |
|
|
||||||
| Zeilen-Aggregation (z. B. Ernährung pro Tag) | `backend/csv_parser/import_row_processing.py` |
|
|
||||||
| Import-Ausführung | `backend/csv_parser/executor.py` |
|
|
||||||
| Fehlertexte / Transaktions-Hinweise | `backend/csv_parser/import_errors.py` (`enrich_row_error`) |
|
|
||||||
| 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.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Checkliste: Neues Zielmodul
|
|
||||||
|
|
||||||
1. **`MODULE_DEFINITIONS`** um Eintrag erweitern: `table`, `fields` (Typen `date` / `datetime` / `float` / `int` / `string`), `duplicate_key`, `duplicate_strategy`, ggf. `derive_date_from_datetime_field`, `import_mode` (Spezialpfade wie Schlaf).
|
|
||||||
2. **DB:** Migration nur nach Projektregel (`backend/migrations/NNN_*.sql`). Spaltenbreiten/Typen so wählen, dass importierte Werte (z. B. kJ→kcal, große Energiebeträge) **keinen NUMERIC-Overflow** verursachen.
|
|
||||||
3. **`source` / CHECK-Constraints:** Wenn die Zieltabelle `source` hat, muss der Wert **`csv`** (oder der vereinbarte Import-Tag) in der DB erlaubt sein (Migration anpassen, nicht nur App-Code).
|
|
||||||
4. **Executor:** Einfügen/Aktualisieren in `executor.py` nur über bestehende Muster (ein Cursor, **kein** verschachteltes `get_db()` im gleichen Request). Bei mehreren Zeilen pro Transaktion: bei **Zeilenfehlern** SAVEPOINT pro Zeile nutzen (siehe Activity-Pattern), damit die Transaktion nicht dauerhaft abgebrochen ist.
|
|
||||||
5. **Trainingstyp / FK-Auflösung:** DB-Zugriffe für abhängige Entitäten (z. B. `get_training_type_for_activity_with_cursor`) **mit dem gleichen Cursor** wie der Import – keine zweite Connection aus dem Importpfad.
|
|
||||||
6. **Vorlagen:** System-Templates in Migration/Seed pflegen (`csv_field_mappings`, `is_system=true`). `type_conversions` und `source_unit` dort setzen, wo Einheiten aus Exporten abweichen (z. B. Apple kJ).
|
|
||||||
7. **Validierung:** Neue/angepasste Admin-Vorlagen müssen **`validate_csv_template`** passieren (Create/Update liefert bei Fehlern **422** mit `validation`). Tests für Randfälle ergänzen (`tests/test_template_validator.py` o. ä.).
|
|
||||||
8. **API / Frontend:** Neue Admin-Endpunkte in `main.py` registrieren; Frontend **nur** über `api.js`. Bei strukturierten FastAPI-Fehlern (`detail` als Objekt/Liste) bestehende Hilfen (`formatFastApiDetail`) nutzen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Checkliste: Bestehendes Modul ändern
|
|
||||||
|
|
||||||
- Schema-Änderung: Migration + ggf. **`module_registry`**-Felder anpassen.
|
|
||||||
- Neue Spalte im Import: Executor-Mapping, optional `type_conversions` / Validator.
|
|
||||||
- Änderung an Duplikatlogik: `duplicate_key` / `ON CONFLICT`-Pfad im Executor prüfen.
|
|
||||||
- Datums-/Zeit-Parsing: **`type_converter`** – ISO-Daten `YYYY-MM-DD` konsistent (**`dayfirst=False`**), Zeiten `HH:MM` ohne Sekunden unterstützen wo nötig.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Validierung & Dry-Run (Stand 2026-07-23, Gitea #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`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Verwandte Regeln
|
|
||||||
|
|
||||||
- `.claude/rules/ARCHITECTURE.md` – Router, DB, `source`-Tracking
|
|
||||||
- `.claude/rules/CODING_RULES.md` – Kurzverweis Universal CSV
|
|
||||||
- `.claude/rules/DOCUMENTATION.md` – Ablage technischer Specs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Version:** 1.0
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1,13 +0,0 @@
|
||||||
Folgende Ergebnisse des Tests:
|
|
||||||
- Valididierung gibt immer noch keine Aufschlüsse was für Fehler und Warning es sind, Es zeigt immer nur noch die Anzahl der entsprechenden Fehler/Warnungen
|
|
||||||
- Speichern als kurzes PopUp -- gut
|
|
||||||
- In der Node selbst wird nun eine Fehlermeldung ausgegeben. Das ist gut. In größen Workflows aber schwierig den Fehler zu lokalisieren.
|
|
||||||
- In der automatischen Zusammenfassung in der Endnode kommt als Überschrift, z.B. Node 10, anstatt den Node-Name auszugeben.
|
|
||||||
- Alle Änderungen an Nodes scheinen automatisch in den Gesamtflow übernommen zu werden. Diese werden dann nach dem Speichern aktiv. Da muss man sehr vorsichtig sein, bei kurzen Änderungen und dem Ausprobieren.
|
|
||||||
- Der Testlauf "Execute" sollte auf dem aktuellen Workflowstand ausgeführt werden, auch wenn dieser vom gespeicherten Abweicht. Ich würde natürlich vor dem Speichern den Workflow testen können. Prüfe und bewerte diesen Punkt, setze ihn aber noch nicht um.
|
|
||||||
- Das löschen von Knoten und Kanten funktioniert aktuell nur über Backspace nicht über entfernen
|
|
||||||
- Wir sollten auch dafür sorgen, dass jeweils nur eine Start-Node, End-Node in einem Workflow existiert, Prüfe ob mehrere End-Nodes sinnvoll sind, da wir ja auch Logik-Pfade abbilden und ggf. auch eine route beschreiten, die ein anderes Ende hat. (Prüfe, ob das heute schon möglich wäre!)
|
|
||||||
- Als zukünftige Ausbaustufe sollten wir überlegen, ob wir auch Trigger implementieren, z.B. um Kurzstatements zu generieren, wenn neue Daten hereinkommen und wir diese Bewertungen aktualisieren wollen
|
|
||||||
- Exportieren aller KI-Prompts/Templates/Workflows im Admin --> KI-Prompts führt zu einem "internal Server Error", Importieren konnte daraufhin nicht getestet werden
|
|
||||||
- Das duplizieren von Workflows funktioniert nicht
|
|
||||||
-
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
**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.
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
**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.
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
**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`
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
**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)
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
**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.
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
**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.
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
**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.
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23**
|
|
||||||
|
|
||||||
Inhalt identisch mit **#55** (Placeholder Registry: UNRESOLVED & TO_VERIFY Metadaten).
|
|
||||||
|
|
||||||
Bitte weiterverfolgen unter #55.
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23**
|
|
||||||
|
|
||||||
Inhalt identisch mit **#56** (Body Cluster — Restarbeiten & Metadaten-Verifizierung).
|
|
||||||
|
|
||||||
Bitte weiterverfolgen unter #56.
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
**Duplikat — geschlossen im Rahmen Issue-Audit 2026-07-23**
|
|
||||||
|
|
||||||
Inhalt identisch mit **#56** (Body Cluster — Restarbeiten & Metadaten-Verifizierung).
|
|
||||||
|
|
||||||
Bitte weiterverfolgen unter #56.
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
**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.
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
**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).
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
**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`
|
|
||||||
|
|
@ -1,458 +0,0 @@
|
||||||
-- Migration XXX: CSV Parser - System Templates Seed Data
|
|
||||||
-- Legt Standard-Import-Konfigurationen für bekannte CSV-Formate an
|
|
||||||
-- Diese Templates sind für alle User verfügbar (is_system = true, profile_id = NULL)
|
|
||||||
|
|
||||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
-- NUTRITION (Ernährung)
|
|
||||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
-- 1. FDDB Export (Deutsch)
|
|
||||||
INSERT INTO csv_field_mappings (
|
|
||||||
profile_id, is_system, module, mapping_name, description,
|
|
||||||
column_signature, delimiter, encoding, has_header,
|
|
||||||
field_mappings, type_conversions
|
|
||||||
) VALUES (
|
|
||||||
NULL,
|
|
||||||
true,
|
|
||||||
'nutrition',
|
|
||||||
'FDDB Export (Standard)',
|
|
||||||
'Standard-Format für FDDB.de CSV-Exporte (Deutsch). Delimiter Semikolon, kJ → kcal Konvertierung.',
|
|
||||||
ARRAY['datum_tag_monat_jahr_stunde_minute', 'fett_g', 'kh_g', 'kj', 'protein_g']::TEXT[],
|
|
||||||
';',
|
|
||||||
'utf-8',
|
|
||||||
true,
|
|
||||||
'{
|
|
||||||
"datum_tag_monat_jahr_stunde_minute": "date",
|
|
||||||
"kj": "kcal",
|
|
||||||
"fett_g": "fat_g",
|
|
||||||
"kh_g": "carbs_g",
|
|
||||||
"protein_g": "protein_g"
|
|
||||||
}'::JSONB,
|
|
||||||
'{
|
|
||||||
"date": {
|
|
||||||
"type": "date",
|
|
||||||
"format": "dd.mm.yyyy HH:MM",
|
|
||||||
"extract": "date_only"
|
|
||||||
},
|
|
||||||
"kcal": {
|
|
||||||
"type": "float",
|
|
||||||
"source_unit": "kj",
|
|
||||||
"decimal_separator": ","
|
|
||||||
},
|
|
||||||
"fat_g": {
|
|
||||||
"type": "float",
|
|
||||||
"decimal_separator": ","
|
|
||||||
},
|
|
||||||
"carbs_g": {
|
|
||||||
"type": "float",
|
|
||||||
"decimal_separator": ","
|
|
||||||
},
|
|
||||||
"protein_g": {
|
|
||||||
"type": "float",
|
|
||||||
"decimal_separator": ","
|
|
||||||
}
|
|
||||||
}'::JSONB
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 2. MyFitnessPal Export (English)
|
|
||||||
INSERT INTO csv_field_mappings (
|
|
||||||
profile_id, is_system, module, mapping_name, description,
|
|
||||||
column_signature, delimiter, encoding, has_header,
|
|
||||||
field_mappings, type_conversions
|
|
||||||
) VALUES (
|
|
||||||
NULL,
|
|
||||||
true,
|
|
||||||
'nutrition',
|
|
||||||
'MyFitnessPal Export',
|
|
||||||
'Standard CSV export from MyFitnessPal (English)',
|
|
||||||
ARRAY['Carbohydrates (g)', 'Calories', 'Date', 'Fat (g)', 'Protein (g)']::TEXT[],
|
|
||||||
',',
|
|
||||||
'utf-8',
|
|
||||||
true,
|
|
||||||
'{
|
|
||||||
"Date": "date",
|
|
||||||
"Calories": "kcal",
|
|
||||||
"Fat (g)": "fat_g",
|
|
||||||
"Carbohydrates (g)": "carbs_g",
|
|
||||||
"Protein (g)": "protein_g"
|
|
||||||
}'::JSONB,
|
|
||||||
'{
|
|
||||||
"date": {
|
|
||||||
"type": "date",
|
|
||||||
"format": "yyyy-mm-dd"
|
|
||||||
},
|
|
||||||
"kcal": {
|
|
||||||
"type": "float",
|
|
||||||
"decimal_separator": "."
|
|
||||||
},
|
|
||||||
"fat_g": {
|
|
||||||
"type": "float",
|
|
||||||
"decimal_separator": "."
|
|
||||||
},
|
|
||||||
"carbs_g": {
|
|
||||||
"type": "float",
|
|
||||||
"decimal_separator": "."
|
|
||||||
},
|
|
||||||
"protein_g": {
|
|
||||||
"type": "float",
|
|
||||||
"decimal_separator": "."
|
|
||||||
}
|
|
||||||
}'::JSONB
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 3. Cronometer Export
|
|
||||||
INSERT INTO csv_field_mappings (
|
|
||||||
profile_id, is_system, module, mapping_name, description,
|
|
||||||
column_signature, delimiter, encoding, has_header,
|
|
||||||
field_mappings, type_conversions
|
|
||||||
) VALUES (
|
|
||||||
NULL,
|
|
||||||
true,
|
|
||||||
'nutrition',
|
|
||||||
'Cronometer Export',
|
|
||||||
'Cronometer daily nutrition export (English)',
|
|
||||||
ARRAY['Day', 'Energy (kcal)', 'Fat (g)', 'Net Carbs (g)', 'Protein (g)']::TEXT[],
|
|
||||||
',',
|
|
||||||
'utf-8',
|
|
||||||
true,
|
|
||||||
'{
|
|
||||||
"Day": "date",
|
|
||||||
"Energy (kcal)": "kcal",
|
|
||||||
"Fat (g)": "fat_g",
|
|
||||||
"Net Carbs (g)": "carbs_g",
|
|
||||||
"Protein (g)": "protein_g"
|
|
||||||
}'::JSONB,
|
|
||||||
'{
|
|
||||||
"date": {
|
|
||||||
"type": "date",
|
|
||||||
"format": "yyyy-mm-dd"
|
|
||||||
},
|
|
||||||
"kcal": {"type": "float", "decimal_separator": "."},
|
|
||||||
"fat_g": {"type": "float", "decimal_separator": "."},
|
|
||||||
"carbs_g": {"type": "float", "decimal_separator": "."},
|
|
||||||
"protein_g": {"type": "float", "decimal_separator": "."}
|
|
||||||
}'::JSONB
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
-- ACTIVITY (Aktivität)
|
|
||||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
-- 1. Apple Health Workout Export (English)
|
|
||||||
INSERT INTO csv_field_mappings (
|
|
||||||
profile_id, is_system, module, mapping_name, description,
|
|
||||||
column_signature, delimiter, encoding, has_header,
|
|
||||||
field_mappings, type_conversions
|
|
||||||
) VALUES (
|
|
||||||
NULL,
|
|
||||||
true,
|
|
||||||
'activity',
|
|
||||||
'Apple Health Workout Export (English)',
|
|
||||||
'Apple Health CSV-Export für Workouts (English). Automatisches Training-Type-Mapping.',
|
|
||||||
ARRAY['Active Energy (kcal)', 'Distance (km)', 'Duration', 'End', 'Heart Rate Average (bpm)', 'Start', 'Workout Type']::TEXT[],
|
|
||||||
',',
|
|
||||||
'utf-8',
|
|
||||||
true,
|
|
||||||
'{
|
|
||||||
"Workout Type": "activity_type",
|
|
||||||
"Start": "start_time",
|
|
||||||
"End": "end_time",
|
|
||||||
"Duration": "duration_min",
|
|
||||||
"Distance (km)": "distance_km",
|
|
||||||
"Active Energy (kcal)": "kcal_active",
|
|
||||||
"Heart Rate Average (bpm)": "hr_avg"
|
|
||||||
}'::JSONB,
|
|
||||||
'{
|
|
||||||
"start_time": {
|
|
||||||
"type": "datetime",
|
|
||||||
"format": "yyyy-mm-dd HH:MM:SS",
|
|
||||||
"extract": "date_and_time"
|
|
||||||
},
|
|
||||||
"end_time": {
|
|
||||||
"type": "datetime",
|
|
||||||
"format": "yyyy-mm-dd HH:MM:SS"
|
|
||||||
},
|
|
||||||
"duration_min": {
|
|
||||||
"type": "duration",
|
|
||||||
"format": "HH:MM:SS",
|
|
||||||
"target_unit": "minutes"
|
|
||||||
},
|
|
||||||
"distance_km": {
|
|
||||||
"type": "float",
|
|
||||||
"decimal_separator": "."
|
|
||||||
},
|
|
||||||
"kcal_active": {
|
|
||||||
"type": "float",
|
|
||||||
"decimal_separator": "."
|
|
||||||
},
|
|
||||||
"hr_avg": {
|
|
||||||
"type": "int"
|
|
||||||
}
|
|
||||||
}'::JSONB
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 2. Apple Health Workout Export (Deutsch)
|
|
||||||
INSERT INTO csv_field_mappings (
|
|
||||||
profile_id, is_system, module, mapping_name, description,
|
|
||||||
column_signature, delimiter, encoding, has_header,
|
|
||||||
field_mappings, type_conversions
|
|
||||||
) VALUES (
|
|
||||||
NULL,
|
|
||||||
true,
|
|
||||||
'activity',
|
|
||||||
'Apple Health Workout Export (Deutsch)',
|
|
||||||
'Apple Health CSV-Export für Workouts (Deutsch). Automatisches Training-Type-Mapping.',
|
|
||||||
ARRAY['Aktive Energie (kcal)', 'Dauer', 'Durchschnittliche Herzfrequenz (bpm)', 'Ende', 'Start', 'Strecke (km)', 'Trainingsart']::TEXT[],
|
|
||||||
',',
|
|
||||||
'utf-8',
|
|
||||||
true,
|
|
||||||
'{
|
|
||||||
"Trainingsart": "activity_type",
|
|
||||||
"Start": "start_time",
|
|
||||||
"Ende": "end_time",
|
|
||||||
"Dauer": "duration_min",
|
|
||||||
"Strecke (km)": "distance_km",
|
|
||||||
"Aktive Energie (kcal)": "kcal_active",
|
|
||||||
"Durchschnittliche Herzfrequenz (bpm)": "hr_avg"
|
|
||||||
}'::JSONB,
|
|
||||||
'{
|
|
||||||
"start_time": {
|
|
||||||
"type": "datetime",
|
|
||||||
"format": "yyyy-mm-dd HH:MM:SS",
|
|
||||||
"extract": "date_and_time"
|
|
||||||
},
|
|
||||||
"end_time": {
|
|
||||||
"type": "datetime",
|
|
||||||
"format": "yyyy-mm-dd HH:MM:SS"
|
|
||||||
},
|
|
||||||
"duration_min": {
|
|
||||||
"type": "duration",
|
|
||||||
"format": "HH:MM:SS",
|
|
||||||
"target_unit": "minutes"
|
|
||||||
},
|
|
||||||
"distance_km": {
|
|
||||||
"type": "float",
|
|
||||||
"decimal_separator": ","
|
|
||||||
},
|
|
||||||
"kcal_active": {
|
|
||||||
"type": "float",
|
|
||||||
"decimal_separator": ","
|
|
||||||
},
|
|
||||||
"hr_avg": {
|
|
||||||
"type": "int"
|
|
||||||
}
|
|
||||||
}'::JSONB
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 3. Garmin Connect Export
|
|
||||||
INSERT INTO csv_field_mappings (
|
|
||||||
profile_id, is_system, module, mapping_name, description,
|
|
||||||
column_signature, delimiter, encoding, has_header,
|
|
||||||
field_mappings, type_conversions
|
|
||||||
) VALUES (
|
|
||||||
NULL,
|
|
||||||
true,
|
|
||||||
'activity',
|
|
||||||
'Garmin Connect Export',
|
|
||||||
'Garmin Connect activity CSV export (English)',
|
|
||||||
ARRAY['Activity Type', 'Avg HR', 'Calories', 'Date', 'Distance', 'Duration', 'Time']::TEXT[],
|
|
||||||
',',
|
|
||||||
'utf-8',
|
|
||||||
true,
|
|
||||||
'{
|
|
||||||
"Activity Type": "activity_type",
|
|
||||||
"Date": "date",
|
|
||||||
"Time": "start_time",
|
|
||||||
"Duration": "duration_min",
|
|
||||||
"Distance": "distance_km",
|
|
||||||
"Calories": "kcal_active",
|
|
||||||
"Avg HR": "hr_avg"
|
|
||||||
}'::JSONB,
|
|
||||||
'{
|
|
||||||
"date": {
|
|
||||||
"type": "date",
|
|
||||||
"format": "yyyy-mm-dd"
|
|
||||||
},
|
|
||||||
"start_time": {
|
|
||||||
"type": "time",
|
|
||||||
"format": "HH:MM:SS"
|
|
||||||
},
|
|
||||||
"duration_min": {
|
|
||||||
"type": "duration",
|
|
||||||
"format": "HH:MM:SS",
|
|
||||||
"target_unit": "minutes"
|
|
||||||
},
|
|
||||||
"distance_km": {
|
|
||||||
"type": "float",
|
|
||||||
"decimal_separator": "."
|
|
||||||
},
|
|
||||||
"kcal_active": {
|
|
||||||
"type": "float",
|
|
||||||
"decimal_separator": "."
|
|
||||||
},
|
|
||||||
"hr_avg": {
|
|
||||||
"type": "int"
|
|
||||||
}
|
|
||||||
}'::JSONB
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
-- BLOOD PRESSURE (Blutdruck)
|
|
||||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
-- 1. Omron Export (Deutsch)
|
|
||||||
INSERT INTO csv_field_mappings (
|
|
||||||
profile_id, is_system, module, mapping_name, description,
|
|
||||||
column_signature, delimiter, encoding, has_header,
|
|
||||||
field_mappings, type_conversions
|
|
||||||
) VALUES (
|
|
||||||
NULL,
|
|
||||||
true,
|
|
||||||
'blood_pressure',
|
|
||||||
'Omron Export (Deutsch)',
|
|
||||||
'Omron Blutdruckmessgerät CSV-Export (Deutsch)',
|
|
||||||
ARRAY['Datum', 'Diastolisch (mmHg)', 'Puls (bpm)', 'Systolisch (mmHg)', 'Zeit']::TEXT[],
|
|
||||||
',',
|
|
||||||
'utf-8',
|
|
||||||
true,
|
|
||||||
'{
|
|
||||||
"Datum": "measured_date",
|
|
||||||
"Zeit": "measured_time",
|
|
||||||
"Systolisch (mmHg)": "systolic",
|
|
||||||
"Diastolisch (mmHg)": "diastolic",
|
|
||||||
"Puls (bpm)": "pulse"
|
|
||||||
}'::JSONB,
|
|
||||||
'{
|
|
||||||
"measured_date": {
|
|
||||||
"type": "date",
|
|
||||||
"format": "dd.mm.yyyy"
|
|
||||||
},
|
|
||||||
"measured_time": {
|
|
||||||
"type": "time",
|
|
||||||
"format": "HH:MM"
|
|
||||||
},
|
|
||||||
"systolic": {"type": "int"},
|
|
||||||
"diastolic": {"type": "int"},
|
|
||||||
"pulse": {"type": "int"}
|
|
||||||
}'::JSONB
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 2. Omron Export (English)
|
|
||||||
INSERT INTO csv_field_mappings (
|
|
||||||
profile_id, is_system, module, mapping_name, description,
|
|
||||||
column_signature, delimiter, encoding, has_header,
|
|
||||||
field_mappings, type_conversions
|
|
||||||
) VALUES (
|
|
||||||
NULL,
|
|
||||||
true,
|
|
||||||
'blood_pressure',
|
|
||||||
'Omron Export (English)',
|
|
||||||
'Omron blood pressure monitor CSV export (English)',
|
|
||||||
ARRAY['Date', 'Diastolic (mmHg)', 'Pulse (bpm)', 'Systolic (mmHg)', 'Time']::TEXT[],
|
|
||||||
',',
|
|
||||||
'utf-8',
|
|
||||||
true,
|
|
||||||
'{
|
|
||||||
"Date": "measured_date",
|
|
||||||
"Time": "measured_time",
|
|
||||||
"Systolic (mmHg)": "systolic",
|
|
||||||
"Diastolic (mmHg)": "diastolic",
|
|
||||||
"Pulse (bpm)": "pulse"
|
|
||||||
}'::JSONB,
|
|
||||||
'{
|
|
||||||
"measured_date": {
|
|
||||||
"type": "date",
|
|
||||||
"format": "mm/dd/yyyy"
|
|
||||||
},
|
|
||||||
"measured_time": {
|
|
||||||
"type": "time",
|
|
||||||
"format": "HH:MM"
|
|
||||||
},
|
|
||||||
"systolic": {"type": "int"},
|
|
||||||
"diastolic": {"type": "int"},
|
|
||||||
"pulse": {"type": "int"}
|
|
||||||
}'::JSONB
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
-- WEIGHT (Gewicht)
|
|
||||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
-- 1. Apple Health Weight Export
|
|
||||||
INSERT INTO csv_field_mappings (
|
|
||||||
profile_id, is_system, module, mapping_name, description,
|
|
||||||
column_signature, delimiter, encoding, has_header,
|
|
||||||
field_mappings, type_conversions
|
|
||||||
) VALUES (
|
|
||||||
NULL,
|
|
||||||
true,
|
|
||||||
'weight',
|
|
||||||
'Apple Health Weight Export',
|
|
||||||
'Apple Health body mass CSV export',
|
|
||||||
ARRAY['Body Mass (kg)', 'Start']::TEXT[],
|
|
||||||
',',
|
|
||||||
'utf-8',
|
|
||||||
true,
|
|
||||||
'{
|
|
||||||
"Start": "date",
|
|
||||||
"Body Mass (kg)": "weight"
|
|
||||||
}'::JSONB,
|
|
||||||
'{
|
|
||||||
"date": {
|
|
||||||
"type": "datetime",
|
|
||||||
"format": "yyyy-mm-dd HH:MM:SS",
|
|
||||||
"extract": "date_only"
|
|
||||||
},
|
|
||||||
"weight": {
|
|
||||||
"type": "float",
|
|
||||||
"decimal_separator": "."
|
|
||||||
}
|
|
||||||
}'::JSONB
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 2. Withings Export
|
|
||||||
INSERT INTO csv_field_mappings (
|
|
||||||
profile_id, is_system, module, mapping_name, description,
|
|
||||||
column_signature, delimiter, encoding, has_header,
|
|
||||||
field_mappings, type_conversions
|
|
||||||
) VALUES (
|
|
||||||
NULL,
|
|
||||||
true,
|
|
||||||
'weight',
|
|
||||||
'Withings Export',
|
|
||||||
'Withings smart scale CSV export (weight, body fat, muscle mass)',
|
|
||||||
ARRAY['Body Fat (%)', 'Date', 'Muscle Mass (kg)', 'Weight (kg)']::TEXT[],
|
|
||||||
',',
|
|
||||||
'utf-8',
|
|
||||||
true,
|
|
||||||
'{
|
|
||||||
"Date": "date",
|
|
||||||
"Weight (kg)": "weight"
|
|
||||||
}'::JSONB,
|
|
||||||
'{
|
|
||||||
"date": {
|
|
||||||
"type": "date",
|
|
||||||
"format": "yyyy-mm-dd"
|
|
||||||
},
|
|
||||||
"weight": {
|
|
||||||
"type": "float",
|
|
||||||
"decimal_separator": "."
|
|
||||||
}
|
|
||||||
}'::JSONB
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
-- SUMMARY
|
|
||||||
-- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
DECLARE
|
|
||||||
template_count INTEGER;
|
|
||||||
BEGIN
|
|
||||||
SELECT COUNT(*) INTO template_count FROM csv_field_mappings WHERE is_system = true;
|
|
||||||
RAISE NOTICE '✓ CSV Parser: % System-Templates created', template_count;
|
|
||||||
RAISE NOTICE ' - Nutrition: 3 (FDDB, MyFitnessPal, Cronometer)';
|
|
||||||
RAISE NOTICE ' - Activity: 3 (Apple Health DE/EN, Garmin)';
|
|
||||||
RAISE NOTICE ' - Blood Pressure: 2 (Omron DE/EN)';
|
|
||||||
RAISE NOTICE ' - Weight: 2 (Apple Health, Withings)';
|
|
||||||
END $$;
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
## Gesamt-Übersicht
|
## Gesamt-Übersicht
|
||||||
|
|
||||||
**Aktuelle Platzhalter:** 114 (PLACEHOLDER_MAP / Registry)
|
**Aktuelle Platzhalter:** 116
|
||||||
**Nach Phase 0c Migration:**
|
**Nach Phase 0c Migration:**
|
||||||
- ✅ **Bleiben einfach (kein Data Layer):** 8 Platzhalter
|
- ✅ **Bleiben einfach (kein Data Layer):** 8 Platzhalter
|
||||||
- 🔄 **Gehen zu Data Layer:** 108 Platzhalter
|
- 🔄 **Gehen zu Data Layer:** 108 Platzhalter
|
||||||
|
|
|
||||||
|
|
@ -216,11 +216,8 @@ updated_at TIMESTAMP DEFAULT NOW()
|
||||||
Tabellen die Daten aus externen Quellen empfangen brauchen:
|
Tabellen die Daten aus externen Quellen empfangen brauchen:
|
||||||
```sql
|
```sql
|
||||||
source VARCHAR(50) DEFAULT 'manual'
|
source VARCHAR(50) DEFAULT 'manual'
|
||||||
-- Werte u. a.: 'manual' | 'apple_health' | 'garmin' | 'withings' | 'csv'
|
-- Werte: 'manual' | 'apple_health' | 'garmin' | 'withings'
|
||||||
```
|
```
|
||||||
Importe über den **Universal CSV**-Pfad setzen `source = 'csv'`, sofern die Tabelle ein `source`-Feld hat; CHECK-Constraints und Migrationen müssen diesen Wert erlauben.
|
|
||||||
|
|
||||||
**Agent-Pflicht bei neuen Import-Zielen oder Executor-Änderungen:** `.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md`
|
|
||||||
|
|
||||||
Manuelle Einträge (`source = 'manual'`) haben IMMER Vorrang bei Reimport:
|
Manuelle Einträge (`source = 'manual'`) haben IMMER Vorrang bei Reimport:
|
||||||
```sql
|
```sql
|
||||||
|
|
@ -387,55 +384,26 @@ Dev-URL: dev.mitai.jinkendo.de
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. CSV-Import vs. Data Layer (Issue #53)
|
## 8. Test-Regeln
|
||||||
|
|
||||||
### 8.1 Leitlinie: Wo Interpretation stattfindet
|
### 8.1 Tests schreiben ist Pflicht
|
||||||
|
|
||||||
| Schicht | Erlaubt | Nicht Sinn der Schicht |
|
|
||||||
|--------|---------|-------------------------|
|
|
||||||
| **Import (Ingest)** | Zuordnung CSV→Speicherfeld, **Typ-/Einheits-Konvertierung** (`type_conversions`), Duplikat-/Constraint-Logik | Fachliche **Interpretation**, Aggregation von „Bedeutung“, Metriken für Auswertung |
|
|
||||||
| **Data Layer (Issue #53, Layer 1+)** | Daten lesen, aufbereiten, ableiten, für Charts/KI/Prompts bereitstellen | — |
|
|
||||||
|
|
||||||
Verbindlich: **Semantik und Auswertung** nicht dauerhaft im Import verstecken; neue Features werden an dieser Grenze geprüft.
|
|
||||||
|
|
||||||
**Detail & Zielbild (Multi-Layer, Single Source of Truth):** `docs/issues/issue-53-phase-0c-multi-layer-architecture.md`
|
|
||||||
|
|
||||||
**Umsetzung Schlaf-Import (Refactoring, Offen):** Gitea http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/69
|
|
||||||
|
|
||||||
### 8.2 Ist-Einordnung Import-Pfade (Übergang)
|
|
||||||
|
|
||||||
Bis sukzessive auf das Zielbild umgestellt ist, gilt:
|
|
||||||
|
|
||||||
| Pfad | Einordnung |
|
|
||||||
|------|----------------|
|
|
||||||
| Universal-CSV (`csv_parser`, `routers/csv_import.py`, Executor für u. a. Gewicht/Ernährung/Blutdruck/Aktivität/Vitals) | **Zielrichtung:** Mapping + Typkonvertierung |
|
|
||||||
| Apple-Schlaf-Aggregat (`csv_parser/sleep_apple_import.py`, `import_mode: apple_sleep_aggregate`) | **Legacy-Adapter** (quellenspezifische Aufbereitung) – Austausch gegen mapping-nah + Layer 1 geplant |
|
|
||||||
| Dedizierte Import-Endpoints (z. B. `/api/activity/import-csv`, Vitals Apple) | **Legacy/Parallel** – neue Quellen bevorzugt über Universal-Pfad + Vorlagen |
|
|
||||||
|
|
||||||
Änderungen an Import-Pfaden: Legacy nur erweitern mit **expliziter** Issue-/Review-Begründung; kein neues „wir rechnen Auswertung beim Insert“ ohne Data-Layer-Bezug.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Test-Regeln
|
|
||||||
|
|
||||||
### 9.1 Tests schreiben ist Pflicht
|
|
||||||
Jedes neue Feature bekommt mindestens einen Playwright-Test in
|
Jedes neue Feature bekommt mindestens einen Playwright-Test in
|
||||||
`tests/dev-smoke-test.spec.js`.
|
`tests/dev-smoke-test.spec.js`.
|
||||||
|
|
||||||
### 9.2 Reihenfolge: Test vor Commit
|
### 8.2 Reihenfolge: Test vor Commit
|
||||||
```
|
```
|
||||||
Implementieren → Tests schreiben → Tests grün → Committen
|
Implementieren → Tests schreiben → Tests grün → Committen
|
||||||
NIEMALS: Implementieren → Committen → Tests später
|
NIEMALS: Implementieren → Committen → Tests später
|
||||||
```
|
```
|
||||||
|
|
||||||
### 9.3 Claude Code schreibt Tests selbst
|
### 8.3 Claude Code schreibt Tests selbst
|
||||||
Nach jeder Implementierung:
|
Nach jeder Implementierung:
|
||||||
1. Passende Tests in dev-smoke-test.spec.js ergänzen
|
1. Passende Tests in dev-smoke-test.spec.js ergänzen
|
||||||
2. `npx playwright test` ausführen
|
2. `npx playwright test` ausführen
|
||||||
3. Fehler korrigieren bis alle Tests grün
|
3. Fehler korrigieren bis alle Tests grün
|
||||||
4. Erst dann committen
|
4. Erst dann committen
|
||||||
|
|
||||||
### 9.4 Test-Kategorien
|
### 8.4 Test-Kategorien
|
||||||
```javascript
|
```javascript
|
||||||
// UI-Test (Playwright)
|
// UI-Test (Playwright)
|
||||||
test('FEATURE: Beschreibung', async ({ page }) => { ... })
|
test('FEATURE: Beschreibung', async ({ page }) => { ... })
|
||||||
|
|
@ -444,26 +412,26 @@ test('FEATURE: Beschreibung', async ({ page }) => { ... })
|
||||||
test('API: Endpoint', async ({ request }) => { ... })
|
test('API: Endpoint', async ({ request }) => { ... })
|
||||||
```
|
```
|
||||||
|
|
||||||
### 9.5 Screenshots bei Fehlern
|
### 8.5 Screenshots bei Fehlern
|
||||||
Fehlgeschlagene Tests erzeugen automatisch Screenshots in:
|
Fehlgeschlagene Tests erzeugen automatisch Screenshots in:
|
||||||
`test-results/TESTNAME/test-failed-1.png`
|
`test-results/TESTNAME/test-failed-1.png`
|
||||||
→ Immer ansehen bevor Code geändert wird
|
→ Immer ansehen bevor Code geändert wird
|
||||||
|
|
||||||
### 9.6 Prod nie testen
|
### 8.6 Prod nie testen
|
||||||
Tests laufen IMMER gegen dev.mitai.jinkendo.de
|
Tests laufen IMMER gegen dev.mitai.jinkendo.de
|
||||||
NIEMALS gegen mitai.jinkendo.de
|
NIEMALS gegen mitai.jinkendo.de
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Dashboard-Widgets und Feature-System
|
## 9. Dashboard-Lab-Widgets und Feature-System
|
||||||
|
|
||||||
**Kontext:** Dashboard-Widgets (`backend/widget_catalog.py`, API unter `/api/app/...`) und das **Subscription-/Feature-Modell** (`features`, `tier_limits`, `check_feature_access` in `backend/auth.py`) sind **getrennte Schichten**, müssen aber bei tariffrelevanten Widgets **verknüpft** werden.
|
**Kontext:** Dashboard-Widgets (`backend/widget_catalog.py`, Lab unter `/api/app/...`) und das **Subscription-/Feature-Modell** (`features`, `tier_limits`, `check_feature_access` in `backend/auth.py`) sind **getrennte Schichten**, müssen aber bei tariffrelevanten Widgets **verknüpft** werden.
|
||||||
|
|
||||||
**Bindend:**
|
**Bindend:**
|
||||||
|
|
||||||
1. **Keine fest codierten Tier-Namen** für Widget-Rechte – Tiers und Limits kommen aus der DB.
|
1. **Keine fest codierten Tier-Namen** für Widget-Rechte – Tiers und Limits kommen aus der DB.
|
||||||
2. **Komplexität** (Module aus, Unter-Stufen, KI vs. Standard) liegt in der **Feature-/Subscription-Logik**, nicht verteilt in Widget-Komponenten.
|
2. **Komplexität** (Module aus, Unter-Stufen, KI vs. Standard) liegt in der **Feature-/Subscription-Logik**, nicht verteilt in Widget-Komponenten.
|
||||||
3. **Nutzer-Konfigurator** (**Übersicht anpassen** / `DashboardConfigurePage`): Widgets **ohne** passende Berechtigung **nicht anzeigen**; alle erlaubten Widgets bleiben verfügbar.
|
3. **Nutzer-Konfigurator** (z. B. Dashboard-Lab): Widgets **ohne** passende Berechtigung **nicht anzeigen**; alle erlaubten Widgets bleiben verfügbar.
|
||||||
4. **Backend** liefert die effektive Erlaubnis (z. B. über erweiterten Katalog oder Entitlements), und **validiert beim Speichern** des Layouts, dass keine unerlaubten Widget-IDs persistiert werden (Policy: ablehnen oder strippen – einheitlich halten).
|
4. **Backend** liefert die effektive Erlaubnis (z. B. über erweiterten Katalog oder Entitlements), und **validiert beim Speichern** des Layouts, dass keine unerlaubten Widget-IDs persistiert werden (Policy: ablehnen oder strippen – einheitlich halten).
|
||||||
5. **Daten/API:** Zusätzlich zur UI-Filterung müssen die **inhaltsliefernden Endpoints** weiterhin über `check_feature_access` geschützt sein (kein Leck über direkte API-Aufrufe).
|
5. **Daten/API:** Zusätzlich zur UI-Filterung müssen die **inhaltsliefernden Endpoints** weiterhin über `check_feature_access` geschützt sein (kein Leck über direkte API-Aufrufe).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,339 +0,0 @@
|
||||||
# Architektur-Regeln – Mitai Jinkendo
|
|
||||||
|
|
||||||
> **PFLICHTLEKTÜRE für Claude Code vor jeder Implementierung.**
|
|
||||||
> Diese Regeln sind verbindlich und dürfen nicht ohne explizite
|
|
||||||
> Genehmigung des Nutzers abgeändert werden.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Router-Architektur
|
|
||||||
|
|
||||||
### 1.1 Ein Modul = Ein Router
|
|
||||||
Jedes fachliche Modul hat genau eine Router-Datei in `backend/routers/`.
|
|
||||||
|
|
||||||
```
|
|
||||||
backend/routers/
|
|
||||||
├── auth.py # Authentifizierung
|
|
||||||
├── profiles.py # Nutzerprofile
|
|
||||||
├── weight.py # Gewichts-Tracking
|
|
||||||
├── sleep.py # Schlaf-Modul
|
|
||||||
├── training_types.py # Trainingstypen + HF
|
|
||||||
└── ... # je neues Modul = neue Datei
|
|
||||||
```
|
|
||||||
|
|
||||||
**Regeln:**
|
|
||||||
- Kein Endpoint darf außerhalb seines thematischen Routers definiert werden
|
|
||||||
- Neue Module immer als neue Router-Datei anlegen, nie in bestehende einfügen
|
|
||||||
- Router in `main.py` registrieren: `app.include_router(modul.router, prefix="/api")`
|
|
||||||
- Router-Datei-Name = Modul-Name in `version.py` MODULE_VERSIONS
|
|
||||||
|
|
||||||
### 1.2 API-First Prinzip
|
|
||||||
Jede Funktion ist zuerst als API-Endpoint implementiert – die UI nutzt ausschließlich
|
|
||||||
diese Endpoints über `api.js`. Keine Business-Logik im Frontend.
|
|
||||||
|
|
||||||
```python
|
|
||||||
# ✅ Richtig: Logik im Backend-Endpoint
|
|
||||||
@router.get("/sleep/stats")
|
|
||||||
def get_sleep_stats(session=Depends(require_auth)):
|
|
||||||
# Berechnung hier
|
|
||||||
return {"avg_duration": ..., "sleep_debt": ...}
|
|
||||||
|
|
||||||
# ❌ Falsch: Berechnung im Frontend
|
|
||||||
const sleepDebt = entries.reduce((sum, e) => sum + (goal - e.duration), 0)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.3 Einheitliche Fehlerbehandlung
|
|
||||||
```python
|
|
||||||
# ✅ Immer dieses Format:
|
|
||||||
raise HTTPException(status_code=404, detail="Eintrag nicht gefunden")
|
|
||||||
# Response: {"detail": "Eintrag nicht gefunden"}
|
|
||||||
|
|
||||||
# ❌ Nie eigene Formate:
|
|
||||||
return {"error": "not found"}
|
|
||||||
return {"message": "Fehler", "success": False}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Versionskontrollsystem
|
|
||||||
|
|
||||||
### 2.1 Versionierungsschema
|
|
||||||
**Semantic Versioning: `MAJOR.MINOR.PATCH`**
|
|
||||||
|
|
||||||
| Typ | Wann | Beispiel |
|
|
||||||
|-----|------|---------|
|
|
||||||
| MAJOR | Breaking Change, DB-Migration inkompatibel | 9.0.0 → 10.0.0 |
|
|
||||||
| MINOR | Neues Feature, neues Modul | 9.2.0 → 9.3.0 |
|
|
||||||
| PATCH | Bugfix, kleine Änderung, Refactor | 9.3.0 → 9.3.1 |
|
|
||||||
|
|
||||||
### 2.2 Versions-Dateien
|
|
||||||
|
|
||||||
**Backend: `backend/version.py`**
|
|
||||||
```python
|
|
||||||
APP_VERSION = "9.3.0"
|
|
||||||
BUILD_DATE = "2026-03-22"
|
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
|
||||||
"auth": "1.2.0",
|
|
||||||
"profiles": "1.1.0",
|
|
||||||
"weight": "1.0.3",
|
|
||||||
"circumference": "1.0.1",
|
|
||||||
"caliper": "1.0.1",
|
|
||||||
"activity": "1.1.0",
|
|
||||||
"nutrition": "1.0.2",
|
|
||||||
"photos": "1.0.0",
|
|
||||||
"insights": "1.3.0",
|
|
||||||
"prompts": "1.1.0",
|
|
||||||
"admin": "1.2.0",
|
|
||||||
"stats": "1.0.1",
|
|
||||||
"exportdata": "1.1.0",
|
|
||||||
"importdata": "1.0.0",
|
|
||||||
"membership": "2.1.0",
|
|
||||||
}
|
|
||||||
|
|
||||||
CHANGELOG = [
|
|
||||||
{
|
|
||||||
"version": "9.3.0",
|
|
||||||
"date": "2026-03-22",
|
|
||||||
"changes": [
|
|
||||||
"Feature: Sleep Module (sleep_log, JSONB-Segmente)",
|
|
||||||
"Feature: Vitalwerte-Seite in Navigation",
|
|
||||||
"Feature: Trainingstypen-Kategorisierung",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "9.2.1",
|
|
||||||
"date": "2026-03-20",
|
|
||||||
"changes": [
|
|
||||||
"Fix: Feature-Enforcement Rollback",
|
|
||||||
"Fix: Erholungsstatus-Gewichtung korrigiert",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Frontend: `frontend/src/version.js`**
|
|
||||||
```javascript
|
|
||||||
export const APP_VERSION = "9.3.0"
|
|
||||||
export const BUILD_DATE = "2026-03-22"
|
|
||||||
|
|
||||||
export const PAGE_VERSIONS = {
|
|
||||||
Dashboard: "1.3.0",
|
|
||||||
LoginScreen: "1.1.0",
|
|
||||||
WeightPage: "1.0.3",
|
|
||||||
ActivityPage: "1.2.0",
|
|
||||||
NutritionPage: "1.1.0",
|
|
||||||
AnalysisPage: "1.3.0",
|
|
||||||
SettingsPage: "1.4.0",
|
|
||||||
AdminPanel: "1.2.0",
|
|
||||||
SubscriptionPage: "1.0.0",
|
|
||||||
// Neue Seiten hier eintragen
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3 Versions-Endpoint
|
|
||||||
|
|
||||||
**`GET /api/version`** – öffentlich (kein Auth erforderlich)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"app_version": "9.3.0",
|
|
||||||
"build_date": "2026-03-22",
|
|
||||||
"backend_version": "9.3.0",
|
|
||||||
"modules": {
|
|
||||||
"auth": "1.2.0",
|
|
||||||
"sleep": "1.0.0"
|
|
||||||
},
|
|
||||||
"db_schema_version": "20260322",
|
|
||||||
"environment": "production"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Dieser Endpoint wird in `backend/routers/version.py` implementiert und liest
|
|
||||||
direkt aus `version.py`.
|
|
||||||
|
|
||||||
### 2.4 Versions-Anzeige in der App
|
|
||||||
|
|
||||||
**Settings-Seite – Versions-Panel:**
|
|
||||||
```
|
|
||||||
System-Versionen
|
|
||||||
─────────────────────────────────────
|
|
||||||
App (gesamt) 9.3.0
|
|
||||||
Backend 9.3.0 ✓ erreichbar
|
|
||||||
Frontend 9.3.0 ✓ geladen
|
|
||||||
DB-Schema 20260322
|
|
||||||
Umgebung production
|
|
||||||
─────────────────────────────────────
|
|
||||||
Module
|
|
||||||
auth 1.2.0
|
|
||||||
sleep 1.0.0
|
|
||||||
membership 2.1.0
|
|
||||||
[alle Module...]
|
|
||||||
─────────────────────────────────────
|
|
||||||
[Changelog] [Cache leeren]
|
|
||||||
```
|
|
||||||
|
|
||||||
Frontend ruft beim Laden der Settings-Seite `/api/version` ab und vergleicht
|
|
||||||
mit der eigenen `APP_VERSION` aus `version.js`. Bei Abweichung: Warnung anzeigen.
|
|
||||||
|
|
||||||
### 2.5 Pflicht-Regel: Versions-Bump bei jedem Commit
|
|
||||||
|
|
||||||
**Jede Code-Änderung erfordert:**
|
|
||||||
1. Versions-Bump in `backend/version.py` (APP_VERSION + betroffenes MODULE_VERSION)
|
|
||||||
2. Versions-Bump in `frontend/src/version.js` (APP_VERSION + betroffene PAGE_VERSION)
|
|
||||||
3. Changelog-Eintrag in `backend/version.py` CHANGELOG
|
|
||||||
|
|
||||||
**Claude Code prüft das im `/deploy` Command automatisch.**
|
|
||||||
|
|
||||||
Kein Commit ohne Versions-Bump – keine Ausnahme.
|
|
||||||
|
|
||||||
### 2.6 DB-Schema-Version
|
|
||||||
|
|
||||||
Format: `YYYYMMDD` (Datum der letzten Migration)
|
|
||||||
|
|
||||||
Gespeichert in `backend/version.py`:
|
|
||||||
```python
|
|
||||||
DB_SCHEMA_VERSION = "20260322"
|
|
||||||
```
|
|
||||||
|
|
||||||
Bei jeder Schema-Änderung (ALTER TABLE, neue Tabelle) → DB_SCHEMA_VERSION aktualisieren.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Datenbankregeln
|
|
||||||
|
|
||||||
### 3.1 Pflichtfelder für neue Tabellen
|
|
||||||
```sql
|
|
||||||
-- Jede neue Tabelle braucht:
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP DEFAULT NOW()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 Source-Tracking bei Import-Daten
|
|
||||||
Tabellen die Daten aus externen Quellen empfangen brauchen:
|
|
||||||
```sql
|
|
||||||
source VARCHAR(50) DEFAULT 'manual'
|
|
||||||
-- Werte: 'manual' | 'apple_health' | 'garmin' | 'withings'
|
|
||||||
```
|
|
||||||
|
|
||||||
Manuelle Einträge (`source = 'manual'`) haben IMMER Vorrang bei Reimport:
|
|
||||||
```sql
|
|
||||||
-- Reimport überschreibt nur nicht-manuelle Einträge:
|
|
||||||
INSERT INTO sleep_log (...) ON CONFLICT (profile_id, date)
|
|
||||||
DO UPDATE SET ... WHERE sleep_log.source != 'manual'
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.3 Profile-ID Isolation
|
|
||||||
Jede Tabelle mit Nutzerdaten hat `profile_id` als Foreign Key.
|
|
||||||
Kein Endpoint gibt Daten eines anderen Profils zurück.
|
|
||||||
Profile-ID kommt IMMER aus der Session, nie aus Request-Parametern.
|
|
||||||
|
|
||||||
### 3.4 Boolean-Werte
|
|
||||||
```sql
|
|
||||||
-- PostgreSQL Boolean (nicht SQLite 0/1):
|
|
||||||
WHERE active = true ✓
|
|
||||||
WHERE active = 1 ✗
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Frontend-Regeln
|
|
||||||
|
|
||||||
### 4.1 Alle API-Calls über api.js
|
|
||||||
```javascript
|
|
||||||
// ✅ Richtig:
|
|
||||||
import { api } from '../utils/api'
|
|
||||||
const data = await api.listSleep()
|
|
||||||
|
|
||||||
// ❌ Falsch:
|
|
||||||
const r = await fetch('/api/sleep')
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 Neue Seite = Eintrag in PAGE_VERSIONS
|
|
||||||
Jede neue Seite in `frontend/src/version.js` registrieren.
|
|
||||||
|
|
||||||
### 4.3 CSS-Variablen statt Hardcoded-Farben
|
|
||||||
```javascript
|
|
||||||
// ✅ Richtig:
|
|
||||||
style={{color: 'var(--accent)'}}
|
|
||||||
|
|
||||||
// ❌ Falsch:
|
|
||||||
style={{color: '#1D9E75'}}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.4 Fehlerbehandlung in allen async Funktionen
|
|
||||||
```javascript
|
|
||||||
try {
|
|
||||||
const data = await api.meinEndpoint()
|
|
||||||
setData(data)
|
|
||||||
} catch(e) {
|
|
||||||
setError(e.message)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Git & Deployment-Regeln
|
|
||||||
|
|
||||||
### 5.1 Nie direkt auf main pushen
|
|
||||||
Immer über Pull Request in Gitea: develop → main.
|
|
||||||
develop Branch niemals löschen.
|
|
||||||
|
|
||||||
### 5.2 Commit-Message Format
|
|
||||||
```
|
|
||||||
feat: neues Feature oder Modul
|
|
||||||
fix: Bugfix
|
|
||||||
refactor: Umbau ohne Funktionsänderung
|
|
||||||
docs: Dokumentation
|
|
||||||
version: Versions-Bump
|
|
||||||
ci: CI/CD Änderungen
|
|
||||||
chore: Maintenance
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.3 Versions-Bump im Commit
|
|
||||||
```
|
|
||||||
feat: Sleep Module v1.0.0
|
|
||||||
|
|
||||||
- sleep_log Tabelle mit JSONB-Segmenten
|
|
||||||
- Import aus Apple Health CSV
|
|
||||||
- Korrelationen Schlaf <-> Ruhepuls
|
|
||||||
|
|
||||||
version: 9.3.0 (backend + frontend)
|
|
||||||
module: sleep 1.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Dokumentations-Regeln
|
|
||||||
|
|
||||||
### 6.1 Neue Module dokumentieren
|
|
||||||
Bei jedem neuen Modul:
|
|
||||||
1. Fachliche Spec: `.claude/docs/functional/MODUL_NAME.md`
|
|
||||||
2. Technische Spec: `.claude/docs/technical/MODUL_NAME.md`
|
|
||||||
3. Nach Fertigstellung: `.claude/library/` aktualisieren
|
|
||||||
|
|
||||||
### 6.2 CLAUDE.md aktuell halten
|
|
||||||
Nach größeren Änderungen CLAUDE.md Versions-Tabelle aktualisieren.
|
|
||||||
|
|
||||||
### 6.3 Lessons Learned dokumentieren
|
|
||||||
Jeder Rollback oder schwerer Bug → Eintrag in `.claude/rules/LESSONS_LEARNED.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Zusammenfassung: Checkliste vor jedem Commit
|
|
||||||
|
|
||||||
```
|
|
||||||
[ ] Versions-Bump in backend/version.py (APP_VERSION + MODULE)
|
|
||||||
[ ] Versions-Bump in frontend/src/version.js (APP_VERSION + PAGE)
|
|
||||||
[ ] Changelog-Eintrag in backend/version.py
|
|
||||||
[ ] DB_SCHEMA_VERSION aktualisiert (wenn Schema geändert)
|
|
||||||
[ ] Neues Modul in PAGE_VERSIONS / MODULE_VERSIONS eingetragen
|
|
||||||
[ ] Auth auf alle neuen Endpoints (require_auth)
|
|
||||||
[ ] Fehlerformat einheitlich (HTTPException mit detail)
|
|
||||||
[ ] Neue Tabellen haben created_at + updated_at
|
|
||||||
[ ] Import-Tabellen haben source-Feld
|
|
||||||
[ ] api.js für alle Frontend API-Calls
|
|
||||||
```
|
|
||||||
|
|
@ -39,13 +39,6 @@ from slowapi import Limiter
|
||||||
def sensitive(request: Request, ...):
|
def sensitive(request: Request, ...):
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. Universal CSV Import / Admin-Vorlagen
|
|
||||||
Neues **Import-Zielmodul**, Änderungen an **`csv_parser`**, Executor, DB-`source`/`CHECK`, oder System-CSV-Vorlagen:
|
|
||||||
|
|
||||||
- Pflichtlektüre und Checkliste: **`.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md`**
|
|
||||||
- Keine zweite DB-Connection im Importpfad; Zeilenfehler ohne „aborted transaction“ (SAVEPOINT-Muster wo nötig)
|
|
||||||
- Admin Create/Update von Systemvorlagen: Validierung über `validate_csv_template` nicht umgehen
|
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
### 1. api.js für alle API-Calls
|
### 1. api.js für alle API-Calls
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@
|
||||||
|-----|------|----------------|
|
|-----|------|----------------|
|
||||||
| **Fachliche Spec (WAS)** | `.claude/docs/functional/` | Domäne, Use Cases, UX-Ziele, fachliche Datenarchitektur. **Keine** reine API-Parameterliste (→ technical). |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **Audits & Matrizen** | `.claude/docs/audit/` | Zeitlich begrenzte Reviews, Reconciliation, Gitea-Vorlagen. |
|
||||||
|
|
@ -64,7 +63,6 @@ Diese Ordner sind **kein** Ersatz für `working/` oder `docs/issues/`, wenn das
|
||||||
```
|
```
|
||||||
.claude/README.md ← Einstieg Agent/Human
|
.claude/README.md ← Einstieg Agent/Human
|
||||||
.claude/docs/README.md ← Spec-Katalog
|
.claude/docs/README.md ← Spec-Katalog
|
||||||
.claude/docs/jinkendo-foundation/ ← Foundation Produktfamilie (Designprinzipien)
|
|
||||||
.claude/docs/functional/ ← WAS
|
.claude/docs/functional/ ← WAS
|
||||||
.claude/docs/technical/ ← WIE
|
.claude/docs/technical/ ← WIE
|
||||||
.claude/docs/working/ ← Arbeitspapiere / Analysen
|
.claude/docs/working/ ← Arbeitspapiere / Analysen
|
||||||
|
|
|
||||||
|
|
@ -3,82 +3,22 @@ name: Build Test
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, develop]
|
branches: [main, develop]
|
||||||
workflow_run:
|
|
||||||
workflows: ["Deploy Development", "Deploy Production"]
|
|
||||||
types: [completed]
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pytest-backend:
|
|
||||||
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Run backend pytest suite in deployed container
|
|
||||||
run: |
|
|
||||||
EVENT_NAME="${{ github.event_name }}"
|
|
||||||
REF_NAME="${{ github.ref_name }}"
|
|
||||||
RUN_WORKFLOW="${{ github.event.workflow_run.name }}"
|
|
||||||
APP_DIR="/home/lars/docker/bodytrack"
|
|
||||||
COMPOSE_FILE="docker-compose.yml"
|
|
||||||
|
|
||||||
if [ "$EVENT_NAME" = "workflow_run" ]; then
|
|
||||||
if [ "$RUN_WORKFLOW" = "Deploy Development" ]; then
|
|
||||||
APP_DIR="/home/lars/docker/bodytrack-dev"
|
|
||||||
COMPOSE_FILE="docker-compose.dev-env.yml"
|
|
||||||
fi
|
|
||||||
elif [ "$REF_NAME" = "develop" ]; then
|
|
||||||
APP_DIR="/home/lars/docker/bodytrack-dev"
|
|
||||||
COMPOSE_FILE="docker-compose.dev-env.yml"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "$APP_DIR"
|
|
||||||
docker compose -f "$COMPOSE_FILE" exec -T backend sh -lc "
|
|
||||||
pip install -r /app/requirements-dev.txt &&
|
|
||||||
cd /app &&
|
|
||||||
python -m pytest tests -m 'not slow' -ra -vv --tb=short
|
|
||||||
"
|
|
||||||
|
|
||||||
lint-backend:
|
lint-backend:
|
||||||
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check backend syntax on deployed app
|
- name: Check backend syntax
|
||||||
run: |
|
run: |
|
||||||
EVENT_NAME="${{ github.event_name }}"
|
python3 -m py_compile /home/lars/docker/bodytrack/backend/main.py
|
||||||
REF_NAME="${{ github.ref_name }}"
|
|
||||||
RUN_WORKFLOW="${{ github.event.workflow_run.name }}"
|
|
||||||
APP_DIR="/home/lars/docker/bodytrack"
|
|
||||||
|
|
||||||
if [ "$EVENT_NAME" = "workflow_run" ]; then
|
|
||||||
if [ "$RUN_WORKFLOW" = "Deploy Development" ]; then
|
|
||||||
APP_DIR="/home/lars/docker/bodytrack-dev"
|
|
||||||
fi
|
|
||||||
elif [ "$REF_NAME" = "develop" ]; then
|
|
||||||
APP_DIR="/home/lars/docker/bodytrack-dev"
|
|
||||||
fi
|
|
||||||
|
|
||||||
python3 -m py_compile "$APP_DIR/backend/main.py"
|
|
||||||
echo "✓ Backend syntax OK"
|
echo "✓ Backend syntax OK"
|
||||||
|
|
||||||
build-frontend:
|
build-frontend:
|
||||||
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Build frontend on deployed app
|
- name: Build frontend
|
||||||
run: |
|
run: |
|
||||||
EVENT_NAME="${{ github.event_name }}"
|
cd /home/lars/docker/bodytrack/frontend
|
||||||
REF_NAME="${{ github.ref_name }}"
|
|
||||||
RUN_WORKFLOW="${{ github.event.workflow_run.name }}"
|
|
||||||
APP_DIR="/home/lars/docker/bodytrack"
|
|
||||||
|
|
||||||
if [ "$EVENT_NAME" = "workflow_run" ]; then
|
|
||||||
if [ "$RUN_WORKFLOW" = "Deploy Development" ]; then
|
|
||||||
APP_DIR="/home/lars/docker/bodytrack-dev"
|
|
||||||
fi
|
|
||||||
elif [ "$REF_NAME" = "develop" ]; then
|
|
||||||
APP_DIR="/home/lars/docker/bodytrack-dev"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "$APP_DIR/frontend"
|
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
npm run build
|
||||||
echo "✓ Frontend build OK"
|
echo "✓ Frontend build OK"
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -58,9 +58,6 @@ coverage/
|
||||||
# Temp
|
# Temp
|
||||||
tmp/
|
tmp/
|
||||||
*.tmp
|
*.tmp
|
||||||
test-results/
|
|
||||||
screenshots/
|
|
||||||
tests/.auth/
|
|
||||||
|
|
||||||
# Claude: nur ausgewählte Bereiche versionieren (siehe .claude/rules/DOCUMENTATION.md)
|
# Claude: nur ausgewählte Bereiche versionieren (siehe .claude/rules/DOCUMENTATION.md)
|
||||||
.claude/**
|
.claude/**
|
||||||
|
|
|
||||||
62
CLAUDE.md
62
CLAUDE.md
|
|
@ -8,12 +8,9 @@
|
||||||
> | Coding-Regeln | `.claude/rules/CODING_RULES.md` |
|
> | Coding-Regeln | `.claude/rules/CODING_RULES.md` |
|
||||||
> | Lessons Learned | `.claude/rules/LESSONS_LEARNED.md` |
|
> | Lessons Learned | `.claude/rules/LESSONS_LEARNED.md` |
|
||||||
> | **Gitea-Landkarte (lokal gepflegt)** | **`.claude/docs/GITEA_ISSUES_INDEX.md`** |
|
> | **Gitea-Landkarte (lokal gepflegt)** | **`.claude/docs/GITEA_ISSUES_INDEX.md`** |
|
||||||
> | **Universal CSV Import** (neues Modul / Executor / Vorlagen) | **`.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md`** |
|
|
||||||
> | **GUI / IA / Admin / Nav / PWA-Leiste** | **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`** |
|
> | **GUI / IA / Admin / Nav / PWA-Leiste** | **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`** |
|
||||||
> | **Dashboard-Widgets** (Katalog, Registrierung, `config`) | **`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`** |
|
> | **Dashboard-Lab-Widgets** (Katalog, Registrierung, `config`) | **`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`** |
|
||||||
> | **Designprinzipien Produktfamilie** (Serie 1–9, Foundation) | **`.claude/docs/jinkendo-foundation/design-principles/README.md`** |
|
|
||||||
> | **Agent-Einstieg** | **`.claude/README.md`** |
|
> | **Agent-Einstieg** | **`.claude/README.md`** |
|
||||||
> | **Activity Session Metrics (EAV, Attributprofile)** | **`.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md`** |
|
|
||||||
|
|
||||||
## Claude Code Verantwortlichkeiten
|
## Claude Code Verantwortlichkeiten
|
||||||
|
|
||||||
|
|
@ -101,61 +98,6 @@ frontend/src/
|
||||||
**Branch:** develop
|
**Branch:** develop
|
||||||
**Nächster Schritt:** Frontend Chart Integration → Testing → Prod Deploy v0.9i
|
**Nächster Schritt:** Frontend Chart Integration → Testing → Prod Deploy v0.9i
|
||||||
|
|
||||||
### Updates (23.04.2026 - Dashboard: veraltete Demo-Route entfernt, klare Produkt-Registry)
|
|
||||||
|
|
||||||
- **Frontend:** Veraltete Visualisierungs-Demo-Route und festes Demo-Layout entfernt; Widget-Registrierung in `frontend/src/widgetSystem/registerDashboardWidgets.js` (`ensureDashboardWidgetsRegistered`). Kern-Widgets unter `frontend/src/components/dashboard-widgets-legacy/`. Chart-Hilfen in `frontend/src/widgetSystem/dashboardChartUtils.js`. Experimentelles Layout-Lab entfernt; Konfiguration nur noch **Übersicht anpassen** (`DashboardConfigurePage`).
|
|
||||||
- **Doku:** `.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` und Kommentar in `backend/widget_catalog.py` angepasst.
|
|
||||||
|
|
||||||
### Updates (09.04.2026 - Universal CSV Import, Prod-Migration abgeschlossen)
|
|
||||||
|
|
||||||
- **Agent-Leitfaden:** `.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` (Checkliste für neue Import-Module, Executor, Vorlagen, `source=csv`, SAVEPOINT-/Cursor-Regeln)
|
|
||||||
- **Regeln:** Verweise in `.claude/rules/ARCHITECTURE.md` (§3.2 `source`), `.claude/rules/CODING_RULES.md` (§6)
|
|
||||||
- **Follow-ups:** **Gitea #71** – Dry-Run inkl. `import_row_processing`, Nutzer-Mapping-Validierung, Fehler-Hints in der Import-UI ([Issue](http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/71))
|
|
||||||
|
|
||||||
### Updates (11.04.2026 - Placeholder Phase A)
|
|
||||||
|
|
||||||
- **`main.py`:** `import placeholder_registrations` beim Start, damit die Registry (**114 Keys**, deckungsgleich `PLACEHOLDER_MAP`) und `get_placeholder_catalog()` ohne vorherigen Export-Request konsistent sind.
|
|
||||||
- **`placeholder_resolver.py`:** `{{top_goal_progress_pct}}` nutzt `_safe_int` statt `_safe_str` (Verdrahtung zu `scores.get_top_priority_goal` korrigiert).
|
|
||||||
|
|
||||||
### Updates (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.
|
|
||||||
- **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:** Stoffe über EAV (`GET /bls/attributes`, `attributes` + `serving_g` beim Anlegen). Supplemente wie Norsan: EPA/DHA aus Etikett, Portionsgramm → Speicherung /100 g.
|
|
||||||
- **Listen / Kombinationen (Mitai):** Dialog mit Zutatensuche (Mappings + Katalog). Menge + Einheit; Umrechnung auf Gramm. Rohmischung ohne Kochschwund. Gekochte Familienrezepte: Tandoor + später `cooked_yield_g` / Fertiggericht pro 100 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).
|
|
||||||
- **`routers/charts.py`:** `/charts/energy-balance` und Protein-Timeline nutzen dieselbe TDEE-/Tageslogik; ohne `weight_log` liefert Energiebilanz-Chart eine klare Fehlermeldung. Adherence-Endpoint: Kcal-CV über **Tages-Summen**.
|
|
||||||
- **Doku:** Normative Platzhalter-Zahl **114** (`docs/PLACEHOLDER_*.md`); `placeholder_metadata_complete.py` als **Legacy** gekennzeichnet — maßgeblich `placeholder_registrations/` + `PLACEHOLDER_REGISTRY_FRAMEWORK.md`.
|
|
||||||
|
|
||||||
### GUI / Informationsarchitektur (Abnahme dieser Iteration, 2026-04-05)
|
### GUI / Informationsarchitektur (Abnahme dieser Iteration, 2026-04-05)
|
||||||
|
|
||||||
Admin-Bereich (`AdminShell`, Hub-Routen), Hauptnavigation inkl. **Ziele** (`config/appNav.js`), Einstellungen nur aktives Profil + E-Mail, KI-Analyse Ergebnis in rechter Spalte, **PWA** Bottom-Nav inkl. iOS Safe Area. Zentrale Agent-Doku: **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`**. Responsive-Epic **Gitea #30:** Phasenplan `docs/issues/PHASE_PLAN_RESPONSIVE_UI.md` — **P7 Kern erledigt**, **P8** (Regression/Abnahme) ausstehend; Issue bewusst **nicht** geschlossen.
|
Admin-Bereich (`AdminShell`, Hub-Routen), Hauptnavigation inkl. **Ziele** (`config/appNav.js`), Einstellungen nur aktives Profil + E-Mail, KI-Analyse Ergebnis in rechter Spalte, **PWA** Bottom-Nav inkl. iOS Safe Area. Zentrale Agent-Doku: **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`**. Responsive-Epic **Gitea #30:** Phasenplan `docs/issues/PHASE_PLAN_RESPONSIVE_UI.md` — **P7 Kern erledigt**, **P8** (Regression/Abnahme) ausstehend; Issue bewusst **nicht** geschlossen.
|
||||||
|
|
@ -910,7 +852,7 @@ Bottom-Padding Mobile: 80px (Navigation)
|
||||||
|Auth-Flow|`.claude/library/AUTH.md`|Sicherheit + Sessions|
|
|Auth-Flow|`.claude/library/AUTH.md`|Sicherheit + Sessions|
|
||||||
|API-Referenz|`.claude/library/API\_REFERENCE.md`|Alle Endpoints|
|
|API-Referenz|`.claude/library/API\_REFERENCE.md`|Alle Endpoints|
|
||||||
|Datenbankschema|`.claude/library/DATABASE.md`|Tabellen + Beziehungen|
|
|Datenbankschema|`.claude/library/DATABASE.md`|Tabellen + Beziehungen|
|
||||||
|Dashboard-Widgets|`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`|Katalog, Validierung, Frontend-Registry, konfigurierbare `config`|
|
|Dashboard-Lab-Widgets|`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`|Katalog, Validierung, Frontend-Registry, konfigurierbare `config`|
|
||||||
|Projekt-Doku (Git)|`docs/README.md` + `docs/issues/`|Issue-Specs, Reviews, Platzhalter-Governance, Status-Snapshots|
|
|Projekt-Doku (Git)|`docs/README.md` + `docs/issues/`|Issue-Specs, Reviews, Platzhalter-Governance, Status-Snapshots|
|
||||||
|
|
||||||
> Library-Dateien werden mit `/document` generiert und nach größeren
|
> Library-Dateien werden mit `/document` generiert und nach größeren
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,6 @@ import bcrypt
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
from db import get_db, get_cursor
|
||||||
|
|
||||||
print("[AUTH.PY] Module loaded - require_auth_flexible will be defined")
|
|
||||||
|
|
||||||
|
|
||||||
def hash_pin(pin: str) -> str:
|
def hash_pin(pin: str) -> str:
|
||||||
"""Hash password with bcrypt. Falls back gracefully from legacy SHA256."""
|
"""Hash password with bcrypt. Falls back gracefully from legacy SHA256."""
|
||||||
|
|
@ -78,24 +76,21 @@ def require_auth(x_auth_token: Optional[str] = Header(default=None)):
|
||||||
return session
|
return session
|
||||||
|
|
||||||
|
|
||||||
def require_auth_flexible(x_auth_token: Optional[str] = Header(default=None), ssetoken: Optional[str] = Query(default=None)):
|
def require_auth_flexible(x_auth_token: Optional[str] = Header(default=None), token: Optional[str] = Query(default=None)):
|
||||||
"""
|
"""
|
||||||
FastAPI dependency - auth via header OR query parameter.
|
FastAPI dependency - auth via header OR query parameter.
|
||||||
|
|
||||||
Used for endpoints accessed by <img> tags and SSE connections that can't send headers.
|
Used for endpoints accessed by <img> tags that can't send headers.
|
||||||
Query parameter is 'ssetoken' to avoid conflicts with endpoint 'token' parameters.
|
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
@app.get("/api/photos/{id}")
|
@app.get("/api/photos/{id}")
|
||||||
def get_photo(id: str, session: dict = Depends(require_auth_flexible)):
|
def get_photo(id: str, session: dict = Depends(require_auth_flexible)):
|
||||||
...
|
...
|
||||||
|
|
||||||
Call with: ?ssetoken=XXX or Header: X-Auth-Token: XXX
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException 401 if not authenticated
|
HTTPException 401 if not authenticated
|
||||||
"""
|
"""
|
||||||
session = get_session(x_auth_token or ssetoken)
|
session = get_session(x_auth_token or token)
|
||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(401, "Nicht eingeloggt")
|
raise HTTPException(401, "Nicht eingeloggt")
|
||||||
return session
|
return session
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
"""BLS 4.0 ingest (official MRI XLSX)."""
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
"""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]:
|
|
||||||
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") -> dict[str, int]:
|
|
||||||
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),
|
|
||||||
}
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
"""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})
|
|
||||||
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)
|
|
||||||
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))
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
"""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) -> int | None:
|
|
||||||
for n in names:
|
|
||||||
if n.lower() in idx:
|
|
||||||
return idx[n.lower()]
|
|
||||||
for key, i in idx.items():
|
|
||||||
for n in names:
|
|
||||||
if n.lower() in key:
|
|
||||||
return i
|
|
||||||
return None
|
|
||||||
|
|
||||||
i_code = col("code", "schlüssel", "schluessel", "attr_key", "komponente")
|
|
||||||
i_de = col("name_de", "deutsch", "bezeichnung_de", "name de")
|
|
||||||
i_en = col("name_en", "english", "bezeichnung_en", "name en")
|
|
||||||
i_unit = col("unit", "einheit")
|
|
||||||
i_cat = col("category", "kategorie", "gruppe")
|
|
||||||
if i_code is None:
|
|
||||||
i_code = 0
|
|
||||||
if i_de is None:
|
|
||||||
i_de = 1 if len(header) > 1 else 0
|
|
||||||
|
|
||||||
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"):
|
|
||||||
continue
|
|
||||||
name_de = _cell(raw[i_de] if i_de is not None and i_de < len(raw) else "") or code
|
|
||||||
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}
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
"""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,11 +223,6 @@ def calculate_arm_28d_delta(profile_id: str) -> Optional[float]:
|
||||||
return _calculate_circumference_delta(profile_id, 'c_arm', 28)
|
return _calculate_circumference_delta(profile_id, 'c_arm', 28)
|
||||||
|
|
||||||
|
|
||||||
def calculate_arm_relaxed_28d_delta(profile_id: str) -> Optional[float]:
|
|
||||||
"""28-day relaxed arm circumference change (cm)."""
|
|
||||||
return _calculate_circumference_delta(profile_id, 'c_arm_relaxed', 28)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_thigh_28d_delta(profile_id: str) -> Optional[float]:
|
def calculate_thigh_28d_delta(profile_id: str) -> Optional[float]:
|
||||||
"""Calculate 28-day thigh circumference change (cm)"""
|
"""Calculate 28-day thigh circumference change (cm)"""
|
||||||
delta = _calculate_circumference_delta(profile_id, 'c_thigh', 28)
|
delta = _calculate_circumference_delta(profile_id, 'c_thigh', 28)
|
||||||
|
|
|
||||||
|
|
@ -509,15 +509,8 @@ def calculate_sleep_quality_7d(profile_id: str) -> Optional[int]:
|
||||||
|
|
||||||
quality_scores = []
|
quality_scores = []
|
||||||
for s in sleep_data:
|
for s in sleep_data:
|
||||||
dur = s["duration_minutes"]
|
if s['deep_minutes'] and s['rem_minutes']:
|
||||||
if not dur or dur <= 0:
|
quality_pct = ((s['deep_minutes'] + s['rem_minutes']) / s['duration_minutes']) * 100
|
||||||
continue
|
|
||||||
d = s["deep_minutes"]
|
|
||||||
r = s["rem_minutes"]
|
|
||||||
if d is None and r is None:
|
|
||||||
continue
|
|
||||||
di, ri = (d or 0), (r or 0)
|
|
||||||
quality_pct = ((di + ri) / dur) * 100
|
|
||||||
# 40-60% deep+REM is good
|
# 40-60% deep+REM is good
|
||||||
if quality_pct >= 45:
|
if quality_pct >= 45:
|
||||||
quality_scores.append(100)
|
quality_scores.append(100)
|
||||||
|
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
"""Universal CSV import foundation (Issue #21)."""
|
|
||||||
|
|
||||||
from csv_parser.core import (
|
|
||||||
decode_raw_bytes,
|
|
||||||
sniff_delimiter,
|
|
||||||
parse_csv_sample,
|
|
||||||
column_signature,
|
|
||||||
normalize_header_for_signature,
|
|
||||||
)
|
|
||||||
from csv_parser.module_registry import MODULE_DEFINITIONS, get_module_definition, list_modules
|
|
||||||
from csv_parser.type_converter import convert_value, build_row_after_mapping
|
|
||||||
from csv_parser.permissions import user_may_delete_mapping, user_may_edit_mapping_row
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"decode_raw_bytes",
|
|
||||||
"sniff_delimiter",
|
|
||||||
"parse_csv_sample",
|
|
||||||
"column_signature",
|
|
||||||
"normalize_header_for_signature",
|
|
||||||
"MODULE_DEFINITIONS",
|
|
||||||
"get_module_definition",
|
|
||||||
"list_modules",
|
|
||||||
"convert_value",
|
|
||||||
"build_row_after_mapping",
|
|
||||||
"user_may_delete_mapping",
|
|
||||||
"user_may_edit_mapping_row",
|
|
||||||
]
|
|
||||||
|
|
@ -1,260 +0,0 @@
|
||||||
"""
|
|
||||||
CSV bytes → text, delimiter sniffing, strukturierte Erstzeilen für Analyse (Issue #21).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import csv
|
|
||||||
import io
|
|
||||||
import re
|
|
||||||
from typing import Any, Dict, Iterator, List, Sequence, Tuple
|
|
||||||
|
|
||||||
_DEFAULT_DELIMS = [",", ";", "\t"]
|
|
||||||
|
|
||||||
|
|
||||||
def decode_raw_bytes(raw: bytes) -> str:
|
|
||||||
"""UTF-8 bevorzugt, Fallback Latin-1; BOM entfernen."""
|
|
||||||
if not raw:
|
|
||||||
return ""
|
|
||||||
for enc in ("utf-8-sig", "utf-8", "latin-1"):
|
|
||||||
try:
|
|
||||||
text = raw.decode(enc)
|
|
||||||
break
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
text = ""
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
text = raw.decode("utf-8", errors="replace")
|
|
||||||
if text.startswith("\ufeff"):
|
|
||||||
text = text[1:]
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def sniff_delimiter(sample_line: str) -> str:
|
|
||||||
"""
|
|
||||||
Heuristik: Zähle Vorkommen der Kandidaten in der ersten Datenzeile.
|
|
||||||
Kein csv.Sniffer (robuster gegen kurze Zeilen).
|
|
||||||
"""
|
|
||||||
if not sample_line or not sample_line.strip():
|
|
||||||
return ","
|
|
||||||
best = ","
|
|
||||||
best_count = -1
|
|
||||||
for d in _DEFAULT_DELIMS:
|
|
||||||
c = sample_line.count(d)
|
|
||||||
if c > best_count:
|
|
||||||
best_count = c
|
|
||||||
best = d
|
|
||||||
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():
|
|
||||||
if line.strip():
|
|
||||||
lines.append(line)
|
|
||||||
if len(lines) >= max_lines:
|
|
||||||
break
|
|
||||||
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,
|
|
||||||
has_header: bool = True,
|
|
||||||
max_data_rows: int = 5,
|
|
||||||
) -> Tuple[List[str], List[dict[str, str]], str]:
|
|
||||||
"""
|
|
||||||
Gibt (headers, rows_as_dicts, verwendetes_delimiter) zurück.
|
|
||||||
rows sind Rohstrings pro Zelle.
|
|
||||||
"""
|
|
||||||
lines = _split_first_lines(text, max_lines=50)
|
|
||||||
if not lines:
|
|
||||||
return [], [], ","
|
|
||||||
|
|
||||||
delim = delimiter if delimiter is not None else sniff_delimiter(lines[0])
|
|
||||||
reader = csv.reader(io.StringIO(text.replace("\r\n", "\n").replace("\r", "\n")), delimiter=delim)
|
|
||||||
rows_raw: List[List[str]] = []
|
|
||||||
for i, row in enumerate(reader):
|
|
||||||
if i >= 1 + max_data_rows + (1 if has_header else 0):
|
|
||||||
break
|
|
||||||
if not any(c.strip() for c in row):
|
|
||||||
continue
|
|
||||||
rows_raw.append(row)
|
|
||||||
|
|
||||||
if not rows_raw:
|
|
||||||
return [], [], delim
|
|
||||||
|
|
||||||
if has_header:
|
|
||||||
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])
|
|
||||||
headers = [f"col_{i}" for i in range(n)]
|
|
||||||
data = rows_raw[:max_data_rows]
|
|
||||||
|
|
||||||
dict_rows: List[dict[str, str]] = []
|
|
||||||
for r in data:
|
|
||||||
row_dict: dict[str, str] = {}
|
|
||||||
for j, h in enumerate(headers):
|
|
||||||
row_dict[h] = r[j].strip() if j < len(r) else ""
|
|
||||||
dict_rows.append(row_dict)
|
|
||||||
|
|
||||||
return headers, dict_rows, delim
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_header_for_signature(name: str) -> str:
|
|
||||||
s = canonical_csv_header_label(name).lower()
|
|
||||||
s = re.sub(r"\s+", "_", s)
|
|
||||||
s = re.sub(r"[^a-z0-9_äöüß().%-]+", "_", s)
|
|
||||||
return s.strip("_")
|
|
||||||
|
|
||||||
|
|
||||||
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 canonical_csv_header_label(str(h))}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def headers_signature_match_score(sig_csv: List[str], sig_template: List[str]) -> float:
|
|
||||||
"""Jaccard-Überlappung 0..1 (|A∩B|/|A∪B|). Fällt stark, wenn die CSV viele Zusatzspalten hat."""
|
|
||||||
a, b = set(sig_csv), set(sig_template)
|
|
||||||
if not a and not b:
|
|
||||||
return 1.0
|
|
||||||
if not a or not b:
|
|
||||||
return 0.0
|
|
||||||
inter = len(a & b)
|
|
||||||
union = len(a | b)
|
|
||||||
return inter / union if union else 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def headers_signature_template_recall(sig_csv: Sequence[str], sig_template: Sequence[str]) -> float:
|
|
||||||
"""
|
|
||||||
Anteil der Template-Spalten (Signatur), die in der CSV vorkommen: |A∩B|/|B|.
|
|
||||||
100 %, sobald alle für die Vorlage relevanten Spalten in der Datei sind — unabhängig von
|
|
||||||
Zusatzspalten (Gewicht + Ernährung in einer Datei erzeugt keinen „Abzug“ für die jeweilige Vorlage).
|
|
||||||
"""
|
|
||||||
a = set(sig_csv)
|
|
||||||
b = {normalize_header_for_signature(str(x)) for x in sig_template}
|
|
||||||
b.discard("")
|
|
||||||
if not b:
|
|
||||||
return 1.0 if not a else 0.0
|
|
||||||
inter = len(a & b)
|
|
||||||
return inter / len(b)
|
|
||||||
|
|
||||||
|
|
||||||
def headers_signature_rank_metrics(sig_csv: List[str], sig_template: List[str]) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Einheitliche Kennzahlen für Vorlagen-Ranking und UI.
|
|
||||||
confidence = template_recall (empfohlen für Anzeige / Sortierung primär).
|
|
||||||
"""
|
|
||||||
a = set(sig_csv)
|
|
||||||
b = {normalize_header_for_signature(str(x)) for x in sig_template}
|
|
||||||
b.discard("")
|
|
||||||
inter = a & b
|
|
||||||
n_inter = len(inter)
|
|
||||||
n_b = len(b)
|
|
||||||
n_a = len(a)
|
|
||||||
union = len(a | b)
|
|
||||||
template_recall = n_inter / n_b if n_b else (1.0 if not n_a else 0.0)
|
|
||||||
jaccard = n_inter / union if union else 0.0
|
|
||||||
return {
|
|
||||||
"confidence": round(template_recall, 4),
|
|
||||||
"template_recall": round(template_recall, 4),
|
|
||||||
"jaccard": round(jaccard, 4),
|
|
||||||
"columns_matched": n_inter,
|
|
||||||
"columns_in_template": n_b,
|
|
||||||
"columns_in_csv": n_a,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_csv_import_limits(conn_row: dict | None) -> dict[str, int]:
|
|
||||||
"""Liest Limits aus system_config.csv_import; Fallback bei fehlendem Key."""
|
|
||||||
defaults = {"max_rows_per_file": 50_000, "max_file_bytes": 52_428_800}
|
|
||||||
if not conn_row or "value" not in conn_row:
|
|
||||||
return defaults
|
|
||||||
val = conn_row["value"]
|
|
||||||
if isinstance(val, dict):
|
|
||||||
out = {**defaults, **{k: int(v) for k, v in val.items() if k in defaults}}
|
|
||||||
return out
|
|
||||||
return defaults
|
|
||||||
|
|
||||||
|
|
||||||
def iter_csv_dict_rows(
|
|
||||||
text: str,
|
|
||||||
delimiter: str,
|
|
||||||
*,
|
|
||||||
has_header: bool = True,
|
|
||||||
) -> Iterator[Dict[str, str]]:
|
|
||||||
"""
|
|
||||||
Vollständige Datei zeilenweise als Dict (Header = Keys).
|
|
||||||
Spaltenreihenfolge ist egal; zusätzliche Spalten werden ignoriert, wenn sie nicht
|
|
||||||
in field_mappings vorkommen. Keine Obergrenze für die Spaltenanzahl (nur Zeilenlimits
|
|
||||||
kommen aus system_config / Import-Router).
|
|
||||||
"""
|
|
||||||
if not has_header:
|
|
||||||
raise ValueError("CSV ohne Kopfzeile wird für Import noch nicht unterstützt")
|
|
||||||
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
||||||
reader = csv.DictReader(io.StringIO(normalized), delimiter=delimiter)
|
|
||||||
for row in reader:
|
|
||||||
if row is None:
|
|
||||||
continue
|
|
||||||
if not any(v and str(v).strip() for v in row.values()):
|
|
||||||
continue
|
|
||||||
yield {
|
|
||||||
canonical_csv_header_label(k): (v or "").strip()
|
|
||||||
for k, v in row.items()
|
|
||||||
if canonical_csv_header_label(k)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,115 +0,0 @@
|
||||||
"""
|
|
||||||
Kanonische Speichereinheiten pro CSV-Zielfeld (module_registry: field.unit) und
|
|
||||||
abwählbare Quelleinheiten → Faktor für type_conversions.source_unit.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from csv_parser.module_registry import get_module_definition
|
|
||||||
|
|
||||||
# 1 kcal = 4.184 kJ (IEC/ISO 80000)
|
|
||||||
_KJ_TO_KCAL = 1.0 / 4.184
|
|
||||||
|
|
||||||
# — Energie (Ziel kcal) —
|
|
||||||
_ENERGY: list[dict[str, Any]] = [
|
|
||||||
{"id": "kcal", "label": "Kilokalorien (kcal), wie in DB", "factor": 1.0},
|
|
||||||
{"id": "kj", "label": "Kilojoule (kJ) → kcal", "factor": _KJ_TO_KCAL},
|
|
||||||
{"id": "j", "label": "Joule (J) → kcal", "factor": _KJ_TO_KCAL / 1000.0},
|
|
||||||
]
|
|
||||||
|
|
||||||
# — Masse klein (Ziel g): Makronährstoffe —
|
|
||||||
_GRAM: list[dict[str, Any]] = [
|
|
||||||
{"id": "g", "label": "Gramm (g), wie in DB", "factor": 1.0},
|
|
||||||
{"id": "kg", "label": "Kilogramm (kg) → g", "factor": 1000.0},
|
|
||||||
{"id": "mg", "label": "Milligramm (mg) → g", "factor": 0.001},
|
|
||||||
]
|
|
||||||
|
|
||||||
# — Körpergewicht (Ziel kg) —
|
|
||||||
_KG: list[dict[str, Any]] = [
|
|
||||||
{"id": "kg", "label": "Kilogramm (kg), wie in DB", "factor": 1.0},
|
|
||||||
{"id": "g", "label": "Gramm (g) → kg", "factor": 0.001},
|
|
||||||
{"id": "lb", "label": "Pfund / lb → kg", "factor": 0.45359237},
|
|
||||||
{"id": "oz", "label": "Unze / oz → kg", "factor": 0.028349523125},
|
|
||||||
]
|
|
||||||
|
|
||||||
# — Strecke (Ziel km) —
|
|
||||||
_KM: list[dict[str, Any]] = [
|
|
||||||
{"id": "km", "label": "Kilometer (km), wie in DB", "factor": 1.0},
|
|
||||||
{"id": "m", "label": "Meter (m) → km", "factor": 0.001},
|
|
||||||
{"id": "mi", "label": "Meilen (mi) → km", "factor": 1.609344},
|
|
||||||
]
|
|
||||||
|
|
||||||
_UNIT_FAMILY: dict[str, list[dict[str, Any]]] = {
|
|
||||||
"kcal": _ENERGY,
|
|
||||||
"g": _GRAM,
|
|
||||||
"kg": _KG,
|
|
||||||
"km": _KM,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_canonical_unit(module: str, db_field: str) -> str | None:
|
|
||||||
mod = get_module_definition(module)
|
|
||||||
if not mod:
|
|
||||||
return None
|
|
||||||
finfo: dict[str, Any] | None = mod.get("fields", {}).get(db_field)
|
|
||||||
if not finfo:
|
|
||||||
return None
|
|
||||||
u = finfo.get("unit")
|
|
||||||
return str(u) if u else None
|
|
||||||
|
|
||||||
|
|
||||||
def source_unit_choices_for_field(module: str, db_field: str) -> list[dict[str, Any]]:
|
|
||||||
"""Optionen für GUI: id, label, canonical_unit, is_canonical (Umrechnung serverseitig)."""
|
|
||||||
cu = get_canonical_unit(module, db_field)
|
|
||||||
if not cu:
|
|
||||||
return []
|
|
||||||
choices = _UNIT_FAMILY.get(cu)
|
|
||||||
if not choices:
|
|
||||||
return []
|
|
||||||
out: list[dict[str, Any]] = [
|
|
||||||
{
|
|
||||||
"id": c["id"],
|
|
||||||
"label": c["label"],
|
|
||||||
"canonical_unit": cu,
|
|
||||||
"is_canonical": c["id"] == cu,
|
|
||||||
}
|
|
||||||
for c in choices
|
|
||||||
]
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"id": "custom",
|
|
||||||
"label": "Benutzerdefiniert (Konvertierungsfaktor, z. B. ml→g je nach Dichte)",
|
|
||||||
"canonical_unit": cu,
|
|
||||||
"is_canonical": False,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def factor_source_to_canonical(module: str, db_field: str, source_unit: str | None) -> float:
|
|
||||||
"""
|
|
||||||
Multiplikator: CSV-Zahl * Faktor → Wert in kanonischer DB-Einheit.
|
|
||||||
Unbekannte/None/leer/Passthrough → 1.0
|
|
||||||
|
|
||||||
``source_unit`` ``custom`` / ``none``: kein Registry-Faktor (1.0); freie Skalierung nur über
|
|
||||||
``conversion_factor`` in type_conversions (JSON).
|
|
||||||
"""
|
|
||||||
if source_unit is None:
|
|
||||||
return 1.0
|
|
||||||
su = str(source_unit).strip().lower()
|
|
||||||
if not su:
|
|
||||||
return 1.0
|
|
||||||
if su in ("custom", "none"):
|
|
||||||
return 1.0
|
|
||||||
cu = get_canonical_unit(module, db_field)
|
|
||||||
if not cu:
|
|
||||||
return 1.0
|
|
||||||
choices = _UNIT_FAMILY.get(cu)
|
|
||||||
if not choices:
|
|
||||||
return 1.0
|
|
||||||
for c in choices:
|
|
||||||
if str(c["id"]).lower() == su:
|
|
||||||
return float(c["factor"])
|
|
||||||
return 1.0
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
"""
|
|
||||||
Menschenlesbare Hinweise zu typischen Import-/DB-Fehlern (Universal-CSV).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
|
|
||||||
def enrich_row_error(message: str, module: str | None = None) -> dict[str, str | None]:
|
|
||||||
"""
|
|
||||||
Ergänzt eine Rohexception-Zeichenkette um ``code`` und ``hint`` für die Fehlerliste im Import.
|
|
||||||
"""
|
|
||||||
low = (message or "").lower()
|
|
||||||
out: dict[str, str | None] = {"error": message, "code": None, "hint": None}
|
|
||||||
|
|
||||||
if "numeric field overflow" in low or "numeric value out of range" in low:
|
|
||||||
out["code"] = "db_numeric_overflow"
|
|
||||||
out["hint"] = (
|
|
||||||
"Wert passt nicht in die Datenbank-Spalte (z. B. NUMERIC mit begrenzter Größe). "
|
|
||||||
"Häufig: Kilojoule aus dem Export landen im Kalorien-Feld – in der Vorlage für kcal_active/kcal_resting "
|
|
||||||
'"source_unit": "kj" setzen. Oder eine falsche CSV-Spalte ist einem kleinen Zielfeld zugeordnet '
|
|
||||||
"(z. B. große Zahl in einem HF-Feld)."
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
if "violates check constraint" in low and "source" in low:
|
|
||||||
out["code"] = "db_check_constraint_source"
|
|
||||||
out["hint"] = (
|
|
||||||
"Die Tabelle erlaubt den gesetzten «source»-Wert nicht. "
|
|
||||||
"System-Vorlage / Migration zur erlaubten Quelle prüfen (z. B. csv für Universal-Import)."
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
if "current transaction is aborted" in low:
|
|
||||||
out["code"] = "transaction_aborted"
|
|
||||||
out["hint"] = (
|
|
||||||
"Eine frühere Zeile hat einen Datenbankfehler ausgelöst. "
|
|
||||||
"Zuerst die niedrigste Zeilennummer in error_details beheben (Vorlage/Daten prüfen)."
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
if "invalid input syntax" in low and "time" in low:
|
|
||||||
out["code"] = "db_time_cast"
|
|
||||||
out["hint"] = (
|
|
||||||
"start_time/end_time passen nicht zum erwarteten Zeitformat in der Datenbank. "
|
|
||||||
"Vorlage: Datums- und Zeitanteil konsistent (oft nur Uhrzeit, wenn date separat)."
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
if module == "activity" and "foreign key" in low:
|
|
||||||
out["code"] = "db_foreign_key"
|
|
||||||
out["hint"] = "Verknüpfung zur Datenbank verletzt (z. B. training_type). Support kontaktieren."
|
|
||||||
|
|
||||||
return out
|
|
||||||
|
|
@ -1,217 +0,0 @@
|
||||||
"""
|
|
||||||
Zeilenaggregation nach CSV-Mapping (group_by + aggregates), vor dem DB-Upsert.
|
|
||||||
|
|
||||||
Spezifikation in der Vorlage (import_row_processing JSONB). Optional: Modul-Default
|
|
||||||
(import_row_processing_default in module_registry) nur als **Legacy-Fallback**, wenn
|
|
||||||
die Vorlage nichts speichert — mittelfristig sollen Vorlagen explizit sein.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import datetime as dt
|
|
||||||
import statistics
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
from csv_parser.module_registry import get_module_definition
|
|
||||||
|
|
||||||
ALLOWED_AGGREGATES = frozenset({"sum", "mean", "min", "max", "median", "first", "last"})
|
|
||||||
# Mehr als eine CSV-Zeile pro group_by-Schlüssel
|
|
||||||
ALLOWED_MULTI_ROW_POLICIES = frozenset({"aggregate", "reject", "first_row", "last_row"})
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_import_row_processing(module: str, mapping_row: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
||||||
"""Explizite Vorlage hat Vorrang; sonst Modul-Default; leeres Dict zählt wie „nicht gesetzt“."""
|
|
||||||
raw = mapping_row.get("import_row_processing")
|
|
||||||
if isinstance(raw, dict) and raw:
|
|
||||||
return dict(raw)
|
|
||||||
mod = get_module_definition(module)
|
|
||||||
if not mod:
|
|
||||||
return None
|
|
||||||
default = mod.get("import_row_processing_default")
|
|
||||||
if isinstance(default, dict) and default:
|
|
||||||
return dict(default)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
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 []
|
|
||||||
if not isinstance(group_by, list) or not all(isinstance(x, str) for x in group_by):
|
|
||||||
raise ValueError("import_row_processing.group_by muss eine Liste von Feldnamen sein")
|
|
||||||
aggregates = spec.get("aggregates") or {}
|
|
||||||
if not isinstance(aggregates, dict):
|
|
||||||
raise ValueError("import_row_processing.aggregates muss ein Objekt sein")
|
|
||||||
|
|
||||||
for g in group_by:
|
|
||||||
if g not in allowed:
|
|
||||||
raise ValueError(f"group_by: unbekanntes Feld '{g}' für Modul '{module}'")
|
|
||||||
if g not in fm_targets:
|
|
||||||
raise ValueError(
|
|
||||||
f"group_by: Zielfeld '{g}' ist keiner CSV-Spalte zugeordnet — Aggregation nicht möglich."
|
|
||||||
)
|
|
||||||
|
|
||||||
for field, op in aggregates.items():
|
|
||||||
if field not in allowed:
|
|
||||||
raise ValueError(f"aggregates: unbekanntes Feld '{field}' für Modul '{module}'")
|
|
||||||
if str(op) not in ALLOWED_AGGREGATES:
|
|
||||||
raise ValueError(
|
|
||||||
f"aggregates['{field}']: ungültige Operation '{op}'. "
|
|
||||||
f"Erlaubt: {', '.join(sorted(ALLOWED_AGGREGATES))}"
|
|
||||||
)
|
|
||||||
|
|
||||||
mrp = spec.get("multi_row_policy")
|
|
||||||
if mrp is not None and str(mrp) not in ALLOWED_MULTI_ROW_POLICIES:
|
|
||||||
raise ValueError(
|
|
||||||
f"multi_row_policy: ungültiger Wert '{mrp}'. "
|
|
||||||
f"Erlaubt: {', '.join(sorted(ALLOWED_MULTI_ROW_POLICIES))}"
|
|
||||||
)
|
|
||||||
|
|
||||||
dedupe = spec.get("dedupe_identical_rows")
|
|
||||||
if dedupe is not None and not isinstance(dedupe, bool):
|
|
||||||
raise ValueError("dedupe_identical_rows muss ein Boolean sein")
|
|
||||||
|
|
||||||
|
|
||||||
def _sort_key_for_group(v: Any) -> Any:
|
|
||||||
if isinstance(v, dt.datetime):
|
|
||||||
return v.isoformat()
|
|
||||||
if isinstance(v, dt.date):
|
|
||||||
return v.isoformat()
|
|
||||||
if isinstance(v, dt.time):
|
|
||||||
return v.isoformat()
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_aggregate(op: str, values: list[Any]) -> Any:
|
|
||||||
nums: list[float] = []
|
|
||||||
for x in values:
|
|
||||||
if x is None or x == "":
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
nums.append(float(x))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if op == "sum":
|
|
||||||
return sum(nums) if nums else None
|
|
||||||
if op == "mean":
|
|
||||||
return statistics.mean(nums) if nums else None
|
|
||||||
if op == "median":
|
|
||||||
return float(statistics.median(nums)) if nums else None
|
|
||||||
if op == "min":
|
|
||||||
return min(nums) if nums else None
|
|
||||||
if op == "max":
|
|
||||||
return max(nums) if nums else None
|
|
||||||
if op == "first":
|
|
||||||
for x in values:
|
|
||||||
if x is not None and x != "":
|
|
||||||
return x
|
|
||||||
return None
|
|
||||||
if op == "last":
|
|
||||||
for x in reversed(values):
|
|
||||||
if x is not None and x != "":
|
|
||||||
return x
|
|
||||||
return None
|
|
||||||
raise ValueError(f"Unbekannte Aggregations-Operation: {op}")
|
|
||||||
|
|
||||||
|
|
||||||
def _row_identity_signature(r: dict[str, Any]) -> tuple[Any, ...]:
|
|
||||||
return tuple(sorted((k, _sort_key_for_group(r.get(k))) for k in sorted(r.keys())))
|
|
||||||
|
|
||||||
|
|
||||||
def _dedupe_identical_mapped_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
||||||
"""Exakt gleiche gemappte Zeilen (alle Keys/Werte) — erste behalten."""
|
|
||||||
seen: set[tuple[Any, ...]] = set()
|
|
||||||
out: list[dict[str, Any]] = []
|
|
||||||
for r in rows:
|
|
||||||
sig = _row_identity_signature(r)
|
|
||||||
if sig in seen:
|
|
||||||
continue
|
|
||||||
seen.add(sig)
|
|
||||||
out.append(r)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def aggregate_mapped_rows(
|
|
||||||
rows: list[dict[str, Any]],
|
|
||||||
spec: Mapping[str, Any],
|
|
||||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
||||||
"""
|
|
||||||
Gruppiert gemappte Zeilen-Dicts nach group_by und wendet aggregates an.
|
|
||||||
Felder, die weder in group_by noch in aggregates vorkommen: Wert aus der ersten Zeile der Gruppe.
|
|
||||||
|
|
||||||
Rückgabe: (merged_rows, strukturelle Fehler / Hinweise, z. B. abgelehnte Schlüsselgruppen).
|
|
||||||
"""
|
|
||||||
errors: list[dict[str, Any]] = []
|
|
||||||
rows = list(rows)
|
|
||||||
if spec.get("dedupe_identical_rows"):
|
|
||||||
rows = _dedupe_identical_mapped_rows(rows)
|
|
||||||
|
|
||||||
group_by = spec.get("group_by") or []
|
|
||||||
aggregates = spec.get("aggregates") or {}
|
|
||||||
policy = str(spec.get("multi_row_policy") or "aggregate")
|
|
||||||
if policy not in ALLOWED_MULTI_ROW_POLICIES:
|
|
||||||
policy = "aggregate"
|
|
||||||
|
|
||||||
if not group_by:
|
|
||||||
return rows, errors
|
|
||||||
|
|
||||||
buckets: dict[tuple[Any, ...], list[dict[str, Any]]] = {}
|
|
||||||
order: list[tuple[Any, ...]] = []
|
|
||||||
for r in rows:
|
|
||||||
key = tuple(_sort_key_for_group(r.get(g)) for g in group_by)
|
|
||||||
if key not in buckets:
|
|
||||||
buckets[key] = []
|
|
||||||
order.append(key)
|
|
||||||
buckets[key].append(r)
|
|
||||||
|
|
||||||
gb_label = ", ".join(group_by)
|
|
||||||
out: list[dict[str, Any]] = []
|
|
||||||
for key in order:
|
|
||||||
group_rows = buckets[key]
|
|
||||||
if len(group_rows) > 1:
|
|
||||||
if policy == "reject":
|
|
||||||
errors.append(
|
|
||||||
{
|
|
||||||
"error": "mehrere_zeilen_pro_schluessel",
|
|
||||||
"message": (
|
|
||||||
f"{len(group_rows)} CSV-Zeilen mit gleichem Schlüssel ({gb_label}); "
|
|
||||||
"laut Vorlage abgelehnt (multi_row_policy=reject)."
|
|
||||||
),
|
|
||||||
"rows_in_group": len(group_rows),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
if policy == "first_row":
|
|
||||||
group_rows = [group_rows[0]]
|
|
||||||
elif policy == "last_row":
|
|
||||||
group_rows = [group_rows[-1]]
|
|
||||||
|
|
||||||
first = group_rows[0]
|
|
||||||
merged: dict[str, Any] = {}
|
|
||||||
for g in group_by:
|
|
||||||
merged[g] = first.get(g)
|
|
||||||
for field, op in aggregates.items():
|
|
||||||
merged[field] = _apply_aggregate(str(op), [row.get(field) for row in group_rows])
|
|
||||||
for row in group_rows:
|
|
||||||
for k, v in row.items():
|
|
||||||
if k in merged:
|
|
||||||
continue
|
|
||||||
if k in group_by or k in aggregates:
|
|
||||||
continue
|
|
||||||
merged[k] = v
|
|
||||||
out.append(merged)
|
|
||||||
return out, errors
|
|
||||||
|
|
@ -1,270 +0,0 @@
|
||||||
"""
|
|
||||||
Heuristische Vorschläge für CSV field_mappings / type_conversions (Admin-Editor, Issue #21).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from copy import deepcopy
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
from csv_parser.core import normalize_header_for_signature
|
|
||||||
from csv_parser.module_registry import get_module_definition
|
|
||||||
|
|
||||||
# Normalisierte Header-Fragmente → DB-Feld (Substring- oder exakter Norm-Vergleich)
|
|
||||||
_MODULE_HEADER_ALIASES: dict[str, dict[str, frozenset[str]]] = {
|
|
||||||
"nutrition": {
|
|
||||||
"date": frozenset(
|
|
||||||
{"datum", "date", "tag", "day", "zeit", "timestamp", "uhrzeit", "monat", "jahr"}
|
|
||||||
),
|
|
||||||
"kcal": frozenset({"kcal", "kalorie", "calorie", "energie", "energy", "kj", "joule"}),
|
|
||||||
"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"}),
|
|
||||||
"weight": frozenset({"gewicht", "weight", "masse", "kg", "kilo"}),
|
|
||||||
"note": frozenset({"notiz", "note", "comment", "kommentar"}),
|
|
||||||
},
|
|
||||||
"blood_pressure": {
|
|
||||||
"measured_date": frozenset({"datum", "date", "tag", "day", "messdatum"}),
|
|
||||||
"measured_time": frozenset({"zeit", "time", "uhr", "uhrzeit"}),
|
|
||||||
"systolic": frozenset({"systol", "sys", "sbp", "oberdruck"}),
|
|
||||||
"diastolic": frozenset({"diastol", "dia", "dbp", "unterdruck"}),
|
|
||||||
"pulse": frozenset({"puls", "pulse", "hr", "herz", "bpm"}),
|
|
||||||
},
|
|
||||||
"activity": {
|
|
||||||
"date": frozenset({"datum", "date", "tag", "day"}),
|
|
||||||
"start_time": frozenset({"start", "beginn", "von"}),
|
|
||||||
"end_time": frozenset({"end", "ende", "bis", "stop"}),
|
|
||||||
"activity_type": frozenset({"workout", "training", "typ", "type", "art", "aktiv"}),
|
|
||||||
"duration_min": frozenset({"dauer", "duration", "min"}),
|
|
||||||
"distance_km": frozenset({"strecke", "distance", "km", "distanz"}),
|
|
||||||
"kcal_active": frozenset({"kcal", "kalorie", "energie", "active"}),
|
|
||||||
"kcal_resting": frozenset({"ruhe", "resting"}),
|
|
||||||
"hr_avg": frozenset({"puls", "heart", "hr", "bpm", "herzfrequenz", "durchschn"}),
|
|
||||||
"hr_max": frozenset({"max", "peak"}),
|
|
||||||
},
|
|
||||||
"vitals_baseline": {
|
|
||||||
"date": frozenset({"datum", "date", "tag", "start", "zeit"}),
|
|
||||||
"resting_hr": frozenset({"ruhepuls", "resting", "rhr"}),
|
|
||||||
"hrv": frozenset({"hrv", "variabilit", "vfc"}),
|
|
||||||
"vo2_max": frozenset({"vo2"}),
|
|
||||||
"spo2": frozenset({"sauerstoff", "spo2", "oxygen"}),
|
|
||||||
"respiratory_rate": frozenset({"atem", "respiratory"}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
_DEFAULT_TYPE_CONVERSIONS: dict[str, dict[str, dict[str, Any]]] = {
|
|
||||||
"nutrition": {
|
|
||||||
"date": {"type": "date", "format": "dd.mm.yyyy HH:MM", "extract": "date_only", "flexible": True},
|
|
||||||
"kcal": {"type": "float", "decimal_separator": "auto", "flexible": True},
|
|
||||||
"protein_g": {"type": "float", "decimal_separator": "auto", "flexible": True},
|
|
||||||
"fat_g": {"type": "float", "decimal_separator": "auto", "flexible": True},
|
|
||||||
"carbs_g": {"type": "float", "decimal_separator": "auto", "flexible": True},
|
|
||||||
},
|
|
||||||
"weight": {
|
|
||||||
"date": {"type": "date", "format": "dd.mm.yyyy", "flexible": True},
|
|
||||||
"weight": {"type": "float", "decimal_separator": "auto", "flexible": True},
|
|
||||||
"note": {"type": "string"},
|
|
||||||
},
|
|
||||||
"blood_pressure": {
|
|
||||||
"measured_date": {"type": "date", "format": "dd.mm.yyyy", "flexible": True},
|
|
||||||
"measured_time": {"type": "time", "format": "HH:MM", "flexible": True},
|
|
||||||
"start_time": {"type": "datetime", "format": "yyyy-mm-dd HH:MM:SS", "flexible": True},
|
|
||||||
"systolic": {"type": "int", "flexible": True},
|
|
||||||
"diastolic": {"type": "int", "flexible": True},
|
|
||||||
"pulse": {"type": "int", "flexible": True},
|
|
||||||
},
|
|
||||||
"activity": {
|
|
||||||
"date": {"type": "date", "format": "yyyy-mm-dd", "flexible": True},
|
|
||||||
"start_time": {"type": "datetime", "format": "yyyy-mm-dd HH:MM:SS", "flexible": True},
|
|
||||||
"end_time": {"type": "datetime", "format": "yyyy-mm-dd HH:MM:SS", "flexible": True},
|
|
||||||
"activity_type": {"type": "string"},
|
|
||||||
"duration_min": {"type": "duration", "format": "HH:MM:SS", "target_unit": "minutes", "flexible": True},
|
|
||||||
"distance_km": {"type": "float", "decimal_separator": "auto", "flexible": True},
|
|
||||||
"kcal_active": {"type": "float", "decimal_separator": "auto", "flexible": True},
|
|
||||||
"kcal_resting": {"type": "float", "decimal_separator": "auto", "flexible": True},
|
|
||||||
"hr_avg": {"type": "int", "flexible": True},
|
|
||||||
"hr_max": {"type": "int", "flexible": True},
|
|
||||||
},
|
|
||||||
"vitals_baseline": {
|
|
||||||
"date": {
|
|
||||||
"type": "datetime",
|
|
||||||
"format": "yyyy-mm-dd HH:MM:SS",
|
|
||||||
"extract": "date_only",
|
|
||||||
"flexible": True,
|
|
||||||
},
|
|
||||||
"resting_hr": {"type": "int", "flexible": True},
|
|
||||||
"hrv": {"type": "int", "flexible": True},
|
|
||||||
"vo2_max": {"type": "float", "decimal_separator": "auto", "flexible": True},
|
|
||||||
"spo2": {"type": "int", "flexible": True},
|
|
||||||
"respiratory_rate": {"type": "float", "decimal_separator": "auto", "flexible": True},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _norm_key(header: str) -> str:
|
|
||||||
return normalize_header_for_signature(header)
|
|
||||||
|
|
||||||
|
|
||||||
def _match_seed_to_db_field(header: str, seed_fm: Mapping[str, str]) -> str | None:
|
|
||||||
"""Findet Ziel-Feld, wenn Seed-Key zu diesem Header passt (exakt oder normalisiert)."""
|
|
||||||
if header in seed_fm:
|
|
||||||
v = seed_fm[header]
|
|
||||||
if v and v not in ("-", "_skip"):
|
|
||||||
return v
|
|
||||||
nh = _norm_key(header)
|
|
||||||
if nh in seed_fm:
|
|
||||||
v = seed_fm[nh]
|
|
||||||
if v and v not in ("-", "_skip"):
|
|
||||||
return v
|
|
||||||
for sk, sv in seed_fm.items():
|
|
||||||
if not sv or sv in ("-", "_skip"):
|
|
||||||
continue
|
|
||||||
if _norm_key(str(sk)) == nh:
|
|
||||||
return sv
|
|
||||||
return 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
|
|
||||||
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())
|
|
||||||
nlow = norm.lower()
|
|
||||||
if nlow == db_field or nlow.replace("_", "") == db_field.replace("_", ""):
|
|
||||||
return db_field
|
|
||||||
for tok in tokens:
|
|
||||||
if len(tok) >= 2 and tok in nlow:
|
|
||||||
return db_field
|
|
||||||
if len(tok) >= 4 and tok in norm:
|
|
||||||
return db_field
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
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 '-'.
|
|
||||||
Nutzt zuerst eine passende Seed-Vorlage, dann Alias-Heuristik.
|
|
||||||
"""
|
|
||||||
if module == "sleep":
|
|
||||||
return {h: "-" for h in headers}
|
|
||||||
|
|
||||||
mod = get_module_definition(module)
|
|
||||||
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 and db in fields_map:
|
|
||||||
fm[h] = db
|
|
||||||
used.add(db)
|
|
||||||
|
|
||||||
for h in headers:
|
|
||||||
if fm[h] != "-":
|
|
||||||
continue
|
|
||||||
norm = _norm_key(h)
|
|
||||||
db = _alias_suggest(norm, module, used, field_order=field_order)
|
|
||||||
if db:
|
|
||||||
fm[h] = db
|
|
||||||
used.add(db)
|
|
||||||
|
|
||||||
return fm
|
|
||||||
|
|
||||||
|
|
||||||
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":
|
|
||||||
return {}
|
|
||||||
|
|
||||||
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():
|
|
||||||
if k in targets and isinstance(v, dict):
|
|
||||||
out[k] = deepcopy(v)
|
|
||||||
|
|
||||||
for t in targets:
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
_ENERGY_FIELDS = frozenset({"kcal", "kcal_active", "kcal_resting"})
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_energy_kj_hint_from_headers(
|
|
||||||
module: str,
|
|
||||||
field_mappings: Mapping[str, str],
|
|
||||||
out: dict[str, Any],
|
|
||||||
) -> None:
|
|
||||||
"""Wenn Überschrift kJ/Kilojoule nahelegt (nicht kcal), source_unit kj setzen (FDDB & Co.)."""
|
|
||||||
if module not in ("nutrition", "activity"):
|
|
||||||
return
|
|
||||||
for csv_col, db_field in field_mappings.items():
|
|
||||||
if db_field not in _ENERGY_FIELDS:
|
|
||||||
continue
|
|
||||||
spec = out.get(db_field)
|
|
||||||
if not isinstance(spec, dict):
|
|
||||||
continue
|
|
||||||
if spec.get("source_unit"):
|
|
||||||
continue
|
|
||||||
norm = normalize_header_for_signature(str(csv_col)).lower()
|
|
||||||
if "kcal" in norm:
|
|
||||||
continue
|
|
||||||
if "kj" in norm or "kilojoule" in norm:
|
|
||||||
spec2 = deepcopy(spec)
|
|
||||||
spec2["source_unit"] = "kj"
|
|
||||||
out[db_field] = spec2
|
|
||||||
|
|
@ -1,186 +0,0 @@
|
||||||
"""
|
|
||||||
Ziel-Module für CSV-Import: Tabellen-Felder, Pflichtfelder, Duplikat-Strategie (Issue #21).
|
|
||||||
|
|
||||||
Hinweis: blood_pressure nutzt in der DB measured_at; Logik-Felder measured_date + measured_time
|
|
||||||
werden im Executor zu measured_at zusammengefügt (Phase Import-Executor).
|
|
||||||
|
|
||||||
Activity: date kann aus start_time (ISO-Datetime) abgeleitet werden, wenn nur start_time gesetzt ist.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, cast
|
|
||||||
|
|
||||||
MODULE_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
|
||||||
"nutrition": {
|
|
||||||
"table": "nutrition_log",
|
|
||||||
"fields": {
|
|
||||||
"date": {"type": "date", "required": True},
|
|
||||||
"kcal": {"type": "float", "required": False, "unit": "kcal"},
|
|
||||||
"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",
|
|
||||||
# Legacy-Fallback wenn die Vorlage kein import_row_processing speichert — Vorlagen mittelfristig explizit.
|
|
||||||
"import_row_processing_default": {
|
|
||||||
"group_by": ["date"],
|
|
||||||
"aggregates": {
|
|
||||||
"kcal": "sum",
|
|
||||||
"protein_g": "sum",
|
|
||||||
"fat_g": "sum",
|
|
||||||
"carbs_g": "sum",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
# 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, "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"],
|
|
||||||
"duplicate_strategy": "update",
|
|
||||||
},
|
|
||||||
"sleep": {
|
|
||||||
"table": "sleep_log",
|
|
||||||
"fields": {},
|
|
||||||
"import_mode": "apple_sleep_aggregate",
|
|
||||||
},
|
|
||||||
"vitals_baseline": {
|
|
||||||
"table": "vitals_baseline",
|
|
||||||
"fields": {
|
|
||||||
"date": {"type": "date", "required": True},
|
|
||||||
"resting_hr": {"type": "int", "required": False},
|
|
||||||
"hrv": {"type": "int", "required": False},
|
|
||||||
"vo2_max": {"type": "float", "required": False},
|
|
||||||
"spo2": {"type": "int", "required": False},
|
|
||||||
"respiratory_rate": {"type": "float", "required": False},
|
|
||||||
},
|
|
||||||
"duplicate_key": ["profile_id", "date"],
|
|
||||||
"duplicate_strategy": "update",
|
|
||||||
# Legacy-Fallback — Vorlagen mittelfristig explizit setzen.
|
|
||||||
"import_row_processing_default": {
|
|
||||||
"group_by": ["date"],
|
|
||||||
"aggregates": {
|
|
||||||
"resting_hr": "mean",
|
|
||||||
"hrv": "mean",
|
|
||||||
"vo2_max": "mean",
|
|
||||||
"spo2": "mean",
|
|
||||||
"respiratory_rate": "mean",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"blood_pressure": {
|
|
||||||
"table": "blood_pressure_log",
|
|
||||||
"fields": {
|
|
||||||
"measured_date": {"type": "date", "required": True},
|
|
||||||
"measured_time": {"type": "time", "required": True},
|
|
||||||
# Apple Health: eine Spalte „Start“ / „Datum/Uhrzeit“ (Datetime); Executor splittet.
|
|
||||||
"start_time": {"type": "datetime", "required": False},
|
|
||||||
"systolic": {"type": "int", "required": True},
|
|
||||||
"diastolic": {"type": "int", "required": True},
|
|
||||||
"pulse": {"type": "int", "required": False},
|
|
||||||
},
|
|
||||||
"logical_to_db": "blood_pressure_composite_measured_at",
|
|
||||||
"duplicate_key": ["profile_id", "measured_at"],
|
|
||||||
"duplicate_strategy": "update",
|
|
||||||
},
|
|
||||||
"weight": {
|
|
||||||
"table": "weight_log",
|
|
||||||
"fields": {
|
|
||||||
"date": {"type": "date", "required": True},
|
|
||||||
"weight": {"type": "float", "required": True, "min": 20, "max": 400, "unit": "kg"},
|
|
||||||
"note": {"type": "string", "required": False, "max_length": 2000},
|
|
||||||
},
|
|
||||||
"duplicate_key": ["profile_id", "date"],
|
|
||||||
"duplicate_strategy": "update",
|
|
||||||
# Legacy-Fallback — Vorlagen mittelfristig explizit setzen.
|
|
||||||
"import_row_processing_default": {
|
|
||||||
"group_by": ["date"],
|
|
||||||
"aggregates": {
|
|
||||||
"weight": "last",
|
|
||||||
"note": "last",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_module_definition(module: str) -> Dict[str, Any] | None:
|
|
||||||
return MODULE_DEFINITIONS.get(module)
|
|
||||||
|
|
||||||
|
|
||||||
def list_modules() -> list[str]:
|
|
||||||
return sorted(MODULE_DEFINITIONS.keys())
|
|
||||||
|
|
||||||
|
|
||||||
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"):
|
|
||||||
raise ValueError(
|
|
||||||
f"Modul '{module}' nutzt einen Aggregat-Import ohne Spalten-Mapping; "
|
|
||||||
f"alle Spalten müssen „ignorieren“ sein."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
for _csv_col, db_field in field_mappings.items():
|
|
||||||
if db_field in ("", None, "-", "_skip"):
|
|
||||||
continue
|
|
||||||
if db_field not in allowed:
|
|
||||||
raise ValueError(f"Ungültiges Zielfeld '{db_field}' für Modul '{module}'")
|
|
||||||
|
|
||||||
|
|
||||||
def validate_required_field_targets(module: str, field_mappings: dict) -> None:
|
|
||||||
"""Stellt sicher, dass jedes als required markierte Zielfeld mindestens einer Spalte zugeordnet ist."""
|
|
||||||
mod = get_module_definition(module)
|
|
||||||
if not mod:
|
|
||||||
raise ValueError(f"Unbekanntes Modul: {module}")
|
|
||||||
field_defs = cast(dict, mod["fields"])
|
|
||||||
targets = {v for v in field_mappings.values() if v and v not in ("-", "_skip")}
|
|
||||||
if module == "blood_pressure" and "start_time" in targets:
|
|
||||||
targets = set(targets) | {"measured_date", "measured_time"}
|
|
||||||
for fname, finfo in field_defs.items():
|
|
||||||
if finfo.get("required") and fname not in targets:
|
|
||||||
raise ValueError(f"Pflicht-Zielfeld nicht zugeordnet: {fname}")
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
"""Zugriffsregeln für csv_field_mappings (Issue #21)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
|
|
||||||
def user_may_edit_mapping_row(row: Mapping[str, Any], session: Mapping[str, Any]) -> bool:
|
|
||||||
if session.get("role") == "admin":
|
|
||||||
return True
|
|
||||||
if row.get("is_system"):
|
|
||||||
return False
|
|
||||||
return str(row.get("profile_id")) == str(session.get("profile_id"))
|
|
||||||
|
|
||||||
|
|
||||||
def user_may_delete_mapping(row: Mapping[str, Any], session: Mapping[str, Any]) -> bool:
|
|
||||||
if row.get("is_system"):
|
|
||||||
return False
|
|
||||||
return str(row.get("profile_id")) == str(session.get("profile_id"))
|
|
||||||
|
|
@ -1,350 +0,0 @@
|
||||||
"""
|
|
||||||
Apple-Health-Schlaf-CSV → sleep_log (für Universal-Import und /api/sleep/import).
|
|
||||||
Nutzt dieselbe Logik wie der Sleep-Router, ohne HTTPException — arbeitet auf einem übergebenen Cursor.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import csv
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from datetime import date, datetime
|
|
||||||
from decimal import Decimal, InvalidOperation
|
|
||||||
from typing import Any, Literal
|
|
||||||
|
|
||||||
from dateutil import parser as dateutil_parser
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_row_keys(row: dict) -> dict:
|
|
||||||
return {(k or "").strip(): (v.strip() if isinstance(v, str) else v) for k, v in row.items()}
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_float(value: Any) -> float | None:
|
|
||||||
if value is None or value == "":
|
|
||||||
return None
|
|
||||||
if isinstance(value, (int, float)):
|
|
||||||
return float(value)
|
|
||||||
if isinstance(value, Decimal):
|
|
||||||
return float(value)
|
|
||||||
try:
|
|
||||||
s = str(value).strip().replace(",", ".")
|
|
||||||
return float(s)
|
|
||||||
except (ValueError, InvalidOperation):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_apple_sleep_datetime(value: str) -> datetime:
|
|
||||||
raw = (value or "").strip()
|
|
||||||
if not raw:
|
|
||||||
raise ValueError("empty datetime")
|
|
||||||
fmts = (
|
|
||||||
"%Y-%m-%d %H:%M:%S %z",
|
|
||||||
"%d.%m.%y %H:%M:%S",
|
|
||||||
"%d.%m.%Y %H:%M:%S",
|
|
||||||
"%Y-%m-%d %H:%M:%S",
|
|
||||||
)
|
|
||||||
for fmt in fmts:
|
|
||||||
try:
|
|
||||||
return datetime.strptime(raw, fmt)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
return dateutil_parser.parse(raw, dayfirst=False)
|
|
||||||
except (ValueError, TypeError, OverflowError) as e:
|
|
||||||
raise ValueError(f"Unbekanntes Datumsformat: {raw!r}") from e
|
|
||||||
|
|
||||||
|
|
||||||
def _hr_to_minutes(hours: float | None) -> int:
|
|
||||||
if hours is None:
|
|
||||||
return 0
|
|
||||||
return int(round(float(hours) * 60))
|
|
||||||
|
|
||||||
|
|
||||||
def detect_apple_sleep_csv_format(fieldnames: list[str] | None) -> Literal["segments", "summary"]:
|
|
||||||
if not fieldnames:
|
|
||||||
raise ValueError("CSV enthält keine Spaltenüberschriften.")
|
|
||||||
fn = {(f or "").strip() for f in fieldnames}
|
|
||||||
if {"Start", "End", "Duration (hr)", "Value"}.issubset(fn):
|
|
||||||
return "segments"
|
|
||||||
if "Start" in fn and "End" in fn and "Total Sleep (hr)" in fn:
|
|
||||||
return "summary"
|
|
||||||
raise ValueError(
|
|
||||||
"Unbekanntes Apple-Health-Schlaf-CSV. Erwartet: Segment-Export oder Schlafanalyse-Zusammenfassung."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_nights_from_apple_summary(reader: csv.DictReader) -> dict[date, dict]:
|
|
||||||
nights_dict: dict[date, dict] = {}
|
|
||||||
for raw in reader:
|
|
||||||
row = _strip_row_keys(raw)
|
|
||||||
start_s = row.get("Start") or ""
|
|
||||||
end_s = row.get("End") or ""
|
|
||||||
if not start_s or not end_s:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
start_dt = _parse_apple_sleep_datetime(start_s)
|
|
||||||
end_dt = _parse_apple_sleep_datetime(end_s)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
dt_key = (row.get("Date/Time") or row.get("Datum/Uhrzeit") or "").strip()
|
|
||||||
if dt_key:
|
|
||||||
try:
|
|
||||||
wake_d = datetime.strptime(dt_key[:10], "%Y-%m-%d").date()
|
|
||||||
except ValueError:
|
|
||||||
wake_d = end_dt.date()
|
|
||||||
else:
|
|
||||||
wake_d = end_dt.date()
|
|
||||||
|
|
||||||
core_hr = _safe_float(row.get("Core (hr)"))
|
|
||||||
if core_hr is None:
|
|
||||||
core_hr = _safe_float(row.get("Light (hr)")) or 0.0
|
|
||||||
deep_min = _hr_to_minutes(_safe_float(row.get("Deep (hr)")))
|
|
||||||
rem_min = _hr_to_minutes(_safe_float(row.get("REM (hr)")))
|
|
||||||
light_min = _hr_to_minutes(core_hr)
|
|
||||||
awake_min = _hr_to_minutes(_safe_float(row.get("Awake (hr)")))
|
|
||||||
total_sleep_hr = _safe_float(row.get("Total Sleep (hr)"))
|
|
||||||
nights_dict[wake_d] = {
|
|
||||||
"bedtime": start_dt,
|
|
||||||
"wake_time": end_dt,
|
|
||||||
"segments": [],
|
|
||||||
"deep_minutes": deep_min,
|
|
||||||
"rem_minutes": rem_min,
|
|
||||||
"light_minutes": light_min,
|
|
||||||
"awake_minutes": awake_min,
|
|
||||||
"total_sleep_hr": total_sleep_hr,
|
|
||||||
}
|
|
||||||
return nights_dict
|
|
||||||
|
|
||||||
|
|
||||||
def _build_nights_from_apple_segments(reader: csv.DictReader, phase_map: dict) -> dict[date, dict]:
|
|
||||||
segments = []
|
|
||||||
for raw in reader:
|
|
||||||
row = _strip_row_keys(raw)
|
|
||||||
phase_key = (row.get("Value") or "").strip()
|
|
||||||
phase_en = phase_map.get(phase_key)
|
|
||||||
if phase_en is None:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
start_dt = _parse_apple_sleep_datetime(row.get("Start") or "")
|
|
||||||
end_dt = _parse_apple_sleep_datetime(row.get("End") or "")
|
|
||||||
duration_hr = _safe_float(row.get("Duration (hr)"))
|
|
||||||
if duration_hr is None:
|
|
||||||
continue
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
continue
|
|
||||||
duration_min = int(duration_hr * 60)
|
|
||||||
segments.append({
|
|
||||||
"start": start_dt,
|
|
||||||
"end": end_dt,
|
|
||||||
"duration_min": duration_min,
|
|
||||||
"phase": phase_en,
|
|
||||||
})
|
|
||||||
|
|
||||||
segments.sort(key=lambda s: s["start"])
|
|
||||||
nights = []
|
|
||||||
current_night = None
|
|
||||||
|
|
||||||
for seg in segments:
|
|
||||||
if current_night is None or (seg["start"] - current_night["wake_time"]).total_seconds() > 7200:
|
|
||||||
current_night = {
|
|
||||||
"bedtime": seg["start"],
|
|
||||||
"wake_time": seg["end"],
|
|
||||||
"segments": [],
|
|
||||||
"deep_minutes": 0,
|
|
||||||
"rem_minutes": 0,
|
|
||||||
"light_minutes": 0,
|
|
||||||
"awake_minutes": 0,
|
|
||||||
}
|
|
||||||
nights.append(current_night)
|
|
||||||
|
|
||||||
current_night["segments"].append(seg)
|
|
||||||
current_night["wake_time"] = max(current_night["wake_time"], seg["end"])
|
|
||||||
current_night["bedtime"] = min(current_night["bedtime"], seg["start"])
|
|
||||||
|
|
||||||
if seg["phase"] == "deep":
|
|
||||||
current_night["deep_minutes"] += seg["duration_min"]
|
|
||||||
elif seg["phase"] == "rem":
|
|
||||||
current_night["rem_minutes"] += seg["duration_min"]
|
|
||||||
elif seg["phase"] == "light":
|
|
||||||
current_night["light_minutes"] += seg["duration_min"]
|
|
||||||
elif seg["phase"] == "awake":
|
|
||||||
current_night["awake_minutes"] += seg["duration_min"]
|
|
||||||
|
|
||||||
nights_dict: dict[date, dict] = {}
|
|
||||||
for night in nights:
|
|
||||||
wake_date = night["wake_time"].date()
|
|
||||||
nights_dict[wake_date] = night
|
|
||||||
return nights_dict
|
|
||||||
|
|
||||||
|
|
||||||
def import_apple_sleep_nights(cur, profile_id: str, text: str) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Schreibt in sleep_log. Kein conn.commit — Aufrufer rollt Transaktion.
|
|
||||||
Gibt Statistik im Executor-Format zurück.
|
|
||||||
"""
|
|
||||||
csv_text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
||||||
if csv_text.startswith("\ufeff"):
|
|
||||||
csv_text = csv_text[1:]
|
|
||||||
reader = csv.DictReader(io.StringIO(csv_text))
|
|
||||||
fmt = detect_apple_sleep_csv_format(reader.fieldnames)
|
|
||||||
|
|
||||||
phase_map = {
|
|
||||||
"Kern": "light",
|
|
||||||
"Core": "light",
|
|
||||||
"Light": "light",
|
|
||||||
"REM": "rem",
|
|
||||||
"Tief": "deep",
|
|
||||||
"Deep": "deep",
|
|
||||||
"Wach": "awake",
|
|
||||||
"Awake": "awake",
|
|
||||||
"Schlafend": None,
|
|
||||||
"Asleep": None,
|
|
||||||
"In Bed": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
if fmt == "summary":
|
|
||||||
nights_dict = _build_nights_from_apple_summary(reader)
|
|
||||||
else:
|
|
||||||
nights_dict = _build_nights_from_apple_segments(reader, phase_map)
|
|
||||||
|
|
||||||
if not nights_dict:
|
|
||||||
raise ValueError(
|
|
||||||
"Keine importierbaren Schlafzeilen gefunden (prüfe Start/Ende und Format)."
|
|
||||||
)
|
|
||||||
|
|
||||||
inserted = 0
|
|
||||||
updated = 0
|
|
||||||
skipped = 0
|
|
||||||
error_details: list[dict[str, Any]] = []
|
|
||||||
affected_ids: list[str] = []
|
|
||||||
|
|
||||||
row_hint = 0
|
|
||||||
for wake_date, night in nights_dict.items():
|
|
||||||
row_hint += 1
|
|
||||||
phase_sum = (
|
|
||||||
night["deep_minutes"] + night["rem_minutes"] + night["light_minutes"]
|
|
||||||
)
|
|
||||||
total_hr = night.get("total_sleep_hr")
|
|
||||||
fallback_min = int(round(float(total_hr) * 60)) if total_hr is not None else 0
|
|
||||||
duration_minutes = phase_sum if phase_sum > 0 else fallback_min
|
|
||||||
if duration_minutes <= 0:
|
|
||||||
logger.warning(
|
|
||||||
"Sleep import: überspringe %s — Dauer 0 (Phasen-Summe und Total Sleep (hr) leer/0).",
|
|
||||||
wake_date,
|
|
||||||
)
|
|
||||||
error_details.append(
|
|
||||||
{
|
|
||||||
"row": row_hint,
|
|
||||||
"error": f"Schlafdauer für {wake_date} ist 0 — Phasen oder Total Sleep (hr) fehlen.",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
wake_count = sum(1 for seg in night["segments"] if seg["phase"] == "awake")
|
|
||||||
|
|
||||||
sleep_segments = [
|
|
||||||
{
|
|
||||||
"phase": seg["phase"],
|
|
||||||
"start": seg["start"].isoformat(),
|
|
||||||
"end": seg["end"].isoformat(),
|
|
||||||
"duration_min": seg["duration_min"],
|
|
||||||
}
|
|
||||||
for seg in night["segments"]
|
|
||||||
]
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, source FROM sleep_log
|
|
||||||
WHERE profile_id = %s AND date = %s
|
|
||||||
""",
|
|
||||||
(profile_id, wake_date),
|
|
||||||
)
|
|
||||||
existing = cur.fetchone()
|
|
||||||
|
|
||||||
if existing and existing["source"] == "manual":
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
if existing:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
UPDATE sleep_log SET
|
|
||||||
bedtime = %s,
|
|
||||||
wake_time = %s,
|
|
||||||
duration_minutes = %s,
|
|
||||||
wake_count = %s,
|
|
||||||
deep_minutes = %s,
|
|
||||||
rem_minutes = %s,
|
|
||||||
light_minutes = %s,
|
|
||||||
awake_minutes = %s,
|
|
||||||
sleep_segments = %s,
|
|
||||||
source = 'apple_health',
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = %s AND profile_id = %s
|
|
||||||
RETURNING id
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
night["bedtime"].time(),
|
|
||||||
night["wake_time"].time(),
|
|
||||||
duration_minutes,
|
|
||||||
wake_count,
|
|
||||||
night["deep_minutes"],
|
|
||||||
night["rem_minutes"],
|
|
||||||
night["light_minutes"],
|
|
||||||
night["awake_minutes"],
|
|
||||||
json.dumps(sleep_segments),
|
|
||||||
existing["id"],
|
|
||||||
profile_id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
updated += 1
|
|
||||||
if row and row.get("id"):
|
|
||||||
affected_ids.append(str(row["id"]))
|
|
||||||
else:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO sleep_log (
|
|
||||||
profile_id, date, bedtime, wake_time, duration_minutes,
|
|
||||||
wake_count, deep_minutes, rem_minutes, light_minutes, awake_minutes,
|
|
||||||
sleep_segments, source, created_at, updated_at
|
|
||||||
) VALUES (
|
|
||||||
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'apple_health', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
RETURNING id
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
profile_id,
|
|
||||||
wake_date,
|
|
||||||
night["bedtime"].time(),
|
|
||||||
night["wake_time"].time(),
|
|
||||||
duration_minutes,
|
|
||||||
wake_count,
|
|
||||||
night["deep_minutes"],
|
|
||||||
night["rem_minutes"],
|
|
||||||
night["light_minutes"],
|
|
||||||
night["awake_minutes"],
|
|
||||||
json.dumps(sleep_segments),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
inserted += 1
|
|
||||||
if row and row.get("id"):
|
|
||||||
affected_ids.append(str(row["id"]))
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Sleep import row failed: %s", e)
|
|
||||||
error_details.append({"row": row_hint, "error": str(e)})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"rows_total": len(nights_dict),
|
|
||||||
"inserted": inserted,
|
|
||||||
"updated": updated,
|
|
||||||
"skipped": skipped,
|
|
||||||
"new_entries": inserted,
|
|
||||||
"error_details": error_details,
|
|
||||||
"affected_ids": affected_ids,
|
|
||||||
}
|
|
||||||
|
|
@ -1,241 +0,0 @@
|
||||||
"""
|
|
||||||
Formatprüfung für CSV-Import-Vorlagen (field_mappings, type_conversions).
|
|
||||||
|
|
||||||
Liefert strukturierte Fehler/Warnungen für Admin-UI und Speicher-Guards.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
from csv_parser.core import normalize_header_for_signature
|
|
||||||
from csv_parser.import_row_processing import validate_import_row_processing as validate_import_row_processing_spec
|
|
||||||
from csv_parser.module_registry import (
|
|
||||||
get_module_definition,
|
|
||||||
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"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _issue(
|
|
||||||
severity: str,
|
|
||||||
code: str,
|
|
||||||
message: str,
|
|
||||||
*,
|
|
||||||
hint: str | None = None,
|
|
||||||
field: str | None = None,
|
|
||||||
csv_columns: list[str] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
out: dict[str, Any] = {
|
|
||||||
"severity": severity,
|
|
||||||
"code": code,
|
|
||||||
"message": message,
|
|
||||||
}
|
|
||||||
if hint:
|
|
||||||
out["hint"] = hint
|
|
||||||
if field:
|
|
||||||
out["field"] = field
|
|
||||||
if csv_columns:
|
|
||||||
out["csv_columns"] = csv_columns
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def validate_csv_template(
|
|
||||||
module: str,
|
|
||||||
field_mappings: Mapping[str, Any] | None,
|
|
||||||
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.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
``{"valid": bool, "errors": [...], "warnings": [...]}``
|
|
||||||
"""
|
|
||||||
errors: list[dict[str, Any]] = []
|
|
||||||
warnings: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
fm = dict(field_mappings or {})
|
|
||||||
tc: dict[str, Any] = dict(type_conversions or {}) if type_conversions else {}
|
|
||||||
mod = get_module_definition(module)
|
|
||||||
if not mod:
|
|
||||||
errors.append(
|
|
||||||
_issue(
|
|
||||||
"error",
|
|
||||||
"unknown_module",
|
|
||||||
f"Unbekanntes Modul «{module}».",
|
|
||||||
hint="Nur registrierte Module in module_registry sind erlaubt.",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
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, cur=cur)
|
|
||||||
except ValueError as e:
|
|
||||||
errors.append(
|
|
||||||
_issue(
|
|
||||||
"error",
|
|
||||||
"invalid_field_mapping",
|
|
||||||
str(e),
|
|
||||||
hint="Jede Zuordnung muss auf ein bekanntes Zielfeld des Moduls zeigen (oder „–“ / ignorieren).",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
validate_required_field_targets(module, fm)
|
|
||||||
except ValueError as e:
|
|
||||||
errors.append(
|
|
||||||
_issue(
|
|
||||||
"error",
|
|
||||||
"missing_required_target",
|
|
||||||
str(e),
|
|
||||||
hint="Pflichtfelder des Moduls müssen mindestens einer CSV-Spalte zugeordnet sein.",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if import_row_processing:
|
|
||||||
try:
|
|
||||||
validate_import_row_processing_spec(module, import_row_processing, fm, cur=cur)
|
|
||||||
except ValueError as e:
|
|
||||||
errors.append(
|
|
||||||
_issue(
|
|
||||||
"error",
|
|
||||||
"invalid_import_row_processing",
|
|
||||||
str(e),
|
|
||||||
hint="import_row_processing: group_by und aggregates prüfen (siehe Doku Issue #21).",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
for db_field, spec in tc.items():
|
|
||||||
if db_field not in field_defs:
|
|
||||||
errors.append(
|
|
||||||
_issue(
|
|
||||||
"error",
|
|
||||||
"unknown_type_conversion_field",
|
|
||||||
f"type_conversions enthält unbekanntes Zielfeld «{db_field}».",
|
|
||||||
hint="Nur Felder aus der Moduldefinition sind erlaubt.",
|
|
||||||
field=db_field,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
if not isinstance(spec, Mapping):
|
|
||||||
errors.append(
|
|
||||||
_issue(
|
|
||||||
"error",
|
|
||||||
"type_conversion_not_object",
|
|
||||||
f"type_conversions[\"{db_field}\"] muss ein JSON-Objekt sein.",
|
|
||||||
field=db_field,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
stype = spec.get("type", "string")
|
|
||||||
if stype not in ALLOWED_SPEC_TYPES:
|
|
||||||
warnings.append(
|
|
||||||
_issue(
|
|
||||||
"warning",
|
|
||||||
"unusual_conversion_type",
|
|
||||||
f"Ungewöhnlicher Typ «{stype}» für «{db_field}» (erwartet u. a. string, float, date, datetime).",
|
|
||||||
field=db_field,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
finfo = field_defs.get(db_field) or {}
|
|
||||||
expected = finfo.get("type")
|
|
||||||
if expected == "date" and stype not in ("date", "datetime"):
|
|
||||||
warnings.append(
|
|
||||||
_issue(
|
|
||||||
"warning",
|
|
||||||
"date_field_conversion",
|
|
||||||
f"Zielfeld «{db_field}» ist ein Datum; der Konvertierungstyp ist «{stype}».",
|
|
||||||
hint="Meist «date» oder «datetime» mit passendem format.",
|
|
||||||
field=db_field,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if expected == "float" and stype == "int" and db_field in ("hr_avg", "hr_max"):
|
|
||||||
warnings.append(
|
|
||||||
_issue(
|
|
||||||
"warning",
|
|
||||||
"hr_as_int",
|
|
||||||
"Herzfrequenz als «int» konvertiert; Nachkommastellen aus Apple-Export gehen verloren.",
|
|
||||||
hint="Optional «float» mit flexible: true verwenden.",
|
|
||||||
field=db_field,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mehrere CSV-Spalten → dasselbe Zielfeld
|
|
||||||
by_target: dict[str, list[str]] = {}
|
|
||||||
for csv_col, dbf in fm.items():
|
|
||||||
if dbf in (None, "", "-", "_skip"):
|
|
||||||
continue
|
|
||||||
by_target.setdefault(str(dbf), []).append(str(csv_col))
|
|
||||||
for dbf, cols in by_target.items():
|
|
||||||
if len(cols) > 1:
|
|
||||||
warnings.append(
|
|
||||||
_issue(
|
|
||||||
"warning",
|
|
||||||
"duplicate_target_columns",
|
|
||||||
f"Mehrere Spalten mappen auf «{dbf}»: {', '.join(cols)}.",
|
|
||||||
hint="Beim Import gewinnt die letzte Spalte in der CSV-Kopfzeilen-Reihenfolge.",
|
|
||||||
field=dbf,
|
|
||||||
csv_columns=cols,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Kilojoule in kcal-Feldern (häufiger Apple-DE-Fehler)
|
|
||||||
for csv_col, dbf in fm.items():
|
|
||||||
if dbf not in ("kcal_active", "kcal_resting"):
|
|
||||||
continue
|
|
||||||
col_l = str(csv_col).lower()
|
|
||||||
if "kj" in col_l or "kilojoule" in col_l:
|
|
||||||
sub = tc.get(dbf)
|
|
||||||
su = (sub or {}).get("source_unit") if isinstance(sub, Mapping) else None
|
|
||||||
if str(su or "").strip().lower() != "kj":
|
|
||||||
warnings.append(
|
|
||||||
_issue(
|
|
||||||
"warning",
|
|
||||||
"energy_kj_without_source_unit",
|
|
||||||
f"Spalte «{csv_col}» deutet auf Kilojoule, Zielfeld «{dbf}» speichert kcal.",
|
|
||||||
hint='In type_conversions für dieses Feld "source_unit": "kj" setzen (Faktor 1/4.184).',
|
|
||||||
field=str(dbf),
|
|
||||||
csv_columns=[str(csv_col)],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Signatur vs. gemappte Spalten: beide Seiten wie beim Import normalisieren
|
|
||||||
# (column_signature kann sortierte Normalform aus Analyse sein, field_mappings rohe Header).
|
|
||||||
if column_signature:
|
|
||||||
sig_forms = {
|
|
||||||
normalize_header_for_signature(str(c))
|
|
||||||
for c in column_signature
|
|
||||||
if str(c).strip()
|
|
||||||
}
|
|
||||||
sig_forms.discard("")
|
|
||||||
mapped_forms = {
|
|
||||||
normalize_header_for_signature(str(k))
|
|
||||||
for k in fm.keys()
|
|
||||||
if str(k).strip()
|
|
||||||
}
|
|
||||||
mapped_forms.discard("")
|
|
||||||
if sig_forms and mapped_forms and not sig_forms.intersection(mapped_forms):
|
|
||||||
warnings.append(
|
|
||||||
_issue(
|
|
||||||
"warning",
|
|
||||||
"signature_vs_mappings_mismatch",
|
|
||||||
"column_signature und field_mappings (Schlüssel) haben nach Normalisierung keine gemeinsame Spalte.",
|
|
||||||
hint="Prüfen Sie, ob die gespeicherte Signatur zur CSV passt; Zuordnungen nutzen rohe Kopfzeilen, die Signatur oft die gleiche Normalform wie in der Analyse.",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return {"valid": len(errors) == 0, "errors": errors, "warnings": warnings}
|
|
||||||
|
|
@ -1,736 +0,0 @@
|
||||||
"""
|
|
||||||
Typkonvertierung für CSV-Zellen gemäß type_conversions-JSON (Issue #21).
|
|
||||||
|
|
||||||
Locale-robust: dieselbe Vorlage kann Exporte mit wechselndem Datumsformat oder
|
|
||||||
Dezimaltrenner verarbeiten, wenn flexible oder auto-Optionen gesetzt sind.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import datetime as dt
|
|
||||||
import re
|
|
||||||
from decimal import Decimal, InvalidOperation
|
|
||||||
from typing import Any, Mapping, Sequence
|
|
||||||
|
|
||||||
from dateutil import parser as dateutil_parser
|
|
||||||
|
|
||||||
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)
|
|
||||||
DATE_FORMAT_STRPTIME: dict[str, str] = {
|
|
||||||
"yyyy-mm-dd": "%Y-%m-%d",
|
|
||||||
"mm/dd/yyyy": "%m/%d/%Y",
|
|
||||||
"dd/mm/yyyy": "%d/%m/%Y",
|
|
||||||
"dd.mm.yyyy": "%d.%m.%Y",
|
|
||||||
"dd.mm.yyyy hh:mm": "%d.%m.%Y %H:%M",
|
|
||||||
"dd.mm.yyyy HH:MM": "%d.%m.%Y %H:%M",
|
|
||||||
"yyyy-mm-dd hh:mm:ss": "%Y-%m-%d %H:%M:%S",
|
|
||||||
"yyyy-mm-dd HH:MM:SS": "%Y-%m-%d %H:%M:%S",
|
|
||||||
}
|
|
||||||
|
|
||||||
TIME_FORMAT_STRPTIME: dict[str, str] = {
|
|
||||||
"HH:MM": "%H:%M",
|
|
||||||
"HH:MM:SS": "%H:%M:%S",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Wenn flexible: zusätzliche strptime-Versuche (ungefähr häufig → seltener)
|
|
||||||
_STRPTIME_FALLBACK_DATES: list[str] = [
|
|
||||||
"%Y-%m-%d",
|
|
||||||
"%d.%m.%Y",
|
|
||||||
"%d.%m.%y",
|
|
||||||
"%d/%m/%Y",
|
|
||||||
"%m/%d/%Y",
|
|
||||||
"%Y/%m/%d",
|
|
||||||
"%Y%m%d",
|
|
||||||
]
|
|
||||||
_STRPTIME_FALLBACK_DATETIME: list[str] = [
|
|
||||||
"%Y-%m-%d",
|
|
||||||
"%Y-%m-%d %H:%M:%S",
|
|
||||||
"%Y-%m-%d %H:%M",
|
|
||||||
"%d.%m.%Y %H:%M:%S",
|
|
||||||
"%d.%m.%Y %H:%M",
|
|
||||||
"%d.%m.%y %H:%M:%S",
|
|
||||||
"%d.%m.%y %H:%M",
|
|
||||||
"%Y-%m-%dT%H:%M:%S",
|
|
||||||
"%Y-%m-%dT%H:%M:%SZ",
|
|
||||||
"%Y-%m-%dT%H:%M:%S%z",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_num_token(raw: str) -> str:
|
|
||||||
return re.sub(r"[\s\u00a0\u202f]", "", raw.strip())
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_float_auto(s: str) -> float:
|
|
||||||
"""
|
|
||||||
Heuristik ohne festes Locale: Punkt/Komma als Tausender vs. Dezimal,
|
|
||||||
basierend auf der letzten erkannten Trennstelle und Gruppierung.
|
|
||||||
|
|
||||||
Apple Health u. a. liefern berechnete Mittelwerte mit vielen Nachkommastellen
|
|
||||||
(z. B. «96.874937…») und Energie als «596.668904…» — dabei ist der Punkt
|
|
||||||
immer Dezimaltrenner. Früher wurden lange Nachkommateile fälschlich so
|
|
||||||
behandelt, dass der Punkt entfernt wurde (Tausender-Heuristik).
|
|
||||||
"""
|
|
||||||
raw = s
|
|
||||||
s = _normalize_num_token(s)
|
|
||||||
if not s or s in ("-", "—", "–"):
|
|
||||||
raise ValueError("leer")
|
|
||||||
neg = False
|
|
||||||
if s.startswith("(") and s.endswith(")"):
|
|
||||||
neg = True
|
|
||||||
s = s[1:-1].strip()
|
|
||||||
if s.startswith("-"):
|
|
||||||
neg = not neg
|
|
||||||
s = s[1:]
|
|
||||||
elif s.startswith("+"):
|
|
||||||
s = s[1:]
|
|
||||||
|
|
||||||
last_comma = s.rfind(",")
|
|
||||||
last_dot = s.rfind(".")
|
|
||||||
|
|
||||||
if last_comma >= 0 and last_dot >= 0:
|
|
||||||
if last_comma > last_dot:
|
|
||||||
s = s.replace(".", "").replace(",", ".")
|
|
||||||
else:
|
|
||||||
s = s.replace(",", "")
|
|
||||||
elif last_comma >= 0:
|
|
||||||
parts = s.split(",")
|
|
||||||
if len(parts) == 2:
|
|
||||||
left, right = parts[0], parts[1]
|
|
||||||
if not right:
|
|
||||||
raise ValueError("leer")
|
|
||||||
left_digits = left.replace(".", "")
|
|
||||||
# Langer Nachkommateil → Dezimalkomma; «1.234,56»-Fälle oben mit Punkt+Komma
|
|
||||||
if len(right) > 3 or len(right) <= 2:
|
|
||||||
s = left_digits + "." + right.replace(".", "")
|
|
||||||
elif len(right) == 3 and len(left_digits) <= 3:
|
|
||||||
s = left_digits + right
|
|
||||||
else:
|
|
||||||
s = left_digits + "." + right.replace(".", "")
|
|
||||||
else:
|
|
||||||
s = s.replace(",", "")
|
|
||||||
elif last_dot >= 0:
|
|
||||||
parts = s.split(".")
|
|
||||||
if len(parts) == 2:
|
|
||||||
left, right = parts[0], parts[1]
|
|
||||||
if not right:
|
|
||||||
raise ValueError("leer")
|
|
||||||
left_digits = left.replace(",", "")
|
|
||||||
# Genau ein Punkt: viele Nachkommastellen → Apple/US-Dezimalpunkt (nicht „.“ streichen)
|
|
||||||
if len(right) > 3 or len(right) <= 2:
|
|
||||||
s = left_digits + "." + right
|
|
||||||
elif len(right) == 3:
|
|
||||||
if len(left_digits) == 1 and left_digits != "0" and left_digits.isdigit():
|
|
||||||
s = left_digits + right
|
|
||||||
else:
|
|
||||||
s = left_digits + "." + right
|
|
||||||
elif len(parts) > 2:
|
|
||||||
if len(parts[-1]) <= 2:
|
|
||||||
s = "".join(parts[:-1]) + "." + parts[-1]
|
|
||||||
else:
|
|
||||||
s = "".join(parts)
|
|
||||||
else:
|
|
||||||
s = s.replace(".", "")
|
|
||||||
|
|
||||||
try:
|
|
||||||
v = float(Decimal(s))
|
|
||||||
except (InvalidOperation, ValueError) as e:
|
|
||||||
raise ValueError(f"Zahl nicht parsbar: {raw!r}") from e
|
|
||||||
return -v if neg else v
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_float(raw: str, decimal_sep: str) -> float:
|
|
||||||
s = _normalize_num_token(raw)
|
|
||||||
if not s:
|
|
||||||
raise ValueError("leer")
|
|
||||||
if "." in s and "," in s:
|
|
||||||
return _parse_float_auto(s)
|
|
||||||
if decimal_sep == ",":
|
|
||||||
if "," not in s and "." in s:
|
|
||||||
return _parse_float_auto(s)
|
|
||||||
s = s.replace(".", "").replace(",", ".")
|
|
||||||
else:
|
|
||||||
if "," in s and "." not in s:
|
|
||||||
return _parse_float_auto(s)
|
|
||||||
s = s.replace(",", "")
|
|
||||||
return float(Decimal(s))
|
|
||||||
|
|
||||||
|
|
||||||
def _float_from_spec(raw: str, spec: Mapping[str, Any]) -> float:
|
|
||||||
dec = spec.get("decimal_separator", ".")
|
|
||||||
flexible = bool(spec.get("flexible"))
|
|
||||||
if dec in (None, "auto"):
|
|
||||||
return _parse_float_auto(raw)
|
|
||||||
try:
|
|
||||||
return _parse_float(raw, str(dec))
|
|
||||||
except (InvalidOperation, ValueError):
|
|
||||||
if flexible:
|
|
||||||
return _parse_float_auto(raw)
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_strptime_pattern(fmt_key: str) -> str | None:
|
|
||||||
k = fmt_key.strip()
|
|
||||||
if k.startswith("%"):
|
|
||||||
return k
|
|
||||||
return DATE_FORMAT_STRPTIME.get(k.lower())
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_strptime_date_formats(spec: Mapping[str, Any], *, for_datetime: bool) -> list[str]:
|
|
||||||
seen: set[str] = set()
|
|
||||||
out: list[str] = []
|
|
||||||
|
|
||||||
def add(fmt_key: str) -> None:
|
|
||||||
p = _resolve_strptime_pattern(fmt_key)
|
|
||||||
if p and p not in seen:
|
|
||||||
seen.add(p)
|
|
||||||
out.append(p)
|
|
||||||
if not for_datetime and p.endswith(" %H:%M") and "%H:%M:%S" not in p:
|
|
||||||
p2 = p + ":%S"
|
|
||||||
if p2 not in seen:
|
|
||||||
seen.add(p2)
|
|
||||||
out.append(p2)
|
|
||||||
elif for_datetime and p.endswith(":%S"):
|
|
||||||
# z. B. Apple Health „2026-04-09 16:48“ ohne Sekunden
|
|
||||||
p_short = p[:-3]
|
|
||||||
if p_short not in seen:
|
|
||||||
seen.add(p_short)
|
|
||||||
out.append(p_short)
|
|
||||||
|
|
||||||
primary = spec.get("format")
|
|
||||||
if primary:
|
|
||||||
add(str(primary))
|
|
||||||
extra = spec.get("formats")
|
|
||||||
if isinstance(extra, Sequence) and not isinstance(extra, (str, bytes)):
|
|
||||||
for item in extra:
|
|
||||||
if item:
|
|
||||||
add(str(item))
|
|
||||||
|
|
||||||
if bool(spec.get("flexible")):
|
|
||||||
for p in _STRPTIME_FALLBACK_DATETIME if for_datetime else _STRPTIME_FALLBACK_DATES:
|
|
||||||
if p not in seen:
|
|
||||||
seen.add(p)
|
|
||||||
out.append(p)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _try_strptime(s: str, patterns: Sequence[str]) -> dt.datetime | None:
|
|
||||||
for pat in patterns:
|
|
||||||
try:
|
|
||||||
return dt.datetime.strptime(s, pat)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _try_strptime_trim_time(s: str, patterns: Sequence[str]) -> dt.datetime | None:
|
|
||||||
head = s.split(maxsplit=1)[0].strip() if s else ""
|
|
||||||
if head and head != s:
|
|
||||||
hit = _try_strptime(head, patterns)
|
|
||||||
if hit:
|
|
||||||
return hit
|
|
||||||
return _try_strptime(s, patterns)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_locale_date_months(s: str) -> str:
|
|
||||||
"""
|
|
||||||
Omron Connect / Berichte: «10 Apr. 2026», «31 März 2026» — ohne DE→EN scheitert dateutil.
|
|
||||||
"""
|
|
||||||
if not s:
|
|
||||||
return s
|
|
||||||
out = s
|
|
||||||
for pat, rep in (
|
|
||||||
(r"März", "March"),
|
|
||||||
(r"Maerz", "March"),
|
|
||||||
(r"Januar", "January"),
|
|
||||||
(r"Februar", "February"),
|
|
||||||
(r"Oktober", "October"),
|
|
||||||
(r"Dezember", "December"),
|
|
||||||
(r"Juni", "June"),
|
|
||||||
(r"Juli", "July"),
|
|
||||||
(r"\bMai\b", "May"),
|
|
||||||
):
|
|
||||||
out = re.sub(pat, rep, out, flags=re.IGNORECASE)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _dateutil_parse(s: str, spec: Mapping[str, Any]) -> dt.datetime | None:
|
|
||||||
s_trim = s.strip()
|
|
||||||
dayfirst_opt = spec.get("dayfirst")
|
|
||||||
# ISO YYYY-MM-DD: dayfirst=True vertauscht Monat/Tag (09.04. → 04.09.)
|
|
||||||
iso_ymd_prefix = bool(re.match(r"^\d{4}-\d{2}-\d{2}(\D|$)", s_trim))
|
|
||||||
tries: list[bool | None]
|
|
||||||
if dayfirst_opt is True:
|
|
||||||
tries = [True]
|
|
||||||
elif dayfirst_opt is False:
|
|
||||||
tries = [False]
|
|
||||||
else:
|
|
||||||
tries = [False, True] if iso_ymd_prefix else [True, False]
|
|
||||||
for df in tries:
|
|
||||||
try:
|
|
||||||
return dateutil_parser.parse(s_trim, dayfirst=df)
|
|
||||||
except (ValueError, TypeError, OverflowError):
|
|
||||||
continue
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_date_typed(s: str, spec: Mapping[str, Any]) -> dt.date | dt.datetime:
|
|
||||||
extract = spec.get("extract", "date_only")
|
|
||||||
s0 = _normalize_locale_date_months(s.strip())
|
|
||||||
patterns = _collect_strptime_date_formats(spec, for_datetime=False)
|
|
||||||
part = _try_strptime_trim_time(s0, patterns) if patterns else None
|
|
||||||
if part is None:
|
|
||||||
part = _try_strptime(s0, _collect_strptime_date_formats(spec, for_datetime=True))
|
|
||||||
if part is None and (bool(spec.get("flexible")) or spec.get("formats")):
|
|
||||||
part = _dateutil_parse(s0, spec)
|
|
||||||
if part is None:
|
|
||||||
merged: dict[str, Any] = {**dict(spec), "flexible": True}
|
|
||||||
if "dayfirst" not in merged:
|
|
||||||
merged["dayfirst"] = True
|
|
||||||
part = _dateutil_parse(s0, merged)
|
|
||||||
if part is None:
|
|
||||||
fmt_key = str(spec.get("format", ""))
|
|
||||||
raise ValueError(f"Datum nicht parsbar: {fmt_key} / {s!r}")
|
|
||||||
if extract == "date_only":
|
|
||||||
return part.date()
|
|
||||||
return part
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_datetime_typed(s: str, spec: Mapping[str, Any]) -> dt.datetime:
|
|
||||||
s0 = _normalize_locale_date_months(s.strip())
|
|
||||||
patterns = _collect_strptime_date_formats(spec, for_datetime=True)
|
|
||||||
part = _try_strptime(s0, patterns)
|
|
||||||
if part is None and (bool(spec.get("flexible")) or spec.get("formats")):
|
|
||||||
du = _dateutil_parse(s0, spec)
|
|
||||||
if du:
|
|
||||||
part = du
|
|
||||||
if part is None:
|
|
||||||
merged: dict[str, Any] = {**dict(spec), "flexible": True}
|
|
||||||
if "dayfirst" not in merged:
|
|
||||||
merged["dayfirst"] = True
|
|
||||||
du = _dateutil_parse(s0, merged)
|
|
||||||
if du:
|
|
||||||
part = du
|
|
||||||
if part is None:
|
|
||||||
fmt_key = str(spec.get("format", ""))
|
|
||||||
raise ValueError(f"Datetime nicht parsbar: {fmt_key} / {s!r}")
|
|
||||||
return part
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_time_typed(s: str, spec: Mapping[str, Any]) -> dt.time:
|
|
||||||
patterns: list[str] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
primary = spec.get("format")
|
|
||||||
if primary:
|
|
||||||
fk = str(primary)
|
|
||||||
p = TIME_FORMAT_STRPTIME.get(fk, _resolve_strptime_pattern(fk) or fk)
|
|
||||||
if p not in seen:
|
|
||||||
seen.add(p)
|
|
||||||
patterns.append(p)
|
|
||||||
extra = spec.get("formats")
|
|
||||||
if isinstance(extra, Sequence) and not isinstance(extra, (str, bytes)):
|
|
||||||
for item in extra:
|
|
||||||
if not item:
|
|
||||||
continue
|
|
||||||
p = TIME_FORMAT_STRPTIME.get(str(item), str(item))
|
|
||||||
if p not in seen:
|
|
||||||
seen.add(p)
|
|
||||||
patterns.append(p)
|
|
||||||
if bool(spec.get("flexible")):
|
|
||||||
for p in ("%H:%M:%S", "%H:%M"):
|
|
||||||
if p not in seen:
|
|
||||||
seen.add(p)
|
|
||||||
patterns.append(p)
|
|
||||||
part = _try_strptime(s.strip(), patterns)
|
|
||||||
if part is None:
|
|
||||||
raise ValueError(f"Zeit nicht parsbar: {s!r}")
|
|
||||||
return part.time()
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_int(raw: str, spec: Mapping[str, Any]) -> int:
|
|
||||||
s = raw.strip()
|
|
||||||
if bool(spec.get("flexible")) or spec.get("thousands_separator") == "auto":
|
|
||||||
s2 = _normalize_num_token(s)
|
|
||||||
if not s2 or s2 in ("-", "—", "–"):
|
|
||||||
raise ValueError("leer")
|
|
||||||
# EU-Dezimal (z. B. Apple DE «37,26» für HRV) — nicht alle Ziffern konkatenieren (würde 3726 → CHECK).
|
|
||||||
if "," in s2 or "." in s2:
|
|
||||||
try:
|
|
||||||
fv = _parse_float_auto(s2)
|
|
||||||
return int(round(fv))
|
|
||||||
except (ValueError, InvalidOperation):
|
|
||||||
pass
|
|
||||||
neg = s2.startswith("-")
|
|
||||||
body = s2[1:] if neg else s2
|
|
||||||
digits = re.sub(r"\D", "", body)
|
|
||||||
if not digits:
|
|
||||||
raise ValueError("leer")
|
|
||||||
v = int(digits)
|
|
||||||
return -v if neg else v
|
|
||||||
# Ohne flexible: «108.0» / «96,8» trotzdem als Zahl mit Nachkommastellen
|
|
||||||
s2 = _normalize_num_token(s)
|
|
||||||
if "," in s2 or "." in s2:
|
|
||||||
dec = spec.get("decimal_separator", ".")
|
|
||||||
try:
|
|
||||||
if dec in (None, "auto"):
|
|
||||||
fv = _parse_float_auto(s2)
|
|
||||||
else:
|
|
||||||
fv = _parse_float(raw, str(dec))
|
|
||||||
return int(round(fv))
|
|
||||||
except (ValueError, InvalidOperation):
|
|
||||||
pass
|
|
||||||
s = re.sub(r"[^\d-]", "", s)
|
|
||||||
if not s:
|
|
||||||
raise ValueError("leer")
|
|
||||||
return int(s)
|
|
||||||
|
|
||||||
|
|
||||||
def convert_value(
|
|
||||||
raw: str,
|
|
||||||
db_field: str,
|
|
||||||
spec: Mapping[str, Any] | None,
|
|
||||||
module: str | None = None,
|
|
||||||
) -> Any:
|
|
||||||
"""
|
|
||||||
Konvertiert eine Roh-Zelle in einen Python-Wert.
|
|
||||||
spec kommt aus type_conversions[db_field].
|
|
||||||
|
|
||||||
Optionen (JSON):
|
|
||||||
- flexible: true — nach Primärformat Fallbacks (Datum/Zahl/Zeit/Duration).
|
|
||||||
- decimal_separator: ".", ",", "auto" — bei auto Heuristik EU/US-Mischformen.
|
|
||||||
- formats: [ "yyyy-mm-dd", "%d.%m.%y", ... ] — weitere strptime-/Alias-Ketten.
|
|
||||||
- dayfirst: true|false — nur für dateutil-Fallback; Standard: true dann false.
|
|
||||||
- source_unit: Registry-IDs (z. B. "kj", "kg") oder "custom"/"none" — letztere ohne
|
|
||||||
vordefinierten Faktor; beliebige Skalierung dann nur über conversion_factor.
|
|
||||||
- conversion_factor: Zusätzlicher Multiplikator nach dem Parsen (und nach source_unit);
|
|
||||||
für nicht vordefinierte Umrechnungen source_unit weglassen oder "custom" setzen und
|
|
||||||
hier den Faktor angeben.
|
|
||||||
"""
|
|
||||||
if spec is None:
|
|
||||||
return raw.strip() if raw else None
|
|
||||||
if raw is None:
|
|
||||||
return None
|
|
||||||
s = raw.strip()
|
|
||||||
if s == "":
|
|
||||||
return None
|
|
||||||
|
|
||||||
t = spec.get("type", "string")
|
|
||||||
if t == "string":
|
|
||||||
return s
|
|
||||||
|
|
||||||
if t in ("float", "number"):
|
|
||||||
v = _float_from_spec(raw, spec)
|
|
||||||
if module:
|
|
||||||
su = spec.get("source_unit")
|
|
||||||
if su is not None and str(su).strip() != "":
|
|
||||||
v = float(v) * factor_source_to_canonical(module, db_field, str(su))
|
|
||||||
factor = spec.get("conversion_factor")
|
|
||||||
if factor is not None:
|
|
||||||
v = float(v) * float(factor)
|
|
||||||
return v
|
|
||||||
|
|
||||||
if t == "int":
|
|
||||||
return _parse_int(raw, spec)
|
|
||||||
|
|
||||||
if t == "date":
|
|
||||||
return _parse_date_typed(s, spec)
|
|
||||||
|
|
||||||
if t == "time":
|
|
||||||
return _parse_time_typed(s, spec)
|
|
||||||
|
|
||||||
if t == "datetime":
|
|
||||||
return _parse_datetime_typed(s, spec)
|
|
||||||
|
|
||||||
if t == "duration":
|
|
||||||
target = spec.get("target_unit", "minutes")
|
|
||||||
parts = [p.strip() for p in s.split(":")]
|
|
||||||
flexible = bool(spec.get("flexible"))
|
|
||||||
if len(parts) == 3:
|
|
||||||
try:
|
|
||||||
h, m, sec = int(parts[0]), int(parts[1]), int(parts[2])
|
|
||||||
if target == "minutes":
|
|
||||||
return round(h * 60 + m + sec / 60.0, 4)
|
|
||||||
except ValueError:
|
|
||||||
if not flexible:
|
|
||||||
raise ValueError(f"Duration nicht parsbar: {s!r}") from None
|
|
||||||
if flexible:
|
|
||||||
try:
|
|
||||||
h, m, sec = int(parts[0]), int(parts[1]), float(parts[2])
|
|
||||||
if target == "minutes":
|
|
||||||
return round(h * 60 + m + sec / 60.0, 4)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
if len(parts) == 2:
|
|
||||||
try:
|
|
||||||
h, m = int(parts[0]), int(parts[1])
|
|
||||||
return h * 60 + m
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
raise ValueError(f"Duration nicht parsbar: {s!r}")
|
|
||||||
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
def _lookup_db_field(csv_col: str, field_mappings: Mapping[str, str]) -> str | None:
|
|
||||||
"""
|
|
||||||
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
|
|
||||||
norm = normalize_header_for_signature(csv_col)
|
|
||||||
v = field_mappings.get(norm)
|
|
||||||
if v:
|
|
||||||
return v if v not in ("-", "_skip") else None
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def _vitals_baseline_alias_db_field(csv_col: str) -> str | None:
|
|
||||||
"""
|
|
||||||
Apple Health: deutsch „Vitalwerte.csv“ (Breitexport) vs. schmale Vorlage
|
|
||||||
(Start / Resting Heart Rate …). Ohne Alias wählt die Analyse oft die
|
|
||||||
englische Vorlage → jede Zeile „Datum fehlt“.
|
|
||||||
Abgleich über normalisierten Header (normalize_header_for_signature).
|
|
||||||
"""
|
|
||||||
n = normalize_header_for_signature(str(csv_col))
|
|
||||||
if n in ("datum_uhrzeit", "start", "date_time", "datetime"):
|
|
||||||
return "date"
|
|
||||||
if "ruhepuls" in n or n.startswith("resting_heart_rate"):
|
|
||||||
return "resting_hr"
|
|
||||||
if "herzfrequenzvariabilit" in n or "heart_rate_variability" in n:
|
|
||||||
return "hrv"
|
|
||||||
if "vo2" in n and "max" in n:
|
|
||||||
return "vo2_max"
|
|
||||||
if "blutsauerstoff" in n or "oxygen_saturation" in n:
|
|
||||||
return "spo2"
|
|
||||||
if "atemfrequenz" in n or "respiratory_rate" in n:
|
|
||||||
return "respiratory_rate"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _blood_pressure_alias_db_field(csv_col: str) -> str | None:
|
|
||||||
"""
|
|
||||||
Omron (schmal) vs. Apple-Gesundheit (Breitexport): unterschiedliche Spaltennamen;
|
|
||||||
kombinierte Messzeit oft als „Start“ oder „Datum/Uhrzeit“.
|
|
||||||
"""
|
|
||||||
n = normalize_header_for_signature(str(csv_col))
|
|
||||||
low = str(csv_col).lower()
|
|
||||||
if n in ("datum_uhrzeit", "datetime", "date_time", "messzeitpunkt"):
|
|
||||||
return "start_time"
|
|
||||||
if n in ("start", "beginn"):
|
|
||||||
return "start_time"
|
|
||||||
if n in ("datum", "date", "messdatum"):
|
|
||||||
return "measured_date"
|
|
||||||
if n in ("zeit", "time", "uhrzeit"):
|
|
||||||
return "measured_time"
|
|
||||||
if "systolisch" in n or ("blutdruck" in n and "systol" in low) or n.startswith("systolic"):
|
|
||||||
return "systolic"
|
|
||||||
if "diastolisch" in n or ("blutdruck" in n and "diastol" in low) or n.startswith("diastolic"):
|
|
||||||
return "diastolic"
|
|
||||||
if n.startswith("puls") or n.startswith("pulse") or "puls_" in n:
|
|
||||||
return "pulse"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _activity_alias_db_field(csv_col: str) -> str | None:
|
|
||||||
"""
|
|
||||||
Apple-Workout schmal vs. Breitexport (viele Spalten): Trainingsart/Dauer/Strecke
|
|
||||||
trotzdem zuverlässig erkennen.
|
|
||||||
"""
|
|
||||||
n = normalize_header_for_signature(str(csv_col))
|
|
||||||
low = str(csv_col).lower()
|
|
||||||
if n in ("trainingsart", "workout_type", "activity_type", "workouttype"):
|
|
||||||
return "activity_type"
|
|
||||||
if ("trainings" in n and "art" in n) or ("workout" in low and "type" in low):
|
|
||||||
return "activity_type"
|
|
||||||
if n in ("datum_uhrzeit", "start", "beginn", "startzeit", "von"):
|
|
||||||
return "start_time"
|
|
||||||
if n in ("ende", "end", "endzeit", "bis"):
|
|
||||||
return "end_time"
|
|
||||||
if n in ("date", "datum"):
|
|
||||||
return "date"
|
|
||||||
if "dauer" in n or n == "duration" or n.startswith("duration_"):
|
|
||||||
return "duration_min"
|
|
||||||
if ("strecke" in n or "distance" in low) and ("km" in low or "(km" in low or " km" in low):
|
|
||||||
return "distance_km"
|
|
||||||
if "aktive_energie" in n or "active_energy" in n:
|
|
||||||
return "kcal_active"
|
|
||||||
if "ruheeintr" in n or "ruheenergie" in n or "resting_energy" in n:
|
|
||||||
return "kcal_resting"
|
|
||||||
if ("herzfrequenz" in n or "heart_rate" in n) and ("max" in low or "max" in n):
|
|
||||||
return "hr_max"
|
|
||||||
if (
|
|
||||||
"durchschnittliche_herzfrequenz" in n
|
|
||||||
or "heart_rate_average" in n
|
|
||||||
or ("herzfrequenz" in n and ("durchschn" in n or "avg" in low or "average" in low))
|
|
||||||
or ("heart_rate" in n and ("avg" in low or "average" in low))
|
|
||||||
):
|
|
||||||
return "hr_avg"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _effective_conversion_spec(
|
|
||||||
db_field: str,
|
|
||||||
spec: Mapping[str, Any] | None,
|
|
||||||
module: str | None,
|
|
||||||
) -> Mapping[str, Any] | None:
|
|
||||||
if spec is not None:
|
|
||||||
return spec
|
|
||||||
if module == "blood_pressure" and db_field == "start_time":
|
|
||||||
return {"type": "datetime", "format": "yyyy-mm-dd HH:MM:SS", "flexible": True}
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def build_row_after_mapping(
|
|
||||||
csv_row: Mapping[str, str],
|
|
||||||
field_mappings: Mapping[str, str],
|
|
||||||
type_conversions: Mapping[str, Any] | None,
|
|
||||||
module: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Wendet Zuordnung csv_spalte → db_feld und Typkonvertierung an.
|
|
||||||
Unzugeordnete oder „—“ werden übersprungen.
|
|
||||||
Die Reihenfolge der Spalten in der CSV spielt keine Rolle (Dict-Zugriff nach Name).
|
|
||||||
Falls mehrere Spalten auf dasselbe db_field abbilden, gewinnt die zuletzt verarbeitete
|
|
||||||
(iterreihenfolge = Kopfzeilen-Reihenfolge in der Datei) — in der Praxis selten.
|
|
||||||
"""
|
|
||||||
out: dict[str, Any] = {}
|
|
||||||
tc = type_conversions or {}
|
|
||||||
for csv_col, raw in csv_row.items():
|
|
||||||
db_field = _lookup_db_field(str(csv_col), field_mappings)
|
|
||||||
if not db_field and module == "vitals_baseline":
|
|
||||||
db_field = _vitals_baseline_alias_db_field(csv_col)
|
|
||||||
elif not db_field and module == "blood_pressure":
|
|
||||||
db_field = _blood_pressure_alias_db_field(csv_col)
|
|
||||||
elif not db_field and module == "activity":
|
|
||||||
db_field = _activity_alias_db_field(csv_col)
|
|
||||||
if not db_field:
|
|
||||||
continue
|
|
||||||
raw_spec = tc.get(db_field) if isinstance(tc, dict) else None
|
|
||||||
if not isinstance(raw_spec, dict):
|
|
||||||
raw_spec = None
|
|
||||||
spec = _effective_conversion_spec(db_field, raw_spec, module)
|
|
||||||
try:
|
|
||||||
out[db_field] = convert_value(
|
|
||||||
raw, db_field, spec if isinstance(spec, dict) else None, module=module
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
out[db_field] = None
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def diagnose_row_mapping(
|
|
||||||
csv_row: Mapping[str, str],
|
|
||||||
field_mappings: Mapping[str, str],
|
|
||||||
type_conversions: Mapping[str, Any] | None,
|
|
||||||
module: str | None = None,
|
|
||||||
*,
|
|
||||||
mapped_typed: Mapping[str, Any] | None = None,
|
|
||||||
max_columns: int = 512,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Nur für Diagnose-Endpunkt: Quelle (Vorlage vs. Alias), Konvertierung pro Spalte,
|
|
||||||
Ergebnis wie build_row_after_mapping (json-freundliche Vorschau).
|
|
||||||
max_columns begrenzt nur die Länge der Liste „per_column“ in der Antwort — der echte
|
|
||||||
Import verarbeitet alle Spalten (siehe iter_csv_dict_rows / build_row_after_mapping).
|
|
||||||
"""
|
|
||||||
tc = type_conversions or {}
|
|
||||||
per_column: list[dict[str, Any]] = []
|
|
||||||
n = 0
|
|
||||||
for csv_col, raw in csv_row.items():
|
|
||||||
if n >= max_columns:
|
|
||||||
break
|
|
||||||
n += 1
|
|
||||||
sc = str(csv_col)
|
|
||||||
via_t = _lookup_db_field(sc, field_mappings)
|
|
||||||
via_a = None
|
|
||||||
if not via_t and module == "vitals_baseline":
|
|
||||||
via_a = _vitals_baseline_alias_db_field(sc)
|
|
||||||
elif not via_t and module == "blood_pressure":
|
|
||||||
via_a = _blood_pressure_alias_db_field(sc)
|
|
||||||
elif not via_t and module == "activity":
|
|
||||||
via_a = _activity_alias_db_field(sc)
|
|
||||||
target = via_t or via_a
|
|
||||||
src = "template" if via_t else ("alias" if via_a else "none")
|
|
||||||
raw_spec = tc.get(target) if isinstance(tc, dict) and target else None
|
|
||||||
if not isinstance(raw_spec, dict):
|
|
||||||
raw_spec = None
|
|
||||||
spec = _effective_conversion_spec(target, raw_spec, module) if target else None
|
|
||||||
conv_err: str | None = None
|
|
||||||
conv_preview: Any = None
|
|
||||||
if target:
|
|
||||||
try:
|
|
||||||
conv_val = convert_value(
|
|
||||||
(raw or "").strip(),
|
|
||||||
target,
|
|
||||||
spec if isinstance(spec, dict) else None,
|
|
||||||
module=module,
|
|
||||||
)
|
|
||||||
conv_preview = conv_val.isoformat() if hasattr(conv_val, "isoformat") else conv_val
|
|
||||||
except Exception as e:
|
|
||||||
conv_err = str(e)
|
|
||||||
per_column.append(
|
|
||||||
{
|
|
||||||
"csv_column": sc,
|
|
||||||
"raw_preview": ((raw or "")[:120]),
|
|
||||||
"db_field": target,
|
|
||||||
"source": src,
|
|
||||||
"convert_error": conv_err,
|
|
||||||
"converted_preview": conv_preview,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
src_map = (
|
|
||||||
build_row_after_mapping(csv_row, field_mappings, type_conversions, module=module)
|
|
||||||
if mapped_typed is None
|
|
||||||
else mapped_typed
|
|
||||||
)
|
|
||||||
mapped_preview: dict[str, Any] = {}
|
|
||||||
for k, v in src_map.items():
|
|
||||||
mapped_preview[k] = v.isoformat() if hasattr(v, "isoformat") else v
|
|
||||||
|
|
||||||
tmpl_keys = [
|
|
||||||
str(k)
|
|
||||||
for k, v in field_mappings.items()
|
|
||||||
if v not in (None, "-", "_skip")
|
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"per_column": per_column,
|
|
||||||
"columns_truncated": len(csv_row) > max_columns,
|
|
||||||
"template_mapped_keys": tmpl_keys[:40],
|
|
||||||
"template_mapped_keys_truncated": len(tmpl_keys) > 40,
|
|
||||||
"mapped": mapped_preview,
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
"""
|
"""
|
||||||
Dashboard-Layout v1: Validierung, Produkt-Standard (Übersicht) und Servertemplate (`lab_default_layout_dict`).
|
Dashboard-Layout v1: Validierung, Produkt-Standard (Übersicht) und Lab-Standard.
|
||||||
|
|
||||||
Erlaubte Widget-IDs und Reihenfolge: widget_catalog.WIDGET_CATALOG.
|
Erlaubte Widget-IDs und Reihenfolge: widget_catalog.WIDGET_CATALOG.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
|
|
@ -26,13 +25,12 @@ __all__ = [
|
||||||
"coalesce_effective_layout",
|
"coalesce_effective_layout",
|
||||||
"default_layout_dict",
|
"default_layout_dict",
|
||||||
"lab_default_layout_dict",
|
"lab_default_layout_dict",
|
||||||
"merge_missing_catalog_widgets",
|
|
||||||
"product_default_layout_dict",
|
"product_default_layout_dict",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def lab_default_layout_dict() -> dict[str, Any]:
|
def lab_default_layout_dict() -> dict[str, Any]:
|
||||||
"""Serverseitiges Standardlayout (DEFAULT_LAB_WIDGET_IDS); API-Feld `lab_default_layout`, u. a. für Editor/Reset."""
|
"""Standard für Dashboard-Lab (Experimentier-Widgets)."""
|
||||||
on = DEFAULT_LAB_WIDGET_IDS
|
on = DEFAULT_LAB_WIDGET_IDS
|
||||||
return {
|
return {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
|
|
@ -54,25 +52,6 @@ def default_layout_dict() -> dict[str, Any]:
|
||||||
return product_default_layout_dict()
|
return product_default_layout_dict()
|
||||||
|
|
||||||
|
|
||||||
def merge_missing_catalog_widgets(layout: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Hängt fehlende Widget-IDs aus WIDGET_CATALOG an (enabled=False, leere config).
|
|
||||||
Bestehende Reihenfolge bleibt erhalten — nötig, damit neue Katalog-Einträge in
|
|
||||||
„Übersicht anpassen“ / Lab erscheinen, ohne dass Nutzer:innen das Layout resetten müssen.
|
|
||||||
"""
|
|
||||||
out = copy.deepcopy(layout)
|
|
||||||
widgets: list[dict[str, Any]] = list(out.get("widgets") or [])
|
|
||||||
seen: set[str] = {str(w["id"]) for w in widgets if w.get("id")}
|
|
||||||
for e in WIDGET_CATALOG:
|
|
||||||
wid = e["id"]
|
|
||||||
if wid not in seen:
|
|
||||||
widgets.append({"id": wid, "enabled": False, "config": {}})
|
|
||||||
seen.add(wid)
|
|
||||||
out["version"] = out.get("version", 1)
|
|
||||||
out["widgets"] = widgets
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
class DashboardWidgetEntry(BaseModel):
|
class DashboardWidgetEntry(BaseModel):
|
||||||
id: str = Field(min_length=1, max_length=64)
|
id: str = Field(min_length=1, max_length=64)
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
|
|
|
||||||
|
|
@ -14,18 +14,12 @@ MAX_WIDGET_CONFIG_JSON_BYTES = 3072
|
||||||
|
|
||||||
WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({
|
WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({
|
||||||
"body_overview",
|
"body_overview",
|
||||||
"body_history_viz",
|
|
||||||
"nutrition_history_viz",
|
|
||||||
"fitness_history_viz",
|
|
||||||
"recovery_history_viz",
|
|
||||||
"history_overview_viz",
|
|
||||||
"activity_overview",
|
"activity_overview",
|
||||||
"kpi_board",
|
"kpi_board",
|
||||||
"quick_capture",
|
"quick_capture",
|
||||||
"trend_kcal_weight",
|
"trend_kcal_weight",
|
||||||
"nutrition_detail_charts",
|
"nutrition_detail_charts",
|
||||||
"recovery_charts_panel",
|
"recovery_charts_panel",
|
||||||
"report_export",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
_QUICK_CAPTURE_KEYS: frozenset[str] = frozenset({
|
_QUICK_CAPTURE_KEYS: frozenset[str] = frozenset({
|
||||||
|
|
@ -38,141 +32,6 @@ _QUICK_CAPTURE_KEYS: frozenset[str] = frozenset({
|
||||||
_KPI_TILE_FIXED: frozenset[str] = frozenset({"body_fat", "avg_kcal"})
|
_KPI_TILE_FIXED: frozenset[str] = frozenset({"body_fat", "avg_kcal"})
|
||||||
_KPI_REF_TILE_RE = re.compile(r"^ref:[a-z0-9_]{1,64}$")
|
_KPI_REF_TILE_RE = re.compile(r"^ref:[a-z0-9_]{1,64}$")
|
||||||
|
|
||||||
_BODY_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
|
|
||||||
"show_goals_strip",
|
|
||||||
"show_intro_blurb",
|
|
||||||
"show_layer_meta",
|
|
||||||
"show_kpis",
|
|
||||||
"show_weight_chart",
|
|
||||||
"show_body_fat_chart",
|
|
||||||
"show_proportion_chart",
|
|
||||||
"show_circumference_index_chart",
|
|
||||||
"show_circumference_lines_chart",
|
|
||||||
})
|
|
||||||
|
|
||||||
_BODY_HISTORY_VIZ_DEFAULTS: dict[str, Any] = {
|
|
||||||
"chart_days": 30,
|
|
||||||
"show_goals_strip": False,
|
|
||||||
"show_intro_blurb": False,
|
|
||||||
"show_layer_meta": False,
|
|
||||||
"show_kpis": True,
|
|
||||||
"kpi_detail": "compact",
|
|
||||||
"show_weight_chart": True,
|
|
||||||
"show_body_fat_chart": False,
|
|
||||||
"show_proportion_chart": False,
|
|
||||||
"show_circumference_index_chart": False,
|
|
||||||
"show_circumference_lines_chart": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
_NUTRITION_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
|
|
||||||
"show_goals_strip",
|
|
||||||
"show_intro_blurb",
|
|
||||||
"show_kpis",
|
|
||||||
"show_kcal_vs_weight",
|
|
||||||
"show_calorie_balance_chart",
|
|
||||||
"show_protein_lean_chart",
|
|
||||||
"show_heuristics",
|
|
||||||
"show_macro_daily_bars",
|
|
||||||
"show_macro_distribution_pair",
|
|
||||||
"show_energy_protein_charts",
|
|
||||||
})
|
|
||||||
|
|
||||||
_NUTRITION_HISTORY_VIZ_DEFAULTS: dict[str, Any] = {
|
|
||||||
"chart_days": 30,
|
|
||||||
"show_goals_strip": False,
|
|
||||||
"show_intro_blurb": False,
|
|
||||||
"show_kpis": True,
|
|
||||||
"kpi_detail": "compact",
|
|
||||||
"show_kcal_vs_weight": True,
|
|
||||||
"show_calorie_balance_chart": False,
|
|
||||||
"show_protein_lean_chart": False,
|
|
||||||
"show_heuristics": False,
|
|
||||||
"show_macro_daily_bars": True,
|
|
||||||
"show_macro_distribution_pair": True,
|
|
||||||
"show_energy_protein_charts": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
_FITNESS_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
|
|
||||||
"show_layer_meta",
|
|
||||||
"show_kpis",
|
|
||||||
"show_progress_insights",
|
|
||||||
"show_chart_training_volume",
|
|
||||||
"show_chart_training_type_distribution",
|
|
||||||
"show_chart_quality_sessions",
|
|
||||||
"show_chart_load_monitoring",
|
|
||||||
})
|
|
||||||
|
|
||||||
_FITNESS_HISTORY_VIZ_DEFAULTS: dict[str, Any] = {
|
|
||||||
"chart_days": 30,
|
|
||||||
"show_layer_meta": False,
|
|
||||||
"show_kpis": True,
|
|
||||||
"kpi_detail": "compact",
|
|
||||||
"show_progress_insights": False,
|
|
||||||
"show_chart_training_volume": True,
|
|
||||||
"show_chart_training_type_distribution": True,
|
|
||||||
"show_chart_quality_sessions": False,
|
|
||||||
"show_chart_load_monitoring": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
_RECOVERY_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
|
|
||||||
"show_layer_meta",
|
|
||||||
"show_kpis",
|
|
||||||
"show_progress_insights",
|
|
||||||
"show_sleep_section_heading",
|
|
||||||
"show_chart_recovery_score",
|
|
||||||
"show_chart_sleep_quality",
|
|
||||||
"show_chart_sleep_debt",
|
|
||||||
"show_heart_section_heading",
|
|
||||||
"show_heart_context_card",
|
|
||||||
"show_chart_hrv_rhr",
|
|
||||||
"show_vitals_extra_heading",
|
|
||||||
"show_vitals_extra_trends",
|
|
||||||
})
|
|
||||||
|
|
||||||
_RECOVERY_HISTORY_VIZ_DEFAULTS: dict[str, Any] = {
|
|
||||||
"chart_days": 30,
|
|
||||||
"show_layer_meta": False,
|
|
||||||
"show_kpis": True,
|
|
||||||
"kpi_detail": "compact",
|
|
||||||
"show_progress_insights": False,
|
|
||||||
"show_sleep_section_heading": True,
|
|
||||||
"show_chart_recovery_score": True,
|
|
||||||
"show_chart_sleep_quality": True,
|
|
||||||
"show_chart_sleep_debt": False,
|
|
||||||
"show_heart_section_heading": True,
|
|
||||||
"show_heart_context_card": False,
|
|
||||||
"show_chart_hrv_rhr": True,
|
|
||||||
"show_vitals_extra_heading": False,
|
|
||||||
"show_vitals_extra_trends": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
_HISTORY_OVERVIEW_VIZ_SECTION_KEYS: frozenset[str] = frozenset({
|
|
||||||
"show_section_body",
|
|
||||||
"show_section_nutrition",
|
|
||||||
"show_section_fitness",
|
|
||||||
"show_section_recovery",
|
|
||||||
})
|
|
||||||
|
|
||||||
_HISTORY_OVERVIEW_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
|
|
||||||
"show_confidence_banner",
|
|
||||||
"show_intro_blurb",
|
|
||||||
*_HISTORY_OVERVIEW_VIZ_SECTION_KEYS,
|
|
||||||
"show_correlation_c1_c3",
|
|
||||||
"show_drivers_c4",
|
|
||||||
})
|
|
||||||
|
|
||||||
_HISTORY_OVERVIEW_VIZ_DEFAULTS: dict[str, Any] = {
|
|
||||||
"chart_days": 30,
|
|
||||||
"show_confidence_banner": True,
|
|
||||||
"show_intro_blurb": True,
|
|
||||||
"show_section_body": True,
|
|
||||||
"show_section_nutrition": True,
|
|
||||||
"show_section_fitness": True,
|
|
||||||
"show_section_recovery": True,
|
|
||||||
"show_correlation_c1_c3": True,
|
|
||||||
"show_drivers_c4": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _config_json_size_bytes(config: dict[str, Any]) -> int:
|
def _config_json_size_bytes(config: dict[str, Any]) -> int:
|
||||||
return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8"))
|
return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8"))
|
||||||
|
|
@ -180,44 +39,19 @@ def _config_json_size_bytes(config: dict[str, Any]) -> int:
|
||||||
|
|
||||||
def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
|
def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
|
||||||
if raw is None:
|
if raw is None:
|
||||||
raw = {}
|
return {}
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
raise ValueError(f"Widget {widget_id}: config muss ein Objekt sein")
|
raise ValueError(f"Widget {widget_id}: config muss ein Objekt sein")
|
||||||
if _config_json_size_bytes(raw) > MAX_WIDGET_CONFIG_JSON_BYTES:
|
if _config_json_size_bytes(raw) > MAX_WIDGET_CONFIG_JSON_BYTES:
|
||||||
raise ValueError(f"Widget {widget_id}: config zu groß (max. {MAX_WIDGET_CONFIG_JSON_BYTES} Byte JSON)")
|
raise ValueError(f"Widget {widget_id}: config zu groß (max. {MAX_WIDGET_CONFIG_JSON_BYTES} Byte JSON)")
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
|
||||||
if widget_id not in WIDGETS_ALLOWING_CONFIG:
|
if widget_id not in WIDGETS_ALLOWING_CONFIG:
|
||||||
if raw:
|
|
||||||
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
|
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
|
||||||
return {}
|
|
||||||
|
|
||||||
if not raw:
|
|
||||||
if widget_id == "body_history_viz":
|
|
||||||
return _validate_body_history_viz_config({})
|
|
||||||
if widget_id == "nutrition_history_viz":
|
|
||||||
return _validate_nutrition_history_viz_config({})
|
|
||||||
if widget_id == "fitness_history_viz":
|
|
||||||
return _validate_fitness_history_viz_config({})
|
|
||||||
if widget_id == "recovery_history_viz":
|
|
||||||
return _validate_recovery_history_viz_config({})
|
|
||||||
if widget_id == "history_overview_viz":
|
|
||||||
return _validate_history_overview_viz_config({})
|
|
||||||
if widget_id == "report_export":
|
|
||||||
return _validate_report_export_config({})
|
|
||||||
return {}
|
|
||||||
|
|
||||||
if widget_id == "body_overview":
|
if widget_id == "body_overview":
|
||||||
return _validate_chart_days_only(raw, label="body_overview")
|
return _validate_chart_days_only(raw, label="body_overview")
|
||||||
if widget_id == "body_history_viz":
|
|
||||||
return _validate_body_history_viz_config(raw)
|
|
||||||
if widget_id == "nutrition_history_viz":
|
|
||||||
return _validate_nutrition_history_viz_config(raw)
|
|
||||||
if widget_id == "fitness_history_viz":
|
|
||||||
return _validate_fitness_history_viz_config(raw)
|
|
||||||
if widget_id == "recovery_history_viz":
|
|
||||||
return _validate_recovery_history_viz_config(raw)
|
|
||||||
if widget_id == "history_overview_viz":
|
|
||||||
return _validate_history_overview_viz_config(raw)
|
|
||||||
if widget_id == "activity_overview":
|
if widget_id == "activity_overview":
|
||||||
return _validate_chart_days_only(raw, label="activity_overview")
|
return _validate_chart_days_only(raw, label="activity_overview")
|
||||||
if widget_id == "kpi_board":
|
if widget_id == "kpi_board":
|
||||||
|
|
@ -230,8 +64,6 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
|
||||||
return _validate_chart_days_only(raw, label="nutrition_detail_charts")
|
return _validate_chart_days_only(raw, label="nutrition_detail_charts")
|
||||||
if widget_id == "recovery_charts_panel":
|
if widget_id == "recovery_charts_panel":
|
||||||
return _validate_chart_days_only(raw, label="recovery_charts_panel")
|
return _validate_chart_days_only(raw, label="recovery_charts_panel")
|
||||||
if widget_id == "report_export":
|
|
||||||
return _validate_report_export_config(raw)
|
|
||||||
|
|
||||||
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
|
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
|
||||||
|
|
||||||
|
|
@ -318,210 +150,6 @@ def _parse_chart_days(v: Any, label: str) -> int:
|
||||||
raise ValueError(f"{label}: chart_days muss ganze Zahl sein")
|
raise ValueError(f"{label}: chart_days muss ganze Zahl sein")
|
||||||
|
|
||||||
|
|
||||||
def _validate_body_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
label = "body_history_viz"
|
|
||||||
allowed = _BODY_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"})
|
|
||||||
unknown = set(raw) - allowed
|
|
||||||
if unknown:
|
|
||||||
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
|
|
||||||
out: dict[str, Any] = dict(_BODY_HISTORY_VIZ_DEFAULTS)
|
|
||||||
for k in _BODY_HISTORY_VIZ_BOOL_KEYS:
|
|
||||||
if k not in raw:
|
|
||||||
continue
|
|
||||||
v = raw[k]
|
|
||||||
if not isinstance(v, bool):
|
|
||||||
raise ValueError(f"{label}: {k} muss boolean sein")
|
|
||||||
out[k] = v
|
|
||||||
if "kpi_detail" in raw:
|
|
||||||
kd = raw["kpi_detail"]
|
|
||||||
if kd not in ("compact", "full"):
|
|
||||||
raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein")
|
|
||||||
out["kpi_detail"] = kd
|
|
||||||
if "chart_days" in raw:
|
|
||||||
v = _parse_chart_days(raw["chart_days"], label)
|
|
||||||
if v < 7 or v > 90:
|
|
||||||
raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen")
|
|
||||||
out["chart_days"] = v
|
|
||||||
if not out["show_kpis"] and not any(
|
|
||||||
out[k]
|
|
||||||
for k in (
|
|
||||||
"show_weight_chart",
|
|
||||||
"show_body_fat_chart",
|
|
||||||
"show_proportion_chart",
|
|
||||||
"show_circumference_index_chart",
|
|
||||||
"show_circumference_lines_chart",
|
|
||||||
)
|
|
||||||
):
|
|
||||||
raise ValueError(f"{label}: mindestens KPIs oder ein Chart muss sichtbar sein")
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_nutrition_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
label = "nutrition_history_viz"
|
|
||||||
allowed = _NUTRITION_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"})
|
|
||||||
unknown = set(raw) - allowed
|
|
||||||
if unknown:
|
|
||||||
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
|
|
||||||
out: dict[str, Any] = dict(_NUTRITION_HISTORY_VIZ_DEFAULTS)
|
|
||||||
for k in _NUTRITION_HISTORY_VIZ_BOOL_KEYS:
|
|
||||||
if k not in raw:
|
|
||||||
continue
|
|
||||||
v = raw[k]
|
|
||||||
if not isinstance(v, bool):
|
|
||||||
raise ValueError(f"{label}: {k} muss boolean sein")
|
|
||||||
out[k] = v
|
|
||||||
if "kpi_detail" in raw:
|
|
||||||
kd = raw["kpi_detail"]
|
|
||||||
if kd not in ("compact", "full"):
|
|
||||||
raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein")
|
|
||||||
out["kpi_detail"] = kd
|
|
||||||
if "chart_days" in raw:
|
|
||||||
v = _parse_chart_days(raw["chart_days"], label)
|
|
||||||
if v < 7 or v > 90:
|
|
||||||
raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen")
|
|
||||||
out["chart_days"] = v
|
|
||||||
if not out["show_kpis"] and not any(
|
|
||||||
out[k]
|
|
||||||
for k in (
|
|
||||||
"show_kcal_vs_weight",
|
|
||||||
"show_calorie_balance_chart",
|
|
||||||
"show_protein_lean_chart",
|
|
||||||
"show_heuristics",
|
|
||||||
"show_macro_daily_bars",
|
|
||||||
"show_macro_distribution_pair",
|
|
||||||
"show_energy_protein_charts",
|
|
||||||
)
|
|
||||||
):
|
|
||||||
raise ValueError(f"{label}: mindestens KPIs oder ein Chart-Bereich muss sichtbar sein")
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_fitness_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
label = "fitness_history_viz"
|
|
||||||
allowed = _FITNESS_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"})
|
|
||||||
unknown = set(raw) - allowed
|
|
||||||
if unknown:
|
|
||||||
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
|
|
||||||
out: dict[str, Any] = dict(_FITNESS_HISTORY_VIZ_DEFAULTS)
|
|
||||||
for k in _FITNESS_HISTORY_VIZ_BOOL_KEYS:
|
|
||||||
if k not in raw:
|
|
||||||
continue
|
|
||||||
v = raw[k]
|
|
||||||
if not isinstance(v, bool):
|
|
||||||
raise ValueError(f"{label}: {k} muss boolean sein")
|
|
||||||
out[k] = v
|
|
||||||
if "kpi_detail" in raw:
|
|
||||||
kd = raw["kpi_detail"]
|
|
||||||
if kd not in ("compact", "full"):
|
|
||||||
raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein")
|
|
||||||
out["kpi_detail"] = kd
|
|
||||||
if "chart_days" in raw:
|
|
||||||
v = _parse_chart_days(raw["chart_days"], label)
|
|
||||||
if v < 7 or v > 90:
|
|
||||||
raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen")
|
|
||||||
out["chart_days"] = v
|
|
||||||
if not out["show_kpis"] and not out["show_progress_insights"] and not any(
|
|
||||||
out[k]
|
|
||||||
for k in (
|
|
||||||
"show_chart_training_volume",
|
|
||||||
"show_chart_training_type_distribution",
|
|
||||||
"show_chart_quality_sessions",
|
|
||||||
"show_chart_load_monitoring",
|
|
||||||
)
|
|
||||||
):
|
|
||||||
raise ValueError(f"{label}: mindestens KPIs, Einschätzungen oder ein Chart muss sichtbar sein")
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_recovery_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
label = "recovery_history_viz"
|
|
||||||
allowed = _RECOVERY_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"})
|
|
||||||
unknown = set(raw) - allowed
|
|
||||||
if unknown:
|
|
||||||
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
|
|
||||||
out: dict[str, Any] = dict(_RECOVERY_HISTORY_VIZ_DEFAULTS)
|
|
||||||
for k in _RECOVERY_HISTORY_VIZ_BOOL_KEYS:
|
|
||||||
if k not in raw:
|
|
||||||
continue
|
|
||||||
v = raw[k]
|
|
||||||
if not isinstance(v, bool):
|
|
||||||
raise ValueError(f"{label}: {k} muss boolean sein")
|
|
||||||
out[k] = v
|
|
||||||
if "kpi_detail" in raw:
|
|
||||||
kd = raw["kpi_detail"]
|
|
||||||
if kd not in ("compact", "full"):
|
|
||||||
raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein")
|
|
||||||
out["kpi_detail"] = kd
|
|
||||||
if "chart_days" in raw:
|
|
||||||
v = _parse_chart_days(raw["chart_days"], label)
|
|
||||||
if v < 7 or v > 90:
|
|
||||||
raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen")
|
|
||||||
out["chart_days"] = v
|
|
||||||
if not out["show_kpis"] and not out["show_progress_insights"] and not out["show_heart_context_card"] and not out[
|
|
||||||
"show_vitals_extra_trends"
|
|
||||||
] and not any(
|
|
||||||
out[k]
|
|
||||||
for k in (
|
|
||||||
"show_chart_recovery_score",
|
|
||||||
"show_chart_sleep_quality",
|
|
||||||
"show_chart_sleep_debt",
|
|
||||||
"show_chart_hrv_rhr",
|
|
||||||
)
|
|
||||||
):
|
|
||||||
raise ValueError(f"{label}: mindestens KPIs, Überblick, Kontextkarte, Extra-Vitals oder ein Chart muss sichtbar sein")
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _migrate_history_overview_viz_raw(raw: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Alt: show_area_summaries → vier show_section_* (nur wo keine expliziten Section-Keys gesetzt)."""
|
|
||||||
r = dict(raw)
|
|
||||||
if "show_area_summaries" not in r:
|
|
||||||
return r
|
|
||||||
leg = r.pop("show_area_summaries")
|
|
||||||
if not isinstance(leg, bool):
|
|
||||||
raise ValueError("history_overview_viz: show_area_summaries muss boolean sein (veraltet — nutze show_section_*)")
|
|
||||||
for k in _HISTORY_OVERVIEW_VIZ_SECTION_KEYS:
|
|
||||||
if k not in r:
|
|
||||||
r[k] = leg
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_history_overview_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
label = "history_overview_viz"
|
|
||||||
raw_m = _migrate_history_overview_viz_raw(raw)
|
|
||||||
allowed = _HISTORY_OVERVIEW_VIZ_BOOL_KEYS | frozenset({"chart_days"})
|
|
||||||
unknown = set(raw_m) - allowed
|
|
||||||
if unknown:
|
|
||||||
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
|
|
||||||
out: dict[str, Any] = dict(_HISTORY_OVERVIEW_VIZ_DEFAULTS)
|
|
||||||
for k in _HISTORY_OVERVIEW_VIZ_BOOL_KEYS:
|
|
||||||
if k not in raw_m:
|
|
||||||
continue
|
|
||||||
v = raw_m[k]
|
|
||||||
if not isinstance(v, bool):
|
|
||||||
raise ValueError(f"{label}: {k} muss boolean sein")
|
|
||||||
out[k] = v
|
|
||||||
if "chart_days" in raw_m:
|
|
||||||
v = _parse_chart_days(raw_m["chart_days"], label)
|
|
||||||
if v < 7 or v > 90:
|
|
||||||
raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen")
|
|
||||||
out["chart_days"] = v
|
|
||||||
has_section = any(out[k] for k in _HISTORY_OVERVIEW_VIZ_SECTION_KEYS)
|
|
||||||
has_other = any(
|
|
||||||
out[k]
|
|
||||||
for k in (
|
|
||||||
"show_confidence_banner",
|
|
||||||
"show_correlation_c1_c3",
|
|
||||||
"show_drivers_c4",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if not has_section and not has_other:
|
|
||||||
raise ValueError(
|
|
||||||
f"{label}: mindestens eine Bereichs-Kachel, das Datenlage-Banner, Lag-Korrelationen (C1–C3) oder Treiber (C4) muss sichtbar sein"
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, Any]:
|
def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, Any]:
|
||||||
allowed = frozenset({"chart_days"})
|
allowed = frozenset({"chart_days"})
|
||||||
unknown = set(raw) - allowed
|
unknown = set(raw) - allowed
|
||||||
|
|
@ -535,43 +163,3 @@ def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, A
|
||||||
return {"chart_days": v}
|
return {"chart_days": v}
|
||||||
|
|
||||||
|
|
||||||
def _validate_report_export_config(raw: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
label = "report_export"
|
|
||||||
allowed = frozenset({"document_title", "subtitle", "capture_scale"})
|
|
||||||
unknown = set(raw) - allowed
|
|
||||||
if unknown:
|
|
||||||
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
|
|
||||||
out: dict[str, Any] = {"capture_scale": 2}
|
|
||||||
if "document_title" in raw:
|
|
||||||
t = raw["document_title"]
|
|
||||||
if t is not None and not isinstance(t, str):
|
|
||||||
raise ValueError(f"{label}: document_title muss Text sein")
|
|
||||||
s = (t or "").strip()
|
|
||||||
if len(s) > 120:
|
|
||||||
raise ValueError(f"{label}: document_title max. 120 Zeichen")
|
|
||||||
if s:
|
|
||||||
out["document_title"] = s
|
|
||||||
if "subtitle" in raw:
|
|
||||||
t = raw["subtitle"]
|
|
||||||
if t is not None and not isinstance(t, str):
|
|
||||||
raise ValueError(f"{label}: subtitle muss Text sein")
|
|
||||||
s = (t or "").strip()
|
|
||||||
if len(s) > 240:
|
|
||||||
raise ValueError(f"{label}: subtitle max. 240 Zeichen")
|
|
||||||
if s:
|
|
||||||
out["subtitle"] = s
|
|
||||||
if "capture_scale" in raw:
|
|
||||||
v = raw["capture_scale"]
|
|
||||||
if isinstance(v, bool) or isinstance(v, float):
|
|
||||||
if isinstance(v, float) and math.isfinite(v) and abs(v - round(v)) < 1e-9:
|
|
||||||
v = int(round(v))
|
|
||||||
else:
|
|
||||||
raise ValueError(f"{label}: capture_scale muss ganze Zahl 1–3 sein")
|
|
||||||
if not isinstance(v, int):
|
|
||||||
raise ValueError(f"{label}: capture_scale muss ganze Zahl 1–3 sein")
|
|
||||||
if v < 1 or v > 3:
|
|
||||||
raise ValueError(f"{label}: capture_scale muss zwischen 1 und 3 liegen")
|
|
||||||
out["capture_scale"] = v
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,6 @@ __all__ = [
|
||||||
|
|
||||||
# Body Metrics (Basic)
|
# Body Metrics (Basic)
|
||||||
'get_latest_weight_data',
|
'get_latest_weight_data',
|
||||||
'get_bmi_data',
|
|
||||||
'get_profile_goal_weight_data',
|
|
||||||
'get_profile_goal_bf_pct_data',
|
|
||||||
'get_weight_trend_data',
|
'get_weight_trend_data',
|
||||||
'get_body_composition_data',
|
'get_body_composition_data',
|
||||||
'get_circumference_summary_data',
|
'get_circumference_summary_data',
|
||||||
|
|
@ -70,7 +67,6 @@ __all__ = [
|
||||||
'calculate_hip_28d_delta',
|
'calculate_hip_28d_delta',
|
||||||
'calculate_chest_28d_delta',
|
'calculate_chest_28d_delta',
|
||||||
'calculate_arm_28d_delta',
|
'calculate_arm_28d_delta',
|
||||||
'calculate_arm_relaxed_28d_delta',
|
|
||||||
'calculate_thigh_28d_delta',
|
'calculate_thigh_28d_delta',
|
||||||
'calculate_waist_hip_ratio',
|
'calculate_waist_hip_ratio',
|
||||||
'calculate_recomposition_quadrant',
|
'calculate_recomposition_quadrant',
|
||||||
|
|
@ -103,9 +99,6 @@ __all__ = [
|
||||||
'get_activity_summary_data',
|
'get_activity_summary_data',
|
||||||
'get_activity_detail_data',
|
'get_activity_detail_data',
|
||||||
'get_training_type_distribution_data',
|
'get_training_type_distribution_data',
|
||||||
'get_training_frequency_by_type_data',
|
|
||||||
'get_training_inter_session_gap_data',
|
|
||||||
'get_training_sessions_recent_weeks_data',
|
|
||||||
|
|
||||||
# Activity Metrics (Calculated)
|
# Activity Metrics (Calculated)
|
||||||
'calculate_training_minutes_week',
|
'calculate_training_minutes_week',
|
||||||
|
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
"""
|
|
||||||
Kanonische Aufteilung activity_log vs. EAV für Aktivitätssessions.
|
|
||||||
|
|
||||||
- **Kern / Mapping-Ziele für activity_log:** ausschließlich die Keys aus
|
|
||||||
``csv_parser.module_registry.MODULE_DEFINITIONS["activity"].fields`` (keine zweite hartcodierte Liste).
|
|
||||||
- **Alle anderen Attribute:** ``training_parameters`` + Attributprofil (Kategorie/Typ) → EAV;
|
|
||||||
Lesefallback für bekannte Legacy-Spalten siehe unten.
|
|
||||||
|
|
||||||
Normative Doku: .claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md,
|
|
||||||
ACTIVITY_SCALAR_KANON_TABLE.md
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Dict, Final
|
|
||||||
|
|
||||||
from csv_parser.module_registry import get_module_definition
|
|
||||||
|
|
||||||
|
|
||||||
def get_activity_module_registry_field_keys() -> frozenset[str]:
|
|
||||||
"""Keys des Universal-CSV-Moduls ``activity`` (= feste activity_log-Kernfelder / Mapping-Ziele)."""
|
|
||||||
mod = get_module_definition("activity")
|
|
||||||
if not mod:
|
|
||||||
return frozenset()
|
|
||||||
return frozenset((mod.get("fields") or {}).keys())
|
|
||||||
|
|
||||||
|
|
||||||
# Gleiche Menge wie ``MODULE_DEFINITIONS["activity"].fields`` — zur Laufzeit aus der Registry abgeleitet.
|
|
||||||
ACTIVITY_MODULE_REGISTRY_FIELD_KEYS: Final[frozenset[str]] = get_activity_module_registry_field_keys()
|
|
||||||
|
|
||||||
# Teil-UPDATEs (Import): alle Kernfelder außer ``date`` (Identität / Duplikat-Key).
|
|
||||||
ACTIVITY_LOG_PATCHABLE_COLUMNS: Final[frozenset[str]] = ACTIVITY_MODULE_REGISTRY_FIELD_KEYS - {"date"}
|
|
||||||
|
|
||||||
# Parameter-Keys (training_parameters.key), die primär in EAV geführt werden; source_field nach Migration 057 NULL.
|
|
||||||
# Lesen (Merge): activity_log-Legacy-Spalte schlägt EAV, wenn beide befüllt; sonst EAV.
|
|
||||||
ACTIVITY_EAV_PRIMARY_PARAMETER_KEYS: Final[frozenset[str]] = frozenset(
|
|
||||||
{
|
|
||||||
"min_hr",
|
|
||||||
"pace_min_per_km",
|
|
||||||
"cadence",
|
|
||||||
"avg_power",
|
|
||||||
"elevation_gain",
|
|
||||||
"temperature_celsius",
|
|
||||||
"humidity_percent",
|
|
||||||
"avg_hr_percent",
|
|
||||||
"kcal_per_km",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Spaltenname activity_log für Legacy-Merge (Vorrang vor EAV bei gesetztem Spaltenwert).
|
|
||||||
ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM: Final[Dict[str, str]] = {
|
|
||||||
"min_hr": "hr_min",
|
|
||||||
"pace_min_per_km": "pace_min_per_km",
|
|
||||||
"cadence": "cadence",
|
|
||||||
"avg_power": "avg_power",
|
|
||||||
"elevation_gain": "elevation_gain",
|
|
||||||
"temperature_celsius": "temperature_celsius",
|
|
||||||
"humidity_percent": "humidity_percent",
|
|
||||||
"avg_hr_percent": "avg_hr_percent",
|
|
||||||
"kcal_per_km": "kcal_per_km",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -7,10 +7,6 @@ Functions:
|
||||||
- get_activity_summary_data(): Count, total duration, calories, averages
|
- get_activity_summary_data(): Count, total duration, calories, averages
|
||||||
- get_activity_detail_data(): Detailed activity log entries
|
- get_activity_detail_data(): Detailed activity log entries
|
||||||
- get_training_type_distribution_data(): Training category percentages
|
- get_training_type_distribution_data(): Training category percentages
|
||||||
- get_training_frequency_by_type_data(): Häufigkeit & Intensität pro activity_type
|
|
||||||
- get_training_inter_session_gap_data(): Pausen zwischen Einheiten (Stunden)
|
|
||||||
- get_training_sessions_recent_weeks_data(): Wochen-JSON für KI-Kontext
|
|
||||||
- get_training_parameters_ki_glossary_data(): Parameter-Katalog (Feld, Namen, Beschreibungen) für KI
|
|
||||||
|
|
||||||
All functions return structured data (dict) without formatting.
|
All functions return structured data (dict) without formatting.
|
||||||
Use placeholder_resolver.py for formatted strings for AI.
|
Use placeholder_resolver.py for formatted strings for AI.
|
||||||
|
|
@ -19,16 +15,11 @@ Phase 0c: Multi-Layer Architecture
|
||||||
Version: 1.0
|
Version: 1.0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Dict, List, Optional, Any
|
from typing import Dict, List, Optional
|
||||||
from datetime import datetime, timedelta, date, time
|
from datetime import datetime, timedelta, date
|
||||||
import statistics
|
import statistics
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
from data_layer.activity_session_metrics import enrich_sessions_with_metrics
|
from data_layer.utils import calculate_confidence, safe_float, safe_int
|
||||||
from data_layer.utils import calculate_confidence, safe_float, safe_int, serialize_dates
|
|
||||||
from data_layer.prompt_output_compact import (
|
|
||||||
normalize_prompt_number,
|
|
||||||
session_metrics_list_to_key_value_compact,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_activity_summary_data(
|
def get_activity_summary_data(
|
||||||
|
|
@ -129,8 +120,7 @@ def get_activity_detail_data(
|
||||||
"duration_min": int,
|
"duration_min": int,
|
||||||
"kcal_active": int,
|
"kcal_active": int,
|
||||||
"hr_avg": int | None,
|
"hr_avg": int | None,
|
||||||
"training_category": str | None,
|
"training_category": str | None
|
||||||
"session_metrics": list | None, # EAV (enrich_sessions_with_metrics)
|
|
||||||
},
|
},
|
||||||
...
|
...
|
||||||
],
|
],
|
||||||
|
|
@ -149,7 +139,6 @@ def get_activity_detail_data(
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""SELECT
|
"""SELECT
|
||||||
id,
|
|
||||||
date,
|
date,
|
||||||
activity_type,
|
activity_type,
|
||||||
duration_min,
|
duration_min,
|
||||||
|
|
@ -160,7 +149,7 @@ def get_activity_detail_data(
|
||||||
WHERE profile_id=%s AND date >= %s
|
WHERE profile_id=%s AND date >= %s
|
||||||
ORDER BY date DESC
|
ORDER BY date DESC
|
||||||
LIMIT %s""",
|
LIMIT %s""",
|
||||||
(profile_id, cutoff, limit),
|
(profile_id, cutoff, limit)
|
||||||
)
|
)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
|
@ -169,24 +158,19 @@ def get_activity_detail_data(
|
||||||
"activities": [],
|
"activities": [],
|
||||||
"total_count": 0,
|
"total_count": 0,
|
||||||
"confidence": "insufficient",
|
"confidence": "insufficient",
|
||||||
"days_analyzed": days,
|
"days_analyzed": days
|
||||||
}
|
}
|
||||||
|
|
||||||
activities = []
|
activities = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
activities.append(
|
activities.append({
|
||||||
{
|
"date": row['date'],
|
||||||
"id": str(row["id"]),
|
"activity_type": row['activity_type'],
|
||||||
"date": row["date"],
|
"duration_min": safe_int(row['duration_min']),
|
||||||
"activity_type": row["activity_type"],
|
"kcal_active": safe_int(row['kcal_active']),
|
||||||
"duration_min": safe_int(row["duration_min"]),
|
"hr_avg": safe_int(row['hr_avg']) if row.get('hr_avg') else None,
|
||||||
"kcal_active": safe_int(row["kcal_active"]),
|
"training_category": row.get('training_category')
|
||||||
"hr_avg": safe_int(row["hr_avg"]) if row.get("hr_avg") else None,
|
})
|
||||||
"training_category": row.get("training_category"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
enrich_sessions_with_metrics(cur, activities)
|
|
||||||
|
|
||||||
confidence = calculate_confidence(len(activities), days, "general")
|
confidence = calculate_confidence(len(activities), days, "general")
|
||||||
|
|
||||||
|
|
@ -194,7 +178,7 @@ def get_activity_detail_data(
|
||||||
"activities": activities,
|
"activities": activities,
|
||||||
"total_count": len(activities),
|
"total_count": len(activities),
|
||||||
"confidence": confidence,
|
"confidence": confidence,
|
||||||
"days_analyzed": days,
|
"days_analyzed": days
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -330,30 +314,24 @@ def calculate_training_frequency_7d(profile_id: str) -> Optional[int]:
|
||||||
return int(row['session_count']) if row else None
|
return int(row['session_count']) if row else None
|
||||||
|
|
||||||
|
|
||||||
def calculate_quality_sessions_pct(profile_id: str, days: int = 28) -> Optional[int]:
|
def calculate_quality_sessions_pct(profile_id: str) -> Optional[int]:
|
||||||
"""Anteil qualitativ guter Sessions (quality_label) im Zeitfenster ``days``."""
|
"""Calculate percentage of quality sessions (good or better) last 28 days"""
|
||||||
if days < 1:
|
|
||||||
days = 28
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute(
|
cur.execute("""
|
||||||
"""
|
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) as total,
|
COUNT(*) as total,
|
||||||
COUNT(*) FILTER (WHERE quality_label IN ('excellent', 'very_good', 'good')) as quality_count
|
COUNT(*) FILTER (WHERE quality_label IN ('excellent', 'very_good', 'good')) as quality_count
|
||||||
FROM activity_log
|
FROM activity_log
|
||||||
WHERE profile_id = %s
|
WHERE profile_id = %s
|
||||||
AND date >= %s
|
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
||||||
""",
|
""", (profile_id,))
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
|
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if not row or row["total"] == 0:
|
if not row or row['total'] == 0:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
pct = (row["quality_count"] / row["total"]) * 100
|
pct = (row['quality_count'] / row['total']) * 100
|
||||||
return int(pct)
|
return int(pct)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -501,12 +479,11 @@ def calculate_ability_balance_mobility(profile_id: str) -> Optional[int]:
|
||||||
# A5: Load Monitoring (Proxy-based)
|
# A5: Load Monitoring (Proxy-based)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
def calculate_proxy_internal_load_window(profile_id: str, days: int = 7) -> Optional[float]:
|
def calculate_proxy_internal_load_7d(profile_id: str) -> Optional[int]:
|
||||||
"""
|
"""
|
||||||
Proxy-Last über die letzten ``days`` Kalendertage (gleiche Formel wie bisher nur für 7 Tage).
|
Calculate proxy internal load (last 7 days)
|
||||||
|
Formula: duration × intensity_factor × quality_factor
|
||||||
"""
|
"""
|
||||||
if days < 1:
|
|
||||||
days = 7
|
|
||||||
intensity_factors = {'low': 1.0, 'moderate': 1.5, 'high': 2.0}
|
intensity_factors = {'low': 1.0, 'moderate': 1.5, 'high': 2.0}
|
||||||
quality_factors = {
|
quality_factors = {
|
||||||
'excellent': 1.15,
|
'excellent': 1.15,
|
||||||
|
|
@ -519,15 +496,12 @@ def calculate_proxy_internal_load_window(profile_id: str, days: int = 7) -> Opti
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute(
|
cur.execute("""
|
||||||
"""
|
|
||||||
SELECT duration_min, hr_avg, rpe
|
SELECT duration_min, hr_avg, rpe
|
||||||
FROM activity_log
|
FROM activity_log
|
||||||
WHERE profile_id = %s
|
WHERE profile_id = %s
|
||||||
AND date >= CURRENT_DATE - (%s::int * INTERVAL '1 day')
|
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
||||||
""",
|
""", (profile_id,))
|
||||||
(profile_id, days),
|
|
||||||
)
|
|
||||||
|
|
||||||
activities = cur.fetchall()
|
activities = cur.fetchall()
|
||||||
|
|
||||||
|
|
@ -564,12 +538,7 @@ def calculate_proxy_internal_load_window(profile_id: str, days: int = 7) -> Opti
|
||||||
load = float(duration) * intensity_factors[intensity] * quality_factors.get(quality, 1.0)
|
load = float(duration) * intensity_factors[intensity] * quality_factors.get(quality, 1.0)
|
||||||
total_load += load
|
total_load += load
|
||||||
|
|
||||||
return float(total_load)
|
return int(total_load)
|
||||||
|
|
||||||
|
|
||||||
def calculate_proxy_internal_load_7d(profile_id: str) -> Optional[float]:
|
|
||||||
"""Letzte 7 Tage — Kompatibilität mit Platzhaltern / älteren Aufrufern."""
|
|
||||||
return calculate_proxy_internal_load_window(profile_id, 7)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_monotony_score(profile_id: str) -> Optional[float]:
|
def calculate_monotony_score(profile_id: str) -> Optional[float]:
|
||||||
|
|
@ -632,23 +601,26 @@ def calculate_activity_score(profile_id: str, focus_weights: Optional[Dict] = No
|
||||||
from data_layer.scores import get_user_focus_weights
|
from data_layer.scores import get_user_focus_weights
|
||||||
focus_weights = get_user_focus_weights(profile_id)
|
focus_weights = get_user_focus_weights(profile_id)
|
||||||
|
|
||||||
# Activity-related focus areas (English keys from DB); Gewichte float (kein Decimal×float)
|
# Activity-related focus areas (English keys from DB)
|
||||||
strength = float(focus_weights.get('strength', 0) or 0)
|
# Strength training
|
||||||
strength_endurance = float(focus_weights.get('strength_endurance', 0) or 0)
|
strength = focus_weights.get('strength', 0)
|
||||||
power = float(focus_weights.get('power', 0) or 0)
|
strength_endurance = focus_weights.get('strength_endurance', 0)
|
||||||
|
power = focus_weights.get('power', 0)
|
||||||
total_strength = strength + strength_endurance + power
|
total_strength = strength + strength_endurance + power
|
||||||
|
|
||||||
aerobic = float(focus_weights.get('aerobic_endurance', 0) or 0)
|
# Endurance training
|
||||||
anaerobic = float(focus_weights.get('anaerobic_endurance', 0) or 0)
|
aerobic = focus_weights.get('aerobic_endurance', 0)
|
||||||
cardiovascular = float(focus_weights.get('cardiovascular_health', 0) or 0)
|
anaerobic = focus_weights.get('anaerobic_endurance', 0)
|
||||||
|
cardiovascular = focus_weights.get('cardiovascular_health', 0)
|
||||||
total_cardio = aerobic + anaerobic + cardiovascular
|
total_cardio = aerobic + anaerobic + cardiovascular
|
||||||
|
|
||||||
flexibility = float(focus_weights.get('flexibility', 0) or 0)
|
# Mobility/Coordination
|
||||||
mobility = float(focus_weights.get('mobility', 0) or 0)
|
flexibility = focus_weights.get('flexibility', 0)
|
||||||
balance = float(focus_weights.get('balance', 0) or 0)
|
mobility = focus_weights.get('mobility', 0)
|
||||||
reaction = float(focus_weights.get('reaction', 0) or 0)
|
balance = focus_weights.get('balance', 0)
|
||||||
rhythm = float(focus_weights.get('rhythm', 0) or 0)
|
reaction = focus_weights.get('reaction', 0)
|
||||||
coordination = float(focus_weights.get('coordination', 0) or 0)
|
rhythm = focus_weights.get('rhythm', 0)
|
||||||
|
coordination = focus_weights.get('coordination', 0)
|
||||||
total_ability = flexibility + mobility + balance + reaction + rhythm + coordination
|
total_ability = flexibility + mobility + balance + reaction + rhythm + coordination
|
||||||
|
|
||||||
total_activity_weight = total_strength + total_cardio + total_ability
|
total_activity_weight = total_strength + total_cardio + total_ability
|
||||||
|
|
@ -699,9 +671,9 @@ def calculate_activity_score(profile_id: str, focus_weights: Optional[Dict] = No
|
||||||
if not components:
|
if not components:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Weighted average (float: DB-Aggregate können Decimal sein)
|
# Weighted average
|
||||||
total_score = sum(float(score) * float(weight) for _, score, weight in components)
|
total_score = sum(score * weight for _, score, weight in components)
|
||||||
total_weight = sum(float(weight) for _, _, weight in components)
|
total_weight = sum(weight for _, _, weight in components)
|
||||||
|
|
||||||
return int(total_score / total_weight)
|
return int(total_score / total_weight)
|
||||||
|
|
||||||
|
|
@ -753,13 +725,12 @@ def _score_cardio_presence(profile_id: str) -> Optional[int]:
|
||||||
if not row:
|
if not row:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# psycopg2: SUM() → oft Decimal — vor Mix mit float konvertieren
|
cardio_days = row['cardio_days']
|
||||||
cardio_days = int(row['cardio_days'] or 0)
|
cardio_minutes = row['cardio_minutes'] or 0
|
||||||
cardio_minutes = float(row['cardio_minutes'] or 0)
|
|
||||||
|
|
||||||
# Target: 3-5 days/week, 150+ minutes
|
# Target: 3-5 days/week, 150+ minutes
|
||||||
day_score = min(100.0, (cardio_days / 4) * 100)
|
day_score = min(100, (cardio_days / 4) * 100)
|
||||||
minute_score = min(100.0, (cardio_minutes / 150) * 100)
|
minute_score = min(100, (cardio_minutes / 150) * 100)
|
||||||
|
|
||||||
return int((day_score + minute_score) / 2)
|
return int((day_score + minute_score) / 2)
|
||||||
|
|
||||||
|
|
@ -933,605 +904,3 @@ def calculate_activity_data_quality(profile_id: str) -> Dict[str, any]:
|
||||||
"quality": int(quality_score)
|
"quality": int(quality_score)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _session_sort_ts(row: Dict) -> datetime:
|
|
||||||
"""Einheitlicher Zeitstempel für Sortierung und Pausenberechnung."""
|
|
||||||
d = row["date"]
|
|
||||||
if isinstance(d, str):
|
|
||||||
d = datetime.strptime(d[:10], "%Y-%m-%d").date()
|
|
||||||
st = row.get("start_time")
|
|
||||||
if st is None:
|
|
||||||
t = time(12, 0, 0)
|
|
||||||
else:
|
|
||||||
t = st
|
|
||||||
return datetime.combine(d, t)
|
|
||||||
|
|
||||||
|
|
||||||
def get_training_frequency_by_type_data(
|
|
||||||
profile_id: str,
|
|
||||||
days: int = 28,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Pro activity_type (Roh-Label aus Import/Anzeige): Häufigkeit & Intensitätskennzahlen.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
"days_analyzed": int,
|
|
||||||
"confidence": str,
|
|
||||||
"by_type": [
|
|
||||||
{
|
|
||||||
"activity_type": str,
|
|
||||||
"session_count": int,
|
|
||||||
"sessions_per_week": float,
|
|
||||||
"avg_duration_min": float | None,
|
|
||||||
"avg_kcal_active": float | None,
|
|
||||||
"avg_hr_avg": float | None,
|
|
||||||
"avg_hr_max": float | None,
|
|
||||||
"avg_rpe": float | None,
|
|
||||||
"avg_kcal_per_min": float | None, # grobe Intensität, wenn kcal & Dauer
|
|
||||||
},
|
|
||||||
...
|
|
||||||
],
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
weeks = max(days / 7.0, 0.01)
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
activity_type,
|
|
||||||
COUNT(*)::int AS session_count,
|
|
||||||
AVG(duration_min)::float AS avg_duration_min,
|
|
||||||
AVG(kcal_active)::float AS avg_kcal_active,
|
|
||||||
AVG(hr_avg)::float AS avg_hr_avg,
|
|
||||||
AVG(hr_max)::float AS avg_hr_max,
|
|
||||||
AVG(rpe)::float AS avg_rpe,
|
|
||||||
SUM(COALESCE(duration_min, 0))::float AS sum_duration,
|
|
||||||
SUM(COALESCE(kcal_active, 0))::float AS sum_kcal
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s AND date >= %s
|
|
||||||
GROUP BY activity_type
|
|
||||||
ORDER BY session_count DESC
|
|
||||||
""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
rows = [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
if not rows:
|
|
||||||
return {
|
|
||||||
"days_analyzed": days,
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"by_type": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
by_type = []
|
|
||||||
for r in rows:
|
|
||||||
sc = int(r["session_count"])
|
|
||||||
sum_dur = float(r["sum_duration"] or 0)
|
|
||||||
sum_kcal = float(r["sum_kcal"] or 0)
|
|
||||||
kcal_per_min = (sum_kcal / sum_dur) if sum_dur > 0 else None
|
|
||||||
by_type.append(
|
|
||||||
{
|
|
||||||
"activity_type": r["activity_type"],
|
|
||||||
"session_count": sc,
|
|
||||||
"sessions_per_week": round(sc / weeks, 2),
|
|
||||||
"avg_duration_min": r["avg_duration_min"],
|
|
||||||
"avg_kcal_active": r["avg_kcal_active"],
|
|
||||||
"avg_hr_avg": r["avg_hr_avg"],
|
|
||||||
"avg_hr_max": r["avg_hr_max"],
|
|
||||||
"avg_rpe": r["avg_rpe"],
|
|
||||||
"avg_kcal_per_min": round(kcal_per_min, 2) if kcal_per_min is not None else None,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
total_sessions = sum(x["session_count"] for x in by_type)
|
|
||||||
confidence = calculate_confidence(total_sessions, days, "general")
|
|
||||||
return {
|
|
||||||
"days_analyzed": days,
|
|
||||||
"confidence": confidence,
|
|
||||||
"by_type": by_type,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_training_inter_session_gap_data(
|
|
||||||
profile_id: str,
|
|
||||||
days: int = 28,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Mittlere/median Pausen zwischen aufeinanderfolgenden Trainingseinheiten (Stunden).
|
|
||||||
|
|
||||||
Sortierung: Datum + start_time (fehlend → 12:00), dann created.
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date, start_time, created
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s AND date >= %s
|
|
||||||
ORDER BY date ASC, start_time ASC NULLS LAST, created ASC
|
|
||||||
""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
rows = [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
if len(rows) < 2:
|
|
||||||
return {
|
|
||||||
"days_analyzed": days,
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"gap_hours_median": None,
|
|
||||||
"gap_hours_mean": None,
|
|
||||||
"gap_hours_min": None,
|
|
||||||
"gaps_count": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
gaps = []
|
|
||||||
prev_ts = None
|
|
||||||
for r in rows:
|
|
||||||
ts = _session_sort_ts(r)
|
|
||||||
if prev_ts is not None:
|
|
||||||
gaps.append((ts - prev_ts).total_seconds() / 3600.0)
|
|
||||||
prev_ts = ts
|
|
||||||
|
|
||||||
if not gaps:
|
|
||||||
return {
|
|
||||||
"days_analyzed": days,
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"gap_hours_median": None,
|
|
||||||
"gap_hours_mean": None,
|
|
||||||
"gap_hours_min": None,
|
|
||||||
"gaps_count": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
gaps_sorted = sorted(gaps)
|
|
||||||
mid = len(gaps_sorted) // 2
|
|
||||||
median = (
|
|
||||||
gaps_sorted[mid]
|
|
||||||
if len(gaps_sorted) % 2
|
|
||||||
else (gaps_sorted[mid - 1] + gaps_sorted[mid]) / 2.0
|
|
||||||
)
|
|
||||||
confidence = calculate_confidence(len(rows), days, "general")
|
|
||||||
return {
|
|
||||||
"days_analyzed": days,
|
|
||||||
"confidence": confidence,
|
|
||||||
"gap_hours_median": round(median, 1),
|
|
||||||
"gap_hours_mean": round(statistics.mean(gaps), 1),
|
|
||||||
"gap_hours_min": round(min(gaps), 1),
|
|
||||||
"gaps_count": len(gaps),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_training_sessions_recent_weeks_data(
|
|
||||||
profile_id: str,
|
|
||||||
weeks: int = 4,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Letzte Wochen mit Einzeltrainings für KI-Kontext (Dauer, kcal, HF, Typ).
|
|
||||||
|
|
||||||
weeks: Anzahl zurückliegender ISO-Kalenderwochen (Default 4).
|
|
||||||
|
|
||||||
session_metrics pro Einheit: kompaktes Objekt ``{key: Wert}`` (keine wiederholten
|
|
||||||
Namen/Beschreibungen). Bedeutung der Keys: Platzhalter ``{{training_parameters_glossary_md}}``.
|
|
||||||
Zahlen werden für Prompt-Token kompakt gerundet.
|
|
||||||
"""
|
|
||||||
days = max(weeks * 7, 7)
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
a.id,
|
|
||||||
a.date,
|
|
||||||
a.start_time,
|
|
||||||
a.activity_type,
|
|
||||||
a.training_category,
|
|
||||||
a.duration_min,
|
|
||||||
a.kcal_active,
|
|
||||||
a.hr_avg,
|
|
||||||
a.hr_max,
|
|
||||||
a.rpe,
|
|
||||||
tt.name_de AS training_type_name
|
|
||||||
FROM activity_log a
|
|
||||||
LEFT JOIN training_types tt ON tt.id = a.training_type_id
|
|
||||||
WHERE a.profile_id = %s AND a.date >= %s
|
|
||||||
ORDER BY a.date ASC, a.start_time ASC NULLS LAST, a.created ASC
|
|
||||||
""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
rows = [r2d(r) for r in cur.fetchall()]
|
|
||||||
enrich_sessions_with_metrics(cur, rows)
|
|
||||||
|
|
||||||
if not rows:
|
|
||||||
return {
|
|
||||||
"weeks": [],
|
|
||||||
"meta": {
|
|
||||||
"weeks_requested": weeks,
|
|
||||||
"days_loaded": days,
|
|
||||||
"session_count": 0,
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"session_metrics_shape": "key_value",
|
|
||||||
"metric_semantics_placeholder": "{{training_parameters_glossary_md}}",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
by_week: Dict[str, List[Dict]] = {}
|
|
||||||
for r in rows:
|
|
||||||
d = r["date"]
|
|
||||||
if isinstance(d, str):
|
|
||||||
d = datetime.strptime(d[:10], "%Y-%m-%d").date()
|
|
||||||
iso = d.isocalendar()
|
|
||||||
wk = f"{iso.year}-W{iso.week:02d}"
|
|
||||||
if wk not in by_week:
|
|
||||||
by_week[wk] = []
|
|
||||||
dur = r.get("duration_min")
|
|
||||||
dur_f = float(dur) if dur is not None else None
|
|
||||||
kcal = r.get("kcal_active")
|
|
||||||
kcal_f = float(kcal) if kcal is not None else None
|
|
||||||
hr_a = r.get("hr_avg")
|
|
||||||
hr_m = r.get("hr_max")
|
|
||||||
sm_compact = session_metrics_list_to_key_value_compact(r.get("session_metrics"))
|
|
||||||
by_week[wk].append(
|
|
||||||
{
|
|
||||||
"id": str(r["id"]),
|
|
||||||
"date": d,
|
|
||||||
"start_time": str(r["start_time"]) if r.get("start_time") is not None else None,
|
|
||||||
"activity_type": r.get("activity_type"),
|
|
||||||
"training_category": r.get("training_category"),
|
|
||||||
"training_type_name": r.get("training_type_name"),
|
|
||||||
"duration_min": normalize_prompt_number(dur_f) if dur_f is not None else None,
|
|
||||||
"kcal_active": normalize_prompt_number(kcal_f) if kcal_f is not None else None,
|
|
||||||
"hr_avg": int(hr_a) if hr_a is not None else None,
|
|
||||||
"hr_max": int(hr_m) if hr_m is not None else None,
|
|
||||||
"rpe": int(r["rpe"]) if r.get("rpe") is not None else None,
|
|
||||||
"session_metrics": sm_compact,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
week_keys = sorted(by_week.keys())
|
|
||||||
weeks_out = [{"week_iso": wk, "sessions": by_week[wk]} for wk in week_keys]
|
|
||||||
confidence = calculate_confidence(len(rows), days, "general")
|
|
||||||
return serialize_dates(
|
|
||||||
{
|
|
||||||
"weeks": weeks_out,
|
|
||||||
"meta": {
|
|
||||||
"weeks_requested": weeks,
|
|
||||||
"days_loaded": days,
|
|
||||||
"session_count": len(rows),
|
|
||||||
"confidence": confidence,
|
|
||||||
"session_metrics_shape": "key_value",
|
|
||||||
"metric_semantics_placeholder": "{{training_parameters_glossary_md}}",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_training_parameters_ki_glossary_data(profile_id: str) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Alle aktiven ``training_parameters`` für KI-Kontext (z. B. neben ``training_sessions_recent_json``).
|
|
||||||
|
|
||||||
Enthält technischen key, name_de/name_en, description_de/description_en, data_type, unit, category.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_id: Reserviert für spätere Einschränkung (z. B. nur im Profil vorkommende Keys);
|
|
||||||
aktuell ungenutzt, Signatur bleibt für Platzhalter-Resolver.
|
|
||||||
"""
|
|
||||||
_ = profile_id
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT key, name_de, name_en, description_de, description_en,
|
|
||||||
data_type, unit, category
|
|
||||||
FROM training_parameters
|
|
||||||
WHERE is_active = true
|
|
||||||
ORDER BY category, key
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
rows = [r2d(r) for r in cur.fetchall()]
|
|
||||||
return {
|
|
||||||
"parameters": rows,
|
|
||||||
"meta": {"count": len(rows), "scope": "global_active_catalog"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Chart payloads (Phase 0c / Layer 1) — gemeinsam mit charts-Router und Layer-2b-Bundles
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
def build_training_volume_chart_payload(profile_id: str, weeks: int) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Wöchentliches Trainingsvolumen (Minuten) — gleiche Logik wie GET /api/charts/training-volume.
|
|
||||||
"""
|
|
||||||
if weeks < 4:
|
|
||||||
weeks = 4
|
|
||||||
if weeks > 52:
|
|
||||||
weeks = 52
|
|
||||||
|
|
||||||
cutoff = (datetime.now() - timedelta(weeks=weeks)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT
|
|
||||||
DATE_TRUNC('week', date) as week_start,
|
|
||||||
SUM(duration_min) as total_minutes,
|
|
||||||
COUNT(*) as session_count
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id=%s AND date >= %s
|
|
||||||
GROUP BY week_start
|
|
||||||
ORDER BY week_start""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
|
|
||||||
if not rows:
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Keine Aktivitätsdaten vorhanden",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
labels = [row["week_start"].strftime("KW %V") for row in rows]
|
|
||||||
values = [safe_float(row["total_minutes"]) for row in rows]
|
|
||||||
|
|
||||||
confidence = calculate_confidence(len(rows), weeks * 7, "general")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {
|
|
||||||
"labels": labels,
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "Trainingsminuten",
|
|
||||||
"data": values,
|
|
||||||
"backgroundColor": "#1D9E75",
|
|
||||||
"borderColor": "#085041",
|
|
||||||
"borderWidth": 1,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"metadata": serialize_dates(
|
|
||||||
{
|
|
||||||
"confidence": confidence,
|
|
||||||
"data_points": len(rows),
|
|
||||||
"avg_minutes_week": round(sum(values) / len(values), 1) if values else 0,
|
|
||||||
"total_sessions": sum(row["session_count"] for row in rows),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_training_type_distribution_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Trainingstyp-Verteilung — gleiche Logik wie GET /api/charts/training-type-distribution.
|
|
||||||
"""
|
|
||||||
dist_data = get_training_type_distribution_data(profile_id, days)
|
|
||||||
|
|
||||||
if dist_data["confidence"] == "insufficient":
|
|
||||||
return {
|
|
||||||
"chart_type": "pie",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Keine Trainingstypen-Daten",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
labels = [item["category"] for item in dist_data["distribution"]]
|
|
||||||
values = [item["count"] for item in dist_data["distribution"]]
|
|
||||||
|
|
||||||
colors = [
|
|
||||||
"#1D9E75",
|
|
||||||
"#3B82F6",
|
|
||||||
"#F59E0B",
|
|
||||||
"#EF4444",
|
|
||||||
"#8B5CF6",
|
|
||||||
"#10B981",
|
|
||||||
"#F97316",
|
|
||||||
"#06B6D4",
|
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "pie",
|
|
||||||
"data": {
|
|
||||||
"labels": labels,
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"data": values,
|
|
||||||
"backgroundColor": colors[: len(values)],
|
|
||||||
"borderWidth": 2,
|
|
||||||
"borderColor": "#fff",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": dist_data["confidence"],
|
|
||||||
"total_sessions": dist_data["total_sessions"],
|
|
||||||
"categorized_sessions": dist_data["categorized_sessions"],
|
|
||||||
"uncategorized_sessions": dist_data["uncategorized_sessions"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_training_volume_two_week_delta(profile_id: str) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Trainingsminuten: letzte 7 Kalendertage vs. die 7 Tage davor (Fortschritt Volumen).
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
COALESCE(SUM(duration_min) FILTER (WHERE date >= CURRENT_DATE - INTERVAL '7 days'), 0)::bigint AS last7,
|
|
||||||
COALESCE(SUM(duration_min) FILTER (
|
|
||||||
WHERE date < CURRENT_DATE - INTERVAL '7 days'
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '14 days'), 0)::bigint AS prev7
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND date >= CURRENT_DATE - INTERVAL '14 days'
|
|
||||||
""",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return {"last7_min": 0, "prior7_min": 0, "delta_pct": None, "has_data": False}
|
|
||||||
last7 = int(row["last7"] or 0)
|
|
||||||
prev7 = int(row["prev7"] or 0)
|
|
||||||
if last7 == 0 and prev7 == 0:
|
|
||||||
return {"last7_min": 0, "prior7_min": 0, "delta_pct": None, "has_data": False}
|
|
||||||
delta_pct: Optional[float] = None
|
|
||||||
if prev7 > 0:
|
|
||||||
delta_pct = round((last7 - prev7) / float(prev7) * 100.0, 1)
|
|
||||||
return {
|
|
||||||
"last7_min": last7,
|
|
||||||
"prior7_min": prev7,
|
|
||||||
"delta_pct": delta_pct,
|
|
||||||
"has_data": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_quality_sessions_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
"""Qualitäts-Sessions vs. regulär — gleiche Logik wie GET /api/charts/quality-sessions."""
|
|
||||||
if days < 7:
|
|
||||||
days = 7
|
|
||||||
if days > 90:
|
|
||||||
days = 90
|
|
||||||
quality_pct = calculate_quality_sessions_pct(profile_id, days)
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT COUNT(*) as total
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id=%s AND date >= %s""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
total_sessions = row["total"] if row else 0
|
|
||||||
|
|
||||||
if total_sessions == 0:
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Keine Aktivitätsdaten",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
q = float(quality_pct or 0)
|
|
||||||
quality_count = int(round(q / 100.0 * total_sessions))
|
|
||||||
quality_count = max(0, min(quality_count, total_sessions))
|
|
||||||
regular_count = total_sessions - quality_count
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {
|
|
||||||
"labels": ["Qualitäts-Sessions", "Reguläre Sessions"],
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "Anzahl",
|
|
||||||
"data": [quality_count, regular_count],
|
|
||||||
"backgroundColor": ["#1D9E75", "#888"],
|
|
||||||
"borderColor": "#085041",
|
|
||||||
"borderWidth": 1,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": calculate_confidence(total_sessions, days, "general"),
|
|
||||||
"data_points": total_sessions,
|
|
||||||
"quality_pct": round(q, 1),
|
|
||||||
"quality_count": quality_count,
|
|
||||||
"regular_count": regular_count,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_load_monitoring_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
"""Tages-Load-Zeitreihe + ACWR — gleiche Logik wie GET /api/charts/load-monitoring."""
|
|
||||||
if days < 14:
|
|
||||||
days = 14
|
|
||||||
if days > 90:
|
|
||||||
days = 90
|
|
||||||
|
|
||||||
acute_load = calculate_proxy_internal_load_window(profile_id, 7)
|
|
||||||
chronic_load = calculate_proxy_internal_load_window(profile_id, 28)
|
|
||||||
|
|
||||||
acwr = (
|
|
||||||
(acute_load / chronic_load) if acute_load is not None and chronic_load and chronic_load > 0 else 0.0
|
|
||||||
)
|
|
||||||
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT
|
|
||||||
date,
|
|
||||||
SUM(duration_min * COALESCE(rpe, 5)) as daily_load
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id=%s AND date >= %s
|
|
||||||
GROUP BY date
|
|
||||||
ORDER BY date""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
|
|
||||||
if not rows:
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Keine Load-Daten",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
labels = [row["date"].isoformat() for row in rows]
|
|
||||||
values = [safe_float(row["daily_load"]) for row in rows]
|
|
||||||
|
|
||||||
al = float(acute_load) if acute_load is not None else 0.0
|
|
||||||
cl = float(chronic_load) if chronic_load is not None else 0.0
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {
|
|
||||||
"labels": labels,
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "Tages-Load",
|
|
||||||
"data": values,
|
|
||||||
"borderColor": "#1D9E75",
|
|
||||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"tension": 0.3,
|
|
||||||
"fill": True,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"metadata": serialize_dates(
|
|
||||||
{
|
|
||||||
"confidence": calculate_confidence(len(rows), days, "general"),
|
|
||||||
"data_points": len(rows),
|
|
||||||
"acute_load_7d": round(al, 1),
|
|
||||||
"chronic_load_28d": round(cl, 1),
|
|
||||||
"acwr": round(acwr, 2),
|
|
||||||
"acwr_status": "optimal" if 0.8 <= acwr <= 1.3 else "suboptimal",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,406 +0,0 @@
|
||||||
"""
|
|
||||||
Zentrale Persistenz für activity_log + EAV-Nebenwirkungen (Eval).
|
|
||||||
|
|
||||||
Alle Schreibpfade (REST, Universal-CSV, Legacy-Upload) laufen hier zusammen.
|
|
||||||
|
|
||||||
Feld-Katalog für CSV-Mappings: get_mappable_activity_field_catalog()
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import datetime as dt
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
from typing import Any, Dict, List, Mapping, Optional
|
|
||||||
|
|
||||||
from models import ActivityEntry
|
|
||||||
|
|
||||||
from csv_parser.module_registry import get_module_definition
|
|
||||||
from data_layer.activity_data_canon import get_activity_module_registry_field_keys
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
try:
|
|
||||||
from evaluation_helper import evaluate_and_save_activity as _evaluate_and_save_activity
|
|
||||||
|
|
||||||
_EVALUATION_AVAILABLE = True
|
|
||||||
except Exception: # pragma: no cover
|
|
||||||
_evaluate_and_save_activity = None
|
|
||||||
_EVALUATION_AVAILABLE = False
|
|
||||||
|
|
||||||
|
|
||||||
def find_activity_duplicate_id(
|
|
||||||
cur,
|
|
||||||
profile_id: str,
|
|
||||||
date_iso: str,
|
|
||||||
start_time: Optional[Any],
|
|
||||||
) -> Optional[str]:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT id FROM activity_log
|
|
||||||
WHERE profile_id = %s AND date = %s::date
|
|
||||||
AND start_time IS NOT DISTINCT FROM %s::time
|
|
||||||
""",
|
|
||||||
(profile_id, date_iso, start_time),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
return str(row["id"]) if row else None
|
|
||||||
|
|
||||||
|
|
||||||
# Datum/Start/Ende/Typ setzt der CSV-Executor explizit (Normalisierung); nicht aus diesem Patch überschreiben.
|
|
||||||
_ACTIVITY_CSV_REGISTRY_EXCLUDE = frozenset({"date", "start_time", "end_time", "activity_type"})
|
|
||||||
|
|
||||||
|
|
||||||
def activity_registry_field_keys() -> frozenset[str]:
|
|
||||||
"""Gleiche Menge wie ``ACTIVITY_MODULE_REGISTRY_FIELD_KEYS`` (Registry als Single Source)."""
|
|
||||||
return get_activity_module_registry_field_keys()
|
|
||||||
|
|
||||||
|
|
||||||
def activity_csv_registry_updates_from_mapped(mapped: Mapping[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
activity_log-Updates nur aus Modul-Registry-Feldern (Kernspalten).
|
|
||||||
Trainingsparameter-Keys (nur in training_parameters) laufen über EAV, nicht hier.
|
|
||||||
"""
|
|
||||||
mod = get_module_definition("activity")
|
|
||||||
if not mod:
|
|
||||||
return {}
|
|
||||||
fields = mod.get("fields") or {}
|
|
||||||
out: Dict[str, Any] = {}
|
|
||||||
|
|
||||||
def _sf(v: Any) -> float | None:
|
|
||||||
try:
|
|
||||||
if v is None or (isinstance(v, str) and not str(v).strip()):
|
|
||||||
return None
|
|
||||||
return round(float(v), 1)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _si(v: Any) -> int | None:
|
|
||||||
try:
|
|
||||||
if v is None or (isinstance(v, str) and not str(v).strip()):
|
|
||||||
return None
|
|
||||||
return int(round(float(v)))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _hr(v: Any) -> float | None:
|
|
||||||
x = _sf(v)
|
|
||||||
if x is None or x < 20 or x > 280:
|
|
||||||
return None
|
|
||||||
return x
|
|
||||||
|
|
||||||
for key, spec in fields.items():
|
|
||||||
if key in _ACTIVITY_CSV_REGISTRY_EXCLUDE:
|
|
||||||
continue
|
|
||||||
if key not in mapped:
|
|
||||||
continue
|
|
||||||
raw = mapped[key]
|
|
||||||
if raw is None or raw == "":
|
|
||||||
continue
|
|
||||||
if isinstance(raw, str) and not raw.strip():
|
|
||||||
continue
|
|
||||||
typ = spec.get("type", "string")
|
|
||||||
if typ == "float":
|
|
||||||
v = _hr(raw) if key in ("hr_avg", "hr_max") else _sf(raw)
|
|
||||||
if v is not None:
|
|
||||||
out[key] = v
|
|
||||||
elif typ == "int":
|
|
||||||
v = _si(raw)
|
|
||||||
if v is not None:
|
|
||||||
out[key] = v
|
|
||||||
elif typ == "datetime":
|
|
||||||
if isinstance(raw, dt.datetime):
|
|
||||||
out[key] = raw.strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
elif isinstance(raw, dt.date):
|
|
||||||
out[key] = f"{raw.isoformat()} 00:00:00"
|
|
||||||
elif isinstance(raw, str) and raw.strip():
|
|
||||||
out[key] = raw.strip()
|
|
||||||
elif typ == "date":
|
|
||||||
if isinstance(raw, dt.date):
|
|
||||||
out[key] = raw.isoformat()
|
|
||||||
elif isinstance(raw, dt.datetime):
|
|
||||||
out[key] = raw.date().isoformat()
|
|
||||||
elif isinstance(raw, str) and raw.strip():
|
|
||||||
out[key] = raw.strip()
|
|
||||||
else:
|
|
||||||
out[key] = str(raw).strip()
|
|
||||||
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def insert_activity_from_entry(cur, profile_id: str, eid: str, e: ActivityEntry) -> None:
|
|
||||||
"""INSERT activity_log aus ActivityEntry (manueller API-Pfad)."""
|
|
||||||
d = e.model_dump()
|
|
||||||
cur.execute(
|
|
||||||
"""INSERT INTO activity_log (id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting,
|
|
||||||
hr_avg,hr_max,hr_min,distance_km,pace_min_per_km,cadence,avg_power,elevation_gain,
|
|
||||||
temperature_celsius,humidity_percent,avg_hr_percent,kcal_per_km,rpe,source,notes,
|
|
||||||
training_type_id,training_category,training_subcategory,created)
|
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
|
|
||||||
(
|
|
||||||
eid,
|
|
||||||
profile_id,
|
|
||||||
d["date"],
|
|
||||||
d["start_time"],
|
|
||||||
d["end_time"],
|
|
||||||
d["activity_type"],
|
|
||||||
d["duration_min"],
|
|
||||||
d["kcal_active"],
|
|
||||||
d["kcal_resting"],
|
|
||||||
d["hr_avg"],
|
|
||||||
d["hr_max"],
|
|
||||||
d.get("hr_min"),
|
|
||||||
d["distance_km"],
|
|
||||||
d.get("pace_min_per_km"),
|
|
||||||
d.get("cadence"),
|
|
||||||
d.get("avg_power"),
|
|
||||||
d.get("elevation_gain"),
|
|
||||||
d.get("temperature_celsius"),
|
|
||||||
d.get("humidity_percent"),
|
|
||||||
d.get("avg_hr_percent"),
|
|
||||||
d.get("kcal_per_km"),
|
|
||||||
d["rpe"],
|
|
||||||
d["source"],
|
|
||||||
d["notes"],
|
|
||||||
d.get("training_type_id"),
|
|
||||||
d.get("training_category"),
|
|
||||||
d.get("training_subcategory"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def update_activity_from_entry(cur, profile_id: str, eid: str, e: ActivityEntry) -> None:
|
|
||||||
"""Volles UPDATE aus ActivityEntry (REST PUT)."""
|
|
||||||
d = e.model_dump()
|
|
||||||
cur.execute(
|
|
||||||
f"UPDATE activity_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s",
|
|
||||||
list(d.values()) + [eid, profile_id],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def update_activity_columns(
|
|
||||||
cur,
|
|
||||||
profile_id: str,
|
|
||||||
eid: str,
|
|
||||||
updates: Dict[str, Any],
|
|
||||||
) -> None:
|
|
||||||
"""Teil-UPDATE nur für übergebene Spalten (Importe)."""
|
|
||||||
if not updates:
|
|
||||||
return
|
|
||||||
cols = [f"{k} = %s" for k in updates]
|
|
||||||
vals = list(updates.values()) + [eid, profile_id]
|
|
||||||
cur.execute(
|
|
||||||
f"UPDATE activity_log SET {', '.join(cols)} WHERE id = %s AND profile_id = %s",
|
|
||||||
vals,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def insert_activity_csv_minimal(
|
|
||||||
cur,
|
|
||||||
profile_id: str,
|
|
||||||
eid: str,
|
|
||||||
*,
|
|
||||||
date_iso: str,
|
|
||||||
start_time: Any,
|
|
||||||
end_time: Any,
|
|
||||||
activity_type: str,
|
|
||||||
duration_min: Any,
|
|
||||||
kcal_active: Any,
|
|
||||||
kcal_resting: Any,
|
|
||||||
hr_avg: Any,
|
|
||||||
hr_max: Any,
|
|
||||||
distance_km: Any,
|
|
||||||
training_type_id: Any,
|
|
||||||
training_category: Any,
|
|
||||||
training_subcategory: Any,
|
|
||||||
source: str,
|
|
||||||
) -> None:
|
|
||||||
"""INSERT minimale activity_log-Zeile (Universal-CSV)."""
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO activity_log (
|
|
||||||
id, profile_id, date, start_time, end_time, activity_type, duration_min,
|
|
||||||
kcal_active, kcal_resting, hr_avg, hr_max, distance_km,
|
|
||||||
source, training_type_id, training_category, training_subcategory, created
|
|
||||||
)
|
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
eid,
|
|
||||||
profile_id,
|
|
||||||
date_iso,
|
|
||||||
start_time,
|
|
||||||
end_time,
|
|
||||||
activity_type,
|
|
||||||
duration_min,
|
|
||||||
kcal_active,
|
|
||||||
kcal_resting,
|
|
||||||
hr_avg,
|
|
||||||
hr_max,
|
|
||||||
distance_km,
|
|
||||||
source,
|
|
||||||
training_type_id,
|
|
||||||
training_category,
|
|
||||||
training_subcategory,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_activity_post_write_hooks(cur, profile_id: str, eid: str) -> None:
|
|
||||||
"""Auto-Eval (falls aktiv). Kein Spalte→EAV-Sync: Lesepfad merge_column_backed_and_eav_metrics."""
|
|
||||||
if _EVALUATION_AVAILABLE and _evaluate_and_save_activity:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, profile_id, date, training_type_id, duration_min,
|
|
||||||
hr_avg, hr_max, distance_km, kcal_active, kcal_resting,
|
|
||||||
rpe, pace_min_per_km, cadence, elevation_gain
|
|
||||||
FROM activity_log
|
|
||||||
WHERE id = %s
|
|
||||||
""",
|
|
||||||
(eid,),
|
|
||||||
)
|
|
||||||
activity_row = cur.fetchone()
|
|
||||||
if activity_row:
|
|
||||||
activity_dict = dict(activity_row)
|
|
||||||
training_type_id = activity_dict.get("training_type_id")
|
|
||||||
if training_type_id:
|
|
||||||
try:
|
|
||||||
_evaluate_and_save_activity(cur, eid, activity_dict, training_type_id, profile_id)
|
|
||||||
except Exception as eval_error:
|
|
||||||
logger.error("[AUTO-EVAL] activity %s: %s", eid, eval_error)
|
|
||||||
|
|
||||||
|
|
||||||
def run_activity_post_write_hooks_import(
|
|
||||||
cur,
|
|
||||||
profile_id: str,
|
|
||||||
eid: str,
|
|
||||||
*,
|
|
||||||
workout_date: str,
|
|
||||||
training_type_id: Optional[int],
|
|
||||||
duration_min: Any,
|
|
||||||
hr_avg: Any,
|
|
||||||
hr_max: Any,
|
|
||||||
distance_km: Any,
|
|
||||||
kcal_active: Any,
|
|
||||||
kcal_resting: Any,
|
|
||||||
) -> None:
|
|
||||||
"""Auto-Eval nach Import. Kein Spalte→EAV-Sync (siehe run_activity_post_write_hooks)."""
|
|
||||||
if _EVALUATION_AVAILABLE and training_type_id and _evaluate_and_save_activity:
|
|
||||||
try:
|
|
||||||
activity_dict = {
|
|
||||||
"id": eid,
|
|
||||||
"profile_id": profile_id,
|
|
||||||
"date": workout_date,
|
|
||||||
"training_type_id": training_type_id,
|
|
||||||
"duration_min": duration_min,
|
|
||||||
"hr_avg": hr_avg,
|
|
||||||
"hr_max": hr_max,
|
|
||||||
"distance_km": distance_km,
|
|
||||||
"kcal_active": kcal_active,
|
|
||||||
"kcal_resting": kcal_resting,
|
|
||||||
"rpe": None,
|
|
||||||
"pace_min_per_km": None,
|
|
||||||
"cadence": None,
|
|
||||||
"elevation_gain": None,
|
|
||||||
}
|
|
||||||
_evaluate_and_save_activity(cur, eid, activity_dict, training_type_id, profile_id)
|
|
||||||
except Exception as eval_err:
|
|
||||||
logger.warning("[activity import] Auto-Eval fehlgeschlagen: %s", eval_err)
|
|
||||||
|
|
||||||
|
|
||||||
def merge_activity_csv_module_fields(
|
|
||||||
cur,
|
|
||||||
static_fields: Dict[str, Any],
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
activity-Modul für CSV: statische Registry-Felder + alle aktiven training_parameters.
|
|
||||||
|
|
||||||
Gleiche Quelle wie get_mappable_activity_field_catalog.training_parameters — erscheint
|
|
||||||
in Admin-CSV-Ziel-Liste, Validierung und Import-Zeilenaggregation.
|
|
||||||
"""
|
|
||||||
out = dict(static_fields)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT key, data_type, unit, name_de
|
|
||||||
FROM training_parameters
|
|
||||||
WHERE is_active = true
|
|
||||||
ORDER BY key
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
for row in cur.fetchall():
|
|
||||||
k = row["key"]
|
|
||||||
if k in out:
|
|
||||||
continue
|
|
||||||
dt = row["data_type"] or "float"
|
|
||||||
if dt == "integer":
|
|
||||||
mtype = "int"
|
|
||||||
elif dt == "float":
|
|
||||||
mtype = "float"
|
|
||||||
elif dt == "boolean":
|
|
||||||
mtype = "string"
|
|
||||||
else:
|
|
||||||
mtype = "string"
|
|
||||||
spec: Dict[str, Any] = {
|
|
||||||
"type": mtype,
|
|
||||||
"required": False,
|
|
||||||
"from_training_parameter": True,
|
|
||||||
}
|
|
||||||
if row.get("unit"):
|
|
||||||
spec["unit"] = row["unit"]
|
|
||||||
if row.get("name_de"):
|
|
||||||
spec["label_de"] = row["name_de"]
|
|
||||||
out[k] = spec
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def get_mappable_activity_field_catalog(cur, profile_id: str) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Felder für konfigurierbare Import-Mappings.
|
|
||||||
|
|
||||||
core_fields: module_registry „activity“ → activity_log.
|
|
||||||
training_parameters: alle aktiven Parameter (global); bei Anwendung auf eine Session
|
|
||||||
werden Keys verworfen, die nicht in resolve_activity_attribute_schema(Kategorie/Typ) liegen.
|
|
||||||
|
|
||||||
profile_id: reserviert für künftige Profil-Filter.
|
|
||||||
"""
|
|
||||||
_ = profile_id
|
|
||||||
mod = get_module_definition("activity") or {}
|
|
||||||
fields = mod.get("fields") or {}
|
|
||||||
core_fields: List[Dict[str, Any]] = []
|
|
||||||
for key, spec in fields.items():
|
|
||||||
s = spec or {}
|
|
||||||
core_fields.append(
|
|
||||||
{
|
|
||||||
"key": key,
|
|
||||||
"target": "activity_log",
|
|
||||||
"column": key,
|
|
||||||
"data_type": s.get("type", "string"),
|
|
||||||
"required": bool(s.get("required")),
|
|
||||||
"unit": s.get("unit"),
|
|
||||||
"label_de": s.get("label_de") or key,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
core_fields.sort(key=lambda x: x["key"])
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, key, name_de, name_en, category AS param_category,
|
|
||||||
data_type, unit, source_field
|
|
||||||
FROM training_parameters
|
|
||||||
WHERE is_active = true
|
|
||||||
ORDER BY key
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
parameters = [dict(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"core_fields": core_fields,
|
|
||||||
"training_parameters": parameters,
|
|
||||||
"notes": (
|
|
||||||
"training_parameters listet alle aktiven Keys. Pro Session werden Werte ignoriert, "
|
|
||||||
"die für deren training_category/training_type_id nicht im Attribut-Schema vorkommen."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def new_activity_id() -> str:
|
|
||||||
return str(uuid.uuid4())
|
|
||||||
|
|
@ -1,779 +0,0 @@
|
||||||
"""
|
|
||||||
Activity session metrics (EAV) and resolved attribute schema — Layer 1.
|
|
||||||
|
|
||||||
See: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from decimal import Decimal
|
|
||||||
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
|
||||||
|
|
||||||
from data_layer.activity_data_canon import (
|
|
||||||
ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM,
|
|
||||||
ACTIVITY_MODULE_REGISTRY_FIELD_KEYS,
|
|
||||||
)
|
|
||||||
from data_layer.prompt_output_compact import normalize_prompt_number
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_metric_value_for_read(data_type: str, val: Any) -> Any:
|
|
||||||
"""Lesepfad (Layer 1): keine unnötig langen Float-Strings für KI/UI (Issue 53 / Platzhalter)."""
|
|
||||||
if val is None:
|
|
||||||
return None
|
|
||||||
dt = (data_type or "").strip().lower()
|
|
||||||
if dt == "string":
|
|
||||||
return normalize_prompt_number(val)
|
|
||||||
if dt == "boolean":
|
|
||||||
return bool(val)
|
|
||||||
if dt == "integer":
|
|
||||||
try:
|
|
||||||
if isinstance(val, bool):
|
|
||||||
return int(val)
|
|
||||||
return int(val)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return normalize_prompt_number(val)
|
|
||||||
if dt == "float":
|
|
||||||
return normalize_prompt_number(val)
|
|
||||||
return normalize_prompt_number(val)
|
|
||||||
|
|
||||||
# Diese Spalten nicht aus CSV-Parameter-Zuordnung überschreiben (kommen aus Typ-Mapping / System).
|
|
||||||
ACTIVITY_LOG_PATCH_FORBIDDEN = frozenset(
|
|
||||||
{
|
|
||||||
"id",
|
|
||||||
"profile_id",
|
|
||||||
"date",
|
|
||||||
"created",
|
|
||||||
"training_type_id",
|
|
||||||
"training_category",
|
|
||||||
"training_subcategory",
|
|
||||||
"source",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ActivitySessionMetricsError(Exception):
|
|
||||||
"""Raised by Layer 1; routers map to HTTP (404/400)."""
|
|
||||||
|
|
||||||
def __init__(self, status_code: int, detail: str):
|
|
||||||
self.status_code = status_code
|
|
||||||
self.detail = detail
|
|
||||||
super().__init__(detail)
|
|
||||||
|
|
||||||
|
|
||||||
def _effective_training_category(
|
|
||||||
cur, training_category: Optional[str], training_type_id: Optional[int]
|
|
||||||
) -> Optional[str]:
|
|
||||||
if training_category:
|
|
||||||
return training_category.strip() or None
|
|
||||||
if training_type_id is None:
|
|
||||||
return None
|
|
||||||
cur.execute("SELECT category FROM training_types WHERE id = %s", (training_type_id,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
if row and row.get("category"):
|
|
||||||
return row["category"]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def merge_parameter_schema_rows(
|
|
||||||
category_rows: Sequence[Dict[str, Any]],
|
|
||||||
type_rows: Sequence[Dict[str, Any]],
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Pure merge: category assignments + type assignments → sorted schema list.
|
|
||||||
Row shapes match SELECTs in resolve_activity_attribute_schema (cat_sort / typ_* aliases).
|
|
||||||
"""
|
|
||||||
merged: Dict[int, Dict[str, Any]] = {}
|
|
||||||
|
|
||||||
for r in category_rows:
|
|
||||||
pid = r["training_parameter_id"]
|
|
||||||
merged[pid] = {
|
|
||||||
"training_parameter_id": pid,
|
|
||||||
"key": r["key"],
|
|
||||||
"name_de": r["name_de"],
|
|
||||||
"name_en": r["name_en"],
|
|
||||||
"description_de": r.get("description_de"),
|
|
||||||
"description_en": r.get("description_en"),
|
|
||||||
"param_category": r["param_category"],
|
|
||||||
"data_type": r["data_type"],
|
|
||||||
"unit": r["unit"],
|
|
||||||
"validation_rules": r["validation_rules"] or {},
|
|
||||||
"source_field": r["source_field"],
|
|
||||||
"sort_order": r["cat_sort"],
|
|
||||||
"required": bool(r["cat_required"]),
|
|
||||||
"ui_group": r["cat_ui_group"],
|
|
||||||
}
|
|
||||||
|
|
||||||
for r in type_rows:
|
|
||||||
pid = r["training_parameter_id"]
|
|
||||||
base = merged.get(pid)
|
|
||||||
if base is None:
|
|
||||||
merged[pid] = {
|
|
||||||
"training_parameter_id": pid,
|
|
||||||
"key": r["key"],
|
|
||||||
"name_de": r["name_de"],
|
|
||||||
"name_en": r["name_en"],
|
|
||||||
"description_de": r.get("description_de"),
|
|
||||||
"description_en": r.get("description_en"),
|
|
||||||
"param_category": r["param_category"],
|
|
||||||
"data_type": r["data_type"],
|
|
||||||
"unit": r["unit"],
|
|
||||||
"validation_rules": r["validation_rules"] or {},
|
|
||||||
"source_field": r["source_field"],
|
|
||||||
"sort_order": r["typ_sort"] if r["typ_sort"] is not None else 0,
|
|
||||||
"required": bool(r["typ_required"]) if r["typ_required"] is not None else False,
|
|
||||||
"ui_group": r["typ_ui_group"],
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
if r["typ_sort"] is not None:
|
|
||||||
base["sort_order"] = r["typ_sort"]
|
|
||||||
if r["typ_required"] is not None:
|
|
||||||
base["required"] = bool(r["typ_required"])
|
|
||||||
if r["typ_ui_group"] is not None:
|
|
||||||
base["ui_group"] = r["typ_ui_group"]
|
|
||||||
|
|
||||||
out = list(merged.values())
|
|
||||||
out.sort(key=lambda x: (x["sort_order"], x["key"]))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_activity_attribute_schema(
|
|
||||||
cur,
|
|
||||||
training_category: Optional[str],
|
|
||||||
training_type_id: Optional[int],
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Merged parameter definitions for UI / validation (category base + type overrides/additions).
|
|
||||||
Sorted by sort_order, then key.
|
|
||||||
"""
|
|
||||||
cat = _effective_training_category(cur, training_category, training_type_id)
|
|
||||||
category_rows: List[Dict[str, Any]] = []
|
|
||||||
type_rows: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
if cat:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
tcp.training_parameter_id,
|
|
||||||
tcp.sort_order AS cat_sort,
|
|
||||||
tcp.required AS cat_required,
|
|
||||||
tcp.ui_group AS cat_ui_group,
|
|
||||||
tp.key, tp.name_de, tp.name_en,
|
|
||||||
tp.description_de, tp.description_en,
|
|
||||||
tp.category AS param_category,
|
|
||||||
tp.data_type, tp.unit, tp.validation_rules, tp.source_field
|
|
||||||
FROM training_category_parameter tcp
|
|
||||||
JOIN training_parameters tp ON tp.id = tcp.training_parameter_id
|
|
||||||
WHERE tcp.training_category = %s AND tp.is_active = true
|
|
||||||
""",
|
|
||||||
(cat,),
|
|
||||||
)
|
|
||||||
category_rows = list(cur.fetchall())
|
|
||||||
|
|
||||||
if training_type_id is not None:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
ttp.training_parameter_id,
|
|
||||||
ttp.sort_order AS typ_sort,
|
|
||||||
ttp.required AS typ_required,
|
|
||||||
ttp.ui_group AS typ_ui_group,
|
|
||||||
tp.key, tp.name_de, tp.name_en,
|
|
||||||
tp.description_de, tp.description_en,
|
|
||||||
tp.category AS param_category,
|
|
||||||
tp.data_type, tp.unit, tp.validation_rules, tp.source_field
|
|
||||||
FROM training_type_parameter ttp
|
|
||||||
JOIN training_parameters tp ON tp.id = ttp.training_parameter_id
|
|
||||||
WHERE ttp.training_type_id = %s AND tp.is_active = true
|
|
||||||
""",
|
|
||||||
(training_type_id,),
|
|
||||||
)
|
|
||||||
type_rows = list(cur.fetchall())
|
|
||||||
|
|
||||||
return merge_parameter_schema_rows(category_rows, type_rows)
|
|
||||||
|
|
||||||
|
|
||||||
def _metric_human_labels(schema_row: Mapping[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""Bezeichnung + Kurzbeschreibung aus training_parameters (KI / Export)."""
|
|
||||||
return {
|
|
||||||
"name_de": schema_row.get("name_de"),
|
|
||||||
"name_en": schema_row.get("name_en"),
|
|
||||||
"description_de": schema_row.get("description_de"),
|
|
||||||
"description_en": schema_row.get("description_en"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _validation_rules_dict(raw: Any) -> Dict[str, Any]:
|
|
||||||
if isinstance(raw, dict):
|
|
||||||
return raw
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_single_value(data_type: str, value: Any, rules: Dict[str, Any]) -> None:
|
|
||||||
if data_type == "integer":
|
|
||||||
if not isinstance(value, int) or isinstance(value, bool):
|
|
||||||
raise ActivitySessionMetricsError(400, f"Erwartet integer, erhalten: {type(value).__name__}")
|
|
||||||
if "min" in rules and value < rules["min"]:
|
|
||||||
raise ActivitySessionMetricsError(400, f"Wert unter min ({rules['min']})")
|
|
||||||
if "max" in rules and value > rules["max"]:
|
|
||||||
raise ActivitySessionMetricsError(400, f"Wert über max ({rules['max']})")
|
|
||||||
elif data_type == "float":
|
|
||||||
if isinstance(value, bool) or not isinstance(value, (int, float, Decimal)):
|
|
||||||
raise ActivitySessionMetricsError(400, f"Erwartet Zahl, erhalten: {type(value).__name__}")
|
|
||||||
v = float(value)
|
|
||||||
if "min" in rules and v < float(rules["min"]):
|
|
||||||
raise ActivitySessionMetricsError(400, f"Wert unter min ({rules['min']})")
|
|
||||||
if "max" in rules and v > float(rules["max"]):
|
|
||||||
raise ActivitySessionMetricsError(400, f"Wert über max ({rules['max']})")
|
|
||||||
elif data_type == "string":
|
|
||||||
if not isinstance(value, str):
|
|
||||||
raise ActivitySessionMetricsError(400, f"Erwartet string, erhalten: {type(value).__name__}")
|
|
||||||
if rules.get("not_empty") and not value.strip():
|
|
||||||
raise ActivitySessionMetricsError(400, "Leerer String nicht erlaubt")
|
|
||||||
if "max_length" in rules and len(value) > int(rules["max_length"]):
|
|
||||||
raise ActivitySessionMetricsError(400, f"String zu lang (max {rules['max_length']})")
|
|
||||||
allowed = rules.get("allowed_values")
|
|
||||||
if allowed and value not in allowed:
|
|
||||||
raise ActivitySessionMetricsError(400, "Wert nicht in erlaubter Menge")
|
|
||||||
elif data_type == "boolean":
|
|
||||||
if not isinstance(value, bool):
|
|
||||||
raise ActivitySessionMetricsError(400, f"Erwartet boolean, erhalten: {type(value).__name__}")
|
|
||||||
else:
|
|
||||||
raise ActivitySessionMetricsError(400, f"Unbekannter data_type: {data_type}")
|
|
||||||
|
|
||||||
|
|
||||||
def _row_value_tuple(data_type: str, value: Any) -> tuple:
|
|
||||||
if data_type == "integer":
|
|
||||||
return (None, int(value), None, None)
|
|
||||||
if data_type == "float":
|
|
||||||
return (float(value), None, None, None)
|
|
||||||
if data_type == "string":
|
|
||||||
return (None, None, str(value), None)
|
|
||||||
if data_type == "boolean":
|
|
||||||
return (None, None, None, bool(value))
|
|
||||||
raise ValueError(data_type)
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce_raw_value_for_parameter(data_type: str, raw: Any) -> Any:
|
|
||||||
"""Wert aus activity_log-Spalte in den Typ bringen, den training_parameters.data_type erwartet."""
|
|
||||||
if data_type == "integer":
|
|
||||||
if isinstance(raw, bool):
|
|
||||||
raise TypeError("boolean nicht als integer erlaubt")
|
|
||||||
if isinstance(raw, str):
|
|
||||||
s = raw.strip().replace(",", ".")
|
|
||||||
return int(round(float(s)))
|
|
||||||
return int(round(float(raw)))
|
|
||||||
if data_type == "float":
|
|
||||||
if isinstance(raw, str):
|
|
||||||
s = raw.strip().replace(",", ".")
|
|
||||||
return float(s)
|
|
||||||
return float(raw)
|
|
||||||
if data_type == "string":
|
|
||||||
return str(raw) if raw is not None else ""
|
|
||||||
if data_type == "boolean":
|
|
||||||
if isinstance(raw, bool):
|
|
||||||
return raw
|
|
||||||
s = str(raw).strip().lower()
|
|
||||||
if s in ("true", "1", "t", "yes"):
|
|
||||||
return True
|
|
||||||
if s in ("false", "0", "f", "no", ""):
|
|
||||||
return False
|
|
||||||
raise TypeError(f"boolean-Koercion nicht möglich: {raw!r}")
|
|
||||||
raise ValueError(data_type)
|
|
||||||
|
|
||||||
|
|
||||||
def upsert_session_metrics_from_csv_mapped(
|
|
||||||
cur,
|
|
||||||
profile_id: str,
|
|
||||||
activity_log_id: str,
|
|
||||||
mapped: Mapping[str, Any],
|
|
||||||
training_category: Optional[str],
|
|
||||||
training_type_id: Optional[int],
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
EAV für Trainingsparameter aus CSV.
|
|
||||||
|
|
||||||
Es werden nur Parameter geschrieben, die in ``resolve_activity_attribute_schema`` (Kategorie +
|
|
||||||
Trainingstyp) vorkommen. CSV-Spalten-Mappings sind import-spezifisch und definieren **nicht** das
|
|
||||||
UI-/Auswertungs-Schema — fehlende tcp/ttp-Zuordnung bedeutet: kein EAV für diesen Key (Werte ggf.
|
|
||||||
nur in ``activity_log``-Kernfeldern).
|
|
||||||
|
|
||||||
Kernfelder schreibt der Executor nach ``activity_log``; hier keine EAV-Zeilen für Registry-Keys.
|
|
||||||
|
|
||||||
Hat ein Parameter ``source_field`` (Semantik aus ``activity_log``), wird EAV nur dann **nicht**
|
|
||||||
geschrieben, wenn diese Spalte nach dem Import bereits befüllt ist — sonst gäbe es doppelte
|
|
||||||
Speicherung und der Merge würde ohnehin die Spalte bevorzugen. Ist die Spalte leer (z. B. Feld
|
|
||||||
nur noch über EAV / Custom-Mapping, ohne Registry-Patch), schreibt der Import den Wert aus
|
|
||||||
``mapped`` nach EAV — analog zum Lesepfad (Spalte zuerst, sonst EAV).
|
|
||||||
"""
|
|
||||||
cur.execute("SELECT * FROM activity_log WHERE id = %s", (activity_log_id,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row or str(row["profile_id"]) != str(profile_id):
|
|
||||||
return
|
|
||||||
header = dict(row)
|
|
||||||
schema = resolve_activity_attribute_schema(cur, training_category, training_type_id)
|
|
||||||
for spec in schema:
|
|
||||||
pkey = spec["key"]
|
|
||||||
if pkey not in mapped:
|
|
||||||
continue
|
|
||||||
raw = mapped[pkey]
|
|
||||||
if raw is None or raw == "":
|
|
||||||
continue
|
|
||||||
if pkey in ACTIVITY_MODULE_REGISTRY_FIELD_KEYS:
|
|
||||||
continue
|
|
||||||
sf_raw = spec.get("source_field")
|
|
||||||
if sf_raw is not None and str(sf_raw).strip():
|
|
||||||
col = str(sf_raw).strip()
|
|
||||||
if col in header and header[col] is not None:
|
|
||||||
continue
|
|
||||||
tid = spec["training_parameter_id"]
|
|
||||||
dt = spec["data_type"]
|
|
||||||
rules = _validation_rules_dict(spec["validation_rules"])
|
|
||||||
try:
|
|
||||||
coerced = _coerce_raw_value_for_parameter(dt, raw)
|
|
||||||
_validate_single_value(dt, coerced, rules)
|
|
||||||
except (ActivitySessionMetricsError, TypeError, ValueError) as ex:
|
|
||||||
logger.warning("CSV EAV skipped %s: %s", pkey, ex)
|
|
||||||
continue
|
|
||||||
vn, vi, vt, vb = _row_value_tuple(dt, coerced)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, NOW())
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id)
|
|
||||||
DO UPDATE SET
|
|
||||||
value_num = EXCLUDED.value_num,
|
|
||||||
value_int = EXCLUDED.value_int,
|
|
||||||
value_text = EXCLUDED.value_text,
|
|
||||||
value_bool = EXCLUDED.value_bool,
|
|
||||||
updated_at = NOW()
|
|
||||||
""",
|
|
||||||
(activity_log_id, tid, vn, vi, vt, vb),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def merge_column_backed_and_eav_metrics(
|
|
||||||
header: Mapping[str, Any],
|
|
||||||
schema: Sequence[Dict[str, Any]],
|
|
||||||
eav_metrics: Sequence[Dict[str, Any]],
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Effektive Metrikliste **nur** für Parameter aus ``schema`` (Kategorie + Trainingstyp / tcp+ttp).
|
|
||||||
|
|
||||||
Kanon beim Lesen: **activity_log** schlägt EAV, sobald ein passender Spaltenwert existiert und
|
|
||||||
koerzierbar ist — in dieser Reihenfolge:
|
|
||||||
|
|
||||||
1. ``source_field`` → Spalte
|
|
||||||
2. Parameter-Key = Registry-Kernfeld (``ACTIVITY_MODULE_REGISTRY_FIELD_KEYS``) → gleichnamige Spalte
|
|
||||||
3. EAV-primäre Keys → Legacy-Spalte laut ``ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM``
|
|
||||||
4. sonst EAV
|
|
||||||
|
|
||||||
EAV-Zeilen zu Parametern, die nicht im Schema sind, werden nicht ausgegeben.
|
|
||||||
"""
|
|
||||||
eav_by_key = {m["key"]: m for m in eav_metrics}
|
|
||||||
merged: List[Dict[str, Any]] = []
|
|
||||||
keys_handled: set[str] = set()
|
|
||||||
|
|
||||||
for s in schema:
|
|
||||||
k = s["key"]
|
|
||||||
tid = s["training_parameter_id"]
|
|
||||||
dt = s["data_type"]
|
|
||||||
unit = s.get("unit")
|
|
||||||
sf = s.get("source_field")
|
|
||||||
|
|
||||||
used_column = False
|
|
||||||
if sf and isinstance(sf, str) and str(sf).strip():
|
|
||||||
col = str(sf).strip()
|
|
||||||
if col in header and header[col] is not None:
|
|
||||||
try:
|
|
||||||
val = _coerce_raw_value_for_parameter(dt, header[col])
|
|
||||||
merged.append(
|
|
||||||
{
|
|
||||||
"training_parameter_id": tid,
|
|
||||||
"key": k,
|
|
||||||
"data_type": dt,
|
|
||||||
"unit": unit,
|
|
||||||
"value": val,
|
|
||||||
**_metric_human_labels(s),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
used_column = True
|
|
||||||
keys_handled.add(k)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
if used_column:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if k in ACTIVITY_MODULE_REGISTRY_FIELD_KEYS and k in header and header[k] is not None:
|
|
||||||
try:
|
|
||||||
val = _coerce_raw_value_for_parameter(dt, header[k])
|
|
||||||
merged.append(
|
|
||||||
{
|
|
||||||
"training_parameter_id": tid,
|
|
||||||
"key": k,
|
|
||||||
"data_type": dt,
|
|
||||||
"unit": unit,
|
|
||||||
"value": val,
|
|
||||||
**_metric_human_labels(s),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
keys_handled.add(k)
|
|
||||||
continue
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
legacy_col = ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM.get(k)
|
|
||||||
if legacy_col and legacy_col in header and header[legacy_col] is not None:
|
|
||||||
try:
|
|
||||||
val = _coerce_raw_value_for_parameter(dt, header[legacy_col])
|
|
||||||
merged.append(
|
|
||||||
{
|
|
||||||
"training_parameter_id": tid,
|
|
||||||
"key": k,
|
|
||||||
"data_type": dt,
|
|
||||||
"unit": unit,
|
|
||||||
"value": val,
|
|
||||||
**_metric_human_labels(s),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
keys_handled.add(k)
|
|
||||||
continue
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
if k in eav_by_key:
|
|
||||||
row = dict(eav_by_key[k])
|
|
||||||
row.update(_metric_human_labels(s))
|
|
||||||
merged.append(row)
|
|
||||||
keys_handled.add(k)
|
|
||||||
|
|
||||||
merged.sort(key=lambda x: x["key"])
|
|
||||||
for m in merged:
|
|
||||||
m["value"] = _normalize_metric_value_for_read(m.get("data_type") or "", m.get("value"))
|
|
||||||
return merged
|
|
||||||
|
|
||||||
|
|
||||||
def sync_column_backed_session_metrics(cur, profile_id: str, activity_log_id: str) -> None:
|
|
||||||
"""
|
|
||||||
[Veraltet / nicht mehr in Schreibpfaden aufgerufen]
|
|
||||||
|
|
||||||
Früher: EAV spiegelte activity_log-Spalten für Parameter mit source_field.
|
|
||||||
Kanon: Spaltenwerte werden bei merge_column_backed_and_eav_metrics beim Lesen berücksichtigt; keine
|
|
||||||
doppelte Speicherung. Funktion bleibt für optionale Admin-/Reparatur-Skripte.
|
|
||||||
"""
|
|
||||||
cur.execute("SELECT * FROM activity_log WHERE id = %s", (activity_log_id,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row or str(row["profile_id"]) != str(profile_id):
|
|
||||||
return
|
|
||||||
header = dict(row)
|
|
||||||
schema = resolve_activity_attribute_schema(
|
|
||||||
cur, header.get("training_category"), header.get("training_type_id")
|
|
||||||
)
|
|
||||||
for spec in schema:
|
|
||||||
sf = spec.get("source_field")
|
|
||||||
if sf is None or (isinstance(sf, str) and not str(sf).strip()):
|
|
||||||
continue
|
|
||||||
col = str(sf).strip()
|
|
||||||
if col not in header:
|
|
||||||
continue
|
|
||||||
raw = header[col]
|
|
||||||
tid = spec["training_parameter_id"]
|
|
||||||
dt = spec["data_type"]
|
|
||||||
rules = _validation_rules_dict(spec["validation_rules"])
|
|
||||||
|
|
||||||
if raw is None:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
DELETE FROM activity_session_metrics
|
|
||||||
WHERE activity_log_id = %s AND training_parameter_id = %s
|
|
||||||
""",
|
|
||||||
(activity_log_id, tid),
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
coerced = _coerce_raw_value_for_parameter(dt, raw)
|
|
||||||
_validate_single_value(dt, coerced, rules)
|
|
||||||
except (ActivitySessionMetricsError, TypeError, ValueError) as ex:
|
|
||||||
logger.warning(
|
|
||||||
"sync_column_backed_session_metrics: überspringe %s (Spalte %s): %s",
|
|
||||||
spec.get("key"),
|
|
||||||
col,
|
|
||||||
ex,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
vn, vi, vt, vb = _row_value_tuple(dt, coerced)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, NOW())
|
|
||||||
ON CONFLICT (activity_log_id, training_parameter_id)
|
|
||||||
DO UPDATE SET
|
|
||||||
value_num = EXCLUDED.value_num,
|
|
||||||
value_int = EXCLUDED.value_int,
|
|
||||||
value_text = EXCLUDED.value_text,
|
|
||||||
value_bool = EXCLUDED.value_bool,
|
|
||||||
updated_at = NOW()
|
|
||||||
""",
|
|
||||||
(activity_log_id, tid, vn, vi, vt, vb),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_activity_session_metrics(cur, activity_log_id: str) -> List[Dict[str, Any]]:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
m.id,
|
|
||||||
m.activity_log_id,
|
|
||||||
m.training_parameter_id,
|
|
||||||
m.value_num,
|
|
||||||
m.value_int,
|
|
||||||
m.value_text,
|
|
||||||
m.value_bool,
|
|
||||||
tp.key,
|
|
||||||
tp.data_type,
|
|
||||||
tp.unit
|
|
||||||
FROM activity_session_metrics m
|
|
||||||
JOIN training_parameters tp ON tp.id = m.training_parameter_id
|
|
||||||
WHERE m.activity_log_id = %s
|
|
||||||
ORDER BY tp.key
|
|
||||||
""",
|
|
||||||
(activity_log_id,),
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
out: List[Dict[str, Any]] = []
|
|
||||||
for r in rows:
|
|
||||||
dt = r["data_type"]
|
|
||||||
if dt == "integer":
|
|
||||||
val = int(r["value_int"]) if r["value_int"] is not None else None
|
|
||||||
elif dt == "float":
|
|
||||||
val = float(r["value_num"]) if r["value_num"] is not None else None
|
|
||||||
elif dt == "string":
|
|
||||||
val = r["value_text"]
|
|
||||||
else:
|
|
||||||
val = r["value_bool"]
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"training_parameter_id": r["training_parameter_id"],
|
|
||||||
"key": r["key"],
|
|
||||||
"data_type": dt,
|
|
||||||
"unit": r["unit"],
|
|
||||||
"value": val,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def replace_activity_session_metrics(
|
|
||||||
cur,
|
|
||||||
profile_id: str,
|
|
||||||
activity_log_id: str,
|
|
||||||
metrics: Sequence[Dict[str, Any]],
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Full replace of EAV rows for this session. metrics: [{ "parameter_key": str, "value": ... }, ...]
|
|
||||||
"""
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, profile_id, training_category, training_type_id
|
|
||||||
FROM activity_log WHERE id = %s
|
|
||||||
""",
|
|
||||||
(activity_log_id,),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row or str(row["profile_id"]) != str(profile_id):
|
|
||||||
raise ActivitySessionMetricsError(404, "Aktivität nicht gefunden")
|
|
||||||
|
|
||||||
schema = resolve_activity_attribute_schema(
|
|
||||||
cur, row.get("training_category"), row.get("training_type_id")
|
|
||||||
)
|
|
||||||
by_key = {s["key"]: s for s in schema}
|
|
||||||
payload_by_key: Dict[str, Dict[str, Any]] = {}
|
|
||||||
for item in metrics:
|
|
||||||
raw_k = item.get("parameter_key")
|
|
||||||
if raw_k is None or not str(raw_k).strip():
|
|
||||||
raise ActivitySessionMetricsError(400, "parameter_key fehlt")
|
|
||||||
k = str(raw_k).strip()
|
|
||||||
if k not in by_key:
|
|
||||||
raise ActivitySessionMetricsError(400, f"Unbekannter oder nicht zugewiesener Parameter: {k}")
|
|
||||||
payload_by_key[k] = item
|
|
||||||
|
|
||||||
for s in schema:
|
|
||||||
if not s["required"]:
|
|
||||||
continue
|
|
||||||
itk = s["key"]
|
|
||||||
hit = payload_by_key.get(itk)
|
|
||||||
if hit is None or hit.get("value") is None:
|
|
||||||
raise ActivitySessionMetricsError(400, f"Pflichtfeld fehlt: {itk}")
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"DELETE FROM activity_session_metrics WHERE activity_log_id = %s",
|
|
||||||
(activity_log_id,),
|
|
||||||
)
|
|
||||||
|
|
||||||
for item in metrics:
|
|
||||||
k = str(item["parameter_key"]).strip()
|
|
||||||
spec = by_key[k]
|
|
||||||
val = item.get("value")
|
|
||||||
if val is None:
|
|
||||||
if spec["required"]:
|
|
||||||
raise ActivitySessionMetricsError(400, f"Pflichtfeld fehlt: {k}")
|
|
||||||
continue
|
|
||||||
rules = _validation_rules_dict(spec["validation_rules"])
|
|
||||||
_validate_single_value(spec["data_type"], val, rules)
|
|
||||||
vn, vi, vt, vb = _row_value_tuple(spec["data_type"], val)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO activity_session_metrics (
|
|
||||||
activity_log_id, training_parameter_id,
|
|
||||||
value_num, value_int, value_text, value_bool, updated_at
|
|
||||||
) VALUES (%s, %s, %s, %s, %s, %s, NOW())
|
|
||||||
""",
|
|
||||||
(activity_log_id, spec["training_parameter_id"], vn, vi, vt, vb),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Kein sync_column_backed nach PUT /metrics: der Request ist maßgeblich für EAV. Ein Spalten-Sync würde
|
|
||||||
# Werte aus nicht mitgeschriebenen activity_log-Spalten wieder verwerfen.
|
|
||||||
|
|
||||||
return fetch_activity_session_metrics(cur, activity_log_id)
|
|
||||||
|
|
||||||
|
|
||||||
def get_activity_session_logical_unit(
|
|
||||||
cur,
|
|
||||||
profile_id: str,
|
|
||||||
activity_log_id: str,
|
|
||||||
*,
|
|
||||||
use_form_training_context: bool = False,
|
|
||||||
form_training_category: Optional[str] = None,
|
|
||||||
form_training_type_id: Optional[int] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
cur.execute("SELECT * FROM activity_log WHERE id = %s", (activity_log_id,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row or str(row["profile_id"]) != str(profile_id):
|
|
||||||
raise ActivitySessionMetricsError(404, "Aktivität nicht gefunden")
|
|
||||||
|
|
||||||
header = dict(row)
|
|
||||||
if use_form_training_context:
|
|
||||||
cat = form_training_category
|
|
||||||
if isinstance(cat, str):
|
|
||||||
cat = cat.strip() or None
|
|
||||||
tid = form_training_type_id
|
|
||||||
else:
|
|
||||||
cat = header.get("training_category")
|
|
||||||
tid = header.get("training_type_id")
|
|
||||||
if tid is not None:
|
|
||||||
try:
|
|
||||||
tid = int(tid)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
tid = None
|
|
||||||
schema = resolve_activity_attribute_schema(cur, cat, tid)
|
|
||||||
metrics = fetch_activity_session_metrics(cur, activity_log_id)
|
|
||||||
merged_metrics = merge_column_backed_and_eav_metrics(header, schema, metrics)
|
|
||||||
return {
|
|
||||||
"header": header,
|
|
||||||
"schema": schema,
|
|
||||||
"metrics": merged_metrics,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def enrich_sessions_with_metrics(cur, sessions: List[Dict[str, Any]]) -> None:
|
|
||||||
"""
|
|
||||||
Mutates each session dict: adds key 'session_metrics' (list).
|
|
||||||
|
|
||||||
Kombiniert EAV mit activity_log-Spalten für Parameter mit source_field (kanonisch: Spalte),
|
|
||||||
analog zu get_activity_session_logical_unit – ohne doppelte EAV-Speicherung beim Import.
|
|
||||||
"""
|
|
||||||
if not sessions:
|
|
||||||
return
|
|
||||||
ids = [str(s["id"]) for s in sessions if s.get("id")]
|
|
||||||
if not ids:
|
|
||||||
return
|
|
||||||
ph = ",".join(["%s"] * len(ids))
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
f"SELECT * FROM activity_log WHERE id IN ({ph})",
|
|
||||||
ids,
|
|
||||||
)
|
|
||||||
headers_by_id: Dict[str, Dict[str, Any]] = {}
|
|
||||||
for r in cur.fetchall():
|
|
||||||
h = dict(r)
|
|
||||||
headers_by_id[str(h["id"])] = h
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
f"""
|
|
||||||
SELECT
|
|
||||||
m.activity_log_id,
|
|
||||||
m.training_parameter_id,
|
|
||||||
tp.key,
|
|
||||||
tp.data_type,
|
|
||||||
tp.unit,
|
|
||||||
m.value_num,
|
|
||||||
m.value_int,
|
|
||||||
m.value_text,
|
|
||||||
m.value_bool
|
|
||||||
FROM activity_session_metrics m
|
|
||||||
JOIN training_parameters tp ON tp.id = m.training_parameter_id
|
|
||||||
WHERE m.activity_log_id IN ({ph})
|
|
||||||
ORDER BY m.activity_log_id, tp.key
|
|
||||||
""",
|
|
||||||
ids,
|
|
||||||
)
|
|
||||||
by_act: Dict[str, List[Dict[str, Any]]] = {}
|
|
||||||
for r in cur.fetchall():
|
|
||||||
aid = str(r["activity_log_id"])
|
|
||||||
dt = r["data_type"]
|
|
||||||
if dt == "integer":
|
|
||||||
val = int(r["value_int"]) if r["value_int"] is not None else None
|
|
||||||
elif dt == "float":
|
|
||||||
val = float(r["value_num"]) if r["value_num"] is not None else None
|
|
||||||
elif dt == "string":
|
|
||||||
val = r["value_text"]
|
|
||||||
else:
|
|
||||||
val = r["value_bool"]
|
|
||||||
by_act.setdefault(aid, []).append(
|
|
||||||
{
|
|
||||||
"training_parameter_id": r["training_parameter_id"],
|
|
||||||
"key": r["key"],
|
|
||||||
"data_type": dt,
|
|
||||||
"unit": r["unit"],
|
|
||||||
"value": val,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
schema_cache: Dict[tuple[Any, Any], List[Dict[str, Any]]] = {}
|
|
||||||
|
|
||||||
def _schema(cat: Any, tid: Any) -> List[Dict[str, Any]]:
|
|
||||||
cache_key = (cat, tid)
|
|
||||||
if cache_key not in schema_cache:
|
|
||||||
schema_cache[cache_key] = resolve_activity_attribute_schema(cur, cat, tid)
|
|
||||||
return schema_cache[cache_key]
|
|
||||||
|
|
||||||
for s in sessions:
|
|
||||||
aid = str(s.get("id"))
|
|
||||||
header = headers_by_id.get(aid)
|
|
||||||
if not header:
|
|
||||||
s["session_metrics"] = []
|
|
||||||
continue
|
|
||||||
schema = _schema(header.get("training_category"), header.get("training_type_id"))
|
|
||||||
eav_list = by_act.get(aid, [])
|
|
||||||
merged = merge_column_backed_and_eav_metrics(header, schema, eav_list)
|
|
||||||
s["session_metrics"] = [
|
|
||||||
{
|
|
||||||
"key": m["key"],
|
|
||||||
"data_type": m["data_type"],
|
|
||||||
"unit": m["unit"],
|
|
||||||
"value": m["value"],
|
|
||||||
"name_de": m.get("name_de"),
|
|
||||||
"name_en": m.get("name_en"),
|
|
||||||
"description_de": m.get("description_de"),
|
|
||||||
"description_en": m.get("description_en"),
|
|
||||||
}
|
|
||||||
for m in merged
|
|
||||||
]
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
"""
|
|
||||||
Einheitliche Startzeit-Normalisierung für Aktivität (CSV, Legacy-Import, Dedupe).
|
|
||||||
|
|
||||||
Anbieter-agnostisch: beliebige ISO-/Export-Strings über dateutil.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import time as dt_time
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from dateutil import parser as du_parser
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_activity_start(start_raw: str) -> tuple[str, Optional[dt_time]]:
|
|
||||||
"""
|
|
||||||
Roh-String „Start“ aus Exporten → (YYYY-MM-DD, TIME ohne μs) für DB Dedupe/INSERT.
|
|
||||||
|
|
||||||
Leerer Input → ("", None). Fallback bei Parse-Fehler: erstes Datum aus ersten 10 Zeichen.
|
|
||||||
"""
|
|
||||||
s = (start_raw or "").strip()
|
|
||||||
if not s:
|
|
||||||
return "", None
|
|
||||||
try:
|
|
||||||
parsed = du_parser.parse(s, dayfirst=False)
|
|
||||||
t = parsed.time().replace(microsecond=0)
|
|
||||||
return parsed.date().isoformat(), t
|
|
||||||
except (ValueError, TypeError, OverflowError):
|
|
||||||
if len(s) >= 10:
|
|
||||||
return s[:10], None
|
|
||||||
return "", None
|
|
||||||
|
|
@ -1,330 +0,0 @@
|
||||||
"""
|
|
||||||
Body interpretation tiles for Layer 2b (Verlauf UI).
|
|
||||||
|
|
||||||
Logic aligned with frontend/src/utils/interpret.js (Körper-Kontext).
|
|
||||||
Uses the same thresholds; outputs structured tiles + related_placeholder_keys
|
|
||||||
for alignment with Layer 2a registry keys.
|
|
||||||
|
|
||||||
No formatting for KI — structured dicts only.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date, datetime
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_float(v: Any) -> Optional[float]:
|
|
||||||
if v is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return round(float(v), 4)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _calc_derived(m: Dict, height_cm: float) -> Dict[str, float]:
|
|
||||||
out: Dict[str, float] = {}
|
|
||||||
w = _safe_float(m.get("c_waist"))
|
|
||||||
h = _safe_float(m.get("c_hip"))
|
|
||||||
lean = _safe_float(m.get("lean_mass"))
|
|
||||||
if w and h:
|
|
||||||
out["whr"] = round(w / h, 2)
|
|
||||||
if w and height_cm:
|
|
||||||
out["whtr"] = round(w / height_cm, 2)
|
|
||||||
if lean and height_cm:
|
|
||||||
hm = height_cm / 100.0
|
|
||||||
out["ffmi"] = round(lean / (hm ** 2), 1)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _bf_status_ranges(sex: str) -> Dict[str, float]:
|
|
||||||
if sex == "f":
|
|
||||||
return {"essential": 14, "athletic": 21, "fit": 25, "avg": 32}
|
|
||||||
return {"essential": 6, "athletic": 14, "fit": 18, "avg": 25}
|
|
||||||
|
|
||||||
|
|
||||||
def get_body_interpretation_tiles(
|
|
||||||
measurement: Dict[str, Any],
|
|
||||||
profile: Dict[str, Any],
|
|
||||||
prev_measurement: Optional[Dict[str, Any]] = None,
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Returns interpretation tiles. Each tile includes related_placeholder_keys
|
|
||||||
pointing to Layer 2a registry keys fed by the same Layer-1 metrics.
|
|
||||||
"""
|
|
||||||
results: List[Dict[str, Any]] = []
|
|
||||||
sex = profile.get("sex") or "m"
|
|
||||||
height = _safe_float(profile.get("height")) or 178.0
|
|
||||||
|
|
||||||
m = measurement
|
|
||||||
derived = _calc_derived(m, height)
|
|
||||||
|
|
||||||
# ── Körperfett ──────────────────────────────────────────────────────────
|
|
||||||
bf = _safe_float(m.get("body_fat_pct"))
|
|
||||||
if bf is not None:
|
|
||||||
ranges = _bf_status_ranges(sex)
|
|
||||||
if bf <= ranges["essential"]:
|
|
||||||
msg = "Sehr niedriger Körperfettanteil"
|
|
||||||
detail = (
|
|
||||||
"Essenzielle Fettwerte – nur für Leistungssportler geeignet, "
|
|
||||||
"auf Dauer nicht empfehlenswert."
|
|
||||||
)
|
|
||||||
status = "warn"
|
|
||||||
elif bf <= ranges["athletic"]:
|
|
||||||
msg = "Athletischer Körperfettanteil"
|
|
||||||
detail = "Ausgezeichnet. Typisch für aktive Sportler mit hohem Trainingsvolumen."
|
|
||||||
status = "good"
|
|
||||||
elif bf <= ranges["fit"]:
|
|
||||||
msg = "Guter Körperfettanteil"
|
|
||||||
detail = "Sehr gute Fitness-Kategorie. Gesund und gut in Form."
|
|
||||||
status = "good"
|
|
||||||
elif bf <= ranges["avg"]:
|
|
||||||
msg = "Durchschnittlicher Körperfettanteil"
|
|
||||||
detail = (
|
|
||||||
"Im normalen Bereich. Verbesserung durch Kombination aus Kraft- "
|
|
||||||
"und Ausdauertraining möglich."
|
|
||||||
)
|
|
||||||
status = "warn"
|
|
||||||
else:
|
|
||||||
msg = "Erhöhter Körperfettanteil"
|
|
||||||
detail = (
|
|
||||||
"Über dem empfohlenen Bereich. Ernährungsumstellung und "
|
|
||||||
"regelmäßiges Training empfohlen."
|
|
||||||
)
|
|
||||||
status = "bad"
|
|
||||||
|
|
||||||
results.append(
|
|
||||||
{
|
|
||||||
"category": "Körperfett",
|
|
||||||
"icon": "🫧",
|
|
||||||
"status": status,
|
|
||||||
"title": msg,
|
|
||||||
"detail": detail,
|
|
||||||
"value": f"{bf}%",
|
|
||||||
"related_placeholder_keys": ["caliper_summary", "fm_28d_change"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── WHR ─────────────────────────────────────────────────────────────────
|
|
||||||
whr = derived.get("whr")
|
|
||||||
if whr is not None:
|
|
||||||
limit = 0.90 if sex == "m" else 0.85
|
|
||||||
limit_high = 1.0 if sex == "m" else 0.95
|
|
||||||
if whr < limit:
|
|
||||||
status = "good"
|
|
||||||
title = "Günstige Fettverteilung"
|
|
||||||
detail = (
|
|
||||||
f"Dein WHR von {whr} liegt unter dem Grenzwert ({limit}). "
|
|
||||||
"Birnenförmige Fettverteilung – metabolisch günstig."
|
|
||||||
)
|
|
||||||
elif whr < limit_high:
|
|
||||||
status = "warn"
|
|
||||||
title = "Grenzwertiger WHR"
|
|
||||||
detail = (
|
|
||||||
f"Dein WHR von {whr} liegt leicht über dem Zielwert ({limit}). "
|
|
||||||
"Apfelförmige Tendenz – Bauchfett reduzieren empfohlen."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
status = "bad"
|
|
||||||
title = "Erhöhtes Risiko durch Fettverteilung"
|
|
||||||
detail = (
|
|
||||||
f"WHR von {whr} deutlich über dem Grenzwert. Erhöhtes "
|
|
||||||
"kardiovaskuläres Risiko durch viszerales Fett."
|
|
||||||
)
|
|
||||||
results.append(
|
|
||||||
{
|
|
||||||
"category": "Fettverteilung",
|
|
||||||
"icon": "📐",
|
|
||||||
"status": status,
|
|
||||||
"title": title,
|
|
||||||
"detail": detail,
|
|
||||||
"value": str(whr),
|
|
||||||
"related_placeholder_keys": ["waist_hip_ratio", "circ_summary"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── WHtR ────────────────────────────────────────────────────────────────
|
|
||||||
whtr = derived.get("whtr")
|
|
||||||
if whtr is not None:
|
|
||||||
if whtr < 0.40:
|
|
||||||
status = "warn"
|
|
||||||
title = "Sehr schlanke Taille"
|
|
||||||
detail = f"WHtR {whtr} – möglicherweise zu wenig Körpermasse."
|
|
||||||
elif whtr < 0.50:
|
|
||||||
status = "good"
|
|
||||||
title = "Optimale Taillen-Größen-Relation"
|
|
||||||
detail = (
|
|
||||||
f"WHtR {whtr} – im optimalen Bereich. Geringstes kardiovaskuläres Risiko."
|
|
||||||
)
|
|
||||||
elif whtr < 0.60:
|
|
||||||
status = "warn"
|
|
||||||
title = "Leicht erhöhter WHtR"
|
|
||||||
detail = f"WHtR {whtr} – Ziel ist unter 0,50. Moderat erhöhtes Risiko."
|
|
||||||
else:
|
|
||||||
status = "bad"
|
|
||||||
title = "Stark erhöhter WHtR"
|
|
||||||
detail = (
|
|
||||||
f"WHtR {whtr} – deutlich erhöhtes Risiko. Taille sollte weniger "
|
|
||||||
"als die Hälfte der Körpergröße betragen."
|
|
||||||
)
|
|
||||||
results.append(
|
|
||||||
{
|
|
||||||
"category": "Taille/Größe",
|
|
||||||
"icon": "📏",
|
|
||||||
"status": status,
|
|
||||||
"title": title,
|
|
||||||
"detail": detail,
|
|
||||||
"value": str(whtr),
|
|
||||||
"related_placeholder_keys": ["circ_summary", "waist_28d_delta"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── FFMI ─────────────────────────────────────────────────────────────────
|
|
||||||
ffmi = derived.get("ffmi")
|
|
||||||
if ffmi is not None:
|
|
||||||
natural_limit = 25.0 if sex == "m" else 22.0
|
|
||||||
if ffmi < (18.0 if sex == "m" else 15.0):
|
|
||||||
status = "warn"
|
|
||||||
title = "Unterdurchschnittliche Muskelmasse"
|
|
||||||
detail = (
|
|
||||||
f"FFMI {ffmi} – Krafttraining kann die Muskelmasse und den "
|
|
||||||
"Grundumsatz deutlich verbessern."
|
|
||||||
)
|
|
||||||
elif ffmi < (22.0 if sex == "m" else 19.0):
|
|
||||||
status = "good"
|
|
||||||
title = "Durchschnittliche Muskelmasse"
|
|
||||||
detail = f"FFMI {ffmi} – gute Basis. Mit regelmäßigem Krafttraining weiter ausbaubar."
|
|
||||||
elif ffmi <= natural_limit:
|
|
||||||
status = "good"
|
|
||||||
title = "Überdurchschnittliche Muskelmasse"
|
|
||||||
detail = f"FFMI {ffmi} – sehr gut. Oberes natürliches Spektrum für Kraftsportler."
|
|
||||||
else:
|
|
||||||
status = "warn"
|
|
||||||
title = "Außergewöhnlich hohe Muskelmasse"
|
|
||||||
detail = (
|
|
||||||
f"FFMI {ffmi} – oberhalb der natürlichen Grenze (~{natural_limit}). "
|
|
||||||
"Selten ohne unterstützende Mittel erreichbar."
|
|
||||||
)
|
|
||||||
results.append(
|
|
||||||
{
|
|
||||||
"category": "Muskelmasse",
|
|
||||||
"icon": "💪",
|
|
||||||
"status": status,
|
|
||||||
"title": title,
|
|
||||||
"detail": detail,
|
|
||||||
"value": str(ffmi),
|
|
||||||
"related_placeholder_keys": ["lbm_28d_change", "caliper_summary"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── BMI ───────────────────────────────────────────────────────────────────
|
|
||||||
w_kg = _safe_float(m.get("weight"))
|
|
||||||
if w_kg is not None and height > 0:
|
|
||||||
bmi = round(w_kg / ((height / 100.0) ** 2), 1)
|
|
||||||
if bmi < 18.5:
|
|
||||||
status = "warn"
|
|
||||||
title = "Untergewicht (BMI)"
|
|
||||||
detail = f"BMI {bmi} – unter 18,5. Auf ausreichende Kalorienzufuhr und Nährstoffversorgung achten."
|
|
||||||
elif bmi < 25:
|
|
||||||
status = "good"
|
|
||||||
title = "Normalgewicht (BMI)"
|
|
||||||
detail = f"BMI {bmi} – im optimalen Bereich (18,5–24,9)."
|
|
||||||
elif bmi < 30:
|
|
||||||
status = "warn"
|
|
||||||
title = "Übergewicht (BMI)"
|
|
||||||
detail = (
|
|
||||||
f"BMI {bmi} – leichtes Übergewicht. BMI allein ist wenig aussagekräftig "
|
|
||||||
"bei Muskelmasse – Körperfett-% beachten."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
status = "bad"
|
|
||||||
title = "Adipositas (BMI)"
|
|
||||||
detail = f"BMI {bmi} – deutliches Übergewicht. Ärztliche Beratung empfohlen."
|
|
||||||
results.append(
|
|
||||||
{
|
|
||||||
"category": "BMI",
|
|
||||||
"icon": "⚖️",
|
|
||||||
"status": status,
|
|
||||||
"title": title,
|
|
||||||
"detail": detail,
|
|
||||||
"value": str(bmi),
|
|
||||||
"related_placeholder_keys": ["bmi", "weight_aktuell"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Vergleich zur letzten Messung (Caliper) ───────────────────────────────
|
|
||||||
if prev_measurement:
|
|
||||||
p = prev_measurement
|
|
||||||
m_date = m.get("date")
|
|
||||||
p_date = p.get("date")
|
|
||||||
days = 0
|
|
||||||
if m_date and p_date:
|
|
||||||
if isinstance(m_date, str):
|
|
||||||
m_date = datetime.fromisoformat(m_date[:10]).date()
|
|
||||||
if isinstance(p_date, str):
|
|
||||||
p_date = datetime.fromisoformat(p_date[:10]).date()
|
|
||||||
if isinstance(m_date, date) and isinstance(p_date, date):
|
|
||||||
days = (m_date - p_date).days
|
|
||||||
|
|
||||||
changes: List[Dict[str, Any]] = []
|
|
||||||
if m.get("body_fat_pct") is not None and p.get("body_fat_pct") is not None:
|
|
||||||
diff = round(float(m["body_fat_pct"]) - float(p["body_fat_pct"]), 1)
|
|
||||||
if abs(diff) >= 0.3:
|
|
||||||
changes.append({"label": "Körperfett", "diff": diff, "unit": "%", "invert": True})
|
|
||||||
if m.get("weight") is not None and p.get("weight") is not None:
|
|
||||||
diff = round(float(m["weight"]) - float(p["weight"]), 1)
|
|
||||||
if abs(diff) >= 0.2:
|
|
||||||
changes.append({"label": "Gewicht", "diff": diff, "unit": "kg", "invert": True})
|
|
||||||
if m.get("lean_mass") is not None and p.get("lean_mass") is not None:
|
|
||||||
diff = round(float(m["lean_mass"]) - float(p["lean_mass"]), 1)
|
|
||||||
if abs(diff) >= 0.2:
|
|
||||||
changes.append({"label": "Magermasse", "diff": diff, "unit": "kg", "invert": False})
|
|
||||||
if m.get("c_waist") is not None and p.get("c_waist") is not None:
|
|
||||||
diff = round(float(m["c_waist"]) - float(p["c_waist"]), 1)
|
|
||||||
if abs(diff) >= 0.5:
|
|
||||||
changes.append({"label": "Taille", "diff": diff, "unit": "cm", "invert": True})
|
|
||||||
if m.get("c_belly") is not None and p.get("c_belly") is not None:
|
|
||||||
diff = round(float(m["c_belly"]) - float(p["c_belly"]), 1)
|
|
||||||
if abs(diff) >= 0.5:
|
|
||||||
changes.append({"label": "Bauch", "diff": diff, "unit": "cm", "invert": True})
|
|
||||||
|
|
||||||
if changes:
|
|
||||||
positive = [c for c in changes if (c["diff"] < 0 if c["invert"] else c["diff"] > 0)]
|
|
||||||
negative = [c for c in changes if (c["diff"] > 0 if c["invert"] else c["diff"] < 0)]
|
|
||||||
detail_parts = []
|
|
||||||
for c in changes:
|
|
||||||
sign = "+" if c["diff"] > 0 else ""
|
|
||||||
good = (c["diff"] < 0) if c["invert"] else (c["diff"] > 0)
|
|
||||||
detail_parts.append(
|
|
||||||
f"{c['label']}: {sign}{c['diff']} {c['unit']} {'✓' if good else '↑'}"
|
|
||||||
)
|
|
||||||
detail = " · ".join(detail_parts)
|
|
||||||
if len(positive) > len(negative):
|
|
||||||
st = "good"
|
|
||||||
title = "Positive Entwicklung seit letzter Messung"
|
|
||||||
elif len(negative) > len(positive):
|
|
||||||
st = "warn"
|
|
||||||
title = "Verschlechterung seit letzter Messung"
|
|
||||||
else:
|
|
||||||
st = "warn"
|
|
||||||
title = "Gemischte Entwicklung seit letzter Messung"
|
|
||||||
|
|
||||||
results.append(
|
|
||||||
{
|
|
||||||
"category": f"Seit letzter Messung ({days} Tage)",
|
|
||||||
"icon": "📊",
|
|
||||||
"status": st,
|
|
||||||
"title": title,
|
|
||||||
"detail": detail,
|
|
||||||
"value": f"{days}d",
|
|
||||||
"related_placeholder_keys": [
|
|
||||||
"caliper_summary",
|
|
||||||
"weight_trend",
|
|
||||||
"lbm_28d_change",
|
|
||||||
"waist_28d_delta",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
@ -5,9 +5,6 @@ Provides structured data for body composition and measurements.
|
||||||
|
|
||||||
Functions:
|
Functions:
|
||||||
- get_latest_weight_data(): Most recent weight entry
|
- get_latest_weight_data(): Most recent weight entry
|
||||||
- get_bmi_data(): BMI from latest weight + profile height
|
|
||||||
- get_profile_goal_weight_data(): Zielgewicht (Profilfeld)
|
|
||||||
- get_profile_goal_bf_pct_data(): Ziel-KFA % (Profilfeld)
|
|
||||||
- get_weight_trend_data(): Weight trend with slope and direction
|
- get_weight_trend_data(): Weight trend with slope and direction
|
||||||
- get_body_composition_data(): Body fat percentage and lean mass
|
- get_body_composition_data(): Body fat percentage and lean mass
|
||||||
- get_circumference_summary_data(): Latest circumference measurements
|
- get_circumference_summary_data(): Latest circumference measurements
|
||||||
|
|
@ -71,105 +68,6 @@ def get_latest_weight_data(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_bmi_data(profile_id: str) -> Dict:
|
|
||||||
"""
|
|
||||||
BMI from latest weight_log entry and profiles.height (cm).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
"bmi": float | None,
|
|
||||||
"weight_kg": float | None,
|
|
||||||
"height_cm": float | None,
|
|
||||||
"confidence": "high" | "insufficient",
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT pr.height,
|
|
||||||
(SELECT wl.weight FROM weight_log wl
|
|
||||||
WHERE wl.profile_id = pr.id
|
|
||||||
ORDER BY wl.date DESC
|
|
||||||
LIMIT 1) AS weight
|
|
||||||
FROM profiles pr
|
|
||||||
WHERE pr.id = %s
|
|
||||||
""",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
return {
|
|
||||||
"bmi": None,
|
|
||||||
"weight_kg": None,
|
|
||||||
"height_cm": None,
|
|
||||||
"confidence": "insufficient",
|
|
||||||
}
|
|
||||||
|
|
||||||
height_cm = row["height"]
|
|
||||||
weight = row["weight"]
|
|
||||||
if height_cm is None or weight is None:
|
|
||||||
return {
|
|
||||||
"bmi": None,
|
|
||||||
"weight_kg": safe_float(weight) if weight is not None else None,
|
|
||||||
"height_cm": safe_float(height_cm) if height_cm is not None else None,
|
|
||||||
"confidence": "insufficient",
|
|
||||||
}
|
|
||||||
|
|
||||||
h = safe_float(height_cm)
|
|
||||||
w = safe_float(weight)
|
|
||||||
if h <= 0:
|
|
||||||
return {
|
|
||||||
"bmi": None,
|
|
||||||
"weight_kg": w,
|
|
||||||
"height_cm": h,
|
|
||||||
"confidence": "insufficient",
|
|
||||||
}
|
|
||||||
|
|
||||||
height_m = h / 100.0
|
|
||||||
bmi = w / (height_m ** 2)
|
|
||||||
return {
|
|
||||||
"bmi": bmi,
|
|
||||||
"weight_kg": w,
|
|
||||||
"height_cm": h,
|
|
||||||
"confidence": "high",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_profile_goal_weight_data(profile_id: str) -> Dict:
|
|
||||||
"""Strategisches Zielgewicht aus profiles.goal_weight (kg), nicht goals-Tabelle."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"SELECT goal_weight FROM profiles WHERE id=%s",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row or row.get("goal_weight") is None:
|
|
||||||
return {"goal_weight_kg": None, "confidence": "insufficient"}
|
|
||||||
return {
|
|
||||||
"goal_weight_kg": safe_float(row["goal_weight"]),
|
|
||||||
"confidence": "high",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_profile_goal_bf_pct_data(profile_id: str) -> Dict:
|
|
||||||
"""Strategisches Ziel-KFA aus profiles.goal_bf_pct (%), nicht goals-Tabelle."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"SELECT goal_bf_pct FROM profiles WHERE id=%s",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row or row.get("goal_bf_pct") is None:
|
|
||||||
return {"goal_bf_pct": None, "confidence": "insufficient"}
|
|
||||||
return {
|
|
||||||
"goal_bf_pct": safe_float(row["goal_bf_pct"]),
|
|
||||||
"confidence": "high",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_weight_trend_data(
|
def get_weight_trend_data(
|
||||||
profile_id: str,
|
profile_id: str,
|
||||||
days: int = 28
|
days: int = 28
|
||||||
|
|
@ -191,8 +89,7 @@ def get_weight_trend_data(
|
||||||
"confidence": str,
|
"confidence": str,
|
||||||
"days_analyzed": int,
|
"days_analyzed": int,
|
||||||
"first_date": date,
|
"first_date": date,
|
||||||
"last_date": date,
|
"last_date": date
|
||||||
"series": [{"date": date, "weight": float}, ...], # für Charts ohne zweites Query
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Confidence Rules:
|
Confidence Rules:
|
||||||
|
|
@ -230,8 +127,7 @@ def get_weight_trend_data(
|
||||||
"delta": 0.0,
|
"delta": 0.0,
|
||||||
"direction": "unknown",
|
"direction": "unknown",
|
||||||
"first_date": None,
|
"first_date": None,
|
||||||
"last_date": None,
|
"last_date": None
|
||||||
"series": [],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Extract values
|
# Extract values
|
||||||
|
|
@ -256,11 +152,7 @@ def get_weight_trend_data(
|
||||||
"confidence": confidence,
|
"confidence": confidence,
|
||||||
"days_analyzed": days,
|
"days_analyzed": days,
|
||||||
"first_date": rows[0]['date'],
|
"first_date": rows[0]['date'],
|
||||||
"last_date": rows[-1]['date'],
|
"last_date": rows[-1]['date']
|
||||||
"series": [
|
|
||||||
{"date": r["date"], "weight": safe_float(r["weight"])}
|
|
||||||
for r in rows
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -370,8 +262,7 @@ def get_circumference_summary_data(
|
||||||
('c_hip', 'Hüfte'),
|
('c_hip', 'Hüfte'),
|
||||||
('c_thigh', 'Oberschenkel'),
|
('c_thigh', 'Oberschenkel'),
|
||||||
('c_calf', 'Wade'),
|
('c_calf', 'Wade'),
|
||||||
('c_arm', 'Oberarm kontrahiert'),
|
('c_arm', 'Arm')
|
||||||
('c_arm_relaxed', 'Oberarm'),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
measurements = []
|
measurements = []
|
||||||
|
|
@ -402,7 +293,7 @@ def get_circumference_summary_data(
|
||||||
})
|
})
|
||||||
|
|
||||||
# Calculate confidence based on how many points we have
|
# Calculate confidence based on how many points we have
|
||||||
confidence = calculate_confidence(len(measurements), 9, "general")
|
confidence = calculate_confidence(len(measurements), 8, "general")
|
||||||
|
|
||||||
if not measurements:
|
if not measurements:
|
||||||
return {
|
return {
|
||||||
|
|
@ -446,16 +337,12 @@ def calculate_weight_7d_median(profile_id: str) -> Optional[float]:
|
||||||
ORDER BY date DESC
|
ORDER BY date DESC
|
||||||
""", (profile_id,))
|
""", (profile_id,))
|
||||||
|
|
||||||
weights = [
|
weights = [row['weight'] for row in cur.fetchall()]
|
||||||
safe_float(row['weight'])
|
|
||||||
for row in cur.fetchall()
|
|
||||||
if row['weight'] is not None
|
|
||||||
]
|
|
||||||
|
|
||||||
if len(weights) < 4: # Need at least 4 measurements
|
if len(weights) < 4: # Need at least 4 measurements
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return round(float(statistics.median(weights)), 1)
|
return round(statistics.median(weights), 1)
|
||||||
|
|
||||||
|
|
||||||
def calculate_weight_28d_slope(profile_id: str) -> Optional[float]:
|
def calculate_weight_28d_slope(profile_id: str) -> Optional[float]:
|
||||||
|
|
@ -483,11 +370,7 @@ def _calculate_weight_slope(profile_id: str, days: int) -> Optional[float]:
|
||||||
ORDER BY date
|
ORDER BY date
|
||||||
""", (profile_id, days))
|
""", (profile_id, days))
|
||||||
|
|
||||||
data = [
|
data = [(row['date'], row['weight']) for row in cur.fetchall()]
|
||||||
(row['date'], safe_float(row['weight']))
|
|
||||||
for row in cur.fetchall()
|
|
||||||
if row['weight'] is not None
|
|
||||||
]
|
|
||||||
|
|
||||||
# Need minimum data points based on period
|
# Need minimum data points based on period
|
||||||
min_points = max(18, int(days * 0.6)) # 60% coverage
|
min_points = max(18, int(days * 0.6)) # 60% coverage
|
||||||
|
|
@ -497,21 +380,21 @@ def _calculate_weight_slope(profile_id: str, days: int) -> Optional[float]:
|
||||||
# Convert dates to days since start
|
# Convert dates to days since start
|
||||||
start_date = data[0][0]
|
start_date = data[0][0]
|
||||||
x_values = [(date - start_date).days for date, _ in data]
|
x_values = [(date - start_date).days for date, _ in data]
|
||||||
y_values = [w for _, w in data]
|
y_values = [weight for _, weight in data]
|
||||||
|
|
||||||
# Linear regression (alles float: PostgreSQL numeric → Decimal in Python)
|
# Linear regression
|
||||||
n = len(data)
|
n = len(data)
|
||||||
x_mean = float(sum(x_values)) / n
|
x_mean = sum(x_values) / n
|
||||||
y_mean = float(sum(y_values)) / n
|
y_mean = sum(y_values) / n
|
||||||
|
|
||||||
numerator = sum(float(x - x_mean) * float(y - y_mean) for x, y in zip(x_values, y_values))
|
numerator = sum((x - x_mean) * (y - y_mean) for x, y in zip(x_values, y_values))
|
||||||
denominator = float(sum((x - x_mean) ** 2 for x in x_values))
|
denominator = sum((x - x_mean) ** 2 for x in x_values)
|
||||||
|
|
||||||
if denominator == 0:
|
if denominator == 0:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
slope = numerator / denominator
|
slope = numerator / denominator
|
||||||
return round(float(slope), 4) # kg/day
|
return round(slope, 4) # kg/day
|
||||||
|
|
||||||
|
|
||||||
def calculate_goal_projection_date(profile_id: str, goal_id: str) -> Optional[str]:
|
def calculate_goal_projection_date(profile_id: str, goal_id: str) -> Optional[str]:
|
||||||
|
|
@ -603,24 +486,19 @@ def _calculate_body_composition_change(profile_id: str, metric: str, days: int)
|
||||||
recent = data[0]
|
recent = data[0]
|
||||||
oldest = data[-1]
|
oldest = data[-1]
|
||||||
|
|
||||||
# Calculate FM and LBM (DB numeric → Decimal; für Regression/Scores nur float)
|
# Calculate FM and LBM
|
||||||
rw = float(safe_float(recent['weight']) or 0)
|
recent_fm = recent['weight'] * (recent['bf_pct'] / 100)
|
||||||
ob = float(safe_float(recent['bf_pct']) or 0)
|
recent_lbm = recent['weight'] - recent_fm
|
||||||
ow = float(safe_float(oldest['weight']) or 0)
|
|
||||||
obf = float(safe_float(oldest['bf_pct']) or 0)
|
|
||||||
|
|
||||||
recent_fm = rw * (ob / 100)
|
oldest_fm = oldest['weight'] * (oldest['bf_pct'] / 100)
|
||||||
recent_lbm = rw - recent_fm
|
oldest_lbm = oldest['weight'] - oldest_fm
|
||||||
|
|
||||||
oldest_fm = ow * (obf / 100)
|
|
||||||
oldest_lbm = ow - oldest_fm
|
|
||||||
|
|
||||||
if metric == 'fm':
|
if metric == 'fm':
|
||||||
change = recent_fm - oldest_fm
|
change = recent_fm - oldest_fm
|
||||||
else:
|
else:
|
||||||
change = recent_lbm - oldest_lbm
|
change = recent_lbm - oldest_lbm
|
||||||
|
|
||||||
return round(float(change), 2)
|
return round(change, 2)
|
||||||
|
|
||||||
|
|
||||||
# ── Circumference Calculations ──────────────────────────────────────────────
|
# ── Circumference Calculations ──────────────────────────────────────────────
|
||||||
|
|
@ -641,15 +519,10 @@ def calculate_chest_28d_delta(profile_id: str) -> Optional[float]:
|
||||||
|
|
||||||
|
|
||||||
def calculate_arm_28d_delta(profile_id: str) -> Optional[float]:
|
def calculate_arm_28d_delta(profile_id: str) -> Optional[float]:
|
||||||
"""28-Tage-Delta Oberarm kontrahiert (c_arm), cm."""
|
"""Calculate 28-day arm circumference change (cm)"""
|
||||||
return _calculate_circumference_delta(profile_id, 'c_arm', 28)
|
return _calculate_circumference_delta(profile_id, 'c_arm', 28)
|
||||||
|
|
||||||
|
|
||||||
def calculate_arm_relaxed_28d_delta(profile_id: str) -> Optional[float]:
|
|
||||||
"""28-Tage-Delta Oberarm entspannt (c_arm_relaxed), cm."""
|
|
||||||
return _calculate_circumference_delta(profile_id, 'c_arm_relaxed', 28)
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_thigh_28d_delta(profile_id: str) -> Optional[float]:
|
def calculate_thigh_28d_delta(profile_id: str) -> Optional[float]:
|
||||||
"""Calculate 28-day thigh circumference change (cm)"""
|
"""Calculate 28-day thigh circumference change (cm)"""
|
||||||
delta = _calculate_circumference_delta(profile_id, 'c_thigh', 28)
|
delta = _calculate_circumference_delta(profile_id, 'c_thigh', 28)
|
||||||
|
|
@ -750,9 +623,9 @@ def calculate_body_progress_score(profile_id: str, focus_weights: Optional[Dict]
|
||||||
from data_layer.scores import get_user_focus_weights
|
from data_layer.scores import get_user_focus_weights
|
||||||
focus_weights = get_user_focus_weights(profile_id)
|
focus_weights = get_user_focus_weights(profile_id)
|
||||||
|
|
||||||
weight_loss = float(focus_weights.get('weight_loss', 0) or 0)
|
weight_loss = focus_weights.get('weight_loss', 0)
|
||||||
muscle_gain = float(focus_weights.get('muscle_gain', 0) or 0)
|
muscle_gain = focus_weights.get('muscle_gain', 0)
|
||||||
body_recomp = float(focus_weights.get('body_recomposition', 0) or 0)
|
body_recomp = focus_weights.get('body_recomposition', 0)
|
||||||
|
|
||||||
total_body_weight = weight_loss + muscle_gain + body_recomp
|
total_body_weight = weight_loss + muscle_gain + body_recomp
|
||||||
|
|
||||||
|
|
@ -779,8 +652,8 @@ def calculate_body_progress_score(profile_id: str, focus_weights: Optional[Dict]
|
||||||
if not components:
|
if not components:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
total_score = sum(float(score) * float(weight) for _, score, weight in components)
|
total_score = sum(score * weight for _, score, weight in components)
|
||||||
total_weight = sum(float(weight) for _, _, weight in components)
|
total_weight = sum(weight for _, _, weight in components)
|
||||||
|
|
||||||
return int(total_score / total_weight)
|
return int(total_score / total_weight)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,494 +0,0 @@
|
||||||
"""
|
|
||||||
Layer 2b: Structured body history / Verlauf «Körper» bundle.
|
|
||||||
|
|
||||||
Single source for Verlauf-UI: series + Kennzahlen + Interpretation tiles.
|
|
||||||
All queries use the same tables as Layer 1 / Layer 2a body placeholders.
|
|
||||||
|
|
||||||
See: placeholder_registrations/body_metrics.py, body_extras.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date, datetime, timedelta
|
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from data_layer.body_interpretation import get_body_interpretation_tiles
|
|
||||||
from data_layer.utils import safe_float
|
|
||||||
|
|
||||||
|
|
||||||
def _cutoff_sql(days: int) -> Optional[str]:
|
|
||||||
if days >= 9999:
|
|
||||||
return None
|
|
||||||
return (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
|
|
||||||
def _rolling_avg(rows: List[Dict[str, Any]], key: str, window: int) -> List[Dict[str, Any]]:
|
|
||||||
out: List[Dict[str, Any]] = []
|
|
||||||
for i, d in enumerate(rows):
|
|
||||||
sl = rows[max(0, i - window + 1) : i + 1]
|
|
||||||
vals: List[float] = []
|
|
||||||
for x in sl:
|
|
||||||
v = safe_float(x.get(key))
|
|
||||||
if v is not None:
|
|
||||||
vals.append(v)
|
|
||||||
if not vals:
|
|
||||||
out.append({**d, f"{key}_avg": None})
|
|
||||||
continue
|
|
||||||
avg = round(sum(vals) / len(vals), 1)
|
|
||||||
out.append({**d, f"{key}_avg": avg})
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _iso(d: Any) -> Optional[str]:
|
|
||||||
if d is None:
|
|
||||||
return None
|
|
||||||
if hasattr(d, "isoformat"):
|
|
||||||
return d.isoformat()
|
|
||||||
return str(d)[:10]
|
|
||||||
|
|
||||||
|
|
||||||
def _weight_trend_kpi(trend_periods: List[Dict[str, Any]]) -> Dict[str, str]:
|
|
||||||
"""
|
|
||||||
Kurzurteil Gewichtstrend (Schwelle ±0,25 kg, Priorität 90T → 30T → erste Periode).
|
|
||||||
Eine Quelle mit dem Verlauf-Bundle — kein paralleles Frontend-Routing mehr.
|
|
||||||
"""
|
|
||||||
if not trend_periods:
|
|
||||||
return {"verdict": "Stabil", "status": "good"}
|
|
||||||
t90 = next((t for t in trend_periods if t.get("label") == "90T"), None)
|
|
||||||
t30 = next((t for t in trend_periods if t.get("label") == "30T"), None)
|
|
||||||
d: Optional[float] = None
|
|
||||||
if t90 is not None and t90.get("diff_kg") is not None:
|
|
||||||
d = float(t90["diff_kg"])
|
|
||||||
elif t30 is not None and t30.get("diff_kg") is not None:
|
|
||||||
d = float(t30["diff_kg"])
|
|
||||||
elif trend_periods[0].get("diff_kg") is not None:
|
|
||||||
d = float(trend_periods[0]["diff_kg"])
|
|
||||||
else:
|
|
||||||
return {"verdict": "Stabil", "status": "good"}
|
|
||||||
if d < -0.25:
|
|
||||||
return {"verdict": "Trend ↓", "status": "good"}
|
|
||||||
if d > 0.25:
|
|
||||||
return {"verdict": "Trend ↑", "status": "warn"}
|
|
||||||
return {"verdict": "Stabil", "status": "good"}
|
|
||||||
|
|
||||||
|
|
||||||
def get_body_history_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Returns chart-ready series and interpretation tiles for the body history tab.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
profile_id: profiles.id
|
|
||||||
days: analysis window (use >= 9999 for full history)
|
|
||||||
|
|
||||||
Tables: weight_log, caliper_log, circumference_log, profiles
|
|
||||||
"""
|
|
||||||
cutoff = _cutoff_sql(days)
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, sex, height, dob, goal_weight, goal_bf_pct
|
|
||||||
FROM profiles WHERE id = %s
|
|
||||||
""",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
pr = r2d(cur.fetchone())
|
|
||||||
if not pr:
|
|
||||||
return {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"message": "Profil nicht gefunden",
|
|
||||||
"profile": {},
|
|
||||||
"weight": {},
|
|
||||||
"caliper": {},
|
|
||||||
"circumference": {},
|
|
||||||
"interpretation_tiles": [],
|
|
||||||
"meta": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
profile_ui = {
|
|
||||||
"sex": pr.get("sex") or "m",
|
|
||||||
"height": safe_float(pr.get("height")) or 178.0,
|
|
||||||
"goal_weight_kg": safe_float(pr.get("goal_weight")),
|
|
||||||
"goal_bf_pct": safe_float(pr.get("goal_bf_pct")),
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Weight (same window as Verlauf-Filter) ────────────────────────────
|
|
||||||
if cutoff:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date, weight FROM weight_log
|
|
||||||
WHERE profile_id = %s AND date >= %s
|
|
||||||
ORDER BY date ASC
|
|
||||||
""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date, weight FROM weight_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
ORDER BY date ASC
|
|
||||||
""",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
wrows = [r2d(r) for r in cur.fetchall()]
|
|
||||||
w_points = [
|
|
||||||
{"date": r["date"], "weight": safe_float(r["weight"])}
|
|
||||||
for r in wrows
|
|
||||||
if r.get("weight") is not None
|
|
||||||
]
|
|
||||||
w_with_avg7 = _rolling_avg([dict(x) for x in w_points], "weight", 7)
|
|
||||||
w_with_avg14 = _rolling_avg([dict(x) for x in w_points], "weight", 14)
|
|
||||||
weight_series: List[Dict[str, Any]] = []
|
|
||||||
for i, base in enumerate(w_points):
|
|
||||||
weight_series.append(
|
|
||||||
{
|
|
||||||
"date": _iso(base["date"]),
|
|
||||||
"weight": base["weight"],
|
|
||||||
"avg7": w_with_avg7[i].get("weight_avg") if i < len(w_with_avg7) else None,
|
|
||||||
"avg14": w_with_avg14[i].get("weight_avg") if i < len(w_with_avg14) else None,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ws = [p["weight"] for p in w_points if p.get("weight") is not None]
|
|
||||||
overall_avg = round(sum(ws) / len(ws), 1) if len(ws) else None
|
|
||||||
min_w = min(ws) if ws else None
|
|
||||||
max_w = max(ws) if ws else None
|
|
||||||
|
|
||||||
today = datetime.now().date()
|
|
||||||
trend_periods: List[Dict[str, Any]] = []
|
|
||||||
for span in (7, 30, 90):
|
|
||||||
cut = today - timedelta(days=span)
|
|
||||||
per = [p for p in w_points if p["date"] >= cut]
|
|
||||||
if len(per) >= 2:
|
|
||||||
diff = round(float(per[-1]["weight"]) - float(per[0]["weight"]), 1)
|
|
||||||
trend_periods.append({"label": f"{span}T", "diff_kg": diff, "count": len(per)})
|
|
||||||
|
|
||||||
# ── Caliper series ───────────────────────────────────────────────────
|
|
||||||
if cutoff:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date, body_fat_pct, lean_mass, fat_mass
|
|
||||||
FROM caliper_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
AND body_fat_pct IS NOT NULL
|
|
||||||
AND date >= %s
|
|
||||||
ORDER BY date ASC
|
|
||||||
""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date, body_fat_pct, lean_mass, fat_mass
|
|
||||||
FROM caliper_log
|
|
||||||
WHERE profile_id = %s AND body_fat_pct IS NOT NULL
|
|
||||||
ORDER BY date ASC
|
|
||||||
""",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
cal_rows = [r2d(r) for r in cur.fetchall()]
|
|
||||||
caliper_series = [
|
|
||||||
{
|
|
||||||
"date": _iso(r["date"]),
|
|
||||||
"body_fat_pct": safe_float(r.get("body_fat_pct")),
|
|
||||||
"lean_mass": safe_float(r.get("lean_mass")),
|
|
||||||
}
|
|
||||||
for r in cal_rows
|
|
||||||
]
|
|
||||||
|
|
||||||
# Latest / prev caliper in window (for interpretation)
|
|
||||||
if cutoff:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date, body_fat_pct, lean_mass
|
|
||||||
FROM caliper_log
|
|
||||||
WHERE profile_id = %s AND date >= %s
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 2
|
|
||||||
""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date, body_fat_pct, lean_mass
|
|
||||||
FROM caliper_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 2
|
|
||||||
""",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
cal_latest_rows = [r2d(r) for r in cur.fetchall()]
|
|
||||||
latest_cal = cal_latest_rows[0] if cal_latest_rows else None
|
|
||||||
prev_cal = cal_latest_rows[1] if len(cal_latest_rows) > 1 else None
|
|
||||||
|
|
||||||
# ── Circumference rows ───────────────────────────────────────────────
|
|
||||||
if cutoff:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date, c_chest, c_waist, c_hip, c_belly
|
|
||||||
FROM circumference_log
|
|
||||||
WHERE profile_id = %s AND date >= %s
|
|
||||||
ORDER BY date ASC
|
|
||||||
""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date, c_chest, c_waist, c_hip, c_belly
|
|
||||||
FROM circumference_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
ORDER BY date ASC
|
|
||||||
""",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
cir_rows = [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
if cutoff:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date, c_chest, c_waist, c_hip, c_belly
|
|
||||||
FROM circumference_log
|
|
||||||
WHERE profile_id = %s AND date >= %s
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 2
|
|
||||||
""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date, c_chest, c_waist, c_hip, c_belly
|
|
||||||
FROM circumference_log
|
|
||||||
WHERE profile_id = %s
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 2
|
|
||||||
""",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
circ_latest_desc = [r2d(r) for r in cur.fetchall()]
|
|
||||||
latest_circ_row = circ_latest_desc[0] if circ_latest_desc else None
|
|
||||||
prev_circ_row = circ_latest_desc[1] if len(circ_latest_desc) > 1 else None
|
|
||||||
|
|
||||||
# Latest weight in window
|
|
||||||
latest_w = w_points[-1] if w_points else None
|
|
||||||
|
|
||||||
# ── Proportion & index (computed from L1 rows only) ─────────────────────
|
|
||||||
prop_base: List[Dict[str, Any]] = []
|
|
||||||
for r in cir_rows:
|
|
||||||
ch = safe_float(r.get("c_chest"))
|
|
||||||
wa = safe_float(r.get("c_waist"))
|
|
||||||
if ch is None or wa is None:
|
|
||||||
continue
|
|
||||||
belly = safe_float(r.get("c_belly"))
|
|
||||||
prop_base.append(
|
|
||||||
{
|
|
||||||
"date": _iso(r["date"]),
|
|
||||||
"v_taper_cm": round(ch - wa, 1),
|
|
||||||
"belly_cm": belly,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
prop_chart = _rolling_avg([dict(x) for x in prop_base], "v_taper_cm", 3) if len(prop_base) >= 2 else []
|
|
||||||
for i, row in enumerate(prop_chart):
|
|
||||||
row["belly_cm"] = prop_base[i].get("belly_cm")
|
|
||||||
|
|
||||||
fb_first: Dict[str, Optional[float]] = {"chest": None, "waist": None, "belly": None}
|
|
||||||
for r in cir_rows:
|
|
||||||
if fb_first["chest"] is None and r.get("c_chest") is not None:
|
|
||||||
fb_first["chest"] = safe_float(r["c_chest"])
|
|
||||||
if fb_first["waist"] is None and r.get("c_waist") is not None:
|
|
||||||
fb_first["waist"] = safe_float(r["c_waist"])
|
|
||||||
if fb_first["belly"] is None and r.get("c_belly") is not None:
|
|
||||||
fb_first["belly"] = safe_float(r["c_belly"])
|
|
||||||
|
|
||||||
index_series: List[Dict[str, Any]] = []
|
|
||||||
for r in cir_rows:
|
|
||||||
idx_row: Dict[str, Any] = {"date": _iso(r["date"])}
|
|
||||||
cc = safe_float(r.get("c_chest"))
|
|
||||||
ww = safe_float(r.get("c_waist"))
|
|
||||||
bb = safe_float(r.get("c_belly"))
|
|
||||||
if cc is not None and fb_first["chest"]:
|
|
||||||
idx_row["chest_idx"] = round(cc / fb_first["chest"] * 100, 1)
|
|
||||||
else:
|
|
||||||
idx_row["chest_idx"] = None
|
|
||||||
if ww is not None and fb_first["waist"]:
|
|
||||||
idx_row["waist_idx"] = round(ww / fb_first["waist"] * 100, 1)
|
|
||||||
else:
|
|
||||||
idx_row["waist_idx"] = None
|
|
||||||
if bb is not None and fb_first["belly"]:
|
|
||||||
idx_row["belly_idx"] = round(bb / fb_first["belly"] * 100, 1)
|
|
||||||
else:
|
|
||||||
idx_row["belly_idx"] = None
|
|
||||||
index_series.append(idx_row)
|
|
||||||
|
|
||||||
idx_nonempty = sum(
|
|
||||||
1
|
|
||||||
for row in index_series
|
|
||||||
if row.get("chest_idx") is not None
|
|
||||||
or row.get("waist_idx") is not None
|
|
||||||
or row.get("belly_idx") is not None
|
|
||||||
)
|
|
||||||
|
|
||||||
fallback_circ = [
|
|
||||||
{
|
|
||||||
"date": _iso(r["date"]),
|
|
||||||
"waist": safe_float(r.get("c_waist")),
|
|
||||||
"hip": safe_float(r.get("c_hip")),
|
|
||||||
"belly": safe_float(r.get("c_belly")),
|
|
||||||
}
|
|
||||||
for r in cir_rows
|
|
||||||
if r.get("c_waist") or r.get("c_hip") or r.get("c_belly")
|
|
||||||
]
|
|
||||||
|
|
||||||
# ── Merge measurement for interpretation ────────────────────────────────
|
|
||||||
measurement: Dict[str, Any] = {}
|
|
||||||
if latest_cal:
|
|
||||||
measurement.update(
|
|
||||||
{
|
|
||||||
"date": latest_cal.get("date"),
|
|
||||||
"body_fat_pct": safe_float(latest_cal.get("body_fat_pct")),
|
|
||||||
"lean_mass": safe_float(latest_cal.get("lean_mass")),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if latest_circ_row:
|
|
||||||
measurement["c_waist"] = safe_float(latest_circ_row.get("c_waist"))
|
|
||||||
measurement["c_hip"] = safe_float(latest_circ_row.get("c_hip"))
|
|
||||||
measurement["c_belly"] = safe_float(latest_circ_row.get("c_belly"))
|
|
||||||
if latest_w:
|
|
||||||
measurement["weight"] = safe_float(latest_w.get("weight"))
|
|
||||||
# Referenzdatum für „aktuell“: neueste verfügbare Quelle (Caliper > Umfang > Gewicht)
|
|
||||||
if not measurement.get("date"):
|
|
||||||
if latest_circ_row and latest_circ_row.get("date"):
|
|
||||||
measurement["date"] = latest_circ_row.get("date")
|
|
||||||
elif latest_w and latest_w.get("date"):
|
|
||||||
measurement["date"] = latest_w.get("date")
|
|
||||||
|
|
||||||
# Vorperiode: vorherige Caliper-Zeile + vorherige Umfangsmessung + vorheriges Gewicht (w_points[-2])
|
|
||||||
prev_for_interp: Optional[Dict[str, Any]] = {}
|
|
||||||
if prev_cal:
|
|
||||||
prev_for_interp["date"] = prev_cal.get("date")
|
|
||||||
prev_for_interp["body_fat_pct"] = safe_float(prev_cal.get("body_fat_pct"))
|
|
||||||
prev_for_interp["lean_mass"] = safe_float(prev_cal.get("lean_mass"))
|
|
||||||
if prev_circ_row:
|
|
||||||
prev_for_interp["c_waist"] = safe_float(prev_circ_row.get("c_waist"))
|
|
||||||
prev_for_interp["c_hip"] = safe_float(prev_circ_row.get("c_hip"))
|
|
||||||
prev_for_interp["c_belly"] = safe_float(prev_circ_row.get("c_belly"))
|
|
||||||
if not prev_for_interp.get("date") and prev_circ_row.get("date"):
|
|
||||||
prev_for_interp["date"] = prev_circ_row.get("date")
|
|
||||||
if len(w_points) >= 2:
|
|
||||||
prev_for_interp["weight"] = safe_float(w_points[-2].get("weight"))
|
|
||||||
if not prev_for_interp.get("date") and w_points[-2].get("date"):
|
|
||||||
prev_for_interp["date"] = w_points[-2].get("date")
|
|
||||||
|
|
||||||
if not prev_for_interp:
|
|
||||||
prev_for_interp = None
|
|
||||||
else:
|
|
||||||
# Mindestens ein vergleichbares Feld zur aktuellen Messung
|
|
||||||
has_cmp = any(
|
|
||||||
prev_for_interp.get(k) is not None
|
|
||||||
for k in ("body_fat_pct", "lean_mass", "weight", "c_waist", "c_belly")
|
|
||||||
)
|
|
||||||
if not has_cmp:
|
|
||||||
prev_for_interp = None
|
|
||||||
|
|
||||||
tiles = get_body_interpretation_tiles(measurement, profile_ui, prev_for_interp)
|
|
||||||
|
|
||||||
last_dates: List[date] = []
|
|
||||||
if w_points:
|
|
||||||
last_dates.append(w_points[-1]["date"])
|
|
||||||
if latest_cal and latest_cal.get("date"):
|
|
||||||
d = latest_cal["date"]
|
|
||||||
if isinstance(d, str):
|
|
||||||
d = datetime.fromisoformat(d[:10]).date()
|
|
||||||
last_dates.append(d)
|
|
||||||
if latest_circ_row and latest_circ_row.get("date"):
|
|
||||||
d = latest_circ_row["date"]
|
|
||||||
if isinstance(d, str):
|
|
||||||
d = datetime.fromisoformat(d[:10]).date()
|
|
||||||
last_dates.append(d)
|
|
||||||
last_updated = max(last_dates).isoformat() if last_dates else None
|
|
||||||
|
|
||||||
bf_cat = None
|
|
||||||
if measurement.get("body_fat_pct") is not None:
|
|
||||||
# simple label bucket (aligned with frontend BF_CATEGORIES order)
|
|
||||||
bf = float(measurement["body_fat_pct"])
|
|
||||||
sex = profile_ui["sex"]
|
|
||||||
if sex == "f":
|
|
||||||
labels = ["Essenziell", "Athletisch", "Fit", "Durchschnitt", "Übergewicht"]
|
|
||||||
bounds = [14, 21, 25, 32, 1000]
|
|
||||||
else:
|
|
||||||
labels = ["Essenziell", "Athletisch", "Fit", "Durchschnitt", "Übergewicht"]
|
|
||||||
bounds = [6, 14, 18, 25, 1000]
|
|
||||||
for i, b in enumerate(bounds):
|
|
||||||
if bf <= b:
|
|
||||||
bf_cat = labels[i]
|
|
||||||
break
|
|
||||||
|
|
||||||
summary = {
|
|
||||||
"weight_kg": measurement.get("weight"),
|
|
||||||
"body_fat_pct": measurement.get("body_fat_pct"),
|
|
||||||
"lean_mass_kg": measurement.get("lean_mass"),
|
|
||||||
"whr": (
|
|
||||||
round(measurement["c_waist"] / measurement["c_hip"], 2)
|
|
||||||
if measurement.get("c_waist") and measurement.get("c_hip")
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
"whtr": (
|
|
||||||
round(measurement["c_waist"] / profile_ui["height"], 2)
|
|
||||||
if measurement.get("c_waist") and profile_ui.get("height")
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
"ffmi": None,
|
|
||||||
"bf_category_label": bf_cat,
|
|
||||||
}
|
|
||||||
if measurement.get("lean_mass") and profile_ui.get("height"):
|
|
||||||
hm = float(profile_ui["height"]) / 100.0
|
|
||||||
summary["ffmi"] = round(float(measurement["lean_mass"]) / (hm**2), 1)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"confidence": "high" if w_points or caliper_series or cir_rows else "insufficient",
|
|
||||||
"days_requested": days,
|
|
||||||
"last_updated": last_updated,
|
|
||||||
"profile": profile_ui,
|
|
||||||
"summary": summary,
|
|
||||||
"weight": {
|
|
||||||
"series": weight_series,
|
|
||||||
"overall_avg_kg": overall_avg,
|
|
||||||
"min_kg": min_w,
|
|
||||||
"max_kg": max_w,
|
|
||||||
"trend_periods": trend_periods,
|
|
||||||
"trend_kpi": _weight_trend_kpi(trend_periods),
|
|
||||||
"data_points": len(w_points),
|
|
||||||
"related_placeholder_keys": [
|
|
||||||
"weight_aktuell",
|
|
||||||
"weight_trend",
|
|
||||||
"weight_7d_median",
|
|
||||||
"weight_28d_slope",
|
|
||||||
"weight_90d_slope",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"caliper": {
|
|
||||||
"series": caliper_series,
|
|
||||||
"data_points": len(caliper_series),
|
|
||||||
"related_placeholder_keys": ["caliper_summary", "fm_28d_change", "lbm_28d_change"],
|
|
||||||
},
|
|
||||||
"circumference": {
|
|
||||||
"proportion_series": prop_chart,
|
|
||||||
"index_series": index_series,
|
|
||||||
"index_usable": idx_nonempty >= 2 and any(v for v in fb_first.values()),
|
|
||||||
"fallback_multiline": fallback_circ,
|
|
||||||
"has_chest_waist": len(prop_base) >= 2,
|
|
||||||
"related_placeholder_keys": ["circ_summary", "waist_hip_ratio", "waist_28d_delta"],
|
|
||||||
},
|
|
||||||
"interpretation_tiles": tiles,
|
|
||||||
"meta": {
|
|
||||||
"layer_1": "data_layer.body_viz + data_layer.body_interpretation",
|
|
||||||
"layer_2b": "This bundle — sole numeric source for Verlauf Körper charts/tiles",
|
|
||||||
"layer_2a_alignment": "Tiles carry related_placeholder_keys; metrics from same tables as body_metrics placeholders",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -1,256 +0,0 @@
|
||||||
"""
|
|
||||||
Chart.js-kompatible Payloads für Lag-Korrelationen C1–C3 und Treiber C4.
|
|
||||||
|
|
||||||
Gemeinsame Quelle für GET /charts/* und history_overview_viz.chart_payloads (Issue 53).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from data_layer.correlations import calculate_lag_correlation, calculate_top_drivers
|
|
||||||
|
|
||||||
|
|
||||||
def build_weight_energy_correlation_chart_payload(profile_id: str, max_lag: int) -> Dict[str, Any]:
|
|
||||||
corr_data = calculate_lag_correlation(profile_id, "energy_balance", "weight", max_lag)
|
|
||||||
|
|
||||||
if not corr_data or corr_data.get("correlation") is None:
|
|
||||||
msg = "Nicht genug Daten für Korrelationsanalyse"
|
|
||||||
if isinstance(corr_data, dict):
|
|
||||||
msg = str(corr_data.get("interpretation") or corr_data.get("reason") or msg)
|
|
||||||
return {
|
|
||||||
"chart_type": "scatter",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": corr_data.get("data_points", 0) if isinstance(corr_data, dict) else 0,
|
|
||||||
"message": msg,
|
|
||||||
"lag_details": corr_data.get("lag_details") if isinstance(corr_data, dict) else None,
|
|
||||||
"tdee_kcal_used": corr_data.get("tdee_kcal_used") if isinstance(corr_data, dict) else None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
best_lag = corr_data.get("best_lag_days", corr_data.get("best_lag", 0))
|
|
||||||
correlation = corr_data.get("correlation", 0)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "scatter",
|
|
||||||
"data": {
|
|
||||||
"labels": [f"Lag {best_lag} Tage"],
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "Korrelation",
|
|
||||||
"data": [{"x": best_lag, "y": correlation}],
|
|
||||||
"backgroundColor": "#1D9E75",
|
|
||||||
"borderColor": "#085041",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"pointRadius": 8,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": corr_data.get("confidence", "low"),
|
|
||||||
"correlation": round(float(correlation), 3),
|
|
||||||
"best_lag_days": best_lag,
|
|
||||||
"interpretation": corr_data.get("interpretation", ""),
|
|
||||||
"data_points": corr_data.get("data_points", 0),
|
|
||||||
"lag_details": corr_data.get("lag_details"),
|
|
||||||
"tdee_kcal_used": corr_data.get("tdee_kcal_used"),
|
|
||||||
"layer_1": "correlations._correlate_energy_weight",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_lbm_protein_correlation_chart_payload(profile_id: str, max_lag: int) -> Dict[str, Any]:
|
|
||||||
corr_data = calculate_lag_correlation(profile_id, "protein", "lbm", max_lag)
|
|
||||||
|
|
||||||
if not corr_data or corr_data.get("correlation") is None:
|
|
||||||
msg = "Nicht genug Daten für LBM-Protein Korrelation"
|
|
||||||
if isinstance(corr_data, dict):
|
|
||||||
msg = str(corr_data.get("interpretation") or corr_data.get("reason") or msg)
|
|
||||||
return {
|
|
||||||
"chart_type": "scatter",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": corr_data.get("data_points", 0) if isinstance(corr_data, dict) else 0,
|
|
||||||
"message": msg,
|
|
||||||
"lag_details": corr_data.get("lag_details") if isinstance(corr_data, dict) else None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
best_lag = corr_data.get("best_lag_days", corr_data.get("best_lag", 0))
|
|
||||||
correlation = corr_data.get("correlation", 0)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "scatter",
|
|
||||||
"data": {
|
|
||||||
"labels": [f"Lag {best_lag} Tage"],
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "Korrelation",
|
|
||||||
"data": [{"x": best_lag, "y": correlation}],
|
|
||||||
"backgroundColor": "#3B82F6",
|
|
||||||
"borderColor": "#1E40AF",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"pointRadius": 8,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": corr_data.get("confidence", "low"),
|
|
||||||
"correlation": round(float(correlation), 3),
|
|
||||||
"best_lag_days": best_lag,
|
|
||||||
"interpretation": corr_data.get("interpretation", ""),
|
|
||||||
"data_points": corr_data.get("data_points", 0),
|
|
||||||
"lag_details": corr_data.get("lag_details"),
|
|
||||||
"layer_1": "correlations._correlate_protein_lbm",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_load_vitals_correlation_chart_payload(profile_id: str, max_lag: int) -> Dict[str, Any]:
|
|
||||||
corr_hrv = calculate_lag_correlation(profile_id, "load", "hrv", max_lag)
|
|
||||||
corr_rhr = calculate_lag_correlation(profile_id, "load", "rhr", max_lag)
|
|
||||||
|
|
||||||
def _abs_corr(c: Any) -> float:
|
|
||||||
if not c or c.get("correlation") is None:
|
|
||||||
return -1.0
|
|
||||||
try:
|
|
||||||
return abs(float(c["correlation"]))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return -1.0
|
|
||||||
|
|
||||||
if _abs_corr(corr_hrv) < 0 and _abs_corr(corr_rhr) < 0:
|
|
||||||
msg = "Nicht genug Daten für Load-Vitals Korrelation"
|
|
||||||
h_msg = corr_hrv.get("interpretation") if isinstance(corr_hrv, dict) else None
|
|
||||||
r_msg = corr_rhr.get("interpretation") if isinstance(corr_rhr, dict) else None
|
|
||||||
if h_msg or r_msg:
|
|
||||||
msg = f"HRV: {h_msg or '—'} · RHR: {r_msg or '—'}"
|
|
||||||
return {
|
|
||||||
"chart_type": "scatter",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": msg,
|
|
||||||
"lag_details_hrv": corr_hrv.get("lag_details") if isinstance(corr_hrv, dict) else None,
|
|
||||||
"lag_details_rhr": corr_rhr.get("lag_details") if isinstance(corr_rhr, dict) else None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if _abs_corr(corr_hrv) >= _abs_corr(corr_rhr):
|
|
||||||
corr_data = corr_hrv
|
|
||||||
metric_name = "HRV"
|
|
||||||
else:
|
|
||||||
corr_data = corr_rhr
|
|
||||||
metric_name = "RHR"
|
|
||||||
|
|
||||||
if not corr_data or corr_data.get("correlation") is None:
|
|
||||||
return {
|
|
||||||
"chart_type": "scatter",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": str(corr_data.get("interpretation") or "Nicht genug Daten für Load-Vitals Korrelation"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
best_lag = corr_data.get("best_lag_days", corr_data.get("best_lag", 0))
|
|
||||||
correlation = corr_data.get("correlation", 0)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "scatter",
|
|
||||||
"data": {
|
|
||||||
"labels": [f"Load → {metric_name} (Lag {best_lag}d)"],
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "Korrelation",
|
|
||||||
"data": [{"x": best_lag, "y": correlation}],
|
|
||||||
"backgroundColor": "#F59E0B",
|
|
||||||
"borderColor": "#D97706",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"pointRadius": 8,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": corr_data.get("confidence", "low"),
|
|
||||||
"correlation": round(float(correlation), 3),
|
|
||||||
"best_lag_days": best_lag,
|
|
||||||
"metric": metric_name,
|
|
||||||
"interpretation": corr_data.get("interpretation", ""),
|
|
||||||
"data_points": corr_data.get("data_points", 0),
|
|
||||||
"lag_details": corr_data.get("lag_details"),
|
|
||||||
"layer_1": "correlations._correlate_load_vitals",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_recovery_performance_chart_payload(profile_id: str) -> Dict[str, Any]:
|
|
||||||
drivers = calculate_top_drivers(profile_id)
|
|
||||||
|
|
||||||
if not drivers or len(drivers) == 0:
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Nicht genug Daten für Driver-Analyse",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
hindering = [d for d in drivers if d.get("impact", "") == "hindering"]
|
|
||||||
helpful = [d for d in drivers if d.get("impact", "") == "helpful"]
|
|
||||||
|
|
||||||
top_hindering = hindering[:3]
|
|
||||||
top_helpful = helpful[:3]
|
|
||||||
|
|
||||||
labels = []
|
|
||||||
values = []
|
|
||||||
colors = []
|
|
||||||
|
|
||||||
for d in top_hindering:
|
|
||||||
labels.append(f"❌ {d.get('factor', '')}")
|
|
||||||
values.append(-abs(d.get("score", 0)))
|
|
||||||
colors.append("#EF4444")
|
|
||||||
|
|
||||||
for d in top_helpful:
|
|
||||||
labels.append(f"✅ {d.get('factor', '')}")
|
|
||||||
values.append(abs(d.get("score", 0)))
|
|
||||||
colors.append("#1D9E75")
|
|
||||||
|
|
||||||
if not labels:
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "low",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Keine signifikanten Treiber gefunden",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {
|
|
||||||
"labels": labels,
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "Impact Score",
|
|
||||||
"data": values,
|
|
||||||
"backgroundColor": colors,
|
|
||||||
"borderColor": "#085041",
|
|
||||||
"borderWidth": 1,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "medium",
|
|
||||||
"hindering_count": len(top_hindering),
|
|
||||||
"helpful_count": len(top_helpful),
|
|
||||||
"total_factors": len(drivers),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -17,401 +17,116 @@ Phase 0c: Multi-Layer Architecture
|
||||||
Version: 1.0
|
Version: 1.0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from datetime import datetime, timedelta, date
|
from datetime import datetime, timedelta, date
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
import statistics
|
import statistics
|
||||||
|
|
||||||
from data_layer.nutrition_body_merge import build_merged_daily_nutrition_body_rows
|
|
||||||
from data_layer.nutrition_metrics import estimate_tdee_kcal_from_latest_weight
|
|
||||||
|
|
||||||
# Lag-Korrelation (Issue #53): gleiche TDEE-Logik wie nutrition_metrics / nutrition_viz
|
|
||||||
MIN_PAIRS_LAG_CORR = 15
|
|
||||||
LAG_CORR_LOOKBACK_DAYS = 120
|
|
||||||
|
|
||||||
def calculate_lag_correlation(profile_id: str, var1: str, var2: str, max_lag_days: int = 14) -> Optional[Dict]:
|
def calculate_lag_correlation(profile_id: str, var1: str, var2: str, max_lag_days: int = 14) -> Optional[Dict]:
|
||||||
"""
|
"""
|
||||||
Pearson-Korrelation mit Lag-Sweep (Issue 53, Data-Layer).
|
Calculate lagged correlation between two variables
|
||||||
|
|
||||||
C1: Tagesbilanz (kcal − TDEE wie ``estimate_tdee_kcal_from_latest_weight``) vs. ΔGewicht [t→t+L], L≥1.
|
Args:
|
||||||
C2: Protein (g) vs. ΔMager [t→t+L] aus ``build_merged_daily_nutrition_body_rows``, L≥1.
|
var1: 'energy', 'protein', 'training_load'
|
||||||
C3: Summe ``duration_min`` pro Tag vs. HRV oder Ruhepuls am Tag t+L (L≥0).
|
var2: 'weight', 'lbm', 'hrv', 'rhr'
|
||||||
|
max_lag_days: Maximum lag to test
|
||||||
|
|
||||||
Rückgabe enthält u. a. ``best_lag`` / ``best_lag_days``, ``correlation``, ``interpretation``,
|
Returns:
|
||||||
optional ``lag_details`` (r, n je Lag), mindestens ``MIN_PAIRS_LAG_CORR`` Paare am besten Lag.
|
{
|
||||||
|
'best_lag': X, # days
|
||||||
|
'correlation': 0.XX, # -1 to 1
|
||||||
|
'direction': 'positive'/'negative'/'none',
|
||||||
|
'confidence': 'high'/'medium'/'low',
|
||||||
|
'data_points': N
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
v1 = (var1 or "").strip().lower()
|
if var1 == 'energy' and var2 == 'weight':
|
||||||
if v1 in ("energy", "energy_balance"):
|
return _correlate_energy_weight(profile_id, max_lag_days)
|
||||||
v1n = "energy"
|
elif var1 == 'protein' and var2 == 'lbm':
|
||||||
elif v1 in ("training_load", "load"):
|
return _correlate_protein_lbm(profile_id, max_lag_days)
|
||||||
v1n = "training_load"
|
elif var1 == 'training_load' and var2 in ['hrv', 'rhr']:
|
||||||
elif v1 == "protein":
|
return _correlate_load_vitals(profile_id, var2, max_lag_days)
|
||||||
v1n = "protein"
|
|
||||||
else:
|
|
||||||
v1n = v1
|
|
||||||
|
|
||||||
if v1n == 'energy' and var2 == 'weight':
|
|
||||||
return _normalize_lag_payload(_correlate_energy_weight(profile_id, max_lag_days))
|
|
||||||
elif v1n == 'protein' and var2 == 'lbm':
|
|
||||||
return _normalize_lag_payload(_correlate_protein_lbm(profile_id, max_lag_days))
|
|
||||||
elif v1n == 'training_load' and var2 in ['hrv', 'rhr']:
|
|
||||||
return _normalize_lag_payload(_correlate_load_vitals(profile_id, var2, max_lag_days))
|
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _normalize_lag_payload(raw: Optional[Dict]) -> Optional[Dict]:
|
|
||||||
"""Charts erwarten u. a. ``best_lag_days``; Layer liefert teils ``best_lag``."""
|
|
||||||
if not raw:
|
|
||||||
return None
|
|
||||||
out = dict(raw)
|
|
||||||
if out.get("best_lag_days") is None and out.get("best_lag") is not None:
|
|
||||||
out["best_lag_days"] = out["best_lag"]
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _iso_date_key(d: Any) -> str:
|
|
||||||
if d is None:
|
|
||||||
return ""
|
|
||||||
if hasattr(d, "isoformat"):
|
|
||||||
return str(d.isoformat())[:10]
|
|
||||||
s = str(d)
|
|
||||||
return s[:10] if len(s) >= 10 else s
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_iso_to_date(ds: str) -> Optional[date]:
|
|
||||||
if not ds or len(ds) < 10:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return date.fromisoformat(ds[:10])
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _pearson_r(xs: List[float], ys: List[float]) -> Optional[float]:
|
|
||||||
"""Pearson-Korrelation; mindestens ``MIN_PAIRS_LAG_CORR`` Paare."""
|
|
||||||
n = len(xs)
|
|
||||||
if n < MIN_PAIRS_LAG_CORR or n != len(ys):
|
|
||||||
return None
|
|
||||||
mx = sum(xs) / n
|
|
||||||
my = sum(ys) / n
|
|
||||||
num = sum((xs[i] - mx) * (ys[i] - my) for i in range(n))
|
|
||||||
dx = sum((xs[i] - mx) ** 2 for i in range(n))
|
|
||||||
dy = sum((ys[i] - my) ** 2 for i in range(n))
|
|
||||||
if dx <= 1e-12 or dy <= 1e-12:
|
|
||||||
return None
|
|
||||||
r = num / ((dx**0.5) * (dy**0.5))
|
|
||||||
return float(max(-1.0, min(1.0, r)))
|
|
||||||
|
|
||||||
|
|
||||||
def _direction_from_r(r: float) -> str:
|
|
||||||
if r > 0.05:
|
|
||||||
return "positive"
|
|
||||||
if r < -0.05:
|
|
||||||
return "negative"
|
|
||||||
return "none"
|
|
||||||
|
|
||||||
|
|
||||||
def _lag_confidence(n_pairs: int, r: float) -> str:
|
|
||||||
return calculate_correlation_confidence(n_pairs, abs(r))
|
|
||||||
|
|
||||||
|
|
||||||
def _correlate_energy_weight(profile_id: str, max_lag: int) -> Optional[Dict]:
|
def _correlate_energy_weight(profile_id: str, max_lag: int) -> Optional[Dict]:
|
||||||
"""
|
"""
|
||||||
Pearson: Tagesbilanz (kcal − TDEE wie nutrition_metrics) vs. Gewichtsdifferenz
|
Correlate energy balance with weight change
|
||||||
vom Tag t zu Tag t+L (L = 0 … max_lag). Bestes Lag nach maximalem |r|.
|
Test lags: 0, 3, 7, 10, 14 days
|
||||||
"""
|
"""
|
||||||
tdee = estimate_tdee_kcal_from_latest_weight(profile_id)
|
|
||||||
if tdee is None or float(tdee) <= 0:
|
|
||||||
return {
|
|
||||||
"best_lag": None,
|
|
||||||
"correlation": None,
|
|
||||||
"direction": "none",
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"interpretation": "Keine TDEE-Schätzung möglich (Gewicht/Demografie).",
|
|
||||||
"reason": "no_tdee",
|
|
||||||
}
|
|
||||||
|
|
||||||
tdee_f = float(tdee)
|
|
||||||
cutoff = (datetime.now() - timedelta(days=LAG_CORR_LOOKBACK_DAYS)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date::date AS d, SUM(kcal)::float AS kcal
|
|
||||||
FROM nutrition_log
|
|
||||||
WHERE profile_id = %s AND date >= %s::date AND kcal IS NOT NULL
|
|
||||||
GROUP BY date
|
|
||||||
ORDER BY date
|
|
||||||
""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
kcal_rows = cur.fetchall()
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date::date AS d, weight::float AS weight
|
|
||||||
FROM weight_log
|
|
||||||
WHERE profile_id = %s AND date >= %s::date AND weight IS NOT NULL
|
|
||||||
ORDER BY date
|
|
||||||
""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
w_rows = cur.fetchall()
|
|
||||||
|
|
||||||
kcal_by: Dict[str, float] = {}
|
# Get energy balance data (daily calories - estimated TDEE)
|
||||||
for r in kcal_rows:
|
cur.execute("""
|
||||||
kcal_by[_iso_date_key(r["d"])] = float(r["kcal"] or 0)
|
SELECT n.date, n.kcal, w.weight
|
||||||
weight_by: Dict[str, float] = {}
|
FROM nutrition_log n
|
||||||
for r in w_rows:
|
LEFT JOIN weight_log w ON w.profile_id = n.profile_id
|
||||||
weight_by[_iso_date_key(r["d"])] = float(r["weight"])
|
AND w.date = n.date
|
||||||
|
WHERE n.profile_id = %s
|
||||||
|
AND n.date >= CURRENT_DATE - INTERVAL '90 days'
|
||||||
|
ORDER BY n.date
|
||||||
|
""", (profile_id,))
|
||||||
|
|
||||||
balance_by = {d: kcal_by[d] - tdee_f for d in kcal_by}
|
data = cur.fetchall()
|
||||||
|
|
||||||
best: Optional[Tuple[int, float, int]] = None
|
if len(data) < 30:
|
||||||
lag_details: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
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 {
|
return {
|
||||||
"best_lag": None,
|
'best_lag': None,
|
||||||
"correlation": None,
|
'correlation': None,
|
||||||
"direction": "none",
|
'direction': 'none',
|
||||||
"confidence": "insufficient",
|
'confidence': 'low',
|
||||||
"data_points": 0,
|
'data_points': len(data),
|
||||||
"interpretation": "Zu wenige gepaarte Tage mit Ernährung, Gewicht und gewähltem Lag.",
|
'reason': 'Insufficient data (<30 days)'
|
||||||
"reason": "insufficient_pairs",
|
|
||||||
"lag_details": lag_details,
|
|
||||||
"tdee_kcal_used": round(tdee_f, 0),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lag_b, r_b, n_b = best
|
# Calculate 7d rolling energy balance
|
||||||
direction = _direction_from_r(r_b)
|
# (Simplified - actual implementation would need TDEE estimation)
|
||||||
conf = _lag_confidence(n_b, r_b)
|
|
||||||
interp = (
|
|
||||||
f"Tagesbilanz (kcal − TDEE ~{tdee_f:.0f}) vs. Gewichtsänderung nach {lag_b} Tagen: "
|
|
||||||
f"r ≈ {r_b:.2f} ({direction}). "
|
|
||||||
f"Basierend auf {n_b} Kalendertagen mit vollständigen Paaren."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
# For now, return placeholder
|
||||||
return {
|
return {
|
||||||
"best_lag": lag_b,
|
'best_lag': 7,
|
||||||
"correlation": round(r_b, 4),
|
'correlation': -0.45, # Placeholder
|
||||||
"direction": direction,
|
'direction': 'negative', # Higher deficit = lower weight (expected)
|
||||||
"confidence": conf,
|
'confidence': 'medium',
|
||||||
"data_points": n_b,
|
'data_points': len(data)
|
||||||
"interpretation": interp,
|
|
||||||
"lag_details": lag_details,
|
|
||||||
"tdee_kcal_used": round(tdee_f, 0),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _correlate_protein_lbm(profile_id: str, max_lag: int) -> Optional[Dict]:
|
def _correlate_protein_lbm(profile_id: str, max_lag: int) -> Optional[Dict]:
|
||||||
"""
|
"""Correlate protein intake with LBM trend"""
|
||||||
Pearson: Protein (g/Tag) vs. Magermasse-Differenz (kg) vom Tag t zu t+L.
|
# TODO: Implement full correlation calculation
|
||||||
Datenbasis: nutrition_body_merge (Caliper-LBM forward-filled wie Ernährungs-Verlauf).
|
|
||||||
"""
|
|
||||||
merged = build_merged_daily_nutrition_body_rows(profile_id)
|
|
||||||
if not merged:
|
|
||||||
return {
|
return {
|
||||||
"best_lag": None,
|
'best_lag': 0,
|
||||||
"correlation": None,
|
'correlation': 0.32, # Placeholder
|
||||||
"direction": "none",
|
'direction': 'positive',
|
||||||
"confidence": "insufficient",
|
'confidence': 'medium',
|
||||||
"data_points": 0,
|
'data_points': 28
|
||||||
"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]:
|
def _correlate_load_vitals(profile_id: str, vital: str, max_lag: int) -> Optional[Dict]:
|
||||||
"""
|
"""
|
||||||
Pearson: Tages-Trainingslast (Summe duration_min) vs. Vitals (HRV ms oder Ruhepuls)
|
Correlate training load with HRV or RHR
|
||||||
am Kalendertag t+Lag (typisch: Belastung am Vortag, Vitalwert am Folgetag bei Lag ≥ 1).
|
Test lags: 1, 2, 3 days
|
||||||
"""
|
"""
|
||||||
col = "hrv" if vital == "hrv" else "resting_hr"
|
# TODO: Implement full correlation calculation
|
||||||
cutoff = (datetime.now() - timedelta(days=LAG_CORR_LOOKBACK_DAYS)).strftime("%Y-%m-%d")
|
if vital == 'hrv':
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date::text AS d, COALESCE(SUM(duration_min), 0)::float AS minutes
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s AND date >= %s::date
|
|
||||||
AND duration_min IS NOT NULL AND duration_min > 0
|
|
||||||
GROUP BY date
|
|
||||||
ORDER BY date
|
|
||||||
""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
load_rows = cur.fetchall()
|
|
||||||
cur.execute(
|
|
||||||
f"""
|
|
||||||
SELECT date::text AS d, {col}::float AS v
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s AND date >= %s::date AND {col} IS NOT NULL
|
|
||||||
ORDER BY date
|
|
||||||
""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
vit_rows = cur.fetchall()
|
|
||||||
|
|
||||||
load_by = {str(r["d"])[:10]: float(r["minutes"] or 0) for r in load_rows}
|
|
||||||
vital_by = {str(r["d"])[:10]: float(r["v"]) for r in vit_rows}
|
|
||||||
|
|
||||||
best: Optional[Tuple[int, float, int]] = None
|
|
||||||
lag_details: List[Dict[str, Any]] = []
|
|
||||||
max_l = max(0, min(int(max_lag), 28))
|
|
||||||
vlabel = "HRV (ms)" if vital == "hrv" else "Ruhepuls (bpm)"
|
|
||||||
|
|
||||||
for lag in range(0, max_l + 1):
|
|
||||||
xs: List[float] = []
|
|
||||||
ys: List[float] = []
|
|
||||||
for ds in sorted(load_by.keys()):
|
|
||||||
d0 = _parse_iso_to_date(ds)
|
|
||||||
if d0 is None:
|
|
||||||
continue
|
|
||||||
d1 = d0 + timedelta(days=lag)
|
|
||||||
ds1 = d1.isoformat()
|
|
||||||
if ds1 not in vital_by:
|
|
||||||
continue
|
|
||||||
xs.append(load_by[ds])
|
|
||||||
ys.append(vital_by[ds1])
|
|
||||||
r = _pearson_r(xs, ys)
|
|
||||||
n_p = len(xs)
|
|
||||||
lag_details.append({"lag": lag, "n_pairs": n_p, "r": None if r is None else round(r, 4)})
|
|
||||||
if r is None:
|
|
||||||
continue
|
|
||||||
if best is None or abs(r) > abs(best[1]):
|
|
||||||
best = (lag, r, n_p)
|
|
||||||
|
|
||||||
if best is None:
|
|
||||||
return {
|
return {
|
||||||
"best_lag": None,
|
'best_lag': 1,
|
||||||
"correlation": None,
|
'correlation': -0.38, # Negative = high load reduces HRV (expected)
|
||||||
"direction": "none",
|
'direction': 'negative',
|
||||||
"confidence": "insufficient",
|
'confidence': 'medium',
|
||||||
"data_points": 0,
|
'data_points': 25
|
||||||
"interpretation": f"Zu wenige gepaarte Tage mit Training und {vlabel}.",
|
|
||||||
"reason": "insufficient_pairs",
|
|
||||||
"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 {
|
return {
|
||||||
"best_lag": lag_b,
|
'best_lag': 1,
|
||||||
"correlation": round(r_b, 4),
|
'correlation': 0.42, # Positive = high load increases RHR (expected)
|
||||||
"direction": direction,
|
'direction': 'positive',
|
||||||
"confidence": conf,
|
'confidence': 'medium',
|
||||||
"data_points": n_b,
|
'data_points': 25
|
||||||
"interpretation": interp,
|
|
||||||
"lag_details": lag_details,
|
|
||||||
"vital": vital,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,283 +0,0 @@
|
||||||
"""
|
|
||||||
KPI-Kacheln für Layer-2b Fitness-Dashboard (Issue #53).
|
|
||||||
|
|
||||||
Ausgabe für KpiTilesOverview; ``keys`` = Platzhalter-Registry-Referenzen.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
|
|
||||||
def _verdict(status: str) -> str:
|
|
||||||
if status == "good":
|
|
||||||
return "Gut"
|
|
||||||
if status == "warn":
|
|
||||||
return "Hinweis"
|
|
||||||
return "Achtung"
|
|
||||||
|
|
||||||
|
|
||||||
def _minutes_status(minutes: Optional[int]) -> str:
|
|
||||||
if minutes is None:
|
|
||||||
return "warn"
|
|
||||||
if 150 <= minutes <= 300:
|
|
||||||
return "good"
|
|
||||||
if minutes < 150:
|
|
||||||
return "warn" if minutes >= 90 else "bad"
|
|
||||||
return "warn"
|
|
||||||
|
|
||||||
|
|
||||||
def _quality_status(pct: Optional[int]) -> str:
|
|
||||||
if pct is None:
|
|
||||||
return "warn"
|
|
||||||
if pct >= 60:
|
|
||||||
return "good"
|
|
||||||
if pct >= 40:
|
|
||||||
return "warn"
|
|
||||||
return "bad"
|
|
||||||
|
|
||||||
|
|
||||||
def _score_status(score: Optional[int]) -> str:
|
|
||||||
if score is None:
|
|
||||||
return "warn"
|
|
||||||
if score >= 70:
|
|
||||||
return "good"
|
|
||||||
if score >= 50:
|
|
||||||
return "warn"
|
|
||||||
return "bad"
|
|
||||||
|
|
||||||
|
|
||||||
def _vo2_status(trend: Optional[float]) -> str:
|
|
||||||
if trend is None:
|
|
||||||
return "warn"
|
|
||||||
if trend > 0.5:
|
|
||||||
return "good"
|
|
||||||
if trend >= -0.5:
|
|
||||||
return "warn"
|
|
||||||
return "bad"
|
|
||||||
|
|
||||||
|
|
||||||
def _vol_delta_status(delta_pct: Optional[float], prior7: int, last7: int) -> str:
|
|
||||||
if delta_pct is None:
|
|
||||||
if last7 > 0 and prior7 == 0:
|
|
||||||
return "good"
|
|
||||||
return "warn"
|
|
||||||
if delta_pct >= 5:
|
|
||||||
return "good"
|
|
||||||
if delta_pct >= -10:
|
|
||||||
return "warn"
|
|
||||||
return "bad"
|
|
||||||
|
|
||||||
|
|
||||||
def build_fitness_progress_insights(
|
|
||||||
vol_delta: Dict[str, Any],
|
|
||||||
load_meta: Dict[str, Any],
|
|
||||||
quality_pct: Optional[int],
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Kurz-Aussagen für die UI (Layer 2b), keine zweite Datenquelle.
|
|
||||||
"""
|
|
||||||
out: List[Dict[str, Any]] = []
|
|
||||||
if vol_delta.get("has_data"):
|
|
||||||
last7 = int(vol_delta.get("last7_min") or 0)
|
|
||||||
prev7 = int(vol_delta.get("prior7_min") or 0)
|
|
||||||
d = vol_delta.get("delta_pct")
|
|
||||||
if d is not None:
|
|
||||||
sign = "+" if d > 0 else ""
|
|
||||||
body = (
|
|
||||||
f"Trainingsminuten letzte 7 Tage ({last7} min) vs. Vorwoche ({prev7} min): "
|
|
||||||
f"{sign}{d} %."
|
|
||||||
)
|
|
||||||
elif last7 > 0 and prev7 == 0:
|
|
||||||
body = f"Mehr Volumen als in der Vorwoche: zuletzt {last7} min (Vorwoche 0 min)."
|
|
||||||
else:
|
|
||||||
body = "Zu wenig Daten für einen Vorwochen-Vergleich."
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"key": "ins_vol_trend",
|
|
||||||
"tone": _vol_delta_status(
|
|
||||||
float(d) if d is not None else None, prev7, last7
|
|
||||||
),
|
|
||||||
"title": "Volumen-Trend",
|
|
||||||
"body": body,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
acwr = load_meta.get("acwr")
|
|
||||||
st = load_meta.get("acwr_status")
|
|
||||||
if acwr is not None and isinstance(load_meta, dict) and load_meta.get("data_points", 0) > 0:
|
|
||||||
if st == "optimal":
|
|
||||||
tone = "good"
|
|
||||||
hint = "Akute zu chronischer Last (ACWR) liegt im oft empfohlenen Bereich (ca. 0,8–1,3)."
|
|
||||||
else:
|
|
||||||
tone = "warn"
|
|
||||||
hint = (
|
|
||||||
"ACWR außerhalb des häufig genannten Zielkorridors — bei anhaltender Belastung "
|
|
||||||
"Erholung oder Volumen prüfen (Proxy-Modell)."
|
|
||||||
)
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"key": "ins_acwr",
|
|
||||||
"tone": tone,
|
|
||||||
"title": "Belastungsverhältnis (ACWR)",
|
|
||||||
"body": f"Verhältnis akut (7 Tage) zu chronisch (28 Tage): {float(acwr):.2f}. {hint}",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if quality_pct is not None:
|
|
||||||
tone = "good" if quality_pct >= 60 else "warn" if quality_pct >= 40 else "bad"
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"key": "ins_quality",
|
|
||||||
"tone": tone,
|
|
||||||
"title": "Session-Qualität",
|
|
||||||
"body": f"{quality_pct} % der Sessions sind als «gut» oder besser eingestuft — Grundlage für progressive Belastung.",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def build_fitness_dashboard_kpi_tiles(
|
|
||||||
summary: Dict[str, Any],
|
|
||||||
minutes_7d: Optional[int],
|
|
||||||
quality_pct: Optional[int],
|
|
||||||
quality_window_days: int,
|
|
||||||
activity_score: Optional[int],
|
|
||||||
vo2_trend: Optional[float],
|
|
||||||
top_focus: Optional[Dict[str, Any]],
|
|
||||||
vol_delta: Optional[Dict[str, Any]] = None,
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
spw = summary.get("sessions_per_week")
|
|
||||||
try:
|
|
||||||
spw_f = float(spw) if spw is not None else None
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
spw_f = None
|
|
||||||
spw_s = f"{spw_f:.1f}".replace(".", ",") if spw_f is not None else "—"
|
|
||||||
|
|
||||||
m_status = _minutes_status(minutes_7d)
|
|
||||||
q_status = _quality_status(quality_pct)
|
|
||||||
s_status = _score_status(activity_score)
|
|
||||||
v_status = _vo2_status(vo2_trend)
|
|
||||||
|
|
||||||
tiles: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
if vol_delta and vol_delta.get("has_data"):
|
|
||||||
d = vol_delta.get("delta_pct")
|
|
||||||
last7 = int(vol_delta.get("last7_min") or 0)
|
|
||||||
prev7 = int(vol_delta.get("prior7_min") or 0)
|
|
||||||
if d is not None:
|
|
||||||
sign = "+" if float(d) > 0 else ""
|
|
||||||
v_s = f"{sign}{d:.1f} %".replace(".", ",")
|
|
||||||
sub = f"{last7} min vs. {prev7} min (7-Tage-Fenster)"
|
|
||||||
elif last7 > 0 and prev7 == 0:
|
|
||||||
v_s = "neu"
|
|
||||||
sub = f"{last7} min letzte Woche"
|
|
||||||
else:
|
|
||||||
v_s = "—"
|
|
||||||
sub = "Vergleich Vorwoche"
|
|
||||||
vd_st = _vol_delta_status(float(d) if d is not None else None, prev7, last7)
|
|
||||||
tiles.append(
|
|
||||||
{
|
|
||||||
"key": "volume_vs_prior_week",
|
|
||||||
"category": "Volumen vs. Vorwoche",
|
|
||||||
"icon": "📈",
|
|
||||||
"value": v_s,
|
|
||||||
"sublabel": sub,
|
|
||||||
"status": vd_st,
|
|
||||||
"verdict": _verdict(vd_st),
|
|
||||||
"hoverTop": "Fortschritt Trainingsminuten",
|
|
||||||
"hoverBody": "Letzte 7 Kalendertage vs. die 7 Tage davor (activity_log).",
|
|
||||||
"keys": ["training_minutes_week", "activity_summary"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
tiles.extend(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"key": "minutes_week",
|
|
||||||
"category": "Minuten (7 Tage)",
|
|
||||||
"icon": "⏱",
|
|
||||||
"value": f"{minutes_7d} min" if minutes_7d is not None else "—",
|
|
||||||
"sublabel": "WHO: 150–300 min/Woche",
|
|
||||||
"status": m_status,
|
|
||||||
"verdict": _verdict(m_status),
|
|
||||||
"hoverTop": "Summe Trainingsminuten (letzte 7 Tage)",
|
|
||||||
"hoverBody": "Gleiche Quelle wie Platzhalter training_minutes_week.",
|
|
||||||
"keys": ["training_minutes_week", "activity_score"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "sessions_per_week",
|
|
||||||
"category": "Sessions / Woche",
|
|
||||||
"icon": "📅",
|
|
||||||
"value": spw_s,
|
|
||||||
"sublabel": f"Fenster: {summary.get('days_analyzed', '—')} Tage",
|
|
||||||
"status": "good",
|
|
||||||
"verdict": "Gut",
|
|
||||||
"hoverTop": "Durchschnittliche Sessions pro Woche",
|
|
||||||
"hoverBody": "Aus activity_summary (activity_log im gewählten Zeitraum).",
|
|
||||||
"keys": ["activity_summary"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "quality_pct",
|
|
||||||
"category": "Qualitätssessions",
|
|
||||||
"icon": "✓",
|
|
||||||
"value": f"{quality_pct} %" if quality_pct is not None else "—",
|
|
||||||
"sublabel": f"Anteil «gut+» · {quality_window_days} Tage",
|
|
||||||
"status": q_status,
|
|
||||||
"verdict": _verdict(q_status),
|
|
||||||
"hoverTop": "Anteil Sessions mit guter Qualitätslabel-Klassifikation",
|
|
||||||
"hoverBody": "Entspricht quality_sessions_pct (Fenster wie gewählt).",
|
|
||||||
"keys": ["quality_sessions_pct"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "activity_score",
|
|
||||||
"category": "Activity-Score",
|
|
||||||
"icon": "🎯",
|
|
||||||
"value": str(activity_score) if activity_score is not None else "—",
|
|
||||||
"sublabel": "Ausrichtung an gewichteten Fokusbereichen",
|
|
||||||
"status": s_status,
|
|
||||||
"verdict": _verdict(s_status) if activity_score is not None else "Hinweis",
|
|
||||||
"hoverTop": "Gewichteter Score (0–100)",
|
|
||||||
"hoverBody": "Ohne gewichtete Aktivitäts-Fokusbereiche kein Score.",
|
|
||||||
"keys": ["activity_score"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "vo2_trend",
|
|
||||||
"category": "VO₂max-Trend",
|
|
||||||
"icon": "🫁",
|
|
||||||
"value": f"{vo2_trend:+.1f}" if vo2_trend is not None else "—",
|
|
||||||
"sublabel": "28-Tage-Trend (geschätzt)",
|
|
||||||
"status": v_status,
|
|
||||||
"verdict": _verdict(v_status) if vo2_trend is not None else "Hinweis",
|
|
||||||
"hoverTop": "Trend der VO₂max-Schätzung aus Aktivitätsdaten",
|
|
||||||
"hoverBody": "Wie vo2max_trend_28d im Data Layer.",
|
|
||||||
"keys": ["vo2max_trend_28d"],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
if top_focus:
|
|
||||||
prog = top_focus.get("progress")
|
|
||||||
prog_s = f"{prog} %" if prog is not None else "—"
|
|
||||||
w = top_focus.get("weight")
|
|
||||||
try:
|
|
||||||
w_s = f"{float(w):.0f} %" if w is not None else "—"
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
w_s = "—"
|
|
||||||
tiles.append(
|
|
||||||
{
|
|
||||||
"key": "top_focus",
|
|
||||||
"category": "Schwerpunkt-Fokus",
|
|
||||||
"icon": "🔭",
|
|
||||||
"value": str(top_focus.get("label") or "—"),
|
|
||||||
"sublabel": f"Fortschritt {prog_s} · Gewicht {w_s}",
|
|
||||||
"status": "good",
|
|
||||||
"verdict": "Gut",
|
|
||||||
"hoverTop": "Höchstgewichteter Fokusbereich",
|
|
||||||
"hoverBody": "Aus focus_area_definitions + Nutzer-Gewichtungen.",
|
|
||||||
"keys": ["top_focus_area_name", "top_focus_area_progress"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return tiles
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
"""
|
|
||||||
Layer 2b: Fitness-Hub — ein Bundle für die Aktivitäts-/Fitness-UI (Issue #53).
|
|
||||||
|
|
||||||
Single Source: activity_metrics + dieselben Hilfsfunktionen wie Chart-Endpunkte A1/A2.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
|
||||||
from data_layer.activity_metrics import (
|
|
||||||
build_load_monitoring_chart_payload,
|
|
||||||
build_quality_sessions_chart_payload,
|
|
||||||
build_training_type_distribution_chart_payload,
|
|
||||||
build_training_volume_chart_payload,
|
|
||||||
calculate_activity_score,
|
|
||||||
calculate_training_minutes_week,
|
|
||||||
calculate_quality_sessions_pct,
|
|
||||||
calculate_vo2max_trend_28d,
|
|
||||||
get_activity_summary_data,
|
|
||||||
get_training_volume_two_week_delta,
|
|
||||||
)
|
|
||||||
from data_layer.fitness_interpretation import (
|
|
||||||
build_fitness_dashboard_kpi_tiles,
|
|
||||||
build_fitness_progress_insights,
|
|
||||||
)
|
|
||||||
from data_layer.scores import get_top_focus_area
|
|
||||||
|
|
||||||
|
|
||||||
def _iso(d: Any) -> Optional[str]:
|
|
||||||
if d is None:
|
|
||||||
return None
|
|
||||||
if hasattr(d, "isoformat"):
|
|
||||||
return d.isoformat()[:10]
|
|
||||||
return str(d)[:10]
|
|
||||||
|
|
||||||
|
|
||||||
def _has_activity_entries(profile_id: str) -> bool:
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"SELECT 1 FROM activity_log WHERE profile_id=%s LIMIT 1",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
return cur.fetchone() is not None
|
|
||||||
|
|
||||||
|
|
||||||
def _last_activity_date(profile_id: str) -> Optional[str]:
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"SELECT MAX(date) AS d FROM activity_log WHERE profile_id=%s",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row or row["d"] is None:
|
|
||||||
return None
|
|
||||||
return _iso(row["d"])
|
|
||||||
|
|
||||||
|
|
||||||
def get_activity_last_updated_iso(profile_id: str) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Leichtgewicht: letztes activity_log.date — identisch zu ``last_updated`` im Fitness-Viz-Bundle.
|
|
||||||
|
|
||||||
Für History-Header o. Ä. ohne vollständige Aktivitätsliste (Phase A, Issue-53-Pfad).
|
|
||||||
"""
|
|
||||||
return _last_activity_date(profile_id)
|
|
||||||
|
|
||||||
|
|
||||||
def get_fitness_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Bundle für Fitness-Übersicht: KPI-Kacheln + eingebettete Chart-Payloads (Chart.js-Format).
|
|
||||||
|
|
||||||
``days``: Analysefenster für Zusammenfassung; >=9999 = lange Historie (max. 3650 Tage).
|
|
||||||
"""
|
|
||||||
if not _has_activity_entries(profile_id):
|
|
||||||
return {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"has_activity_entries": False,
|
|
||||||
"message": "Noch keine Aktivitätsdaten",
|
|
||||||
"kpi_tiles": [],
|
|
||||||
"summary": {},
|
|
||||||
"progress_insights": [],
|
|
||||||
"volume_delta": {},
|
|
||||||
"charts": {},
|
|
||||||
"meta": {"layer_1": "activity_metrics", "layer_2b": "fitness_viz"},
|
|
||||||
}
|
|
||||||
|
|
||||||
all_history = days >= 9999
|
|
||||||
eff_days = 3650 if all_history else max(7, min(int(days), 3650))
|
|
||||||
|
|
||||||
summary = get_activity_summary_data(profile_id, eff_days)
|
|
||||||
|
|
||||||
weeks_vol = max(4, min(52, (min(eff_days, 365) + 6) // 7))
|
|
||||||
dist_days = min(90, max(7, min(eff_days, 365)))
|
|
||||||
load_days = min(90, max(14, min(eff_days, 365)))
|
|
||||||
|
|
||||||
volume_chart = build_training_volume_chart_payload(profile_id, weeks_vol)
|
|
||||||
type_chart = build_training_type_distribution_chart_payload(profile_id, dist_days)
|
|
||||||
quality_chart = build_quality_sessions_chart_payload(profile_id, dist_days)
|
|
||||||
load_chart = build_load_monitoring_chart_payload(profile_id, load_days)
|
|
||||||
|
|
||||||
quality_days = dist_days
|
|
||||||
quality_pct = calculate_quality_sessions_pct(profile_id, quality_days)
|
|
||||||
minutes_7d = calculate_training_minutes_week(profile_id)
|
|
||||||
activity_score = calculate_activity_score(profile_id)
|
|
||||||
vo2_trend = calculate_vo2max_trend_28d(profile_id)
|
|
||||||
top_focus = get_top_focus_area(profile_id)
|
|
||||||
vol_delta = get_training_volume_two_week_delta(profile_id)
|
|
||||||
|
|
||||||
kpi_tiles = build_fitness_dashboard_kpi_tiles(
|
|
||||||
summary,
|
|
||||||
minutes_7d,
|
|
||||||
quality_pct,
|
|
||||||
quality_days,
|
|
||||||
activity_score,
|
|
||||||
vo2_trend,
|
|
||||||
top_focus,
|
|
||||||
vol_delta,
|
|
||||||
)
|
|
||||||
|
|
||||||
load_meta = load_chart.get("metadata") or {}
|
|
||||||
if not isinstance(load_meta, dict):
|
|
||||||
load_meta = {}
|
|
||||||
progress_insights = build_fitness_progress_insights(vol_delta, load_meta, quality_pct)
|
|
||||||
|
|
||||||
conf = summary.get("confidence") or "medium"
|
|
||||||
if summary.get("activity_count", 0) == 0:
|
|
||||||
conf = "insufficient"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"confidence": conf,
|
|
||||||
"has_activity_entries": True,
|
|
||||||
"days_requested": days,
|
|
||||||
"effective_window_days": eff_days,
|
|
||||||
"training_volume_weeks_used": weeks_vol,
|
|
||||||
"training_type_dist_days_used": dist_days,
|
|
||||||
"last_updated": _last_activity_date(profile_id),
|
|
||||||
"summary": summary,
|
|
||||||
"kpi_tiles": kpi_tiles,
|
|
||||||
"interpretation_tiles": [],
|
|
||||||
"progress_insights": progress_insights,
|
|
||||||
"volume_delta": vol_delta,
|
|
||||||
"charts": {
|
|
||||||
"training_volume": volume_chart,
|
|
||||||
"training_type_distribution": type_chart,
|
|
||||||
"quality_sessions": quality_chart,
|
|
||||||
"load_monitoring": load_chart,
|
|
||||||
},
|
|
||||||
"load_chart_days_used": load_days,
|
|
||||||
"meta": {
|
|
||||||
"layer_1": "activity_metrics",
|
|
||||||
"layer_2b": "fitness_viz",
|
|
||||||
"issue": "53-layer-2b-fitness",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
"""Catalog attribute definitions and numeric values (BLS EAV + extensions)."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
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) -> list[dict[str, Any]]:
|
|
||||||
q = (query or "").strip()
|
|
||||||
lim = min(max(int(limit or 40), 1), 80)
|
|
||||||
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 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()}
|
|
||||||
|
|
@ -1,265 +0,0 @@
|
||||||
"""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,
|
|
||||||
}
|
|
||||||
|
|
@ -1,525 +0,0 @@
|
||||||
"""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()
|
|
||||||
|
|
||||||
|
|
||||||
_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 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
|
|
||||||
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
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
(source_system, norm, 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
|
|
||||||
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
|
|
||||||
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:
|
|
||||||
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
|
|
||||||
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,
|
|
||||||
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()]
|
|
||||||
|
|
@ -1,323 +0,0 @@
|
||||||
"""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,
|
|
||||||
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 = _resolved_ingredient_qty(cur, profile_id, raw_name, 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_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 DISTINCT i.date::text AS date
|
|
||||||
FROM nutrition_items i
|
|
||||||
JOIN food_recipes r ON r.profile_id = i.profile_id
|
|
||||||
AND i.source_name_normalized = r.name_normalized
|
|
||||||
WHERE i.profile_id = %s AND i.food_id IS NULL
|
|
||||||
""",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
dates = [r["date"] for r in cur.fetchall()]
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
UPDATE nutrition_items i
|
|
||||||
SET recipe_id = r.id, updated_at = NOW()
|
|
||||||
FROM food_recipes r
|
|
||||||
WHERE i.profile_id = %s AND r.profile_id = %s
|
|
||||||
AND i.source_name_normalized = r.name_normalized
|
|
||||||
AND i.food_id IS NULL
|
|
||||||
""",
|
|
||||||
(profile_id, profile_id),
|
|
||||||
)
|
|
||||||
return cur.rowcount or 0, dates
|
|
||||||
|
|
||||||
|
|
||||||
def list_recipes(cur, profile_id: str) -> 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]
|
|
||||||
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
|
|
||||||
""",
|
|
||||||
(ids,),
|
|
||||||
)
|
|
||||||
by_r: dict[str, list] = {str(i): [] for i in ids}
|
|
||||||
for row in cur.fetchall():
|
|
||||||
by_r.setdefault(str(row["recipe_id"]), []).append(dict(row))
|
|
||||||
for rec in recipes:
|
|
||||||
rec["id"] = str(rec["id"])
|
|
||||||
rec["ingredients"] = by_r.get(rec["id"], [])
|
|
||||||
return recipes
|
|
||||||
|
|
||||||
|
|
||||||
def get_recipe(cur, profile_id: str, recipe_id: str) -> dict[str, Any] | None:
|
|
||||||
for rec in list_recipes(cur, profile_id):
|
|
||||||
if rec["id"] == str(recipe_id):
|
|
||||||
return rec
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
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:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
UPDATE nutrition_items
|
|
||||||
SET recipe_id = %s, food_id = NULL, mapping_id = NULL, value_origin = 'fddb', updated_at = NOW()
|
|
||||||
WHERE profile_id = %s AND source_name_normalized = %s
|
|
||||||
""",
|
|
||||||
(recipe_id, profile_id, source_name_normalized),
|
|
||||||
)
|
|
||||||
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
|
|
||||||
|
|
@ -1,311 +0,0 @@
|
||||||
"""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]:
|
|
||||||
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"),
|
|
||||||
"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
|
|
||||||
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 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], 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), 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
|
|
||||||
|
|
@ -1,251 +0,0 @@
|
||||||
"""
|
|
||||||
Layer 2b: Gesamtansicht «Verlauf» — komponiert nur Bundles aus body-, nutrition-, fitness-, recovery_viz.
|
|
||||||
|
|
||||||
Issue #53: keine parallele Business-Logik; ein Router-Endpoint liefert diese Zusammenfassung.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from data_layer.body_viz import get_body_history_viz_bundle
|
|
||||||
from data_layer.correlation_chart_payloads import (
|
|
||||||
build_lbm_protein_correlation_chart_payload,
|
|
||||||
build_load_vitals_correlation_chart_payload,
|
|
||||||
build_recovery_performance_chart_payload,
|
|
||||||
build_weight_energy_correlation_chart_payload,
|
|
||||||
)
|
|
||||||
from data_layer.correlations import calculate_lag_correlation, calculate_top_drivers
|
|
||||||
from data_layer.fitness_viz import get_fitness_dashboard_viz_bundle
|
|
||||||
from data_layer.nutrition_viz import get_nutrition_history_viz_bundle
|
|
||||||
from data_layer.recovery_viz import get_recovery_dashboard_viz_bundle
|
|
||||||
from data_layer.utils import safe_float
|
|
||||||
|
|
||||||
|
|
||||||
def _take_kpis(tiles: Any, max_n: int = 4) -> List[Dict[str, Any]]:
|
|
||||||
if not isinstance(tiles, list):
|
|
||||||
return []
|
|
||||||
out: List[Dict[str, Any]] = []
|
|
||||||
for t in tiles[:max_n]:
|
|
||||||
if not isinstance(t, dict):
|
|
||||||
continue
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"key": t.get("key"),
|
|
||||||
"category": t.get("category"),
|
|
||||||
"icon": t.get("icon"),
|
|
||||||
"value": t.get("value"),
|
|
||||||
"sublabel": t.get("sublabel"),
|
|
||||||
"status": t.get("status"),
|
|
||||||
"verdict": t.get("verdict"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _short_body_interpretation_tiles(tiles: Any, max_n: int = 3) -> List[Dict[str, Any]]:
|
|
||||||
"""Körper-Interpretationskacheln (keine KPI-Kacheln)."""
|
|
||||||
if not isinstance(tiles, list):
|
|
||||||
return []
|
|
||||||
out: List[Dict[str, Any]] = []
|
|
||||||
for t in tiles[:max_n]:
|
|
||||||
if not isinstance(t, dict):
|
|
||||||
continue
|
|
||||||
det = str(t.get("detail") or "")
|
|
||||||
if len(det) > 140:
|
|
||||||
det = det[:137] + "…"
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"title": t.get("title") or t.get("category") or "Hinweis",
|
|
||||||
"detail": det,
|
|
||||||
"status": t.get("status"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _take_insights(items: Any, max_n: int = 2) -> List[Dict[str, Any]]:
|
|
||||||
if not isinstance(items, list):
|
|
||||||
return []
|
|
||||||
out: List[Dict[str, Any]] = []
|
|
||||||
for it in items[:max_n]:
|
|
||||||
if not isinstance(it, dict):
|
|
||||||
continue
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"title": it.get("title") or it.get("title_de"),
|
|
||||||
"body": it.get("body") or it.get("detail") or it.get("message"),
|
|
||||||
"tone": it.get("tone") or it.get("status"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def get_history_overview_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Kompakte Übersicht für den ersten Reiter «Gesamtansicht»: KPI-Kurzformen + Lag-Korrelationen (C1–C4).
|
|
||||||
"""
|
|
||||||
eff = max(7, min(int(days), 9999))
|
|
||||||
body = get_body_history_viz_bundle(profile_id, eff)
|
|
||||||
nutr = get_nutrition_history_viz_bundle(profile_id, eff)
|
|
||||||
fit = get_fitness_dashboard_viz_bundle(profile_id, eff)
|
|
||||||
rec = get_recovery_dashboard_viz_bundle(profile_id, eff)
|
|
||||||
|
|
||||||
c1 = calculate_lag_correlation(profile_id, "energy_balance", "weight", 14)
|
|
||||||
c2 = calculate_lag_correlation(profile_id, "protein", "lbm", 14)
|
|
||||||
c3_hrv = calculate_lag_correlation(profile_id, "load", "hrv", 14)
|
|
||||||
c3_rhr = calculate_lag_correlation(profile_id, "load", "rhr", 14)
|
|
||||||
c3 = None
|
|
||||||
if c3_hrv and c3_rhr:
|
|
||||||
a1 = abs(safe_float(c3_hrv.get("correlation"), 0.0))
|
|
||||||
a2 = abs(safe_float(c3_rhr.get("correlation"), 0.0))
|
|
||||||
c3 = c3_hrv if a1 >= a2 else c3_rhr
|
|
||||||
if c3 is c3_hrv:
|
|
||||||
c3 = dict(c3)
|
|
||||||
c3["metric"] = "HRV"
|
|
||||||
else:
|
|
||||||
c3 = dict(c3_rhr)
|
|
||||||
c3["metric"] = "RHR"
|
|
||||||
elif c3_hrv:
|
|
||||||
c3 = dict(c3_hrv)
|
|
||||||
c3["metric"] = "HRV"
|
|
||||||
elif c3_rhr:
|
|
||||||
c3 = dict(c3_rhr)
|
|
||||||
c3["metric"] = "RHR"
|
|
||||||
|
|
||||||
drivers = calculate_top_drivers(profile_id)
|
|
||||||
|
|
||||||
b_sum = body.get("summary") if isinstance(body.get("summary"), dict) else {}
|
|
||||||
last_w = b_sum.get("weight_kg")
|
|
||||||
|
|
||||||
fs = fit.get("summary") if isinstance(fit.get("summary"), dict) else {}
|
|
||||||
if fit.get("has_activity_entries"):
|
|
||||||
ac = int(fs.get("activity_count") or 0)
|
|
||||||
fitness_line = f"{ac} Trainingseinheiten im gewählten Fenster"
|
|
||||||
else:
|
|
||||||
fitness_line = fit.get("message") or "Keine Trainingsdaten"
|
|
||||||
|
|
||||||
drv_list = drivers if isinstance(drivers, list) else []
|
|
||||||
|
|
||||||
return {
|
|
||||||
"days_requested": days,
|
|
||||||
"effective_window_days": eff,
|
|
||||||
"confidence": _overview_confidence(body, nutr, fit, rec),
|
|
||||||
"sections": [
|
|
||||||
{
|
|
||||||
"id": "body",
|
|
||||||
"title": "Körper",
|
|
||||||
"tab_id": "body",
|
|
||||||
"summary_line": (
|
|
||||||
f"Letztes Gewicht: {last_w} kg"
|
|
||||||
if last_w is not None
|
|
||||||
else "Keine Gewichtsdaten im Fenster"
|
|
||||||
),
|
|
||||||
"interpretation_short": _short_body_interpretation_tiles(body.get("interpretation_tiles"), 3),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "nutrition",
|
|
||||||
"title": "Ernährung",
|
|
||||||
"tab_id": "nutrition",
|
|
||||||
"summary_line": (
|
|
||||||
f"Ø {round(float((nutr.get('summary') or {}).get('kcal_avg') or 0))} kcal/Tag"
|
|
||||||
if nutr.get("has_nutrition_entries")
|
|
||||||
else (nutr.get("message") or "Keine Ernährungsdaten")
|
|
||||||
),
|
|
||||||
"kpi_short": _take_kpis(nutr.get("kpi_tiles"), 4),
|
|
||||||
"heuristic_short": (nutr.get("nutrition_correlation_heuristics") or [])[:2],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fitness",
|
|
||||||
"title": "Fitness",
|
|
||||||
"tab_id": "activity",
|
|
||||||
"summary_line": fitness_line,
|
|
||||||
"kpi_short": _take_kpis(fit.get("kpi_tiles"), 4),
|
|
||||||
"insights_short": _take_insights(fit.get("progress_insights"), 2),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "recovery",
|
|
||||||
"title": "Erholung",
|
|
||||||
"tab_id": "activity",
|
|
||||||
"summary_line": "Schlaf & Vitalwerte"
|
|
||||||
if rec.get("has_recovery_data")
|
|
||||||
else (rec.get("message") or "Keine Erholungsdaten"),
|
|
||||||
"kpi_short": _take_kpis(rec.get("kpi_tiles"), 4),
|
|
||||||
"insights_short": _take_insights(rec.get("progress_insights"), 2),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"lag_correlations": {
|
|
||||||
"weight_energy": _compact_lag("C1 Energiebilanz ↔ Gewicht", c1),
|
|
||||||
"protein_lbm": _compact_lag("C2 Protein ↔ Magermasse", c2),
|
|
||||||
"load_vitals": _compact_lag(
|
|
||||||
f"C3 Last ↔ {(c3 or {}).get('metric') or 'Vital'}",
|
|
||||||
c3,
|
|
||||||
extra_keys=("metric",),
|
|
||||||
),
|
|
||||||
"recovery_performance": {
|
|
||||||
"label": "C4 Top-Treiber (Einflussfaktoren)",
|
|
||||||
"drivers": drv_list[:8],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"chart_payloads": {
|
|
||||||
"c1_weight_energy": build_weight_energy_correlation_chart_payload(profile_id, 14),
|
|
||||||
"c2_protein_lbm": build_lbm_protein_correlation_chart_payload(profile_id, 14),
|
|
||||||
"c3_load_vitals": build_load_vitals_correlation_chart_payload(profile_id, 14),
|
|
||||||
"c4_recovery_performance": build_recovery_performance_chart_payload(profile_id),
|
|
||||||
},
|
|
||||||
"meta": {
|
|
||||||
"layer_1": "composed_metrics",
|
|
||||||
"layer_2b": "history_overview_viz",
|
|
||||||
"issue": "53-history-overview",
|
|
||||||
"sources": {
|
|
||||||
"body": "body_viz",
|
|
||||||
"nutrition": "nutrition_viz",
|
|
||||||
"fitness": "fitness_viz",
|
|
||||||
"recovery": "recovery_viz",
|
|
||||||
"lag": "correlations.calculate_lag_correlation",
|
|
||||||
"drivers": "correlations.calculate_top_drivers",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _overview_confidence(b: Dict, n: Dict, f: Dict, r: Dict) -> str:
|
|
||||||
scores = []
|
|
||||||
for x in (b, n, f, r):
|
|
||||||
c = x.get("confidence")
|
|
||||||
if c == "high":
|
|
||||||
scores.append(3)
|
|
||||||
elif c == "medium":
|
|
||||||
scores.append(2)
|
|
||||||
elif c == "low":
|
|
||||||
scores.append(1)
|
|
||||||
else:
|
|
||||||
scores.append(0)
|
|
||||||
s = sum(scores) / max(len(scores), 1)
|
|
||||||
if s >= 2.5:
|
|
||||||
return "high"
|
|
||||||
if s >= 1.5:
|
|
||||||
return "medium"
|
|
||||||
return "low"
|
|
||||||
|
|
||||||
|
|
||||||
def _compact_lag(
|
|
||||||
label: str,
|
|
||||||
payload: Optional[Dict[str, Any]],
|
|
||||||
extra_keys: tuple = (),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
if not payload:
|
|
||||||
return {"label": label, "available": False}
|
|
||||||
out: Dict[str, Any] = {
|
|
||||||
"label": label,
|
|
||||||
"available": payload.get("correlation") is not None,
|
|
||||||
"correlation": payload.get("correlation"),
|
|
||||||
"best_lag_days": payload.get("best_lag_days", payload.get("best_lag")),
|
|
||||||
"confidence": payload.get("confidence"),
|
|
||||||
"interpretation": payload.get("interpretation", ""),
|
|
||||||
"data_points": payload.get("data_points"),
|
|
||||||
}
|
|
||||||
for k in extra_keys:
|
|
||||||
if k in payload:
|
|
||||||
out[k] = payload[k]
|
|
||||||
return out
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
"""
|
|
||||||
Layer 1 Hilfslogik: Ernährung + Gewicht + Caliper (forward-filled Magermasse).
|
|
||||||
|
|
||||||
Genutzt von Layer 2b (nutrition_viz) und vom Router GET /api/nutrition/correlations.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from caliper_composition import as_date, compute_lean_fat_kg, nearest_weight_kg_from_map
|
|
||||||
|
|
||||||
|
|
||||||
def build_merged_daily_nutrition_body_rows(profile_id: str) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Pro Kalendertag: Makros aus nutrition_log, Gewicht, forward-filled Caliper (lean_mass, bf%).
|
|
||||||
Gleiche Semantik wie bisher ``GET /api/nutrition/correlations``.
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date", (profile_id,))
|
|
||||||
nutr: Dict[Any, Dict[str, Any]] = {}
|
|
||||||
for r in cur.fetchall():
|
|
||||||
rd = r2d(r)
|
|
||||||
dk = as_date(rd.get("date"))
|
|
||||||
if dk is not None:
|
|
||||||
nutr[dk] = rd
|
|
||||||
cur.execute("SELECT date, weight FROM weight_log WHERE profile_id=%s ORDER BY date", (profile_id,))
|
|
||||||
wlog: Dict[Any, Any] = {}
|
|
||||||
for r in cur.fetchall():
|
|
||||||
rd = r2d(r)
|
|
||||||
dk = as_date(rd.get("date"))
|
|
||||||
if dk is not None:
|
|
||||||
wlog[dk] = rd["weight"]
|
|
||||||
cur.execute(
|
|
||||||
"SELECT date, lean_mass, body_fat_pct FROM caliper_log WHERE profile_id=%s ORDER BY date",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
cals = [r2d(r) for r in cur.fetchall()]
|
|
||||||
cals = sorted(
|
|
||||||
[c for c in cals if as_date(c.get("date")) is not None],
|
|
||||||
key=lambda x: as_date(x["date"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Alle Keys sind datetime.date — vermeidet TypeError bei Vergleichen (str vs date)
|
|
||||||
all_dates = sorted(set(nutr.keys()) | set(wlog.keys()))
|
|
||||||
mi = 0
|
|
||||||
last_cal: Dict[str, Any] = {}
|
|
||||||
cal_by_date: Dict[Any, Dict[str, Any]] = {}
|
|
||||||
for d in all_dates:
|
|
||||||
while mi < len(cals):
|
|
||||||
cd = as_date(cals[mi].get("date"))
|
|
||||||
if cd is None:
|
|
||||||
mi += 1
|
|
||||||
continue
|
|
||||||
if cd > d:
|
|
||||||
break
|
|
||||||
last_cal = cals[mi]
|
|
||||||
mi += 1
|
|
||||||
if last_cal:
|
|
||||||
cal_by_date[d] = last_cal
|
|
||||||
|
|
||||||
result: List[Dict[str, Any]] = []
|
|
||||||
for d in all_dates:
|
|
||||||
if d not in nutr and d not in wlog:
|
|
||||||
continue
|
|
||||||
row: Dict[str, Any] = {"date": d}
|
|
||||||
if d in nutr:
|
|
||||||
for k in ("kcal", "protein_g", "fat_g", "carbs_g"):
|
|
||||||
v = nutr[d].get(k)
|
|
||||||
row[k] = float(v) if v is not None else None
|
|
||||||
if d in wlog:
|
|
||||||
row["weight"] = float(wlog[d])
|
|
||||||
if d in cal_by_date:
|
|
||||||
lm = cal_by_date[d].get("lean_mass")
|
|
||||||
bf = cal_by_date[d].get("body_fat_pct")
|
|
||||||
if bf is not None and lm is None:
|
|
||||||
wkg = nearest_weight_kg_from_map(wlog, d)
|
|
||||||
if wkg is not None:
|
|
||||||
lm, _fat = compute_lean_fat_kg(wkg, float(bf))
|
|
||||||
row["lean_mass"] = float(lm) if lm is not None else None
|
|
||||||
row["body_fat_pct"] = float(bf) if bf is not None else None
|
|
||||||
result.append(row)
|
|
||||||
return result
|
|
||||||
|
|
@ -1,404 +0,0 @@
|
||||||
"""
|
|
||||||
Chart.js-kompatible Payloads für Ernährungs-Charts (E1, E2, E4).
|
|
||||||
|
|
||||||
Gleiche Logik wie ``routers/charts.py`` — hier zentral, damit ``nutrition_viz``
|
|
||||||
und die API dieselbe Berechnung nutzen (Phase C, Issue 53).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
|
||||||
from data_layer.nutrition_metrics import (
|
|
||||||
get_energy_balance_data,
|
|
||||||
get_protein_adequacy_data,
|
|
||||||
get_protein_targets_data,
|
|
||||||
)
|
|
||||||
from data_layer.utils import calculate_confidence, safe_float, serialize_dates
|
|
||||||
|
|
||||||
|
|
||||||
def build_energy_balance_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
"""E1 Energiebilanz — identisch zu GET /api/charts/energy-balance."""
|
|
||||||
balance_meta = get_energy_balance_data(profile_id, days)
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT date, SUM(kcal)::float AS kcal
|
|
||||||
FROM nutrition_log
|
|
||||||
WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL
|
|
||||||
GROUP BY date
|
|
||||||
ORDER BY date""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
|
|
||||||
if not rows or len(rows) < 3:
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": len(rows) if rows else 0,
|
|
||||||
"message": "Nicht genug Ernährungsdaten (min. 3 Tage)",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
estimated_tdee = balance_meta.get("estimated_tdee") or 0
|
|
||||||
if estimated_tdee <= 0:
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": len(rows),
|
|
||||||
"message": "Kein Gewicht für TDEE-Schätzung (weight_log erforderlich)",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
labels = []
|
|
||||||
daily_values = []
|
|
||||||
avg_7d = []
|
|
||||||
avg_14d = []
|
|
||||||
|
|
||||||
for i, row in enumerate(rows):
|
|
||||||
labels.append(row["date"].isoformat())
|
|
||||||
daily_values.append(safe_float(row["kcal"]))
|
|
||||||
|
|
||||||
start_7d = max(0, i - 6)
|
|
||||||
window_7d = [safe_float(rows[j]["kcal"]) for j in range(start_7d, i + 1)]
|
|
||||||
avg_7d.append(round(sum(window_7d) / len(window_7d), 1) if window_7d else None)
|
|
||||||
|
|
||||||
start_14d = max(0, i - 13)
|
|
||||||
window_14d = [safe_float(rows[j]["kcal"]) for j in range(start_14d, i + 1)]
|
|
||||||
avg_14d.append(round(sum(window_14d) / len(window_14d), 1) if window_14d else None)
|
|
||||||
|
|
||||||
avg_intake = float(
|
|
||||||
balance_meta.get("avg_intake")
|
|
||||||
or (sum(daily_values) / len(daily_values) if daily_values else 0)
|
|
||||||
)
|
|
||||||
energy_balance = float(
|
|
||||||
balance_meta.get("energy_balance") or (avg_intake - estimated_tdee)
|
|
||||||
)
|
|
||||||
balance_status = balance_meta.get("status") or (
|
|
||||||
"deficit"
|
|
||||||
if energy_balance < -200
|
|
||||||
else "surplus"
|
|
||||||
if energy_balance > 200
|
|
||||||
else "maintenance"
|
|
||||||
)
|
|
||||||
|
|
||||||
datasets = [
|
|
||||||
{
|
|
||||||
"label": "Kalorien (täglich)",
|
|
||||||
"data": daily_values,
|
|
||||||
"borderColor": "#1D9E7599",
|
|
||||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
|
||||||
"borderWidth": 1.5,
|
|
||||||
"tension": 0.2,
|
|
||||||
"fill": False,
|
|
||||||
"pointRadius": 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Ø 7 Tage",
|
|
||||||
"data": avg_7d,
|
|
||||||
"borderColor": "#1D9E75",
|
|
||||||
"borderWidth": 2.5,
|
|
||||||
"tension": 0.3,
|
|
||||||
"fill": False,
|
|
||||||
"pointRadius": 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Ø 14 Tage",
|
|
||||||
"data": avg_14d,
|
|
||||||
"borderColor": "#085041",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"tension": 0.3,
|
|
||||||
"fill": False,
|
|
||||||
"pointRadius": 0,
|
|
||||||
"borderDash": [6, 3],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "TDEE (geschätzt)",
|
|
||||||
"data": [estimated_tdee] * len(labels),
|
|
||||||
"borderColor": "#888",
|
|
||||||
"borderWidth": 1,
|
|
||||||
"borderDash": [5, 5],
|
|
||||||
"fill": False,
|
|
||||||
"pointRadius": 0,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
confidence = balance_meta.get("confidence") or "low"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {"labels": labels, "datasets": datasets},
|
|
||||||
"metadata": serialize_dates(
|
|
||||||
{
|
|
||||||
"confidence": confidence,
|
|
||||||
"data_points": len(rows),
|
|
||||||
"avg_kcal": round(avg_intake, 1),
|
|
||||||
"estimated_tdee": estimated_tdee,
|
|
||||||
"energy_balance": round(energy_balance, 1),
|
|
||||||
"balance_status": balance_status,
|
|
||||||
"first_date": rows[0]["date"],
|
|
||||||
"last_date": rows[-1]["date"],
|
|
||||||
}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_protein_adequacy_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
"""E2 Protein Adequacy — identisch zu GET /api/charts/protein-adequacy."""
|
|
||||||
targets = get_protein_targets_data(profile_id)
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT date, SUM(protein_g)::float AS protein_g
|
|
||||||
FROM nutrition_log
|
|
||||||
WHERE profile_id=%s AND date >= %s AND protein_g IS NOT NULL
|
|
||||||
GROUP BY date
|
|
||||||
ORDER BY date""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
|
|
||||||
if not rows or len(rows) < 3:
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": len(rows) if rows else 0,
|
|
||||||
"message": "Nicht genug Protein-Daten (min. 3 Tage)",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
labels = []
|
|
||||||
daily_values = []
|
|
||||||
avg_7d = []
|
|
||||||
avg_28d = []
|
|
||||||
|
|
||||||
for i, row in enumerate(rows):
|
|
||||||
labels.append(row["date"].isoformat())
|
|
||||||
daily_values.append(safe_float(row["protein_g"]))
|
|
||||||
|
|
||||||
start_7d = max(0, i - 6)
|
|
||||||
window_7d = [safe_float(rows[j]["protein_g"]) for j in range(start_7d, i + 1)]
|
|
||||||
avg_7d.append(round(sum(window_7d) / len(window_7d), 1) if window_7d else None)
|
|
||||||
|
|
||||||
start_28d = max(0, i - 27)
|
|
||||||
window_28d = [safe_float(rows[j]["protein_g"]) for j in range(start_28d, i + 1)]
|
|
||||||
avg_28d.append(round(sum(window_28d) / len(window_28d), 1) if window_28d else None)
|
|
||||||
|
|
||||||
target_low = targets["protein_target_low"]
|
|
||||||
target_high = targets["protein_target_high"]
|
|
||||||
|
|
||||||
datasets = [
|
|
||||||
{
|
|
||||||
"label": "Protein (täglich)",
|
|
||||||
"data": daily_values,
|
|
||||||
"borderColor": "#1D9E7599",
|
|
||||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
|
||||||
"borderWidth": 1.5,
|
|
||||||
"tension": 0.2,
|
|
||||||
"fill": False,
|
|
||||||
"pointRadius": 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Ø 7 Tage",
|
|
||||||
"data": avg_7d,
|
|
||||||
"borderColor": "#1D9E75",
|
|
||||||
"borderWidth": 2.5,
|
|
||||||
"tension": 0.3,
|
|
||||||
"fill": False,
|
|
||||||
"pointRadius": 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Ø 28 Tage",
|
|
||||||
"data": avg_28d,
|
|
||||||
"borderColor": "#085041",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"tension": 0.3,
|
|
||||||
"fill": False,
|
|
||||||
"pointRadius": 0,
|
|
||||||
"borderDash": [6, 3],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Ziel Min",
|
|
||||||
"data": [target_low] * len(labels),
|
|
||||||
"borderColor": "#888",
|
|
||||||
"borderWidth": 1,
|
|
||||||
"borderDash": [5, 5],
|
|
||||||
"fill": False,
|
|
||||||
"pointRadius": 0,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
datasets.append(
|
|
||||||
{
|
|
||||||
"label": "Ziel Max",
|
|
||||||
"data": [target_high] * len(labels),
|
|
||||||
"borderColor": "#888",
|
|
||||||
"borderWidth": 1,
|
|
||||||
"borderDash": [5, 5],
|
|
||||||
"fill": False,
|
|
||||||
"pointRadius": 0,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
confidence = calculate_confidence(len(rows), days, "general")
|
|
||||||
|
|
||||||
days_in_target = sum(1 for v in daily_values if target_low <= v <= target_high)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {"labels": labels, "datasets": datasets},
|
|
||||||
"metadata": serialize_dates(
|
|
||||||
{
|
|
||||||
"confidence": confidence,
|
|
||||||
"data_points": len(rows),
|
|
||||||
"target_low": round(target_low, 1),
|
|
||||||
"target_high": round(target_high, 1),
|
|
||||||
"days_in_target": days_in_target,
|
|
||||||
"target_compliance_pct": round(
|
|
||||||
days_in_target / len(daily_values) * 100, 1
|
|
||||||
)
|
|
||||||
if daily_values
|
|
||||||
else 0,
|
|
||||||
"first_date": rows[0]["date"],
|
|
||||||
"last_date": rows[-1]["date"],
|
|
||||||
}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_nutrition_adherence_score_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
"""E4 Adhärenz — identisch zu GET /api/charts/nutrition-adherence-score."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT goal_mode FROM profiles WHERE id = %s", (profile_id,))
|
|
||||||
profile_row = cur.fetchone()
|
|
||||||
goal_mode = (
|
|
||||||
profile_row["goal_mode"]
|
|
||||||
if profile_row and profile_row["goal_mode"]
|
|
||||||
else "health"
|
|
||||||
)
|
|
||||||
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""WITH daily AS (
|
|
||||||
SELECT date,
|
|
||||||
COALESCE(SUM(kcal), 0)::float AS dk,
|
|
||||||
COALESCE(SUM(protein_g), 0)::float AS dp,
|
|
||||||
COALESCE(SUM(carbs_g), 0)::float AS dc,
|
|
||||||
COALESCE(SUM(fat_g), 0)::float AS df FROM nutrition_log
|
|
||||||
WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL
|
|
||||||
GROUP BY date
|
|
||||||
)
|
|
||||||
SELECT COUNT(*)::int AS cnt,
|
|
||||||
AVG(dk) AS avg_kcal,
|
|
||||||
STDDEV(dk) AS std_kcal,
|
|
||||||
AVG(dp) AS avg_protein,
|
|
||||||
AVG(dc) AS avg_carbs,
|
|
||||||
AVG(df) AS avg_fat
|
|
||||||
FROM daily""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
stats = cur.fetchone()
|
|
||||||
|
|
||||||
if not stats or stats["cnt"] < 7:
|
|
||||||
return {
|
|
||||||
"score": 0,
|
|
||||||
"components": {},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"message": "Nicht genug Daten (min. 7 Tage)",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
protein_data = get_protein_adequacy_data(profile_id, days)
|
|
||||||
|
|
||||||
calorie_adherence = 70.0
|
|
||||||
protein_adequacy_pct = protein_data.get("adequacy_score", 0)
|
|
||||||
protein_adherence = min(100, protein_adequacy_pct)
|
|
||||||
|
|
||||||
kcal_cv = (
|
|
||||||
(safe_float(stats["std_kcal"]) / safe_float(stats["avg_kcal"]) * 100)
|
|
||||||
if safe_float(stats["avg_kcal"]) > 0
|
|
||||||
else 100
|
|
||||||
)
|
|
||||||
intake_consistency = max(0, 100 - kcal_cv)
|
|
||||||
|
|
||||||
food_quality = 60.0
|
|
||||||
|
|
||||||
if goal_mode == "weight_loss":
|
|
||||||
weights = {
|
|
||||||
"calorie": 0.35,
|
|
||||||
"protein": 0.25,
|
|
||||||
"consistency": 0.20,
|
|
||||||
"quality": 0.20,
|
|
||||||
}
|
|
||||||
elif goal_mode == "strength":
|
|
||||||
weights = {
|
|
||||||
"calorie": 0.25,
|
|
||||||
"protein": 0.35,
|
|
||||||
"consistency": 0.20,
|
|
||||||
"quality": 0.20,
|
|
||||||
}
|
|
||||||
elif goal_mode == "endurance":
|
|
||||||
weights = {
|
|
||||||
"calorie": 0.30,
|
|
||||||
"protein": 0.20,
|
|
||||||
"consistency": 0.20,
|
|
||||||
"quality": 0.30,
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
weights = {
|
|
||||||
"calorie": 0.25,
|
|
||||||
"protein": 0.25,
|
|
||||||
"consistency": 0.25,
|
|
||||||
"quality": 0.25,
|
|
||||||
}
|
|
||||||
|
|
||||||
final_score = (
|
|
||||||
calorie_adherence * weights["calorie"]
|
|
||||||
+ protein_adherence * weights["protein"]
|
|
||||||
+ intake_consistency * weights["consistency"]
|
|
||||||
+ food_quality * weights["quality"]
|
|
||||||
)
|
|
||||||
|
|
||||||
components = {
|
|
||||||
"calorie_adherence": round(calorie_adherence, 1),
|
|
||||||
"protein_adherence": round(protein_adherence, 1),
|
|
||||||
"intake_consistency": round(intake_consistency, 1),
|
|
||||||
"food_quality": round(food_quality, 1),
|
|
||||||
}
|
|
||||||
|
|
||||||
weak_areas = [k for k, v in components.items() if v < 60]
|
|
||||||
if weak_areas:
|
|
||||||
recommendation = f"Verbesserungspotenzial: {', '.join(weak_areas)}"
|
|
||||||
else:
|
|
||||||
recommendation = "Gute Adhärenz, weiter so!"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"score": round(final_score, 1),
|
|
||||||
"components": components,
|
|
||||||
"goal_mode": goal_mode,
|
|
||||||
"weights": weights,
|
|
||||||
"recommendation": recommendation,
|
|
||||||
"metadata": {
|
|
||||||
"confidence": calculate_confidence(stats["cnt"], days, "general"),
|
|
||||||
"data_points": stats["cnt"],
|
|
||||||
"days_analyzed": days,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -1,323 +0,0 @@
|
||||||
"""
|
|
||||||
Interpretation + KPI-Kacheln für Layer 2b Ernährungs-Verlauf.
|
|
||||||
|
|
||||||
Gleiche Schwellen wie zuvor im Frontend (History.jsx); Ausgabe strukturiert
|
|
||||||
für KpiTilesOverview (keys = related_placeholder_keys).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
|
|
||||||
def _verdict(status: str) -> str:
|
|
||||||
if status == "good":
|
|
||||||
return "Gut"
|
|
||||||
if status == "warn":
|
|
||||||
return "Hinweis"
|
|
||||||
return "Achtung"
|
|
||||||
|
|
||||||
|
|
||||||
def build_nutrition_history_kpi_tiles(
|
|
||||||
navg: Dict[str, Any],
|
|
||||||
targets: Dict[str, Any],
|
|
||||||
date_span_label: str,
|
|
||||||
n_days_with_entries: int,
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
KPI-Kacheln wie buildNutritionKpiTiles im Frontend (Kalorien/KH/Fett + Regeln).
|
|
||||||
"""
|
|
||||||
kcal_avg = round(float(navg.get("kcal_avg") or 0))
|
|
||||||
avg_carbs = round(float(navg.get("carbs_avg") or 0) * 10) / 10
|
|
||||||
avg_fat = round(float(navg.get("fat_avg") or 0) * 10) / 10
|
|
||||||
avg_protein = round(float(navg.get("protein_avg") or 0) * 10) / 10
|
|
||||||
|
|
||||||
pt_low = round(float(targets.get("protein_target_low") or 0))
|
|
||||||
pt_high = round(float(targets.get("protein_target_high") or 0))
|
|
||||||
targets_ok = targets.get("confidence") != "insufficient" and pt_low > 0
|
|
||||||
protein_ok = targets_ok and avg_protein >= pt_low
|
|
||||||
|
|
||||||
total_macro_kcal = avg_protein * 4 + avg_carbs * 4 + avg_fat * 9
|
|
||||||
prot_pct = (
|
|
||||||
round(avg_protein * 4 / total_macro_kcal * 100)
|
|
||||||
if total_macro_kcal > 0
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
kh_pct = (
|
|
||||||
round(avg_carbs * 4 / total_macro_kcal * 100)
|
|
||||||
if total_macro_kcal > 0
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
fat_pct = (
|
|
||||||
round(avg_fat * 9 / total_macro_kcal * 100)
|
|
||||||
if total_macro_kcal > 0
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
|
|
||||||
tiles: List[Dict[str, Any]] = [
|
|
||||||
{
|
|
||||||
"key": "kcal",
|
|
||||||
"category": "Kalorien (Ø)",
|
|
||||||
"icon": "🔥",
|
|
||||||
"value": f"{kcal_avg} kcal",
|
|
||||||
"sublabel": date_span_label,
|
|
||||||
"status": "good",
|
|
||||||
"verdict": "Gut",
|
|
||||||
"hoverTop": "Durchschnittliche tägliche Energie",
|
|
||||||
"hoverBody": f"Mittel über {n_days_with_entries} Tage mit Ernährungseinträgen im gewählten Zeitraum.",
|
|
||||||
"keys": ["nutrition_score"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "carbs",
|
|
||||||
"category": "KH (Ø)",
|
|
||||||
"icon": "🌾",
|
|
||||||
"value": f"{avg_carbs} g",
|
|
||||||
"sublabel": "Kohlenhydrate / Tag",
|
|
||||||
"status": "good",
|
|
||||||
"verdict": "Gut",
|
|
||||||
"hoverTop": "Durchschnittliche Kohlenhydrate",
|
|
||||||
"hoverBody": "Summe der täglichen Werte im Zeitraum, gemittelt.",
|
|
||||||
"keys": ["nutrition_summary"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "fat",
|
|
||||||
"category": "Fett (Ø)",
|
|
||||||
"icon": "🧈",
|
|
||||||
"value": f"{avg_fat} g",
|
|
||||||
"sublabel": "Fett / Tag",
|
|
||||||
"status": "good",
|
|
||||||
"verdict": "Gut",
|
|
||||||
"hoverTop": "Durchschnittliches Fett",
|
|
||||||
"hoverBody": "Summe der täglichen Werte im Zeitraum, gemittelt.",
|
|
||||||
"keys": ["nutrition_summary"],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
if not targets_ok:
|
|
||||||
tiles.append(
|
|
||||||
{
|
|
||||||
"key": "eval-protein",
|
|
||||||
"category": "Protein",
|
|
||||||
"icon": "🥩",
|
|
||||||
"value": f"{avg_protein}g",
|
|
||||||
"sublabel": "Referenzgewicht fehlt",
|
|
||||||
"status": "warn",
|
|
||||||
"verdict": _verdict("warn"),
|
|
||||||
"hint": "Ohne aktuelles Körpergewicht lässt sich das Protein-Ziel (g/kg) nicht bewerten.",
|
|
||||||
"hoverTop": "Protein-Ziel nicht berechenbar",
|
|
||||||
"hoverBody": "Für 1,6–2,2 g/kg wird ein aktuelles Körpergewicht benötigt.",
|
|
||||||
"keys": ["protein_adequacy"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
elif not protein_ok:
|
|
||||||
miss = max(0, pt_low - round(avg_protein))
|
|
||||||
tiles.append(
|
|
||||||
{
|
|
||||||
"key": "eval-protein",
|
|
||||||
"category": "Protein",
|
|
||||||
"icon": "🥩",
|
|
||||||
"value": f"{avg_protein}g",
|
|
||||||
"sublabel": f"Unterversorgung: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)",
|
|
||||||
"status": "bad",
|
|
||||||
"verdict": _verdict("bad"),
|
|
||||||
"hint": (
|
|
||||||
f"~{miss} g Protein/Tag fehlen – bei Defizit Muskelerhalt gefährdet."
|
|
||||||
),
|
|
||||||
"hoverTop": f"Unterversorgung: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)",
|
|
||||||
"hoverBody": (
|
|
||||||
f"1,6–2,2g/kg KG. Fehlend: ~{miss}g täglich. "
|
|
||||||
"Konsequenz: Muskelverlust bei Defizit."
|
|
||||||
),
|
|
||||||
"keys": ["protein_adequacy", "nutrition_score"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
tiles.append(
|
|
||||||
{
|
|
||||||
"key": "eval-protein",
|
|
||||||
"category": "Protein",
|
|
||||||
"icon": "🥩",
|
|
||||||
"value": f"{avg_protein}g",
|
|
||||||
"sublabel": f"Gut: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)",
|
|
||||||
"status": "good",
|
|
||||||
"verdict": _verdict("good"),
|
|
||||||
"hoverTop": f"Gut: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)",
|
|
||||||
"hoverBody": "Ausreichend für Muskelerhalt und -aufbau.",
|
|
||||||
"keys": ["protein_adequacy", "nutrition_score"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if prot_pct < 20 and total_macro_kcal > 0:
|
|
||||||
tiles.append(
|
|
||||||
{
|
|
||||||
"key": "eval-macro-pct",
|
|
||||||
"category": "Makro-Anteil",
|
|
||||||
"icon": "📊",
|
|
||||||
"value": f"{prot_pct}%",
|
|
||||||
"sublabel": f"Protein-Anteil niedrig: {prot_pct}% der Kalorien",
|
|
||||||
"status": "warn",
|
|
||||||
"verdict": _verdict("warn"),
|
|
||||||
"hint": (
|
|
||||||
f"Protein-Kalorienanteil niedrig (P {prot_pct} % / KH {kh_pct} % / F {fat_pct} %); "
|
|
||||||
"Ziel oft 25–35 %."
|
|
||||||
),
|
|
||||||
"hoverTop": f"Protein-Anteil niedrig: {prot_pct}% der Kalorien",
|
|
||||||
"hoverBody": (
|
|
||||||
f"Empfehlung oft 25–35%. Aktuell: {prot_pct}% P / {kh_pct}% KH / {fat_pct}% F"
|
|
||||||
),
|
|
||||||
"keys": ["nutrition_summary"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return tiles
|
|
||||||
|
|
||||||
|
|
||||||
def build_energy_availability_kpi_tile(ea: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
||||||
"""E5: nur bei caution/warning — gleiche Daten wie /charts/energy-availability-warning."""
|
|
||||||
level = str(ea.get("warning_level") or "none").strip().lower()
|
|
||||||
if level == "none":
|
|
||||||
return None
|
|
||||||
triggers: List[str] = list(ea.get("triggers") or [])
|
|
||||||
msg = str(ea.get("message") or "").strip()
|
|
||||||
st = "bad" if level == "warning" else "warn"
|
|
||||||
first = triggers[0] if triggers else msg
|
|
||||||
if len(first) > 90:
|
|
||||||
first = first[:87] + "…"
|
|
||||||
meta = ea.get("metadata") if isinstance(ea.get("metadata"), dict) else {}
|
|
||||||
note = str(meta.get("note") or "")
|
|
||||||
hover_lines = [msg] + [f"• {t}" for t in triggers]
|
|
||||||
if note:
|
|
||||||
hover_lines.append(note)
|
|
||||||
return {
|
|
||||||
"key": "energy-availability-e5",
|
|
||||||
"category": "Energieverfügbarkeit",
|
|
||||||
"icon": "⚡",
|
|
||||||
"value": "Achtung" if level == "warning" else "Hinweis",
|
|
||||||
"sublabel": first or "Signale prüfen",
|
|
||||||
"status": st,
|
|
||||||
"verdict": _verdict(st),
|
|
||||||
"hint": msg,
|
|
||||||
"hoverTop": "Energieverfügbarkeit (Heuristik)",
|
|
||||||
"hoverBody": "\n".join(hover_lines),
|
|
||||||
"keys": ["nutrition_score"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_macro_donut_from_averages(navg: Dict[str, Any]) -> Optional[List[Dict[str, Any]]]:
|
|
||||||
"""Anteile in % der Makro-kcal + Gramm für Legende."""
|
|
||||||
p = float(navg.get("protein_avg") or 0)
|
|
||||||
c = float(navg.get("carbs_avg") or 0)
|
|
||||||
f = float(navg.get("fat_avg") or 0)
|
|
||||||
pkcal, ckcal, fkcal = p * 4, c * 4, f * 9
|
|
||||||
tot = pkcal + ckcal + fkcal
|
|
||||||
if tot <= 0:
|
|
||||||
return None
|
|
||||||
return [
|
|
||||||
{"name": "Protein", "value": round(pkcal / tot * 100), "color": "#4a8f72", "grams": round(p, 1)},
|
|
||||||
{"name": "KH", "value": round(ckcal / tot * 100), "color": "#c17d45", "grams": round(c, 1)},
|
|
||||||
{"name": "Fett", "value": round(fkcal / tot * 100), "color": "#6e8eb8", "grams": round(f, 1)},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def build_nutrition_correlation_heuristic_items(
|
|
||||||
merged_rows: List[Dict[str, Any]],
|
|
||||||
tdee_kcal: float,
|
|
||||||
protein_target_low_g: float,
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Heuristische Kurz-Aussagen (vormals Reiter «Korrelation») — gleiche Logik wie History.jsx,
|
|
||||||
TDEE aber aus Data-Layer (nutrition_metrics / estimate_tdee), nicht ×1,4 im Frontend.
|
|
||||||
"""
|
|
||||||
filtered = [
|
|
||||||
r
|
|
||||||
for r in merged_rows
|
|
||||||
if r.get("kcal") is not None and r.get("weight") is not None
|
|
||||||
]
|
|
||||||
if len(filtered) < 5:
|
|
||||||
return []
|
|
||||||
|
|
||||||
td = float(tdee_kcal)
|
|
||||||
latest_w = float(filtered[-1].get("weight") or 0) or 80.0
|
|
||||||
pt_low = round(float(protein_target_low_g or 0)) or max(1, round(latest_w * 1.6))
|
|
||||||
|
|
||||||
items: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
if len(filtered) >= 14:
|
|
||||||
high_k = [d for d in filtered if float(d.get("kcal") or 0) > td + 200]
|
|
||||||
low_k = [d for d in filtered if float(d.get("kcal") or 0) < td - 200]
|
|
||||||
if len(high_k) >= 3 and len(low_k) >= 3:
|
|
||||||
avg_wh = sum(float(d["weight"]) for d in high_k) / len(high_k)
|
|
||||||
avg_wl = sum(float(d["weight"]) for d in low_k) / len(low_k)
|
|
||||||
avg_wh_r = round(avg_wh * 10) / 10
|
|
||||||
avg_wl_r = round(avg_wl * 10) / 10
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"icon": "📊",
|
|
||||||
"status": "good" if avg_wl < avg_wh else "warn",
|
|
||||||
"title": (
|
|
||||||
f"Kalorienreduktion wirkt: Ø {avg_wl_r} kg bei Defizit vs. {avg_wh_r} kg bei Überschuss"
|
|
||||||
if avg_wl < avg_wh
|
|
||||||
else "Kein klarer Kalorieneffekt auf Gewicht erkennbar"
|
|
||||||
),
|
|
||||||
"detail": (
|
|
||||||
f"Tage mit Überschuss (>{int(td + 200)} kcal): Ø {avg_wh_r} kg · "
|
|
||||||
f"Tage mit Defizit (<{int(td - 200)} kcal): Ø {avg_wl_r} kg"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
prot_vs_lean = [
|
|
||||||
d
|
|
||||||
for d in filtered
|
|
||||||
if d.get("protein_g") is not None and d.get("lean_mass") is not None
|
|
||||||
]
|
|
||||||
if len(prot_vs_lean) >= 3:
|
|
||||||
high_p = [d for d in prot_vs_lean if float(d.get("protein_g") or 0) >= pt_low]
|
|
||||||
low_p = [d for d in prot_vs_lean if float(d.get("protein_g") or 0) < pt_low]
|
|
||||||
if len(high_p) >= 2 and len(low_p) >= 2:
|
|
||||||
avg_lh = sum(float(d["lean_mass"]) for d in high_p) / len(high_p)
|
|
||||||
avg_ll = sum(float(d["lean_mass"]) for d in low_p) / len(low_p)
|
|
||||||
avg_lh_r = round(avg_lh * 10) / 10
|
|
||||||
avg_ll_r = round(avg_ll * 10) / 10
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"icon": "🥩",
|
|
||||||
"status": "good" if avg_lh >= avg_ll else "warn",
|
|
||||||
"title": (
|
|
||||||
f"Hohe Proteinzufuhr (≥{pt_low} g): Ø {avg_lh_r} kg Mager · Niedrig: Ø {avg_ll_r} kg"
|
|
||||||
),
|
|
||||||
"detail": (
|
|
||||||
f"{len(high_p)} Messpunkte mit hoher vs. {len(low_p)} mit niedriger Proteinzufuhr verglichen."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
balances = [float(d["kcal"]) - td for d in filtered if d.get("kcal") is not None]
|
|
||||||
avg_balance = int(round(sum(balances) / len(balances))) if balances else 0
|
|
||||||
ab_s = f"{avg_balance:+d}" if avg_balance > 0 else str(avg_balance)
|
|
||||||
if avg_balance < -100:
|
|
||||||
ic, st = "✅", "good"
|
|
||||||
elif avg_balance > 200:
|
|
||||||
ic, st = "⬆️", "warn" if avg_balance > 300 else "good"
|
|
||||||
else:
|
|
||||||
ic, st = "➡️", "good"
|
|
||||||
|
|
||||||
if avg_balance < -500:
|
|
||||||
bal_detail = "Starkes Defizit – Muskelerhalt durch ausreichend Protein sicherstellen."
|
|
||||||
elif avg_balance < -100:
|
|
||||||
bal_detail = "Moderates Defizit – ideal für Fettabbau bei Muskelerhalt."
|
|
||||||
elif avg_balance > 300:
|
|
||||||
bal_detail = "Kalorienüberschuss – günstig für Muskelaufbau, Fettzunahme möglich."
|
|
||||||
else:
|
|
||||||
bal_detail = "Nahezu ausgeglichen – Gewicht sollte stabil bleiben."
|
|
||||||
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"icon": ic,
|
|
||||||
"status": st,
|
|
||||||
"title": f"Ø Kalorienbilanz: {ab_s} kcal/Tag",
|
|
||||||
"detail": f"Geschätzter TDEE: {int(round(td))} kcal (Data-Layer, konsistent mit Verlauf). {bal_detail}",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return items
|
|
||||||
|
|
@ -1,466 +0,0 @@
|
||||||
"""Nutrition diary items, three macro sums, import policy, attribute resolve."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from datetime import date, datetime
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
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 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()]
|
|
||||||
|
|
@ -20,100 +20,15 @@ Phase 0c: Multi-Layer Architecture
|
||||||
Version: 1.0
|
Version: 1.0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import statistics
|
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
from datetime import datetime, timedelta, date
|
from datetime import datetime, timedelta, date
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
from data_layer.utils import calculate_confidence, safe_float, safe_int
|
from data_layer.utils import calculate_confidence, safe_float, safe_int
|
||||||
|
|
||||||
# Fallback TDEE (kcal/day) when demographics for Mifflin–St Jeor are incomplete.
|
|
||||||
TDEE_KCAL_PER_KG_BODYWEIGHT = 32.5
|
|
||||||
# PAL applied to MSJ BMR when height, sex, dob and weight are available (moderate activity).
|
|
||||||
TDEE_PAL_MODERATE = 1.55
|
|
||||||
|
|
||||||
|
|
||||||
def _age_years_from_dob(dob) -> Optional[int]:
|
|
||||||
if dob is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
if isinstance(dob, str):
|
|
||||||
birth = datetime.strptime(dob[:10], "%Y-%m-%d").date()
|
|
||||||
else:
|
|
||||||
birth = dob
|
|
||||||
today = date.today()
|
|
||||||
return today.year - birth.year - ((today.month, today.day) < (birth.month, birth.day))
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _mifflin_st_jeor_bmr_kcal(
|
|
||||||
weight_kg: float, height_cm: float, age_years: int, sex_is_male: bool
|
|
||||||
) -> float:
|
|
||||||
if sex_is_male:
|
|
||||||
return 10.0 * weight_kg + 6.25 * height_cm - 5.0 * age_years + 5.0
|
|
||||||
return 10.0 * weight_kg + 6.25 * height_cm - 5.0 * age_years - 161.0
|
|
||||||
|
|
||||||
|
|
||||||
def estimate_tdee_kcal_from_latest_weight(profile_id: str) -> Optional[float]:
|
|
||||||
"""
|
|
||||||
Estimated TDEE (kcal/day).
|
|
||||||
|
|
||||||
Primary: Mifflin–St Jeor BMR × TDEE_PAL_MODERATE when latest weight plus
|
|
||||||
profiles.height, profiles.sex, profiles.dob are usable.
|
|
||||||
|
|
||||||
Fallback: latest weight (kg) × TDEE_KCAL_PER_KG_BODYWEIGHT (legacy heuristic).
|
|
||||||
|
|
||||||
Returns None if no weight on record.
|
|
||||||
"""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT weight FROM weight_log
|
|
||||||
WHERE profile_id=%s ORDER BY date DESC LIMIT 1""",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
wrow = cur.fetchone()
|
|
||||||
if not wrow or wrow["weight"] is None:
|
|
||||||
return None
|
|
||||||
weight_kg = float(wrow["weight"])
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"SELECT height, sex, dob FROM profiles WHERE id=%s",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
prow = cur.fetchone()
|
|
||||||
|
|
||||||
if prow and prow.get("height") and prow.get("sex") is not None and prow.get("dob"):
|
|
||||||
height_cm = float(prow["height"])
|
|
||||||
age = _age_years_from_dob(prow["dob"])
|
|
||||||
if age is not None and 10 < age < 120 and height_cm > 50:
|
|
||||||
sex_raw = str(prow["sex"]).strip().lower()
|
|
||||||
sex_is_male = sex_raw in ("m", "male", "männlich", "mann")
|
|
||||||
bmr = _mifflin_st_jeor_bmr_kcal(weight_kg, height_cm, age, sex_is_male)
|
|
||||||
if bmr > 400:
|
|
||||||
return bmr * TDEE_PAL_MODERATE
|
|
||||||
|
|
||||||
return weight_kg * TDEE_KCAL_PER_KG_BODYWEIGHT
|
|
||||||
|
|
||||||
|
|
||||||
def _get_profile_goal_mode(profile_id: str) -> str:
|
|
||||||
"""Strategic goal_mode from profiles (Phase 0a); defaults to health."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT goal_mode FROM profiles WHERE id=%s", (profile_id,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
if row and row.get("goal_mode"):
|
|
||||||
g = str(row["goal_mode"]).strip().lower()
|
|
||||||
if g:
|
|
||||||
return g
|
|
||||||
return "health"
|
|
||||||
|
|
||||||
|
|
||||||
def get_nutrition_average_data(
|
def get_nutrition_average_data(
|
||||||
profile_id: str,
|
profile_id: str,
|
||||||
days: int = 30,
|
days: int = 30
|
||||||
*,
|
|
||||||
all_history: bool = False,
|
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""
|
"""
|
||||||
Get average nutrition values for all macros.
|
Get average nutrition values for all macros.
|
||||||
|
|
@ -139,38 +54,22 @@ def get_nutrition_average_data(
|
||||||
"""
|
"""
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cutoff = None if all_history else (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||||
|
|
||||||
# Mean over calendar days (per-day sums), not over raw log rows.
|
|
||||||
if cutoff:
|
|
||||||
inner_where = "WHERE profile_id=%s AND date >= %s"
|
|
||||||
params = (profile_id, cutoff)
|
|
||||||
else:
|
|
||||||
inner_where = "WHERE profile_id=%s"
|
|
||||||
params = (profile_id,)
|
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""SELECT
|
"""SELECT
|
||||||
AVG(daily_kcal) AS kcal_avg,
|
AVG(kcal) as kcal_avg,
|
||||||
AVG(daily_protein) AS protein_avg,
|
AVG(protein_g) as protein_avg,
|
||||||
AVG(daily_carbs) AS carbs_avg,
|
AVG(carbs_g) as carbs_avg,
|
||||||
AVG(daily_fat) AS fat_avg,
|
AVG(fat_g) as fat_avg,
|
||||||
COUNT(*)::int AS day_count
|
COUNT(*) as data_points
|
||||||
FROM (
|
|
||||||
SELECT date,
|
|
||||||
COALESCE(SUM(kcal), 0)::float AS daily_kcal,
|
|
||||||
COALESCE(SUM(protein_g), 0)::float AS daily_protein,
|
|
||||||
COALESCE(SUM(carbs_g), 0)::float AS daily_carbs,
|
|
||||||
COALESCE(SUM(fat_g), 0)::float AS daily_fat
|
|
||||||
FROM nutrition_log
|
FROM nutrition_log
|
||||||
{inner_where}
|
WHERE profile_id=%s AND date >= %s""",
|
||||||
GROUP BY date
|
(profile_id, cutoff)
|
||||||
) AS daily""",
|
|
||||||
params,
|
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
|
|
||||||
if not row or row["day_count"] == 0:
|
if not row or row['data_points'] == 0:
|
||||||
return {
|
return {
|
||||||
"kcal_avg": 0.0,
|
"kcal_avg": 0.0,
|
||||||
"protein_avg": 0.0,
|
"protein_avg": 0.0,
|
||||||
|
|
@ -181,7 +80,7 @@ def get_nutrition_average_data(
|
||||||
"days_analyzed": days
|
"days_analyzed": days
|
||||||
}
|
}
|
||||||
|
|
||||||
data_points = row["day_count"]
|
data_points = row['data_points']
|
||||||
confidence = calculate_confidence(data_points, days, "general")
|
confidence = calculate_confidence(data_points, days, "general")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -291,26 +190,40 @@ def get_energy_balance_data(
|
||||||
days: int = 7
|
days: int = 7
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""
|
"""
|
||||||
Energy balance (intake - estimated expenditure), kcal/day.
|
Calculate energy balance (intake - estimated expenditure).
|
||||||
|
|
||||||
Intake: mean of daily total kcal (sum per calendar day).
|
Note: This is a simplified calculation.
|
||||||
TDEE: estimate_tdee_kcal_from_latest_weight (MSJ × PAL oder kg-Fallback).
|
For accurate TDEE, use profile-based calculations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile_id: User profile ID
|
||||||
|
days: Analysis window (default 7)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"energy_balance": float, # kcal/day (negative = deficit)
|
||||||
|
"avg_intake": float,
|
||||||
|
"estimated_tdee": float,
|
||||||
|
"status": str, # "deficit" | "surplus" | "maintenance"
|
||||||
|
"confidence": str,
|
||||||
|
"days_analyzed": int,
|
||||||
|
"data_points": int
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
# Get average intake
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""SELECT date, SUM(kcal)::float AS daily_kcal
|
"""SELECT AVG(kcal) as avg_kcal, COUNT(*) as cnt
|
||||||
FROM nutrition_log
|
FROM nutrition_log
|
||||||
WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL
|
WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL""",
|
||||||
GROUP BY date
|
(profile_id, cutoff)
|
||||||
ORDER BY date""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
)
|
||||||
daily_rows = cur.fetchall()
|
row = cur.fetchone()
|
||||||
|
|
||||||
if not daily_rows:
|
if not row or row['cnt'] == 0:
|
||||||
return {
|
return {
|
||||||
"energy_balance": 0.0,
|
"energy_balance": 0.0,
|
||||||
"avg_intake": 0.0,
|
"avg_intake": 0.0,
|
||||||
|
|
@ -318,27 +231,19 @@ def get_energy_balance_data(
|
||||||
"status": "unknown",
|
"status": "unknown",
|
||||||
"confidence": "insufficient",
|
"confidence": "insufficient",
|
||||||
"days_analyzed": days,
|
"days_analyzed": days,
|
||||||
"data_points": 0,
|
"data_points": 0
|
||||||
}
|
}
|
||||||
|
|
||||||
daily_totals = [safe_float(r["daily_kcal"]) for r in daily_rows]
|
avg_intake = safe_float(row['avg_kcal'])
|
||||||
avg_intake = sum(daily_totals) / len(daily_totals)
|
data_points = row['cnt']
|
||||||
data_points = len(daily_totals)
|
|
||||||
|
|
||||||
estimated_tdee = estimate_tdee_kcal_from_latest_weight(profile_id)
|
# Simple TDEE estimation (this should be improved with profile data)
|
||||||
if estimated_tdee is None:
|
# For now, use a rough estimate: 2500 kcal for average adult
|
||||||
return {
|
estimated_tdee = 2500.0 # TODO: Calculate from profile (weight, height, age, activity)
|
||||||
"energy_balance": 0.0,
|
|
||||||
"avg_intake": avg_intake,
|
|
||||||
"estimated_tdee": 0.0,
|
|
||||||
"status": "unknown",
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"days_analyzed": days,
|
|
||||||
"data_points": data_points
|
|
||||||
}
|
|
||||||
|
|
||||||
energy_balance = avg_intake - estimated_tdee
|
energy_balance = avg_intake - estimated_tdee
|
||||||
|
|
||||||
|
# Determine status
|
||||||
if energy_balance < -200:
|
if energy_balance < -200:
|
||||||
status = "deficit"
|
status = "deficit"
|
||||||
elif energy_balance > 200:
|
elif energy_balance > 200:
|
||||||
|
|
@ -386,6 +291,7 @@ def get_protein_adequacy_data(
|
||||||
"confidence": str
|
"confidence": str
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
# Get protein targets
|
||||||
targets = get_protein_targets_data(profile_id)
|
targets = get_protein_targets_data(profile_id)
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
|
|
@ -393,15 +299,17 @@ def get_protein_adequacy_data(
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""SELECT COALESCE(SUM(protein_g), 0)::float AS daily_protein
|
"""SELECT
|
||||||
|
AVG(protein_g) as avg_protein,
|
||||||
|
COUNT(*) as cnt,
|
||||||
|
SUM(CASE WHEN protein_g >= %s AND protein_g <= %s THEN 1 ELSE 0 END) as days_in_target
|
||||||
FROM nutrition_log
|
FROM nutrition_log
|
||||||
WHERE profile_id=%s AND date >= %s
|
WHERE profile_id=%s AND date >= %s AND protein_g IS NOT NULL""",
|
||||||
GROUP BY date""",
|
(targets['protein_target_low'], targets['protein_target_high'], profile_id, cutoff)
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
)
|
||||||
rows = cur.fetchall()
|
row = cur.fetchone()
|
||||||
|
|
||||||
if not rows or targets.get("confidence") == "insufficient" or targets["current_weight"] <= 0:
|
if not row or row['cnt'] == 0:
|
||||||
return {
|
return {
|
||||||
"adequacy_score": 0,
|
"adequacy_score": 0,
|
||||||
"avg_protein_g": 0.0,
|
"avg_protein_g": 0.0,
|
||||||
|
|
@ -413,21 +321,24 @@ def get_protein_adequacy_data(
|
||||||
"confidence": "insufficient"
|
"confidence": "insufficient"
|
||||||
}
|
}
|
||||||
|
|
||||||
daily_totals = [safe_float(r["daily_protein"]) for r in rows]
|
avg_protein = safe_float(row['avg_protein'])
|
||||||
days_with_data = len(daily_totals)
|
days_with_data = row['cnt']
|
||||||
low = targets["protein_target_low"]
|
days_in_target = row['days_in_target']
|
||||||
high = targets["protein_target_high"]
|
|
||||||
days_in_target = sum(1 for d in daily_totals if low <= d <= high)
|
|
||||||
|
|
||||||
avg_protein = sum(daily_totals) / days_with_data
|
protein_g_per_kg = avg_protein / targets['current_weight'] if targets['current_weight'] > 0 else 0.0
|
||||||
protein_g_per_kg = avg_protein / targets["current_weight"] if targets["current_weight"] > 0 else 0.0
|
|
||||||
|
|
||||||
|
# Calculate adequacy score
|
||||||
|
# 100 = always in target range
|
||||||
|
# Scale based on percentage of days in target + average relative to target
|
||||||
target_pct = (days_in_target / days_with_data * 100) if days_with_data > 0 else 0
|
target_pct = (days_in_target / days_with_data * 100) if days_with_data > 0 else 0
|
||||||
target_mid = (low + high) / 2
|
|
||||||
|
# Bonus/penalty for average protein level
|
||||||
|
target_mid = (targets['protein_target_low'] + targets['protein_target_high']) / 2
|
||||||
avg_vs_target = (avg_protein / target_mid) if target_mid > 0 else 0
|
avg_vs_target = (avg_protein / target_mid) if target_mid > 0 else 0
|
||||||
|
|
||||||
|
# Weighted score: 70% target days, 30% average level
|
||||||
adequacy_score = int(target_pct * 0.7 + min(avg_vs_target * 100, 100) * 0.3)
|
adequacy_score = int(target_pct * 0.7 + min(avg_vs_target * 100, 100) * 0.3)
|
||||||
adequacy_score = max(0, min(100, adequacy_score))
|
adequacy_score = max(0, min(100, adequacy_score)) # Clamp to 0-100
|
||||||
|
|
||||||
confidence = calculate_confidence(days_with_data, days, "general")
|
confidence = calculate_confidence(days_with_data, days, "general")
|
||||||
|
|
||||||
|
|
@ -476,18 +387,16 @@ def get_macro_consistency_data(
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""SELECT
|
"""SELECT
|
||||||
COALESCE(SUM(kcal), 0)::float AS kcal,
|
protein_g, carbs_g, fat_g, 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
|
FROM nutrition_log
|
||||||
WHERE profile_id=%s AND date >= %s
|
WHERE profile_id=%s
|
||||||
GROUP BY date
|
AND date >= %s
|
||||||
HAVING COALESCE(SUM(kcal), 0) > 0
|
AND protein_g IS NOT NULL
|
||||||
AND COALESCE(SUM(protein_g), 0) > 0
|
AND carbs_g IS NOT NULL
|
||||||
AND COALESCE(SUM(carbs_g), 0) > 0
|
AND fat_g IS NOT NULL
|
||||||
AND COALESCE(SUM(fat_g), 0) > 0""",
|
AND kcal > 0
|
||||||
(profile_id, cutoff),
|
ORDER BY date""",
|
||||||
|
(profile_id, cutoff)
|
||||||
)
|
)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
|
@ -504,6 +413,9 @@ def get_macro_consistency_data(
|
||||||
"data_points": len(rows)
|
"data_points": len(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Calculate macro percentages for each day
|
||||||
|
import statistics
|
||||||
|
|
||||||
protein_pcts = []
|
protein_pcts = []
|
||||||
carbs_pcts = []
|
carbs_pcts = []
|
||||||
fat_pcts = []
|
fat_pcts = []
|
||||||
|
|
@ -513,6 +425,7 @@ def get_macro_consistency_data(
|
||||||
if total_kcal == 0:
|
if total_kcal == 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Convert grams to kcal (protein=4, carbs=4, fat=9)
|
||||||
protein_kcal = safe_float(row['protein_g']) * 4
|
protein_kcal = safe_float(row['protein_g']) * 4
|
||||||
carbs_kcal = safe_float(row['carbs_g']) * 4
|
carbs_kcal = safe_float(row['carbs_g']) * 4
|
||||||
fat_kcal = safe_float(row['fat_g']) * 9
|
fat_kcal = safe_float(row['fat_g']) * 9
|
||||||
|
|
@ -569,200 +482,6 @@ def get_macro_consistency_data(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_weekly_macro_distribution_chart_data(profile_id: str, weeks: int) -> Dict:
|
|
||||||
"""
|
|
||||||
Chart E3: gestapelte Wochenbalken (Makro-%), gleiche Logik wie /charts/weekly-macro-distribution.
|
|
||||||
"""
|
|
||||||
cutoff = (datetime.now() - timedelta(weeks=weeks)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT date, protein_g, carbs_g, fat_g, kcal
|
|
||||||
FROM nutrition_log
|
|
||||||
WHERE profile_id=%s AND date >= %s
|
|
||||||
AND protein_g IS NOT NULL AND carbs_g IS NOT NULL
|
|
||||||
AND fat_g IS NOT NULL AND kcal > 0
|
|
||||||
ORDER BY date""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
|
|
||||||
if not rows or len(rows) < 7:
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {
|
|
||||||
"labels": [],
|
|
||||||
"datasets": [],
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": len(rows) if rows else 0,
|
|
||||||
"message": "Nicht genug Daten für Wochen-Analyse (min. 7 Tage)",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
weekly_data: Dict[str, Dict[str, List[float]]] = {}
|
|
||||||
for row in rows:
|
|
||||||
date_obj = row["date"] if isinstance(row["date"], datetime) else datetime.fromisoformat(str(row["date"]))
|
|
||||||
iso_week = date_obj.strftime("%Y-W%V")
|
|
||||||
|
|
||||||
if iso_week not in weekly_data:
|
|
||||||
weekly_data[iso_week] = {
|
|
||||||
"protein": [],
|
|
||||||
"carbs": [],
|
|
||||||
"fat": [],
|
|
||||||
"kcal": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
weekly_data[iso_week]["protein"].append(safe_float(row["protein_g"]))
|
|
||||||
weekly_data[iso_week]["carbs"].append(safe_float(row["carbs_g"]))
|
|
||||||
weekly_data[iso_week]["fat"].append(safe_float(row["fat_g"]))
|
|
||||||
weekly_data[iso_week]["kcal"].append(safe_float(row["kcal"]))
|
|
||||||
|
|
||||||
labels: List[str] = []
|
|
||||||
protein_pcts: List[float] = []
|
|
||||||
carbs_pcts: List[float] = []
|
|
||||||
fat_pcts: List[float] = []
|
|
||||||
|
|
||||||
for iso_week in sorted(weekly_data.keys())[-weeks:]:
|
|
||||||
data = weekly_data[iso_week]
|
|
||||||
|
|
||||||
avg_protein = sum(data["protein"]) / len(data["protein"]) if data["protein"] else 0
|
|
||||||
avg_carbs = sum(data["carbs"]) / len(data["carbs"]) if data["carbs"] else 0
|
|
||||||
avg_fat = sum(data["fat"]) / len(data["fat"]) if data["fat"] else 0
|
|
||||||
|
|
||||||
protein_kcal = avg_protein * 4
|
|
||||||
carbs_kcal = avg_carbs * 4
|
|
||||||
fat_kcal = avg_fat * 9
|
|
||||||
|
|
||||||
total_kcal = protein_kcal + carbs_kcal + fat_kcal
|
|
||||||
|
|
||||||
if total_kcal > 0:
|
|
||||||
labels.append(f"KW {iso_week[-2:]}")
|
|
||||||
protein_pcts.append(round((protein_kcal / total_kcal) * 100, 1))
|
|
||||||
carbs_pcts.append(round((carbs_kcal / total_kcal) * 100, 1))
|
|
||||||
fat_pcts.append(round((fat_kcal / total_kcal) * 100, 1))
|
|
||||||
|
|
||||||
protein_cv = (
|
|
||||||
statistics.stdev(protein_pcts) / statistics.mean(protein_pcts) * 100
|
|
||||||
if len(protein_pcts) > 1 and statistics.mean(protein_pcts) > 0
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
carbs_cv = (
|
|
||||||
statistics.stdev(carbs_pcts) / statistics.mean(carbs_pcts) * 100
|
|
||||||
if len(carbs_pcts) > 1 and statistics.mean(carbs_pcts) > 0
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
fat_cv = (
|
|
||||||
statistics.stdev(fat_pcts) / statistics.mean(fat_pcts) * 100
|
|
||||||
if len(fat_pcts) > 1 and statistics.mean(fat_pcts) > 0
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {
|
|
||||||
"labels": labels,
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "Protein (%)",
|
|
||||||
"data": protein_pcts,
|
|
||||||
"backgroundColor": "#4a8f72",
|
|
||||||
"stack": "macro",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Kohlenhydrate (%)",
|
|
||||||
"data": carbs_pcts,
|
|
||||||
"backgroundColor": "#c17d45",
|
|
||||||
"stack": "macro",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Fett (%)",
|
|
||||||
"data": fat_pcts,
|
|
||||||
"backgroundColor": "#6e8eb8",
|
|
||||||
"stack": "macro",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": calculate_confidence(len(rows), weeks * 7, "general"),
|
|
||||||
"data_points": len(rows),
|
|
||||||
"weeks_analyzed": len(labels),
|
|
||||||
"avg_protein_pct": round(statistics.mean(protein_pcts), 1) if protein_pcts else 0,
|
|
||||||
"avg_carbs_pct": round(statistics.mean(carbs_pcts), 1) if carbs_pcts else 0,
|
|
||||||
"avg_fat_pct": round(statistics.mean(fat_pcts), 1) if fat_pcts else 0,
|
|
||||||
"protein_cv": round(protein_cv, 1),
|
|
||||||
"carbs_cv": round(carbs_cv, 1),
|
|
||||||
"fat_cv": round(fat_cv, 1),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_energy_availability_warning_payload(profile_id: str, days: int = 14) -> Dict:
|
|
||||||
"""
|
|
||||||
E5 Energieverfügbarkeit — gleiche Heuristik wie GET /charts/energy-availability-warning.
|
|
||||||
"""
|
|
||||||
from data_layer.recovery_metrics import calculate_recovery_score_v2, calculate_sleep_quality_7d
|
|
||||||
from data_layer.body_metrics import calculate_lbm_28d_change
|
|
||||||
|
|
||||||
triggers: List[str] = []
|
|
||||||
warning_level = "none"
|
|
||||||
|
|
||||||
energy_data = get_energy_balance_data(profile_id, days)
|
|
||||||
if energy_data.get("energy_balance", 0) < -500:
|
|
||||||
triggers.append("Großes Energiedefizit (>500 kcal/Tag)")
|
|
||||||
|
|
||||||
try:
|
|
||||||
recovery_score = calculate_recovery_score_v2(profile_id)
|
|
||||||
if recovery_score and recovery_score < 50:
|
|
||||||
triggers.append("Recovery Score niedrig (<50)")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
sleep_quality = calculate_sleep_quality_7d(profile_id)
|
|
||||||
if sleep_quality and sleep_quality < 60:
|
|
||||||
triggers.append("Schlafqualität reduziert (<60%)")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
lbm_change = calculate_lbm_28d_change(profile_id)
|
|
||||||
if lbm_change and lbm_change < -1.0:
|
|
||||||
triggers.append("Magermasse sinkt (-{:.1f} kg)".format(abs(lbm_change)))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if len(triggers) >= 3:
|
|
||||||
warning_level = "warning"
|
|
||||||
message = (
|
|
||||||
"⚠️ Hinweis auf mögliche Unterversorgung. Mehrere Indikatoren auffällig. "
|
|
||||||
"Erwäge Defizit-Anpassung oder Regenerationswoche."
|
|
||||||
)
|
|
||||||
elif len(triggers) >= 2:
|
|
||||||
warning_level = "caution"
|
|
||||||
message = (
|
|
||||||
"⚡ Beobachte folgende Signale genau. Aktuell noch kein Handlungsbedarf, aber Trend beachten."
|
|
||||||
)
|
|
||||||
elif len(triggers) >= 1:
|
|
||||||
warning_level = "caution"
|
|
||||||
message = "💡 Ein Indikator auffällig. Weiter beobachten."
|
|
||||||
else:
|
|
||||||
message = "✅ Energieverfügbarkeit unauffällig."
|
|
||||||
|
|
||||||
return {
|
|
||||||
"warning_level": warning_level,
|
|
||||||
"triggers": triggers,
|
|
||||||
"message": message,
|
|
||||||
"metadata": {
|
|
||||||
"days_analyzed": days,
|
|
||||||
"trigger_count": len(triggers),
|
|
||||||
"note": "Heuristische Einschätzung, keine medizinische Diagnose",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Calculated Metrics (migrated from calculations/nutrition_metrics.py)
|
# Calculated Metrics (migrated from calculations/nutrition_metrics.py)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
@ -772,15 +491,50 @@ def get_energy_availability_warning_payload(profile_id: str, days: int = 14) ->
|
||||||
|
|
||||||
def calculate_energy_balance_7d(profile_id: str) -> Optional[float]:
|
def calculate_energy_balance_7d(profile_id: str) -> Optional[float]:
|
||||||
"""
|
"""
|
||||||
7-day mean energy balance (kcal/day), same rules as get_energy_balance_data(..., 7).
|
Calculate 7-day average energy balance (kcal/day)
|
||||||
|
Positive = surplus, Negative = deficit
|
||||||
|
|
||||||
|
Migration from Phase 0b:
|
||||||
|
Used by placeholders that need single balance value
|
||||||
"""
|
"""
|
||||||
data = get_energy_balance_data(profile_id, 7)
|
with get_db() as conn:
|
||||||
if data["data_points"] < 4:
|
cur = get_cursor(conn)
|
||||||
|
cur.execute("""
|
||||||
|
SELECT kcal
|
||||||
|
FROM nutrition_log
|
||||||
|
WHERE profile_id = %s
|
||||||
|
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
||||||
|
ORDER BY date DESC
|
||||||
|
""", (profile_id,))
|
||||||
|
|
||||||
|
calories = [row['kcal'] for row in cur.fetchall()]
|
||||||
|
|
||||||
|
if len(calories) < 4: # Need at least 4 days
|
||||||
return None
|
return None
|
||||||
tdee = data.get("estimated_tdee") or 0
|
|
||||||
if tdee <= 0:
|
avg_intake = float(sum(calories) / len(calories))
|
||||||
|
|
||||||
|
# Get estimated TDEE (simplified - could use Harris-Benedict)
|
||||||
|
# For now, use weight-based estimate
|
||||||
|
cur.execute("""
|
||||||
|
SELECT weight
|
||||||
|
FROM weight_log
|
||||||
|
WHERE profile_id = %s
|
||||||
|
ORDER BY date DESC
|
||||||
|
LIMIT 1
|
||||||
|
""", (profile_id,))
|
||||||
|
|
||||||
|
weight_row = cur.fetchone()
|
||||||
|
if not weight_row:
|
||||||
return None
|
return None
|
||||||
return round(float(data["energy_balance"]), 0)
|
|
||||||
|
# Simple TDEE estimate: bodyweight (kg) × 30-35
|
||||||
|
# TODO: Improve with activity level, age, gender
|
||||||
|
estimated_tdee = float(weight_row['weight']) * 32.5
|
||||||
|
|
||||||
|
balance = avg_intake - estimated_tdee
|
||||||
|
|
||||||
|
return round(balance, 0)
|
||||||
|
|
||||||
|
|
||||||
def calculate_energy_deficit_surplus(profile_id: str, days: int = 7) -> Optional[str]:
|
def calculate_energy_deficit_surplus(profile_id: str, days: int = 7) -> Optional[str]:
|
||||||
|
|
@ -900,14 +654,15 @@ def calculate_protein_days_in_target(profile_id: str, target_low: float = 1.6, t
|
||||||
|
|
||||||
def calculate_protein_adequacy_28d(profile_id: str) -> Optional[int]:
|
def calculate_protein_adequacy_28d(profile_id: str) -> Optional[int]:
|
||||||
"""
|
"""
|
||||||
Protein adequacy score 0-100 (last 28 days).
|
Protein adequacy score 0-100 (last 28 days)
|
||||||
Uses per-calendar-day total protein vs. average weight in the window (g/kg per day).
|
Based on consistency and target achievement
|
||||||
"""
|
"""
|
||||||
import statistics
|
import statistics
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
|
# Get average weight (28d)
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT AVG(weight) as avg_weight
|
SELECT AVG(weight) as avg_weight
|
||||||
FROM weight_log
|
FROM weight_log
|
||||||
|
|
@ -921,29 +676,38 @@ def calculate_protein_adequacy_28d(profile_id: str) -> Optional[int]:
|
||||||
|
|
||||||
weight = float(weight_row['avg_weight'])
|
weight = float(weight_row['avg_weight'])
|
||||||
|
|
||||||
|
# Get protein intake (28d)
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT COALESCE(SUM(protein_g), 0)::float AS daily_protein
|
SELECT protein_g
|
||||||
FROM nutrition_log
|
FROM nutrition_log
|
||||||
WHERE profile_id = %s
|
WHERE profile_id = %s
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
||||||
GROUP BY date
|
AND protein_g IS NOT NULL
|
||||||
""", (profile_id,))
|
""", (profile_id,))
|
||||||
|
|
||||||
daily_totals = [float(row['daily_protein']) for row in cur.fetchall()]
|
protein_values = [float(row['protein_g']) for row in cur.fetchall()]
|
||||||
|
|
||||||
if len(daily_totals) < 18:
|
if len(protein_values) < 18: # 60% coverage
|
||||||
return None
|
return None
|
||||||
|
|
||||||
protein_per_kg_values = [p / weight for p in daily_totals]
|
# Calculate metrics
|
||||||
|
protein_per_kg_values = [p / weight for p in protein_values]
|
||||||
avg_protein_per_kg = sum(protein_per_kg_values) / len(protein_per_kg_values)
|
avg_protein_per_kg = sum(protein_per_kg_values) / len(protein_per_kg_values)
|
||||||
|
|
||||||
|
# Target range: 1.6-2.2 g/kg for active individuals
|
||||||
|
target_mid = 1.9
|
||||||
|
|
||||||
|
# Score based on distance from target
|
||||||
if 1.6 <= avg_protein_per_kg <= 2.2:
|
if 1.6 <= avg_protein_per_kg <= 2.2:
|
||||||
base_score = 100
|
base_score = 100
|
||||||
elif avg_protein_per_kg < 1.6:
|
elif avg_protein_per_kg < 1.6:
|
||||||
|
# Below target
|
||||||
base_score = max(40, 100 - ((1.6 - avg_protein_per_kg) * 40))
|
base_score = max(40, 100 - ((1.6 - avg_protein_per_kg) * 40))
|
||||||
else:
|
else:
|
||||||
|
# Above target (less penalty)
|
||||||
base_score = max(80, 100 - ((avg_protein_per_kg - 2.2) * 10))
|
base_score = max(80, 100 - ((avg_protein_per_kg - 2.2) * 10))
|
||||||
|
|
||||||
|
# Consistency bonus/penalty
|
||||||
std_dev = statistics.stdev(protein_per_kg_values)
|
std_dev = statistics.stdev(protein_per_kg_values)
|
||||||
if std_dev < 0.3:
|
if std_dev < 0.3:
|
||||||
consistency_bonus = 10
|
consistency_bonus = 10
|
||||||
|
|
@ -959,24 +723,20 @@ def calculate_protein_adequacy_28d(profile_id: str) -> Optional[int]:
|
||||||
|
|
||||||
def calculate_macro_consistency_score(profile_id: str) -> Optional[int]:
|
def calculate_macro_consistency_score(profile_id: str) -> Optional[int]:
|
||||||
"""
|
"""
|
||||||
Macro consistency score 0-100 (last 28 days).
|
Macro consistency score 0-100 (last 28 days)
|
||||||
CV of daily totals (kcal and macros), not raw log rows.
|
Lower variability = higher score
|
||||||
"""
|
"""
|
||||||
import statistics
|
import statistics
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT
|
SELECT kcal, protein_g, fat_g, carbs_g
|
||||||
COALESCE(SUM(kcal), 0)::float AS dk,
|
|
||||||
COALESCE(SUM(protein_g), 0)::float AS dp,
|
|
||||||
COALESCE(SUM(fat_g), 0)::float AS df,
|
|
||||||
COALESCE(SUM(carbs_g), 0)::float AS dc
|
|
||||||
FROM nutrition_log
|
FROM nutrition_log
|
||||||
WHERE profile_id = %s
|
WHERE profile_id = %s
|
||||||
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
AND date >= CURRENT_DATE - INTERVAL '28 days'
|
||||||
GROUP BY date
|
AND kcal IS NOT NULL
|
||||||
HAVING COALESCE(SUM(kcal), 0) > 0
|
ORDER BY date DESC
|
||||||
""", (profile_id,))
|
""", (profile_id,))
|
||||||
|
|
||||||
data = cur.fetchall()
|
data = cur.fetchall()
|
||||||
|
|
@ -984,7 +744,9 @@ def calculate_macro_consistency_score(profile_id: str) -> Optional[int]:
|
||||||
if len(data) < 18:
|
if len(data) < 18:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Calculate coefficient of variation for each macro
|
||||||
def cv(values):
|
def cv(values):
|
||||||
|
"""Coefficient of variation (std_dev / mean)"""
|
||||||
if not values or len(values) < 2:
|
if not values or len(values) < 2:
|
||||||
return None
|
return None
|
||||||
mean = sum(values) / len(values)
|
mean = sum(values) / len(values)
|
||||||
|
|
@ -993,10 +755,10 @@ def calculate_macro_consistency_score(profile_id: str) -> Optional[int]:
|
||||||
std_dev = statistics.stdev(values)
|
std_dev = statistics.stdev(values)
|
||||||
return std_dev / mean
|
return std_dev / mean
|
||||||
|
|
||||||
calories_cv = cv([d['dk'] for d in data])
|
calories_cv = cv([d['kcal'] for d in data])
|
||||||
protein_cv = cv([d['dp'] for d in data if d['dp']])
|
protein_cv = cv([d['protein_g'] for d in data if d['protein_g']])
|
||||||
fat_cv = cv([d['df'] for d in data if d['df']])
|
fat_cv = cv([d['fat_g'] for d in data if d['fat_g']])
|
||||||
carbs_cv = cv([d['dc'] for d in data if d['dc']])
|
carbs_cv = cv([d['carbs_g'] for d in data if d['carbs_g']])
|
||||||
|
|
||||||
cv_values = [v for v in [calories_cv, protein_cv, fat_cv, carbs_cv] if v is not None]
|
cv_values = [v for v in [calories_cv, protein_cv, fat_cv, carbs_cv] if v is not None]
|
||||||
|
|
||||||
|
|
@ -1005,6 +767,9 @@ def calculate_macro_consistency_score(profile_id: str) -> Optional[int]:
|
||||||
|
|
||||||
avg_cv = sum(cv_values) / len(cv_values)
|
avg_cv = sum(cv_values) / len(cv_values)
|
||||||
|
|
||||||
|
# Score: lower CV = higher score
|
||||||
|
# CV < 0.2 = excellent consistency
|
||||||
|
# CV > 0.5 = poor consistency
|
||||||
if avg_cv < 0.2:
|
if avg_cv < 0.2:
|
||||||
score = 100
|
score = 100
|
||||||
elif avg_cv < 0.3:
|
elif avg_cv < 0.3:
|
||||||
|
|
@ -1046,16 +811,14 @@ def calculate_nutrition_score(profile_id: str, focus_weights: Optional[Dict] = N
|
||||||
from data_layer.scores import get_user_focus_weights
|
from data_layer.scores import get_user_focus_weights
|
||||||
focus_weights = get_user_focus_weights(profile_id)
|
focus_weights = get_user_focus_weights(profile_id)
|
||||||
|
|
||||||
# Nutrition-related focus areas (English keys from DB; Gewichte immer float)
|
# Nutrition-related focus areas (English keys from DB)
|
||||||
protein_intake = float(focus_weights.get('protein_intake', 0) or 0)
|
protein_intake = focus_weights.get('protein_intake', 0)
|
||||||
calorie_balance = float(focus_weights.get('calorie_balance', 0) or 0)
|
calorie_balance = focus_weights.get('calorie_balance', 0)
|
||||||
macro_consistency = float(focus_weights.get('macro_consistency', 0) or 0)
|
macro_consistency = focus_weights.get('macro_consistency', 0)
|
||||||
meal_timing = float(focus_weights.get('meal_timing', 0) or 0)
|
meal_timing = focus_weights.get('meal_timing', 0)
|
||||||
hydration = float(focus_weights.get('hydration', 0) or 0)
|
hydration = focus_weights.get('hydration', 0)
|
||||||
|
|
||||||
total_nutrition_weight = (
|
total_nutrition_weight = protein_intake + calorie_balance + macro_consistency + meal_timing + hydration
|
||||||
protein_intake + calorie_balance + macro_consistency + meal_timing + hydration
|
|
||||||
)
|
|
||||||
|
|
||||||
if total_nutrition_weight == 0:
|
if total_nutrition_weight == 0:
|
||||||
return None # No nutrition goals
|
return None # No nutrition goals
|
||||||
|
|
@ -1090,66 +853,40 @@ def calculate_nutrition_score(profile_id: str, focus_weights: Optional[Dict] = N
|
||||||
if not components:
|
if not components:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Weighted average (float: DB-Werte können Decimal sein)
|
# Weighted average
|
||||||
total_score = sum(float(score) * float(weight) for _, score, weight in components)
|
total_score = sum(score * weight for _, score, weight in components)
|
||||||
total_weight = sum(float(weight) for _, _, weight in components)
|
total_weight = sum(weight for _, _, weight in components)
|
||||||
|
|
||||||
return int(total_score / total_weight)
|
return int(total_score / total_weight)
|
||||||
|
|
||||||
|
|
||||||
def _score_calorie_adherence(profile_id: str) -> Optional[int]:
|
def _score_calorie_adherence(profile_id: str) -> Optional[int]:
|
||||||
"""Score calorie target adherence (0–100) using 7d balance vs profiles.goal_mode."""
|
"""Score calorie target adherence (0-100)"""
|
||||||
|
# Check for energy balance goal
|
||||||
|
# For now, use energy balance calculation
|
||||||
balance = calculate_energy_balance_7d(profile_id)
|
balance = calculate_energy_balance_7d(profile_id)
|
||||||
|
|
||||||
if balance is None:
|
if balance is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
mode = _get_profile_goal_mode(profile_id)
|
# Score based on whether deficit/surplus aligns with goal
|
||||||
b = float(balance)
|
# Simplified: assume weight loss goal = deficit is good
|
||||||
|
# TODO: Check actual goal type
|
||||||
|
|
||||||
def _weight_loss(x: float) -> int:
|
abs_balance = abs(balance)
|
||||||
if -550 <= x <= -250:
|
|
||||||
return 100
|
|
||||||
if x > 450:
|
|
||||||
return 38
|
|
||||||
if -750 <= x < -550 or -250 < x <= 120:
|
|
||||||
return 82
|
|
||||||
if x < -1200:
|
|
||||||
return 52
|
|
||||||
if -950 <= x < -750 or 120 < x <= 350:
|
|
||||||
return 68
|
|
||||||
return 58
|
|
||||||
|
|
||||||
def _surplus_friendly(x: float) -> int:
|
# Moderate deficit/surplus = good
|
||||||
if 80 <= x <= 480:
|
if 200 <= abs_balance <= 500:
|
||||||
return 100
|
return 100
|
||||||
if -120 <= x < 80 or 480 < x <= 700:
|
elif 100 <= abs_balance <= 700:
|
||||||
return 86
|
return 85
|
||||||
if -380 <= x < -120:
|
elif abs_balance <= 900:
|
||||||
return 68
|
|
||||||
if x > 850:
|
|
||||||
return 54
|
|
||||||
if x < -650:
|
|
||||||
return 44
|
|
||||||
return 72
|
|
||||||
|
|
||||||
def _maintenance(x: float) -> int:
|
|
||||||
a = abs(x)
|
|
||||||
if a <= 200:
|
|
||||||
return 100
|
|
||||||
if a <= 400:
|
|
||||||
return 84
|
|
||||||
if a <= 650:
|
|
||||||
return 70
|
return 70
|
||||||
if a <= 900:
|
elif abs_balance <= 1200:
|
||||||
return 55
|
return 55
|
||||||
|
else:
|
||||||
return 40
|
return 40
|
||||||
|
|
||||||
if mode == "weight_loss":
|
|
||||||
return _weight_loss(b)
|
|
||||||
if mode in ("strength", "recomposition"):
|
|
||||||
return _surplus_friendly(b)
|
|
||||||
return _maintenance(b)
|
|
||||||
|
|
||||||
|
|
||||||
def _score_macro_balance(profile_id: str) -> Optional[int]:
|
def _score_macro_balance(profile_id: str) -> Optional[int]:
|
||||||
"""Score macro balance (0-100)"""
|
"""Score macro balance (0-100)"""
|
||||||
|
|
|
||||||
|
|
@ -1,393 +0,0 @@
|
||||||
"""
|
|
||||||
Layer 2b: Ernährungs-Verlauf — ein Bundle für die UI (Issue #53).
|
|
||||||
|
|
||||||
Single Source: nutrition_metrics + dieselben Tabellen wie Ernährungs-Platzhalter.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date, datetime, timedelta
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
|
||||||
from data_layer.nutrition_body_merge import build_merged_daily_nutrition_body_rows
|
|
||||||
from data_layer.nutrition_interpretation import (
|
|
||||||
build_energy_availability_kpi_tile,
|
|
||||||
build_macro_donut_from_averages,
|
|
||||||
build_nutrition_correlation_heuristic_items,
|
|
||||||
build_nutrition_history_kpi_tiles,
|
|
||||||
)
|
|
||||||
from data_layer.nutrition_chart_payloads import (
|
|
||||||
build_energy_balance_chart_payload,
|
|
||||||
build_nutrition_adherence_score_payload,
|
|
||||||
build_protein_adequacy_chart_payload,
|
|
||||||
)
|
|
||||||
from data_layer.nutrition_metrics import (
|
|
||||||
estimate_tdee_kcal_from_latest_weight,
|
|
||||||
get_energy_availability_warning_payload,
|
|
||||||
get_energy_balance_data,
|
|
||||||
get_nutrition_average_data,
|
|
||||||
get_protein_targets_data,
|
|
||||||
get_weekly_macro_distribution_chart_data,
|
|
||||||
)
|
|
||||||
from data_layer.utils import safe_float
|
|
||||||
|
|
||||||
|
|
||||||
def _cutoff_sql(days: int) -> Optional[str]:
|
|
||||||
if days >= 9999:
|
|
||||||
return None
|
|
||||||
return (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
|
|
||||||
def _iso(d: Any) -> Optional[str]:
|
|
||||||
if d is None:
|
|
||||||
return None
|
|
||||||
if hasattr(d, "isoformat"):
|
|
||||||
return d.isoformat()[:10]
|
|
||||||
return str(d)[:10]
|
|
||||||
|
|
||||||
|
|
||||||
def _rolling_avg(rows: List[Dict[str, Any]], key: str, window: int) -> List[Dict[str, Any]]:
|
|
||||||
out: List[Dict[str, Any]] = []
|
|
||||||
for i, d in enumerate(rows):
|
|
||||||
sl = rows[max(0, i - window + 1) : i + 1]
|
|
||||||
vals: List[float] = []
|
|
||||||
for x in sl:
|
|
||||||
v = safe_float(x.get(key))
|
|
||||||
if v is not None:
|
|
||||||
vals.append(v)
|
|
||||||
if not vals:
|
|
||||||
out.append({**d, f"{key}_avg": None})
|
|
||||||
continue
|
|
||||||
avg = round(sum(vals) / len(vals), 1)
|
|
||||||
out.append({**d, f"{key}_avg": avg})
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _has_nutrition_entries(profile_id: str) -> bool:
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"SELECT 1 FROM nutrition_log WHERE profile_id=%s LIMIT 1",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
return cur.fetchone() is not None
|
|
||||||
|
|
||||||
|
|
||||||
def _last_nutrition_date(profile_id: str) -> Optional[str]:
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"SELECT MAX(date) AS d FROM nutrition_log WHERE profile_id=%s",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row or row["d"] is None:
|
|
||||||
return None
|
|
||||||
return _iso(row["d"])
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_daily_macro_totals(profile_id: str, cutoff: Optional[str]) -> List[Dict[str, Any]]:
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
if cutoff:
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT date,
|
|
||||||
COALESCE(SUM(kcal), 0)::float AS kcal,
|
|
||||||
COALESCE(SUM(protein_g), 0)::float AS protein_g,
|
|
||||||
COALESCE(SUM(carbs_g), 0)::float AS carbs_g,
|
|
||||||
COALESCE(SUM(fat_g), 0)::float AS fat_g
|
|
||||||
FROM nutrition_log
|
|
||||||
WHERE profile_id=%s AND date >= %s
|
|
||||||
GROUP BY date
|
|
||||||
ORDER BY date ASC""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT date,
|
|
||||||
COALESCE(SUM(kcal), 0)::float AS kcal,
|
|
||||||
COALESCE(SUM(protein_g), 0)::float AS protein_g,
|
|
||||||
COALESCE(SUM(carbs_g), 0)::float AS carbs_g,
|
|
||||||
COALESCE(SUM(fat_g), 0)::float AS fat_g
|
|
||||||
FROM nutrition_log
|
|
||||||
WHERE profile_id=%s
|
|
||||||
GROUP BY date
|
|
||||||
ORDER BY date ASC""",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
return [r2d(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
|
|
||||||
def _filter_merged_rows_by_cutoff(
|
|
||||||
merged: List[Dict[str, Any]], cutoff: Optional[str]
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
if not cutoff:
|
|
||||||
return list(merged)
|
|
||||||
return [r for r in merged if str(r.get("date"))[:10] >= cutoff]
|
|
||||||
|
|
||||||
|
|
||||||
def _calorie_balance_daily_series(
|
|
||||||
merged_filtered: List[Dict[str, Any]], tdee: float
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""Tagesbilanz (Aufnahme − TDEE) + 7-Tage-Mittel der Bilanz — gleiche TDEE-Quelle wie kcal_vs_weight."""
|
|
||||||
rows: List[Dict[str, Any]] = []
|
|
||||||
for r in merged_filtered:
|
|
||||||
if r.get("kcal") is None:
|
|
||||||
continue
|
|
||||||
ds = _iso(r.get("date"))
|
|
||||||
if not ds:
|
|
||||||
continue
|
|
||||||
bal = round(float(r["kcal"]) - float(tdee))
|
|
||||||
rows.append({"date": ds, "balance_kcal": bal})
|
|
||||||
rolled = _rolling_avg([dict(x) for x in rows], "balance_kcal", 7)
|
|
||||||
out: List[Dict[str, Any]] = []
|
|
||||||
for x in rolled:
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"date": x["date"],
|
|
||||||
"balance_kcal": x.get("balance_kcal"),
|
|
||||||
"balance_kcal_avg": x.get("balance_kcal_avg"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _protein_lean_mass_points(merged_filtered: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
||||||
out: List[Dict[str, Any]] = []
|
|
||||||
for r in merged_filtered:
|
|
||||||
if r.get("protein_g") is None or r.get("lean_mass") is None:
|
|
||||||
continue
|
|
||||||
ds = _iso(r.get("date"))
|
|
||||||
if not ds:
|
|
||||||
continue
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"date": ds,
|
|
||||||
"protein_g": round(safe_float(r.get("protein_g")) or 0, 1),
|
|
||||||
"lean_mass_kg": round(safe_float(r.get("lean_mass")) or 0, 2),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _kcal_weight_points_for_window(
|
|
||||||
profile_id: str, cutoff: Optional[str]
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""Gemeinsame Tage: Tages-kcal vs. Gewicht; gleiche Idee wie /nutrition/correlations, gefiltert."""
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
if cutoff:
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT date, SUM(kcal)::float AS kcal
|
|
||||||
FROM nutrition_log
|
|
||||||
WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL
|
|
||||||
GROUP BY date""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT date, SUM(kcal)::float AS kcal
|
|
||||||
FROM nutrition_log
|
|
||||||
WHERE profile_id=%s AND kcal IS NOT NULL
|
|
||||||
GROUP BY date""",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
nk = { _iso(r["date"]): safe_float(r["kcal"]) for r in cur.fetchall() }
|
|
||||||
|
|
||||||
if cutoff:
|
|
||||||
cur.execute(
|
|
||||||
"SELECT date, weight FROM weight_log WHERE profile_id=%s AND date >= %s ORDER BY date",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cur.execute(
|
|
||||||
"SELECT date, weight FROM weight_log WHERE profile_id=%s ORDER BY date",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
wk = { _iso(r["date"]): safe_float(r["weight"]) for r in cur.fetchall() if r.get("weight") is not None }
|
|
||||||
|
|
||||||
common = sorted(set(nk) & set(wk))
|
|
||||||
raw: List[Dict[str, Any]] = []
|
|
||||||
for ds in common:
|
|
||||||
raw.append({"date": ds, "kcal": nk[ds], "weight": wk[ds]})
|
|
||||||
rolled = _rolling_avg(raw, "kcal", 7)
|
|
||||||
out: List[Dict[str, Any]] = []
|
|
||||||
for r in rolled:
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"date": r["date"],
|
|
||||||
"kcal": r.get("kcal"),
|
|
||||||
"weight": r.get("weight"),
|
|
||||||
"kcal_avg": r.get("kcal_avg"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Layer 2b Bundle für Verlauf «Ernährung».
|
|
||||||
|
|
||||||
days: Analysefenster (>=9999 = gesamte Historie für Mittelwerte / Reihen).
|
|
||||||
"""
|
|
||||||
if not _has_nutrition_entries(profile_id):
|
|
||||||
return {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"has_nutrition_entries": False,
|
|
||||||
"message": "Noch keine Ernährungsdaten",
|
|
||||||
"kpi_tiles": [],
|
|
||||||
"summary": {},
|
|
||||||
"daily_macros": [],
|
|
||||||
"donut_avg_pct": None,
|
|
||||||
"kcal_vs_weight": {"points": [], "tdee_reference_kcal": None, "common_days_count": 0},
|
|
||||||
"weekly_macro_chart": {},
|
|
||||||
"tdee_reference_kcal": None,
|
|
||||||
"energy_balance_meta": {},
|
|
||||||
"interpretation_tiles": [],
|
|
||||||
"energy_availability_warning": None,
|
|
||||||
"calorie_balance_daily": [],
|
|
||||||
"protein_vs_lean_mass": {"points": [], "protein_target_low_g": None},
|
|
||||||
"nutrition_correlation_heuristics": [],
|
|
||||||
"chart_payloads": {},
|
|
||||||
"chart_payloads_days": None,
|
|
||||||
"meta": {"layer_1": "nutrition_metrics", "layer_2b": "nutrition_viz"},
|
|
||||||
}
|
|
||||||
|
|
||||||
all_history = days >= 9999
|
|
||||||
eff_days = 3650 if all_history else max(7, min(int(days), 3650))
|
|
||||||
cutoff = _cutoff_sql(days)
|
|
||||||
chart_days_for_pipeline = 90 if all_history else max(7, min(eff_days, 365))
|
|
||||||
|
|
||||||
navg = get_nutrition_average_data(profile_id, eff_days, all_history=all_history)
|
|
||||||
targets = get_protein_targets_data(profile_id)
|
|
||||||
energy_days = eff_days if not all_history else min(9999, 3650)
|
|
||||||
energy_meta = get_energy_balance_data(profile_id, energy_days)
|
|
||||||
tdee = estimate_tdee_kcal_from_latest_weight(profile_id)
|
|
||||||
if tdee is None:
|
|
||||||
tdee = safe_float(energy_meta.get("estimated_tdee")) or None
|
|
||||||
else:
|
|
||||||
tdee = float(tdee)
|
|
||||||
|
|
||||||
daily_rows = _fetch_daily_macro_totals(profile_id, cutoff)
|
|
||||||
daily_macros: List[Dict[str, Any]] = []
|
|
||||||
for r in daily_rows:
|
|
||||||
daily_macros.append(
|
|
||||||
{
|
|
||||||
"date": _iso(r["date"]),
|
|
||||||
"kcal": round(safe_float(r.get("kcal")) or 0),
|
|
||||||
"Protein": round(safe_float(r.get("protein_g")) or 0),
|
|
||||||
"KH": round(safe_float(r.get("carbs_g")) or 0),
|
|
||||||
"Fett": round(safe_float(r.get("fat_g")) or 0),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
date_span_label = ""
|
|
||||||
if daily_macros:
|
|
||||||
date_span_label = f"{daily_macros[0]['date']} – {daily_macros[-1]['date']}"
|
|
||||||
|
|
||||||
n_days = int(navg.get("data_points") or 0)
|
|
||||||
kpi_tiles = build_nutrition_history_kpi_tiles(
|
|
||||||
navg, targets, date_span_label or "—", max(1, n_days)
|
|
||||||
)
|
|
||||||
|
|
||||||
ea_days = min(28, max(7, chart_days_for_pipeline))
|
|
||||||
ea_payload = get_energy_availability_warning_payload(profile_id, ea_days)
|
|
||||||
ea_tile = build_energy_availability_kpi_tile(ea_payload)
|
|
||||||
kpi_tiles_out: List[Dict[str, Any]] = list(kpi_tiles)
|
|
||||||
if ea_tile:
|
|
||||||
kpi_tiles_out.append(ea_tile)
|
|
||||||
|
|
||||||
donut = build_macro_donut_from_averages(navg)
|
|
||||||
|
|
||||||
kw_points = _kcal_weight_points_for_window(profile_id, cutoff)
|
|
||||||
pt_low = round(float(targets.get("protein_target_low") or 0))
|
|
||||||
|
|
||||||
merged_all = build_merged_daily_nutrition_body_rows(profile_id)
|
|
||||||
merged_win = _filter_merged_rows_by_cutoff(merged_all, cutoff)
|
|
||||||
tdee_eff = float(tdee) if tdee is not None else float(safe_float(energy_meta.get("estimated_tdee")) or 0)
|
|
||||||
calorie_balance_daily: List[Dict[str, Any]] = (
|
|
||||||
_calorie_balance_daily_series(merged_win, tdee_eff) if tdee_eff > 0 else []
|
|
||||||
)
|
|
||||||
pl_points = _protein_lean_mass_points(merged_win)
|
|
||||||
nutrition_correlation_heuristics = (
|
|
||||||
build_nutrition_correlation_heuristic_items(merged_win, tdee_eff, float(pt_low))
|
|
||||||
if tdee_eff > 0
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
|
|
||||||
weeks_for_weekly = max(4, min(52, (chart_days_for_pipeline + 6) // 7))
|
|
||||||
weekly_chart = get_weekly_macro_distribution_chart_data(profile_id, weeks_for_weekly)
|
|
||||||
|
|
||||||
# E1/E2/E4 Chart.js-Payloads — gleiche Funktionen wie /api/charts/* (kein zweiter HTTP-Roundtrip im Verlauf)
|
|
||||||
days_for_embedded_charts = max(7, min(int(chart_days_for_pipeline), 90))
|
|
||||||
chart_payloads = {
|
|
||||||
"energy_balance": build_energy_balance_chart_payload(
|
|
||||||
profile_id, days_for_embedded_charts
|
|
||||||
),
|
|
||||||
"protein_adequacy": build_protein_adequacy_chart_payload(
|
|
||||||
profile_id, days_for_embedded_charts
|
|
||||||
),
|
|
||||||
"nutrition_adherence": build_nutrition_adherence_score_payload(
|
|
||||||
profile_id, days_for_embedded_charts
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
conf = navg.get("confidence") or "medium"
|
|
||||||
if targets.get("confidence") == "insufficient":
|
|
||||||
conf = "insufficient"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"confidence": conf,
|
|
||||||
"has_nutrition_entries": True,
|
|
||||||
"days_requested": days,
|
|
||||||
"effective_window_days": eff_days,
|
|
||||||
"nutrition_charts_days": chart_days_for_pipeline,
|
|
||||||
"weekly_macro_weeks_used": weeks_for_weekly,
|
|
||||||
"last_updated": _last_nutrition_date(profile_id),
|
|
||||||
"summary": {
|
|
||||||
"kcal_avg": navg.get("kcal_avg"),
|
|
||||||
"protein_avg": navg.get("protein_avg"),
|
|
||||||
"carbs_avg": navg.get("carbs_avg"),
|
|
||||||
"fat_avg": navg.get("fat_avg"),
|
|
||||||
"data_points": navg.get("data_points"),
|
|
||||||
"days_analyzed": navg.get("days_analyzed"),
|
|
||||||
"protein_target_low": targets.get("protein_target_low"),
|
|
||||||
"protein_target_high": targets.get("protein_target_high"),
|
|
||||||
"reference_weight_kg": targets.get("current_weight"),
|
|
||||||
},
|
|
||||||
"kpi_tiles": kpi_tiles_out,
|
|
||||||
"interpretation_tiles": [],
|
|
||||||
"energy_availability_warning": ea_payload,
|
|
||||||
"daily_macros": daily_macros,
|
|
||||||
"donut_avg_pct": donut,
|
|
||||||
"protein_reference_line_g": pt_low,
|
|
||||||
"kcal_vs_weight": {
|
|
||||||
"points": kw_points,
|
|
||||||
"tdee_reference_kcal": tdee,
|
|
||||||
"common_days_count": len(kw_points),
|
|
||||||
},
|
|
||||||
"weekly_macro_chart": weekly_chart,
|
|
||||||
"tdee_reference_kcal": tdee,
|
|
||||||
"energy_balance_meta": {
|
|
||||||
"energy_balance": energy_meta.get("energy_balance"),
|
|
||||||
"avg_intake": energy_meta.get("avg_intake"),
|
|
||||||
"estimated_tdee": energy_meta.get("estimated_tdee"),
|
|
||||||
"status": energy_meta.get("status"),
|
|
||||||
"confidence": energy_meta.get("confidence"),
|
|
||||||
"data_points": energy_meta.get("data_points"),
|
|
||||||
},
|
|
||||||
"calorie_balance_daily": calorie_balance_daily,
|
|
||||||
"protein_vs_lean_mass": {
|
|
||||||
"points": pl_points,
|
|
||||||
"protein_target_low_g": pt_low if pt_low > 0 else None,
|
|
||||||
},
|
|
||||||
"nutrition_correlation_heuristics": nutrition_correlation_heuristics,
|
|
||||||
"chart_payloads": chart_payloads,
|
|
||||||
"chart_payloads_days": days_for_embedded_charts,
|
|
||||||
"meta": {
|
|
||||||
"layer_1": "nutrition_metrics",
|
|
||||||
"layer_2b": "nutrition_viz",
|
|
||||||
"issue": "53-phase-0c",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
"""
|
|
||||||
Kompakte Zahlen- und JSON-Aufbereitung für KI-Platzhalter (Token sparen).
|
|
||||||
|
|
||||||
- Floats: sinnvolle Nachkommastellen je nach Größenordnung (kleine Werte <0,1 mehr Präzision).
|
|
||||||
- ≥10 meist ganzzahlig; Prozent/Verhältnisse über denselben Mechanismus lesbar.
|
|
||||||
- Rekursiv auf dict/list-Strukturen vor json.dumps in _safe_json anwendbar.
|
|
||||||
|
|
||||||
Hinweis: numpy.float64 und numerische Strings (DB/API) sind keine ``float``-Instanzen —
|
|
||||||
diese werden explizit mit float() normalisiert.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import math
|
|
||||||
import re
|
|
||||||
from decimal import Decimal
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
def compact_float_for_prompt(x: float) -> float | int:
|
|
||||||
"""
|
|
||||||
Reduziert unnötige Nachkommastellen; erhält kleine Beträge (<0,1) mit mehr Stellen.
|
|
||||||
"""
|
|
||||||
if not math.isfinite(x):
|
|
||||||
return x
|
|
||||||
ax = abs(x)
|
|
||||||
if ax == 0.0:
|
|
||||||
return 0
|
|
||||||
if ax >= 100.0:
|
|
||||||
return int(round(x))
|
|
||||||
if ax >= 10.0:
|
|
||||||
return int(round(x))
|
|
||||||
if ax >= 1.0:
|
|
||||||
r = round(x, 2)
|
|
||||||
return int(r) if abs(r - int(round(r))) < 1e-6 else r
|
|
||||||
if ax >= 0.1:
|
|
||||||
r = round(x, 2)
|
|
||||||
return int(r) if abs(r - int(round(r))) < 1e-6 else r
|
|
||||||
if ax >= 0.01:
|
|
||||||
return round(x, 3)
|
|
||||||
return round(x, 4)
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_prompt_number(x: Any) -> Any:
|
|
||||||
"""int/Decimal/float kompakt; numpy-Scalars; numerische Strings; sonst unverändert."""
|
|
||||||
if x is None:
|
|
||||||
return None
|
|
||||||
if isinstance(x, bool):
|
|
||||||
return x
|
|
||||||
if isinstance(x, int) and not isinstance(x, bool):
|
|
||||||
return x
|
|
||||||
if isinstance(x, str):
|
|
||||||
s = x.strip()
|
|
||||||
if not s:
|
|
||||||
return x
|
|
||||||
try:
|
|
||||||
if re.fullmatch(r"-?\d+", s):
|
|
||||||
return int(s)
|
|
||||||
xf = float(s)
|
|
||||||
except ValueError:
|
|
||||||
return x
|
|
||||||
if not math.isfinite(xf):
|
|
||||||
return x
|
|
||||||
return compact_float_for_prompt(xf)
|
|
||||||
if isinstance(x, Decimal):
|
|
||||||
try:
|
|
||||||
xf = float(x)
|
|
||||||
except Exception:
|
|
||||||
return x
|
|
||||||
if not math.isfinite(xf):
|
|
||||||
return x
|
|
||||||
return compact_float_for_prompt(xf)
|
|
||||||
if isinstance(x, float):
|
|
||||||
if not math.isfinite(x):
|
|
||||||
return x
|
|
||||||
return compact_float_for_prompt(x)
|
|
||||||
try:
|
|
||||||
xf = float(x)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return x
|
|
||||||
if not math.isfinite(xf):
|
|
||||||
return x
|
|
||||||
return compact_float_for_prompt(xf)
|
|
||||||
|
|
||||||
|
|
||||||
def compact_json_payload_for_prompts(obj: Any) -> Any:
|
|
||||||
"""
|
|
||||||
Tiefe Kopie mit kompakten Zahlen (dicts/list/tuples rekursiv).
|
|
||||||
Strings und dict-Keys werden nicht verändert.
|
|
||||||
"""
|
|
||||||
if obj is None:
|
|
||||||
return None
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
return {k: compact_json_payload_for_prompts(v) for k, v in obj.items()}
|
|
||||||
if isinstance(obj, (list, tuple)):
|
|
||||||
t = [compact_json_payload_for_prompts(v) for v in obj]
|
|
||||||
return tuple(t) if isinstance(obj, tuple) else t
|
|
||||||
return normalize_prompt_number(obj)
|
|
||||||
|
|
||||||
|
|
||||||
def format_scalar_for_prompt_text(x: Any) -> str:
|
|
||||||
"""
|
|
||||||
Kurzdarstellung für Text-Platzhalter (activity_detail, Tabellen, …).
|
|
||||||
Alle Zahlenpfade über normalize_prompt_number; Ausgabe kurz (%g, keine Float-Schweife).
|
|
||||||
"""
|
|
||||||
if x is None:
|
|
||||||
return "—"
|
|
||||||
if isinstance(x, bool):
|
|
||||||
return "ja" if x else "nein"
|
|
||||||
n = normalize_prompt_number(x)
|
|
||||||
if isinstance(n, bool):
|
|
||||||
return "ja" if n else "nein"
|
|
||||||
if isinstance(n, str):
|
|
||||||
return n
|
|
||||||
if isinstance(n, int) and not isinstance(n, bool):
|
|
||||||
return str(n)
|
|
||||||
if isinstance(n, float):
|
|
||||||
if not math.isfinite(n):
|
|
||||||
return str(n)
|
|
||||||
return "%g" % n
|
|
||||||
return str(n)
|
|
||||||
|
|
||||||
|
|
||||||
def session_metrics_list_to_key_value_compact(metrics: list[Any] | None) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Session-Metriken für KI-JSON: nur key → Wert (keine wiederholten Namen/Beschreibungen).
|
|
||||||
|
|
||||||
Semantik: {{training_parameters_glossary_md}} im Prompt ergänzen.
|
|
||||||
"""
|
|
||||||
out: dict[str, Any] = {}
|
|
||||||
for m in metrics or []:
|
|
||||||
if not isinstance(m, dict):
|
|
||||||
continue
|
|
||||||
k = m.get("key")
|
|
||||||
if not k:
|
|
||||||
continue
|
|
||||||
v = m.get("value")
|
|
||||||
dt = (m.get("data_type") or "").lower()
|
|
||||||
if v is None:
|
|
||||||
out[str(k)] = None
|
|
||||||
continue
|
|
||||||
if dt == "integer":
|
|
||||||
try:
|
|
||||||
out[str(k)] = int(v)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
out[str(k)] = normalize_prompt_number(v)
|
|
||||||
elif dt == "boolean":
|
|
||||||
out[str(k)] = bool(v)
|
|
||||||
elif dt == "string":
|
|
||||||
out[str(k)] = normalize_prompt_number(v)
|
|
||||||
else:
|
|
||||||
out[str(k)] = normalize_prompt_number(v)
|
|
||||||
return out
|
|
||||||
|
|
@ -1,573 +0,0 @@
|
||||||
"""
|
|
||||||
Chart.js-Payloads für Recovery (R1–R5) — gemeinsam mit routers/charts und recovery-dashboard-viz.
|
|
||||||
|
|
||||||
Ausgelagert aus routers/charts.py (Issue 53 / Layer 1).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date, datetime, timedelta
|
|
||||||
from typing import Any, Dict, Optional, Set
|
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
|
||||||
from data_layer.recovery_metrics import (
|
|
||||||
SLEEP_DEBT_ROLLING_WINDOW_DAYS,
|
|
||||||
SLEEP_DEBT_TARGET_HOURS_DEFAULT,
|
|
||||||
calculate_hrv_vs_baseline_pct,
|
|
||||||
calculate_recovery_score_v2,
|
|
||||||
calculate_rhr_vs_baseline_pct,
|
|
||||||
calculate_sleep_debt_hours,
|
|
||||||
get_sleep_duration_data,
|
|
||||||
get_sleep_quality_data,
|
|
||||||
sleep_debt_sum_hours_in_window,
|
|
||||||
)
|
|
||||||
from data_layer.utils import calculate_confidence, safe_float, serialize_dates
|
|
||||||
from data_layer.vital_signs_assessment import build_vital_items_from_rows
|
|
||||||
|
|
||||||
|
|
||||||
def build_recovery_score_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
if days < 7:
|
|
||||||
days = 7
|
|
||||||
if days > 90:
|
|
||||||
days = 90
|
|
||||||
current_score = calculate_recovery_score_v2(profile_id)
|
|
||||||
|
|
||||||
if current_score is None:
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Keine Recovery-Daten vorhanden",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT date, resting_hr, hrv
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id=%s AND date >= %s
|
|
||||||
ORDER BY date""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
|
|
||||||
if not rows:
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {
|
|
||||||
"labels": [datetime.now().strftime("%Y-%m-%d")],
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "Recovery Score",
|
|
||||||
"data": [current_score],
|
|
||||||
"borderColor": "#1D9E75",
|
|
||||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"tension": 0.3,
|
|
||||||
"fill": True,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "low",
|
|
||||||
"data_points": 1,
|
|
||||||
"current_score": current_score,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
labels = [row["date"].isoformat() for row in rows]
|
|
||||||
values = [min(100, max(0, safe_float(row["hrv"]) if row["hrv"] else 50)) for row in rows]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {
|
|
||||||
"labels": labels,
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "HRV (ms, auf 0–100 begrenzt) — nicht der KPI Recovery-Score",
|
|
||||||
"data": values,
|
|
||||||
"borderColor": "#1D9E75",
|
|
||||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"tension": 0.3,
|
|
||||||
"fill": True,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"metadata": serialize_dates(
|
|
||||||
{
|
|
||||||
"confidence": calculate_confidence(len(rows), days, "general"),
|
|
||||||
"data_points": len(rows),
|
|
||||||
"current_score": current_score,
|
|
||||||
"chart_series_kind": "hrv_ms_clamped",
|
|
||||||
"kpi_score_source": "calculate_recovery_score_v2",
|
|
||||||
"note": "Kurve = HRV-Rohwert (ms) begrenzt auf 0–100, nur Verlaufsorientierung. "
|
|
||||||
"KPI-Kachel «Recovery-Score» = gewichteter Score (HRV, RHR, Schlaf, …).",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_hrv_rhr_baseline_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
if days < 7:
|
|
||||||
days = 7
|
|
||||||
if days > 90:
|
|
||||||
days = 90
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT date, resting_hr, hrv
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id=%s AND date >= %s
|
|
||||||
ORDER BY date""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
|
|
||||||
if not rows:
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Keine Vitalwerte vorhanden",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
labels = [row["date"].isoformat() for row in rows]
|
|
||||||
hrv_values = [safe_float(row["hrv"]) if row["hrv"] else None for row in rows]
|
|
||||||
rhr_values = [safe_float(row["resting_hr"]) if row["resting_hr"] else None for row in rows]
|
|
||||||
|
|
||||||
hrv_baseline = calculate_hrv_vs_baseline_pct(profile_id)
|
|
||||||
rhr_baseline = calculate_rhr_vs_baseline_pct(profile_id)
|
|
||||||
|
|
||||||
hrv_filtered = [v for v in hrv_values if v is not None]
|
|
||||||
rhr_filtered = [v for v in rhr_values if v is not None]
|
|
||||||
|
|
||||||
avg_hrv = sum(hrv_filtered) / len(hrv_filtered) if hrv_filtered else 50
|
|
||||||
avg_rhr = sum(rhr_filtered) / len(rhr_filtered) if rhr_filtered else 60
|
|
||||||
|
|
||||||
datasets = [
|
|
||||||
{
|
|
||||||
"label": "HRV (ms)",
|
|
||||||
"data": hrv_values,
|
|
||||||
"borderColor": "#1D9E75",
|
|
||||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"tension": 0.3,
|
|
||||||
"yAxisID": "y1",
|
|
||||||
"fill": False,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "RHR (bpm)",
|
|
||||||
"data": rhr_values,
|
|
||||||
"borderColor": "#3B82F6",
|
|
||||||
"backgroundColor": "rgba(59, 130, 246, 0.1)",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"tension": 0.3,
|
|
||||||
"yAxisID": "y2",
|
|
||||||
"fill": False,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {"labels": labels, "datasets": datasets},
|
|
||||||
"metadata": serialize_dates(
|
|
||||||
{
|
|
||||||
"confidence": calculate_confidence(len(rows), days, "general"),
|
|
||||||
"data_points": len(rows),
|
|
||||||
"avg_hrv": round(avg_hrv, 1),
|
|
||||||
"avg_rhr": round(avg_rhr, 1),
|
|
||||||
"hrv_vs_baseline_pct": hrv_baseline,
|
|
||||||
"rhr_vs_baseline_pct": rhr_baseline,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_sleep_duration_quality_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
if days < 7:
|
|
||||||
days = 7
|
|
||||||
if days > 90:
|
|
||||||
days = 90
|
|
||||||
duration_data = get_sleep_duration_data(profile_id, days)
|
|
||||||
quality_data = get_sleep_quality_data(profile_id, days)
|
|
||||||
|
|
||||||
if duration_data["confidence"] == "insufficient":
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Keine Schlafdaten vorhanden",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT date, duration_minutes
|
|
||||||
FROM sleep_log
|
|
||||||
WHERE profile_id=%s AND date >= %s
|
|
||||||
ORDER BY date""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
|
|
||||||
if not rows:
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Keine Schlafdaten",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
labels = [row["date"].isoformat() for row in rows]
|
|
||||||
duration_hours = [
|
|
||||||
safe_float(row["duration_minutes"]) / 60 if row["duration_minutes"] else None for row in rows
|
|
||||||
]
|
|
||||||
|
|
||||||
quality_scores = [(d / 8 * 100) if d else None for d in duration_hours]
|
|
||||||
|
|
||||||
datasets = [
|
|
||||||
{
|
|
||||||
"label": "Schlafdauer (h)",
|
|
||||||
"data": duration_hours,
|
|
||||||
"borderColor": "#3B82F6",
|
|
||||||
"backgroundColor": "rgba(59, 130, 246, 0.1)",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"tension": 0.3,
|
|
||||||
"yAxisID": "y1",
|
|
||||||
"fill": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Qualität (%)",
|
|
||||||
"data": quality_scores,
|
|
||||||
"borderColor": "#1D9E75",
|
|
||||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"tension": 0.3,
|
|
||||||
"yAxisID": "y2",
|
|
||||||
"fill": False,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {"labels": labels, "datasets": datasets},
|
|
||||||
"metadata": serialize_dates(
|
|
||||||
{
|
|
||||||
"confidence": duration_data["confidence"],
|
|
||||||
"data_points": len(rows),
|
|
||||||
"avg_duration_hours": round(duration_data["avg_duration_hours"], 1),
|
|
||||||
"sleep_quality_score": quality_data.get("quality_score", 0),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_sleep_debt_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
|
||||||
if days < 7:
|
|
||||||
days = 7
|
|
||||||
if days > 90:
|
|
||||||
days = 90
|
|
||||||
current_debt = calculate_sleep_debt_hours(profile_id)
|
|
||||||
|
|
||||||
if current_debt is None:
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Keine Schlafdaten für Schulden-Berechnung",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
chart_cutoff = (datetime.now() - timedelta(days=days)).date()
|
|
||||||
# Historie vor dem Chart-Fenster, damit das rollierende 14-Tage-Fenster früh korrekt gefüllt ist
|
|
||||||
ext_cutoff = (datetime.now() - timedelta(days=days + SLEEP_DEBT_ROLLING_WINDOW_DAYS + 3)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT date, duration_minutes
|
|
||||||
FROM sleep_log
|
|
||||||
WHERE profile_id=%s AND date >= %s
|
|
||||||
AND duration_minutes IS NOT NULL
|
|
||||||
ORDER BY date ASC""",
|
|
||||||
(profile_id, ext_cutoff),
|
|
||||||
)
|
|
||||||
all_rows = [dict(r) for r in cur.fetchall()]
|
|
||||||
|
|
||||||
visible = []
|
|
||||||
for r in all_rows:
|
|
||||||
rd = r.get("date")
|
|
||||||
d = rd.date() if isinstance(rd, datetime) else rd
|
|
||||||
if d >= chart_cutoff:
|
|
||||||
visible.append(r)
|
|
||||||
|
|
||||||
if not visible:
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Keine Schlafdaten",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
labels: list[str] = []
|
|
||||||
debt_values: list[float] = []
|
|
||||||
for r in visible:
|
|
||||||
rd = r.get("date")
|
|
||||||
end_d = rd.date() if isinstance(rd, datetime) else rd
|
|
||||||
if not isinstance(end_d, date):
|
|
||||||
continue
|
|
||||||
labels.append(end_d.isoformat())
|
|
||||||
debt_values.append(sleep_debt_sum_hours_in_window(all_rows, end_d))
|
|
||||||
|
|
||||||
# KPI nutzt immer Fensterende = heute; die Kurve endete bisher am Datum der letzten Schlaf-Zeile
|
|
||||||
# (z. B. gestern) → anderes 14-Tage-Fenster. Letzter Punkt = exakt KPI-Wert, Datum = heute.
|
|
||||||
today = datetime.now().date()
|
|
||||||
if labels and debt_values:
|
|
||||||
try:
|
|
||||||
last_d = date.fromisoformat(labels[-1])
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
last_d = None
|
|
||||||
if last_d is not None:
|
|
||||||
if last_d < today:
|
|
||||||
labels.append(today.isoformat())
|
|
||||||
debt_values.append(float(current_debt))
|
|
||||||
elif last_d == today:
|
|
||||||
debt_values[-1] = float(current_debt)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "line",
|
|
||||||
"data": {
|
|
||||||
"labels": labels,
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": f"Schlafschuld (h), rollierend {SLEEP_DEBT_ROLLING_WINDOW_DAYS} Tage — wie KPI",
|
|
||||||
"data": debt_values,
|
|
||||||
"borderColor": "#EF4444",
|
|
||||||
"backgroundColor": "rgba(239, 68, 68, 0.1)",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"tension": 0.3,
|
|
||||||
"fill": True,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"metadata": serialize_dates(
|
|
||||||
{
|
|
||||||
"confidence": calculate_confidence(len(visible), days, "general"),
|
|
||||||
"data_points": len(labels),
|
|
||||||
"current_debt_hours": round(float(current_debt), 1),
|
|
||||||
"sleep_debt_target_hours_per_night": SLEEP_DEBT_TARGET_HOURS_DEFAULT,
|
|
||||||
"rolling_window_days": SLEEP_DEBT_ROLLING_WINDOW_DAYS,
|
|
||||||
"note": "Gleiche Formel wie KPI: Summe der nächtlichen Defizite vs. "
|
|
||||||
f"{SLEEP_DEBT_TARGET_HOURS_DEFAULT} h/Nacht im rollierenden {SLEEP_DEBT_ROLLING_WINDOW_DAYS}-Tage-Fenster. "
|
|
||||||
"Zwischenpunkte: Fensterende = Datum der jeweiligen Schlaf-Zeile; "
|
|
||||||
"letzter Punkt ist auf «heute» bzw. KPI-Wert gesetzt, damit Kurve und Kachel übereinstimmen.",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
VITAL_BASELINE_KEYS = ("resting_hr", "hrv", "vo2_max", "spo2", "respiratory_rate")
|
|
||||||
|
|
||||||
|
|
||||||
def _vitals_row_has_any_value(row: Any) -> bool:
|
|
||||||
if not row:
|
|
||||||
return False
|
|
||||||
for k in VITAL_BASELINE_KEYS:
|
|
||||||
if row.get(k) is not None:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_vitals_baseline_rows(rows: Any) -> tuple[Optional[Dict[str, Any]], Optional[Any]]:
|
|
||||||
"""
|
|
||||||
Pro Kennzahl den jeweils neuesten nicht-leeren Wert (Zeilen sortiert: date DESC).
|
|
||||||
So können KPIs (Aggregation über Zeilen) Daten haben, obwohl die jüngste Zeile leer ist.
|
|
||||||
"""
|
|
||||||
if not rows:
|
|
||||||
return None, None
|
|
||||||
merged: Dict[str, Any] = {k: None for k in VITAL_BASELINE_KEYS}
|
|
||||||
for row in rows:
|
|
||||||
for k in VITAL_BASELINE_KEYS:
|
|
||||||
if merged[k] is None and row.get(k) is not None:
|
|
||||||
merged[k] = row[k]
|
|
||||||
if all(merged[k] is not None for k in VITAL_BASELINE_KEYS):
|
|
||||||
break
|
|
||||||
if not _vitals_row_has_any_value(merged):
|
|
||||||
return None, None
|
|
||||||
newest_date = rows[0].get("date") if rows else None
|
|
||||||
return merged, newest_date
|
|
||||||
|
|
||||||
|
|
||||||
def _bp_row_complete(row: Any) -> bool:
|
|
||||||
return bool(row and row.get("systolic") is not None and row.get("diastolic") is not None)
|
|
||||||
|
|
||||||
|
|
||||||
def _tone_to_bar_value(tone: str) -> float:
|
|
||||||
return {"good": 88.0, "warn": 52.0, "bad": 22.0, "neutral": 62.0}.get(tone, 55.0)
|
|
||||||
|
|
||||||
|
|
||||||
def build_vital_signs_matrix_chart_payload(
|
|
||||||
profile_id: str,
|
|
||||||
days: int,
|
|
||||||
omit_snapshot_keys: Optional[Set[str]] = None,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Letzte Messungen im Fenster; sonst Fallback auf jüngste Messung überhaupt (Issue 53 / Layer 1).
|
|
||||||
|
|
||||||
omit_snapshot_keys: z. B. {'resting_hr','hrv'} wenn dieselbe Einordnung bereits im Vital-Verlauf steht.
|
|
||||||
"""
|
|
||||||
if days < 7:
|
|
||||||
days = 7
|
|
||||||
if days > 365:
|
|
||||||
days = 365
|
|
||||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
bp_row = None
|
|
||||||
vitals_measured_at = None
|
|
||||||
bp_measured_at = None
|
|
||||||
vitals_for_items: Optional[Dict[str, Any]] = None
|
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT date, resting_hr, hrv, vo2_max, spo2, respiratory_rate
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id=%s AND date >= %s
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 200""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
vitals_merged, vitals_date = _merge_vitals_baseline_rows(cur.fetchall())
|
|
||||||
if vitals_merged is None:
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT date, resting_hr, hrv, vo2_max, spo2, respiratory_rate
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id=%s
|
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 400""",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
vitals_merged, vitals_date = _merge_vitals_baseline_rows(cur.fetchall())
|
|
||||||
if vitals_merged is not None:
|
|
||||||
vitals_for_items = dict(vitals_merged)
|
|
||||||
if vitals_date is not None:
|
|
||||||
vitals_measured_at = vitals_date.isoformat() if hasattr(vitals_date, "isoformat") else str(vitals_date)
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT measured_at, systolic, diastolic
|
|
||||||
FROM blood_pressure_log
|
|
||||||
WHERE profile_id=%s AND measured_at::date >= %s::date
|
|
||||||
ORDER BY measured_at DESC
|
|
||||||
LIMIT 1""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
bp_row = cur.fetchone()
|
|
||||||
if bp_row and bp_row.get("measured_at") is not None:
|
|
||||||
bp_measured_at = bp_row["measured_at"]
|
|
||||||
|
|
||||||
if not _bp_row_complete(bp_row):
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT measured_at, systolic, diastolic
|
|
||||||
FROM blood_pressure_log
|
|
||||||
WHERE profile_id=%s
|
|
||||||
ORDER BY measured_at DESC
|
|
||||||
LIMIT 1""",
|
|
||||||
(profile_id,),
|
|
||||||
)
|
|
||||||
bp_row = cur.fetchone()
|
|
||||||
if bp_row and bp_row.get("measured_at") is not None:
|
|
||||||
bp_measured_at = bp_row["measured_at"]
|
|
||||||
|
|
||||||
bp_for_items = None
|
|
||||||
if bp_row:
|
|
||||||
bp_for_items = {"systolic": bp_row.get("systolic"), "diastolic": bp_row.get("diastolic")}
|
|
||||||
|
|
||||||
items = build_vital_items_from_rows(
|
|
||||||
vitals_for_items, bp_for_items, omit_keys=omit_snapshot_keys
|
|
||||||
)
|
|
||||||
if not items and vitals_for_items and omit_snapshot_keys:
|
|
||||||
items = build_vital_items_from_rows(vitals_for_items, bp_for_items, omit_keys=None)
|
|
||||||
|
|
||||||
if not items:
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Keine Vitalwerte mit Zahlenwerten — Baseline-Vitals und/oder Blutdruck erfassen.",
|
|
||||||
"vital_items": [],
|
|
||||||
"vitals_measured_at": vitals_measured_at,
|
|
||||||
"blood_pressure_measured_at": bp_measured_at.isoformat() if bp_measured_at and hasattr(bp_measured_at, "isoformat") else None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for it in items:
|
|
||||||
it["bar_value"] = round(_tone_to_bar_value(it["tone"]), 1)
|
|
||||||
|
|
||||||
labels_short = [it["label_de"] for it in items]
|
|
||||||
bar_values = [it["bar_value"] for it in items]
|
|
||||||
colors = []
|
|
||||||
for it in items:
|
|
||||||
t = it["tone"]
|
|
||||||
if t == "good":
|
|
||||||
colors.append("#1D9E75")
|
|
||||||
elif t == "warn":
|
|
||||||
colors.append("#EF9F27")
|
|
||||||
elif t == "bad":
|
|
||||||
colors.append("#D85A30")
|
|
||||||
else:
|
|
||||||
colors.append("#6B7280")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {
|
|
||||||
"labels": labels_short,
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "Einschätzung (relativ)",
|
|
||||||
"data": bar_values,
|
|
||||||
"backgroundColor": colors,
|
|
||||||
"borderColor": colors,
|
|
||||||
"borderWidth": 1,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"metadata": serialize_dates(
|
|
||||||
{
|
|
||||||
"confidence": "medium",
|
|
||||||
"data_points": len(items),
|
|
||||||
"note": "Orientierende Zonen, keine Diagnose. Balken = relative Einordnung (nicht körperliche Einheit).",
|
|
||||||
"vital_items": items,
|
|
||||||
"bar_is_relative_score": True,
|
|
||||||
"vitals_measured_at": vitals_measured_at,
|
|
||||||
"blood_pressure_measured_at": bp_measured_at.isoformat()
|
|
||||||
if bp_measured_at and hasattr(bp_measured_at, "isoformat")
|
|
||||||
else (str(bp_measured_at) if bp_measured_at else None),
|
|
||||||
"disclaimer_de": "Hinweis: Nur Orientierung; bei Beschwerden oder auffälligen Werten ärztlich abklären.",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user