From 43d14552f1883d3294df6d2f8deb3ae8a1e02940 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 1 Sep 2026 16:27:43 +0200 Subject: [PATCH] UI --- plugins/trainingsplan/static/index.html | 68 +- plugins/trainingstracker/README.md | 103 +- plugins/trainingstracker/analysis_store.py | 24 - plugins/trainingstracker/app.py | 3386 +---------------- plugins/trainingstracker/backend.py | 10 +- plugins/trainingstracker/contract.py | 11 +- plugins/trainingstracker/plugin.json | 34 +- plugins/trainingstracker/session_store.py | 28 - plugins/trainingstracker/static/index.html | 1635 ++------ plugins/trainingstracker/tests/test_app.py | 621 +-- .../trainingstracker/tests/test_stability.py | 52 +- 11 files changed, 640 insertions(+), 5332 deletions(-) delete mode 100644 plugins/trainingstracker/analysis_store.py delete mode 100644 plugins/trainingstracker/session_store.py diff --git a/plugins/trainingsplan/static/index.html b/plugins/trainingsplan/static/index.html index e4353d2..9c6ece2 100644 --- a/plugins/trainingsplan/static/index.html +++ b/plugins/trainingsplan/static/index.html @@ -21,13 +21,50 @@ .tp-exercise-row { display: grid; grid-template-columns: minmax(220px, 1.2fr) minmax(180px, 1fr) auto; gap: 8px; align-items: end; padding: 9px 0; border-bottom: 1px solid var(--bt-line-soft); } .tp-exercise-row:last-child { border-bottom: none; } .tp-exercise-actions { display: flex; gap: 5px; justify-content: flex-end; } - .tp-library { display: grid; gap: 12px; } - .tp-library-row { border-bottom: 1px solid var(--bt-line); padding: 14px 16px; } + .tp-field { margin: 0; } + .tp-compact-input, .tp-library input, .tp-library select { + min-height: 34px; + padding: 6px 9px; + border-radius: var(--bt-r-sm); + font-size: 12.5px; + line-height: 1.3; + } + .tp-library { display: grid; gap: 0; } + .tp-library-row { border-bottom: 1px solid var(--bt-line); padding: 0; } .tp-library-row:last-child { border-bottom: none; } - .tp-library-main { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) auto; gap: 8px; align-items: end; } - .tp-progressions { display: grid; gap: 8px; margin-top: 10px; } - .tp-progression-row { display: grid; grid-template-columns: 34px minmax(180px, 1fr) auto; gap: 8px; align-items: center; } - .tp-progression-number { color: var(--bt-muted); font-size: 12px; text-align: center; } + .tp-library-main { + display: grid; + grid-template-columns: minmax(240px, 1fr) minmax(240px, 1fr) auto; + gap: 10px; + align-items: end; + padding: 12px 14px; + background: var(--bt-surface-2); + border-bottom: 1px solid var(--bt-line-soft); + } + .tp-library-id { padding: 7px 14px 12px; font-size: 11px; } + .tp-progressions { display: grid; gap: 0; margin: 0; } + .tp-progressions-head { + display: grid; + grid-template-columns: 34px minmax(180px, 1fr) auto; + gap: 8px; + align-items: center; + padding: 8px 14px 4px; + color: var(--bt-muted); + font-size: 11px; + font-weight: 800; + letter-spacing: .06em; + text-transform: uppercase; + } + .tp-progression-row { + display: grid; + grid-template-columns: 34px minmax(180px, 1fr) auto; + gap: 8px; + align-items: center; + padding: 6px 14px; + border-top: 1px solid var(--bt-line-soft); + } + .tp-progression-number { color: var(--bt-muted); font-family: var(--bt-mono); font-size: 11px; text-align: center; } + .tp-progression-input { margin: 0; } .tp-addline, .tp-formline { display: grid; grid-template-columns: minmax(220px, 1fr) auto; gap: 8px; align-items: end; margin-top: 12px; } .tp-table-input { width: 100%; min-width: 0; } .tp-code { min-height: 520px; font-family: var(--bt-mono); font-size: 12.5px; } @@ -36,7 +73,7 @@ .tp-top, .tp-layout, .tp-day-head, .tp-textareas, .tp-addline, .tp-formline { grid-template-columns: 1fr; } .tp-side { position: static; } .tp-actions, .tp-day-actions { justify-content: flex-start; } - .tp-exercise-row, .tp-library-main, .tp-progression-row { grid-template-columns: 1fr; } + .tp-exercise-row, .tp-library-main, .tp-progression-row, .tp-progressions-head { grid-template-columns: 1fr; } .tp-exercise-actions { justify-content: flex-start; } .tp-progression-number { text-align: left; } } @@ -291,8 +328,8 @@ function renderExercises() { const rows = (state.cfg.exercises || []).map((exercise) => `
- - + @@ -302,10 +339,17 @@ function renderExercises() {
+ ${(exercise.progressions || []).length ? ` +
+ + Progressionen + +
+ ` : ""} ${(exercise.progressions || []).map((progression, index) => `
${index + 1}
- +
@@ -314,14 +358,14 @@ function renderExercises() {
`).join("") || `
Keine Progressionen hinterlegt.
`}
-
${esc(exercise.id)}
+
${esc(exercise.id)}
`).join(""); $("tab-uebungen").innerHTML = `

Übung anlegen

- +
diff --git a/plugins/trainingstracker/README.md b/plugins/trainingstracker/README.md index 0454ccd..920dbcf 100644 --- a/plugins/trainingstracker/README.md +++ b/plugins/trainingstracker/README.md @@ -1,85 +1,48 @@ -# Trainings-Session-Tracker 2.0 +# Training 3.0 -Mobiler Session-Tracker für veröffentlichte Pläne des boehmitools-Plugins `trainingsplan`. +Schlanke mobile Ansicht fuer Plaene aus dem Plugin `trainingsplan`. -## Datenaufteilung +Das Plugin verwaltet keine Sessions mehr. Es gibt keine Wochen, kein Tracking, +keine Ergebnisfelder, keine KI-Analyse und keine Vorschlaege. Angezeigt werden +nur: + +- Trainingstage +- Warm-up +- Cool-down +- Uebungen des Tages +- aktuelle Progression pro Uebung + +## Datenzugriff + +Gelesen und geschrieben werden die Plan-JSON-Dateien aus: ```text -data/trainingstracker/ -├── sessions/.json -├── analyses// -│ ├── index.json -│ ├── state.json -│ ├── week-01.json -│ ├── week-02.json -│ └── overall.json -└── proposals/.json +data/trainingsplan/plans/ ``` -Die Sessiondatei enthält ausschließlich Profil-, Wochenstatus- und Sessiondaten. Prompts, KI-Antworten und Jobstatus liegen vollständig getrennt. - -## Genau eine Analyse pro Bereich - -Für jede Woche gibt es höchstens eine Datei `week-NN.json`, für den Gesamtplan genau eine `overall.json`. Eine neue Auswertung überschreibt die vorherige Datei. Es gibt keine Analysehistorie und keine Versionierung von KI-Auswertungen. Ältere Zeitstempeldateien werden beim ersten Lesen auf dieses Modell reduziert; erhalten bleibt nur die zuvor als aktuell markierte Auswertung. - -Unveränderte Daten werden über einen Hash erkannt und lösen keinen neuen OpenAI-Aufruf aus. Der Hash berücksichtigt unter anderem: - -- veröffentlichte Planrevision, -- Plan- und Vertragsversion, -- Prompt- und Antwortschemaversion, -- Modell, -- Normalisierungsversion, -- Sessiondaten und Wochenstatus. - -## Sessiondaten und Konfliktschutz - -Der Browser speichert nur die aktuell bearbeitete Session als Patch. Eine Revisionsnummer verhindert, dass ein älterer Browser-Tab neuere Daten überschreibt. - -Jede Session merkt `plan_id` und `plan_revision`. Alte positionsbasierte Einträge wie `d1-r0-e0` werden beim Öffnen automatisch den neuen stabilen Übungs-IDs zugeordnet und beim nächsten Speichern migriert. - -Sessionstatus: +Die einzige fachliche Schreiboperation ist: ```text -planned → in_progress → stopped/completed -stopped → in_progress -completed → in_progress +PATCH /api/plans//exercises//current ``` -Übungsstatus: +Body: -- offen, -- erledigt, -- teilweise, -- übersprungen mit optionalem Grund. +```json +{ + "current_progression_id": "progression-high-plank" +} +``` -Nicht enthalten sind RIR/RPE, Technikbewertung oder unterschiedliche Gewichte pro Satz. +Die Aenderung wird direkt in `config` und `draft` des Plans geschrieben und ist +damit die neue Wahrheit fuer App und Editor. -## Plan- und progressionsabhängige Ergebnisfelder +## Vertrag -Der Tracker liest das Ergebnisformat zunächst aus der gewählten Progressionsstufe, danach aus der Übung. So kann eine Squat-Progression zunächst Sekunden mit optionalem Gewicht und später Wiederholungen oder Gewicht plus Wiederholungen verlangen. +```text +plan_schema: 5 +training_contract: 4 +``` -Alte Freitextergebnisse bleiben lesbar. Eindeutige Werte werden in strukturierte Felder übernommen, unklare Angaben nicht erfunden. - -## Wochenabschluss - -Eine Woche kann ausdrücklich als laufend oder abgeschlossen markiert werden. Eine laufende Woche erzeugt eine Zwischenanalyse, eine geschlossene Woche eine Abschlussanalyse. Wird der Wochenstatus oder eine Session geändert, gilt die bestehende Analyse als veraltet. Die nächste manuelle Auswertung überschreibt sie. - -## Progressionsanalyse - -Wochen werden einzeln analysiert. Die Gesamtanalyse verwendet nur aktuelle Wochenzusammenfassungen und lokale Aggregate, nicht erneut sämtliche Rohsessions. - -Der veröffentlichte Plan ist bindend. Bei Tabata bleiben Arbeitszeit, Pause, Rundenzahl und Satzlogik unverändert. Die KI erhält lokal berechnete Variantencluster und soll deren Faktoren nicht selbst neu erfinden. - -Planvorschläge werden strukturiert mit `exercise_id`, `progression_id` und `step_id` nach `data/trainingstracker/proposals/` geschrieben. Der Planeditor zeigt sie als Prüfpostfach an und verändert den Plan niemals automatisch. - -## Robuste Analysejobs - -Der Jobstatus wird vor dem API-Aufruf gespeichert. Eine Prozess- und Dateisperre verhindert parallele Jobs für denselben Plan. Ein Heartbeat verlängert die Job-Lease. Nach einem Prozessabbruch läuft die Sperre zeitnah ab, auch nach einem Browser-Reload oder Containerneustart. - -## Übungsbibliothek und FAQ - -Die planbezogene `exercise_catalog` ist die primäre Quelle für Bewegungscluster, Varianten und Faktoren. Die FAQ zeigt diese Planbibliothek sowie die transparenten Fallback-Tabellen. Dynamische Übungen werden als Referenz-Reps, Holds als Referenzsekunden und externe Lasten als kg·Reps beziehungsweise kg·s ausgewertet. - -## Oberflächenzustand - -Der letzte Tab, die Woche, der Tag und die ausgewählte Analyse bleiben pro Trainingsplan im Browser erhalten. +Uebungen enthalten `progressions` und `current_progression_id`. Tagesuebungen +referenzieren nur noch `exercise_id`. diff --git a/plugins/trainingstracker/analysis_store.py b/plugins/trainingstracker/analysis_store.py deleted file mode 100644 index 7574dad..0000000 --- a/plugins/trainingstracker/analysis_store.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import annotations -import re -from pathlib import Path -from typing import Any - - -def current_filename(record: dict[str, Any]) -> str: - if record.get("type") == "week": - return f"week-{int(record.get('week') or 0):02d}.json" - return "overall.json" - - -def cleanup_legacy_files(directory: Path, record: dict[str, Any], keep: str) -> None: - if record.get("type") == "week": - week = int(record.get("week") or 0) - pattern = re.compile(rf"^week[-_]?0?{week}(?:[-_].*)?\.json$", re.I) - else: - pattern = re.compile(r"^overall(?:[-_].*)?\.json$", re.I) - for path in directory.glob("*.json"): - if path.name in {"index.json", "state.json", keep}: continue - if pattern.match(path.name): - try: path.unlink() - except OSError: pass diff --git a/plugins/trainingstracker/app.py b/plugins/trainingstracker/app.py index 2ccd3d8..d5b0a0d 100644 --- a/plugins/trainingstracker/app.py +++ b/plugins/trainingstracker/app.py @@ -1,246 +1,68 @@ # -*- coding: utf-8 -*- -"""Mobile Tracking-App für boehmitools-Trainingspläne. +"""Schlanke Plan-App fuer den aktuellen Trainingsplan. -Quelle, strikt read-only: - /data/trainingsplan/plans/*.json - -Tracker-Daten: - /data/trainingstracker/sessions/.json - -Analyse-Daten, vollständig getrennt von den Sessions. Pro Woche und -Gesamtplan existiert jeweils nur eine überschreibbare Datei: - /data/trainingstracker/analyses// +Der alte Session-Tracker hat Ergebnisse, Wochen, Analysen und Vorschlaege +verwaltet. Die neue App arbeitet nur noch mit der Wahrheit im Plan: +Uebungen haben Progressionsvarianten, und genau eine davon ist aktuell. """ from __future__ import annotations -import hashlib -import html import json import os import re +import sys import tempfile import threading -import time -from contextlib import contextmanager from copy import deepcopy -from datetime import date, datetime, timedelta, timezone from pathlib import Path from typing import Any -from flask import Flask, Response, jsonify, request, send_file - -from contract import (PLAN_SCHEMA_VERSION, CONTRACT_VERSION, TRACKER_SCHEMA_VERSION, RESULT_DATA_VERSION, PROMPT_VERSION, ANALYSIS_SCHEMA_VERSION, PROGRESSION_NORMALIZATION_VERSION) -from session_store import merge_session_patch, VALID_ITEM_STATUSES -from analysis_store import current_filename, cleanup_legacy_files +from flask import Flask, Response, jsonify, request PLUGIN_DIR = Path(__file__).resolve().parent STATIC_DIR = PLUGIN_DIR / "static" +TRAININGSPLAN_DIR = PLUGIN_DIR.parent / "trainingsplan" +if str(TRAININGSPLAN_DIR) not in sys.path: + sys.path.insert(0, str(TRAININGSPLAN_DIR)) + +from schema_contract import CONTRACT_VERSION, PLAN_SCHEMA_VERSION, normalize_training_config, validate_training_config # noqa: E402 +from storage import utc_now # noqa: E402 + _WRITE_LOCK = threading.RLock() -_ANALYSIS_GUARD = threading.RLock() -_ANALYSIS_THREADS: dict[str, threading.Thread] = {} -_ANALYSIS_TIMEOUT_SECONDS = 3 * 60 -_ANALYSIS_HEARTBEAT_SECONDS = 30 -# Version der deterministischen, planunabhängigen Normalisierung. Eine Änderung -# macht bestehende Analysen über den Dataset-Hash automatisch veraltet. - -# Die Faktoren sind keine medizinischen oder biomechanischen Naturkonstanten. -# Sie dienen ausschließlich dazu, Varianten desselben Bewegungsmusters in einer -# nachvollziehbaren Trendkurve abzubilden. Push-up-Anker orientieren sich an veröffentlichten Kraftplattenmessungen; die -# eigentliche Referenzskala ist bewusst nichtlinear, damit ein Stufenwechsel mit -# weniger Wiederholungen nicht fälschlich als Rückschritt erscheint. Alle übrigen -# Werte sind konservative Heuristiken aus Hebel, Unterstützung, ROM und Stufenfolge. -_EQUIVALENCE_TABLES: list[dict[str, Any]] = [ - { - "id": "push_up", "label": "Push-up", "mode": "reps", "priority": 100, - "reference": "voller Push-up = 1,00 Referenz-Rep", - "variants": [ - {"label": "Wand-Push-up", "factor": 0.05, "patterns": [r"wand", r"wall"]}, - {"label": "Incline hoch, ca. 60 cm", "factor": 0.10, "patterns": [r"inkline.?\(?hoch", r"incline.?high", r"hände hoch"]}, - {"label": "Incline mittel", "factor": 0.20, "patterns": [r"inkline.?mittel", r"incline.?medium", r"halbe höhe"]}, - {"label": "Incline tief, ca. 30 cm", "factor": 0.35, "patterns": [r"inkline.?tief", r"incline.?low", r"tiefe erhöh"]}, - {"label": "Incline tiefer / Knie", "factor": 0.40, "patterns": [r"tiefer.?/? ?knie"]}, - {"label": "Knie-Push-up", "factor": 0.45, "patterns": [r"knie.?liegest", r"knee.?push", r"\bknie\b"]}, - {"label": "Teilwiederholung", "factor": 0.35, "patterns": [r"teil.?rep", r"partial", r"kleiner rom"]}, - {"label": "Negativ", "factor": 0.70, "patterns": [r"negativ", r"eccentric", r"absenk"]}, - {"label": "Negativ + erste volle", "factor": 0.85, "patterns": [r"negativ.*erste volle"]}, - {"label": "Voller Push-up", "factor": 1.00, "patterns": [r"volle? push", r"standard", r"normal", r"boden"]}, - {"label": "Decline / Zusatzlast", "factor": 1.25, "patterns": [r"decline", r"füße erhöht", r"zusatzlast", r"gewichtete"]}, - ], - }, - { - "id": "dip", "label": "Dip", "mode": "reps", "priority": 82, - "reference": "voller Dip am Barren = 1,00 Referenz-Rep", - "variants": [ - {"label": "Bank-Dip, Knie stark gebeugt, kleiner ROM", "factor": 0.25, "patterns": [r"knie stark gebeugt.*klein"]}, - {"label": "Bank-Dip, Knie gebeugt, voller ROM", "factor": 0.40, "patterns": [r"knie gebeugt.*voll"]}, - {"label": "Bank-Dip, Füße etwas weiter", "factor": 0.55, "patterns": [r"füße etwas weiter"]}, - {"label": "Bank-Dip, Füße weit / Beine gerade", "factor": 0.70, "patterns": [r"füße weiter", r"beine gerade"]}, - {"label": "Assistierter Dip", "factor": 0.65, "patterns": [r"assistiert"]}, - {"label": "Negativer Dip", "factor": 0.80, "patterns": [r"negativ"]}, - {"label": "Voller Dip", "factor": 1.00, "patterns": [r"volle? dip", r"standard dip"]}, - ], - }, - { - "id": "pike_press", "label": "Pike Push-up", "mode": "reps", "priority": 78, - "reference": "voller Pike Push-up = 1,00 Referenz-Rep", - "variants": [ - {"label": "Hold + kleine Senkung", "factor": 0.30, "patterns": [r"kleine senkung", r"partial"]}, - {"label": "Pike Push-up, Hände erhöht", "factor": 0.50, "patterns": [r"hände erhöht", r"erhöht"]}, - {"label": "Pike Push-up am Boden, kleiner ROM", "factor": 0.75, "patterns": [r"boden.*klein", r"kleiner rom"]}, - {"label": "Voller Pike Push-up", "factor": 1.00, "patterns": [r"voll", r"pike push"]}, - ], - }, - { - "id": "inverted_row", "label": "Inverted Row", "mode": "reps", "priority": 92, - "reference": "Straight-Leg Row = 1,00 Referenz-Rep", - "variants": [ - {"label": "Aufrechte assistierte Row", "factor": 0.25, "patterns": [r"aufrecht", r"stark assist"]}, - {"label": "Bent-Leg Row", "factor": 0.50, "patterns": [r"bent.?leg", r"beine gebeugt", r"knie gebeugt"]}, - {"label": "Straight-Leg Row", "factor": 1.00, "patterns": [r"straight.?leg", r"beine gestreckt", r"volle row"]}, - {"label": "Füße erhöht", "factor": 1.15, "patterns": [r"füße erhöht", r"feet elevated"]}, - ], - }, - { - "id": "pull_up", "label": "Pull-up", "mode": "reps", "priority": 100, - "reference": "voller Pull-up = 1,00 Referenz-Rep", - "variants": [ - {"label": "Scapular Pull-up", "factor": 0.20, "patterns": [r"scapular"]}, - {"label": "Stark assistiert / Fußhilfe", "factor": 0.35, "patterns": [r"fußhilfe", r"stark assist"]}, - {"label": "Assistierter Pull-up", "factor": 0.55, "patterns": [r"assistiert", r"band"]}, - {"label": "Negativer Pull-up", "factor": 0.70, "patterns": [r"negativ"]}, - {"label": "Leicht assistiert", "factor": 0.80, "patterns": [r"leicht assist"]}, - {"label": "Voller Pull-up", "factor": 1.00, "patterns": [r"volle? pull", r"klimmzug", r"standard"]}, - {"label": "Pull-up mit Zusatzlast", "factor": 1.15, "patterns": [r"zusatzlast", r"weighted"]}, - ], - }, - { - "id": "squat", "label": "Squat", "mode": "reps", "priority": 100, - "reference": "voller Bodyweight Squat = 1,00 Referenz-Rep", - "variants": [ - {"label": "Chair / Box Squat", "factor": 0.35, "patterns": [r"chair", r"box", r"stuhl"]}, - {"label": "Assistierter Squat", "factor": 0.55, "patterns": [r"assistiert", r"festhalten", r"türrahmen"]}, - {"label": "Teilwiederholung", "factor": 0.45, "patterns": [r"teil", r"partial"]}, - {"label": "Negativer Squat", "factor": 0.75, "patterns": [r"negativ", r"3s runter"]}, - {"label": "Bodyweight Squat", "factor": 1.00, "patterns": [r"körpergewicht", r"bodyweight", r"volle squat", r"kniebeuge"]}, - {"label": "Goblet Squat", "factor": 1.00, "patterns": [r"goblet"]}, - {"label": "Einbeinige Variante", "factor": 1.45, "patterns": [r"pistol", r"einbeinig", r"shrimp"]}, - ], - }, - { - "id": "plank", "label": "Plank", "mode": "seconds", "priority": 100, - "reference": "volle Plank = 1,00 Referenzsekunde", - "variants": [ - {"label": "Erhöhte Plank", "factor": 0.55, "patterns": [r"erhöht", r"incline"]}, - {"label": "Plank auf Knien", "factor": 0.65, "patterns": [r"knien", r"knie"]}, - {"label": "Volle Plank", "factor": 1.00, "patterns": [r"voll", r"standard", r"high plank"]}, - {"label": "Plank mit Schulter-Taps", "factor": 1.15, "patterns": [r"schulter.?tap", r"shoulder.?tap"]}, - {"label": "Long-Lever / erschwerter Hebel", "factor": 1.25, "patterns": [r"long.?lever", r"hebel", r"schwieriger"]}, - ], - }, - { - "id": "side_plank", "label": "Side Plank", "mode": "seconds", "priority": 82, - "reference": "volle Side Plank = 1,00 Referenzsekunde", - "variants": [ - {"label": "Side Plank auf Knien", "factor": 0.65, "patterns": [r"knien", r"knie"]}, - {"label": "Volle Side Plank", "factor": 1.00, "patterns": [r"voll", r"standard"]}, - {"label": "Star / langer Hebel", "factor": 1.25, "patterns": [r"star", r"langer hebel", r"bein oben"]}, - ], - }, - { - "id": "hollow_hold", "label": "Hollow Hold", "mode": "seconds", "priority": 82, - "reference": "voller Hollow Hold = 1,00 Referenzsekunde", - "variants": [ - {"label": "Tuck", "factor": 0.65, "patterns": [r"tuck", r"knie an"]}, - {"label": "Halb gestreckt", "factor": 0.82, "patterns": [r"halb"]}, - {"label": "Voll", "factor": 1.00, "patterns": [r"voll", r"gestreckt"]}, - ], - }, - { - "id": "active_hang", "label": "Active Hang", "mode": "seconds", "priority": 92, - "reference": "freier Active Hang = 1,00 Referenzsekunde", - "variants": [ - {"label": "Mit Fußhilfe", "factor": 0.60, "patterns": [r"fußhilfe", r"assist"]}, - {"label": "Freier Active Hang", "factor": 1.00, "patterns": [r"active hang", r"frei"]}, - {"label": "Einarmig", "factor": 1.60, "patterns": [r"einarm"]}, - ], - }, - { - "id": "deep_squat_hold", "label": "Deep Squat Hold", "mode": "seconds", "priority": 90, - "reference": "ungewichteter Deep Squat Hold = 1,00 Referenzsekunde", - "variants": [ - {"label": "Assistiert", "factor": 0.75, "patterns": [r"assist", r"festhalten"]}, - {"label": "Ungewichtet", "factor": 1.00, "patterns": [r"ungewichtet", r"bodyweight", r"deep squat"]}, - {"label": "Gewichtet", "factor": 1.00, "patterns": [r"gewichtet", r"goblet"]}, - ], - }, - { - "id": "wall_sit", "label": "Wall Sit", "mode": "seconds", "priority": 75, - "reference": "sauberer Wall Sit = 1,00 Referenzsekunde", - "variants": [{"label": "Wall Sit", "factor": 1.00, "patterns": [r"wall sit", r"wandsitz"]}], - }, -] - -_EQUIVALENCE_SOURCES = [ - { - "title": "CaliHoss Calisthenics Course", - "url": "https://calihoss.me/learn/calisthenics/#playlist-2-progression", - "note": "Progression über Technik, ROM, Unterstützung, Hebel, Widerstand und mechanische Schwierigkeit; 4–8 Reps bzw. 10–30-s-Holds.", - }, - { - "title": "Suprak et al. (2011): The effect of position on the percentage of body mass supported during traditional and modified push-up variants", - "url": "https://pubmed.ncbi.nlm.nih.gov/21273908/", - "note": "Biomechanischer Anker für die Reihenfolge traditioneller und Knie-Push-ups; die Analysefaktoren sind bewusst nichtlinear.", - }, - { - "title": "Ebben et al. (2011): Kinetic analysis of several variations of push-ups", - "url": "https://pubmed.ncbi.nlm.nih.gov/21873902/", - "note": "Biomechanischer Anker für die Reihenfolge verschiedener Handhöhen; die Analysefaktoren sind bewusst nichtlinear.", - }, -] - - -def _discover_boehmitools_root() -> Path: - explicit = os.environ.get("BOEHMITOOLS_ROOT") +def _discover_root() -> Path: + explicit = os.environ.get("BOEHMITOOLS_ROOT", "").strip() if explicit: return Path(explicit).expanduser().resolve() - for parent in [PLUGIN_DIR, *PLUGIN_DIR.parents]: - if (parent / "data" / "trainingsplan" / "plans").is_dir(): - return parent - # Standardfall bei .../boehmitools/plugins/trainingstracker + for candidate in ( + Path("/home/michael/boehmitools/runtime/training"), + Path("/home/michael/runtime/training"), + ): + if (candidate / "data" / "trainingsplan" / "plans").is_dir(): + return candidate.resolve() if PLUGIN_DIR.parent.name == "plugins": - return PLUGIN_DIR.parent.parent - return PLUGIN_DIR + return PLUGIN_DIR.parent.parent.resolve() + return PLUGIN_DIR.resolve() -ROOT_DIR = _discover_boehmitools_root() -DATA_DIR = Path(os.environ.get( - "TRAININGSTRACKER_DATA_DIR", ROOT_DIR / "data" / "trainingstracker" -)).expanduser().resolve() -PLANS_DIR = Path(os.environ.get( - "TRAININGSTRACKER_PLANS_DIR", ROOT_DIR / "data" / "trainingsplan" / "plans" -)).expanduser().resolve() -SESSIONS_DIR = DATA_DIR / "sessions" -ANALYSES_DIR = DATA_DIR / "analyses" -PROPOSALS_DIR = DATA_DIR / "proposals" +ROOT_DIR = _discover_root() +DATA_DIR = Path(os.environ.get("TRAININGSTRACKER_DATA_DIR", ROOT_DIR / "data" / "trainingstracker")).expanduser().resolve() +PLANS_DIR = Path(os.environ.get("TRAININGSTRACKER_PLANS_DIR", ROOT_DIR / "data" / "trainingsplan" / "plans")).expanduser().resolve() SETTINGS_FILE = DATA_DIR / "settings.json" -SESSIONS_DIR.mkdir(parents=True, exist_ok=True) -ANALYSES_DIR.mkdir(parents=True, exist_ok=True) -PROPOSALS_DIR.mkdir(parents=True, exist_ok=True) +DATA_DIR.mkdir(parents=True, exist_ok=True) app = Flask(__name__, static_folder=None) -# --------------------------------------------------------------------------- -# Dateisystem und JSON -# --------------------------------------------------------------------------- -def _utc_now() -> str: - return datetime.now(timezone.utc).isoformat(timespec="seconds") +def _read_json(path: Path, default: Any = None) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return deepcopy(default) def _atomic_json_write(path: Path, payload: Any) -> None: - """Schreibt JSON atomar, damit auch ein abgebrochener Request nichts zerlegt.""" path.parent.mkdir(parents=True, exist_ok=True) with _WRITE_LOCK: fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent) @@ -256,291 +78,47 @@ def _atomic_json_write(path: Path, payload: Any) -> None: os.unlink(temp_name) -def _read_json(path: Path, default: Any = None) -> Any: - try: - with path.open("r", encoding="utf-8") as handle: - return json.load(handle) - except (FileNotFoundError, json.JSONDecodeError, OSError): - return deepcopy(default) +def _plain(value: Any) -> str: + text = str(value or "") + text = re.sub(r"", "\n", text, flags=re.I) + text = re.sub(r"<[^>]+>", "", text) + return text.strip() -def _source_hash(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as handle: - for chunk in iter(lambda: handle.read(1024 * 128), b""): - digest.update(chunk) - return digest.hexdigest() +def _unwrap(raw: Any, fallback_name: str) -> tuple[str, dict[str, Any], dict[str, Any]]: + if not isinstance(raw, dict): + raw = {} + if isinstance(raw.get("draft"), dict): + cfg = raw["draft"] + elif isinstance(raw.get("config"), dict): + cfg = raw["config"] + else: + cfg = raw + meta = cfg.get("meta") if isinstance(cfg.get("meta"), dict) else {} + name = _plain(raw.get("name") or meta.get("title") or fallback_name) + return name, cfg, raw + + +def _is_plan(path: Path) -> bool: + raw = _read_json(path, {}) + _, cfg, _ = _unwrap(raw, path.stem) + if not isinstance(cfg, dict) or cfg.get("type") == "recipe": + return False + return isinstance(cfg.get("days"), list) def _known_plan_files() -> dict[str, Path]: - """Ermittelt valide Trainingspläne. Rezeptsammlungen werden ausgeschlossen.""" - result: dict[str, Path] = {} if not PLANS_DIR.is_dir(): - return result - for path in sorted(PLANS_DIR.glob("*.json"), key=lambda p: p.name.casefold()): - try: - raw = _read_json(path) - _, cfg = _unwrap_plan(raw, path.stem) - if _is_training_config(cfg): - result[path.name] = path - except Exception: - continue - return result + return {} + return { + path.name: path + for path in sorted(PLANS_DIR.glob("*.json"), key=lambda item: item.name.casefold()) + if _is_plan(path) + } def _validated_plan_path(plan_id: str) -> Path | None: - # Exakte Auswahl aus dem Scan verhindert Traversal und fremde Dateien. - return _known_plan_files().get(plan_id) - - -def _tracker_path(plan_filename: str) -> Path: - # Original-Dateiname im separaten sessions-Ordner, wie vorgegeben. - return SESSIONS_DIR / Path(plan_filename).name - - -def _analysis_plan_dir(plan_filename: str) -> Path: - # Exakter Plan-Dateiname als eigener Archivordner, ohne Annahmen zum Linux-Root. - return ANALYSES_DIR / Path(plan_filename).name - - -def _analysis_index_path(plan_filename: str) -> Path: - return _analysis_plan_dir(plan_filename) / "index.json" - - -def _analysis_state_path(plan_filename: str) -> Path: - return _analysis_plan_dir(plan_filename) / "state.json" - - -def _analysis_lock_path(plan_filename: str) -> Path: - return _analysis_plan_dir(plan_filename) / ".analysis.lock" - - -@contextmanager -def _analysis_file_lock(plan_filename: str): - """Prozessübergreifende Sperre für Job-Claim und Statuswechsel auf Linux.""" - import fcntl - path = _analysis_lock_path(plan_filename) - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("a+", encoding="utf-8") as handle: - fcntl.flock(handle.fileno(), fcntl.LOCK_EX) - try: - yield - finally: - fcntl.flock(handle.fileno(), fcntl.LOCK_UN) - - -def _empty_analysis_index(plan_filename: str) -> dict[str, Any]: - return { - "version": 2, - "source_file": Path(plan_filename).name, - "latest": {"weeks": {}, "overall": None}, - "updated_at": _utc_now(), - } - - -def _read_analysis_index(plan_filename: str) -> dict[str, Any]: - raw = _read_json(_analysis_index_path(plan_filename), None) - index = raw if isinstance(raw, dict) else _empty_analysis_index(plan_filename) - latest = index.get("latest") if isinstance(index.get("latest"), dict) else {} - index = { - "version": 2, - "source_file": Path(plan_filename).name, - "latest": { - "weeks": latest.get("weeks") if isinstance(latest.get("weeks"), dict) else {}, - "overall": latest.get("overall") if isinstance(latest.get("overall"), str) else None, - }, - "updated_at": str(index.get("updated_at") or _utc_now()), - } - return index - - -def _write_analysis_index(plan_filename: str, index: dict[str, Any]) -> None: - index = _sanitize(index) - if not isinstance(index, dict): - raise ValueError("Ungültiger Analyseindex") - index["version"] = 2 - index["source_file"] = Path(plan_filename).name - index["updated_at"] = _utc_now() - index.pop("history", None) - _atomic_json_write(_analysis_index_path(plan_filename), index) - - -def _public_analysis_record(record: Any) -> dict[str, Any] | None: - if not isinstance(record, dict) or record.get("status") not in (None, "success"): - return None - response = record.get("response") if isinstance(record.get("response"), dict) else record.get("result") - if not isinstance(response, dict): - return None - keys = ( - "id", "type", "week", "created_at", "model", "source_hash", - "sessions_considered", "weeks_considered", "archive_file", "analysis_kind", - ) - public = {key: deepcopy(record.get(key)) for key in keys if key in record} - public["result"] = deepcopy(response) - public["visuals"] = deepcopy(record.get("visuals") if isinstance(record.get("visuals"), dict) else {}) - return public - - -def _read_analysis_record(plan_filename: str, relative_name: Any) -> dict[str, Any] | None: - if not isinstance(relative_name, str) or not relative_name: - return None - base = _analysis_plan_dir(plan_filename).resolve() - candidate = (base / relative_name).resolve() - try: - candidate.relative_to(base) - except ValueError: - return None - raw = _read_json(candidate, None) - return raw if isinstance(raw, dict) else None - - -def _migrate_single_analysis_store(plan_filename: str) -> dict[str, Any]: - """Reduziert ältere Analysearchive auf genau eine aktuelle Datei je Scope. - - Alte Versionen führten Zeitstempeldateien und optional eine Historie im Index. - Beim ersten Lesen werden nur die im Index als aktuell markierten Datensätze - übernommen, auf deterministische Dateinamen umgeschrieben und alle älteren - Dateien desselben Scopes entfernt. - """ - with _WRITE_LOCK: - index = _read_analysis_index(plan_filename) - directory = _analysis_plan_dir(plan_filename) - changed = False - weeks = index["latest"]["weeks"] - for week_key, relative_name in list(weeks.items()): - record = _read_analysis_record(plan_filename, relative_name) - if not isinstance(record, dict): - weeks.pop(week_key, None) - changed = True - continue - try: - week = int(record.get("week") or week_key) - except (TypeError, ValueError): - weeks.pop(week_key, None) - changed = True - continue - record["type"] = "week" - record["week"] = week - target = current_filename(record) - if relative_name != target or not (directory / target).exists(): - record["archive_file"] = target - _atomic_json_write(directory / target, record) - changed = True - cleanup_legacy_files(directory, record, target) - if week_key != str(week): - weeks.pop(week_key, None) - if weeks.get(str(week)) != target: - weeks[str(week)] = target - changed = True - - overall_name = index["latest"].get("overall") - if overall_name: - record = _read_analysis_record(plan_filename, overall_name) - if isinstance(record, dict): - record["type"] = "overall" - target = current_filename(record) - if overall_name != target or not (directory / target).exists(): - record["archive_file"] = target - _atomic_json_write(directory / target, record) - changed = True - cleanup_legacy_files(directory, record, target) - if index["latest"].get("overall") != target: - index["latest"]["overall"] = target - changed = True - else: - index["latest"]["overall"] = None - changed = True - if changed: - _write_analysis_index(plan_filename, index) - return index - - -def _read_analysis_cache(plan: dict[str, Any]) -> dict[str, Any]: - index = _migrate_single_analysis_store(plan["source_file"]) - latest = index["latest"] - weeks: dict[str, Any] = {} - for week, relative_name in latest["weeks"].items(): - public = _public_analysis_record(_read_analysis_record(plan["source_file"], relative_name)) - if public is not None: - weeks[str(week)] = public - overall = _public_analysis_record(_read_analysis_record(plan["source_file"], latest.get("overall"))) - return {"weeks": weeks, "overall": overall} - - -def _write_proposals(plan: dict[str, Any], archive: dict[str, Any]) -> None: - response = archive.get("response") if isinstance(archive.get("response"), dict) else {} - adjustments = response.get("plan_adjustments") if isinstance(response.get("plan_adjustments"), list) else [] - scope_key = f"{archive.get('type', 'analysis')}:{archive.get('week') or 'overall'}" - proposals = [] - for index, item in enumerate(adjustments[:3]): - if not isinstance(item, dict): - continue - proposal = deepcopy(item) - proposal.setdefault("id", f"{scope_key}-{index+1}") - proposal.setdefault("status", "open") - proposal["scope_key"] = scope_key - proposal["analysis_created_at"] = archive.get("created_at") - proposal["analysis_type"] = archive.get("type") - proposal["week"] = archive.get("week") - proposals.append(proposal) - path = PROPOSALS_DIR / Path(plan["source_file"]).name - previous = _read_json(path, {}) - previous_items = previous.get("proposals") if isinstance(previous, dict) and isinstance(previous.get("proposals"), list) else [] - retained = [ - deepcopy(item) for item in previous_items - if isinstance(item, dict) and str(item.get("scope_key") or "") != scope_key - ] - payload = { - "version": 2, "source_file": plan["source_file"], "plan_id": plan.get("plan_id"), - "published_revision": plan.get("published_revision"), "updated_at": _utc_now(), - "proposals": retained + proposals, - } - _atomic_json_write(path, payload) - - -def _archive_analysis_record(plan: dict[str, Any], record: dict[str, Any]) -> dict[str, Any]: - """Speichert genau eine aktuelle Analyse je Woche sowie eine Gesamtanalyse. - - Neue Auswertungen überschreiben die bisherige Datei. Es wird bewusst keine - Analysehistorie oder Versionierung geführt. - """ - archive = _sanitize(record) - if not isinstance(archive, dict): - raise ValueError("Ungültiger Analysedatensatz") - filename = current_filename(archive) - archive["version"] = 2 - archive["status"] = "success" - archive["source_file"] = plan["source_file"] - archive["archive_file"] = filename - directory = _analysis_plan_dir(plan["source_file"]) - path = directory / filename - _atomic_json_write(path, archive) - cleanup_legacy_files(directory, archive, filename) - - with _WRITE_LOCK: - index = _read_analysis_index(plan["source_file"]) - if archive.get("type") == "week": - index["latest"]["weeks"][str(int(archive.get("week") or 0))] = filename - else: - index["latest"]["overall"] = filename - _write_analysis_index(plan["source_file"], index) - _write_proposals(plan, archive) - public = _public_analysis_record(archive) - if public is None: - raise ValueError("Analyse konnte nicht gelesen werden") - return public - - -def _read_analysis_state(plan: dict[str, Any]) -> dict[str, Any]: - return _normalize_analysis_state(_read_json(_analysis_state_path(plan["source_file"]), {})) - - -def _write_analysis_state(plan: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]: - clean = _normalize_analysis_state(_sanitize(state)) - clean["source_file"] = plan["source_file"] - clean["updated_at"] = _utc_now() - _atomic_json_write(_analysis_state_path(plan["source_file"]), clean) - return clean + return _known_plan_files().get(Path(plan_id).name) def _read_settings() -> dict[str, Any]: @@ -550,2771 +128,183 @@ def _read_settings() -> dict[str, Any]: def _save_selected(plan_id: str) -> None: settings = _read_settings() - settings["selected_plan"] = plan_id - settings["updated_at"] = _utc_now() + settings["selected_plan"] = Path(plan_id).name + settings["updated_at"] = utc_now() _atomic_json_write(SETTINGS_FILE, settings) -# --------------------------------------------------------------------------- -# Plan-Normalisierung -# --------------------------------------------------------------------------- -def _unwrap_plan(raw: Any, fallback_name: str) -> tuple[str, dict[str, Any]]: - if not isinstance(raw, dict): - raise ValueError("Plan ist kein JSON-Objekt") - if isinstance(raw.get("config"), dict): - return _plain_text(raw.get("name") or fallback_name), raw["config"] - meta = raw.get("meta") if isinstance(raw.get("meta"), dict) else {} - return _plain_text(meta.get("title") or fallback_name), raw +def _exercise_map(cfg: dict[str, Any]) -> dict[str, dict[str, Any]]: + return {str(exercise.get("id")): exercise for exercise in cfg.get("exercises", []) if isinstance(exercise, dict)} -def _is_training_config(cfg: dict[str, Any]) -> bool: - if not isinstance(cfg, dict) or cfg.get("type") == "recipe": - return False - days = cfg.get("days") - return isinstance(days, list) and any(isinstance(day, dict) for day in days) - - -def _plain_text(value: Any) -> str: - """Wandelt HTML-formatierte Planfelder in sichtbaren Klartext um. - - Der PDF-Editor speichert Sonderzeichen teils als HTML-Entities, zum Beispiel - ``&``. In reinen UI-Labels dürfen diese nicht erneut escaped werden. - """ - text = str(value or "") - text = re.sub(r"", "\n", text, flags=re.I) - text = re.sub(r"<[^>]+>", "", text) - return html.unescape(text).strip() - - -def _sequence_items(value: Any) -> list[str]: - text = str(value or "") - text = re.sub(r"", " · ", text, flags=re.I) - text = _plain_text(text) - if not text: - return [] - parts = re.split(r"\s*(?:·|•|\n)\s*", text) - return [part.strip() for part in parts if part.strip()] - - -def _slug(value: Any) -> str: - text = _plain_text(value).lower() - text = text.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss") - text = re.sub(r"[^a-z0-9]+", "-", text).strip("-") - return text or "item" - - -def _normalize_phases(cfg: dict[str, Any]) -> list[dict[str, Any]]: - phases = cfg.get("phases") if isinstance(cfg.get("phases"), dict) else {} - items = phases.get("items") - normalized: list[dict[str, Any]] = [] - if isinstance(items, list): - for item in items: - if not isinstance(item, dict): - continue - normalized.append({ - "id": str(item.get("id") or f"phase-{len(normalized)+1}"), - "name": _plain_text(item.get("name") or f"Phase {len(normalized) + 1}"), - "params": str(item.get("params") or ""), - "weeks": max(1, _as_int(item.get("weeks"), 1)), - }) - else: - names = phases.get("names") if isinstance(phases.get("names"), list) else [] - params = phases.get("params") if isinstance(phases.get("params"), list) else [] - for index, name in enumerate(names): - normalized.append({ - "id": f"phase-{index+1}", - "name": _plain_text(name or f"Phase {index + 1}"), - "params": str(params[index] if index < len(params) else ""), - "weeks": 2, - }) - if not normalized: - normalized.append({"id": "phase-1", "name": "Gesamter Plan", "params": "", "weeks": max(1, _plan_weeks(cfg))}) - - cursor = 1 - for index, item in enumerate(normalized): - item["index"] = index - item["start_week"] = cursor - item["end_week"] = cursor + item["weeks"] - 1 - cursor = item["end_week"] + 1 - return normalized - - -def _sanitize_plan_result_schema(value: Any, *, partial: bool = True) -> dict[str, Any] | None: - """Normalisiert ein im Trainingsplan gespeichertes Ergebnisschema. - - Stufen-Schemata dürfen partiell sein und nur einzelne Eigenschaften des - Übungsstandards überschreiben. Unbekannte Felder werden ignoriert. - """ - if not isinstance(value, dict): - return None - result: dict[str, Any] = {} - mode = str(value.get("mode") or "") - if mode in {"auto", "reps", "seconds", "minutes", "none"}: - result["mode"] = mode - weight_mode = str(value.get("weight_mode") or "") - if weight_mode in {"none", "optional", "required"}: - result["weight_mode"] = weight_mode - laterality = str(value.get("laterality") or "") - if laterality in {"bilateral", "unilateral"}: - result["laterality"] = laterality - sides_mode = str(value.get("sides_mode") or "") - if sides_mode in {"same", "separate"}: - result["sides_mode"] = sides_mode - try: - sets = int(value.get("sets") or value.get("default_sets") or 0) - except (TypeError, ValueError): - sets = 0 - if 1 <= sets <= 20: - result["sets"] = sets - result["locked_sets"] = bool(value.get("locked_sets", True)) - elif "locked_sets" in value: - result["locked_sets"] = bool(value.get("locked_sets")) - if not partial: - result.setdefault("mode", "auto") - result.setdefault("weight_mode", "none") - result.setdefault("laterality", "bilateral") - result.setdefault("sides_mode", "same") - return result or None - - -def _normalize_stages(cfg: dict[str, Any]) -> dict[str, dict[str, Any]]: - stages = cfg.get("stages") if isinstance(cfg.get("stages"), dict) else {} - result: dict[str, dict[str, Any]] = {} - for key, value in stages.items(): - if isinstance(value, dict): - raw_steps = value.get("steps") if isinstance(value.get("steps"), list) else [] - raw_schemas = value.get("result_schemas") if isinstance(value.get("result_schemas"), list) else [] - steps: list[dict[str, Any]] = [] - for index, raw_step in enumerate(raw_steps): - if isinstance(raw_step, dict): - name = html.unescape(str(raw_step.get("name") or raw_step.get("label") or "")) - schema = _sanitize_plan_result_schema(raw_step.get("result_schema"), partial=True) - step_id = str(raw_step.get("id") or f"{key}-step-{index+1}") - factor = _result_number(raw_step.get("factor")) or 1.0 - cluster = str(raw_step.get("movement_cluster") or "") - phase_id = str(raw_step.get("phase_id") or "") - else: - name = html.unescape(str(raw_step or "")) - schema = _sanitize_plan_result_schema(raw_schemas[index], partial=True) if index < len(raw_schemas) else None - step_id = f"{key}-step-{index+1}" - factor = 1.0 - cluster = "" - phase_id = "" - steps.append({ - "id": step_id, "name": name, "result_schema": schema, - "factor": factor, "movement_cluster": cluster, "phase_id": phase_id, - }) - result[str(key)] = { - "id": str(value.get("id") or f"progression-{key}"), - "key": str(value.get("key") or key), - "name": _plain_text(value.get("name") or key), - "steps": steps, - } - elif isinstance(value, list): - result[str(key)] = { - "id": f"progression-{key}", "key": str(key), "name": _plain_text(key), - "steps": [{"id": f"{key}-step-{i+1}", "name": html.unescape(str(step or "")), "result_schema": None, "factor": 1.0, "movement_cluster": "", "phase_id": ""} for i, step in enumerate(value)], - } - return result - - -def _library_lookup(cfg: dict[str, Any]) -> dict[str, dict[str, str]]: - lookup: dict[str, dict[str, str]] = {} - groups = cfg.get("library") if isinstance(cfg.get("library"), list) else [] - for group in groups: - if not isinstance(group, dict): - continue - for item in group.get("items", []) if isinstance(group.get("items"), list) else []: - if not isinstance(item, dict): - continue - name = _plain_text(item.get("name") or "") - if not name: - continue - entry = { - "name": name, - "desc": str(item.get("desc") or ""), - "url": str(item.get("url") or ""), - "group": _plain_text(group.get("group") or ""), - } - lookup[_slug(name)] = entry - return lookup - - -def _closest_library_entry(name: str, lookup: dict[str, dict[str, str]]) -> dict[str, str] | None: - exact = lookup.get(_slug(name)) - if exact: - return exact - target = _slug(name) - for key, entry in lookup.items(): - if target in key or key in target: - return entry +def _current_progression(exercise: dict[str, Any]) -> dict[str, str] | None: + progressions = exercise.get("progressions") if isinstance(exercise.get("progressions"), list) else [] + current_id = str(exercise.get("current_progression_id") or "") + for progression in progressions: + if isinstance(progression, dict) and str(progression.get("id") or "") == current_id: + return {"id": str(progression.get("id") or ""), "name": str(progression.get("name") or "")} + for progression in progressions: + if isinstance(progression, dict): + return {"id": str(progression.get("id") or ""), "name": str(progression.get("name") or "")} return None -_RESULT_STATIC_RE = re.compile( - r"(?:\bhold\b|\bplank\b|\bhang\b|wall\s*sit|wandsitz|isometr|stützposition)", - flags=re.I, -) -_RESULT_DYNAMIC_RE = re.compile( - r"(?:push.?up|pull.?up|klimmzug|scapular|negative?|liegestütz|wiederhol|\breps?\b|rudern|\brow\b|" - r"squat|kniebeuge|dip|curl|deadlift|rdl|hinge|swing|raise|heben|climber|crunch|twist|lunge|ausfallschritt)", - flags=re.I, -) -_RESULT_NO_MEASURE_RE = re.compile( - r"(?:regeneration\s+prüfen|schlaf\s+priorisieren|schmerzcheck|sonst\s+vollständig\s+pausieren|" - r"(?:push|pull|beine|core)\s+protokollieren|schwierigkeit\s+erhöhen|reps?\s+reserve\s+prüfen|" - r"mobilität|mobility)", - flags=re.I, -) -_RESULT_MINUTES_RE = re.compile(r"(?:spaziergang|walk|walking)", flags=re.I) - -_RESULT_REQUIRED_WEIGHT_RE = re.compile( - r"(?:\bkb\b|kettlebell|rucksack|goblet|floor\s*press|deadlift|\brdl\b|hip\s*hinge|" - r"\bswing\b|\bhalo\b|\bcurl|rudern.*\bkb\b|row.*\bkb\b|around.the.world)", - flags=re.I, -) -_RESULT_OPTIONAL_WEIGHT_RE = re.compile( - r"(?:gewicht|gewichtet|zusatzlast|\blast\b|\bkg\b|kettlebell|\bkb\b|rucksack|" - r"schwerer|wadenheben|calf)", - flags=re.I, -) -_RESULT_UNWEIGHTED_RE = re.compile( - r"(?:ohne\s+gewicht|ungewichtet|körpergewicht|koerpergewicht|\bbw\b)", - flags=re.I, -) -_RESULT_UNILATERAL_RE = re.compile( - r"(?:einarm|einbein|je\s+seite|pro\s+seite|links|rechts|seitenwechsel|\bwechsel\b|" - r"ausfallschritt|\blunge\b|side\s*plank|seitstütz|seitheben|lateral\s*raise|" - r"russian\s*twist|dead\s*bug|\bhalo\b|around.the.world|bottoms.?up)", - flags=re.I, -) - - -def _result_default_sets( - rotation_label: Any, cue: Any, training_format: dict[str, Any], exercise_count: int, -) -> tuple[int, bool]: - label = _plain_text(rotation_label or "") - text = _plain_text(f"{label} {cue or ''}") - if re.search(r"\barbeitssatz\s*\d+", label, flags=re.I): - return 1, True - - set_count = None - match = re.search(r"(?= 1 - and exercise_count >= 1 and rounds % exercise_count == 0 - ): - per_block = max(1, rounds // exercise_count) - return max(1, min(20, per_block * (set_count or 1))), True - - if set_count and 1 <= set_count <= 20: - return set_count, True - for pattern in (r"(? dict[str, Any]: - """Leitet die feste Ergebniseingabe aus Übung, Stufen und Planformat ab.""" - name = _plain_text(exercise.get("name") or "") - cue = _plain_text(exercise.get("cue") or "") - progression_id = str(exercise.get("progression_id") or "") - stage = stages.get(progression_id) if isinstance(stages, dict) else None - steps = stage.get("steps") if isinstance(stage, dict) and isinstance(stage.get("steps"), list) else [] - stage_text = " ".join(_plain_text(step) for step in steps) - full_text = f"{name} {cue} {stage_text}" - - static_name = bool(_RESULT_STATIC_RE.search(name)) - static_stage = bool(_RESULT_STATIC_RE.search(stage_text)) - dynamic_stage = bool(_RESULT_DYNAMIC_RE.search(stage_text)) - explicit_time = bool(re.search(r"(?= 1 - and rounds % exercise_count == 0 and rounds // exercise_count == 2 - and default_sets % 2 == 0 - ): - default_sets = max(1, default_sets // 2) - - return { - "version": RESULT_DATA_VERSION, - "mode": mode, - "weight_mode": weight_mode, - "laterality": laterality, - "sides_mode": sides_mode if laterality == "unilateral" else "same", - "default_sets": default_sets, - "locked_sets": locked_sets, - "work_seconds": training_format.get("work_seconds") if training_format.get("fixed_interval") else None, - "fixed_interval": bool(training_format.get("fixed_interval")), - } - - -def _normalize_rotations(day: dict[str, Any], day_num: int, library: dict[str, dict[str, str]]) -> list[dict[str, Any]]: - rotations = day.get("rotations") - if not isinstance(rotations, list): - rotations = [] - if isinstance(day.get("rotA"), list): rotations.append({"label": "Rotation A", "exercises": day["rotA"]}) - if isinstance(day.get("rotB"), list): rotations.append({"label": "Rotation B", "exercises": day["rotB"]}) - result: list[dict[str, Any]] = [] - day_id = str(day.get("id") or f"day-{day_num}") - for rotation_index, rotation in enumerate(rotations): - if not isinstance(rotation, dict): continue - rotation_id = str(rotation.get("id") or f"{day_id}-rotation-{rotation_index+1}") - exercises = rotation.get("exercises") if isinstance(rotation.get("exercises"), list) else [] - normalized_exercises = [] - for exercise_index, exercise in enumerate(exercises): - if not isinstance(exercise, dict): continue - name = _plain_text(exercise.get("name") or f"Übung {exercise_index + 1}") - key = str(exercise.get("progression_id") or exercise.get("key") or "").strip() - progression_id = key or f"name:{_slug(name)}" - normalized_exercises.append({ - "id": str(exercise.get("id") or f"{rotation_id}-exercise-{exercise_index+1}"), - "legacy_id": str(exercise.get("legacy_id") or f"d{day_num}-r{rotation_index}-e{exercise_index}"), - "exercise_id": str(exercise.get("exercise_id") or progression_id), - "name": name, "key": key, "progression_id": progression_id, - "cue": str(exercise.get("cue") or ""), - "movement_cluster": str(exercise.get("movement_cluster") or ""), - "result_schema": _sanitize_plan_result_schema(exercise.get("result_schema"), partial=False), - "library": _closest_library_entry(name, library), - }) - result.append({"id": rotation_id, "label": str(rotation.get("label") or f"Block {rotation_index + 1}"), "exercises": normalized_exercises}) - return result - - -def _as_int(value: Any, default: int) -> int: - try: - return int(value) - except (TypeError, ValueError): - return default - - -def _plan_weeks(cfg: dict[str, Any]) -> int: - meta = cfg.get("meta") if isinstance(cfg.get("meta"), dict) else {} - return max(1, _as_int(meta.get("weeks"), 8)) - - -def _first_int(patterns: list[str], text: str) -> int | None: - for pattern in patterns: - match = re.search(pattern, text, flags=re.I) - if match: - try: - return int(match.group(1)) - except (TypeError, ValueError): - continue - return None - - -def _detect_training_format(cfg: dict[str, Any], days: list[dict[str, Any]]) -> dict[str, Any]: - """Leitet bindende Session-Regeln aus dem Plan ab. - - Die KI bekommt damit nicht nur Übungen, sondern auch das tatsächliche - Ausführungsformat. Bei Tabata bleiben Arbeits- und Pausenintervalle fest. - """ - explicit_format = cfg.get("training_format") if isinstance(cfg.get("training_format"), dict) else {} - explicit_mode = str(explicit_format.get("mode") or "auto") - if explicit_mode in {"sets_reps", "tabata", "fixed_interval"}: - def _positive_int(field: str) -> int | None: - try: - number = int(explicit_format.get(field)) - except (TypeError, ValueError): - return None - return number if number > 0 else None - - fixed = explicit_mode in {"tabata", "fixed_interval"} or bool(explicit_format.get("fixed_interval")) - work = _positive_int("work_seconds") - rest = _positive_int("rest_seconds") - rounds = _positive_int("rounds") - constraints: list[str] = [] - if fixed: - interval = f"{work} Sekunden" if work else "die vorgegebene Dauer" - constraints.extend([ - f"Arbeitsintervalle bleiben fest bei {interval}; keine längeren Holds oder verlängerten Arbeitsintervalle empfehlen.", - "Progression nur innerhalb des Planformats: planmäßige schwierigere Variante, mehr Gewicht, saubererer ROM/Tempo oder bessere Technik; Pausen und Rundenzahl nicht eigenmächtig ändern.", - ]) - else: - constraints.append( - "Vorgegebene Wiederholungs-, Haltezeit-, Satz- und Pausenbereiche bleiben bindend; nach Erreichen der Obergrenze die planmäßige Schwierigkeit anpassen statt unbegrenzt Volumen zu addieren." - ) - front = cfg.get("front") if isinstance(cfg.get("front"), dict) else {} - return { - "mode": explicit_mode, - "is_tabata": explicit_mode == "tabata", - "fixed_interval": fixed, - "work_seconds": work, - "rest_seconds": rest, - "rounds": rounds, - "rounds_scope": "block", - "source": "plan", - "session_method": _plain_text(front.get("session_how_body") or ""), - "timer_note": _plain_text(front.get("timer_note") or ""), - "constraints": constraints, - } - - front = cfg.get("front") if isinstance(cfg.get("front"), dict) else {} - phases = cfg.get("phases") if isinstance(cfg.get("phases"), dict) else {} - phase_items = phases.get("items") if isinstance(phases.get("items"), list) else [] - fragments: list[str] = [ - str(front.get("session_how_body") or ""), - str(front.get("timer_note") or ""), - str(front.get("goals_note") or ""), - *[str(value or "") for value in front.get("reminders", []) if isinstance(front.get("reminders"), list)], - *[str(item.get("params") or "") for item in phase_items if isinstance(item, dict)], - ] - for day in days: - fragments.append(str(day.get("note") or "")) - for rotation in day.get("rotations", []) if isinstance(day.get("rotations"), list) else []: - fragments.append(str(rotation.get("label") or "")) - for exercise in rotation.get("exercises", []) if isinstance(rotation.get("exercises"), list) else []: - fragments.append(str(exercise.get("cue") or "")) - raw_text = " ".join(fragments) - plain = _plain_text(raw_text) - low = plain.casefold() - negated_tabata = bool(re.search(r"\b(?:kein|keine|ohne)\s+tabata\b|\bkein\s+20\s*/\s*10", low)) - is_tabata = not negated_tabata and ("tabata" in low or bool(re.search(r"\b20\s*/\s*10\b", low))) - work_seconds = _first_int([ - r"(\d+)\s*(?:s|sek(?:unden)?)\s*(?:arbeit|work)", - r"(?:arbeit|work)\s*(?:von|:)??\s*(\d+)\s*(?:s|sek(?:unden)?)", - r"(\d+)\s*/\s*\d+", - ], low) - rest_seconds = _first_int([ - r"(\d+)\s*(?:s|sek(?:unden)?)\s*(?:pause|rest)", - r"(?:pause|rest)\s*(?:von|:)??\s*(\d+)\s*(?:s|sek(?:unden)?)", - r"\d+\s*/\s*(\d+)", - ], low) - rounds = _first_int([ - r"(\d+)\s*[×x]\s*\(", - r"(\d+)\s*(?:runden|intervalle)", - r"(?:runden|intervalle)\s*(?:von|:)??\s*(\d+)", - ], low) - fixed_interval = bool(is_tabata or ((work_seconds and rest_seconds) and not negated_tabata)) - mode = "tabata" if is_tabata else ("fixed_interval" if fixed_interval else "sets_reps") - constraints: list[str] = [] - if fixed_interval: - interval = f"{work_seconds} Sekunden" if work_seconds else "die vorgegebene Dauer" - constraints.append( - f"Arbeitsintervalle bleiben fest bei {interval}; keine längeren Holds oder verlängerten Arbeitsintervalle empfehlen." - ) - constraints.append( - "Progression nur innerhalb des Planformats: planmäßige schwierigere Variante, mehr Gewicht, saubererer ROM/Tempo oder bessere Technik; Pausen und Rundenzahl nicht eigenmächtig ändern." - ) - else: - constraints.append( - "Vorgegebene Wiederholungs-, Haltezeit-, Satz- und Pausenbereiche bleiben bindend; nach Erreichen der Obergrenze die planmäßige Schwierigkeit anpassen statt unbegrenzt Volumen zu addieren." - ) - return { - "mode": mode, - "is_tabata": is_tabata, - "fixed_interval": fixed_interval, - "work_seconds": work_seconds, - "rest_seconds": rest_seconds, - "rounds": rounds, - "rounds_scope": "block", - "source": "text_fallback", - "session_method": _plain_text(front.get("session_how_body") or ""), - "timer_note": _plain_text(front.get("timer_note") or ""), - "constraints": constraints, - } - - -def _published_config_hash(config: dict[str, Any]) -> str: - raw = json.dumps(config, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") - return hashlib.sha256(raw).hexdigest() - - -def _normalize_plan(path: Path) -> dict[str, Any]: - raw = _read_json(path) - plan_name, cfg = _unwrap_plan(raw, path.stem) - wrapper = raw if isinstance(raw, dict) and isinstance(raw.get("config"), dict) else {} - if not _is_training_config(cfg): - raise ValueError("Keine Trainingsplan-Datei") - - raw_schema_version = int(cfg.get("schema_version") or 1) - raw_contract_version = int(cfg.get("contract_version") or 1) - if raw_schema_version > PLAN_SCHEMA_VERSION or raw_contract_version > CONTRACT_VERSION: - raise ValueError( - f"Der Trainingsplan verwendet Schema {raw_schema_version}/Vertrag {raw_contract_version}; " - f"dieser Tracker unterstützt höchstens {PLAN_SCHEMA_VERSION}/{CONTRACT_VERSION}." - ) - compatibility_warnings = [] - if raw_schema_version < PLAN_SCHEMA_VERSION: - compatibility_warnings.append( - f"Legacy-Plan-Schema {raw_schema_version}; Fallback-Erkennung bleibt aktiv. Im Trainingsplan-Editor neu veröffentlichen." - ) - if raw_contract_version < CONTRACT_VERSION: - compatibility_warnings.append( - f"Legacy-Trainingsvertrag {raw_contract_version}; einige stabile IDs oder Ergebnisschemata können fehlen." - ) - - meta = cfg.get("meta") if isinstance(cfg.get("meta"), dict) else {} - front = cfg.get("front") if isinstance(cfg.get("front"), dict) else {} - prepost = cfg.get("prepost") if isinstance(cfg.get("prepost"), dict) else {} - library = _library_lookup(cfg) - phases = _normalize_phases(cfg) - stages = _normalize_stages(cfg) - - days: list[dict[str, Any]] = [] - for fallback_index, day in enumerate(cfg.get("days", []), start=1): +def _public_config(cfg: dict[str, Any]) -> dict[str, Any]: + cfg = normalize_training_config(cfg) + exercises = _exercise_map(cfg) + days = [] + for day in cfg.get("days", []): if not isinstance(day, dict): continue - day_num = max(1, _as_int(day.get("num"), fallback_index)) - pp = prepost.get(str(day_num)) if isinstance(prepost.get(str(day_num)), dict) else {} - days.append({ - "id": str(day.get("id") or f"day-{day_num}"), - "num": day_num, - "focus": _plain_text(day.get("focus") or f"Tag {day_num}"), - "light": bool(day.get("light")), - "color": str(day.get("color") or ""), - "badge": deepcopy(day.get("badge") if isinstance(day.get("badge"), dict) else {}), - "note": str(day.get("note") or ""), - "note_bg": str(day.get("note_bg") or ""), - "rotations": _normalize_rotations(day, day_num, library), - "warmup": { - "text": str(pp.get("warmup") or ""), - "items": _sequence_items(pp.get("warmup")), - }, - "cooldown": { - "text": str(pp.get("cooldown") or ""), - "items": _sequence_items(pp.get("cooldown")), - }, - "stretch": { - "text": str(pp.get("stretch") or ""), - "items": _sequence_items(pp.get("stretch")), - }, - }) - days.sort(key=lambda item: item["num"]) + enriched = { + "id": str(day.get("id") or ""), + "num": int(day.get("num") or len(days) + 1), + "name": str(day.get("name") or f"Tag {len(days) + 1}"), + "warmup": deepcopy(day.get("warmup") if isinstance(day.get("warmup"), dict) else {"items": []}), + "cooldown": deepcopy(day.get("cooldown") if isinstance(day.get("cooldown"), dict) else {"items": []}), + "exercises": [], + } + for item in day.get("exercises", []): + if not isinstance(item, dict): + continue + exercise = exercises.get(str(item.get("exercise_id") or "")) + if not exercise: + continue + public_exercise = deepcopy(exercise) + public_exercise["current_progression"] = _current_progression(exercise) + enriched["exercises"].append({ + "id": str(item.get("id") or ""), + "exercise_id": str(exercise.get("id") or ""), + "exercise": public_exercise, + }) + days.append(enriched) + cfg["days"] = days + for exercise in cfg.get("exercises", []): + if isinstance(exercise, dict): + exercise["current_progression"] = _current_progression(exercise) + return cfg - weeks = _plan_weeks(cfg) - phase_by_week: list[int] = [] - for week in range(1, weeks + 1): - phase_index = len(phases) - 1 - for phase in phases: - if phase["start_week"] <= week <= phase["end_week"]: - phase_index = phase["index"] - break - phase_by_week.append(phase_index) - - training_format = _detect_training_format(cfg, days) - for day in days: - for rotation in day.get("rotations", []): - for exercise in rotation.get("exercises", []): - exercise["result_schema"] = _infer_result_schema( - exercise, stages, training_format, rotation.get("label") or "", - len(rotation.get("exercises", [])), - ) +def _read_plan(path: Path) -> dict[str, Any]: + raw = _read_json(path, {}) + name, cfg, wrapper = _unwrap(raw, path.stem) + normalized = normalize_training_config(cfg, plan_id=wrapper.get("plan_id") or cfg.get("plan_id") or path.stem) return { + "id": path.name, + "name": name, "source_file": path.name, - # Entwurfsänderungen im Planeditor dürfen laufende Sessions und Analysen - # nicht veralten lassen. Relevant ist ausschließlich die veröffentlichte Config. - "source_hash": _published_config_hash(cfg), - "plan_id": str(cfg.get("plan_id") or wrapper.get("plan_id") or path.stem), - "schema_version": raw_schema_version, - "contract_version": raw_contract_version, - "compatibility": { - "supported": True, - "legacy": bool(compatibility_warnings), - "warnings": compatibility_warnings, - "supported_plan_schema": PLAN_SCHEMA_VERSION, - "supported_contract": CONTRACT_VERSION, - }, - "published_revision": int(wrapper.get("published_revision") or 1), - "published_at": str(wrapper.get("published_at") or ""), - "name": plan_name, - "title": _plain_text(meta.get("title") or plan_name), - "subtitle": _plain_text(meta.get("subtitle") or ""), - "weeks": weeks, - "days": days, - "phases": phases, - "phase_by_week": phase_by_week, - "stages": stages, - "exercise_catalog": deepcopy(cfg.get("exercise_catalog") if isinstance(cfg.get("exercise_catalog"), dict) else {}), - "front": { - "goals_head": _plain_text(front.get("goals_head") or ""), - "goals_box": str(front.get("goals_box") or ""), - "goals_note": str(front.get("goals_note") or ""), - "reminder_head": _plain_text(front.get("reminder_head") or ""), - "reminders": [str(value or "") for value in front.get("reminders", [])] - if isinstance(front.get("reminders"), list) else [], - "session_how_head": _plain_text(front.get("session_how_head") or ""), - "session_how_body": str(front.get("session_how_body") or ""), - "timer_note": str(front.get("timer_note") or ""), - }, - "training_format": training_format, + "revision": int(wrapper.get("revision") or 1) if isinstance(wrapper, dict) else 1, + "published_revision": int(wrapper.get("published_revision") or 1) if isinstance(wrapper, dict) else 1, + "updated_at": str(wrapper.get("updated_at") or "") if isinstance(wrapper, dict) else "", + "config": _public_config(normalized), } -# --------------------------------------------------------------------------- -# Tracker-Daten -# --------------------------------------------------------------------------- -def _default_tracker(plan: dict[str, Any]) -> dict[str, Any]: - return { - "version": TRACKER_SCHEMA_VERSION, - "revision": 1, - "source_file": plan["source_file"], - "source_hash": plan["source_hash"], - "plan_id": plan.get("plan_id"), - "plan_revision": plan.get("published_revision"), - "profile": { - "start_date": "", - "display_name": "", - "plan_notes": "", - }, - "progressions": {}, # Legacy-Fallback aus Version 1; nicht mehr aktiv bearbeitet. - "sessions": {}, - "week_statuses": {}, - "created_at": _utc_now(), - "updated_at": _utc_now(), - } +def _write_plan_config(path: Path, cfg: dict[str, Any]) -> dict[str, Any]: + raw = _read_json(path, {}) + name, _, wrapper = _unwrap(raw, path.stem) + normalized = normalize_training_config(cfg, plan_id=cfg.get("plan_id") or path.stem) + validation = validate_training_config(normalized) + if validation["errors"]: + raise ValueError(json.dumps(validation["errors"], ensure_ascii=False)) + normalized = validation["config"] + now = utc_now() - -def _normalize_analysis_state(value: Any) -> dict[str, Any]: - state = value if isinstance(value, dict) else {} - status = str(state.get("status") or "idle") - if status not in {"idle", "running", "done", "error"}: status = "idle" - clean = dict(state); clean["status"] = status - if status == "running": - lease = str(clean.get("lease_expires_at") or "") - try: - expired = datetime.now(timezone.utc) > datetime.fromisoformat(lease) - except (TypeError, ValueError): - started = str(clean.get("started_at") or "") - try: expired = (datetime.now(timezone.utc) - datetime.fromisoformat(started)).total_seconds() > _ANALYSIS_TIMEOUT_SECONDS - except (TypeError, ValueError): expired = False - if expired: - clean.update({ - "status": "error", - "error": "Die vorherige Analyse hat ihre Job-Lease verloren oder wurde unterbrochen.", - "message": "Analysejob unterbrochen.", - "finished_at": _utc_now(), - }) - return clean - - -def _load_tracker(plan: dict[str, Any]) -> dict[str, Any]: - tracker = _read_json(_tracker_path(plan["source_file"]), None) - if not isinstance(tracker, dict): - return _default_tracker(plan) - original_tracker = deepcopy(tracker) - tracker.setdefault("version", TRACKER_SCHEMA_VERSION) - tracker.setdefault("revision", 1) - tracker.setdefault("profile", {}) - tracker.setdefault("progressions", {}) - tracker.setdefault("sessions", {}) - tracker.setdefault("week_statuses", {}) - tracker.setdefault("created_at", _utc_now()) - - # Analysefelder aus älteren Plugin-Versionen werden weder gelesen noch - # wieder gespeichert. Die eigentliche Bereinigung bestehender Dateien kann - # separat erfolgen; jeder neue Autosave schreibt bereits das saubere Modell. - tracker.pop("analysis_cache", None) - tracker.pop("analysis_state", None) - tracker.pop("analyses", None) - tracker["version"] = TRACKER_SCHEMA_VERSION - tracker["source_file"] = plan["source_file"] - source_changed = tracker.get("source_hash") not in (None, plan["source_hash"]) - tracker, migrated = _migrate_tracker_sessions(plan, tracker) - if migrated: - tracker["revision"] = int(tracker.get("revision") or 1) + 1 - tracker["updated_at"] = _utc_now() - persisted = deepcopy(tracker) - persisted.pop("source_changed", None) - backup_path = SESSIONS_DIR / ".migration-backups" / f"{plan['source_file']}.pre-result-data-v2.json" - if not backup_path.exists(): - _atomic_json_write(backup_path, original_tracker) - _atomic_json_write(_tracker_path(plan["source_file"]), persisted) - tracker["source_changed"] = source_changed - return tracker - - -def _sanitize(value: Any, depth: int = 0) -> Any: - """Begrenzt Nutzdaten ohne die flexible Tracker-Struktur zu zerstören.""" - if depth > 12: - return None - if value is None or isinstance(value, (bool, int, float)): - return value - if isinstance(value, str): - return value[:20000] - if isinstance(value, list): - return [_sanitize(item, depth + 1) for item in value[:2000]] - if isinstance(value, dict): - result = {} - for index, (key, item) in enumerate(value.items()): - if index >= 5000: - break - clean_key = str(key)[:200] - result[clean_key] = _sanitize(item, depth + 1) - return result - return str(value)[:20000] - - -def _result_number(value: Any) -> float | None: - if value in (None, "") or isinstance(value, bool): - return None - try: - number = float(str(value).replace(",", ".")) - except (TypeError, ValueError): - return None - if number < 0 or number > 100000: - return None - return round(number, 3) - - -def _result_values(value: Any, limit: int = 20) -> list[float | None]: - if not isinstance(value, list): - return [] - return [_result_number(item) for item in value[:limit]] - - -def _sanitize_result_data(value: Any) -> dict[str, Any] | None: - if not isinstance(value, dict): - return None - mode = str(value.get("mode") or "") - if mode not in {"reps", "seconds", "minutes"}: - return None - laterality = str(value.get("laterality") or "bilateral") - if laterality not in {"bilateral", "unilateral"}: - laterality = "bilateral" - sides_mode = str(value.get("sides_mode") or "same") - if sides_mode not in {"same", "separate"}: - sides_mode = "same" - try: - sets = max(1, min(20, int(value.get("sets") or 1))) - except (TypeError, ValueError): - sets = 1 - clean: dict[str, Any] = { - "version": RESULT_DATA_VERSION, - "mode": mode, - "laterality": laterality, - "sides_mode": sides_mode if laterality == "unilateral" else "same", - "sets": sets, - "weight_kg": _result_number(value.get("weight_kg")), - "values": _result_values(value.get("values")), - "left_values": _result_values(value.get("left_values")), - "right_values": _result_values(value.get("right_values")), - } - for key in ("values", "left_values", "right_values"): - values = clean[key][:sets] - values.extend([None] * (sets - len(values))) - clean[key] = values - has_values = any(number is not None for number in clean["values"] + clean["left_values"] + clean["right_values"]) - if not has_values and clean["weight_kg"] is None: - return None - return clean - - -def _format_result_number(value: Any) -> str: - number = _result_number(value) - if number is None: - return "" - return str(int(number)) if float(number).is_integer() else f"{number:.3f}".rstrip("0").rstrip(".") - - -def _format_result_data(value: Any) -> str: - data = _sanitize_result_data(value) - if not data: - return "" - unit = " s" if data["mode"] == "seconds" else (" min" if data["mode"] == "minutes" else " Reps") - weight = _format_result_number(data.get("weight_kg")) - prefix = f"{weight} kg · " if weight else "" - - def joined(values: list[Any]) -> str: - return "/".join(_format_result_number(item) or "–" for item in values) - - if data["laterality"] == "unilateral" and data["sides_mode"] == "separate": - return f"{prefix}L {joined(data['left_values'])} · R {joined(data['right_values'])}{unit}".strip() - side = " je Seite" if data["laterality"] == "unilateral" else "" - return f"{prefix}{joined(data['values'])}{unit}{side}".strip() - - -def _item_has_user_data(item: Any) -> bool: - if not isinstance(item, dict): - return False - status = str(item.get("completion_status") or ("completed" if item.get("done") else "planned")) - if status != "planned" or bool(item.get("done")): - return True - if isinstance(item.get("result_data"), dict): - return True - return any(_has_text(item.get(field)) for field in ( - "result", "note", "progression", "exercise_name", "progression_id", "skip_reason" - )) - - -def _merge_migrated_item(legacy: dict[str, Any], stable: dict[str, Any]) -> dict[str, Any]: - """Vereinigt doppelte Legacy-/Stable-ID-Einträge ohne befüllte Werte zu verlieren. - - Ein durch die neue UI angelegter leerer Stable-ID-Platzhalter darf einen - bereits abgeschlossenen Legacy-Eintrag insbesondere nicht wieder auf - ``planned``/``done=false`` zurücksetzen. - """ - merged = deepcopy(legacy) - legacy_status = str(legacy.get("completion_status") or ("completed" if legacy.get("done") else "planned")) - stable_status = str(stable.get("completion_status") or ("completed" if stable.get("done") else "planned")) - for key, value in stable.items(): - if key not in merged: - merged[key] = deepcopy(value) - continue - empty = value is None or value == "" or value == [] or value == {} - if empty: - continue - if key in {"done", "completion_status"} and stable_status == "planned" and legacy_status != "planned": - continue - merged[key] = deepcopy(value) - if stable_status != "planned": - merged["completion_status"] = stable_status - merged["done"] = stable_status in {"completed", "partial"} - elif legacy_status != "planned": - merged["completion_status"] = legacy_status - merged["done"] = legacy_status in {"completed", "partial"} - return merged - - -def _exercise_lookup_for_day(plan: dict[str, Any], day_num: int) -> tuple[dict[str, dict[str, Any]], dict[str, str]]: - by_id: dict[str, dict[str, Any]] = {} - legacy_to_stable: dict[str, str] = {} - day = next((entry for entry in plan.get("days", []) if isinstance(entry, dict) and int(entry.get("num") or 0) == day_num), None) - if not isinstance(day, dict): - return by_id, legacy_to_stable - for rotation in day.get("rotations", []) if isinstance(day.get("rotations"), list) else []: - if not isinstance(rotation, dict): - continue - for exercise in rotation.get("exercises", []) if isinstance(rotation.get("exercises"), list) else []: - if not isinstance(exercise, dict): - continue - stable_id = str(exercise.get("id") or "") - legacy_id = str(exercise.get("legacy_id") or "") - if stable_id: - by_id[stable_id] = exercise - if legacy_id and stable_id: - by_id[legacy_id] = exercise - legacy_to_stable[legacy_id] = stable_id - return by_id, legacy_to_stable - - -def _progression_step_for_item(plan: dict[str, Any], exercise: dict[str, Any], item: dict[str, Any]) -> dict[str, Any] | None: - progression_id = str(item.get("progression_id") or exercise.get("progression_id") or "") - stage = plan.get("stages", {}).get(progression_id) if isinstance(plan.get("stages"), dict) else None - steps = stage.get("steps") if isinstance(stage, dict) and isinstance(stage.get("steps"), list) else [] - wanted_id = str(item.get("progression_step_id") or "") - if wanted_id: - found = next((step for step in steps if isinstance(step, dict) and str(step.get("id") or "") == wanted_id), None) - if found: - return found - wanted_name = _plain_text(item.get("progression") or "").casefold() - if wanted_name: - return next(( - step for step in steps - if isinstance(step, dict) and _plain_text(step.get("name") or "").casefold() == wanted_name - ), None) - return None - - -def _resolved_schema_for_item(plan: dict[str, Any], exercise: dict[str, Any], item: dict[str, Any]) -> dict[str, Any]: - schema = deepcopy(exercise.get("result_schema") if isinstance(exercise.get("result_schema"), dict) else {}) - step = _progression_step_for_item(plan, exercise, item) - if isinstance(step, dict) and isinstance(step.get("result_schema"), dict): - schema.update(step["result_schema"]) - manual = item.get("result_format") - if isinstance(manual, dict): - for key in ("mode", "weight_mode", "laterality", "sides_mode", "sets", "default_sets", "locked_sets"): - if key in manual: - schema[key] = manual[key] - mode = str(schema.get("mode") or "auto") - if mode not in {"auto", "reps", "seconds", "minutes", "none"}: - mode = "auto" - weight_mode = str(schema.get("weight_mode") or "none") - if weight_mode not in {"none", "optional", "required"}: - weight_mode = "none" - laterality = str(schema.get("laterality") or "bilateral") - if laterality not in {"bilateral", "unilateral"}: - laterality = "bilateral" - sides_mode = str(schema.get("sides_mode") or "same") - if sides_mode not in {"same", "separate"}: - sides_mode = "same" - try: - sets = max(1, min(20, int(schema.get("sets") or schema.get("default_sets") or 1))) - except (TypeError, ValueError): - sets = 1 - return { - "mode": mode, - "weight_mode": weight_mode, - "laterality": laterality, - "sides_mode": sides_mode if laterality == "unilateral" else "same", - "default_sets": sets, - "locked_sets": bool(schema.get("locked_sets")), - } - - -def _parse_legacy_result_data(value: Any, schema: dict[str, Any]) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: - """Überführt eindeutige alte Freitextergebnisse verlustfrei in result_data 2. - - Die historische Eingabe ist für die konkrete Session bindend. Weicht ihre - eindeutig erkennbare Messart vom heutigen Plan ab, wird deshalb zusätzlich - ein manueller ``result_format``-Override gespeichert, statt Werte zu verwerfen. - """ - raw = str(value or "").strip() - if not raw: - return None, None - low = raw.lower().replace("×", "x") - explicit_mode: str | None = None - if re.search(r"(?:\d|\s)(?:min\.?|minuten)\b", low, flags=re.I): - explicit_mode = "minutes" - elif re.search(r"(?:\d|\s)(?:s|sek\.?|sekunden)\b", low, flags=re.I): - explicit_mode = "seconds" - recommended_mode = str(schema.get("mode") or "auto") - mode = explicit_mode or (recommended_mode if recommended_mode in {"reps", "seconds", "minutes"} else "reps") - - weight_match = re.search(r"(\d+(?:[.,]\d+)?)\s*kg\b", low, flags=re.I) - weight = _result_number(weight_match.group(1)) if weight_match else None - - def number_list(part: Any) -> list[float | None]: - return [ - number for number in (_result_number(match.group(0)) for match in re.finditer(r"\d+(?:[.,]\d+)?", str(part or ""))) - if number is not None - ] - - def clean_part(part: Any) -> str: - text = re.sub(r"\d+(?:[.,]\d+)?\s*kg\b", " ", str(part or ""), flags=re.I) - return re.sub( - r"\b(?:reps?|wiederholungen|s|sek\.?|sekunden|min\.?|minuten|je seite|pro seite)\b", - " ", text, flags=re.I, - ) - - separate = re.search( - r"\bL(?:inks)?\s*:?\s*([^·;|]+)[·;|]\s*R(?:echts)?\s*:?\s*(.+)$", - raw, flags=re.I, - ) - if separate: - left = number_list(clean_part(separate.group(1))) - right = number_list(clean_part(separate.group(2))) - if not left and not right: - return None, None - sets = max(len(left), len(right), 1) - data = _sanitize_result_data({ - "version": RESULT_DATA_VERSION, - "mode": mode, - "laterality": "unilateral", - "sides_mode": "separate", - "sets": sets, - "weight_kg": weight, - "values": [], - "left_values": left, - "right_values": right, - }) + if isinstance(wrapper, dict) and ("config" in wrapper or "draft" in wrapper): + wrapper["name"] = name + wrapper["plan_id"] = normalized.get("plan_id") or path.stem + wrapper["config"] = deepcopy(normalized) + wrapper["draft"] = deepcopy(normalized) + wrapper["revision"] = int(wrapper.get("revision") or 1) + 1 + wrapper["published_revision"] = int(wrapper.get("published_revision") or 1) + 1 + wrapper["updated_at"] = now + wrapper["published_at"] = now + payload = wrapper else: - work = clean_part(raw) - repeated = re.search(r"(\d+(?:[.,]\d+)?)\s*x\s*(\d+(?:[.,]\d+)?)", work, flags=re.I) - if repeated: - count = max(1, min(20, int(float(repeated.group(1).replace(",", "."))))) - repeated_value = _result_number(repeated.group(2)) - values = [repeated_value] * count if repeated_value is not None else [] - else: - values = number_list(work) - if not values and weight is None: - return None, None - per_side = bool(re.search(r"(?:je|pro)\s+seite|/\s*seite", low, flags=re.I)) - laterality = "unilateral" if per_side else str(schema.get("laterality") or "bilateral") - if laterality not in {"bilateral", "unilateral"}: - laterality = "bilateral" - # Eine einzige historische Zahlenreihe ist nie eine belastbare Links-/ - # Rechts-Trennung. Bei einseitigen Übungen bedeutet sie daher "je Seite". - sides_mode = "same" - sets = max(1, min(20, len(values) or int(schema.get("default_sets") or 1))) - data = _sanitize_result_data({ - "version": RESULT_DATA_VERSION, - "mode": mode, - "laterality": laterality, - "sides_mode": sides_mode, - "sets": sets, - "weight_kg": weight, - "values": values, - "left_values": [], - "right_values": [], - }) - if data is None: - return None, None - - actual_weight_mode = str(schema.get("weight_mode") or "none") - if weight is not None and actual_weight_mode == "none": - actual_weight_mode = "optional" - differs = ( - data["mode"] != recommended_mode - or data["laterality"] != str(schema.get("laterality") or "bilateral") - or data["sides_mode"] != str(schema.get("sides_mode") or "same") - or actual_weight_mode != str(schema.get("weight_mode") or "none") - ) - result_format = None - if differs: - result_format = { - "mode": data["mode"], - "laterality": data["laterality"], - "sides_mode": data["sides_mode"], - "weight_mode": actual_weight_mode, - } - return data, result_format - - -def _migrate_tracker_sessions(plan: dict[str, Any], tracker: dict[str, Any]) -> tuple[dict[str, Any], bool]: - """Migriert alle vorhandenen Sessions serverseitig und atomar auf stabile IDs. - - Dadurch hängt die Datenmigration nicht davon ab, welche Session zufällig im - Browser geöffnet oder verändert wurde. Uneindeutige Freitexte bleiben als - ``result`` erhalten; eindeutige Werte werden zusätzlich strukturiert gespeichert. - """ - changed = False - tracker = deepcopy(tracker) - desired_root = { - "version": TRACKER_SCHEMA_VERSION, - "source_file": plan.get("source_file"), - "source_hash": plan.get("source_hash"), - "plan_id": plan.get("plan_id"), - "plan_revision": plan.get("published_revision"), - } - for key, value in desired_root.items(): - if tracker.get(key) != value: - tracker[key] = value - changed = True - sessions = tracker.get("sessions") if isinstance(tracker.get("sessions"), dict) else {} - if tracker.get("sessions") is not sessions: - tracker["sessions"] = sessions - changed = True - - for session_key, session in sessions.items(): - if not isinstance(session, dict): - continue - _, day_num = _session_key_parts(str(session_key)) - if day_num == 9999: - continue - by_id, legacy_to_stable = _exercise_lookup_for_day(plan, day_num) - items = session.get("items") if isinstance(session.get("items"), dict) else {} - if session.get("items") is not items: - session["items"] = items - changed = True - - for legacy_id, stable_id in list(legacy_to_stable.items()): - if legacy_id not in items or legacy_id == stable_id: - continue - legacy_item = items.pop(legacy_id) - if isinstance(legacy_item, dict): - stable_item = items.get(stable_id) - items[stable_id] = _merge_migrated_item( - legacy_item, - stable_item if isinstance(stable_item, dict) else {}, - ) - changed = True - - for item_id, item in list(items.items()): - if not isinstance(item, dict): - continue - exercise = by_id.get(str(item_id)) - status = str(item.get("completion_status") or ("completed" if item.get("done") else "planned")) - if status not in VALID_ITEM_STATUSES: - status = "planned" - if item.get("completion_status") != status: - item["completion_status"] = status - changed = True - done = status in {"completed", "partial"} - if bool(item.get("done")) != done: - item["done"] = done - changed = True - if status != "skipped" and "skip_reason" in item: - item.pop("skip_reason", None) - changed = True - - if isinstance(exercise, dict) and _item_has_user_data(item): - metadata = { - "progression_id": str(item.get("progression_id") or exercise.get("progression_id") or ""), - "exercise_name": _plain_text(item.get("exercise_name") or exercise.get("name") or ""), - } - for key, value in metadata.items(): - if value and item.get(key) != value: - item[key] = value - changed = True - step = _progression_step_for_item(plan, exercise, item) - if isinstance(step, dict) and step.get("id") and not item.get("progression_step_id"): - item["progression_step_id"] = str(step["id"]) - changed = True - - if isinstance(item.get("result_data"), dict): - data = _sanitize_result_data(item.get("result_data")) - if data is not None: - formatted = _format_result_data(data) - if item.get("result_data") != data: - item["result_data"] = data - changed = True - if item.get("result") != formatted: - item["result"] = formatted - changed = True - else: - item.pop("result_data", None) - changed = True - elif isinstance(exercise, dict) and _has_text(item.get("result")): - schema = _resolved_schema_for_item(plan, exercise, item) - data, result_format = _parse_legacy_result_data(item.get("result"), schema) - if data is not None: - item["result_data"] = data - item["result"] = _format_result_data(data) - if result_format is not None: - item["result_format"] = result_format - changed = True - - for key, value in { - "plan_id": plan.get("plan_id"), - "plan_revision": plan.get("published_revision"), - }.items(): - if session.get(key) != value: - session[key] = value - changed = True - return tracker, changed - - -def _normalize_tracker_payload(plan: dict[str, Any], incoming: Any, existing: dict[str, Any]) -> dict[str, Any]: - if not isinstance(incoming, dict): - raise ValueError("Tracker-Daten müssen ein JSON-Objekt sein") - clean = _sanitize(incoming) - if not isinstance(clean, dict): - raise ValueError("Ungültige Tracker-Daten") - clean["version"] = TRACKER_SCHEMA_VERSION - clean["source_file"] = plan["source_file"] - clean["source_hash"] = plan["source_hash"] - clean["plan_id"] = plan.get("plan_id") - clean["plan_revision"] = plan.get("published_revision") - clean["created_at"] = existing.get("created_at") or _utc_now() - clean["updated_at"] = _utc_now() - clean.pop("source_changed", None) - clean.pop("analysis_cache", None); clean.pop("analysis_state", None); clean.pop("analyses", None) - if not isinstance(clean.get("profile"), dict): clean["profile"] = {} - if not isinstance(clean.get("progressions"), dict): clean["progressions"] = {} - if not isinstance(clean.get("sessions"), dict): clean["sessions"] = {} - if not isinstance(clean.get("week_statuses"), dict): clean["week_statuses"] = {} - valid_statuses = {"planned", "in_progress", "stopped", "completed"} - for session in clean["sessions"].values(): - if not isinstance(session, dict): continue - session.setdefault("plan_id", existing.get("plan_id") or plan.get("plan_id")) - session.setdefault("plan_revision", existing.get("plan_revision") or plan.get("published_revision")) - status = str(session.get("status") or "planned") - session["status"] = status if status in valid_statuses else "planned" - items = session.get("items") if isinstance(session.get("items"), dict) else {} - session["items"] = items - for item in items.values(): - if not isinstance(item, dict): continue - item_status = str(item.get("completion_status") or ("completed" if item.get("done") else "planned")) - item["completion_status"] = item_status if item_status in VALID_ITEM_STATUSES else "planned" - item["done"] = item["completion_status"] in {"completed", "partial"} - if item["completion_status"] != "skipped": item.pop("skip_reason", None) - if isinstance(item.get("result_data"), dict): - data = _sanitize_result_data(item.get("result_data")) - if data is not None: - item["result_data"] = data; item["result"] = _format_result_data(data) - else: item.pop("result_data", None) - for week, status in list(clean["week_statuses"].items()): - if not isinstance(status, dict): clean["week_statuses"].pop(week, None); continue - status["status"] = "closed" if status.get("status") == "closed" else "open" - clean, _ = _migrate_tracker_sessions(plan, clean) - return clean - - -def _save_tracker(plan: dict[str, Any], incoming: Any) -> dict[str, Any]: - existing = _load_tracker(plan) - expected = incoming.get("expected_revision") if isinstance(incoming, dict) else None - if expected is not None and int(expected) != int(existing.get("revision") or 1): - raise RuntimeError(f"revision_conflict:{int(existing.get('revision') or 1)}") - payload = incoming.get("tracker") if isinstance(incoming, dict) and isinstance(incoming.get("tracker"), dict) else incoming - clean = _normalize_tracker_payload(plan, payload, existing) - clean["revision"] = int(existing.get("revision") or 1) + 1 - _atomic_json_write(_tracker_path(plan["source_file"]), clean) - return clean - - -def _patch_tracker(plan: dict[str, Any], payload: Any) -> dict[str, Any]: - if not isinstance(payload, dict): raise ValueError("Ungültiger Session-Patch") - existing = _load_tracker(plan) - session_payload = payload.get("session") - if isinstance(session_payload, dict): - session_payload = deepcopy(session_payload) - session_payload.setdefault("plan_id", plan.get("plan_id")) - session_payload.setdefault("plan_revision", plan.get("published_revision")) - payload = dict(payload) - payload["session"] = session_payload - try: - merged = merge_session_patch(existing, payload, _utc_now()) - except RuntimeError: - raise - clean = _normalize_tracker_payload(plan, merged, existing) - clean["revision"] = int(merged.get("revision") or int(existing.get("revision") or 1) + 1) - _atomic_json_write(_tracker_path(plan["source_file"]), clean) - return clean - - -def _openai_ready() -> bool: - return bool(os.environ.get("OPENAI_API_KEY", "").strip() and os.environ.get("OPENAI_MODEL", "").strip()) - - -def _stable_hash(value: Any) -> str: - raw = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") - return hashlib.sha256(raw).hexdigest() - - -def _session_key_parts(key: str) -> tuple[int, int]: - match = re.fullmatch(r"w(\d+)-d(\d+)", str(key or "")) - if not match: - return (9999, 9999) - return int(match.group(1)), int(match.group(2)) - - -def _session_date(profile: dict[str, Any], week: int, day_num: int) -> str: - raw = str(profile.get("start_date") or "").strip() - if not raw: - return "" - try: - start = date.fromisoformat(raw) - except ValueError: - return "" - return (start + timedelta(days=(week - 1) * 7 + day_num - 1)).isoformat() - - -def _phase_for_week(plan: dict[str, Any], week: int) -> dict[str, Any]: - phase_map = plan.get("phase_by_week") if isinstance(plan.get("phase_by_week"), list) else [] - phases = plan.get("phases") if isinstance(plan.get("phases"), list) else [] - index = phase_map[max(0, week - 1)] if week - 1 < len(phase_map) else 0 - if isinstance(index, int) and 0 <= index < len(phases): - return phases[index] - return phases[0] if phases else {"name": "", "params": ""} - - -def _has_text(value: Any) -> bool: - return bool(str(value or "").strip()) - - -def _session_records(plan: dict[str, Any], tracker: dict[str, Any]) -> list[dict[str, Any]]: - """Normalisiert ausschließlich tatsächlich befüllte Sessiondaten.""" - days_by_num = {int(day.get("num", 0)): day for day in plan.get("days", []) if isinstance(day, dict)} - profile = tracker.get("profile") if isinstance(tracker.get("profile"), dict) else {} - sessions_out: list[dict[str, Any]] = [] - sessions = tracker.get("sessions") if isinstance(tracker.get("sessions"), dict) else {} - - for key, session in sorted(sessions.items(), key=lambda pair: _session_key_parts(pair[0])): - if not isinstance(session, dict): - continue - week, day_num = _session_key_parts(key) - if week == 9999: - continue - day = days_by_num.get(day_num, {}) - exercise_by_id: dict[str, dict[str, Any]] = {} - for rotation in day.get("rotations", []) if isinstance(day.get("rotations"), list) else []: - if not isinstance(rotation, dict): - continue - for exercise in rotation.get("exercises", []) if isinstance(rotation.get("exercises"), list) else []: - if isinstance(exercise, dict): - enriched = dict(exercise) - enriched["rotation"] = _plain_text(rotation.get("label") or "") - enriched["kind"] = "exercise" - exercise_by_id[str(exercise.get("id") or "")] = enriched - legacy_id = str(exercise.get("legacy_id") or "") - if legacy_id: - exercise_by_id[legacy_id] = enriched - for routine_name in ("warmup", "cooldown", "stretch"): - routine = day.get(routine_name) if isinstance(day.get(routine_name), dict) else {} - for index, label in enumerate(routine.get("items", []) if isinstance(routine.get("items"), list) else []): - exercise_by_id[f"{routine_name}-{index}"] = { - "name": _plain_text(label), "progression_id": "", "cue": "", - "rotation": routine_name, "kind": routine_name, - } - - items_out: list[dict[str, Any]] = [] - items = session.get("items") if isinstance(session.get("items"), dict) else {} - for item_id, item in items.items(): - if not isinstance(item, dict): - continue - item_status = str(item.get("completion_status") or ("completed" if item.get("done") else "planned")) - meaningful = item_status != "planned" or isinstance(item.get("result_data"), dict) or any(_has_text(item.get(field)) for field in ( - "result", "note", "progression", "exercise_name", "progression_id", "skip_reason" - )) - if not meaningful: - continue - exercise = exercise_by_id.get(str(item_id), {}) - items_out.append({ - "item_id": str(item_id), - "exercise": _plain_text(item.get("exercise_name") or exercise.get("name") or item_id), - "progression_id": str(item.get("progression_id") or exercise.get("progression_id") or ""), - "progression": _plain_text(item.get("progression") or ""), - "result": _plain_text(item.get("result") or ""), - "result_data": deepcopy(item.get("result_data")) if isinstance(item.get("result_data"), dict) else None, - "note": _plain_text(item.get("note") or ""), - "done": bool(item.get("done")), - "completion_status": item_status, - "skip_reason": _plain_text(item.get("skip_reason") or ""), - "progression_step_id": str(item.get("progression_step_id") or ""), - "exercise_id": str(exercise.get("exercise_id") or ""), - "movement_cluster": str(exercise.get("movement_cluster") or ""), - "cue": _plain_text(exercise.get("cue") or ""), - "rotation": _plain_text(exercise.get("rotation") or ""), - "kind": str(exercise.get("kind") or "other"), - }) - - section_notes = { - name: _plain_text(session.get(f"{name}_note") or "") - for name in ("warmup", "cooldown", "stretch") - if _has_text(session.get(f"{name}_note")) - } - meaningful_session = ( - session.get("status") in {"in_progress", "stopped", "completed"} - or _has_text(session.get("note")) or bool(section_notes) or bool(items_out) - ) - if not meaningful_session: - continue - phase = _phase_for_week(plan, week) - sessions_out.append({ - "session_key": key, - "week": week, - "day": day_num, - "date": _session_date(profile, week, day_num), - "focus": _plain_text(day.get("focus") or f"Tag {day_num}"), - "light": bool(day.get("light")), - "phase": _plain_text(phase.get("name") or ""), - "phase_instructions": _plain_text(phase.get("params") or ""), - "status": str(session.get("status") or "planned"), - "plan_id": str(session.get("plan_id") or tracker.get("plan_id") or plan.get("plan_id") or ""), - "plan_revision": int(session.get("plan_revision") or tracker.get("plan_revision") or plan.get("published_revision") or 1), - "started_at": str(session.get("started_at") or ""), - "stopped_at": str(session.get("stopped_at") or ""), - "completed_at": str(session.get("completed_at") or ""), - "session_note": _plain_text(session.get("note") or ""), - "section_notes": section_notes, - "items": items_out, - }) - return sessions_out - - -def _plan_context( - plan: dict[str, Any], *, week: int | None = None, progression_ids: set[str] | None = None -) -> dict[str, Any]: - stages = plan.get("stages") if isinstance(plan.get("stages"), dict) else {} - if progression_ids: - stages = {key: value for key, value in stages.items() if key in progression_ids} - phase = _phase_for_week(plan, week) if week else None - return { - "plan_id": plan.get("plan_id"), - "published_revision": plan.get("published_revision"), - "schema_version": plan.get("schema_version"), - "contract_version": plan.get("contract_version"), - "title": plan.get("title") or plan.get("name"), - "subtitle": plan.get("subtitle") or "", - "weeks": plan.get("weeks"), - "selected_week": week, - "selected_phase": { - "name": _plain_text(phase.get("name") or ""), - "instructions": _plain_text(phase.get("params") or ""), - } if phase else None, - "training_format": deepcopy(plan.get("training_format") or {}), - "guidance": { - "goals": _plain_text(plan.get("front", {}).get("goals_box") or ""), - "goals_note": _plain_text(plan.get("front", {}).get("goals_note") or ""), - "session_method": _plain_text(plan.get("front", {}).get("session_how_body") or ""), - "timer_note": _plain_text(plan.get("front", {}).get("timer_note") or ""), - "reminders": [_plain_text(item) for item in plan.get("front", {}).get("reminders", [])], - }, - "days": [ - { - "day": day.get("num"), - "focus": _plain_text(day.get("focus") or ""), - "light": bool(day.get("light")), - "note": _plain_text(day.get("note") or ""), - "warmup": [_plain_text(item) for item in day.get("warmup", {}).get("items", [])], - "cooldown": [_plain_text(item) for item in day.get("cooldown", {}).get("items", [])], - "stretch": [_plain_text(item) for item in day.get("stretch", {}).get("items", [])], - "blocks": [ - { - "label": _plain_text(rotation.get("label") or ""), - "exercises": [ - { - "id": exercise.get("id") or "", - "exercise_id": exercise.get("exercise_id") or "", - "name": _plain_text(exercise.get("name") or ""), - "progression_id": exercise.get("progression_id") or "", - "movement_cluster": exercise.get("movement_cluster") or "", - "cue": _plain_text(exercise.get("cue") or ""), - } - for exercise in rotation.get("exercises", []) if isinstance(exercise, dict) - ], - } - for rotation in day.get("rotations", []) if isinstance(rotation, dict) - ], - } - for day in plan.get("days", []) if isinstance(day, dict) - ], - "phases": [ - { - "name": _plain_text(item.get("name") or ""), - "instructions": _plain_text(item.get("params") or ""), - "start_week": item.get("start_week"), - "end_week": item.get("end_week"), - } - for item in plan.get("phases", []) if isinstance(item, dict) - ], - "progression_stages": stages, - "exercise_catalog": deepcopy(plan.get("exercise_catalog") or {}), - } - - -def _latest_state_before(records: list[dict[str, Any]], week: int) -> list[dict[str, Any]]: - latest: dict[str, dict[str, Any]] = {} - for session in records: - if int(session.get("week") or 0) >= week: - continue - for item in session.get("items", []): - progression_id = str(item.get("progression_id") or "") - if not progression_id: - continue - latest[progression_id] = { - "progression_id": progression_id, - "exercise": item.get("exercise") or progression_id, - "progression": item.get("progression") or "", - "result": item.get("result") or "", - "note": item.get("note") or "", - "session_key": session.get("session_key") or "", - } - return list(latest.values()) - - -def _plan_bodyweight_kg(plan: dict[str, Any]) -> float | None: - """Liest ein optional im Plantext genanntes Körpergewicht, ohne es zu raten.""" - candidates = [ - plan.get("subtitle"), plan.get("title"), - (plan.get("meta") or {}).get("subtitle") if isinstance(plan.get("meta"), dict) else "", - ] - for candidate in candidates: - for value in re.findall(r"(? dict[str, Any]: - tables = [] - for table in _EQUIVALENCE_TABLES: - mode = table["mode"] - unit = "Referenz-Reps" if mode == "reps" else "Referenzsekunden" - variants = [] - for variant in table.get("variants", []): - factor = float(variant.get("factor") or 1) - if mode == "reps": - if factor <= 1: - needed = round(1 / factor, 1) if factor else 0 - example = f"{needed:g} {'Rep' if needed == 1 else 'Reps'} ≈ 1 Referenz-Rep" - else: - example = f"1 Rep = {factor:g} Referenz-Reps" - else: - example = f"20 s = {round(20 * factor, 1):g} {unit}" - variants.append({ - "label": variant.get("label") or "Variante", - "factor": factor, - "example": example, - }) - tables.append({ - "id": table["id"], "label": table["label"], "mode": mode, - "reference": table["reference"], "variants": variants, - }) - plan_catalog = [] - catalog = plan.get("exercise_catalog") if isinstance(plan.get("exercise_catalog"), dict) else {} - seen_catalog: set[tuple[str, str]] = set() - for entry in catalog.values(): - if not isinstance(entry, dict): - continue - progression_id = str(entry.get("progression_id") or "") - cluster_id = str(entry.get("movement_cluster") or "general") - key = (progression_id, cluster_id) - if key in seen_catalog: - continue - seen_catalog.add(key) - variants = [] - for variant in entry.get("variants", []) if isinstance(entry.get("variants"), list) else []: - if not isinstance(variant, dict): - continue - variants.append({ - "id": str(variant.get("id") or ""), - "name": _plain_text(variant.get("name") or ""), - "factor": float(variant.get("factor") or 1.0), - "movement_cluster": str(variant.get("movement_cluster") or cluster_id), - "result_schema": deepcopy(variant.get("result_schema") if isinstance(variant.get("result_schema"), dict) else {}), - }) - plan_catalog.append({ - "exercise_id": str(entry.get("id") or ""), - "name": _plain_text(entry.get("name") or progression_id), - "progression_id": progression_id, - "movement_cluster": cluster_id, - "movement_label": _plain_text(entry.get("movement_label") or cluster_id), - "variants": variants, - }) - plan_catalog.sort(key=lambda row: (row["movement_label"], row["name"])) - bodyweight = _plan_bodyweight_kg(plan) - return { - "version": PROGRESSION_NORMALIZATION_VERSION, - "title": "Varianten-Cluster und Referenzwerte", - "bodyweight_kg": bodyweight, - "method": ( - "Wiederholungen werden innerhalb desselben Bewegungsmusters mit einem Variantenfaktor in Referenz-Reps umgerechnet. " - "Isometrische Übungen bleiben getrennt und werden als Referenzsekunden ausgewertet. Externe Last wird bei reinen " - "Gewichtsübungen als kg·Reps bzw. kg·s gerechnet. Bei Bodyweight-Übungen wird Zusatzlast nur dann relativ berücksichtigt, " - "wenn das Körpergewicht ausdrücklich im Plan steht." - ), - "caveat": ( - "Die Faktoren sind transparente Analyseheuristiken, keine exakten physiologischen Gleichungen und keine Vorgabe für die " - "Trainingssteuerung. Technik, ROM, Tempo und Nähe zum Versagen bleiben zusätzlich entscheidend." - ), - "tables": tables, - "plan_catalog": plan_catalog, - "sources": deepcopy(_EQUIVALENCE_SOURCES), - } - - -def _extract_training_measure(text: Any) -> dict[str, Any] | None: - """Extrahiert Messwerte bevorzugt aus ``result_data``, sonst aus Freitext. - - Beispiele: ``8/7/6``, ``3x8``, ``6x20 s``, ``4 kg, 6/6/5 je Seite``. - Die Rohangabe bleibt zusätzlich erhalten; unklare Felder werden nicht erfunden. - """ - if isinstance(text, dict): - structured = text.get("result_data") if isinstance(text.get("result_data"), dict) else text - data = _sanitize_result_data(structured) - if data: - if data["laterality"] == "unilateral" and data["sides_mode"] == "separate": - left = [value for value in data["left_values"] if value is not None] - right = [value for value in data["right_values"] if value is not None] - values = left + right - total = sum(values) - per_side = False - else: - values = [value for value in data["values"] if value is not None] - per_side = data["laterality"] == "unilateral" - total = sum(values) * (2 if per_side else 1) - if values: - measure_mode = "seconds" if data["mode"] == "minutes" else data["mode"] - if data["mode"] == "minutes": - values = [round(value * 60, 3) for value in values] - total = total * 60 - return { - "mode": measure_mode, "total": round(total, 3), "values": values, - "sets": data["sets"], "weight_kg": data.get("weight_kg"), - "per_side": per_side, "raw": _format_result_data(data), "structured": True, - } - text = text.get("result") or "" - - raw = _plain_text(text or "").strip() - if not raw: - return None - low = raw.casefold().replace("×", "x") - kg_values = [float(v.replace(",", ".")) for v in re.findall(r"(\d+(?:[.,]\d+)?)\s*kg\b", low)] - weight_kg = max(kg_values) if kg_values else None - per_side = bool(re.search(r"\b(?:je|pro)\s+seite\b|/\s*seite\b", low)) - side_multiplier = 2 if per_side else 1 - - # Dauer ist eindeutig, sobald eine Sekunden-/Minuten-Einheit vorkommt. - has_seconds = bool(re.search(r"\b(?:s|sek\.?|sekunden|min\.?|minuten)\b", low)) - if has_seconds: - work = re.sub(r"\d+(?:[.,]\d+)?\s*kg\b", " ", low) - set_duration = re.search( - r"(\d+(?:[.,]\d+)?)\s*x\s*(\d+(?:[.,]\d+)?)\s*(s|sek\.?|sekunden|min\.?|minuten)\b", - work, - ) - if set_duration: - sets = float(set_duration.group(1).replace(",", ".")) - duration = float(set_duration.group(2).replace(",", ".")) - if set_duration.group(3).startswith("min"): - duration *= 60 - total = sets * duration * side_multiplier - values = [duration] * max(1, int(round(sets))) - return { - "mode": "seconds", "total": round(total, 3), "values": values, - "sets": sets, "weight_kg": weight_kg, "per_side": per_side, "raw": raw, - } - durations: list[float] = [] - for number, unit in re.findall(r"(\d+(?:[.,]\d+)?)\s*(s|sek\.?|sekunden|min\.?|minuten)\b", work): - value = float(number.replace(",", ".")) - if unit.startswith("min"): - value *= 60 - durations.append(value) - if durations: - return { - "mode": "seconds", "total": round(sum(durations) * side_multiplier, 3), - "values": durations, "sets": len(durations), "weight_kg": weight_kg, - "per_side": per_side, "raw": raw, - } - # Formate wie „20/20/18 s“: Einheit einmal am Ende. - numbers = [float(v.replace(",", ".")) for v in re.findall(r"\d+(?:[.,]\d+)?", work)] - if numbers: - return { - "mode": "seconds", "total": round(sum(numbers) * side_multiplier, 3), - "values": numbers, "sets": len(numbers), "weight_kg": weight_kg, - "per_side": per_side, "raw": raw, - } - return None - - # Reps: Gewichtsangaben werden vor dem Auslesen entfernt, damit 10 kg nicht - # versehentlich als zehn Wiederholungen zählen. - work = re.sub(r"\d+(?:[.,]\d+)?\s*kg\b", " ", low) - set_reps = re.search(r"(\d+(?:[.,]\d+)?)\s*x\s*(\d+(?:[.,]\d+)?)", work) - if set_reps: - sets = float(set_reps.group(1).replace(",", ".")) - reps = float(set_reps.group(2).replace(",", ".")) - total = sets * reps * side_multiplier - return { - "mode": "reps", "total": round(total, 3), "values": [reps] * max(1, int(round(sets))), - "sets": sets, "weight_kg": weight_kg, "per_side": per_side, "raw": raw, - } - numbers = [float(v.replace(",", ".")) for v in re.findall(r"\d+(?:[.,]\d+)?", work)] - if not numbers: - return None - return { - "mode": "reps", "total": round(sum(numbers) * side_multiplier, 3), - "values": numbers, "sets": len(numbers), "weight_kg": weight_kg, - "per_side": per_side, "raw": raw, - } - - -def _numeric_measure(text: Any) -> dict[str, Any] | None: - """Kompatible Kurzform für bestehende Aufrufer und Tests.""" - measure = _extract_training_measure(text) - if not measure: - return None - unit = "Sekunden" if measure["mode"] == "seconds" else "Reps" - return {"value": measure["total"], "unit": unit, "values": measure["values"]} - - -def _catalog_entry(plan: dict[str, Any], item: dict[str, Any]) -> dict[str, Any] | None: - catalog = plan.get("exercise_catalog") if isinstance(plan.get("exercise_catalog"), dict) else {} - exercise_id = str(item.get("exercise_id") or "") - if exercise_id and isinstance(catalog.get(exercise_id), dict): - return catalog[exercise_id] - progression_id = str(item.get("progression_id") or "") - for entry in catalog.values(): - if isinstance(entry, dict) and progression_id and str(entry.get("progression_id") or "") == progression_id: - return entry - return None - - -def _explicit_cluster_spec(plan: dict[str, Any], item: dict[str, Any], measure: dict[str, Any]) -> dict[str, Any] | None: - entry = _catalog_entry(plan, item) or {} - cluster_id = str(item.get("movement_cluster") or entry.get("movement_cluster") or "") - name = _plain_text(item.get("exercise") or item.get("exercise_name") or entry.get("name") or "") - text = f"{name} {_plain_text(item.get('progression') or '')}".casefold() - seconds = measure.get("mode") == "seconds" - if not cluster_id or cluster_id == "general": - return None - - if cluster_id == "horizontal_push": - return {"id": "push_support_hold" if seconds else "push_up", "label": "Push-up Startposition / High Plank" if seconds else "Push-up", "mode": measure["mode"], "priority": 100} - if cluster_id == "horizontal_pull": - external = bool(measure.get("weight_kg")) or any(token in text for token in ("kb", "kettlebell", "rucksack", "hantel")) - return {"id": "loaded_row" if external else "inverted_row", "label": "Rudern" if external else "Inverted Row", "mode": measure["mode"], "priority": 92 if not external else 84, "external_load": external} - if cluster_id == "vertical_pull": - return {"id": "active_hang" if seconds else "pull_up", "label": "Active Hang" if seconds else "Pull-up", "mode": measure["mode"], "priority": 100} - if cluster_id == "vertical_push": - if "dip" in text: - return {"id": "dip_support_hold" if seconds else "dip", "label": "Dip Support Hold" if seconds else "Dip", "mode": measure["mode"], "priority": 82} - if "pike" in text: - return {"id": "pike_hold" if seconds else "pike_press", "label": "Pike Hold" if seconds else "Pike Push-up", "mode": measure["mode"], "priority": 78} - return {"id": "loaded_press", "label": "Loaded Press", "mode": measure["mode"], "priority": 72, "external_load": True} - if cluster_id == "knee_dominant": - if seconds: - if "wall" in text or "wandsitz" in text: - return {"id": "wall_sit", "label": "Wall Sit", "mode": "seconds", "priority": 75} - return {"id": "deep_squat_hold", "label": "Deep Squat Hold", "mode": "seconds", "priority": 90} - if "lunge" in text or "ausfallschritt" in text: - return {"id": "lunge", "label": "Ausfallschritt", "mode": "reps", "priority": 78} - return {"id": "squat", "label": "Squat", "mode": "reps", "priority": 100} - if cluster_id == "hip_hinge": - if "bridge" in text: - return {"id": "bridge_hold" if seconds else "bridge", "label": "Glute Bridge Hold" if seconds else "Glute Bridge", "mode": measure["mode"], "priority": 74} - if "swing" in text: - return {"id": "swing", "label": "Kettlebell Swing", "mode": measure["mode"], "priority": 72, "external_load": True} - return {"id": "hinge", "label": "Hinge / Deadlift", "mode": measure["mode"], "priority": 88, "external_load": True} - if cluster_id == "core": - if seconds: - if "side" in text or "seit" in text: - return {"id": "side_plank", "label": "Side Plank", "mode": "seconds", "priority": 82} - if "hollow" in text: - return {"id": "hollow_hold", "label": "Hollow Hold", "mode": "seconds", "priority": 82} - return {"id": "plank", "label": name or "Core Hold", "mode": "seconds", "priority": 100} - if "dead bug" in text: - return {"id": "dead_bug", "label": "Dead Bug", "mode": "reps", "priority": 66} - if "beinheben" in text or "leg raise" in text: - return {"id": "leg_raise", "label": "Beinheben", "mode": "reps", "priority": 70} - if cluster_id == "elbow_flexion": - return {"id": "curl", "label": "Bizeps-Curl", "mode": measure["mode"], "priority": 52, "external_load": True} - if cluster_id == "calf": - return {"id": "calf_raise", "label": "Wadenheben", "mode": measure["mode"], "priority": 58} - if cluster_id == "loaded_mobility": - return {"id": "grip_hold" if seconds else f"exercise:{_slug(name)}", "label": name or "Kettlebell-Kontrolle", "mode": measure["mode"], "priority": 55, "external_load": True} - return None - - -def _cluster_spec(plan: dict[str, Any], item: dict[str, Any], measure: dict[str, Any]) -> dict[str, Any]: - explicit = _explicit_cluster_spec(plan, item, measure) - if explicit: - return explicit - name = _plain_text(item.get("exercise") or item.get("exercise_name") or "") - progression = _plain_text(item.get("progression") or "") - pid = str(item.get("progression_id") or "") - text = f"{name} {progression} {pid}".casefold() - seconds = measure.get("mode") == "seconds" - - def found(*patterns: str) -> bool: - return any(re.search(pattern, text, flags=re.I) for pattern in patterns) - - if seconds: - if found(r"side plank", r"seit.*plank", r"\bsp\b"): - return {"id": "side_plank", "label": "Side Plank", "mode": "seconds", "priority": 82} - if found(r"hollow", r"\bholl\b"): - return {"id": "hollow_hold", "label": "Hollow Hold", "mode": "seconds", "priority": 82} - if found(r"wall sit", r"wandsitz", r"\bwall\b"): - return {"id": "wall_sit", "label": "Wall Sit", "mode": "seconds", "priority": 75} - if found(r"deep squat", r"squat hold", r"tiefe.*kniebeuge.*halt"): - return {"id": "deep_squat_hold", "label": "Deep Squat Hold", "mode": "seconds", "priority": 90} - if found(r"active hang", r"hang", r"hängen"): - return {"id": "active_hang", "label": "Active Hang", "mode": "seconds", "priority": 92} - if found(r"push.?up", r"liegestütz") and found(r"plank", r"hold", r"startposition"): - return {"id": "push_support_hold", "label": "Push-up Startposition / High Plank", "mode": "seconds", "priority": 90} - if found(r"pike"): - return {"id": "pike_hold", "label": "Pike Hold", "mode": "seconds", "priority": 76} - if found(r"dip") and found(r"support", r"hold", r"stütz"): - return {"id": "dip_support_hold", "label": "Dip Support Hold", "mode": "seconds", "priority": 78} - if found(r"plank"): - return {"id": "plank", "label": "Plank", "mode": "seconds", "priority": 100} - if found(r"superman", r"y-t-w", r"ytw"): - return {"id": "back_hold", "label": "Superman / Y-T-W Hold", "mode": "seconds", "priority": 68} - if found(r"glute bridge", r"bridge"): - return {"id": "bridge_hold", "label": "Glute Bridge Hold", "mode": "seconds", "priority": 72} - if found(r"bottoms.?up", r"carry", r"griff"): - return {"id": "grip_hold", "label": "Griff- / Carry-Hold", "mode": "seconds", "priority": 62} - fallback = pid or _slug(name) or "isometric" - return {"id": f"iso:{fallback}", "label": name or "Isometrischer Hold", "mode": "seconds", "priority": 45} - - if found(r"push.?up", r"liegestütz") and not found(r"pike"): - return {"id": "push_up", "label": "Push-up", "mode": "reps", "priority": 100} - if found(r"dip"): - return {"id": "dip", "label": "Dip", "mode": "reps", "priority": 82} - if found(r"pike"): - return {"id": "pike_press", "label": "Pike Push-up", "mode": "reps", "priority": 78} - if found(r"pull.?up", r"klimmzug"): - return {"id": "pull_up", "label": "Pull-up", "mode": "reps", "priority": 100} - if found(r"inverted row", r"body row", r"bent leg row", r"straight leg row"): - return {"id": "inverted_row", "label": "Inverted Row", "mode": "reps", "priority": 92} - if found(r"squat", r"kniebeuge") and not found(r"wall sit"): - return {"id": "squat", "label": "Squat", "mode": "reps", "priority": 100} - if found(r"ausfallschritt", r"lunge"): - return {"id": "lunge", "label": "Ausfallschritt", "mode": "reps", "priority": 78} - if found(r"rdl", r"deadlift", r"hip hinge", r"kreuzheben"): - return {"id": "hinge", "label": "Hinge / Deadlift", "mode": "reps", "priority": 88, "external_load": True} - if found(r"glute bridge", r"bridge"): - return {"id": "bridge", "label": "Glute Bridge", "mode": "reps", "priority": 74} - if found(r"wadenheben", r"calf"): - return {"id": "calf_raise", "label": "Wadenheben", "mode": "reps", "priority": 58} - if found(r"rudern.*eng", r"row.*eng"): - return {"id": "row_lat", "label": "Einarm-Rudern eng", "mode": "reps", "priority": 82, "external_load": True} - if found(r"rudern.*breit", r"row.*breit"): - return {"id": "row_upper", "label": "Einarm-Rudern breit", "mode": "reps", "priority": 78, "external_load": True} - if found(r"rudern", r"\brow\b"): - return {"id": "loaded_row", "label": "Rudern", "mode": "reps", "priority": 84, "external_load": True} - if found(r"curl", r"bizeps"): - return {"id": "curl", "label": "Bizeps-Curl", "mode": "reps", "priority": 52, "external_load": True} - if found(r"seitheben", r"lateral raise"): - return {"id": "lateral_raise", "label": "Seitheben", "mode": "reps", "priority": 50, "external_load": True} - if found(r"floor press", r"overhead press", r"schulterdrücken"): - return {"id": "loaded_press", "label": "Loaded Press", "mode": "reps", "priority": 72, "external_load": True} - if found(r"swing"): - return {"id": "swing", "label": "Kettlebell Swing", "mode": "reps", "priority": 72, "external_load": True} - if found(r"mountain climber"): - return {"id": "mountain_climber", "label": "Mountain Climbers", "mode": "reps", "priority": 52} - if found(r"dead bug"): - return {"id": "dead_bug", "label": "Dead Bug", "mode": "reps", "priority": 66} - if found(r"beinheben", r"leg raise"): - return {"id": "leg_raise", "label": "Beinheben", "mode": "reps", "priority": 70} - if found(r"bicycle"): - return {"id": "bicycle", "label": "Bicycle Crunch", "mode": "reps", "priority": 48} - if found(r"russian twist"): - return {"id": "russian_twist", "label": "Russian Twist", "mode": "reps", "priority": 48} - fallback = pid or _slug(name) or "exercise" - return {"id": f"exercise:{fallback}", "label": name or fallback, "mode": "reps", "priority": 40} - -def _equivalence_table(cluster_id: str) -> dict[str, Any] | None: - return next((table for table in _EQUIVALENCE_TABLES if table.get("id") == cluster_id), None) - - -def _stage_factor(plan: dict[str, Any], item: dict[str, Any], mode: str) -> float: - pid = str(item.get("progression_id") or "") - progression = _slug(item.get("progression") or "") - step_id = str(item.get("progression_step_id") or "") - stage = plan.get("stages", {}).get(pid) if isinstance(plan.get("stages"), dict) else None - steps = stage.get("steps") if isinstance(stage, dict) and isinstance(stage.get("steps"), list) else [] - if not steps: - return 1.0 - index = None - selected: dict[str, Any] | None = None - for i, raw_step in enumerate(steps): - step = raw_step if isinstance(raw_step, dict) else {"name": str(raw_step or "")} - normalized = _slug(step.get("name") or "") - if (step_id and str(step.get("id") or "") == step_id) or (progression and (progression == normalized or progression in normalized or normalized in progression)): - index = i - selected = step - break - if selected: - try: - explicit = float(selected.get("factor")) - except (TypeError, ValueError): - explicit = 0.0 - if explicit > 0: - return round(explicit, 3) - if index is None or len(steps) <= 1: - return 1.0 - if mode == "seconds": - factors = [0.65 + (0.50 * i / (len(steps) - 1)) for i in range(len(steps))] - else: - factors = [0.25 * (4 ** (i / (len(steps) - 1))) for i in range(len(steps))] - return round(factors[index], 3) - -def _variation_factor(plan: dict[str, Any], item: dict[str, Any], cluster: dict[str, Any]) -> tuple[float, str]: - entry = _catalog_entry(plan, item) - step_id = str(item.get("progression_step_id") or "") - progression = _slug(item.get("progression") or "") - if entry: - variants = entry.get("variants") if isinstance(entry.get("variants"), list) else [] - for variant in variants: - if not isinstance(variant, dict): - continue - variant_name = _slug(variant.get("name") or "") - if (step_id and str(variant.get("id") or "") == step_id) or (progression and variant_name and (progression == variant_name or progression in variant_name or variant_name in progression)): - try: - factor = float(variant.get("factor") or 1.0) - except (TypeError, ValueError): - factor = 1.0 - return max(0.001, factor), _plain_text(variant.get("name") or item.get("progression") or "Variante") - if cluster.get("external_load"): - return 1.0, _plain_text(item.get("progression") or "Last aus Ergebnisfeld") - table = _equivalence_table(cluster["id"]) - text = f"{_plain_text(item.get('progression') or '')} {_plain_text(item.get('exercise') or item.get('exercise_name') or '')}".casefold() - matches: list[tuple[float, str]] = [] - if table: - for variant in table.get("variants", []): - if any(re.search(pattern, text, flags=re.I) for pattern in variant.get("patterns", [])): - matches.append((float(variant.get("factor") or 1), str(variant.get("label") or "Variante"))) - if matches: - return max(matches, key=lambda entry: entry[0]) - factor = _stage_factor(plan, item, cluster["mode"]) - return factor, _plain_text(item.get("progression") or "nicht näher bezeichnet") - -def _normalized_point(plan: dict[str, Any], item: dict[str, Any], measure: dict[str, Any], cluster: dict[str, Any]) -> dict[str, Any]: - factor, variant = _variation_factor(plan, item, cluster) - total = float(measure.get("total") or 0) - weight_kg = measure.get("weight_kg") - bodyweight_kg = _plan_bodyweight_kg(plan) - load_modifier = 1.0 - unit = "Referenzsekunden" if measure["mode"] == "seconds" else "Referenz-Reps" - - if cluster.get("external_load") and weight_kg: - value = total * float(weight_kg) * factor - unit = "kg·s" if measure["mode"] == "seconds" else "kg·Reps" - else: - # Zusatzlast bei Bodyweight-Bewegungen wird nur mit explizitem - # Körpergewicht verwendet. Ohne diese Information bleibt sie sichtbar, - # fließt aber nicht über eine erfundene Annahme ein. - if weight_kg and bodyweight_kg and cluster["id"] in { - "push_up", "dip", "pike_press", "pull_up", "inverted_row", "squat", - "lunge", "plank", "side_plank", "deep_squat_hold", - }: - load_modifier = 1 + float(weight_kg) / bodyweight_kg - value = total * factor * load_modifier - return { - "value": round(value, 2), "unit": unit, "factor": round(factor, 3), - "variant": variant, "raw_total": round(total, 2), "raw": measure.get("raw") or "", - "weight_kg": weight_kg, "load_modifier": round(load_modifier, 3), - } - - -def _cluster_progression_series(plan: dict[str, Any], records: list[dict[str, Any]], limit: int = 10) -> list[dict[str, Any]]: - sessions = sorted(records, key=lambda row: (int(row.get("week") or 0), int(row.get("day") or 0), str(row.get("session_key") or ""))) - groups: dict[tuple[str, str], dict[str, Any]] = {} - for session in sessions: - session_points: dict[tuple[str, str], dict[str, Any]] = {} - for item in session.get("items", []): - measure = _extract_training_measure(item) - if not measure: - continue - cluster = _cluster_spec(plan, item, measure) - point = _normalized_point(plan, item, measure, cluster) - key = (cluster["id"], point["unit"]) - bucket = session_points.setdefault(key, { - "cluster": cluster, "value": 0.0, "raw": [], "factors": [], "variants": [], "weights": [], - }) - bucket["value"] += point["value"] - bucket["raw"].append(point["raw"]) - bucket["factors"].append(point["factor"]) - bucket["variants"].append(point["variant"]) - if point.get("weight_kg") is not None: - bucket["weights"].append(point["weight_kg"]) - for key, bucket in session_points.items(): - cluster = bucket["cluster"] - group = groups.setdefault(key, { - "id": cluster["id"], "label": cluster["label"], "mode": cluster["mode"], - "unit": key[1], "priority": int(cluster.get("priority") or 40), - "reference": (_equivalence_table(cluster["id"]) or {}).get("reference", "planinterne Stufenheuristik"), - "points": [], - }) - label = f"W{session.get('week')}/T{session.get('day')}" - group["points"].append({ - "label": label, "week": session.get("week"), "day": session.get("day"), - "session_key": session.get("session_key"), "value": round(bucket["value"], 2), - "raw": " + ".join(bucket["raw"]), - "variant": " / ".join(dict.fromkeys(v for v in bucket["variants"] if v)), - "factor": round(max(bucket["factors"] or [1]), 3), - "weight_kg": max(bucket["weights"]) if bucket["weights"] else None, - }) - series = [] - for group in groups.values(): - points = group["points"] - if not points: - continue - first = float(points[0]["value"] or 0) - last = float(points[-1]["value"] or 0) - change_pct = round((last - first) / first * 100, 1) if first else None - group["data_points"] = len(points) - group["first_value"] = first - group["last_value"] = last - group["change_pct"] = change_pct - group["latest_variant"] = points[-1].get("variant") or "" - group["importance"] = group["priority"] + min(len(points), 8) * 6 - series.append(group) - series.sort(key=lambda row: (row["importance"], row["data_points"], row["label"]), reverse=True) - return series[:limit] - - -def _local_metrics(plan: dict[str, Any], records: list[dict[str, Any]], week: int | None = None) -> dict[str, Any]: - selected = [item for item in records if week is None or item.get("week") == week] - completed = sum(1 for item in selected if item.get("status") == "completed") - planned = len(plan.get("days") or []) if week is not None else max(1, len({item.get("week") for item in selected})) * len(plan.get("days") or []) - progression_ids = { - str(item.get("progression_id") or "") - for session in selected for item in session.get("items", []) - if str(item.get("progression_id") or "") - } - clusters = _cluster_progression_series(plan, selected, limit=50) - return { - "sessions_with_data": len(selected), - "completed_sessions": completed, - "planned_sessions": planned, - "completion_rate_pct": round(completed / planned * 100) if planned else 0, - "documented_exercises": len(progression_ids), - "measurable_progression_clusters": len(clusters), - } - - -def _flow_for_progression(plan: dict[str, Any], progression_id: str, exercise: str, current: str) -> dict[str, Any] | None: - stage = plan.get("stages", {}).get(progression_id) if isinstance(plan.get("stages"), dict) else None - steps = stage.get("steps") if isinstance(stage, dict) and isinstance(stage.get("steps"), list) else [] - if not steps: - return None - normalized_current = _slug(current) - current_index = None - for index, step in enumerate(steps): - step_name = step.get("name", "") if isinstance(step, dict) else step - normalized_step = _slug(step_name) - if normalized_current and (normalized_current == normalized_step or normalized_current in normalized_step or normalized_step in normalized_current): - current_index = index - break - nodes = [] - for index, step in enumerate(steps): - step_name = step.get("name", "") if isinstance(step, dict) else step - status = "" - if current_index is not None: - if index < current_index: - status = "done" - elif index == current_index: - status = "current" - elif index == current_index + 1: - status = "next" - nodes.append({"id": str(step.get("id") if isinstance(step, dict) else f"n{index + 1}"), "label": _plain_text(step_name), "status": status}) - return { - "title": exercise, - "nodes": nodes, - "edges": [{"from": nodes[i]["id"], "to": nodes[i + 1]["id"]} for i in range(len(nodes) - 1)], - "insight": f"Dokumentierter Stand: {current}" if current else "Noch keine eindeutige Stufe dokumentiert.", - } - - -def _local_visuals(plan: dict[str, Any], records: list[dict[str, Any]], *, overall: bool) -> dict[str, Any]: - normalized = _cluster_progression_series(plan, records, limit=6) - charts = [] - for group in normalized: - if len(group["points"]) < 2: - continue - labels = [point["label"] if overall else f"T{point['day']}" for point in group["points"]] - values = [point["value"] for point in group["points"]] - first_variant = group["points"][0].get("variant") or "unbekannt" - last_variant = group["points"][-1].get("variant") or "unbekannt" - charts.append({ - "title": f"{group['label']} · normalisierter Verlauf", - "type": "line", "unit": group["unit"], "labels": labels, - "series": [{"name": group["label"], "values": values}], - "insight": ( - f"{first_variant} → {last_variant}. Werte sind über Variantenfaktoren vergleichbar; " - f"Rohdaten bleiben im Analysearchiv erhalten." - ), - }) - latest: dict[str, dict[str, Any]] = {} - for session in records: - for item in session.get("items", []): - progression_id = str(item.get("progression_id") or "") - if progression_id: - latest[progression_id] = item - flows = [] - for progression_id, item in latest.items(): - flow = _flow_for_progression( - plan, progression_id, str(item.get("exercise") or progression_id), str(item.get("progression") or "") - ) - if flow: - flows.append(flow) - if len(flows) >= 4: - break - return { - "charts": charts, "flowcharts": flows, - "normalization_version": PROGRESSION_NORMALIZATION_VERSION, - "cluster_count": len(normalized), - } - - -def _week_dataset(plan: dict[str, Any], tracker: dict[str, Any], week: int) -> dict[str, Any]: - records = _session_records(plan, tracker) - sessions = [item for item in records if item.get("week") == week] - if not sessions: - raise ValueError(f"Woche {week} enthält noch keine befüllten Sessiondaten.") - progression_ids = { - str(item.get("progression_id") or "") - for session in sessions for item in session.get("items", []) - if str(item.get("progression_id") or "") - } - profile = tracker.get("profile") if isinstance(tracker.get("profile"), dict) else {} - week_state = (tracker.get("week_statuses") or {}).get(str(week), {}) if isinstance(tracker.get("week_statuses"), dict) else {} - return { - "analysis_type": "week", - "analysis_kind": "final" if week_state.get("status") == "closed" else "interim", - "week_status": deepcopy(week_state), - "prompt_version": PROMPT_VERSION, - "analysis_schema_version": ANALYSIS_SCHEMA_VERSION, - "normalization_version": PROGRESSION_NORMALIZATION_VERSION, - "model": os.environ.get("OPENAI_MODEL", "gpt-5.5").strip() or "gpt-5.5", - "week": week, - "plan": _plan_context(plan, week=week, progression_ids=progression_ids), - "profile": { - "display_name": _plain_text(profile.get("display_name") or ""), - "start_date": str(profile.get("start_date") or ""), - "plan_notes": _plain_text(profile.get("plan_notes") or ""), - }, - "previous_end_state": _latest_state_before(records, week), - "local_metrics": _local_metrics(plan, records, week), - "progression_normalization": { - "version": PROGRESSION_NORMALIZATION_VERSION, - "clusters": _cluster_progression_series(plan, sessions, limit=10), - }, - "sessions": sessions, - } - - -def _compact_week_result(record: dict[str, Any]) -> dict[str, Any]: - result = record.get("result") if isinstance(record.get("result"), dict) else {} - return { - "week": record.get("week"), - "source_hash": record.get("source_hash"), - "headline": result.get("headline") or "", - "summary": result.get("summary") or result.get("overview", {}).get("summary", ""), - "metrics": result.get("metrics", [])[:4] if isinstance(result.get("metrics"), list) else [], - "exercise_updates": result.get("exercise_updates", [])[:6] if isinstance(result.get("exercise_updates"), list) else [], - "plan_adjustments": result.get("plan_adjustments", [])[:3] if isinstance(result.get("plan_adjustments"), list) else [], - "warnings": result.get("warnings", [])[:2] if isinstance(result.get("warnings"), list) else [], - } - - -def _overall_dataset(plan: dict[str, Any], tracker: dict[str, Any]) -> dict[str, Any]: - records = _session_records(plan, tracker) - cache = _read_analysis_cache(plan) - weeks = sorted({int(item.get("week") or 0) for item in records if int(item.get("week") or 0) > 0}) - summaries = [] - for week in weeks: - record = cache["weeks"].get(str(week)) - if not isinstance(record, dict): - raise ValueError(f"Für Woche {week} fehlt eine aktuelle Wochenanalyse.") - summaries.append(_compact_week_result(record)) - if not summaries: - raise ValueError("Noch keine Wochenanalyse für eine Gesamtanalyse vorhanden.") - weekly_coverage = [] - for week in weeks: - metrics = _local_metrics(plan, records, week) - weekly_coverage.append({"week": week, **metrics}) - return { - "analysis_type": "overall", - "prompt_version": PROMPT_VERSION, - "analysis_schema_version": ANALYSIS_SCHEMA_VERSION, - "normalization_version": PROGRESSION_NORMALIZATION_VERSION, - "model": os.environ.get("OPENAI_MODEL", "gpt-5.5").strip() or "gpt-5.5", - "week_statuses": deepcopy(tracker.get("week_statuses") or {}), - "plan": _plan_context(plan), - "weekly_analyses": summaries, - "local_aggregates": { - "coverage_by_week": weekly_coverage, - "overall": _local_metrics(plan, records), - "current_progressions": _latest_state_before(records, max(weeks) + 1), - "progression_normalization": { - "version": PROGRESSION_NORMALIZATION_VERSION, - "clusters": _cluster_progression_series(plan, records, limit=10), - }, - }, - } - - -def _meaningful_weeks(plan: dict[str, Any], tracker: dict[str, Any]) -> list[int]: - return sorted({int(item.get("week") or 0) for item in _session_records(plan, tracker) if int(item.get("week") or 0) > 0}) - - -def _analysis_catalog(plan: dict[str, Any], tracker: dict[str, Any]) -> dict[str, Any]: - cache = _read_analysis_cache(plan) - week_entries = [] - all_current = True - for week in _meaningful_weeks(plan, tracker): - dataset = _week_dataset(plan, tracker, week) - source_hash = _stable_hash(dataset) - record = cache["weeks"].get(str(week)) - if not isinstance(record, dict): - status = "missing" - elif record.get("source_hash") != source_hash: - status = "stale" - else: - status = "current" - if status != "current": - all_current = False - week_entries.append({ - "week": week, "status": status, - "week_status": dataset.get("week_status", {}).get("status", "open"), - "analysis_kind": dataset.get("analysis_kind", "interim"), - "sessions": len(dataset.get("sessions", [])), - "created_at": record.get("created_at") if isinstance(record, dict) else "", - "record_id": record.get("id") if isinstance(record, dict) else "", - }) - overall_record = cache.get("overall") - if not week_entries: - overall_status = "missing" - overall_hash = "" - elif not all_current: - overall_status = "needs_weeks" - overall_hash = "" - else: - overall_dataset = _overall_dataset(plan, tracker) - overall_hash = _stable_hash(overall_dataset) - if not isinstance(overall_record, dict): - overall_status = "missing" - elif overall_record.get("source_hash") != overall_hash: - overall_status = "stale" - else: - overall_status = "current" - return { - "weeks": week_entries, - "overall": { - "status": overall_status, - "weeks": [entry["week"] for entry in week_entries], - "created_at": overall_record.get("created_at") if isinstance(overall_record, dict) else "", - "record_id": overall_record.get("id") if isinstance(overall_record, dict) else "", - "pending_weeks": [entry["week"] for entry in week_entries if entry["status"] != "current"], - }, - } - - -def _call_ai(messages: list[dict[str, str]], model: str) -> Any: - from core import ai as core_ai - return core_ai.chat_json(messages, model=model) - - -def _violates_fixed_interval(text: Any) -> bool: - low = _plain_text(text).casefold() - return bool(re.search( - r"(?:haltezeit|arbeitszeit|intervall|hold).{0,30}(?:verläng|erhöh|länger)|(?:länger|mehr)\s*(?:als\s*)?\d*\s*(?:s|sek)", - low, - )) - - -def _compact_ai_result(result: Any, training_format: dict[str, Any]) -> dict[str, Any]: - if not isinstance(result, dict): - raise ValueError("Die KI-Antwort besitzt nicht das erwartete JSON-Format") - clean = _sanitize(result) - if not isinstance(clean, dict): - raise ValueError("Ungültige KI-Antwort") - clean["metrics"] = clean.get("metrics", [])[:4] if isinstance(clean.get("metrics"), list) else [] - clean["exercise_updates"] = clean.get("exercise_updates", [])[:6] if isinstance(clean.get("exercise_updates"), list) else [] - clean["plan_adjustments"] = clean.get("plan_adjustments", [])[:3] if isinstance(clean.get("plan_adjustments"), list) else [] - for index, adjustment in enumerate(clean["plan_adjustments"]): - if isinstance(adjustment, dict): - adjustment.setdefault("id", f"proposal-{index+1}") - adjustment.setdefault("action", "review") - clean["warnings"] = clean.get("warnings", [])[:2] if isinstance(clean.get("warnings"), list) else [] - if training_format.get("fixed_interval"): - replacement = ( - "Arbeitsintervall unverändert lassen; nur die im Plan erlaubte Übungsstufe, das Gewicht, " - "den kontrollierten Bewegungsumfang oder die technische Qualität anpassen." - ) - for exercise in clean["exercise_updates"]: - if isinstance(exercise, dict) and _violates_fixed_interval(exercise.get("next_action")): - exercise["next_action"] = replacement - for adjustment in clean["plan_adjustments"]: - if isinstance(adjustment, dict): - combined = " ".join(str(adjustment.get(field) or "") for field in ("suggested_change", "manual_step")) - if _violates_fixed_interval(combined): - adjustment["suggested_change"] = replacement - adjustment["manual_step"] = "Im Trainingsplan-Modul nur Progressionsstufe oder Last anpassen; Tabata-Timer unverändert lassen." - return clean - - -def _run_progress_analysis(dataset: dict[str, Any], scope: str) -> dict[str, Any]: - model = os.environ.get("OPENAI_MODEL", "gpt-5.5").strip() or "gpt-5.5" - schema = { - "headline": "Kurzer Titel", - "summary": "Kompakte Einordnung in höchstens 4 Sätzen", - "data_quality": "Ein kurzer Satz zur Aussagekraft", - "metrics": [{"label": "Kennzahl", "value": "Wert", "detail": "kurze Einordnung"}], - "exercise_updates": [{ - "name": "Übungscluster", "trend": "up|stable|down|unclear", - "current_level": "dokumentierter Stand", "evidence": "knapper Beleg aus Referenzwerten und Rohdaten", - "next_action": "nächster planverträglicher Schritt", "criterion": "messbares Kriterium", - }], - "plan_adjustments": [{ - "id": "stabile kurze Vorschlags-ID", - "action": "change_progression|change_load|change_variant|keep|review", - "target": "Woche/Tag/Übung oder Block", - "target_exercise_id": "ID aus plan.days.blocks.exercises, wenn eindeutig", - "target_progression_id": "Progressions-ID, wenn eindeutig", - "target_step_id": "aktuelle oder vorgeschlagene Stufen-ID, wenn eindeutig", - "suggested_change": "konkreter Vorschlag", - "reason": "kurze Begründung", "manual_step": "was im Trainingsplan-Modul manuell zu ändern wäre", - "condition": "nur wenn/sonst beibehalten", - }], - "warnings": ["nur echte Auffälligkeiten"], - "conclusion": "ein knapper Abschlusssatz", - } - target = ( - "Bewerte genau diese Planwoche und formuliere höchstens drei konkrete, optionale Anpassungen für die nächste Woche." - if scope == "week" else - "Bewerte die Entwicklung über die vorhandenen Wochen und formuliere höchstens drei konkrete, optionale Anpassungen für die nächste Woche beziehungsweise den nächsten Planabschnitt." - ) - messages = [ - { - "role": "system", - "content": ( - "Du analysierst ein Trainingsprotokoll auf Deutsch. Der übergebene Trainingsplan ist bindend und muss die Empfehlungen bestimmen. " - "Verändere niemals eigenmächtig Sessionformat, Arbeitsintervall, Pausen, Rundenzahl, Satzanzahl oder Progressionslogik. " - "Bei Tabata oder anderen festen Intervallen darfst du insbesondere keine längeren Holds oder längere Arbeitszeiten empfehlen. " - "Progression erfolgt dann nur planverträglich, etwa über die vorgesehene schwierigere Variante, Gewicht, ROM, Tempo oder Technik. " - "Nutze ausschließlich die Daten im Input, erfinde keine Leistungen und gib keine medizinische Diagnose. " - "Eine Session mit Status stopped wurde vorzeitig beendet und darf nicht als abgeschlossen gewertet werden; vorhandene Teildaten dürfen vorsichtig berücksichtigt werden. " - "Nutze die deterministisch berechneten progression_normalization-Cluster als maßgebliche Zeitreihe und rechne die Faktoren nicht selbst neu. " - "Referenz-Reps vergleichen Varianten derselben dynamischen Bewegung; Referenzsekunden vergleichen ausschließlich isometrische Varianten. " - "kg·Reps und kg·s sind Lastvolumen und dürfen nicht mit Bodyweight-Referenzwerten vermischt werden. " - "Wähle die wichtigsten Cluster nach Planrelevanz und Datenpunkten. Bei mindestens vier brauchbaren Clustern sollst du vier bis sechs davon knapp bewerten. " - "Die Analyse soll trotzdem kompakt bleiben: maximal 4 Kennzahlen, 6 kurze Übungsupdates, 3 Plananpassungen und 2 Warnungen. " - "Plananpassungen sind nur Vorschläge zur manuellen Übernahme im Trainingsplan-Modul; behaupte nie, den Plan geändert zu haben. " - "Nutze für Vorschläge nach Möglichkeit die im Datensatz vorhandenen stabilen exercise_id-, progression_id- und step_id-Werte. " - + target + " Antworte ausschließlich als JSON im vorgegebenen Schema." - ), - }, - { - "role": "user", - "content": "Gewünschtes JSON-Schema:\n" + json.dumps(schema, ensure_ascii=False) - + "\n\nAnalyse-Datensatz:\n" + json.dumps(dataset, ensure_ascii=False), - }, - ] - result = _call_ai(messages, model) - training_format = dataset.get("plan", {}).get("training_format", {}) if isinstance(dataset.get("plan"), dict) else {} - return { - "model": model, - "request": { - "scope": scope, - "schema": schema, - "messages": messages, - "dataset": deepcopy(dataset), - }, - "result": _compact_ai_result(result, training_format), - } - - -def _week_record(plan: dict[str, Any], tracker: dict[str, Any], week: int) -> dict[str, Any]: - dataset = _week_dataset(plan, tracker, week) - source_hash = _stable_hash(dataset) - output = _run_progress_analysis(dataset, "week") - created_at = _utc_now() - return { - "id": f"week-{week:02d}", - "type": "week", "week": week, "created_at": created_at, - "analysis_kind": dataset.get("analysis_kind", "interim"), - "prompt_version": PROMPT_VERSION, "analysis_schema_version": ANALYSIS_SCHEMA_VERSION, - "normalization_version": PROGRESSION_NORMALIZATION_VERSION, - "model": output["model"], "source_hash": source_hash, - "sessions_considered": len(dataset["sessions"]), - "request": output["request"], - "response": output["result"], - "visuals": _local_visuals(plan, dataset["sessions"], overall=False), - } - - -def _overall_record(plan: dict[str, Any], tracker: dict[str, Any]) -> dict[str, Any]: - dataset = _overall_dataset(plan, tracker) - source_hash = _stable_hash(dataset) - output = _run_progress_analysis(dataset, "overall") - records = _session_records(plan, tracker) - weeks = sorted({item.get("week") for item in records}) - created_at = _utc_now() - return { - "id": "overall", - "type": "overall", "created_at": created_at, - "prompt_version": PROMPT_VERSION, "analysis_schema_version": ANALYSIS_SCHEMA_VERSION, - "normalization_version": PROGRESSION_NORMALIZATION_VERSION, - "model": output["model"], "source_hash": source_hash, - "sessions_considered": len(records), "weeks_considered": weeks, - "request": output["request"], - "response": output["result"], - "visuals": _local_visuals(plan, records, overall=True), - } - - -def _job_state(plan: dict[str, Any], job_id: str, **changes: Any) -> dict[str, Any]: - state = _read_analysis_state(plan) - if state.get("job_id") != job_id: - return state - now = datetime.now(timezone.utc) - state.update(changes) - state["heartbeat_at"] = now.isoformat(timespec="seconds") - state["lease_expires_at"] = (now + timedelta(seconds=_ANALYSIS_TIMEOUT_SECONDS)).isoformat(timespec="seconds") - return _write_analysis_state(plan, state) - - -def _analysis_heartbeat(plan_filename: str, job_id: str, stop_event: threading.Event) -> None: - while not stop_event.wait(_ANALYSIS_HEARTBEAT_SECONDS): - try: - path = _validated_plan_path(plan_filename) - if path is None: - return - plan = _normalize_plan(path) - state = _read_analysis_state(plan) - if state.get("job_id") != job_id or state.get("status") != "running": - return - _job_state(plan, job_id) - except Exception: - # Der Hauptworker entscheidet über Erfolg oder Fehler. Ein einzelner - # Heartbeat-Fehler darf den API-Aufruf nicht abbrechen. - pass - - -def _analysis_worker(plan_filename: str, job_id: str, scope: str, week: int | None) -> None: - heartbeat_stop = threading.Event() - heartbeat = threading.Thread( - target=_analysis_heartbeat, - args=(plan_filename, job_id, heartbeat_stop), - name=f"trainingstracker-heartbeat-{job_id}", daemon=True, - ) - heartbeat.start() - try: - path = _validated_plan_path(plan_filename) - if path is None: - raise ValueError("Trainingsplan nicht mehr vorhanden.") - plan = _normalize_plan(path) - tracker = _load_tracker(plan) - if scope == "week": - assert week is not None - _job_state(plan, job_id, message=f"Woche {week} wird analysiert …", step=0, total_steps=1) - record = _week_record(plan, tracker, week) - _archive_analysis_record(plan, record) - selection = f"week:{week}" - else: - catalog = _analysis_catalog(plan, tracker) - pending = list(catalog.get("overall", {}).get("pending_weeks", [])) - total = len(pending) + 1 - for index, pending_week in enumerate(pending, start=1): - _job_state( - plan, job_id, message=f"Woche {pending_week} wird aktualisiert …", - step=index - 1, total_steps=total, - ) - tracker = _load_tracker(plan) - record = _week_record(plan, tracker, pending_week) - _archive_analysis_record(plan, record) - _job_state(plan, job_id, message="Gesamtanalyse wird erstellt …", step=max(0, total - 1), total_steps=total) - tracker = _load_tracker(plan) - record = _overall_record(plan, tracker) - _archive_analysis_record(plan, record) - selection = "overall" - finished_total = 1 if scope == "week" else total - _job_state( - plan, job_id, status="done", message="Analyse abgeschlossen.", - selection=selection, step=finished_total, total_steps=finished_total, - finished_at=_utc_now(), error="", - ) - except Exception as exc: # noqa: BLE001 - try: - path = _validated_plan_path(plan_filename) - if path is not None: - plan = _normalize_plan(path) - _job_state( - plan, job_id, status="error", message="Analyse fehlgeschlagen.", - error=str(exc), finished_at=_utc_now(), - ) - except Exception: - pass - finally: - heartbeat_stop.set() - with _ANALYSIS_GUARD: - _ANALYSIS_THREADS.pop(plan_filename, None) - - -def _start_analysis(plan: dict[str, Any], tracker: dict[str, Any], scope: str, week: int | None) -> dict[str, Any]: - catalog = _analysis_catalog(plan, tracker) - if scope == "week": - entry = next((item for item in catalog["weeks"] if item["week"] == week), None) - if entry is None: - raise ValueError(f"Woche {week} enthält noch keine befüllten Sessiondaten.") - if entry["status"] == "current": - return {"cached": True, "selection": f"week:{week}"} - label = f"Woche {week}" - total_steps = 1 - elif scope == "overall": - if not catalog["weeks"]: - raise ValueError("Noch keine befüllten Sessiondaten für eine Analyse vorhanden.") - if catalog["overall"]["status"] == "current": - return {"cached": True, "selection": "overall"} - pending = catalog["overall"].get("pending_weeks", []) - label = "Gesamtanalyse" - total_steps = len(pending) + 1 - else: - raise ValueError("Unbekannter Analysebereich.") - - with _analysis_file_lock(plan["source_file"]): - current_state = _read_analysis_state(plan) - if current_state.get("status") == "running": - raise RuntimeError("Es läuft bereits eine Progressionsanalyse.") - job_id = datetime.now(timezone.utc).strftime("job-%Y%m%dT%H%M%S%fZ") - state = { - "status": "running", "job_id": job_id, "scope": scope, "week": week, - "label": label, "message": "Analyse wird vorbereitet …", "step": 0, - "total_steps": total_steps, "started_at": _utc_now(), "heartbeat_at": _utc_now(), - "lease_expires_at": (datetime.now(timezone.utc) + timedelta(seconds=_ANALYSIS_TIMEOUT_SECONDS)).isoformat(timespec="seconds"), - "owner_pid": os.getpid(), "error": "", - } - _write_analysis_state(plan, state) - thread = threading.Thread( - target=_analysis_worker, - args=(plan["source_file"], job_id, scope, week), - name=f"trainingstracker-{job_id}", daemon=True, - ) - with _ANALYSIS_GUARD: - _ANALYSIS_THREADS[plan["source_file"]] = thread - thread.start() - return {"cached": False, "job": state} - - -# --------------------------------------------------------------------------- -# HTTP -# --------------------------------------------------------------------------- -@app.after_request -def no_cache(response): - if request.path.startswith("/api/"): - response.headers["Cache-Control"] = "no-store" - return response + payload = {"name": name, "config": normalized, "updated_at": now} + _atomic_json_write(path, payload) + return _read_plan(path) @app.route("/") def index(): - index_path = STATIC_DIR / "index.html" - source = index_path.read_text(encoding="utf-8") - base = request.script_root.rstrip("/") - source = source.replace("__APP_BASE__", base) - return Response(source, mimetype="text/html") + source = (STATIC_DIR / "index.html").read_text(encoding="utf-8") + return Response(source.replace("__APP_BASE__", request.script_root.rstrip("/")), mimetype="text/html") @app.route("/api/health") def api_health(): return jsonify({ "ok": True, + "name": "Trainingstracker", "plans_dir": str(PLANS_DIR), - "sessions_dir": str(SESSIONS_DIR), - "analyses_dir": str(ANALYSES_DIR), - "plans_dir_exists": PLANS_DIR.is_dir(), - "openai_configured": _openai_ready(), - "versions": { - "plan_schema": PLAN_SCHEMA_VERSION, - "training_contract": CONTRACT_VERSION, - "tracker_schema": TRACKER_SCHEMA_VERSION, - "result_data": RESULT_DATA_VERSION, - "prompt": PROMPT_VERSION, - "analysis_schema": ANALYSIS_SCHEMA_VERSION, - "normalization": PROGRESSION_NORMALIZATION_VERSION, - }, + "data_dir": str(DATA_DIR), + "contract": {"plan_schema": PLAN_SCHEMA_VERSION, "training_contract": CONTRACT_VERSION}, }) @app.route("/api/plans") def api_plans(): + files = _known_plan_files() settings = _read_settings() selected = settings.get("selected_plan") + if selected not in files: + selected = next(iter(files), None) plans = [] - for filename, path in _known_plan_files().items(): - try: - plan = _normalize_plan(path) - tracker = _load_tracker(plan) - completed = sum( - 1 for session in tracker.get("sessions", {}).values() - if isinstance(session, dict) and session.get("status") == "completed" - ) - plans.append({ - "id": filename, - "filename": filename, - "name": plan["name"], - "title": plan["title"], - "subtitle": plan["subtitle"], - "weeks": plan["weeks"], - "days": len(plan["days"]), - "tracked": _tracker_path(filename).exists(), - "start_date": tracker.get("profile", {}).get("start_date", ""), - "completed_sessions": completed, - "source_changed": bool(tracker.get("source_changed")), - "plan_id": plan.get("plan_id"), - "published_revision": plan.get("published_revision"), - "tracker_revision": tracker.get("revision", 1), - }) - except Exception: - continue - if selected not in {item["id"] for item in plans}: - selected = plans[0]["id"] if plans else None + for plan_id, path in files.items(): + plan = _read_plan(path) + cfg = plan["config"] + plans.append({ + "id": plan_id, + "name": plan["name"], + "selected": plan_id == selected, + "days": len(cfg.get("days", [])), + "exercises": len(cfg.get("exercises", [])), + }) return jsonify({"plans": plans, "selected": selected}) @app.route("/api/plans//select", methods=["POST"]) def api_select_plan(plan_id: str): - if _validated_plan_path(plan_id) is None: - return jsonify({"error": "Trainingsplan nicht gefunden"}), 404 - _save_selected(plan_id) - return jsonify({"ok": True, "selected": plan_id}) + path = _validated_plan_path(plan_id) + if not path: + return jsonify({"error": "Plan nicht gefunden."}), 404 + _save_selected(path.name) + return jsonify({"ok": True, "selected": path.name}) @app.route("/api/plans/") def api_plan(plan_id: str): path = _validated_plan_path(plan_id) - if path is None: - return jsonify({"error": "Trainingsplan nicht gefunden"}), 404 - try: - plan = _normalize_plan(path) - tracker = _load_tracker(plan) - # Nur für die UI zusammensetzen; diese Felder existieren nicht in der Sessiondatei. - tracker["analysis_cache"] = _read_analysis_cache(plan) - tracker["analysis_state"] = _read_analysis_state(plan) - except Exception as exc: - return jsonify({"error": f"Plan konnte nicht gelesen werden: {exc}"}), 400 - return jsonify({"plan": plan, "tracker": tracker, "analysis_catalog": _analysis_catalog(plan, tracker), "equivalence_guide": _public_equivalence_guide(plan), "capabilities": {"openai": _openai_ready()}}) + if not path: + return jsonify({"error": "Plan nicht gefunden."}), 404 + return jsonify({"plan": _read_plan(path)}) -@app.route("/api/plans//tracker", methods=["PUT"]) -def api_save_tracker(plan_id: str): +@app.route("/api/plans//exercises//current", methods=["PATCH"]) +def api_update_current_progression(plan_id: str, exercise_id: str): path = _validated_plan_path(plan_id) - if path is None: - return jsonify({"error": "Trainingsplan nicht gefunden"}), 404 - if request.content_length and request.content_length > 5 * 1024 * 1024: - return jsonify({"error": "Tracker-Daten sind zu groß"}), 413 + if not path: + return jsonify({"error": "Plan nicht gefunden."}), 404 + body = request.get_json(silent=True) + body = body if isinstance(body, dict) else {} + progression_id = str(body.get("current_progression_id") or "").strip() + + plan = _read_plan(path) + cfg = deepcopy(plan["config"]) + exercise = next((item for item in cfg.get("exercises", []) if str(item.get("id")) == exercise_id), None) + if not exercise: + return jsonify({"error": "Übung nicht gefunden."}), 404 + ids = {str(item.get("id") or "") for item in exercise.get("progressions", []) if isinstance(item, dict)} + if progression_id not in ids: + return jsonify({"error": "Progression nicht gefunden."}), 400 + exercise["current_progression_id"] = progression_id try: - plan = _normalize_plan(path) - payload = request.get_json(force=True) - saved = _save_tracker(plan, payload) - except RuntimeError as exc: - if str(exc).startswith("revision_conflict:"): - current = str(exc).split(":", 1)[1] - return jsonify({"error": "Die Sessiondaten wurden in einem anderen Tab oder Gerät geändert.", "code": "revision_conflict", "current_revision": int(current)}), 409 - return jsonify({"error": str(exc)}), 409 - except (ValueError, TypeError) as exc: + saved = _write_plan_config(path, cfg) + except ValueError as exc: return jsonify({"error": str(exc)}), 400 - return jsonify({"ok": True, "updated_at": saved["updated_at"], "revision": saved.get("revision", 1), "analysis_catalog": _analysis_catalog(plan, saved), "analysis_state": _read_analysis_state(plan)}) - - -@app.route("/api/plans//tracker/session", methods=["PATCH"]) -def api_patch_tracker_session(plan_id: str): - path = _validated_plan_path(plan_id) - if path is None: - return jsonify({"error": "Trainingsplan nicht gefunden"}), 404 - try: - plan = _normalize_plan(path) - saved = _patch_tracker(plan, request.get_json(force=True)) - except RuntimeError as exc: - if str(exc).startswith("revision_conflict:"): - current = str(exc).split(":", 1)[1] - return jsonify({"error": "Die Session wurde in einem anderen Tab oder Gerät geändert.", "code": "revision_conflict", "current_revision": int(current)}), 409 - return jsonify({"error": str(exc)}), 409 - except (ValueError, TypeError) as exc: - return jsonify({"error": str(exc)}), 400 - return jsonify({ - "ok": True, "updated_at": saved["updated_at"], "revision": saved.get("revision", 1), - "analysis_catalog": _analysis_catalog(plan, saved), "analysis_state": _read_analysis_state(plan), - }) - - -@app.route("/api/plans//analysis", methods=["POST"]) -def api_progress_analysis(plan_id: str): - """Startet auf expliziten Button-Druck eine Wochen- oder Gesamtanalyse.""" - path = _validated_plan_path(plan_id) - if path is None: - return jsonify({"error": "Trainingsplan nicht gefunden"}), 404 - if not _openai_ready(): - return jsonify({"error": "OpenAI-Key oder Modell fehlt – siehe boehmitools-Einstellungen."}), 400 - try: - payload = request.get_json(silent=True) or {} - scope = str(payload.get("scope") or "overall") - week = _as_int(payload.get("week"), 0) if scope == "week" else None - if scope == "week" and week <= 0: - raise ValueError("Eine gültige Woche muss ausgewählt werden.") - plan = _normalize_plan(path) - tracker = _load_tracker(plan) - outcome = _start_analysis(plan, tracker, scope, week) - latest = _load_tracker(plan) - catalog = _analysis_catalog(plan, latest) - analysis_state = _read_analysis_state(plan) - analysis_cache = _read_analysis_cache(plan) - except RuntimeError as exc: - return jsonify({"error": str(exc), "analysis_state": _read_analysis_state(plan)}), 409 - except (ValueError, TypeError) as exc: - return jsonify({"error": str(exc)}), 400 - if outcome.get("cached"): - return jsonify({ - "ok": True, "cached": True, "selection": outcome.get("selection"), - "analysis_state": analysis_state, - "analysis_cache": analysis_cache, - "analysis_catalog": catalog, - }) - return jsonify({ - "ok": True, "cached": False, "job": outcome.get("job"), - "analysis_state": analysis_state, - "analysis_catalog": catalog, - }), 202 - - -@app.route("/api/plans//analysis/status") -def api_progress_analysis_status(plan_id: str): - path = _validated_plan_path(plan_id) - if path is None: - return jsonify({"error": "Trainingsplan nicht gefunden"}), 404 - try: - plan = _normalize_plan(path) - tracker = _load_tracker(plan) - catalog = _analysis_catalog(plan, tracker) - except Exception as exc: # noqa: BLE001 - return jsonify({"error": f"Analysestatus konnte nicht gelesen werden: {exc}"}), 400 - return jsonify({ - "analysis_state": _read_analysis_state(plan), - "analysis_cache": _read_analysis_cache(plan), - "analysis_catalog": catalog, - "updated_at": tracker.get("updated_at", ""), - }) - - -@app.route("/api/plans//tracker/export") -def api_export_tracker(plan_id: str): - path = _validated_plan_path(plan_id) - if path is None: - return jsonify({"error": "Trainingsplan nicht gefunden"}), 404 - tracker_path = _tracker_path(path.name) - if not tracker_path.exists(): - plan = _normalize_plan(path) - _atomic_json_write(tracker_path, _default_tracker(plan)) - return send_file( - tracker_path, - mimetype="application/json", - as_attachment=True, - download_name=f"tracking-{path.name}", - ) + return jsonify({"ok": True, "plan": saved}) if __name__ == "__main__": - app.run( - host=os.environ.get("HOST", "0.0.0.0"), - port=int(os.environ.get("PORT", "8081")), - debug=os.environ.get("DEBUG") == "1", - ) + app.run(host=os.environ.get("HOST", "0.0.0.0"), port=int(os.environ.get("PORT", "8081")), debug=False) diff --git a/plugins/trainingstracker/backend.py b/plugins/trainingstracker/backend.py index 8f2464b..f6dcd9c 100644 --- a/plugins/trainingstracker/backend.py +++ b/plugins/trainingstracker/backend.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""Plugin-Adapter für den Trainings-Session-Tracker.""" +"""Plugin-Adapter fuer die schlanke mobile Trainingsansicht.""" from __future__ import annotations import os @@ -10,13 +10,7 @@ from core.loader import load_module def create_app(ctx): - """Hängt die Flask-App unter /plugins/trainingstracker ein. - - Es werden ausschließlich Pfade relativ zum boehmitools-Datenordner benutzt: - data/trainingsplan/plans (nur lesen) - data/trainingstracker/sessions (Sessiondaten schreiben) - data/trainingstracker/analyses (Prompts, Antworten, Cache und Jobstatus) - """ + """Haengt die Flask-App unter /plugins/trainingstracker ein.""" tracker_data_dir = Path(ctx.data_dir) plans_dir = tracker_data_dir.parent / "trainingsplan" / "plans" diff --git a/plugins/trainingstracker/contract.py b/plugins/trainingstracker/contract.py index dec8ec1..c74d969 100644 --- a/plugins/trainingstracker/contract.py +++ b/plugins/trainingstracker/contract.py @@ -1,9 +1,4 @@ # -*- coding: utf-8 -*- -"""Shared constants for the versioned plan/tracker contract.""" -PLAN_SCHEMA_VERSION = 3 -CONTRACT_VERSION = 2 -TRACKER_SCHEMA_VERSION = 7 -RESULT_DATA_VERSION = 2 -PROMPT_VERSION = 5 -ANALYSIS_SCHEMA_VERSION = 3 -PROGRESSION_NORMALIZATION_VERSION = 5 +"""Shared constants for the slim training plan contract.""" +PLAN_SCHEMA_VERSION = 5 +CONTRACT_VERSION = 4 diff --git a/plugins/trainingstracker/plugin.json b/plugins/trainingstracker/plugin.json index 6dc2302..5a24665 100644 --- a/plugins/trainingstracker/plugin.json +++ b/plugins/trainingstracker/plugin.json @@ -1,37 +1,27 @@ { "id": "trainingstracker", - "name": "Trainings-Session-Tracker", - "summary": "Sessions mobil erfassen und planbewusst über Wochen analysieren", - "description": "Mobiler Tracker für veröffentlichte Trainingspläne mit stabilen Übungs-IDs, progressionsabhängigen Ergebnisfeldern, Session-Patches, Wochenabschluss, Variantenclustern und genau einer überschreibbaren KI-Analyse pro Woche beziehungsweise Gesamtplan.", + "name": "Training", + "summary": "Schlanke mobile Ansicht für Trainingstage und aktuelle Progressionen", + "description": "Mobile Planansicht für den aktuellen Trainingsplan. Zeigt Tage, Warm-up, Cool-down und Übungen und schreibt ausschließlich die aktuelle Progression einer Übung zurück in den Plan.", "icon": "📱", "category": "Planung", - "version": "2.0.0", + "version": "3.0.0", "entrypoint": "backend:create_app", "order": 11, "requires": [], "plugin_dependencies": { - "trainingsplan": ">=2.0.0" + "trainingsplan": ">=3.0.0" }, "contract": { - "plan_schema": 3, - "training_contract": 2, - "tracker_schema": 7, - "result_data": 2 + "plan_schema": 5, + "training_contract": 4 }, "features": [ - "Stabile Plan-, Übungs-, Progressions- und Stufen-IDs", - "Alte Positionsschlüssel werden automatisch migriert", - "Session-Patches mit Revisionskonfliktschutz", - "Planrevision wird pro Session gespeichert", - "Erledigt, teilweise und übersprungen mit Grund", - "Expliziter Wochenabschluss mit Zwischen- oder Abschlussanalyse", - "Genau eine Wochenanalyse je Woche und eine Gesamtanalyse, jeweils überschreibbar", - "Keine Analysehistorie", - "Persistente Analysejobs mit Dateisperre, Heartbeat und Lease", - "Planbezogene Übungsbibliothek und Variantencluster", - "Strukturierte KI-Vorschläge für den Planeditor", - "Letzter Tab, Woche, Tag und Auswahl bleiben erhalten", - "Trainingsplan bleibt read-only" + "Trainingstage mobil anzeigen", + "Warm-up und Cool-down anzeigen", + "Übungen eines Tages anzeigen", + "Aktuelle Progression je Übung ändern", + "Planwechsel merken" ], "docs": "README.md" } diff --git a/plugins/trainingstracker/session_store.py b/plugins/trainingstracker/session_store.py deleted file mode 100644 index 422e61d..0000000 --- a/plugins/trainingstracker/session_store.py +++ /dev/null @@ -1,28 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import annotations -from copy import deepcopy -from typing import Any - -VALID_SESSION_STATUSES = {"planned", "in_progress", "stopped", "completed"} -VALID_ITEM_STATUSES = {"planned", "completed", "partial", "skipped"} - - -def merge_session_patch(existing: dict[str, Any], payload: dict[str, Any], now: str) -> dict[str, Any]: - current_revision = int(existing.get("revision") or 1) - expected = payload.get("expected_revision") - if expected is not None and int(expected) != current_revision: - raise RuntimeError(f"revision_conflict:{current_revision}") - result = deepcopy(existing) - profile = payload.get("profile") - if isinstance(profile, dict): - result["profile"] = deepcopy(profile) - week_statuses = payload.get("week_statuses") - if isinstance(week_statuses, dict): - result["week_statuses"] = deepcopy(week_statuses) - key = str(payload.get("session_key") or "") - session = payload.get("session") - if key and isinstance(session, dict): - result.setdefault("sessions", {})[key] = deepcopy(session) - result["revision"] = current_revision + 1 - result["updated_at"] = now - return result diff --git a/plugins/trainingstracker/static/index.html b/plugins/trainingstracker/static/index.html index 3476872..6ce249d 100644 --- a/plugins/trainingstracker/static/index.html +++ b/plugins/trainingstracker/static/index.html @@ -6,12 +6,10 @@ - Trainings-Session-Tracker + Training - +
Aktiver Trainingsplan @@ -328,19 +170,15 @@
-