diff --git a/.claude/docs/README.md b/.claude/docs/README.md index c2834d7..8c409d9 100644 --- a/.claude/docs/README.md +++ b/.claude/docs/README.md @@ -55,6 +55,7 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp | 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 | | Universal CSV Import | `technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | `backend/csv_parser/`, `routers/csv_import.py`, `routers/admin_csv_templates.py` | +| Aktivität Produktionsreife | `technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` (+ EAV-Guide) | `backend/data_layer/activity_session_metrics.py`, `activity_metrics.py`, CSV-Orchestrierung | | Mitgliedschaft / Features | `technical/MEMBERSHIP_SYSTEM.md`, `architecture/FEATURE_ENFORCEMENT.md` | `backend/auth.py`, Feature-Logging, Router mit Enforcement | --- @@ -114,6 +115,11 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp | `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) | --- diff --git a/.claude/docs/technical/ACTIVITY_COMPOSITE_METRICS_IMPLEMENTATION_CONCEPT.md b/.claude/docs/technical/ACTIVITY_COMPOSITE_METRICS_IMPLEMENTATION_CONCEPT.md new file mode 100644 index 0000000..249cee5 --- /dev/null +++ b/.claude/docs/technical/ACTIVITY_COMPOSITE_METRICS_IMPLEMENTATION_CONCEPT.md @@ -0,0 +1,317 @@ +# Activity Session Metrics: Composite-Daten (EAV) – Umsetzungskonzept + +**Stand:** 2026-04-16 +**Status:** Normatives Konzept zur nahtlosen Weiterarbeit durch Code-Agenten +**Bezieht sich auf:** `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` (§2.3–2.4, Phasen D–E), `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md`, Issue #53 (Layer-1-Prinzip: Auswertungen nur über `data_layer`) + +--- + +## 1. Ziel und Abgrenzung + +### 1.1 Ziel + +- **Composite-Messgrößen** (strukturierte Werte mit mehreren benannten Slots) werden wie **normale Trainingsparameter** im Katalog geführt, **Kategorie-/Typ-Profilen** zugeordnet und pro Session in der **EAV-Tabelle** persistiert. +- **Persistenz:** ein JSON-Dokument pro Session und `training_parameter_id` (kanonisch **JSONB**), kompatibel mit der bestehenden „eine Zeile pro Parameter“-Semantik. +- **Import:** CSV liefert typischerweise **eine Spalte pro atomarem Slot**; das Mapping verweist auf **`(Parameter-Key, Slot-Key)`** (stabile Strings, nicht Spaltenreihenfolge). +- **Layer 1:** liefert für Consumer weiterhin **eine konsistente API**: Rohdokument **und** optional **aufgelöste Einzelwerte** (flach oder namenspaced), ohne dass Charts/Platzhalter direkt JSON parsen müssen. + +### 1.2 Nicht-Ziele (explizit) + +- Kein „freies“ JSON-Schema im Admin ohne Archetyp-Bindung (verhindert Datenmüll und nicht validierbare Dokumente). +- Keine Abschwächung bestehender **Skalar-Parameter** (`integer`, `float`, `string`, `boolean`): alle bisherigen Pfade bleiben gültig. +- Kein Ersatz für `activity_log`-**Spine** oder Session-Qualitätsblobs (`evaluation`, …). + +### 1.3 Kompatibilitätsgarantie („keine Regression“) + +| Bereich | Maßnahme | +|---------|----------| +| DB | Nur **additive** Migrationen; bestehende `CHECK`-Regeln für Skalare bleiben für Zeilen **ohne** Composite erhalten bzw. werden zu einer **Oder-Verknüpfung** erweitert (siehe §4). | +| `training_parameters` | Neuer `data_type`-Wert **`composite`** zusätzlich zu den vier bestehenden; bestehende CHECK-Constraint muss erweitert werden (Migration). | +| `activity_session_metrics` | Skalare Zeilen unverändert; Composite-Zeilen nutzen **`value_json`** (neu), alle `value_*` NULL. | +| Layer 1 | `resolve_activity_attribute_schema`, Merge, Replace: Composite erscheint als **ein** Schema-Eintrag; Lese-/Schreibpfade erweitern, nicht ersetzen. | +| CSV | Bestehende Map-Ziele auf Skalare/Registry unverändert; neue Zielnotation nur für Composites. | +| Admin | tcp/ttp-UI: gleiche Zuordnung wie heute; Zusatzfelder nur bei `data_type === composite`. | + +### 1.4 Abgleich mit `functional_concept_composite_data.md` (fachliches Konzept) + +Das **fachliche Konzeptpapier** (Composite Scalar/Layer-Trennung) und dieses **Umsetzungskonzept** sind **vereinbar**, wenn die Rollen klar getrennt bleiben: + +| Thema | Fachliches Konzept (`functional_concept_composite_data.md`) | Dieses Umsetzungskonzept (technisch) | +|--------|-------------------------------------------------------------|--------------------------------------| +| **Speicher in der DB** | Einheitlicher Store; Composite = `jsonb` mit **kleinem Basisschema** (`v`, `kind`, `domain`, `items`, optional `basis`, `meta`) | `activity_session_metrics.value_json`; CHECK Skalar vs. Composite | +| **Technische Container** | Genau **vier** `kind`-Werte: `group_set`, `distribution_set`, `sequence_set`, `model_set` | Layer-1-Validierung **muss** diese Hülle durchsetzen; kein freies JSON ohne `kind`/`v`/`items` | +| **„Archetypen“** | **Fachliche** Ausprägungen werden in **Layer 2a** aus L1-Objekten abgeleitet | Benannte **Preset-/Validierungsprofile** im Code (z. B. Zonenverteilung HF) sind **kein** zweites Persistenz-Schema: sie legen fest, *welches* der vier `kind`-Muster, *welches* `domain`, *welche* Item-Keys/Typen erlaubt sind — inkl. CSV-Slot-Mapping | +| **Layer 1** | Validiert, minimal normalisiert, **keine** Scores/Bewertungen/KI-Texte | Validator + Merge + optional `expand_*` (**technische** Flachstellung für Consumer, z. B. `param.slot` → Skalar) | +| **Layer 2** | Diagramme, Kennzahlen, KI-Platzhalter-**Formulierung** | unverändert; konsumiert L1 (und ggf. L2a) | + +**Konsequenz für die Registry:** Statt „8 freie JSON-Archetypen“ implementiert die Code-Registry **Validierungs-Presets**, die alle auf die **vier technischen `kind`-Formen** abbilden. Die Tabelle in §3 beschreibt weiterhin **fachlich benannte MVP-Anker** — technisch übersetzen sie sich in `(kind, domain, Item-Regeln, v)`. + +**Konsequenz für Platzhalter:** Roh-JSON aus der DB **nicht** ungefiltert in Prompts; L2b nutzt L1/L2a-Aufbereitung (wie im fachlichen Konzept). + +--- + +## 2. Begriffe + +| Begriff | Bedeutung | +|---------|-----------| +| **Archetyp** | Im **Repo versionierte** Strukturvorlage (erlaubte Slots, Typen, Pflichtfelder, Validator, Version). **7–8** Stück geplant; Erweiterung nur per Code-Release. | +| **Slot** | Benanntes Teilfeld innerhalb des Composite-Dokuments, z. B. `z1_sec`, `z2_sec`, `avg_cadence`. | +| **Parameter-Instanz** | Eine Zeile in `training_parameters` mit `data_type = composite` und Metadaten, **welcher** Archetyp gilt (siehe §5). | +| **Dokument** | Ein JSON-Objekt, das alle Slots abbildet; gespeichert in `activity_session_metrics.value_json`. | + +--- + +## 3. Archetypen-Katalog (Planungsstand) — fachliche Namen → technische `kind`-Presets + +Die **konkrete** Slot-Liste und Validierung wird im Code als **Registry** geführt (z. B. `backend/data_layer/activity_composite_archetypes.py`). Jedes Preset **mappt** auf genau eines von **`group_set` | `distribution_set` | `sequence_set` | `model_set`** und erfüllt das **Basisschema** aus `functional_concept_composite_data.md` §7. + +Inhaltlich orientiert an `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` §2.4. + +**Beispielhafte fachliche MVP-Anker** (8 Kandidaten; im Code als Preset-Key + `kind`/`domain` abbilden): + +| `archetype_key` (stabil) | Kurzbeschreibung | Typische Slots (Beispiel) | +|--------------------------|------------------|---------------------------| +| `hr_zone_distribution` | Zeit-/Anteil je HF-Zone | `z1_sec`…`z5_sec` oder `zones[]` | +| `power_zone_distribution` | Leistungszonen | analog | +| `pace_band_profile` | Pace-Bänder / Histogramm | bucket-Struktur | +| `interval_block_summary` | Intervallblöcke aggregiert | `blocks[]` mit Dauer, Ziel, Ist | +| `event_marker_sequence` | Ereignisse mit Zeitstempel | `events[]` | +| `coupling_efficiency_profile` | Kopplungs-/Effizienzmetriken | sportabhängig | +| `model_parameter_profile` | Modell-/Schwellenparameter | key-value-ähnlich, validiert | +| `readiness_recovery_snapshot` | optional: kurzes Multi-Signal-Bundle | nur wenn fachlich gewünscht | + +**Regel:** Jeder Archetyp hat `version` (Integer). Validator lehnt Dokumente mit falscher/fehlender Version ab oder migriert definiert (nur wenn spezifiziert). + +--- + +## 4. Datenmodell-Erweiterungen + +### 4.1 `training_parameters` + +**Migration (additiv):** + +1. `CHECK (data_type IN (...))` erweitern um **`composite`**. +2. Optional eigene Spalte **`composite_archetype_key` `VARCHAR(64)`** (NOT NULL wenn `data_type = composite`, sonst NULL) — **oder** ausschließlich in `validation_rules` speichern (siehe unten). + **Empfehlung:** Spalte `composite_archetype_key` + `composite_archetype_version INT` für einfache Admin-Queries und klare Semantik; `validation_rules` für archetyp-spezifische Feinheiten (z. B. erlaubte Zonenanzahl). + +**Konsistenz-Constraint (DB oder App):** + +- Wenn `data_type = composite`: `composite_archetype_key` gesetzt, `source_field` typischerweise **NULL** (kein `activity_log`-Skalar-Shadowing). +- `unit` am Parameter: optional für „Anzeige-Einheit“ des Gesamtwerts oder leer; Slots haben Einheiten im Archetyp oder in Slot-Metadaten. + +### 4.2 `activity_session_metrics` + +**Migration (additiv):** + +```text +value_json JSONB NULL +``` + +**CHECK-Constraint ersetzen/erweitern** (Konzept): + +- **Modus Skalar:** genau eine der Spalten `value_num`, `value_int`, `value_text`, `value_bool` ist NOT NULL; `value_json` IS NULL. +- **Modus Composite:** `value_json` IS NOT NULL; alle vier Skalar-Spalten IS NULL. + +Damit bleibt die bestehende Semantik „eine Zeile = ein Parameter“ erhalten. + +**Kommentar:** Tabelle trägt weiterhin „EAV“; Composites sind **keine** zusätzlichen Zeilen pro Slot. + +### 4.3 Profil-Zuordnung (tcp / ttp) + +**Keine** Tabellenänderung: `training_category_parameter` und `training_type_parameter` verweisen weiter nur auf `training_parameter_id`. Composite-Parameter verhalten sich wie Skalare in Bezug auf **Zuordnung**, **sort_order**, **required**, **ui_group**. + +**`required`:** bedeutet „Dokument muss nach Validator vollständig sein“, nicht „jede CSV-Spalte muss in jeder Zeile vorkommen“. + +--- + +## 5. Metadaten pro Composite-Parameter + +Minimal in der DB (Beispiel): + +| Feld | Zweck | +|------|--------| +| `data_type` | `composite` | +| `composite_archetype_key` | Verweis auf Code-Registry | +| `composite_archetype_version` | Schema-Version | +| `validation_rules` | optional: Overrides (z. B. `max_zones`, sport-spezifisch) — nur was der Validator explizit auswertet | + +**Admin-API:** bestehende Endpoints erweitern (Payload-Validierung): bei `composite` müssen Archetyp + Version gesetzt sein und in der **Registry** existieren. + +--- + +## 6. Layer 1 – Kontrakt (`activity_session_metrics.py` + Helfer) + +### 6.1 Schema-Auflösung + +`resolve_activity_attribute_schema` liefert pro Composite **einen** Eintrag wie bei Skalaren, mit: + +- `data_type: "composite"` +- `composite_archetype_key`, `composite_archetype_version` (aus DB oder Join) +- ggf. `composite_slot_catalog`: **nur wenn** für Admin/UI gewünscht — alternativ separater Endpoint `GET .../composite-archetypes` (read-only) aus Registry, um Bundle-Größe klein zu halten. + +### 6.2 Lesen / Merge + +- `fetch_activity_session_metrics`: SELECT inkl. `value_json`. +- `merge_column_backed_and_eav_metrics`: Composites **nur** aus EAV (`value_json`), kein `activity_log`-Shadowing (außer später explizit im Kanon — Standard: nein). +- Ausgabe in `metrics`-Liste: ein Eintrag pro Parameter mit z. B. + `value: { "_composite": true, "document": { ... } }` **oder** kanonisch getrennt: `value_document` + `value` null — **festlegen beim Implementieren** und in API-Doku halten; Empfehlung: **`value` = deserialisiertes Objekt (dict)** für Composites, damit Frontend dieselbe Struktur wie Speicher hat. + +### 6.3 „Einzelwerte für Layer 1 / Issue 53“ + +Neue **pure** Funktion (kein SQL im Router), z. B.: + +```text +expand_composite_metrics_for_session( + schema: list[dict], + metrics: list[dict], +) -> dict[str, Any] +``` + +- Input: effektives Schema + gemergte Metriken. +- Output: flaches Dict **`slot_path → typisierter Wert`**, z. B. + `hr_zones.z1_sec → 1200`, oder namespaced Keys `training_param_key.slot_key` zur Kollisionssicherheit. +- Nutzung: `activity_metrics`, Chart-Builder, später Platzhalter-Registry (`data_layer_function`), **ohne** JSON-Parsing in Layer 2. + +**Wichtig:** Skalare Parameter erscheinen im expandierten Dict mit ihrem `parameter_key` wie bisher (kein Breaking Change für Consumer, die nur Skalare erwarten). + +### 6.4 Validierung / Schreiben + +- **`replace_activity_session_metrics`:** Payload-Item für Composite: `value` ist **Objekt** (dict) oder JSON-String — Server normalisiert zu dict, validiert mit Archetyp-Validator, speichert als `value_json`. +- **`upsert_session_metrics_from_csv_mapped`:** siehe §7 (Zusammenbau aus Partial-Updates pro Zeile). + +**Pflicht:** Keine Teil-Updates in DB, die ein halbes Dokument hinterlassen, ohne Validierung — außer explizit als „Draft“-Modus spezifiziert (nicht Teil dieses Konzepts). + +--- + +## 7. CSV / Universal Import + +### 7.1 Map-Ziel-Notation + +Stabiles Muster (Vorschlag, im Import-Modul zentral parsen): + +```text +"." +``` + +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. diff --git a/.claude/docs/technical/ACTIVITY_LAYER2A_PLACEHOLDER_AUDIT.md b/.claude/docs/technical/ACTIVITY_LAYER2A_PLACEHOLDER_AUDIT.md new file mode 100644 index 0000000..fd3caed --- /dev/null +++ b/.claude/docs/technical/ACTIVITY_LAYER2A_PLACEHOLDER_AUDIT.md @@ -0,0 +1,70 @@ +# Aktivität: Layer-2a-Platzhalter — Audit Schritt 1 (Issue #53) + +**Stand:** 2026-04-16 +**Bezug:** [Issue #53 — Multi-Layer Architecture](../../../docs/issues/issue-53-phase-0c-multi-layer-architecture.md): Layer 1 = strukturierte Daten, Layer 2a = KI-Formatierung (keine parallele Domänen-Logik im Resolver). + +**Ziel dieses Dokuments:** Jeder Aktivitäts-Platzhalter hat genau eine **Layer‑1‑Quelle** (`data_layer/activity_metrics.py`); `placeholder_resolver.py` formatiert oder serialisiert nur noch. + +--- + +## 1. Ergebnisübersicht + +| Kategorie | Anzahl | Resolver-SQL für Aktivität? | +|-----------|--------|------------------------------| +| Gebündelt in `PLACEHOLDER_MAP` (Training/Aktivität) | 20 | **Nein** | +| Abweichungen / offene Punkte | 0 | — | + +**Hinweis:** `{{rest_days_count}}` steht in der Karte unter „Schlaf & Erholung“ und nutzt `recovery_metrics.get_rest_days_data` — nicht in dieser Tabelle. + +--- + +## 2. Platzhalter → Layer 1 → Layer 2a + +| Key | Layer 1 (`activity_metrics`) | Layer 2a (`placeholder_resolver`) | Bemerkung | +|-----|------------------------------|-------------------------------------|-----------| +| `activity_summary` | `get_activity_summary_data` | `get_activity_summary` | String-Zusammenfassung | +| `activity_detail` | `get_activity_detail_data` (+ `enrich_sessions_with_metrics`) | `get_activity_detail` | Dynamische `session_metrics[]` pro Zeile (Profil/EAV) | +| `trainingstyp_verteilung` | `get_training_type_distribution_data` | `get_trainingstyp_verteilung` | Ausgabe: Top-3-Text (kein JSON); Registry 2026-04 an Ist angeglichen | +| `training_minutes_week` | `calculate_training_minutes_week` | `_safe_int` | | +| `training_frequency_7d` | `calculate_training_frequency_7d` | `_safe_int` | | +| `quality_sessions_pct` | `calculate_quality_sessions_pct` | `_safe_int` | | +| `proxy_internal_load_7d` | `calculate_proxy_internal_load_7d` | `_safe_int` | | +| `monotony_score` | `calculate_monotony_score` | `_safe_float` | | +| `strain_score` | `calculate_strain_score` | `_safe_int` | | +| `rest_day_compliance` | `calculate_rest_day_compliance` | `_safe_int` | | +| `ability_balance_strength` | `calculate_ability_balance_strength` | `_safe_int` | abilities in `activity_log` | +| `ability_balance_endurance` | `calculate_ability_balance_endurance` | `_safe_int` | | +| `ability_balance_mental` | `calculate_ability_balance_mental` | `_safe_int` | | +| `ability_balance_coordination` | `calculate_ability_balance_coordination` | `_safe_int` | | +| `ability_balance_mobility` | `calculate_ability_balance_mobility` | `_safe_int` | | +| `vo2max_trend_28d` | `calculate_vo2max_trend_28d` | `_safe_float` | | +| `activity_score` | `calculate_activity_score` | `_safe_int` | | +| `training_frequency_by_type_md` | `get_training_frequency_by_type_data` | `get_training_frequency_by_type_md` | Markdown-Tabelle | +| `training_inter_session_gap_md` | `get_training_inter_session_gap_data` | `get_training_inter_session_gap_md` | Markdown-Text | +| `training_sessions_recent_json` | `get_training_sessions_recent_weeks_data` (+ `enrich_sessions_with_metrics`) | `_safe_json('training_sessions_recent_json')` | JSON inkl. `session_metrics[]` pro Session | + +--- + +## 3. Schichten-Disziplin (Checkliste) + +- [x] Kein `SELECT` auf `activity_log` / `activity_session_metrics` in den **Layer‑2a**-Funktionen oben — nur Aufrufe in Layer 1 bzw. `_safe_*`-Wrapper. +- [x] `get_activity_detail` / `get_training_sessions_recent_json` liefern EAV nur über **bereits gemergte** `session_metrics` (Merge-Kanon: `activity_log` vor EAV). +- [x] Registry-Metadaten: `data_layer_module` / `data_layer_function` pro Key in `placeholder_registrations/activity_metrics.py` und `activity_session_insights.py`. +- [x] Korrektur Registry: `activity_summary.resolver_function` = `get_activity_summary` (war veraltet: `_format_activity_summary`). + +--- + +## 4. Nächste Schritte (Roadmap) + +2. ~~**Registry-Texte:** `semantic_contract` / `known_limitations` für dynamische `session_metrics` (tcp/ttp) und Merge-Kanon — **erledigt** (`activity_detail`, `training_sessions_recent_json`); dazu **`trainingstyp_verteilung`**-Metadaten von veraltetem „JSON/Resolver-SQL“ auf Ist (**Layer 1 + Top-3-Text**) korrigiert.~~ +3. **History / Layer 2b:** EAV-Zeitreihen nicht über Platzhalter, sondern dedizierte Layer‑1-/Chart-Pfade. +4. **Optional:** Gitea-Issue „Activity Layer 2a“ bei Änderungen an `activity_metrics` pflegen. + +--- + +## 5. Referenzen + +- `backend/placeholder_resolver.py` — `PLACEHOLDER_MAP` (Training/Aktivität) +- `backend/placeholder_registrations/activity_metrics.py` +- `backend/placeholder_registrations/activity_session_insights.py` +- `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` §2.1a (Navigation Read vs. Berechnen) diff --git a/.claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md b/.claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md new file mode 100644 index 0000000..1e3cd54 --- /dev/null +++ b/.claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md @@ -0,0 +1,215 @@ +# Aktivität: Zielarchitektur & Phasenplan (Produktionsreife) + +**Stand:** 2026-04-16 +**Status:** Normative Zielrichtung für `activity_log`, EAV, Composites, Import, Layer 1/2. +**Ergänzt:** `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` (Ist-Modell, APIs, Tests). +**Phase A:** abgeschlossen — Kanon-Tabelle [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md). +**Phase B:** in Arbeit — Consumer-Audit und Lesepfad-Härtung (siehe §4 Phase B). + +--- + +## 1. Leitprinzipien + +| Prinzip | Bedeutung | +|---------|-----------| +| **Layer 1 = Single Source of Truth** | Alle Auswertungen (Charts, Scores, strukturierte Platzhalter) lesen **nur** über `data_layer` (kanonische Funktionen). Keine parallele SQL-Logik in Routern oder im Placeholder-Resolver für Aktivität. | +| **Eine semantische Größe, eine kanonische Quelle** | Kein Dauer-Sync derselben Bedeutung in `activity_log`-Spalte **und** EAV. Übergang: dokumentierte Abschaltung, nicht implizites Driften. | +| **Spine vs. Parameter** | `activity_log` trägt Identität, Zeit, Typ, Notizen, Audit + **heiße** universelle Skalare (siehe §2.2). Alles Typ-/Admin-Dynamische über EAV. | +| **Composites = Archetyp im Code, Konfiguration in der DB** | Struktur (7+2 Archetypen) und Validierung **versioniert im Repo**; Admin **wählt** Archetyp, **benennt** Slots, **bindet** Sportarten, **mappt** CSV → `(parameter_id, slot_key)`. Kein freies JSON-Schema im Admin. | +| **Import explizit** | Jede CSV-Spalte hat ein klares Ziel: Spine-Spalte, skalarer Parameter oder **Slot** eines Composite-Parameters. Typkonvertierung zentral (Executor / Converter), nicht verteilt. | + +--- + +## 2. Zielarchitektur (Gesamtbild) + +### 2.1 Schichtenmodell + +``` +[CSV / UI / API Write] + ↓ +Orchestrator & Router (Auth, Transaktionen, Feature-Checks) + ↓ +Persistenz: activity_log (Spine + heiße Skalare) + activity_session_metrics (EAV) + ↓ +Layer 1: data_layer (activity_session_metrics.py, activity_metrics.py, …) + ↓ +Layer 2a/2b: Platzhalter-Resolver (Formatierung), Chart-Endpoints (Chart.js-Shapes) + ↓ +KI / UI / Export +``` + +- **Orchestrator:** Schreibpfad, Konsistenz nach Write (kein zweites „Lesen der Wahrheit“ neben Layer 1; optional nur Post-Write-Hooks). +- **Resolver:** für Aktivität **kein** direkter DB-Zugriff; nur Aufruf von Layer 1. + +### 2.1a Navigationsregel: wo nachsehen (ohne Datei-Zwang) + +Die **physische** Aufteilung ist dreigeteilt: **`activity_log`** (Spine + heiße Spalten), **EAV-Skalare** (`activity_session_metrics` + numerische/textuelle `value_*`), **EAV-Composites** (ein Parameter, Nutzlast z. B. JSON/JSONB im EAV-Datensatz). **Fachlich** soll nach außen **eine homogene Session-Sicht** entstehen — Consumer sollen nicht selbst entscheiden, aus welcher Tabelle/Welche Form ein Wert kommt. + +| Thema | Wo nachsehen (Ist; Ziel: Schnittstelle stabil, Datei optional splittbar) | +|--------|--------------------------------------------------------------------------| +| **Homogene Session lesen** (Merge Spalte + EAV-Skalare + später Composite-Payload) | `data_layer/activity_session_metrics.py` — u. a. `get_activity_session_logical_unit`, `enrich_sessions_with_metrics`, `merge_column_backed_and_eav_metrics` | +| **Schreiben / Import / API-Persistenz** | `data_layer/activity_persistence_orchestrator.py` (+ Router) | +| **Berechnungen, Aggregationen, Scores** über viele Sessions oder Zeitfenster | `data_layer/activity_metrics.py` — arbeitet auf der **vereinheitlichten** Session-Datenlage (über die Read-Funktionen oben), nicht durch paralleles Mergen der drei Quellen im Caller | + +**Hinweis:** Orchestrator und Read-Merge **müssen nicht** in derselben Datei stehen. Entscheidend ist, dass es **genau eine dokumentierte Read-Fassade** für „Session inkl. aller effektiven Metriken“ gibt und Layer‑1‑Berechnungen **nur** diese Fassade (oder deren Ergebnisstrukturen) nutzen. Eine spätere Umbenennung oder Auslagerung in z. B. `activity_read_gateway.py` ändert die Rolle nicht — nur der **eine Einstieg** muss in dieser Doku und im Code auffindbar bleiben. + +### 2.2 `activity_log` (Spine + heiße Skalare) + +**Maschinenlesbarer Kanon:** `backend/data_layer/activity_data_canon.py` (`ACTIVITY_MODULE_REGISTRY_FIELD_KEYS`, `ACTIVITY_EAV_PRIMARY_PARAMETER_KEYS`, Legacy-Lesefallback für EAV-primäre Parameter). + +**Immer (fachlich minimal + listenfähig):** `id`, `profile_id`, Kalender-/Zeitfenster (`date`, `started_at`/`ended_at`, ggf. `start_time`/`end_time` bis Konsolidierung), `duration_min`, `training_type_id` (+ ggf. denormalisierte Kategorie), Legacy `activity_type`, `notes`, `source`, `created`. + +**Heiße Skalare (CSV-Modul + `source_field` nach Migration 057):** u. a. `kcal_active`, `kcal_resting`, `distance_km`, `hr_avg`/`hr_max` (Parameter `avg_hr`/`max_hr`), `duration_min`, `rpe` – für Listen und Standard-Aggregate ohne EAV-Join. + +**EAV-primär (erweiterte Metriken):** z. B. Kadenz, Pace, Leistung, Höhe, Umgebung — `training_parameters.source_field` = NULL; Import schreibt EAV; bei leerem EAV optional Lesefallback auf bestehende `activity_log`-Spalte (Migration 057 + Merge-Logik). + +**Session-Qualität / Auswertungsblob:** z. B. `evaluation`, `quality_label`, `overall_score` – **kein** EAV-Parameter-Raster; semantisch „Ergebnis der Einheit“. + +**Nicht dauerhaft doppelt:** dieselbe Semantik nicht parallel pflegen; siehe entfallener Spalte→EAV-Schreib-Sync, Lesepfad `merge_column_backed_and_eav_metrics`. + +### 2.3 EAV (`activity_session_metrics`) + +- **Skalare:** ein `training_parameter`, genau eine `value_*`-Spalte (wie heute). +- **Composites:** ein `training_parameter` pro Composite-Instanz, **ein** gespeichertes Dokument pro Session (serialisiert z. B. in `value_text` als JSON **oder** künftig dedizierte JSONB-Spalte – technische Entscheidung in eigener Migration, Vertrag im Archetyp). +- **Merge-/Schema-Logik:** weiterhin zentral in `activity_session_metrics.py` (effektives Schema aus Kategorie + Typ-Overrides). + +### 2.4 Composite-Metamodell (Ziel) + +**Archetypen (Code, begrenzte Menge):** u. a. Band-/Zonenverteilung, Sequenz-/Übergangsprofil, Intervallblock-, Ereignis-/Aktions-, Kopplungs-/Effizienz-, Modellparameter-Profil; optional Technik-/Zyklus-, Readiness-/Recovery-Profil. + +**Pro Archetyp:** feste strukturelle Regeln (erlaubte Slots, Typen, Pflicht/Optional), Validator + Version. + +**In der DB (Admin):** Zuordnung „Parameter X hat Archetyp A“, Slot-Labels (DE/EN), Einheiten, Aktivierung pro Sportart/Kategorie, Sortierung. + +**Import:** CSV-Spalten → `(training_parameter_id, slot_key)` mit stabilen Keys (`z1_sec`, …), nie nur „Spaltenreihenfolge“. + +### 2.5 Universal CSV & Admin + +- Vorlagen: Mapping inkl. **Composite-Slots** und Typkonvertierung (vollständige Matrix Ziel). +- UI: Trennung **Kern activity_log** vs. **Parameter/EAV** vs. **Composite-Blöcke** (optisch/UX), um Doppel-Tabellen-Chaos zu vermeiden. + +### 2.6 Layer 2 (Platzhalter & Diagramme) + +- Datenbezug **nur** Layer 1. +- Registry-Einträge: `data_layer_module` / `data_layer_function` pflegen; Composite-Auswertung ggf. über Hilfsfunktionen, die JSON → normierte Struktur für Prompts/Charts liefern. + +--- + +## 3. Ist → Soll (Kurz) + +| Bereich | Ist (typisch) | Soll | +|---------|----------------|------| +| Schreibpfad | Teilweise Doppelhaltung Spalte ↔ EAV, Sync-Hooks | Kanon + gezielte Abschaltung; eine Quelle pro Semantik | +| Lesepfad | Layer 1 wächst; Legacy-Spalten noch relevant | `get_activity_session_logical_unit` / `activity_metrics` als alleinige Wahrheit für Consumer | +| Composites | Noch nicht im Einklang mit EAV-Metamodell | Archetypen + Slot-Admin + ein Dokument pro Parameter/Session | +| Import | Mapping teilweise; Typkonvertierung lückenhaft | Vollständige Konvertierung + Composite-Zusammenbau | +| Resolver | Aktivität sauber über Layer 1 | Profil/Focus ggf. später ebenfalls aus Layer 1 | + +--- + +## 4. Vorgehensmodell (Phasen) + +Phasen sind **sequentiell** wo „Abhängigkeit“ steht; Teile können parallel (z. B. UI-Polish) laufen, wenn der Kanon steht. + +### Phase A – Kanon & Abschaltplan (Grundlage) ✅ + +**Inhalt:** Schriftliche **Kanon-Tabelle**: pro Messgröße genau eine Quelle (`activity_log` | `eav_scalar` | `eav_composite` | `session_quality`). Liste der Keys, für die **Sync/Spiegelung** endet. + +**Definition of Done:** Review im Team; Referenz in diesem Dokument oder Verweis auf Gitea-Kommentar; keine Code-Änderung zwingend. + +**Erledigt (2026-04-16):** [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md) — eine Semantik pro Zeile, verlinkt mit `activity_data_canon.py` und Merge-Logik. + +--- + +### Phase B – Lesepfad härten (Layer 1) 🔄 + +**Inhalt:** Sicherstellen, dass **alle** relevanten Consumer (mind. `activity_metrics` für Platzhalter/Charts, Activity-Detail-API) dieselbe Merge-/Fallback-Logik nutzen; Legacy-Spalten nur noch als dokumentierter Fallback bis Enddatum. + +**Definition of Done:** Kurze Audit-Liste „Router/Resolver greifen nicht an Aktivität vorbei“; Tests oder manuelle Stichprobe für Detail + ein Chart + 2 Platzhalter. + +**Abhängigkeit:** Phase A für „welche Spalten noch Fallback sind“. + +**Audit-Stand (2026-04-16, ergänzt Export):** + +| Consumer | Nutzt Layer-1-Merge (`enrich_sessions_with_metrics` / `get_activity_session_logical_unit`) | Anmerkung | +|----------|---------------------------------------------------------------------------------------------|-----------| +| `GET /api/activity/{eid}` | ✅ `get_activity_session_logical_unit` | Referenz-Detail | +| `GET /api/activity` (Liste) | ✅ seit 2026-04-16 `enrich_sessions_with_metrics` auf jeder Listen-Antwort | vorher nur Roh-Spalten | +| `activity_metrics.get_activity_detail_data` | ✅ | Platzhalter `{{activity_detail}}` | +| `activity_metrics.get_training_sessions_recent_weeks_data` | ✅ | KI-Kontext | +| `placeholder_resolver` (Aktivität) | ✅ nur `activity_metrics` | kein paralleles SQL | +| `GET /api/export/json` (`activity`) | ✅ `enrich_sessions_with_metrics` + `serialize_dates` | `session_metrics` pro Zeile | +| `GET /api/export/csv` (Training-Zeilen) | ✅ `enrich_sessions_with_metrics` | gemergte EAV in Spalte „Details“ | +| `GET /api/export/zip` (`data/activity.csv`) | ✅ `enrich_sessions_with_metrics` | Zusatzspalte `session_metrics_json` (Import ignoriert sie) | +| `get_activity_summary_data` | n. a. | rein aggregiert (`SUM`/`COUNT`), keine Session-EAV | +| `routers/charts.py` (A1–A8) | Spalten-Aggregate | bewusst: Dauer/RPE/HF aus **`activity_log`**-Kanon; kein EAV-Join nötig für definierte Charts | +| `activity_stats` (`GET /api/activity/stats`) | nur Spalten | Kacheln: `kcal`/`duration` aus Kernspalten | + +--- + +### Phase C – Schreibpfad entschlacken + +**Inhalt:** Orchestrierung/CSV: kein Schreiben derselben Semantik an zwei Orten; `sync_column_backed_session_metrics` (o. ä.) **stufig abschalten** oder auf Notfall-Flag; Import schreibt gemäß Kanon. + +**Definition of Done:** Deploy auf Prod mit Monitoring; Stichprobe Import + manuelle Bearbeitung; keine Regression in Listenansicht. + +**Abhängigkeit:** Phase A + B (sonst Lücken beim Lesen). + +**Analyse (2026-04-16, nur Ist-Review):** Es gibt **keinen aktiven** Schreibpfad mehr, der `activity_log`-Spalten für `source_field`-Parameter **dauerhaft nach EAV spiegelt**. + +| Prüfpunkt | Ergebnis | +|-----------|----------| +| `sync_column_backed_session_metrics` | Nur noch **Definition** in `activity_session_metrics.py`, als veraltet markiert; **keine Aufrufer** im Repo (grep). Laufzeit-Sync: **abgestellt**. | +| `run_activity_post_write_hooks` / `…_import` | Nur **Auto-Eval** (optional); Kommentar: **kein** Spalte→EAV-Sync. | +| Universal-CSV (`executor.py`) | Kernfelder → `activity_log` (`activity_csv_registry_updates_from_mapped` + `update_activity_columns` / Insert); EAV → `upsert_session_metrics_from_csv_mapped`. Registry-Keys werden **nicht** nach EAV geschrieben; bei `source_field` wird EAV **übersprungen**, wenn die Spalte **bereits befüllt** ist — vermeidet bewusst doppelte Speicherung. | +| REST `PUT /metrics` | Kommentar in Code: **kein** `sync_column_backed` nach EAV-Ersatz. | +| Migrationen 055 / 057 | **Einmaliger** Backfill/Schwenk, kein fortlaufender Sync. | + +**Lesepfad (2026-04-16):** `merge_column_backed_and_eav_metrics` bevorzugt **immer** `activity_log`, wenn ein kanonischer Spaltenwert existiert: zuerst `source_field`, dann Registry-Spalte gleichen Keys, dann Legacy-Spalten für EAV-primäre Parameter, zuletzt EAV. Doppelte physische Schreiborte sind damit in der effektiven Sicht **ohne EAV-Vorrang** behoben. + +--- + +### Phase D – Composite MVP + +**Inhalt:** Ein Archetyp end-to-end (z. B. **Band-/Zonenverteilung**): Code-Validator, DB-Binding (Parameter + Slots), Admin-UI minimal, Import **5 Spalten → ein JSON-Dokument** mit festen Keys, Layer-1-Read (Roh + optional `expand_*`). + +**Definition of Done:** Eine Sportart/Kategorie befüllbar; Dokumentation des JSON-Vertrags im Repo; pytest für Validator/Zusammenbau wo möglich. + +**Abhängigkeit:** Phase A (Kanon „Composites nur als Dokument, nicht doppelt in Spalten“). + +--- + +### Phase E – Composite-Ausbau & Typkonvertierung Import + +**Inhalt:** Weitere Archetypen nach Priorität; Universal-CSV **vollständige** Typkonvertierung für alle gemappten Ziele; Dialog-/Mapping-Konzept (Kern vs. Parameter vs. Composite). + +**Definition of Done:** Matrix „Zieltyp × Converter“ gepflegt; Admin-Flow reviewt. + +--- + +### Phase F – Produktionshärtung + +**Inhalt:** Performance-Indizes bei Bedarf; Observability (Import-Fehler, Validierungs-Fails); Resolver/Profil optional komplett ohne `get_db` für domänische Daten; Doku + Gitea-Issues geschlossen/aktualisiert. + +--- + +## 5. Was zuerst? + +**Erledigt:** Phase A — [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md). + +**Aktuell:** Phase B fortsetzen (weitere Consumer prüfen: Export, Import-Vorschau, ggf. zukünftige Chart-Metriken aus EAV), dann **Phase C** (Schreibpfad), dann **Phase D** (Composite-MVP). + +--- + +## 6. Referenzen + +- `ACTIVITY_SCALAR_KANON_TABLE.md` – **Skalar-Kanon** (Phase A) +- `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` – Tabellen, APIs, Tests, Backfill-Hinweise +- `ACTIVITY_COMPOSITE_METRICS_IMPLEMENTATION_CONCEPT.md` – Composite-EAV (JSONB), Archetypen, Import-Slots, Layer-1-Expand, Migrations- und Testplan +- `UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` – Executor, Vorlagen, Typen +- `PLACEHOLDER_REGISTRY_FRAMEWORK.md` – Layer-2-Registrierung +- `functional/DATA_ARCHITECTURE.md` – fachliche Datenarchitektur (Querschnitt) + +--- + +**Version:** 1.5 · Merge: activity_log (Registry + Legacy-Spalten) vor EAV bei Lesen. diff --git a/.claude/docs/technical/ACTIVITY_SCALAR_KANON_TABLE.md b/.claude/docs/technical/ACTIVITY_SCALAR_KANON_TABLE.md new file mode 100644 index 0000000..8d3fc96 --- /dev/null +++ b/.claude/docs/technical/ACTIVITY_SCALAR_KANON_TABLE.md @@ -0,0 +1,95 @@ +# Aktivität: Skalar-Kanon (eine Semantik → eine Quelle) + +**Stand:** 2026-04-16 +**Normativer Code:** `backend/data_layer/activity_data_canon.py` +**Kontext:** `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` (Phase A abgeschlossen) + +--- + +## 1. Spine & Identität (`activity_log`, nicht EAV) + +Diese Felder sind **keine** `training_parameters`-Skalare. Sie gehören zur Session-Zeile. + +| Semantik | DB / API | Kanonische Quelle | Lesefallback | Sync Spalte↔EAV | +|----------|----------|-------------------|--------------|-----------------| +| Primärschlüssel | `activity_log.id` | `activity_log` | — | — | +| Profil | `profile_id` | `activity_log` | — | — | +| Kalendertag | `date` | `activity_log` | — | — | +| Start / Ende (Zeit) | `start_time`, `end_time`, `started_at`, `ended_at` | `activity_log` | — | — | +| Trainingsart (Freitext/Legacy) | `activity_type` | `activity_log` | — | — | +| Referenz Trainingstyp | `training_type_id`, `training_category`, … | `activity_log` (+ `training_types`) | — | — | +| Notiz | `notes` | `activity_log` | — | — | +| Quelle / Import | `source`, `created`, … | `activity_log` | — | — | +| Session-Auswertung | `evaluation`, `quality_label`, `overall_score`, … | `activity_log` (Blob/Ergebnis) | — | Kein EAV-Raster | + +--- + +## 2. Kernfelder CSV-Modul `activity` (= „heiße“ Skalare) + +Abgeleitet aus `csv_parser.module_registry.MODULE_DEFINITIONS["activity"].fields` — maschinenlesbar über `ACTIVITY_MODULE_REGISTRY_FIELD_KEYS` in `activity_data_canon.py`. + +| Semantik | Key (Registry/API) | Kanonische Quelle | Lesefallback | Bemerkung | +|----------|-------------------|-------------------|--------------|-----------| +| Dauer | `duration_min` | **`activity_log`** | — | Aggregates, Listen | +| Aktive Energie | `kcal_active` | **`activity_log`** | — | | +| Ruhe-Energie | `kcal_resting` | **`activity_log`** | — | | +| Distanz | `distance_km` | **`activity_log`** | — | | +| Ø HF | `hr_avg` (Parameter oft `avg_hr` in EAV-Schema) | **`activity_log`** | EAV nur wenn `source_field` / Profil-Schema | `merge_column_backed_and_eav_metrics`: Spalte schlägt EAV | +| Max-HF | `hr_max` | **`activity_log`** | analog | | +| RPE | `rpe` | **`activity_log`** | analog | | + +Schreibpfad: Universal-CSV und API sollen diese Keys auf **`activity_log`** mappen, sofern nicht ausdrücklich ein EAV-primärer Parameter (§3) gewählt ist. + +--- + +## 3. EAV-primäre Parameter (erweiterte Skalare) + +`ACTIVITY_EAV_PRIMARY_PARAMETER_KEYS` in `activity_data_canon.py`. **`training_parameters.source_field`** = NULL (nach Kanon / Migration 057): kanonischer Speicher ist **`activity_session_metrics`**. + +| Parameter-Key (`training_parameters.key`) | Legacy-Spalte `activity_log` | Schreib-Kanon (Ziel) | +|-------------------------------------------|------------------------------|------------------------| +| `min_hr` | `hr_min` | **EAV** | +| `pace_min_per_km` | `pace_min_per_km` | **EAV** | +| `cadence` | `cadence` | **EAV** | +| `avg_power` | `avg_power` | **EAV** | +| `elevation_gain` | `elevation_gain` | **EAV** | +| `temperature_celsius` | `temperature_celsius` | **EAV** | +| `humidity_percent` | `humidity_percent` | **EAV** | +| `avg_hr_percent` | `avg_hr_percent` | **EAV** | +| `kcal_per_km` | `kcal_per_km` | **EAV** | + +**Lesen:** `merge_column_backed_and_eav_metrics` — wenn Legacy-Spalte **und** EAV einen Wert haben, **gewinnt die Spalte** (kanonische `activity_log`-Sicht). EAV nur, wenn die Spalte leer/nicht koerzierbar ist. + +--- + +## 4. Profil-/Typ-dynamische Skalare (EAV, nicht in Registry-Kernliste) + +| Semantik | Kanonische Quelle | Lesefallback | +|----------|-------------------|--------------| +| Admin-definierte Parameter (Attributprofil Kategorie/Typ) | **`activity_session_metrics`** + `training_parameters` | — | +| Parameter mit `source_field` → Spalte | **`activity_log`** (Spalte) | EAV ergänzend; Leseregel: Spalte bevorzugt (kein veraltetes EAV) | + +--- + +## 5. Composites (Zielbild, noch nicht Kanon-Zeile pro Slot) + +| Semantik | Kanonische Quelle (Ziel) | +|----------|---------------------------| +| Strukturierte Composite-Dokumente (z. B. Zonen/Bänder) | **EAV** ein Dokument pro Parameter/Session (siehe `ACTIVITY_COMPOSITE_METRICS_IMPLEMENTATION_CONCEPT.md`) | + +Kein dauerhaftes Spiegeln derselben Semantik in `activity_log`-Spalten. + +--- + +## 6. Sync & Übergang + +- **Kein** automatischer Dauer-Sync „Spalte → EAV“ für dieselbe Semantik; Lesepfad vereinheitlicht die Sicht (`merge_column_backed_and_eav_metrics`). +- Optionale **Backfill**-Migration/Skript (idempotent) nur nach fachlicher Freigabe — siehe EAV-Agent-Guide §6. + +--- + +## 7. Referenzen + +- `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` — Phasen A–F +- `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` — APIs, Tests +- `activity_data_canon.py` — `ACTIVITY_LOG_PATCHABLE_COLUMNS`, Legacy-Map diff --git a/.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md b/.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md index 186f46c..a516e2b 100644 --- a/.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md +++ b/.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md @@ -4,6 +4,12 @@ **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) @@ -41,7 +47,9 @@ | 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`. | +| 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:** @@ -49,6 +57,8 @@ - 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) @@ -81,10 +91,23 @@ Router: `backend/routers/admin_training_parameters.py`, `backend/routers/admin_a ## 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-Spalten → `training_parameters.key` + Schreiben in EAV (Executor). -- [ ] Optional: Backfill `activity_log.*` → `activity_session_metrics` nach `source_field`. +- [ ] 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). --- @@ -116,6 +139,7 @@ Abdeckung: reine Merge-Logik (`merge_parameter_schema_rows`), Validierung (`_val - 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}}` --- diff --git a/.claude/docs/technical/functional_concept_composite_data.md b/.claude/docs/technical/functional_concept_composite_data.md new file mode 100644 index 0000000..44f4a38 --- /dev/null +++ b/.claude/docs/technical/functional_concept_composite_data.md @@ -0,0 +1,1480 @@ +# Konzeptpapier: Speicherung und Bereitstellung von Scalar- und Composite-Sportdaten + +## Status + +Arbeitsstand auf Basis der fachlichen und architektonischen Diskussion. +Dieses Dokument dient als Rahmenwerk für einen Coding Agent und beschreibt die Zielarchitektur, die Abgrenzung der Schichten sowie die empfohlene technische Struktur für Scalar- und Composite-Werte. + +--- + +## 1. Ziel und Einordnung + +Das System soll zu beliebigen Sportarten Daten speichern und für unterschiedliche nachgelagerte Zwecke bereitstellen, insbesondere: + +- Diagramme +- Kennzahlen +- Scores +- regelbasierte Bewertungen +- KI-Platzhalter und KI-Kontextdaten + +Das System ist bereits mehrschichtig aufgebaut. Diese Schichtung ist fachlich und technisch sinnvoll und soll ausdrücklich erhalten bleiben. + +### Bestehende Schichten + +- **Persistenz / Datenbank** + - reiner Wertespeicher + - Speicherung von Scalars und Composites +- **Layer 1** + - Lesen der Daten + - technische Aufbereitung + - minimale Normalisierung + - Validierung +- **Layer 2a** + - fachliche Aufbereitung für Diagramme, Kennzahlen, Regeln, Auswertungen +- **Layer 2b** + - fachliche Aufbereitung für KI-Platzhalter, KI-Zusammenfassungen, KI-Argumente + +### Zentrale Erkenntnis + +Die Persistenz darf **nicht** mit fachlichen Interpretationen aus Layer 2 überladen werden. + +Das bedeutet konkret: + +- Die Datenbank speichert **Fakten und strukturierte Werte** +- Layer 1 stellt **kanonische, technische Leseobjekte** bereit +- Layer 2 erzeugt daraus **fachliche Bedeutung** + +--- + +## 2. Problemstellung + +Bisher werden überwiegend **Scalar-Werte** gespeichert: + +- Datentyp +- Ausprägung (`value`) +- Einheit + +Nun sollen zusätzlich **Composite-Werte** gespeichert werden. +Aktuell ist der gewünschte Ansatz: + +- derselbe Value Store +- `datatype = composite` +- `value = jsonb` + +Die Herausforderung besteht darin, Composite-Werte so einzuführen, dass: + +1. die Architektur einfach bleibt +2. die Schichtentrennung nicht verletzt wird +3. das System sportartenübergreifend nutzbar bleibt +4. spätere Auswertungslogik nicht bereits in die Persistenz gepresst wird +5. die Lösung für Diagramme, Kennzahlen und KI gleichermaßen nutzbar ist + +--- + +## 3. Architektonische Leitentscheidung + +### 3.1 Grundsatz + +**Composite-Werte werden als strukturierte Fakten gespeichert, nicht als fachlich fertig interpretierte Analyseobjekte.** + +Das bedeutet: + +- Die Datenbank kennt nur wenige **technische Composite-Container** +- Fachliche Archetypen entstehen erst in Layer 2 +- Layer 1 vermittelt zwischen Persistenz und fachlicher Verarbeitung + +### 3.2 Was bewusst vermieden wird + +Nicht in die Persistenz gehören als Teil des Basis-Composite: + +- Scores +- fachliche Bewertungen +- Trainingsqualitätsurteile +- KI-Texte +- narrative Einordnungen +- Interpretationen wie „intervallartig“, „polarisiert“, „gut regeneriert“ +- abgeleitete Kennzahlen wie `switch_rate`, `transition_entropy`, `readiness_index`, `plan_match_score` + +Diese Dinge sind **sekundäre Ableitungen** und gehören in Layer 2. + +--- + +## 4. Fachliche Kernaussage des Konzepts + +Die frühere Diskussion über viele fachliche Composite-Archetypen war fachlich sinnvoll, aber für die Persistenz zu komplex. + +Deshalb wird das Modell in zwei Ebenen getrennt: + +### Ebene A: technische Speicherformen + +Diese werden in der Datenbank als `jsonb` gespeichert. + +### Ebene B: fachliche Analyse-Archetypen + +Diese werden in Layer 2 aus den technischen Speicherformen abgeleitet. + +Damit gilt: + +- **wenige Speicherformen** +- **viele fachliche Projektionen** + +Das ist die zentrale Designentscheidung dieses Dokuments. + +--- + +## 5. Zielbild + +### 5.1 Persistenzebene + +Einheitlicher Value Store für: + +- Scalars +- Composites + +#### Scalar + +- `datatype != composite` +- `value` ist scalar +- `unit` als separate Spalte oder Feld + +#### Composite + +- `datatype = composite` +- `value` ist `jsonb` +- `jsonb` folgt einem kleinen, stabilen Basisschema + +### 5.2 Layer 1 + +Layer 1 liefert für Composite-Werte: + +- validierte Daten +- minimal normalisierte Daten +- kanonische Leseobjekte +- keine fachlichen Scores oder Bewertungen + +### 5.3 Layer 2a + +Layer 2a erzeugt aus Layer-1-Daten: + +- Diagrammserien +- Kennzahlen +- Übergangsmetriken +- Intervallstrukturen +- Auswertungsobjekte +- sportartspezifische Projektionen + +### 5.4 Layer 2b + +Layer 2b erzeugt aus Layer-1- oder Layer-2a-Daten: + +- KI-Platzhalter +- KI-Faktenlisten +- zusammengefasste Trainingsmerkmale +- explizite, strukturierte Aussagen für Prompts + +--- + +## 6. Technische Speicherformen für Composite-Werte + +Statt vieler spezieller Composite-Typen werden nur vier technische Container eingeführt: + +1. `group_set` +2. `distribution_set` +3. `sequence_set` +4. `model_set` + +Diese vier Typen reichen als Speicherebene aus. + +--- + +## 7. Der gemeinsame Basiskern jedes Composite-Objekts + +Jedes Composite-JSONB soll auf einem kleinen gemeinsamen Kern basieren. + +### 7.1 Basisschema + + ```json + { + "v": 1, + "kind": "group_set", + "domain": "recovery", + "basis": null, + "items": [], + "meta": { + "source": "reported", + "definition_ref": null, + "quality": null + } + } + ``` + +### 7.2 Pflichtfelder + +- `v` +- `kind` +- `domain` +- `items` + +### 7.3 Optionale, aber empfohlene Felder + +- `basis` +- `meta.source` +- `meta.definition_ref` +- `meta.quality` + +### 7.4 Semantik der Felder + +#### `v` + +Schema-Version des gespeicherten JSON-Objekts. + +#### `kind` + +Technischer Composite-Container. +Erlaubte Werte: + +- `group_set` +- `distribution_set` +- `sequence_set` +- `model_set` + +#### `domain` + +Fachlicher Bezugsraum der Daten, zum Beispiel: + +- `heart_rate` +- `power` +- `speed` +- `recovery` +- `intervals` +- `technique` +- `critical_power` + +#### `basis` + +Optionale Bezugsgröße, zum Beispiel: + +- `time` +- `distance` +- `repetitions` +- `work` +- `count` +- `score` + +#### `items` + +Liste der eigentlichen Teilwerte oder Segmente. + +#### `meta.source` + +Ursprung des Composite-Werts, z. B.: + +- `reported` +- `imported` +- `detected` +- `derived_l1` + +#### `meta.definition_ref` + +Referenz auf eine externe oder interne Definition, etwa: + +- HF-Zonenmodell +- Power-Zonenmodell +- Intervall-Template +- Testprotokoll +- Schwellenmodell + +#### `meta.quality` + +Optionale Qualitätskennzeichnung, z. B.: + +- `raw` +- `validated` +- `estimated` +- `reported` +- `derived` + +--- + +## 8. Composite-Typ 1: `group_set` + +### 8.1 Zweck + +Speicherung einer kleinen Menge benannter Teilwerte ohne zwingende Reihenfolge und ohne Bandgrenzen. + +### 8.2 Typische Einsatzfälle + +- HRV-Bundle +- Wellness-/Readiness-Bundle +- Technik-Bundle +- Submetriken eines Tests +- kleine Sammlungen korrelierter Werte + +### 8.3 Struktur + + ```json + { + "v": 1, + "kind": "group_set", + "domain": "recovery", + "items": [ + { "key": "rmssd", "value": 42.1, "unit": "ms" }, + { "key": "resting_hr", "value": 54, "unit": "bpm" }, + { "key": "sleep_score", "value": 78, "unit": "score" } + ], + "meta": { + "source": "reported", + "definition_ref": "morning_readiness_protocol_v1", + "quality": "validated" + } + } + ``` + +### 8.4 Pflichtfelder pro Item + +- `key` +- `value` + +### 8.5 Optionale Item-Felder + +- `unit` +- `label` +- `note` + +### 8.6 Regeln + +- `key` muss innerhalb eines Objekts eindeutig sein +- `value` darf scalar oder `null` sein +- keine fachliche Interpretation im Objekt selbst + +### 8.7 Nicht speichern in `group_set` + +Nicht in `group_set` speichern: + +- `readiness_index` +- `recovery_flag` +- `trend_direction` +- generierte Scores + +Diese gehören in Layer 2. + +--- + +## 9. Composite-Typ 2: `distribution_set` + +### 9.1 Zweck + +Speicherung einer Verteilung über Bänder, Klassen oder Bereiche. + +### 9.2 Typische Einsatzfälle + +- Herzfrequenzzonen +- Power-Zonen +- Geschwindigkeitszonen +- Pace-Bänder +- Kadenz-Zonen +- Beschleunigungsbänder +- Sprunghöhenklassen + +### 9.3 Struktur + + ```json + { + "v": 1, + "kind": "distribution_set", + "domain": "heart_rate", + "basis": "time", + "items": [ + { + "key": "z1", + "value": 420, + "unit": "s", + "lower": 50, + "upper": 60, + "range_unit": "%hr_max" + }, + { + "key": "z2", + "value": 780, + "unit": "s", + "lower": 60, + "upper": 70, + "range_unit": "%hr_max" + }, + { + "key": "z3", + "value": 360, + "unit": "s", + "lower": 70, + "upper": 80, + "range_unit": "%hr_max" + } + ], + "meta": { + "source": "reported", + "definition_ref": "hr_zone_model_5_hrmax", + "quality": "validated" + } + } + ``` + +### 9.4 Pflichtfelder pro Item + +- `key` +- `value` + +### 9.5 Empfohlene Item-Felder + +- `unit` +- `lower` +- `upper` +- `range_unit` + +### 9.6 Regeln + +- `key` muss eindeutig sein +- `items` sollen logisch sortierbar sein +- `lower` und `upper` sind optional, aber bei bandbasierten Sets empfohlen +- `basis` definiert, worauf sich `value` bezieht, etwa Zeit, Distanz, Wiederholungen + +### 9.7 Nicht speichern in `distribution_set` + +Nicht in `distribution_set` speichern: + +- Wechselhäufigkeit +- mittlere Verweildauer +- Polarized Index +- Pyramidal Index +- Intervallklassifikation + +Das sind Auswertungsergebnisse aus Layer 2. + +--- + +## 10. Composite-Typ 3: `sequence_set` + +### 10.1 Zweck + +Speicherung geordneter Sequenzen, Segmente, Zustandsfolgen oder Intervallblöcke. + +### 10.2 Typische Einsatzfälle + +- Work-/Recovery-Blöcke +- erkannte HF-Zonen-Segmente +- Pace-Wechsel +- Satz-/Rundenabfolgen +- Drill-Sequenzen +- Ereignisfolgen + +### 10.3 Struktur: Intervallblöcke + + ```json + { + "v": 1, + "kind": "sequence_set", + "domain": "intervals", + "basis": "time", + "items": [ + { "seq": 1, "label": "work", "start": 0, "duration": 180, "unit": "s" }, + { "seq": 2, "label": "recovery", "start": 180, "duration": 90, "unit": "s" }, + { "seq": 3, "label": "work", "start": 270, "duration": 180, "unit": "s" } + ], + "meta": { + "source": "detected", + "definition_ref": "interval_template_optional", + "quality": "derived_l1" + } + } + ``` + +### 10.4 Struktur: Zustandssegmente + + ```json + { + "v": 1, + "kind": "sequence_set", + "domain": "heart_rate_zone_state", + "basis": "time", + "items": [ + { "seq": 1, "label": "z2", "start": 0, "duration": 300, "unit": "s" }, + { "seq": 2, "label": "z4", "start": 300, "duration": 90, "unit": "s" }, + { "seq": 3, "label": "z2", "start": 390, "duration": 120, "unit": "s" } + ], + "meta": { + "source": "detected", + "definition_ref": "hr_zone_model_5_hrmax" + } + } + ``` + +### 10.5 Pflichtfelder pro Item + +- `seq` +- mindestens eines von: + - `label` + - `key` +- mindestens eines von: + - `duration` + - `start` und `end` + +### 10.6 Optionale Item-Felder + +- `unit` +- `start` +- `end` +- `value` +- `note` + +### 10.7 Regeln + +- `seq` muss eindeutig und aufsteigend sein +- Segmente müssen logisch geordnet sein +- bei zeitbasierten Segmenten sollen Start/Dauer-Ende konsistent sein +- keine fachlich interpretierten Kennzahlen im Objekt + +### 10.8 Nicht speichern in `sequence_set` + +Nicht in `sequence_set` speichern: + +- `switch_count` +- `switch_rate_per_min` +- `transition_entropy` +- `density_index` +- `plan_match_score` + +Diese Metriken entstehen erst in Layer 2. + +--- + +## 11. Composite-Typ 4: `model_set` + +### 11.1 Zweck + +Speicherung kleiner, strukturierter Parametersätze eines Modells. + +### 11.2 Typische Einsatzfälle + +- Critical Power / W′ +- Critical Speed +- Last-Geschwindigkeits-Profil +- Schwellenmodelle +- Testmodellparameter + +### 11.3 Struktur + + ```json + { + "v": 1, + "kind": "model_set", + "domain": "critical_power", + "items": [ + { "key": "cp", "value": 286, "unit": "W" }, + { "key": "w_prime", "value": 16800, "unit": "J" }, + { "key": "r_squared", "value": 0.98, "unit": "ratio" } + ], + "meta": { + "source": "derived_l1", + "definition_ref": "critical_power_2_parameter", + "quality": "validated" + } + } + ``` + +### 11.4 Pflichtfelder pro Item + +- `key` +- `value` + +### 11.5 Optionale Item-Felder + +- `unit` +- `ci_low` +- `ci_high` +- `error` +- `note` + +### 11.6 Regeln + +- `key` eindeutig +- Modellname und Herleitung über `meta.definition_ref` +- Modellinterpretation nicht im Objekt selbst speichern + +### 11.7 Nicht speichern in `model_set` + +Nicht in `model_set` speichern: + +- Trainingsdiagnosen +- Handlungsempfehlungen +- automatische Coachingtexte + +--- + +## 12. Warum genau diese vier Typen + +Diese vier Typen decken den größten Teil realistisch auftretender Composite-Fälle ab, ohne die Persistenz fachlich zu überfrachten. + +### `group_set` + +Für kleine Bündel benannter Werte + +### `distribution_set` + +Für Verteilungen über Klassen oder Bänder + +### `sequence_set` + +Für geordnete Folgen, Segmente und Intervallstrukturen + +### `model_set` + +Für modellbasierte Parametersätze + +Weitere Speichertypen sollen nur eingeführt werden, wenn ein neuer Fall mit diesen vier Typen **nicht sinnvoll oder nur mit klaren Verformungen** modellierbar ist. + +--- + +## 13. Was ausdrücklich nicht Teil der Persistenz-Basisschicht ist + +Die Persistenzschicht speichert keine fachlichen Endprodukte. + +Nicht Bestandteil der Basis-Composite-Struktur sind: + +- Visualisierungsvorgaben +- Diagrammfarblogik +- Scoring-Ergebnisse +- Kennzahlen aus Übergangsanalysen +- narrative KI-Bausteine +- Trainingsklassifikationen +- Empfehlungen +- Bewertungen +- Interpretationen +- sportartspezifische Schlussfolgerungen + +--- + +## 14. Layer-1-Konzept + +### 14.1 Zweck von Layer 1 + +Layer 1 ist die technische Aufbereitungsschicht zwischen Persistenz und Fachlogik. + +Layer 1 darf: + +- Composite-JSON validieren +- Schema-Version prüfen +- `kind` prüfen +- Items lesen +- Einheiten normalisieren +- Basisreferenzen auflösen +- technische DTOs erzeugen +- leichte Plausibilitätsprüfungen durchführen + +Layer 1 darf nicht: + +- Scores berechnen +- fachliche Bewertungen erzeugen +- Intervallqualität beurteilen +- KI-Platzhalter formulieren +- Trainingscharakter interpretieren + +### 14.2 Layer-1-Output + +Layer 1 erzeugt kanonische interne Datenobjekte, zum Beispiel: + +- `ScalarValueDTO` +- `GroupSetDTO` +- `DistributionSetDTO` +- `SequenceSetDTO` +- `ModelSetDTO` + +### 14.3 Beispielhafte DTOs + + ```ts + type ScalarValueDTO = { + id: string + datatype: string + value: number | string | boolean | null + unit?: string | null + } + + type GroupItemDTO = { + key: string + value: unknown + unit?: string | null + } + + type GroupSetDTO = { + id: string + domain: string + items: GroupItemDTO[] + meta?: Record + } + + type DistributionItemDTO = { + key: string + value: number | null + unit?: string | null + lower?: number | null + upper?: number | null + rangeUnit?: string | null + } + + type DistributionSetDTO = { + id: string + domain: string + basis?: string | null + items: DistributionItemDTO[] + meta?: Record + } + + type SequenceItemDTO = { + seq: number + label?: string | null + start?: number | null + end?: number | null + duration?: number | null + unit?: string | null + value?: unknown + } + + type SequenceSetDTO = { + id: string + domain: string + basis?: string | null + items: SequenceItemDTO[] + meta?: Record + } + + type ModelItemDTO = { + key: string + value: unknown + unit?: string | null + } + + type ModelSetDTO = { + id: string + domain: string + items: ModelItemDTO[] + meta?: Record + } + ``` + +### 14.4 Layer-1-Validierungsregeln + +#### Allgemein + +- JSON muss parsebar sein +- `v` muss unterstützt sein +- `kind` muss bekannt sein +- `domain` darf nicht leer sein +- `items` muss Array sein + +#### `group_set` + +- jeder Eintrag braucht `key` +- `key` eindeutig + +#### `distribution_set` + +- jeder Eintrag braucht `key` +- `key` eindeutig +- `value` numerisch oder `null` +- `lower <= upper`, wenn beide gesetzt sind + +#### `sequence_set` + +- jeder Eintrag braucht `seq` +- `seq` eindeutig +- `seq` sortierbar +- zeitliche Felder konsistent + +#### `model_set` + +- jeder Eintrag braucht `key` +- `key` eindeutig + +### 14.5 Layer-1-Normalisierungen + +Layer 1 darf minimale Normalisierung durchführen: + +- Sekunden, Minuten, Stunden intern vereinheitlichen +- Meter/Kilometer intern vereinheitlichen +- Prozentwerte intern konsistent repräsentieren +- Feldnamen aus Legacy-Importen normalisieren + +Wichtig: +Diese Normalisierung ist technisch, nicht fachlich interpretierend. + +--- + +## 15. Layer-2a-Konzept: fachliche Analyse- und Diagrammprojektionen + +Layer 2a konsumiert Daten aus Layer 1 und erzeugt daraus fachliche Sichtweisen. + +Die früher diskutierten fachlichen Archetypen leben **hier**, nicht in der Persistenz. + +### 15.1 Fachliche Archetypen in Layer 2a + +1. `BandDistribution` +2. `TransitionProfile` +3. `IntervalBlockProfile` +4. `EventActionProfile` +5. `CouplingEfficiencyProfile` +6. `ModelParameterProfile` +7. `TechniqueCycleProfile` +8. `ReadinessRecoveryProfile` + +### 15.2 Mapping von Speicherformen auf Layer-2a-Archetypen + +#### `distribution_set` + +kann projiziert werden auf: + +- `BandDistribution` + +#### `sequence_set` + +kann projiziert werden auf: + +- `TransitionProfile` +- `IntervalBlockProfile` +- `EventActionProfile` + +#### `group_set` + +kann projiziert werden auf: + +- `TechniqueCycleProfile` +- `ReadinessRecoveryProfile` +- Teile eines `EventActionProfile` + +#### `model_set` + +kann projiziert werden auf: + +- `ModelParameterProfile` + +#### Kombination mehrerer Werte + +kann projiziert werden auf: + +- `CouplingEfficiencyProfile` + +### 15.3 Beispiel: TransitionProfile aus `sequence_set` + +Aus einer Zustandssequenz wie: + +- z2 -> 300s +- z4 -> 90s +- z2 -> 120s +- z5 -> 60s + +kann Layer 2a berechnen: + +- Anzahl Wechsel +- Wechselrate +- mittlere Verweildauer pro Zustand +- längste Verweildauer +- Zustandsübergänge +- Intervallcharakter + +Diese Werte werden **nicht** im Basis-Composite gespeichert, sondern von Layer 2a berechnet. + +### 15.4 Beispiel: BandDistribution aus `distribution_set` + +Aus HF-Zonen-Zeiten kann Layer 2a erzeugen: + +- Diagrammserien +- Prozentanteile +- Schwerpunktzone +- Exposition oberhalb einer Schwelle +- Intensitätsverteilung + +Auch dies entsteht erst in Layer 2a. + +--- + +## 16. Layer-2b-Konzept: KI-Projektionen + +Layer 2b soll keine Rohwerte direkt in Prompts kippen, sondern fachlich brauchbare Platzhalter erzeugen. + +### 16.1 Grundsatz + +Layer 2b konsumiert bevorzugt: + +- Layer-1-Daten für einfache Fakten +- Layer-2a-Daten für fachlich verdichtete Aussagen + +### 16.2 Ziel von Layer 2b + +Erzeugung strukturierter KI-Bausteine wie: + +- „Zeit in hoher Intensität“ +- „Intervallstruktur erkannt“ +- „Belastungsdichte hoch“ +- „geringe Variabilität“ +- „Readiness reduziert“ +- „Technikmetriken auffällig“ +- „intern-externe Lastkopplung stabil“ + +### 16.3 Beispielhafte KI-Platzhalter + + ```json + { + "high_intensity_time_s": 480, + "interval_structure_detected": true, + "switch_count": 12, + "dominant_hr_zone": "z2", + "readiness_state": "normal", + "technique_variability_flag": "elevated" + } + ``` + +Wichtig: +Auch diese Objekte sind **keine Persistenz-Basisobjekte**, sondern bereitgestellte Kontexte für KI. + +--- + +## 17. Composite-Speicherung im bestehenden Value Store + +### 17.1 Annahme über den aktuellen Store + +Der bestehende Store speichert Werte als Datensätze mit: + +- Datentyp +- Value +- Einheit +- weiteren Metadaten + +### 17.2 Empfohlene Nutzung + +#### Scalar + +unverändert speichern + +#### Composite + +als Datensatz mit: + +- `datatype = composite` +- `value = jsonb` +- `unit = null` oder optional leer, sofern Einheit innerhalb der Items gespeichert wird + +### 17.3 Empfohlene Zusatzmetadaten außerhalb von `value` + +Soweit euer bestehendes Modell das unterstützt, sind diese Felder außerhalb des JSONB sinnvoll: + +- `id` +- `athlete_id` +- `session_id` +- `source_system` +- `measured_at` +- `datatype` +- `subtype` oder `kind` als Hilfsspalte +- `domain` als Hilfsspalte +- `created_at` +- `updated_at` + +### Empfehlung + +`kind` und `domain` sollten nach Möglichkeit zusätzlich indexierbar sein, selbst wenn sie primär im JSONB liegen. + +--- + +## 18. JSONB-Schema-Regeln + +### 18.1 Schema-Versionierung + +Jedes Composite trägt `v`. + +Regel: + +- neue inkompatible Strukturen erhöhen `v` +- Layer 1 unterstützt definierte Versionen +- unbekannte Versionen werden abgelehnt oder in Fallback-Modus versetzt + +### 18.2 Rückwärtskompatibilität + +Wenn möglich: + +- neue optionale Felder hinzufügen, ohne `v` zu erhöhen +- nur strukturelle Brüche führen zu neuer Version + +### 18.3 Dokumentgröße + +Composite-Objekte sollen fachlich zusammenhängend, aber nicht unnötig groß sein. + +Regel: + +- ein Composite speichert einen **atomaren strukturierten Sachverhalt** +- keine beliebig großen Sammelobjekte +- keine Vermischung mehrerer unabhängiger Analyseebenen + +--- + +## 19. Definitionen, Referenzen und Kataloge + +Die Basis-Composite-Objekte können Definitionen referenzieren, sollen diese aber nicht voll duplizieren. + +### 19.1 Beispiele für `definition_ref` + +- `hr_zone_model_5_hrmax` +- `hr_zone_model_5_hrr` +- `power_zone_model_7_ftp` +- `morning_readiness_protocol_v1` +- `critical_power_2_parameter` +- `boxing_round_structure_3x3` +- `karate_interval_template_v2` + +### 19.2 Nutzen von `definition_ref` + +Damit kann Layer 2: + +- bandbezogene Logik verstehen +- Diagrammbeschriftungen korrekt erzeugen +- Schwellenmodelle zuordnen +- KI-Kontext korrekt ableiten + +Wichtig: +`definition_ref` verweist auf eine Definition, ersetzt diese aber nicht. + +--- + +## 20. Abgeleitete Werte zurückspeichern + +### 20.1 Grundsatz + +Falls Layer-2-Ergebnisse persistiert werden sollen, sollen sie **nicht** in das Basis-Composite zurückgeschrieben werden. + +Stattdessen werden sie als eigene abgeleitete Werte gespeichert. + +### 20.2 Begründung + +So werden vermieden: + +- doppelte Wahrheit +- inkonsistente alte Berechnungsergebnisse +- Vermischung von Fakt und Interpretation +- schwierige Rebuilds + +### 20.3 Empfohlenes Muster + +Ein abgeleiteter Wert bekommt: + +- eigenen Datensatz +- Herkunftskennzeichnung +- Regel-/Berechnungsversion +- Referenz auf Quellwerte + +### 20.4 Beispiel + + ```json + { + "origin": "derived", + "layer": "2a", + "rule": "transition_profile_v2", + "source_ids": ["value_4711", "value_4712", "value_4713"] + } + ``` + +--- + +## 21. Beispielhafte Ende-zu-Ende-Flows + +### 21.1 Flow A: Herzfrequenzzonen + +#### Persistenz + +`distribution_set` mit Zonenzeiten + +#### Layer 1 + +`DistributionSetDTO` + +#### Layer 2a + +- Prozentanteile +- Diagrammwerte +- dominante Zone +- Zeit oberhalb bestimmter Zonen +- Intensitätsprofil + +#### Layer 2b + +- KI-Platzhalter wie: + - `dominant_hr_zone` + - `high_intensity_time_s` + - `intensity_distribution_type` + +### 21.2 Flow B: Intervalltraining + +#### Persistenz + +`sequence_set` mit Work-/Recovery-Segmenten + +#### Layer 1 + +`SequenceSetDTO` + +#### Layer 2a + +- Anzahl Blöcke +- Work-Recovery-Verhältnis +- mittlere Blockdauer +- Dichte +- Compliance +- Intervallcharakter + +#### Layer 2b + +- KI-Platzhalter wie: + - `interval_block_count` + - `work_recovery_ratio` + - `interval_density_state` + +### 21.3 Flow C: Readiness + +#### Persistenz + +`group_set` mit RMSSD, Ruhepuls, Schlafscore + +#### Layer 1 + +`GroupSetDTO` + +#### Layer 2a + +- Baseline-Abweichung +- Trend +- Zustandsklassifikation + +#### Layer 2b + +- `readiness_state` +- `recovery_attention_flag` + +### 21.4 Flow D: Critical Power + +#### Persistenz + +`model_set` mit `cp`, `w_prime`, `r_squared` + +#### Layer 1 + +`ModelSetDTO` + +#### Layer 2a + +- Modellgüte +- Vergleich zum Verlauf +- Zonen-/Schwellenableitung + +#### Layer 2b + +- KI-konforme Modellzusammenfassung + +--- + +## 22. Regeln zur Entscheidung: Scalar oder Composite + +### 22.1 Scalar verwenden, wenn + +- genau ein Wert gespeichert wird +- keine strukturierte Untergliederung nötig ist +- keine zusammenhängende Teilwertgruppe existiert + +Beispiele: + +- Durchschnittspuls +- Maximalpuls +- Distanz +- Dauer +- Durchschnittsleistung + +### 22.2 Composite verwenden, wenn + +- mehrere Teilwerte logisch zusammengehören +- die Teilwerte nur gemeinsam sinnvoll interpretierbar sind +- Reihenfolge, Verteilung oder Modellstruktur relevant ist + +Beispiele: + +- HF-Zonen +- Intervallblöcke +- Readiness-Bundle +- Critical-Power-Parameter + +### 22.3 Nicht jedes Mehrfachfeld ist automatisch Composite + +Wenn mehrere Werte unabhängig voneinander existieren und auch einzeln sinnvoll sind, sollen sie weiterhin als Scalars gespeichert werden. + +Composite nur dann verwenden, wenn die Gruppe selbst eine eigenständige semantische Einheit ist. + +--- + +## 23. Regeln zur Einführung neuer Composite-Formate + +Ein neues Composite-Format darf nur eingeführt werden, wenn mindestens eine der folgenden Bedingungen erfüllt ist: + +1. Der neue Fall lässt sich mit den vier Basis-Typen nicht sinnvoll ausdrücken +2. Eine Abbildung wäre nur durch unsaubere oder missbräuchliche Nutzung möglich +3. Die Lesbarkeit und Wartbarkeit würden mit den vorhandenen Typen stark leiden + +Vor Einführung eines neuen Typs ist zu prüfen: + +- Kann es als `group_set` modelliert werden? +- Kann es als `distribution_set` modelliert werden? +- Kann es als `sequence_set` modelliert werden? +- Kann es als `model_set` modelliert werden? + +Nur wenn alle vier Antworten fachlich und technisch nicht tragfähig sind, soll ein neuer Speichertyp diskutiert werden. + +--- + +## 24. Implementierungsrichtlinien + +### 24.1 Parser + +Implementiere einen zentralen Composite-Parser: + +- Eingang: Value-Record mit `datatype = composite` +- Ausgabe: konkretes Layer-1-DTO je `kind` + +### 24.2 Validator + +Implementiere einen zentralen Validator: + +- allgemeine Prüfung +- kind-spezifische Prüfung +- strukturierte Fehlermeldungen + +### 24.3 Mapper + +Implementiere Mapper: + +- `CompositeRecord -> GroupSetDTO` +- `CompositeRecord -> DistributionSetDTO` +- `CompositeRecord -> SequenceSetDTO` +- `CompositeRecord -> ModelSetDTO` + +### 24.4 Projection Services in Layer 2 + +Beispiele: + +- `BandDistributionProjectionService` +- `TransitionProfileService` +- `IntervalAnalysisService` +- `ReadinessProjectionService` +- `CouplingAnalysisService` +- `TechniqueProjectionService` + +### 24.5 KI-Mapping in Layer 2b + +Beispiele: + +- `AiPlaceholderBuilder` +- `AiFactsBuilder` +- `AiNarrativeInputBuilder` + +--- + +## 25. Fehler- und Fallback-Strategie + +### 25.1 Ungültiges Composite + +Wenn ein Composite ungültig ist: + +- Layer 1 liefert strukturierten Fehler +- der Datensatz wird nicht als fachlich gültig weitergereicht +- optional kann ein „raw passthrough“ für Debugging existieren + +### 25.2 Unbekannte Version + +Wenn `v` unbekannt ist: + +- Datensatz markieren +- nicht stillschweigend interpretieren +- optional Migration oder Fallback-Parser nutzen + +### 25.3 Teilweise fehlende Felder + +Wenn optionale Felder fehlen: + +- lesen, sofern Kernschema gültig bleibt +- fehlende Informationen in Layer 2 berücksichtigen +- keine stillschweigende Erfindung fachlicher Werte + +--- + +## 26. Performance- und Wartungsaspekte + +### 26.1 Grundsatz + +Die Persistenz soll flexibel, aber strukturiert bleiben. + +### 26.2 Empfehlungen + +- kleine, atomare Composite-Objekte +- keine übergroßen Sammelcontainer +- `kind` und `domain` indexierbar halten +- Definitionen referenzieren statt duplizieren +- fachliche Projektionen nicht als Pflichtbestandteil der Persistenz speichern + +### 26.3 Vorteil dieser Struktur + +- geringe Kopplung +- stabile Persistenz +- evolvierbare Fachlogik +- gute Eignung für mehrere Sportarten +- gute Wiederverwendbarkeit in Layer 2a und 2b + +--- + +## 27. Migrationsstrategie + +Falls bereits Composite-Ansätze existieren, wird folgendes Vorgehen empfohlen: + +### Phase 1 + +- Einführung der vier `kind`-Typen +- Aufbau von Validator und Parser +- Speicherung neuer Composites nach neuem Muster + +### Phase 2 + +- Layer-1-DTOs einführen +- bestehende Reader umstellen + +### Phase 3 + +- Layer-2a-Projektionen implementieren +- Diagramme und Kennzahlen umstellen + +### Phase 4 + +- Layer-2b-KI-Platzhalter auf neue Projektionen aufsetzen + +### Phase 5 + +- bestehende Altformate optional migrieren oder kompatibel lesen + +--- + +## 28. Nicht-Ziele dieses Dokuments + +Dieses Dokument definiert bewusst nicht: + +- konkrete UI-Diagrammgestaltung +- konkrete Score-Formeln +- sportartspezifische Bewertungslogiken +- konkrete KI-Prompttexte +- konkrete Datenbanktabellenmigrationen im Detail +- konkrete API-Endpunkte + +Diese Punkte sind nachgelagerte Spezifikationen. + +--- + +## 29. Offene Erweiterungspunkte + +Die Architektur lässt folgende spätere Erweiterungen zu, ohne die Persistenzbasis zu brechen: + +- zusätzliche `definition_ref`-Kataloge +- sportartspezifische Layer-2-Projektionen +- neue KI-Platzhaltertypen +- zusätzliche Qualitäts- und Provenienzfelder +- Rückspeicherung abgeleiteter Werte als eigene Datensätze +- spezielle Event-Detektion in Layer 2a + +--- + +## 30. Endgültige Entscheidung + +### Die Persistenzschicht speichert: + +- Scalars +- vier einfache technische Composite-Typen + +### Layer 1 übernimmt: + +- Lesen +- Validieren +- minimale Normalisierung +- DTO-Bildung + +### Layer 2a übernimmt: + +- fachliche Analyse +- Diagrammaufbereitung +- Kennzahlen +- Profile +- Scores +- Interpretationen + +### Layer 2b übernimmt: + +- KI-Platzhalter +- KI-Fakten +- KI-Zusammenfassungen +- KI-taugliche Verdichtungen + +--- + +## 31. Kurzfassung für einen Coding Agent + +### Ziel + +Erweitere den bestehenden Value Store um Composite-Werte auf `jsonb`-Basis, ohne fachliche Analyse-Logik in die Persistenz zu verlagern. + +### Implementiere + +1. Unterstützung für `datatype = composite` +2. vier `kind`-Typen: + - `group_set` + - `distribution_set` + - `sequence_set` + - `model_set` +3. Layer-1-Validatoren und Parser +4. kanonische DTOs pro `kind` +5. klare Trennung: + - Persistenz = strukturierte Fakten + - Layer 1 = technische Aufbereitung + - Layer 2a = fachliche Projektionen + - Layer 2b = KI-Projektionen + +### Vermeide + +- Scores im Basis-Composite +- Interpretationen im Basis-Composite +- Diagramm- oder KI-Logik in der Persistenz +- zu viele spezialisierte Composite-Speichertypen + +--- + +## 32. Akzeptanzkriterien + +Die Umsetzung gilt als fachlich passend, wenn: + +1. ein Composite-Value mit `jsonb` gespeichert werden kann +2. Layer 1 den Typ sicher validieren und lesen kann +3. die Persistenz keine fachlichen Layer-2-Interpretationen erzwingt +4. HF-Zonen als `distribution_set` abbildbar sind +5. Intervallblöcke als `sequence_set` abbildbar sind +6. Readiness-Bundles als `group_set` abbildbar sind +7. Modellparameter als `model_set` abbildbar sind +8. Layer 2a daraus fachliche Projektionen erzeugen kann +9. Layer 2b daraus KI-Platzhalter erzeugen kann +10. neue Sportarten ohne neue Persistenzarchitektur integrierbar sind + +--- + +## 33. Schlussformel + +Die Leitentscheidung dieses Konzepts lautet: + +**Die Datenbank speichert Struktur. +Layer 1 liefert kanonische technische Objekte. +Layer 2 erzeugt fachliche Bedeutung. +KI konsumiert diese Bedeutung, nicht die rohe Persistenzstruktur.** + +Damit bleibt das System einfach genug für die Implementierung und offen genug für spätere sportartspezifische Erweiterungen. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 014823a..7b3291c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,9 +120,17 @@ frontend/src/ - **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` (wenn befüllt). +- **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). diff --git a/backend/csv_parser/module_registry.py b/backend/csv_parser/module_registry.py index ab0b0f2..3786327 100644 --- a/backend/csv_parser/module_registry.py +++ b/backend/csv_parser/module_registry.py @@ -34,6 +34,8 @@ MODULE_DEFINITIONS: Dict[str, Dict[str, Any]] = { }, }, }, + # Kanon: nur Kern/spine + „heiße“ Metriken → activity_log. Erweiterte Parameter → training_parameters / EAV + # (siehe backend/data_layer/activity_data_canon.py). "activity": { "table": "activity_log", "fields": { @@ -63,16 +65,7 @@ MODULE_DEFINITIONS: Dict[str, Dict[str, Any]] = { "max": 220, "label_de": "Herzfrequenz max (bpm)", }, - "hr_min": {"type": "int", "required": False, "label_de": "Herzfrequenz min (bpm)"}, "rpe": {"type": "int", "required": False, "label_de": "RPE (1–10)"}, - "pace_min_per_km": {"type": "float", "required": False, "label_de": "Tempo (min/km)"}, - "cadence": {"type": "int", "required": False, "label_de": "Kadenz"}, - "avg_power": {"type": "int", "required": False, "label_de": "Leistung Ø (W)"}, - "elevation_gain": {"type": "int", "required": False, "label_de": "Höhenmeter / Aufstieg"}, - "temperature_celsius": {"type": "float", "required": False, "label_de": "Temperatur (°C)"}, - "humidity_percent": {"type": "int", "required": False, "label_de": "Luftfeuchtigkeit (%)"}, - "avg_hr_percent": {"type": "float", "required": False, "label_de": "HF Ø (% von max)"}, - "kcal_per_km": {"type": "float", "required": False, "label_de": "Kalorien pro km"}, "notes": {"type": "string", "required": False, "label_de": "Notiz"}, }, "derive_date_from_datetime_field": "start_time", diff --git a/backend/data_layer/activity_data_canon.py b/backend/data_layer/activity_data_canon.py new file mode 100644 index 0000000..c474c06 --- /dev/null +++ b/backend/data_layer/activity_data_canon.py @@ -0,0 +1,61 @@ +""" +Kanonische Aufteilung activity_log vs. EAV für Aktivitätssessions. + +- **Kern / Mapping-Ziele für activity_log:** ausschließlich die Keys aus + ``csv_parser.module_registry.MODULE_DEFINITIONS["activity"].fields`` (keine zweite hartcodierte Liste). +- **Alle anderen Attribute:** ``training_parameters`` + Attributprofil (Kategorie/Typ) → EAV; + Lesefallback für bekannte Legacy-Spalten siehe unten. + +Normative Doku: .claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md, +ACTIVITY_SCALAR_KANON_TABLE.md +""" +from __future__ import annotations + +from typing import Dict, Final + +from csv_parser.module_registry import get_module_definition + + +def get_activity_module_registry_field_keys() -> frozenset[str]: + """Keys des Universal-CSV-Moduls ``activity`` (= feste activity_log-Kernfelder / Mapping-Ziele).""" + mod = get_module_definition("activity") + if not mod: + return frozenset() + return frozenset((mod.get("fields") or {}).keys()) + + +# Gleiche Menge wie ``MODULE_DEFINITIONS["activity"].fields`` — zur Laufzeit aus der Registry abgeleitet. +ACTIVITY_MODULE_REGISTRY_FIELD_KEYS: Final[frozenset[str]] = get_activity_module_registry_field_keys() + +# Teil-UPDATEs (Import): alle Kernfelder außer ``date`` (Identität / Duplikat-Key). +ACTIVITY_LOG_PATCHABLE_COLUMNS: Final[frozenset[str]] = ACTIVITY_MODULE_REGISTRY_FIELD_KEYS - {"date"} + +# Parameter-Keys (training_parameters.key), die primär in EAV geführt werden; source_field nach Migration 057 NULL. +# Lesen (Merge): activity_log-Legacy-Spalte schlägt EAV, wenn beide befüllt; sonst EAV. +ACTIVITY_EAV_PRIMARY_PARAMETER_KEYS: Final[frozenset[str]] = frozenset( + { + "min_hr", + "pace_min_per_km", + "cadence", + "avg_power", + "elevation_gain", + "temperature_celsius", + "humidity_percent", + "avg_hr_percent", + "kcal_per_km", + } +) + +# Spaltenname activity_log für Legacy-Merge (Vorrang vor EAV bei gesetztem Spaltenwert). +ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM: Final[Dict[str, str]] = { + "min_hr": "hr_min", + "pace_min_per_km": "pace_min_per_km", + "cadence": "cadence", + "avg_power": "avg_power", + "elevation_gain": "elevation_gain", + "temperature_celsius": "temperature_celsius", + "humidity_percent": "humidity_percent", + "avg_hr_percent": "avg_hr_percent", + "kcal_per_km": "kcal_per_km", +} + diff --git a/backend/data_layer/activity_metrics.py b/backend/data_layer/activity_metrics.py index cfdd940..9c27451 100644 --- a/backend/data_layer/activity_metrics.py +++ b/backend/data_layer/activity_metrics.py @@ -10,6 +10,7 @@ Functions: - get_training_frequency_by_type_data(): Häufigkeit & Intensität pro activity_type - get_training_inter_session_gap_data(): Pausen zwischen Einheiten (Stunden) - get_training_sessions_recent_weeks_data(): Wochen-JSON für KI-Kontext + - get_training_parameters_ki_glossary_data(): Parameter-Katalog (Feld, Namen, Beschreibungen) für KI All functions return structured data (dict) without formatting. Use placeholder_resolver.py for formatted strings for AI. @@ -1179,3 +1180,32 @@ def get_training_sessions_recent_weeks_data( }, } ) + + +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"}, + } diff --git a/backend/data_layer/activity_persistence_orchestrator.py b/backend/data_layer/activity_persistence_orchestrator.py index 56d7f04..2e128e2 100644 --- a/backend/data_layer/activity_persistence_orchestrator.py +++ b/backend/data_layer/activity_persistence_orchestrator.py @@ -1,5 +1,5 @@ """ -Zentrale Persistenz für activity_log + EAV-Nebenwirkungen (Eval, Spalten→EAV). +Zentrale Persistenz für activity_log + EAV-Nebenwirkungen (Eval). Alle Schreibpfade (REST, Universal-CSV, Legacy-Upload) laufen hier zusammen. @@ -15,7 +15,7 @@ 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_session_metrics import sync_column_backed_session_metrics +from data_layer.activity_data_canon import get_activity_module_registry_field_keys logger = logging.getLogger(__name__) @@ -51,10 +51,8 @@ _ACTIVITY_CSV_REGISTRY_EXCLUDE = frozenset({"date", "start_time", "end_time", "a def activity_registry_field_keys() -> frozenset[str]: - mod = get_module_definition("activity") - if not mod: - return frozenset() - return frozenset((mod.get("fields") or {}).keys()) + """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]: @@ -248,7 +246,7 @@ def insert_activity_csv_minimal( def run_activity_post_write_hooks(cur, profile_id: str, eid: str) -> None: - """Auto-Eval (falls aktiv) + EAV-Spiegel aus activity_log-Spalten.""" + """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( """ @@ -269,7 +267,6 @@ def run_activity_post_write_hooks(cur, profile_id: str, eid: str) -> None: _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) - sync_column_backed_session_metrics(cur, str(profile_id), str(eid)) def run_activity_post_write_hooks_import( @@ -286,7 +283,7 @@ def run_activity_post_write_hooks_import( kcal_active: Any, kcal_resting: Any, ) -> None: - """Eval + EAV nach Legacy-Import mit vorgebautem Kontext-Dict.""" + """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 = { @@ -308,7 +305,6 @@ def run_activity_post_write_hooks_import( _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) - sync_column_backed_session_metrics(cur, str(profile_id), str(eid)) def merge_activity_csv_module_fields( diff --git a/backend/data_layer/activity_session_metrics.py b/backend/data_layer/activity_session_metrics.py index ab3c812..6894559 100644 --- a/backend/data_layer/activity_session_metrics.py +++ b/backend/data_layer/activity_session_metrics.py @@ -9,37 +9,13 @@ import logging from decimal import Decimal from typing import Any, Dict, List, Mapping, Optional, Sequence -from csv_parser.module_registry import get_module_definition +from data_layer.activity_data_canon import ( + ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM, + ACTIVITY_MODULE_REGISTRY_FIELD_KEYS, +) logger = logging.getLogger(__name__) -# activity_log-Spalten, die per training_parameters.source_field aus CSV (Parameter-Key) befüllt werden dürfen. -# Muss mit sync_column_backed_session_metrics übereinstimmen (inkl. Kernmetriken wie hr_avg). -ACTIVITY_LOG_PATCHABLE_COLUMNS = frozenset( - { - "start_time", - "end_time", - "activity_type", - "duration_min", - "kcal_active", - "kcal_resting", - "hr_avg", - "hr_max", - "hr_min", - "distance_km", - "rpe", - "pace_min_per_km", - "cadence", - "avg_power", - "elevation_gain", - "temperature_celsius", - "humidity_percent", - "avg_hr_percent", - "kcal_per_km", - "notes", - } -) - # Diese Spalten nicht aus CSV-Parameter-Zuordnung überschreiben (kommen aus Typ-Mapping / System). ACTIVITY_LOG_PATCH_FORBIDDEN = frozenset( { @@ -95,6 +71,8 @@ def merge_parameter_schema_rows( "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"], @@ -114,6 +92,8 @@ def merge_parameter_schema_rows( "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"], @@ -157,7 +137,9 @@ def resolve_activity_attribute_schema( 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.category AS param_category, + 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 @@ -175,7 +157,9 @@ def resolve_activity_attribute_schema( 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.category AS param_category, + 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 @@ -188,6 +172,16 @@ def resolve_activity_attribute_schema( 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 @@ -276,20 +270,26 @@ def upsert_session_metrics_from_csv_mapped( training_type_id: Optional[int], ) -> None: """ - EAV für Trainingsparameter aus CSV (nur Keys, die nicht im activity-Modul-Registry liegen). + EAV für Trainingsparameter aus CSV. - Kernfelder (Datum, Start, Distanz, HF, …) schreibt der Executor nach activity_log; - hier keine doppelten EAV-Zeilen für dieselben Registry-Keys. + 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 profile_id FROM activity_log WHERE id = %s", - (activity_log_id,), - ) + 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 - mod = get_module_definition("activity") or {} - activity_registry_keys = frozenset((mod.get("fields") or {}).keys()) + header = dict(row) schema = resolve_activity_attribute_schema(cur, training_category, training_type_id) for spec in schema: pkey = spec["key"] @@ -298,8 +298,13 @@ def upsert_session_metrics_from_csv_mapped( raw = mapped[pkey] if raw is None or raw == "": continue - if pkey in activity_registry_keys: + 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"]) @@ -328,13 +333,113 @@ def upsert_session_metrics_from_csv_mapped( ) +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"]) + return merged + + def sync_column_backed_session_metrics(cur, profile_id: str, activity_log_id: str) -> None: """ - EAV-Zeilen für alle Schema-Parameter mit gesetztem source_field aus der activity_log-Zeile - schreiben (Upsert) bzw. bei NULL in der Quellspalte löschen. Reine Layer-1-Logik; keine Router-Abhängigkeit. + [Veraltet / nicht mehr in Schreibpfaden aufgerufen] - Synchron mit Übergangsphase: activity_log bleibt kanonisch für klassische Spalten; EAV spiegelt dieselben - Werte für Profil/Platzhalter/Detail-API, ohne replace_activity_session_metrics aufzurufen. + 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() @@ -516,47 +621,37 @@ def replace_activity_session_metrics( return fetch_activity_session_metrics(cur, activity_log_id) -def get_activity_session_logical_unit(cur, profile_id: str, activity_log_id: str) -> Dict[str, Any]: +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) - schema = resolve_activity_attribute_schema( - cur, header.get("training_category"), header.get("training_type_id") - ) + 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) - by_key = {m["key"]: m for m in metrics} - merged_metrics: List[Dict[str, Any]] = list(metrics) - for s in schema: - k = s["key"] - if k in by_key: - continue - sf = s.get("source_field") - if not sf or (isinstance(sf, str) and not str(sf).strip()): - continue - col = str(sf).strip() - if col not in header: - continue - raw = header.get(col) - if raw is None: - continue - dt = s["data_type"] - try: - val = _coerce_raw_value_for_parameter(dt, raw) - except (TypeError, ValueError): - continue - merged_metrics.append( - { - "training_parameter_id": s["training_parameter_id"], - "key": k, - "data_type": dt, - "unit": s.get("unit"), - "value": val, - } - ) - merged_metrics.sort(key=lambda x: x["key"]) + merged_metrics = merge_column_backed_and_eav_metrics(header, schema, metrics) return { "header": header, "schema": schema, @@ -565,17 +660,33 @@ def get_activity_session_logical_unit(cur, profile_id: str, activity_log_id: str def enrich_sessions_with_metrics(cur, sessions: List[Dict[str, Any]]) -> None: - """Mutates each session dict: adds key 'session_metrics' (list) when sessions non-empty.""" + """ + 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, @@ -603,8 +714,42 @@ def enrich_sessions_with_metrics(cur, sessions: List[Dict[str, Any]]) -> None: else: val = r["value_bool"] by_act.setdefault(aid, []).append( - {"key": r["key"], "data_type": dt, "unit": r["unit"], "value": val} + { + "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")) - s["session_metrics"] = by_act.get(aid, []) + 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 + ] diff --git a/backend/migrations/057_activity_eav_primary_canon.sql b/backend/migrations/057_activity_eav_primary_canon.sql new file mode 100644 index 0000000..e3a74e1 --- /dev/null +++ b/backend/migrations/057_activity_eav_primary_canon.sql @@ -0,0 +1,115 @@ +-- Migration 057: Kanon EAV-primär für erweiterte Trainingsmetriken +-- Date: 2026-04-15 +-- activity_log-Spalten bleiben erhalten (Lesefallback / API); training_parameters.source_field +-- wird für diese Keys entfernt. Idempotenter EAV-Backfill aus Spalten (wie 055), dann source_field NULL. +-- Siehe: backend/data_layer/activity_data_canon.py + +-- min_hr (Spalte hr_min) +INSERT INTO activity_session_metrics ( + activity_log_id, training_parameter_id, + value_num, value_int, value_text, value_bool, updated_at +) +SELECT a.id, tp.id, NULL, a.hr_min, NULL, NULL, NOW() +FROM activity_log a +JOIN training_parameters tp ON tp.key = 'min_hr' AND tp.is_active = true +WHERE a.hr_min IS NOT NULL +ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING; + +INSERT INTO activity_session_metrics ( + activity_log_id, training_parameter_id, + value_num, value_int, value_text, value_bool, updated_at +) +SELECT a.id, tp.id, a.pace_min_per_km::double precision, NULL, NULL, NULL, NOW() +FROM activity_log a +JOIN training_parameters tp ON tp.key = 'pace_min_per_km' AND tp.is_active = true +WHERE a.pace_min_per_km IS NOT NULL +ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING; + +INSERT INTO activity_session_metrics ( + activity_log_id, training_parameter_id, + value_num, value_int, value_text, value_bool, updated_at +) +SELECT a.id, tp.id, NULL, a.cadence, NULL, NULL, NOW() +FROM activity_log a +JOIN training_parameters tp ON tp.key = 'cadence' AND tp.is_active = true +WHERE a.cadence IS NOT NULL +ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING; + +INSERT INTO activity_session_metrics ( + activity_log_id, training_parameter_id, + value_num, value_int, value_text, value_bool, updated_at +) +SELECT a.id, tp.id, NULL, a.avg_power, NULL, NULL, NOW() +FROM activity_log a +JOIN training_parameters tp ON tp.key = 'avg_power' AND tp.is_active = true +WHERE a.avg_power IS NOT NULL +ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING; + +INSERT INTO activity_session_metrics ( + activity_log_id, training_parameter_id, + value_num, value_int, value_text, value_bool, updated_at +) +SELECT a.id, tp.id, NULL, a.elevation_gain, NULL, NULL, NOW() +FROM activity_log a +JOIN training_parameters tp ON tp.key = 'elevation_gain' AND tp.is_active = true +WHERE a.elevation_gain IS NOT NULL +ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING; + +INSERT INTO activity_session_metrics ( + activity_log_id, training_parameter_id, + value_num, value_int, value_text, value_bool, updated_at +) +SELECT a.id, tp.id, a.temperature_celsius::double precision, NULL, NULL, NULL, NOW() +FROM activity_log a +JOIN training_parameters tp ON tp.key = 'temperature_celsius' AND tp.is_active = true +WHERE a.temperature_celsius IS NOT NULL +ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING; + +INSERT INTO activity_session_metrics ( + activity_log_id, training_parameter_id, + value_num, value_int, value_text, value_bool, updated_at +) +SELECT a.id, tp.id, NULL, a.humidity_percent, NULL, NULL, NOW() +FROM activity_log a +JOIN training_parameters tp ON tp.key = 'humidity_percent' AND tp.is_active = true +WHERE a.humidity_percent IS NOT NULL +ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING; + +INSERT INTO activity_session_metrics ( + activity_log_id, training_parameter_id, + value_num, value_int, value_text, value_bool, updated_at +) +SELECT a.id, tp.id, a.avg_hr_percent::double precision, NULL, NULL, NULL, NOW() +FROM activity_log a +JOIN training_parameters tp ON tp.key = 'avg_hr_percent' AND tp.is_active = true +WHERE a.avg_hr_percent IS NOT NULL +ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING; + +INSERT INTO activity_session_metrics ( + activity_log_id, training_parameter_id, + value_num, value_int, value_text, value_bool, updated_at +) +SELECT a.id, tp.id, a.kcal_per_km::double precision, NULL, NULL, NULL, NOW() +FROM activity_log a +JOIN training_parameters tp ON tp.key = 'kcal_per_km' AND tp.is_active = true +WHERE a.kcal_per_km IS NOT NULL +ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING; + +UPDATE training_parameters +SET source_field = NULL +WHERE key IN ( + 'min_hr', + 'pace_min_per_km', + 'cadence', + 'avg_power', + 'elevation_gain', + 'temperature_celsius', + 'humidity_percent', + 'avg_hr_percent', + 'kcal_per_km' +); + +DO $$ +BEGIN + RAISE NOTICE 'Migration 057: EAV-primary canon — backfill + source_field cleared for extended metrics'; +END $$; diff --git a/backend/placeholder_registrations/activity_metrics.py b/backend/placeholder_registrations/activity_metrics.py index 5a01a20..544249e 100644 --- a/backend/placeholder_registrations/activity_metrics.py +++ b/backend/placeholder_registrations/activity_metrics.py @@ -1,7 +1,7 @@ """ Activity Metrics Placeholder Registrations -Registers 17 Aktivitäts-Platzhalter hier; 3 Session-/Erholungs-Keys in activity_session_insights.py (20 gesamt). +Registers 17 Aktivitäts-Platzhalter hier; 3 weitere Keys in activity_session_insights.py (**20 gesamt** in PLACEHOLDER_MAP). Evidence-based metadata with clear tagging of source. @@ -43,7 +43,7 @@ def register_activity_group_1(): category="Aktivität", description="Zusammenfassung der letzten 14 Tage Aktivität", resolver_module="backend/placeholder_resolver.py", - resolver_function="_format_activity_summary", + resolver_function="get_activity_summary", data_layer_module=None, data_layer_function=None, source_tables=["activity_log", "training_types"], @@ -127,17 +127,23 @@ def register_activity_group_1(): activity_detail_metadata = PlaceholderMetadata( key="activity_detail", category="Aktivität", - description="Detaillierte Liste der letzten 14 Tage Aktivität (Kopfzeile + EAV-Metriken)", + description=( + "Letzte 14 Tage: pro Session Kopfzeile (activity_log) plus gemergte Profil-Metriken " + "(dynamische Keys je training_category / training_type_id)" + ), resolver_module="backend/placeholder_resolver.py", resolver_function="get_activity_detail", data_layer_module="backend/data_layer/activity_metrics.py", data_layer_function="get_activity_detail_data", source_tables=["activity_log", "activity_session_metrics", "training_parameters"], semantic_contract=( - "Liefert bis zu 50 Einheiten (neueste zuerst) der letzten 14 Tage über " - "get_activity_detail_data: activity_log-Spalten plus " - "enrich_sessions_with_metrics (activity_session_metrics / Profil-EAV). " - "Formatter hängt nicht-leere EAV-Werte als „| EAV: key=value; …“ an." + "Layer 1: get_activity_detail_data lädt Sessions, enrich_sessions_with_metrics fügt " + "session_metrics hinzu — effektive Liste aus merge_column_backed_and_eav_metrics: nur " + "Parameter aus dem Attributschema (tcp/ttp), sortiert nach key. " + "Leseregel Kanon: activity_log-Spalte (source_field, Registry-Feld, Legacy-Spalte für " + "EAV-primäre Keys) schlägt EAV, wenn beide Werte liefern. " + "Layer 2a: Zeilen mit „| EAV: key=value; …“ nur für nicht-leere session_metrics; " + "die Menge der Keys ist admin-/profilabhängig, kein festes Prompt-Schema." ), business_meaning=( "Detaillierte Trainingshistorie für KI-Prompts, die Muster, Progressionen " @@ -167,8 +173,10 @@ def register_activity_group_1(): ), known_limitations=( "Keine Profil-Qualitätsfilterung in dieser Liste. Max. 20 Zeilen im Prompt-Output " - "(Hard-Limit Resolver). Doppelte Spalten (z.B. duration_min in Kopf und EAV) können " - "in EAV wiederholt erscheinen — KI kann dominante Spalte nutzen." + "(Hard-Limit Resolver). session_metrics kann leer sein (kein Typ, kein Profil, keine EAV-Zeilen). " + "Keys und Anzahl Metriken variieren je Instanz/Admin — nicht von festen Platzhaltern in anderen " + "Prompts ausgehen. Nur im effektiven Merge erscheinende Parameter; keine verwaisten EAV-Keys " + "außerhalb des Schemas." ), layer_1_decision="activity_metrics.get_activity_detail_data (+ enrich_sessions_with_metrics)", layer_2a_decision="get_activity_detail (Formatierung)", @@ -211,56 +219,47 @@ def register_activity_group_1(): trainingstyp_verteilung_metadata = PlaceholderMetadata( key="trainingstyp_verteilung", category="Aktivität", - description="Trainingstypen-Verteilung der letzten 14 Tage als JSON", + description="Verteilung nach training_category (14 Tage): Top 3 als kompakte Prozent-Textzeile", resolver_module="backend/placeholder_resolver.py", - resolver_function="_format_trainingstyp_verteilung", - data_layer_module=None, - data_layer_function=None, - source_tables=["activity_log", "training_types"], + resolver_function="get_trainingstyp_verteilung", + data_layer_module="backend/data_layer/activity_metrics.py", + data_layer_function="get_training_type_distribution_data", + source_tables=["activity_log"], semantic_contract=( - "Liefert eine JSON-Struktur mit der Verteilung der Trainingstypen über 14 Tage. " - "Für jeden Trainingstyp: Anzahl Einheiten, Gesamtdauer (Minuten), " - "Prozentanteil an Gesamtdauer. Sortiert nach Dauer absteigend." + "Layer 1: get_training_type_distribution_data — Anteil je training_category am " + "Gesamt-Session-Count im Fenster (auch unkategorisierte zählen im Nenner). " + "Layer 2a: Top 3 Kategorien als „Name: p%“ kommagetrennt; bei fehlenden Daten Kurz-Hinweis." ), business_meaning=( "Analyse-Placeholder für Trainingsvielfalt und -schwerpunkte. " "Erlaubt KI-Prompts, Imbalancen zu erkennen (z.B. nur Kraft, keine Ausdauer) " "oder Zielkonformität zu prüfen (z.B. 'zu wenig Mobilität')." ), - unit="json", + unit="text", time_window="14d", - output_type=OutputType.JSON, + output_type=OutputType.TEXT_SUMMARY, placeholder_type=PlaceholderType.INTERPRETED, - format_hint="JSON Object mit Trainingstyp als Key, Value: {count, duration_min, percentage}", - example_output=( - '{"Krafttraining": {"count": 5, "duration_min": 180, "percentage": 57}, ' - '"Ausdauer": {"count": 4, "duration_min": 90, "percentage": 29}, ' - '"Mobilität": {"count": 3, "duration_min": 45, "percentage": 14}}' - ), + format_hint="Eine Zeile: bis zu drei „Kategorie: Prozent%“, durch Komma getrennt", + example_output="cardio: 45%, strength: 30%, mobility: 15%", minimum_data_requirements=None, quality_filter_policy=None, - confidence_logic="Keine Confidence-Berechnung. Aggregation basiert auf verfügbaren Daten.", + confidence_logic="Wie get_training_type_distribution_data (calculate_confidence über categorized_count)", missing_value_policy=MissingValuePolicy( available=False, value_raw=None, missing_reason="no_data", - legacy_display="{}" + legacy_display="Keine kategorisierten Trainings" ), known_limitations=( - "OLD RESOLVER PATTERN: Keine Data Layer Funktion. " - "Aggregation direkt im Resolver. " - "CRITICAL: Keine Qualitätsfilterung - auch ungültige Einheiten werden aggregiert. " - "JOIN mit training_types für Typ-Namen. " - "EDGE CASE: Einheiten ohne training_type_id werden ignoriert (LEFT JOIN)." + "Nur Sessions mit gesetztem training_category fließen in die Verteilungsliste; " + "Prozente beziehen sich auf alle Sessions im Fenster (Nenner = total_sessions). " + "Keine Qualitätsfilterung der Einheiten. Kein drill-down nach training_type_id in diesem Platzhalter." ), - layer_1_decision="NONE - Old resolver pattern (direct SQL aggregation in resolver)", - layer_2a_decision="Placeholder Resolver (aggregation + JSON formatting)", + layer_1_decision="activity_metrics.get_training_type_distribution_data", + layer_2a_decision="get_trainingstyp_verteilung (Top 3 als Text)", layer_2b_reuse_possible=True, - architecture_alignment=( - "PARTIALLY ALIGNED: JSON output structure suitable for chart endpoints, " - "but no data layer separation. Should be refactored." - ), - issue_53_alignment="PARTIALLY ALIGNED - output format good, layer separation missing" + architecture_alignment="Phase 0c — Layer 1 + Formatierung", + issue_53_alignment="Layer 1" ) trainingstyp_verteilung_metadata.set_evidence("key", EvidenceType.CODE_DERIVED) diff --git a/backend/placeholder_registrations/activity_session_insights.py b/backend/placeholder_registrations/activity_session_insights.py index faef32a..0e49eb9 100644 --- a/backend/placeholder_registrations/activity_session_insights.py +++ b/backend/placeholder_registrations/activity_session_insights.py @@ -130,8 +130,8 @@ def register_activity_session_insights(): key="training_sessions_recent_json", category="Aktivität", description=( - "JSON: letzte ISO-Kalenderwochen mit Einheiten (Datum, Art, Dauer, kcal, HF Ø/max, RPE, Kategorie, " - "session_id, session_metrics[] aus EAV)" + "JSON: ISO-Wochen mit Sessions (activity_log-Kopf) plus session_metrics[] — gemergte Profil-Metriken " + "(dynamische Keys)" ), resolver_module="backend/placeholder_resolver.py", resolver_function="_safe_json", @@ -139,9 +139,16 @@ def register_activity_session_insights(): data_layer_function="get_training_sessions_recent_weeks_data", source_tables=["activity_log", "training_types", "activity_session_metrics", "training_parameters"], semantic_contract=( - "Struktur weeks[].week_iso, sessions[] mit Feldern für KI-Auswertung; " - "session_metrics[] = Layer-1-EAV-Werte (key, data_type, unit, value) wenn konfiguriert/gespeichert. " - "Default 4 ISO-Wochen zurück." + "Root: weeks[] mit week_iso; sessions[] pro Einheit u. a. id, date, activity_type, " + "duration_min, kcal_active, hr_avg, hr_max, rpe, training_category, training_type_name, " + "session_metrics[]. " + "session_metrics: effektive Liste nach merge_column_backed_and_eav_metrics — Einträge mit " + "training_parameter_id, key, data_type, unit, value, name_de/name_en, description_de/description_en; " + "nur Parameter aus Attributschema " + "(training_category_parameter + training_type_parameter Overrides), keys sortiert. " + "Kanon Lesen: activity_log-Spalte vor EAV bei Konflikt. " + "meta: weeks_requested, days_loaded, session_count, confidence. " + "Default ca. 4 ISO-Wochen (28 Tage Rohdatenfenster)." ), business_meaning="Rohkontext für wochenweise Auswertung (Erholung, Intensität) in der KI", unit="JSON string", @@ -160,8 +167,12 @@ def register_activity_session_insights(): legacy_display="{}", ), known_limitations=( - "Token-Länge bei vielen Sessions beachten. training_type_name nur bei gesetztem training_type_id. " - "session_metrics nur befüllt, wenn Admin-Profile zugeordnet und Werte in EAV gespeichert sind." + "Token-Länge bei vielen Sessions. training_type_name nur bei gesetztem training_type_id. " + "session_metrics oft [] (kein Typ, kein Profil, keine gespeicherten Werte). " + "Anzahl und Namen der Metrik-Keys sind instanz-/adminabhängig — JSON nicht als festes Schema " + "für Downstream-Parsing harter Logik verwenden. " + "Für KI-Semantik zusätzlich {{training_parameters_glossary_md}} (gesamter aktiver Katalog) in den Prompt legen. " + "Composite-Parameter (JSON in EAV) noch nicht im MVP expandiert; ggf. Roh-value_text in späterer Phase." ), layer_1_decision="activity_metrics.get_training_sessions_recent_weeks_data", layer_2a_decision="_safe_json('training_sessions_recent_json')", @@ -183,5 +194,61 @@ def register_activity_session_insights(): _ev(pj, "known_limitations", EvidenceType.MIXED) register_placeholder(pj) + md_gloss = PlaceholderMetadata( + key="training_parameters_glossary_md", + category="Aktivität", + description=( + "Markdown-Tabelle: alle aktiven training_parameters (key, DE/EN, Beschreibungen, Typ, Einheit, Kategorie). " + "Ergänzung zu training_sessions_recent_json für KI (Bedeutung dynamischer Metrik-Keys)." + ), + resolver_module="backend/placeholder_resolver.py", + resolver_function="get_training_parameters_glossary_md", + data_layer_module="backend/data_layer/activity_metrics.py", + data_layer_function="get_training_parameters_ki_glossary_data", + source_tables=["training_parameters"], + semantic_contract=( + "SELECT auf training_parameters WHERE is_active; sortiert category, key. " + "profile_id-Parameter im Resolver reserviert, aktuell globaler Katalog." + ), + business_meaning="KI: Legende zu session_metrics-Keys und Custom-Parametern", + unit="Markdown", + time_window="n/a (Katalog-Snapshot)", + output_type=OutputType.TEXT_SUMMARY, + placeholder_type=PlaceholderType.INTERPRETED, + format_hint="GitHub-Flavored Markdown-Tabelle", + example_output="| Feld (key) | DE | EN | Beschreibung DE | … |", + minimum_data_requirements="Optional leer → Kurztext statt Tabelle", + quality_filter_policy=None, + confidence_logic="Immer verfügbar wenn DB erreichbar", + missing_value_policy=MissingValuePolicy( + available=False, + value_raw=None, + missing_reason="no_data", + legacy_display="Keine aktiven Trainingsparameter im Katalog.", + ), + known_limitations=( + "Keine profil-spezifische Einschränkung auf tatsächlich genutzte Keys (V2). " + "Tabellen können bei großem Katalog lang werden." + ), + layer_1_decision="activity_metrics.get_training_parameters_ki_glossary_data", + layer_2a_decision="get_training_parameters_glossary_md", + layer_2b_reuse_possible=True, + architecture_alignment="Phase 0c", + issue_53_alignment="Layer 2a", + evidence={}, + ) + for f in ( + "key", "category", "description", "resolver_module", "resolver_function", + "data_layer_module", "data_layer_function", "source_tables", "semantic_contract", + "unit", "time_window", "output_type", "placeholder_type", "format_hint", + "example_output", "minimum_data_requirements", "confidence_logic", + "missing_value_policy", "layer_1_decision", "layer_2a_decision", + "layer_2b_reuse_possible", "architecture_alignment", "issue_53_alignment", + ): + _ev(md_gloss, f) + _ev(md_gloss, "business_meaning", EvidenceType.DRAFT_DERIVED) + _ev(md_gloss, "known_limitations", EvidenceType.MIXED) + register_placeholder(md_gloss) + register_activity_session_insights() diff --git a/backend/placeholder_resolver.py b/backend/placeholder_resolver.py index 7c97dab..6f635c2 100644 --- a/backend/placeholder_resolver.py +++ b/backend/placeholder_resolver.py @@ -35,6 +35,7 @@ from data_layer.activity_metrics import ( get_training_frequency_by_type_data, get_training_inter_session_gap_data, get_training_sessions_recent_weeks_data, + get_training_parameters_ki_glossary_data, ) from data_layer.recovery_metrics import ( get_sleep_duration_data, @@ -426,7 +427,8 @@ def get_activity_detail(profile_id: str, days: int = 14) -> str: k, v = m.get("key"), m.get("value") if k is None or v is None: continue - eav_parts.append(f"{k}={v}") + label = m.get("name_de") or m.get("name_en") or k + eav_parts.append(f"{label} ({k})={v}") eav_str = f" | EAV: {'; '.join(eav_parts)}" if eav_parts else "" lines.append( f"{activity['date']}: {activity['activity_type']} " @@ -456,6 +458,45 @@ def get_trainingstyp_verteilung(profile_id: str, days: int = 14) -> str: return ", ".join(parts) +def get_training_parameters_glossary_md(profile_id: str) -> str: + """ + Markdown-Tabelle: alle aktiven training_parameters (key, Namen, Beschreibungen, Typ, Einheit). + Für KI neben session_metrics / training_sessions_recent_json. + """ + data = get_training_parameters_ki_glossary_data(profile_id) + params = data.get("parameters") or [] + if not params: + return "Keine aktiven Trainingsparameter im Katalog." + + def cell(x: object) -> str: + if x is None: + return "—" + return str(x).replace("|", "·").replace("\n", " ").strip()[:400] + + lines = [ + "| Feld (key) | DE | EN | Beschreibung DE | Beschreibung EN | Typ | Einheit | Kategorie |", + "|---|---|---|---|---|---|---|---|", + ] + for p in params: + lines.append( + "| " + + " | ".join( + [ + cell(p.get("key")), + cell(p.get("name_de")), + cell(p.get("name_en")), + cell(p.get("description_de")), + cell(p.get("description_en")), + cell(p.get("data_type")), + cell(p.get("unit")), + cell(p.get("category")), + ] + ) + + " |" + ) + return "\n".join(lines) + + def get_training_frequency_by_type_md(profile_id: str, days: int = 28) -> str: """ Markdown-Tabelle: pro Trainingsart (Roh-Label) Ø Sessions/Woche, Dauer, kcal, HF, RPE, kcal/min. @@ -1524,7 +1565,7 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = { '{{intake_volatility}}': lambda pid: _safe_str('intake_volatility', pid), '{{nutrition_score}}': lambda pid: _safe_int('nutrition_score', pid), - # Training / Aktivität (17 Registry-Keys — gebündelt; activity_score hier, nicht unter Meta Scores) + # Training / Aktivität (20 Keys: 17 activity_metrics + 3 activity_session_insights; activity_score hier, nicht unter Meta Scores) '{{activity_summary}}': get_activity_summary, '{{activity_detail}}': get_activity_detail, '{{trainingstyp_verteilung}}': get_trainingstyp_verteilung, @@ -1545,6 +1586,7 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = { '{{training_frequency_by_type_md}}': get_training_frequency_by_type_md, '{{training_inter_session_gap_md}}': get_training_inter_session_gap_md, '{{training_sessions_recent_json}}': lambda pid: _safe_json('training_sessions_recent_json', pid), + '{{training_parameters_glossary_md}}': get_training_parameters_glossary_md, # Schlaf & Erholung (10 Registry-Keys; recovery_score hier, nicht unter Meta Scores) '{{sleep_avg_duration}}': lambda pid: get_sleep_avg_duration(pid, 7), @@ -1749,6 +1791,7 @@ def get_available_placeholders(categories: Optional[List[str]] = None) -> Dict[s '{{proxy_internal_load_7d}}', '{{monotony_score}}', '{{strain_score}}', '{{rest_day_compliance}}', '{{vo2max_trend_28d}}', '{{activity_score}}', '{{training_frequency_by_type_md}}', '{{training_inter_session_gap_md}}', '{{training_sessions_recent_json}}', + '{{training_parameters_glossary_md}}', ], 'schlaf': [ '{{sleep_avg_duration}}', '{{sleep_avg_quality}}', '{{rest_days_count}}', diff --git a/backend/routers/activity.py b/backend/routers/activity.py index 852fc8e..da3e3ef 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -32,6 +32,7 @@ from data_layer.activity_persistence_orchestrator import ( new_activity_id, ) from data_layer.activity_time_normalize import normalize_activity_start +from data_layer.activity_session_metrics import enrich_sessions_with_metrics router = APIRouter(prefix="/api/activity", tags=["activity"]) logger = logging.getLogger(__name__) @@ -71,6 +72,12 @@ def _activity_rows_after_list_query(cur): return rows +def _return_activity_list_rows(cur, rows: list) -> list: + """Layer-1: gemergte session_metrics wie Detail-Pfad (Batch).""" + enrich_sessions_with_metrics(cur, rows) + return rows + + # Evaluation import with error handling (Phase 1.2) try: from evaluation_helper import evaluate_and_save_activity @@ -140,7 +147,7 @@ def list_activity( """, (pid, d0, d1, limit), ) - return _activity_rows_after_list_query(cur) + return _return_activity_list_rows(cur, _activity_rows_after_list_query(cur)) cur.execute( f""" SELECT * FROM activity_log @@ -152,7 +159,9 @@ def list_activity( """, (pid, d0, d1, limit), ) - return [r2d(r) for r in cur.fetchall()] + return _return_activity_list_rows( + cur, [r2d(r) for r in cur.fetchall()] + ) if days is not None: if collapse_duplicate_sessions: @@ -173,7 +182,7 @@ def list_activity( """, (pid, days, limit, offset), ) - return _activity_rows_after_list_query(cur) + return _return_activity_list_rows(cur, _activity_rows_after_list_query(cur)) cur.execute( f""" SELECT * FROM activity_log @@ -203,7 +212,7 @@ def list_activity( """, (pid, limit, offset), ) - return _activity_rows_after_list_query(cur) + return _return_activity_list_rows(cur, _activity_rows_after_list_query(cur)) cur.execute( f""" SELECT * FROM activity_log @@ -214,7 +223,7 @@ def list_activity( """, (pid, limit, offset), ) - return [r2d(r) for r in cur.fetchall()] + return _return_activity_list_rows(cur, [r2d(r) for r in cur.fetchall()]) @router.post("") @@ -362,6 +371,25 @@ def get_activity_mappable_fields(session: dict = Depends(require_auth)): return get_mappable_activity_field_catalog(cur, pid) +@router.get("/attribute-schema") +def get_activity_attribute_schema( + training_category: Optional[str] = Query(None), + training_type_id: Optional[int] = Query(None), + session: dict = Depends(require_auth), +): + """ + Aufgelöstes Attributprofil (tcp/ttp) für Erfassung ohne bestehende Session — + gleiche Logik wie resolve_activity_attribute_schema. + """ + from data_layer.activity_session_metrics import resolve_activity_attribute_schema + + cat = (training_category or "").strip() or None + with get_db() as conn: + cur = get_cursor(conn) + schema = resolve_activity_attribute_schema(cur, cat, training_type_id) + return {"schema": schema} + + @router.put("/{eid}") def update_activity(eid: str, e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Update existing activity entry.""" @@ -413,6 +441,12 @@ def replace_activity_metrics( @router.get("/{eid}") def get_activity_session( eid: str, + use_form_schema: bool = Query( + False, + description="True: Schema aus Query training_category / training_type_id (Formular), nicht nur DB-Zeile", + ), + training_category: Optional[str] = Query(None), + training_type_id: Optional[int] = Query(None), session: dict = Depends(require_auth), ): """Session-Kopf + aufgelöstes Schema + EAV-Metriken (Layer 1).""" @@ -426,7 +460,14 @@ def get_activity_session( try: with get_db() as conn: cur = get_cursor(conn) - unit = get_activity_session_logical_unit(cur, pid, eid) + unit = get_activity_session_logical_unit( + cur, + pid, + eid, + use_form_training_context=use_form_schema, + form_training_category=training_category, + form_training_type_id=training_type_id, + ) except ActivitySessionMetricsError as err: raise HTTPException(err.status_code, err.detail) from err unit["header"] = serialize_dates(unit["header"]) diff --git a/backend/routers/exportdata.py b/backend/routers/exportdata.py index a0dd4b2..dc0fa53 100644 --- a/backend/routers/exportdata.py +++ b/backend/routers/exportdata.py @@ -22,6 +22,8 @@ from auth import require_auth, check_feature_access, increment_feature_usage from routers.profiles import get_pid from feature_logger import log_feature_usage from caliper_composition import enrich_caliper_row_for_response, load_weight_rows +from data_layer.activity_session_metrics import enrich_sessions_with_metrics +from data_layer.utils import serialize_dates router = APIRouter(prefix="/api/export", tags=["export"]) logger = logging.getLogger(__name__) @@ -90,10 +92,23 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D for r in cur.fetchall(): writer.writerow(["Ernährung", r['date'], f"{float(r['kcal'])}kcal", f"Protein:{float(r['protein_g'])}g"]) - # Activity - cur.execute("SELECT date, activity_type, duration_min, kcal_active FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,)) - for r in cur.fetchall(): - writer.writerow(["Training", r['date'], r['activity_type'], f"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal"]) + # Activity (Layer-1: gemergte session_metrics in Details) + cur.execute( + "SELECT id, date, activity_type, duration_min, kcal_active FROM activity_log WHERE profile_id=%s ORDER BY date", + (pid,), + ) + act_rows = [r2d(r) for r in cur.fetchall()] + enrich_sessions_with_metrics(cur, act_rows) + for r in act_rows: + base = f"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal" + eav_parts = [] + for m in r.get("session_metrics") or []: + k, v = m.get("key"), m.get("value") + if k is None or v is None: + continue + eav_parts.append(f"{k}={v}") + details = base + (" | " + "; ".join(eav_parts) if eav_parts else "") + writer.writerow(["Training", r["date"], r["activity_type"], details]) output.seek(0) @@ -148,7 +163,9 @@ def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict= data['nutrition'] = [r2d(r) for r in cur.fetchall()] cur.execute("SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,)) - data['activity'] = [r2d(r) for r in cur.fetchall()] + data["activity"] = [r2d(r) for r in cur.fetchall()] + enrich_sessions_with_metrics(cur, data["activity"]) + data["activity"] = serialize_dates(data["activity"]) cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,)) data['insights'] = [r2d(r) for r in cur.fetchall()] @@ -243,6 +260,9 @@ Dieser Export kann in Mitai Jinkendo unter Einstellungen → Import → "Mitai Backup importieren" wieder eingespielt werden. +activity.csv (optional): Spalte session_metrics_json (JSON-Array, Layer-1-merge) +wird beim Standard-Import ignoriert; für Vollständigkeit/externe Tools. + Format-Version 2 (ab v9b): Alle CSV-Dateien sind UTF-8 mit BOM kodiert. Trennzeichen: Semikolon (;) @@ -318,13 +338,41 @@ Datumsformat: YYYY-MM-DD r['fiber'] = None; r['note'] = '' write_csv(zf, "nutrition.csv", rows, ['id','date','meal_name','kcal','protein','fat','carbs','fiber','note','source','created']) - cur.execute("SELECT id, date, activity_type, duration_min, kcal_active, hr_avg, hr_max, distance_km, notes, source, created FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,)) + cur.execute( + "SELECT id, date, activity_type, duration_min, kcal_active, hr_avg, hr_max, distance_km, notes, source, created FROM activity_log WHERE profile_id=%s ORDER BY date", + (pid,), + ) rows = [r2d(r) for r in cur.fetchall()] + enrich_sessions_with_metrics(cur, rows) for r in rows: - r['name'] = r['activity_type']; r['type'] = r.pop('activity_type', None) - r['kcal'] = r.pop('kcal_active', None); r['heart_rate_avg'] = r.pop('hr_avg', None) - r['heart_rate_max'] = r.pop('hr_max', None); r['note'] = r.pop('notes', None) - write_csv(zf, "activity.csv", rows, ['id','date','name','type','duration_min','kcal','heart_rate_avg','heart_rate_max','distance_km','note','source','created']) + sm = r.pop("session_metrics", None) or [] + r["session_metrics_json"] = json.dumps(sm, ensure_ascii=False, default=str) + r["name"] = r["activity_type"] + r["type"] = r.pop("activity_type", None) + r["kcal"] = r.pop("kcal_active", None) + r["heart_rate_avg"] = r.pop("hr_avg", None) + r["heart_rate_max"] = r.pop("hr_max", None) + r["note"] = r.pop("notes", None) + write_csv( + zf, + "activity.csv", + rows, + [ + "id", + "date", + "name", + "type", + "duration_min", + "kcal", + "heart_rate_avg", + "heart_rate_max", + "distance_km", + "note", + "source", + "created", + "session_metrics_json", + ], + ) # 8. insights/ai_insights.json cur.execute("SELECT id, scope, content, created FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,)) diff --git a/backend/scripts/inspect_activity_eav.py b/backend/scripts/inspect_activity_eav.py new file mode 100644 index 0000000..529b242 --- /dev/null +++ b/backend/scripts/inspect_activity_eav.py @@ -0,0 +1,179 @@ +""" +Diagnose: Was liegt in activity_session_metrics (EAV) vs. activity_log? + +Ausführung (mit gesetzten DB_*-Variablen wie die App, z. B. aus .env): + + cd backend + python scripts/inspect_activity_eav.py + +Lokal ohne Docker-Hostname: z. B. ``set DB_HOST=127.0.0.1`` (Windows) / ``export DB_HOST=127.0.0.1``, +Port/User/Pass wie in der laufenden Postgres-Instanz. + +Im Backend-Container (Compose-Service meist ``backend``, Arbeitsverzeichnis ``/app``): + + docker compose exec backend python /app/scripts/inspect_activity_eav.py + +Optional: + python scripts/inspect_activity_eav.py --limit 30 + python scripts/inspect_activity_eav.py --profile + python scripts/inspect_activity_eav.py --activity + +Keine Schreibzugriffe — nur SELECT. +""" +from __future__ import annotations + +import argparse +import os +import sys + +# backend/ als Import-Root +_BACKEND_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if _BACKEND_ROOT not in sys.path: + sys.path.insert(0, _BACKEND_ROOT) + + +def _val_row(r: dict) -> str | None: + dt = r.get("data_type") + if dt == "integer": + v = r.get("value_int") + return str(v) if v is not None else None + if dt == "float": + v = r.get("value_num") + return str(v) if v is not None else None + if dt == "string": + v = r.get("value_text") + return repr(v) if v is not None else None + if dt == "boolean": + v = r.get("value_bool") + return str(v) if v is not None else None + return None + + +def main() -> None: + parser = argparse.ArgumentParser(description="EAV activity_session_metrics inspizieren") + parser.add_argument("--limit", type=int, default=40, help="Zeilen Report A/B") + parser.add_argument("--profile", type=str, default=None, help="profile_id filtern") + parser.add_argument("--activity", type=str, default=None, help="activity_log.id (einzelne Session)") + args = parser.parse_args() + + from db import get_db, get_cursor + + if args.activity: + with get_db() as conn: + with get_cursor(conn) as cur: + cur.execute( + """ + SELECT al.id, al.profile_id, al.date, al.start_time, al.source, + al.training_category, al.training_type_id, al.activity_type, + al.duration_min, al.kcal_active, al.hr_avg, al.hr_max, al.distance_km + FROM activity_log al + WHERE al.id = %s::uuid + """, + (args.activity,), + ) + h = cur.fetchone() + if not h: + print("activity_log: keine Zeile für diese id") + return + print("=== activity_log (Kopfzeile) ===") + for k, v in dict(h).items(): + print(f" {k}: {v}") + + cur.execute( + """ + SELECT m.id AS metric_id, tp.key, tp.data_type, tp.source_field, + m.value_num, m.value_int, m.value_text, m.value_bool, m.updated_at + FROM activity_session_metrics m + JOIN training_parameters tp ON tp.id = m.training_parameter_id + WHERE m.activity_log_id = %s::uuid + ORDER BY tp.key + """, + (args.activity,), + ) + rows = cur.fetchall() + print(f"\n=== activity_session_metrics ({len(rows)} Zeilen) ===") + for r in rows: + d = dict(r) + print( + f" {d['key']} ({d['data_type']}) " + f"value={_val_row(d)!r} source_field={d.get('source_field')!r} " + f"updated_at={d.get('updated_at')}" + ) + if not rows: + print(" (keine EAV-Zeilen)") + return + + prof_filter = "" + if args.profile: + prof_filter = " AND al.profile_id = %s::uuid " + params_a: tuple = (args.profile, args.limit) if args.profile else (args.limit,) + params_b: tuple = (args.profile, args.limit) if args.profile else (args.limit,) + + q_recent_eav = f""" + SELECT + al.id AS activity_id, + al.profile_id, + al.date, + al.start_time, + al.source, + al.training_type_id, + al.training_category, + tp.key AS parameter_key, + tp.data_type, + tp.source_field AS tp_source_field, + m.value_num, + m.value_int, + m.value_text, + m.value_bool, + m.updated_at + FROM activity_session_metrics m + JOIN activity_log al ON al.id = m.activity_log_id + JOIN training_parameters tp ON tp.id = m.training_parameter_id + WHERE 1=1 {prof_filter} + ORDER BY m.updated_at DESC NULLS LAST, al.date DESC, al.start_time DESC + LIMIT %s + """ + + q_csv_no_eav = f""" + SELECT + al.id AS activity_id, + al.profile_id, + al.date, + al.start_time, + al.source, + al.training_type_id, + al.training_category, + (SELECT COUNT(*) FROM activity_session_metrics m WHERE m.activity_log_id = al.id) AS eav_count + FROM activity_log al + WHERE al.source = 'csv' {prof_filter} + ORDER BY al.date DESC, al.start_time DESC + LIMIT %s + """ + + with get_db() as conn: + with get_cursor(conn) as cur: + print("=== A) Neueste EAV-Zeilen (join activity_log + training_parameters) ===\n") + cur.execute(q_recent_eav, params_a) + for r in cur.fetchall(): + d = dict(r) + v = _val_row(d) + print( + f"{d['date']} {d['start_time']} | {d['activity_id']} | src={d['source']!r} | " + f"type={d['training_type_id']} cat={d['training_category']!r} | " + f"{d['parameter_key']}={v!r} (tp.source_field={d.get('tp_source_field')!r})" + ) + + print("\n=== B) Neueste CSV-importierte Sessions: EAV-Anzahl pro Zeile ===\n") + cur.execute(q_csv_no_eav, params_b) + for r in cur.fetchall(): + d = dict(r) + print( + f"{d['date']} {d['start_time']} | {d['activity_id']} | " + f"type={d['training_type_id']} | eav_count={d['eav_count']}" + ) + + print("\nFertig. Für eine Session im Detail: --activity ") + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_activity_session_metrics.py b/backend/tests/test_activity_session_metrics.py index 930ec21..a2bc11a 100644 --- a/backend/tests/test_activity_session_metrics.py +++ b/backend/tests/test_activity_session_metrics.py @@ -1,14 +1,17 @@ """Unit tests for data_layer.activity_session_metrics (no DB for most cases).""" import uuid +from unittest.mock import patch import pytest from data_layer.activity_session_metrics import ( ActivitySessionMetricsError, enrich_sessions_with_metrics, + merge_column_backed_and_eav_metrics, merge_parameter_schema_rows, resolve_activity_attribute_schema, + upsert_session_metrics_from_csv_mapped, _row_value_tuple, _validate_single_value, ) @@ -94,6 +97,52 @@ def test_merge_type_overrides_required_and_sort(): assert merged[0]["required"] is True +def test_merge_parameter_schema_includes_descriptions(): + cat = [ + { + "training_parameter_id": 1, + "cat_sort": 0, + "cat_required": False, + "cat_ui_group": None, + "key": "custom_w", + "name_de": "Leistung", + "name_en": "Watts", + "description_de": "Mittlere 5-Min-Leistung", + "description_en": "5 min average power", + "param_category": "performance", + "data_type": "integer", + "unit": "W", + "validation_rules": {}, + "source_field": None, + } + ] + merged = merge_parameter_schema_rows(cat, []) + assert merged[0]["description_de"] == "Mittlere 5-Min-Leistung" + assert merged[0]["description_en"] == "5 min average power" + + +def test_merge_column_backed_includes_human_labels_from_schema(): + schema = [ + { + "training_parameter_id": 1, + "key": "watts", + "data_type": "integer", + "unit": "W", + "validation_rules": {}, + "source_field": "avg_power", + "name_de": "Leistung", + "name_en": "Power", + "description_de": "Gerätewert", + "description_en": "Device reading", + } + ] + out = merge_column_backed_and_eav_metrics({"avg_power": 200}, schema, []) + assert len(out) == 1 + assert out[0]["value"] == 200 + assert out[0]["name_de"] == "Leistung" + assert out[0]["description_en"] == "Device reading" + + def test_merge_type_adds_parameter_not_in_category(): typ = [_ttp_row(7, "cadence", typ_sort=1, typ_required=True, data_type="integer")] merged = merge_parameter_schema_rows([], typ) @@ -171,22 +220,39 @@ def test_resolve_loads_category_from_training_type_id(): assert cur.executes[0][1] == (42,) -def test_enrich_sessions_batch(): +@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema", return_value=[]) +def test_enrich_sessions_batch(mock_resolve): aid = str(uuid.uuid4()) bid = str(uuid.uuid4()) class _Cur: def __init__(self): self.params = None + self._fetch_n = 0 def execute(self, sql, params=None): self.sql = sql self.params = params def fetchall(self): + self._fetch_n += 1 + if self._fetch_n == 1: + return [ + { + "id": uuid.UUID(aid), + "training_category": None, + "training_type_id": None, + }, + { + "id": uuid.UUID(bid), + "training_category": None, + "training_type_id": None, + }, + ] return [ { "activity_log_id": uuid.UUID(aid), + "training_parameter_id": 3, "key": "rpe", "data_type": "integer", "unit": None, @@ -199,6 +265,280 @@ def test_enrich_sessions_batch(): sessions = [{"id": aid}, {"id": bid}] enrich_sessions_with_metrics(_Cur(), sessions) - assert sessions[0]["session_metrics"][0]["value"] == 7 - assert sessions[0]["session_metrics"][0]["key"] == "rpe" + assert sessions[0]["session_metrics"] == [] assert sessions[1]["session_metrics"] == [] + + +def test_merge_column_backed_prefers_column_over_stale_eav(): + schema = [ + { + "training_parameter_id": 1, + "key": "hr_avg", + "data_type": "float", + "unit": "bpm", + "validation_rules": {}, + "source_field": "hr_avg", + } + ] + eav = [ + { + "training_parameter_id": 1, + "key": "hr_avg", + "data_type": "float", + "unit": "bpm", + "value": 99.0, + } + ] + out = merge_column_backed_and_eav_metrics({"hr_avg": 140.0}, schema, eav) + assert len(out) == 1 + assert out[0]["value"] == 140.0 + + +def test_merge_falls_back_to_eav_when_column_empty(): + schema = [ + { + "training_parameter_id": 1, + "key": "hr_avg", + "data_type": "float", + "unit": "bpm", + "validation_rules": {}, + "source_field": "hr_avg", + } + ] + eav = [ + { + "training_parameter_id": 1, + "key": "hr_avg", + "data_type": "float", + "unit": "bpm", + "value": 99.0, + } + ] + out = merge_column_backed_and_eav_metrics({"hr_avg": None}, schema, eav) + assert len(out) == 1 + assert out[0]["value"] == 99.0 + + +def test_merge_ignores_eav_when_parameter_not_in_schema(): + """Nur tcp/ttp-Schema zählt: verwaiste EAV-Zeilen erscheinen nicht in der effektiven Liste.""" + schema = [] + eav = [ + { + "training_parameter_id": 2, + "key": "custom_param", + "data_type": "string", + "unit": None, + "value": "x", + } + ] + out = merge_column_backed_and_eav_metrics({}, schema, eav) + assert out == [] + + +def test_merge_eav_primary_falls_back_to_legacy_hr_min_column(): + """Kanon: min_hr ohne source_field / ohne EAV — Lesefallback Spalte hr_min.""" + schema = [ + { + "training_parameter_id": 9, + "key": "min_hr", + "data_type": "integer", + "unit": "bpm", + "validation_rules": {}, + "source_field": None, + } + ] + out = merge_column_backed_and_eav_metrics({"hr_min": 88}, schema, []) + assert len(out) == 1 + assert out[0]["key"] == "min_hr" + assert out[0]["value"] == 88 + + +def test_merge_eav_primary_prefers_legacy_column_over_eav_when_both(): + """Kanon: bei min_hr + hr_min und EAV-Zeile gewinnt activity_log (hr_min).""" + schema = [ + { + "training_parameter_id": 9, + "key": "min_hr", + "data_type": "integer", + "unit": "bpm", + "validation_rules": {}, + "source_field": None, + } + ] + eav = [ + { + "training_parameter_id": 9, + "key": "min_hr", + "data_type": "integer", + "unit": "bpm", + "value": 100, + } + ] + out = merge_column_backed_and_eav_metrics({"hr_min": 88}, schema, eav) + assert len(out) == 1 + assert out[0]["value"] == 88 + + +def test_merge_registry_key_prefers_activity_log_column_over_eav(): + """Parameter-Key = Registry-Feld (z. B. duration_min): Spalte vor EAV.""" + schema = [ + { + "training_parameter_id": 3, + "key": "duration_min", + "data_type": "float", + "unit": "min", + "validation_rules": {}, + "source_field": None, + } + ] + eav = [ + { + "training_parameter_id": 3, + "key": "duration_min", + "data_type": "float", + "unit": "min", + "value": 99.0, + } + ] + out = merge_column_backed_and_eav_metrics({"duration_min": 45.0}, schema, eav) + assert len(out) == 1 + assert out[0]["value"] == 45.0 + + +@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema") +def test_upsert_csv_skips_eav_when_source_field_maps_activity_log(mock_schema): + """Parameter mit source_field: kanonisch activity_log — kein doppeltes EAV (z. B. avg_hr → hr_avg).""" + mock_schema.return_value = [ + { + "key": "avg_hr", + "training_parameter_id": 42, + "data_type": "integer", + "validation_rules": {"min": 30, "max": 220}, + "source_field": "hr_avg", + } + ] + + class Cur: + def __init__(self): + self.asm_inserts = 0 + + def execute(self, sql, params=None): + if "INSERT INTO activity_session_metrics" in sql: + self.asm_inserts += 1 + + def fetchone(self): + return { + "profile_id": "00000000-0000-0000-0000-000000000001", + "hr_avg": 130, + } + + cur = Cur() + upsert_session_metrics_from_csv_mapped( + cur, + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + {"avg_hr": 130}, + "cardio", + 1, + ) + assert cur.asm_inserts == 0 + + +@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema") +def test_upsert_csv_writes_eav_when_source_field_but_column_empty(mock_schema): + """source_field gesetzt, activity_log-Spalte leer — Wert aus mapped nur in EAV (kein Registry-Patch).""" + mock_schema.return_value = [ + { + "key": "avg_hr", + "training_parameter_id": 42, + "data_type": "integer", + "validation_rules": {"min": 30, "max": 220}, + "source_field": "hr_avg", + } + ] + + class Cur: + def __init__(self): + self.asm_inserts = 0 + + def execute(self, sql, params=None): + if "INSERT INTO activity_session_metrics" in sql: + self.asm_inserts += 1 + + def fetchone(self): + return { + "profile_id": "00000000-0000-0000-0000-000000000001", + "hr_avg": None, + } + + cur = Cur() + upsert_session_metrics_from_csv_mapped( + cur, + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + {"avg_hr": 130}, + "cardio", + 1, + ) + assert cur.asm_inserts == 1 + + +@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema") +def test_upsert_csv_writes_eav_when_no_source_field(mock_schema): + mock_schema.return_value = [ + { + "key": "custom_note", + "training_parameter_id": 99, + "data_type": "string", + "validation_rules": {}, + "source_field": None, + } + ] + + class Cur: + def __init__(self): + self.asm_inserts = 0 + + def execute(self, sql, params=None): + if "INSERT INTO activity_session_metrics" in sql: + self.asm_inserts += 1 + + def fetchone(self): + return {"profile_id": "00000000-0000-0000-0000-000000000001", "hr_avg": None} + + cur = Cur() + upsert_session_metrics_from_csv_mapped( + cur, + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + {"custom_note": "x"}, + "cardio", + 1, + ) + assert cur.asm_inserts == 1 + + +@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema", return_value=[]) +def test_upsert_csv_skips_eav_when_mapped_key_not_in_profile_schema(mock_resolve): + """Import-Mapping allein legt kein EAV an — Key muss in tcp/ttp (resolve) vorkommen.""" + class Cur: + def __init__(self): + self.asm_inserts = 0 + + def execute(self, sql, params=None): + if "INSERT INTO activity_session_metrics" in sql: + self.asm_inserts += 1 + + def fetchone(self): + return {"profile_id": "00000000-0000-0000-0000-000000000001", "hr_avg": None} + + cur = Cur() + upsert_session_metrics_from_csv_mapped( + cur, + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + {"stola": 12}, + "cardio", + 1, + ) + assert cur.asm_inserts == 0 diff --git a/frontend/src/app.css b/frontend/src/app.css index a237ef6..899e532 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -440,6 +440,141 @@ a.analysis-split__nav-item { } } +/* Admin: Session-Metriken / Attributprofile — volle Breite, linksbündig (nicht globale 90px-Zahlfelder) */ +.activity-attribute-profiles .aaf-stack { + max-width: 42rem; +} +.activity-attribute-profiles .aaf-field { + margin-bottom: 1rem; +} +.activity-attribute-profiles .aaf-label { + display: block; + font-size: 14px; + font-weight: 600; + color: var(--text1); + text-align: left; + margin-bottom: 6px; + line-height: 1.35; +} +.activity-attribute-profiles .aaf-sublabel { + display: block; + font-size: 12px; + font-weight: 600; + color: var(--text2); + text-align: left; + margin-bottom: 4px; +} +.activity-attribute-profiles .aaf-hint { + font-size: 12px; + color: var(--text3); + text-align: left; + margin: 6px 0 0; + line-height: 1.45; +} +.activity-attribute-profiles .aaf-input, +.activity-attribute-profiles textarea.aaf-input { + display: block; + width: 100%; + box-sizing: border-box; + padding: 10px 12px; + text-align: left; + font-family: var(--font); + font-size: 15px; + font-weight: 500; + color: var(--text1); + background: var(--surface2); + border: 1.5px solid var(--border2); + border-radius: 8px; + transition: border-color 0.15s; +} +.activity-attribute-profiles textarea.aaf-input { + resize: vertical; + min-height: 4.5rem; + font-weight: 400; +} +.activity-attribute-profiles .aaf-input:focus { + outline: none; + border-color: var(--accent); +} +.activity-attribute-profiles .aaf-split { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} +@media (max-width: 560px) { + .activity-attribute-profiles .aaf-split { + grid-template-columns: 1fr; + } +} +.activity-attribute-profiles .aaf-field-select { + padding: 12px 0; + border-bottom: 1px solid var(--border); +} +.activity-attribute-profiles .aaf-field-select:last-child { + border-bottom: none; +} +.activity-attribute-profiles .aaf-field-select .form-label { + display: block; + text-align: left; + margin-bottom: 6px; + font-weight: 600; + flex: unset; +} +.activity-attribute-profiles .aaf-field-select .form-input, +.activity-attribute-profiles .aaf-field-select select.form-input { + width: 100%; + max-width: none; + min-width: 0; + text-align: left; + box-sizing: border-box; + padding: 10px 12px; +} +.activity-attribute-profiles .aaf-toolbar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--border); +} +.activity-attribute-profiles .aaf-toolbar .form-label { + display: block; + text-align: left; + margin-bottom: 6px; + font-weight: 600; + flex: unset; +} +.activity-attribute-profiles .aaf-toolbar__grow { + flex: 1 1 240px; + min-width: 0; +} +.activity-attribute-profiles .aaf-toolbar .form-input, +.activity-attribute-profiles .aaf-toolbar select.form-input { + width: 100%; + min-width: 140px; + max-width: none; + text-align: left; + box-sizing: border-box; + padding: 10px 12px; +} +.activity-attribute-profiles .aaf-toolbar__compact { + flex: 0 0 auto; +} +.activity-attribute-profiles .aaf-toolbar__compact .form-input, +.activity-attribute-profiles .aaf-toolbar__compact select.form-input { + width: 100%; + min-width: 5rem; +} +.activity-attribute-profiles .aaf-inline-edit .form-input, +.activity-attribute-profiles .aaf-inline-edit select.form-input { + text-align: left; + min-width: 4.5rem; + width: auto; + max-width: none; + box-sizing: border-box; + padding: 8px 10px; +} + /* Erfassung: Sub-Navigation (Mobil = Chips, Desktop = linke Spalte) */ .capture-shell { width: 100%; diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx index 50ea792..45a872c 100644 --- a/frontend/src/pages/ActivityPage.jsx +++ b/frontend/src/pages/ActivityPage.jsx @@ -96,6 +96,77 @@ const ACTIVITY_LOG_PAYLOAD_KEYS = new Set([ 'training_subcategory', ]) +/** activity_log-Spalten, die bereits in EntryForm (Kopfzeile) bearbeitet werden — Profilfeld mit gleichem source_field nicht doppelt anzeigen. */ +const ENTRY_FORM_ACTIVITY_LOG_COLUMNS = new Set([ + 'duration_min', + 'kcal_active', + 'hr_avg', + 'hr_max', + 'rpe', + 'notes', +]) + +/** + * Bindung Profilparameter ↔ Kopfzeile: Entweder source_field zeigt auf eine Kopfspalte, + * oder der Parameter-key ist selbst eine Kopfspalte (häufig nach Migration / ohne source_field). + * @returns {{ headlineCol: string, parameterKey: string } | null} + */ +function activitySchemaHeadlineBinding(s) { + if (!s || !s.key) return null + const sf = s.source_field != null ? String(s.source_field).trim() : '' + if (sf && ENTRY_FORM_ACTIVITY_LOG_COLUMNS.has(sf)) { + return { headlineCol: sf, parameterKey: s.key } + } + if (ENTRY_FORM_ACTIVITY_LOG_COLUMNS.has(s.key)) { + return { headlineCol: s.key, parameterKey: s.key } + } + return null +} + +/** training_parameters.category (siehe Migration 013); feste Reihenfolge der Wertegruppen */ +const TRAINING_PARAM_CATEGORY_ORDER = [ + 'physical', + 'physiological', + 'performance', + 'subjective', + 'environmental', +] + +const TRAINING_PARAM_CATEGORY_LABEL_DE = { + physical: 'Physisch / Bewegung', + physiological: 'Physiologie', + performance: 'Leistung', + subjective: 'Subjektiv und Wahrnehmung', + environmental: 'Umwelt', +} + +function compareActivityProfileSchemaRows(a, b) { + const ca = (a.param_category && String(a.param_category).trim().toLowerCase()) || '' + const cb = (b.param_category && String(b.param_category).trim().toLowerCase()) || '' + const ia = TRAINING_PARAM_CATEGORY_ORDER.indexOf(ca) + const ib = TRAINING_PARAM_CATEGORY_ORDER.indexOf(cb) + const ra = ia === -1 ? 1000 : ia + const rb = ib === -1 ? 1000 : ib + if (ra !== rb) return ra - rb + if (ca !== cb) return ca.localeCompare(cb, 'de') + + const ga = (a.ui_group && String(a.ui_group).trim()) || '' + const gb = (b.ui_group && String(b.ui_group).trim()) || '' + if (ga !== gb) { + if (!ga) return -1 + if (!gb) return 1 + return ga.localeCompare(gb, 'de') + } + const sa = Number(a.sort_order) || 0 + const sb = Number(b.sort_order) || 0 + if (sa !== sb) return sa - sb + return String(a.key).localeCompare(String(b.key), 'de') +} + +function sortActivityProfileSchemaRows(rows) { + return [...rows].sort(compareActivityProfileSchemaRows) +} + function empty() { return { date: dayjs().format('YYYY-MM-DD'), @@ -146,48 +217,110 @@ function buildMetricsPayload(schema, draft) { function SessionMetricsFields({ schema, values, setValues, metrics }) { const schemaList = Array.isArray(schema) ? schema : [] + const headlineDuplicateKeys = new Set( + schemaList.filter((s) => activitySchemaHeadlineBinding(s) != null).map((s) => s.key), + ) + const schemaForDisplay = schemaList.filter((s) => activitySchemaHeadlineBinding(s) == null) const metricRows = Array.isArray(metrics) ? metrics : [] - const schemaKeys = new Set(schemaList.map((s) => s.key)) - const orphanMetrics = metricRows.filter((row) => row && row.key && !schemaKeys.has(row.key)) + const schemaKeys = new Set(schemaForDisplay.map((s) => s.key)) + const orphanMetrics = metricRows.filter( + (row) => + row && + row.key && + !schemaKeys.has(row.key) && + !headlineDuplicateKeys.has(row.key), + ) - if (schemaList.length === 0 && orphanMetrics.length === 0) return null + if (schemaForDisplay.length === 0 && orphanMetrics.length === 0) return null const set = (k, v) => setValues((prev) => ({ ...prev, [k]: v })) + + const sortedForDisplay = sortActivityProfileSchemaRows(schemaForDisplay) + const profileFieldNodes = [] + let lastCategoryKey = null + let lastUiGroup = null + for (const s of sortedForDisplay) { + const catRaw = (s.param_category && String(s.param_category).trim().toLowerCase()) || '' + const catKey = catRaw || '_other' + if (catKey !== lastCategoryKey) { + lastCategoryKey = catKey + lastUiGroup = null + const catTitle = + (catRaw && TRAINING_PARAM_CATEGORY_LABEL_DE[catRaw]) || s.param_category || 'Sonstige' + profileFieldNodes.push( +
+ {catTitle} +
, + ) + } + const ug = (s.ui_group && String(s.ui_group).trim()) || '' + if (ug) { + if (ug !== lastUiGroup) { + lastUiGroup = ug + profileFieldNodes.push( +
+ {ug} +
, + ) + } + } else { + lastUiGroup = null + } + profileFieldNodes.push( +
+ + {s.data_type === 'boolean' ? ( + set(s.key, e.target.checked)} + /> + ) : s.data_type === 'integer' || s.data_type === 'float' ? ( + set(s.key, e.target.value)} + /> + ) : ( + set(s.key, e.target.value)} + /> + )} + +
, + ) + } + + const orphansSorted = [...orphanMetrics].sort((a, b) => + String(a.key).localeCompare(String(b.key), 'de'), + ) + return (
Weitere Kennwerte (Profil)
- {schemaList.map((s) => ( -
- - {s.data_type === 'boolean' ? ( - set(s.key, e.target.checked)} - /> - ) : s.data_type === 'integer' || s.data_type === 'float' ? ( - set(s.key, e.target.value)} - /> - ) : ( - set(s.key, e.target.value)} - /> - )} - -
- ))} + {profileFieldNodes} {orphanMetrics.length > 0 && (
@@ -195,7 +328,7 @@ function SessionMetricsFields({ schema, values, setValues, metrics }) { in activity_log) nicht ins Schema passen — nur Anzeige. Sichtbar nach erneutem Laden, wenn die Daten in der Datenbank stehen.
- {orphanMetrics.map((row) => { + {orphansSorted.map((row) => { const disp = values[row.key] === null || values[row.key] === undefined || values[row.key] === '' ? '—' @@ -421,6 +554,12 @@ export default function ActivityPage() { const [categories, setCategories] = useState({}) // v9d: Training categories const [sessionDetail, setSessionDetail] = useState(null) const [metricDraft, setMetricDraft] = useState({}) + /** Beim Wechsel Kategorie/Typ: Nutzerwerte für weiterhin vorhandene Schema-Keys nicht mit Server überschreiben */ + const editSchemaKeysPrevRef = useRef(new Set()) + const prevEditingIdRef = useRef(null) + const [manualSchema, setManualSchema] = useState(null) + const [manualMetricDraft, setManualMetricDraft] = useState({}) + const manualSchemaKeysPrevRef = useRef(new Set()) const [sessionLoadError, setSessionLoadError] = useState(null) const [savingEdit, setSavingEdit] = useState(false) const [listLoadingMore, setListLoadingMore] = useState(false) @@ -506,18 +645,31 @@ export default function ActivityPage() { api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err)) }, [fetchMonthsChain]) + useEffect(() => { + editSchemaKeysPrevRef.current = new Set() + }, [editing?.id]) + useEffect(() => { if (!editing?.id) { setSessionDetail(null) setMetricDraft({}) setSessionLoadError(null) + prevEditingIdRef.current = null return } let cancelled = false setSessionLoadError(null) + if (prevEditingIdRef.current !== editing.id) { + setSessionDetail(null) + prevEditingIdRef.current = editing.id + } ;(async () => { try { - const d = await api.getActivitySession(editing.id) + const d = await api.getActivitySession(editing.id, { + useFormSchema: true, + training_category: editing.training_category, + training_type_id: editing.training_type_id, + }) if (!cancelled) setSessionDetail(d) } catch (err) { if (!cancelled) { @@ -527,25 +679,89 @@ export default function ActivityPage() { } })() return () => { cancelled = true } - }, [editing?.id]) + }, [editing?.id, editing?.training_category, editing?.training_type_id]) useEffect(() => { if (!sessionDetail) { setMetricDraft({}) return } - const m = {} - for (const row of sessionDetail.metrics || []) { - m[row.key] = row.value - } - for (const s of sessionDetail.schema || []) { - if (!(s.key in m)) { - m[s.key] = s.data_type === 'boolean' ? false : '' + const newKeys = new Set((sessionDetail.schema || []).map((s) => s.key)) + const oldKeys = editSchemaKeysPrevRef.current + + setMetricDraft((prev) => { + const next = { ...prev } + for (const row of sessionDetail.metrics || []) { + const k = row.key + if (oldKeys.size > 0 && oldKeys.has(k) && newKeys.has(k) && k in prev) { + continue + } + next[k] = row.value } - } - setMetricDraft(m) + for (const s of sessionDetail.schema || []) { + if (!(s.key in next)) { + next[s.key] = s.data_type === 'boolean' ? false : '' + } + } + return next + }) + editSchemaKeysPrevRef.current = newKeys }, [sessionDetail]) + useEffect(() => { + if (tab !== 'add') { + setManualSchema(null) + setManualMetricDraft({}) + manualSchemaKeysPrevRef.current = new Set() + return + } + const tid = form.training_type_id + const cat = form.training_category + if (tid == null && (cat == null || cat === '')) { + setManualSchema(null) + setManualMetricDraft({}) + manualSchemaKeysPrevRef.current = new Set() + return + } + let cancelled = false + ;(async () => { + try { + const r = await api.getActivityAttributeSchema({ + training_category: cat || undefined, + training_type_id: tid ?? undefined, + }) + if (!cancelled) setManualSchema(Array.isArray(r.schema) ? r.schema : []) + } catch (err) { + console.error('attribute-schema:', err) + if (!cancelled) setManualSchema([]) + } + })() + return () => { cancelled = true } + }, [tab, form.training_category, form.training_type_id]) + + useEffect(() => { + if (tab !== 'add' || !manualSchema) { + return + } + const newKeys = new Set(manualSchema.map((s) => s.key)) + const oldKeys = manualSchemaKeysPrevRef.current + + setManualMetricDraft((prev) => { + const next = { ...prev } + for (const s of manualSchema) { + const k = s.key + if (oldKeys.size > 0 && oldKeys.has(k) && newKeys.has(k) && k in prev) { + continue + } + if (!(k in next)) { + next[k] = s.data_type === 'boolean' ? false : '' + } + } + return next + }) + manualSchemaKeysPrevRef.current = newKeys + }, [tab, manualSchema]) + const handleSave = async () => { setSaving(true) setError(null) @@ -565,7 +781,20 @@ export default function ActivityPage() { if(payload.hr_max) payload.hr_max = parseFloat(payload.hr_max) if(payload.rpe) payload.rpe = parseInt(payload.rpe) payload.source = 'manual' - await api.createActivity(payload) + const created = await api.createActivity(payload) + if (manualSchema && manualSchema.length > 0 && created?.id) { + try { + const metrics = buildMetricsPayload(manualSchema, manualMetricDraft) + await api.putActivityMetrics(created.id, { metrics }) + } catch (metErr) { + console.error(metErr) + setError( + metErr.message || + 'Eintrag gespeichert, aber Zusatzfelder konnten nicht gespeichert werden.', + ) + setTimeout(() => setError(null), 8000) + } + } setSaved(true) await load() await loadUsage() // Reload usage after save @@ -624,7 +853,26 @@ export default function ActivityPage() { : timePayloadFromInput(payload.end_time) await api.updateActivity(editing.id, payload) if (sessionDetail?.schema?.length > 0) { - const metrics = buildMetricsPayload(sessionDetail.schema, metricDraft) + const draftForMetrics = { ...metricDraft } + for (const s of sessionDetail.schema) { + const bind = activitySchemaHeadlineBinding(s) + if (!bind || !(s.key in draftForMetrics)) continue + const rawCol = + payload[bind.headlineCol] !== undefined ? payload[bind.headlineCol] : editing?.[bind.headlineCol] + if (rawCol === undefined) continue + if (s.data_type === 'boolean') { + draftForMetrics[s.key] = !!rawCol + } else if (s.data_type === 'integer') { + const n = parseInt(String(rawCol), 10) + draftForMetrics[s.key] = Number.isNaN(n) ? '' : n + } else if (s.data_type === 'float') { + const n = parseFloat(String(rawCol)) + draftForMetrics[s.key] = Number.isNaN(n) ? '' : n + } else { + draftForMetrics[s.key] = rawCol == null ? '' : String(rawCol) + } + } + const metrics = buildMetricsPayload(sessionDetail.schema, draftForMetrics) await api.putActivityMetrics(editing.id, { metrics }) } setEditing(null) @@ -712,9 +960,23 @@ export default function ActivityPage() { Training eintragen {activityUsage && }
- + + } + />
)} diff --git a/frontend/src/pages/AdminActivityAttributeProfilesPage.jsx b/frontend/src/pages/AdminActivityAttributeProfilesPage.jsx index f0e21cd..da5f762 100644 --- a/frontend/src/pages/AdminActivityAttributeProfilesPage.jsx +++ b/frontend/src/pages/AdminActivityAttributeProfilesPage.jsx @@ -10,6 +10,8 @@ const emptyParamForm = () => ({ key: '', name_de: '', name_en: '', + description_de: '', + description_en: '', category: 'physical', data_type: 'float', unit: '', @@ -130,6 +132,8 @@ export default function AdminActivityAttributeProfilesPage() { key: paramForm.key.trim().toLowerCase(), name_de: paramForm.name_de.trim(), name_en: paramForm.name_en.trim(), + description_de: paramForm.description_de.trim() || null, + description_en: paramForm.description_en.trim() || null, category: paramForm.category, data_type: paramForm.data_type, unit: paramForm.unit.trim() || null, @@ -153,6 +157,8 @@ export default function AdminActivityAttributeProfilesPage() { await api.adminUpdateTrainingParameter(editParam.id, { name_de: editParam.name_de.trim(), name_en: editParam.name_en.trim(), + description_de: editParam.description_de?.trim() || null, + description_en: editParam.description_en?.trim() || null, category: editParam.category, data_type: editParam.data_type, unit: editParam.unit?.trim() || null, @@ -273,7 +279,7 @@ export default function AdminActivityAttributeProfilesPage() { } return ( -
+
← Training (Hub) @@ -302,6 +308,11 @@ export default function AdminActivityAttributeProfilesPage() { Nach Migration 055 werden Standard-Parameter allen Kategorien zugeordnet und vorhandene{' '} activity_log-Spalten idempotent nach EAV gespiegelt (sofern noch keine EAV-Zeile existiert). +
  • + KI: Bei eigenen / unklaren Metriken kurze Beschreibung DE/EN im Katalog + pflegen — sie erscheinen in Export/Platzhalter-Kontext (training_sessions_recent_json,{' '} + {'{{training_parameters_glossary_md}}'}). +
  • @@ -367,70 +378,151 @@ export default function AdminActivityAttributeProfilesPage() { style={{ border: '1px solid var(--border)', borderRadius: 8, - padding: 12, + padding: 16, marginBottom: 12, background: 'var(--surface2)', }} > -
    - - setParamForm((f) => ({ ...f, key: e.target.value }))} - /> -
    -
    - - setParamForm((f) => ({ ...f, name_de: e.target.value }))} - /> - setParamForm((f) => ({ ...f, name_en: e.target.value }))} - /> -
    -
    - - - -
    -
    - - setParamForm((f) => ({ ...f, unit: e.target.value }))} - /> - setParamForm((f) => ({ ...f, source_field: e.target.value }))} - /> +
    +
    + + setParamForm((f) => ({ ...f, key: e.target.value }))} + /> +
    +
    + Bezeichnung +
    +
    + + setParamForm((f) => ({ ...f, name_de: e.target.value }))} + /> +
    +
    + + setParamForm((f) => ({ ...f, name_en: e.target.value }))} + /> +
    +
    +
    +
    + Beschreibung (optional, für KI / Export) +
    +
    + +