2965 lines
140 KiB
Python
2965 lines
140 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Mobile Tracking-App für boehmitools-Trainingspläne.
|
||
|
||
Quelle, strikt read-only:
|
||
<boehmitools>/data/trainingsplan/plans/*.json
|
||
|
||
Tracker-Daten:
|
||
<boehmitools>/data/trainingstracker/sessions/<Original-Dateiname>.json
|
||
|
||
Analyse-Daten, vollständig getrennt von den Sessions. Pro Woche und
|
||
Gesamtplan existiert jeweils nur eine überschreibbare Datei:
|
||
<boehmitools>/data/trainingstracker/analyses/<Original-Dateiname>/
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import html
|
||
import json
|
||
import os
|
||
import re
|
||
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
|
||
|
||
PLUGIN_DIR = Path(__file__).resolve().parent
|
||
STATIC_DIR = PLUGIN_DIR / "static"
|
||
_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")
|
||
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
|
||
if PLUGIN_DIR.parent.name == "plugins":
|
||
return PLUGIN_DIR.parent.parent
|
||
return PLUGIN_DIR
|
||
|
||
|
||
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"
|
||
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)
|
||
|
||
app = Flask(__name__, static_folder=None)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Dateisystem und JSON
|
||
# ---------------------------------------------------------------------------
|
||
def _utc_now() -> str:
|
||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||
|
||
|
||
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)
|
||
try:
|
||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||
json.dump(payload, handle, ensure_ascii=False, indent=2)
|
||
handle.write("\n")
|
||
handle.flush()
|
||
os.fsync(handle.fileno())
|
||
os.replace(temp_name, path)
|
||
finally:
|
||
if os.path.exists(temp_name):
|
||
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 _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 _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
|
||
|
||
|
||
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
|
||
|
||
|
||
def _read_settings() -> dict[str, Any]:
|
||
settings = _read_json(SETTINGS_FILE, {})
|
||
return settings if isinstance(settings, dict) else {}
|
||
|
||
|
||
def _save_selected(plan_id: str) -> None:
|
||
settings = _read_settings()
|
||
settings["selected_plan"] = plan_id
|
||
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 _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"<br\s*/?>", "\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"<br\s*/?>", " · ", 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
|
||
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"(?<!\d)(\d{1,2})\s*(?:sätze|saetze|sets)\b", label, flags=re.I)
|
||
if match:
|
||
set_count = int(match.group(1))
|
||
|
||
rounds = training_format.get("rounds")
|
||
if (
|
||
training_format.get("fixed_interval") and isinstance(rounds, int) and rounds >= 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"(?<!\d)(\d{1,2})\s*(?:runden|intervalle)\b",):
|
||
match = re.search(pattern, text, flags=re.I)
|
||
if match:
|
||
value = int(match.group(1))
|
||
if 1 <= value <= 20:
|
||
return value, True
|
||
return 1, False
|
||
|
||
|
||
def _infer_result_schema(
|
||
exercise: dict[str, Any], stages: dict[str, dict[str, Any]],
|
||
training_format: dict[str, Any], rotation_label: Any, exercise_count: int,
|
||
) -> 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"(?<!\d)\d{1,3}(?:[–-]\d{1,3})?\s*(?:s|sek(?:unden)?\.?)(?!\w)", cue, flags=re.I))
|
||
has_stage = bool(steps)
|
||
if _RESULT_NO_MEASURE_RE.search(name):
|
||
mode = "none"
|
||
elif _RESULT_MINUTES_RE.search(name):
|
||
mode = "minutes"
|
||
elif explicit_time and not _RESULT_DYNAMIC_RE.search(name):
|
||
mode = "seconds"
|
||
elif static_name and not dynamic_stage:
|
||
mode = "seconds"
|
||
elif static_stage and dynamic_stage:
|
||
mode = "auto"
|
||
elif static_stage:
|
||
mode = "seconds"
|
||
elif not has_stage and not training_format.get("fixed_interval") and not _RESULT_DYNAMIC_RE.search(full_text):
|
||
mode = "none"
|
||
else:
|
||
mode = "reps"
|
||
|
||
if mode in {"none", "minutes"}:
|
||
weight_mode = "none"
|
||
elif _RESULT_REQUIRED_WEIGHT_RE.search(name):
|
||
# Manche Pläne nennen das spätere Gerät bereits im Übungsnamen, beginnen
|
||
# aber ausdrücklich mit Körpergewicht. Dann ist Gewicht abhängig von der
|
||
# gewählten Progressionsstufe und darf nicht pauschal Pflicht sein.
|
||
weight_mode = "optional" if _RESULT_UNWEIGHTED_RE.search(stage_text) else "required"
|
||
elif _RESULT_OPTIONAL_WEIGHT_RE.search(stage_text) or re.search(r"(?:wadenheben|calf)", name, flags=re.I):
|
||
weight_mode = "optional"
|
||
else:
|
||
weight_mode = "none"
|
||
|
||
laterality = "unilateral" if _RESULT_UNILATERAL_RE.search(full_text) else "bilateral"
|
||
default_sets, locked_sets = _result_default_sets(rotation_label, cue, training_format, exercise_count)
|
||
sides_mode = "same"
|
||
if laterality == "unilateral" and re.search(
|
||
r"(?:dg\s*1.*links.*dg\s*2.*rechts|seitenwechsel|\bwechsel\b)",
|
||
f"{name} {cue}", flags=re.I,
|
||
):
|
||
sides_mode = "separate"
|
||
|
||
# Optionaler Plan-Override. Der Tracker bleibt abwärtskompatibel, kann aber
|
||
# präzise Schemata übernehmen, sobald das Trainingsplan-Modul sie anbietet.
|
||
explicit_sets = 0
|
||
explicit = exercise.get("result_schema")
|
||
if isinstance(explicit, dict):
|
||
explicit_mode = str(explicit.get("mode") or "")
|
||
if explicit_mode in {"auto", "reps", "seconds", "minutes", "none"}:
|
||
mode = explicit_mode
|
||
explicit_weight = str(explicit.get("weight_mode") or "")
|
||
if explicit_weight in {"none", "optional", "required"}:
|
||
weight_mode = explicit_weight
|
||
explicit_laterality = str(explicit.get("laterality") or "")
|
||
if explicit_laterality in {"bilateral", "unilateral"}:
|
||
laterality = explicit_laterality
|
||
explicit_sides = str(explicit.get("sides_mode") or "")
|
||
if explicit_sides in {"same", "separate"}:
|
||
sides_mode = explicit_sides
|
||
try:
|
||
explicit_sets = int(explicit.get("sets"))
|
||
except (TypeError, ValueError):
|
||
explicit_sets = 0
|
||
if 1 <= explicit_sets <= 20:
|
||
default_sets = explicit_sets
|
||
locked_sets = bool(explicit.get("locked_sets", True))
|
||
|
||
# Einseitige Tabata-Übungen mit genau zwei Durchgängen pro Block werden
|
||
# als ein Links-/Rechts-Paar je Block dargestellt. Entscheidend ist die
|
||
# endgültige Lateralisierung nach dem Plan-Override, nicht ein Stichwort in
|
||
# einer späteren Progressionsstufe.
|
||
rounds = training_format.get("rounds")
|
||
if (
|
||
not (1 <= explicit_sets <= 20)
|
||
and laterality == "unilateral"
|
||
and training_format.get("fixed_interval")
|
||
and isinstance(rounds, int) and exercise_count >= 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):
|
||
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"])
|
||
|
||
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", [])),
|
||
)
|
||
|
||
return {
|
||
"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,
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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"],
|
||
"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 _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)
|
||
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"]
|
||
tracker["source_changed"] = tracker.get("source_hash") not in (None, plan["source_hash"])
|
||
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 _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"
|
||
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"(?<!\d)(\d{2,3}(?:[.,]\d+)?)\s*kg\b", _plain_text(candidate or ""), flags=re.I):
|
||
number = float(value.replace(",", "."))
|
||
if 35 <= number <= 300:
|
||
return number
|
||
return None
|
||
|
||
|
||
def _public_equivalence_guide(plan: dict[str, Any]) -> 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
|
||
|
||
|
||
@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")
|
||
|
||
|
||
@app.route("/api/health")
|
||
def api_health():
|
||
return jsonify({
|
||
"ok": True,
|
||
"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,
|
||
},
|
||
})
|
||
|
||
|
||
@app.route("/api/plans")
|
||
def api_plans():
|
||
settings = _read_settings()
|
||
selected = settings.get("selected_plan")
|
||
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
|
||
return jsonify({"plans": plans, "selected": selected})
|
||
|
||
|
||
@app.route("/api/plans/<path:plan_id>/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})
|
||
|
||
|
||
@app.route("/api/plans/<path:plan_id>")
|
||
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()}})
|
||
|
||
|
||
@app.route("/api/plans/<path:plan_id>/tracker", methods=["PUT"])
|
||
def api_save_tracker(plan_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
|
||
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:
|
||
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/<path:plan_id>/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/<path:plan_id>/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/<path:plan_id>/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/<path:plan_id>/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}",
|
||
)
|
||
|
||
|
||
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",
|
||
)
|