# -*- coding: utf-8 -*- """Mobile Tracking-App für boehmitools-Trainingspläne. Quelle, strikt read-only: /data/trainingsplan/plans/*.json Tracker-Daten: /data/trainingstracker/sessions/.json Analyse-Daten, vollständig getrennt von den Sessions. Pro Woche und Gesamtplan existiert jeweils nur eine überschreibbare Datei: /data/trainingstracker/analyses// """ 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"", "\n", text, flags=re.I) text = re.sub(r"<[^>]+>", "", text) return html.unescape(text).strip() def _sequence_items(value: Any) -> list[str]: text = str(value or "") text = re.sub(r"", " · ", text, flags=re.I) text = _plain_text(text) if not text: return [] parts = re.split(r"\s*(?:·|•|\n)\s*", text) return [part.strip() for part in parts if part.strip()] def _slug(value: Any) -> str: text = _plain_text(value).lower() text = text.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss") text = re.sub(r"[^a-z0-9]+", "-", text).strip("-") return text or "item" def _normalize_phases(cfg: dict[str, Any]) -> list[dict[str, Any]]: phases = cfg.get("phases") if isinstance(cfg.get("phases"), dict) else {} items = phases.get("items") normalized: list[dict[str, Any]] = [] if isinstance(items, list): for item in items: if not isinstance(item, dict): continue normalized.append({ "id": str(item.get("id") or f"phase-{len(normalized)+1}"), "name": _plain_text(item.get("name") or f"Phase {len(normalized) + 1}"), "params": str(item.get("params") or ""), "weeks": max(1, _as_int(item.get("weeks"), 1)), }) else: names = phases.get("names") if isinstance(phases.get("names"), list) else [] params = phases.get("params") if isinstance(phases.get("params"), list) else [] for index, name in enumerate(names): normalized.append({ "id": f"phase-{index+1}", "name": _plain_text(name or f"Phase {index + 1}"), "params": str(params[index] if index < len(params) else ""), "weeks": 2, }) if not normalized: normalized.append({"id": "phase-1", "name": "Gesamter Plan", "params": "", "weeks": max(1, _plan_weeks(cfg))}) cursor = 1 for index, item in enumerate(normalized): item["index"] = index item["start_week"] = cursor item["end_week"] = cursor + item["weeks"] - 1 cursor = item["end_week"] + 1 return normalized def _sanitize_plan_result_schema(value: Any, *, partial: bool = True) -> dict[str, Any] | None: """Normalisiert ein im Trainingsplan gespeichertes Ergebnisschema. Stufen-Schemata dürfen partiell sein und nur einzelne Eigenschaften des Übungsstandards überschreiben. Unbekannte Felder werden ignoriert. """ if not isinstance(value, dict): return None result: dict[str, Any] = {} mode = str(value.get("mode") or "") if mode in {"auto", "reps", "seconds", "minutes", "none"}: result["mode"] = mode weight_mode = str(value.get("weight_mode") or "") if weight_mode in {"none", "optional", "required"}: result["weight_mode"] = weight_mode laterality = str(value.get("laterality") or "") if laterality in {"bilateral", "unilateral"}: result["laterality"] = laterality sides_mode = str(value.get("sides_mode") or "") if sides_mode in {"same", "separate"}: result["sides_mode"] = sides_mode try: sets = int(value.get("sets") or value.get("default_sets") or 0) except (TypeError, ValueError): sets = 0 if 1 <= sets <= 20: result["sets"] = sets result["locked_sets"] = bool(value.get("locked_sets", True)) elif "locked_sets" in value: result["locked_sets"] = bool(value.get("locked_sets")) if not partial: result.setdefault("mode", "auto") result.setdefault("weight_mode", "none") result.setdefault("laterality", "bilateral") result.setdefault("sides_mode", "same") return result or None def _normalize_stages(cfg: dict[str, Any]) -> dict[str, dict[str, Any]]: stages = cfg.get("stages") if isinstance(cfg.get("stages"), dict) else {} result: dict[str, dict[str, Any]] = {} for key, value in stages.items(): if isinstance(value, dict): raw_steps = value.get("steps") if isinstance(value.get("steps"), list) else [] raw_schemas = value.get("result_schemas") if isinstance(value.get("result_schemas"), list) else [] steps: list[dict[str, Any]] = [] for index, raw_step in enumerate(raw_steps): if isinstance(raw_step, dict): name = html.unescape(str(raw_step.get("name") or raw_step.get("label") or "")) schema = _sanitize_plan_result_schema(raw_step.get("result_schema"), partial=True) step_id = str(raw_step.get("id") or f"{key}-step-{index+1}") factor = _result_number(raw_step.get("factor")) or 1.0 cluster = str(raw_step.get("movement_cluster") or "") phase_id = str(raw_step.get("phase_id") or "") else: name = html.unescape(str(raw_step or "")) schema = _sanitize_plan_result_schema(raw_schemas[index], partial=True) if index < len(raw_schemas) else None step_id = f"{key}-step-{index+1}" factor = 1.0 cluster = "" phase_id = "" steps.append({ "id": step_id, "name": name, "result_schema": schema, "factor": factor, "movement_cluster": cluster, "phase_id": phase_id, }) result[str(key)] = { "id": str(value.get("id") or f"progression-{key}"), "key": str(value.get("key") or key), "name": _plain_text(value.get("name") or key), "steps": steps, } elif isinstance(value, list): result[str(key)] = { "id": f"progression-{key}", "key": str(key), "name": _plain_text(key), "steps": [{"id": f"{key}-step-{i+1}", "name": html.unescape(str(step or "")), "result_schema": None, "factor": 1.0, "movement_cluster": "", "phase_id": ""} for i, step in enumerate(value)], } return result def _library_lookup(cfg: dict[str, Any]) -> dict[str, dict[str, str]]: lookup: dict[str, dict[str, str]] = {} groups = cfg.get("library") if isinstance(cfg.get("library"), list) else [] for group in groups: if not isinstance(group, dict): continue for item in group.get("items", []) if isinstance(group.get("items"), list) else []: if not isinstance(item, dict): continue name = _plain_text(item.get("name") or "") if not name: continue entry = { "name": name, "desc": str(item.get("desc") or ""), "url": str(item.get("url") or ""), "group": _plain_text(group.get("group") or ""), } lookup[_slug(name)] = entry return lookup def _closest_library_entry(name: str, lookup: dict[str, dict[str, str]]) -> dict[str, str] | None: exact = lookup.get(_slug(name)) if exact: return exact target = _slug(name) for key, entry in lookup.items(): if target in key or key in target: return entry return None _RESULT_STATIC_RE = re.compile( r"(?:\bhold\b|\bplank\b|\bhang\b|wall\s*sit|wandsitz|isometr|stützposition)", flags=re.I, ) _RESULT_DYNAMIC_RE = re.compile( r"(?:push.?up|pull.?up|klimmzug|scapular|negative?|liegestütz|wiederhol|\breps?\b|rudern|\brow\b|" r"squat|kniebeuge|dip|curl|deadlift|rdl|hinge|swing|raise|heben|climber|crunch|twist|lunge|ausfallschritt)", flags=re.I, ) _RESULT_NO_MEASURE_RE = re.compile( r"(?:regeneration\s+prüfen|schlaf\s+priorisieren|schmerzcheck|sonst\s+vollständig\s+pausieren|" r"(?:push|pull|beine|core)\s+protokollieren|schwierigkeit\s+erhöhen|reps?\s+reserve\s+prüfen|" r"mobilität|mobility)", flags=re.I, ) _RESULT_MINUTES_RE = re.compile(r"(?:spaziergang|walk|walking)", flags=re.I) _RESULT_REQUIRED_WEIGHT_RE = re.compile( r"(?:\bkb\b|kettlebell|rucksack|goblet|floor\s*press|deadlift|\brdl\b|hip\s*hinge|" r"\bswing\b|\bhalo\b|\bcurl|rudern.*\bkb\b|row.*\bkb\b|around.the.world)", flags=re.I, ) _RESULT_OPTIONAL_WEIGHT_RE = re.compile( r"(?:gewicht|gewichtet|zusatzlast|\blast\b|\bkg\b|kettlebell|\bkb\b|rucksack|" r"schwerer|wadenheben|calf)", flags=re.I, ) _RESULT_UNWEIGHTED_RE = re.compile( r"(?:ohne\s+gewicht|ungewichtet|körpergewicht|koerpergewicht|\bbw\b)", flags=re.I, ) _RESULT_UNILATERAL_RE = re.compile( r"(?:einarm|einbein|je\s+seite|pro\s+seite|links|rechts|seitenwechsel|\bwechsel\b|" r"ausfallschritt|\blunge\b|side\s*plank|seitstütz|seitheben|lateral\s*raise|" r"russian\s*twist|dead\s*bug|\bhalo\b|around.the.world|bottoms.?up)", flags=re.I, ) def _result_default_sets( rotation_label: Any, cue: Any, training_format: dict[str, Any], exercise_count: int, ) -> tuple[int, bool]: label = _plain_text(rotation_label or "") text = _plain_text(f"{label} {cue or ''}") if re.search(r"\barbeitssatz\s*\d+", label, flags=re.I): return 1, True set_count = None match = re.search(r"(?= 1 and exercise_count >= 1 and rounds % exercise_count == 0 ): per_block = max(1, rounds // exercise_count) return max(1, min(20, per_block * (set_count or 1))), True if set_count and 1 <= set_count <= 20: return set_count, True for pattern in (r"(? dict[str, Any]: """Leitet die feste Ergebniseingabe aus Übung, Stufen und Planformat ab.""" name = _plain_text(exercise.get("name") or "") cue = _plain_text(exercise.get("cue") or "") progression_id = str(exercise.get("progression_id") or "") stage = stages.get(progression_id) if isinstance(stages, dict) else None steps = stage.get("steps") if isinstance(stage, dict) and isinstance(stage.get("steps"), list) else [] stage_text = " ".join(_plain_text(step) for step in steps) full_text = f"{name} {cue} {stage_text}" static_name = bool(_RESULT_STATIC_RE.search(name)) static_stage = bool(_RESULT_STATIC_RE.search(stage_text)) dynamic_stage = bool(_RESULT_DYNAMIC_RE.search(stage_text)) explicit_time = bool(re.search(r"(?= 1 and rounds % exercise_count == 0 and rounds // exercise_count == 2 and default_sets % 2 == 0 ): default_sets = max(1, default_sets // 2) return { "version": RESULT_DATA_VERSION, "mode": mode, "weight_mode": weight_mode, "laterality": laterality, "sides_mode": sides_mode if laterality == "unilateral" else "same", "default_sets": default_sets, "locked_sets": locked_sets, "work_seconds": training_format.get("work_seconds") if training_format.get("fixed_interval") else None, "fixed_interval": bool(training_format.get("fixed_interval")), } def _normalize_rotations(day: dict[str, Any], day_num: int, library: dict[str, dict[str, str]]) -> list[dict[str, Any]]: rotations = day.get("rotations") if not isinstance(rotations, list): rotations = [] if isinstance(day.get("rotA"), list): rotations.append({"label": "Rotation A", "exercises": day["rotA"]}) if isinstance(day.get("rotB"), list): rotations.append({"label": "Rotation B", "exercises": day["rotB"]}) result: list[dict[str, Any]] = [] day_id = str(day.get("id") or f"day-{day_num}") for rotation_index, rotation in enumerate(rotations): if not isinstance(rotation, dict): continue rotation_id = str(rotation.get("id") or f"{day_id}-rotation-{rotation_index+1}") exercises = rotation.get("exercises") if isinstance(rotation.get("exercises"), list) else [] normalized_exercises = [] for exercise_index, exercise in enumerate(exercises): if not isinstance(exercise, dict): continue name = _plain_text(exercise.get("name") or f"Übung {exercise_index + 1}") key = str(exercise.get("progression_id") or exercise.get("key") or "").strip() progression_id = key or f"name:{_slug(name)}" normalized_exercises.append({ "id": str(exercise.get("id") or f"{rotation_id}-exercise-{exercise_index+1}"), "legacy_id": str(exercise.get("legacy_id") or f"d{day_num}-r{rotation_index}-e{exercise_index}"), "exercise_id": str(exercise.get("exercise_id") or progression_id), "name": name, "key": key, "progression_id": progression_id, "cue": str(exercise.get("cue") or ""), "movement_cluster": str(exercise.get("movement_cluster") or ""), "result_schema": _sanitize_plan_result_schema(exercise.get("result_schema"), partial=False), "library": _closest_library_entry(name, library), }) result.append({"id": rotation_id, "label": str(rotation.get("label") or f"Block {rotation_index + 1}"), "exercises": normalized_exercises}) return result def _as_int(value: Any, default: int) -> int: try: return int(value) except (TypeError, ValueError): return default def _plan_weeks(cfg: dict[str, Any]) -> int: meta = cfg.get("meta") if isinstance(cfg.get("meta"), dict) else {} return max(1, _as_int(meta.get("weeks"), 8)) def _first_int(patterns: list[str], text: str) -> int | None: for pattern in patterns: match = re.search(pattern, text, flags=re.I) if match: try: return int(match.group(1)) except (TypeError, ValueError): continue return None def _detect_training_format(cfg: dict[str, Any], days: list[dict[str, Any]]) -> dict[str, Any]: """Leitet bindende Session-Regeln aus dem Plan ab. Die KI bekommt damit nicht nur Übungen, sondern auch das tatsächliche Ausführungsformat. Bei Tabata bleiben Arbeits- und Pausenintervalle fest. """ explicit_format = cfg.get("training_format") if isinstance(cfg.get("training_format"), dict) else {} explicit_mode = str(explicit_format.get("mode") or "auto") if explicit_mode in {"sets_reps", "tabata", "fixed_interval"}: def _positive_int(field: str) -> int | None: try: number = int(explicit_format.get(field)) except (TypeError, ValueError): return None return number if number > 0 else None fixed = explicit_mode in {"tabata", "fixed_interval"} or bool(explicit_format.get("fixed_interval")) work = _positive_int("work_seconds") rest = _positive_int("rest_seconds") rounds = _positive_int("rounds") constraints: list[str] = [] if fixed: interval = f"{work} Sekunden" if work else "die vorgegebene Dauer" constraints.extend([ f"Arbeitsintervalle bleiben fest bei {interval}; keine längeren Holds oder verlängerten Arbeitsintervalle empfehlen.", "Progression nur innerhalb des Planformats: planmäßige schwierigere Variante, mehr Gewicht, saubererer ROM/Tempo oder bessere Technik; Pausen und Rundenzahl nicht eigenmächtig ändern.", ]) else: constraints.append( "Vorgegebene Wiederholungs-, Haltezeit-, Satz- und Pausenbereiche bleiben bindend; nach Erreichen der Obergrenze die planmäßige Schwierigkeit anpassen statt unbegrenzt Volumen zu addieren." ) front = cfg.get("front") if isinstance(cfg.get("front"), dict) else {} return { "mode": explicit_mode, "is_tabata": explicit_mode == "tabata", "fixed_interval": fixed, "work_seconds": work, "rest_seconds": rest, "rounds": rounds, "rounds_scope": "block", "source": "plan", "session_method": _plain_text(front.get("session_how_body") or ""), "timer_note": _plain_text(front.get("timer_note") or ""), "constraints": constraints, } front = cfg.get("front") if isinstance(cfg.get("front"), dict) else {} phases = cfg.get("phases") if isinstance(cfg.get("phases"), dict) else {} phase_items = phases.get("items") if isinstance(phases.get("items"), list) else [] fragments: list[str] = [ str(front.get("session_how_body") or ""), str(front.get("timer_note") or ""), str(front.get("goals_note") or ""), *[str(value or "") for value in front.get("reminders", []) if isinstance(front.get("reminders"), list)], *[str(item.get("params") or "") for item in phase_items if isinstance(item, dict)], ] for day in days: fragments.append(str(day.get("note") or "")) for rotation in day.get("rotations", []) if isinstance(day.get("rotations"), list) else []: fragments.append(str(rotation.get("label") or "")) for exercise in rotation.get("exercises", []) if isinstance(rotation.get("exercises"), list) else []: fragments.append(str(exercise.get("cue") or "")) raw_text = " ".join(fragments) plain = _plain_text(raw_text) low = plain.casefold() negated_tabata = bool(re.search(r"\b(?:kein|keine|ohne)\s+tabata\b|\bkein\s+20\s*/\s*10", low)) is_tabata = not negated_tabata and ("tabata" in low or bool(re.search(r"\b20\s*/\s*10\b", low))) work_seconds = _first_int([ r"(\d+)\s*(?:s|sek(?:unden)?)\s*(?:arbeit|work)", r"(?:arbeit|work)\s*(?:von|:)??\s*(\d+)\s*(?:s|sek(?:unden)?)", r"(\d+)\s*/\s*\d+", ], low) rest_seconds = _first_int([ r"(\d+)\s*(?:s|sek(?:unden)?)\s*(?:pause|rest)", r"(?:pause|rest)\s*(?:von|:)??\s*(\d+)\s*(?:s|sek(?:unden)?)", r"\d+\s*/\s*(\d+)", ], low) rounds = _first_int([ r"(\d+)\s*[×x]\s*\(", r"(\d+)\s*(?:runden|intervalle)", r"(?:runden|intervalle)\s*(?:von|:)??\s*(\d+)", ], low) fixed_interval = bool(is_tabata or ((work_seconds and rest_seconds) and not negated_tabata)) mode = "tabata" if is_tabata else ("fixed_interval" if fixed_interval else "sets_reps") constraints: list[str] = [] if fixed_interval: interval = f"{work_seconds} Sekunden" if work_seconds else "die vorgegebene Dauer" constraints.append( f"Arbeitsintervalle bleiben fest bei {interval}; keine längeren Holds oder verlängerten Arbeitsintervalle empfehlen." ) constraints.append( "Progression nur innerhalb des Planformats: planmäßige schwierigere Variante, mehr Gewicht, saubererer ROM/Tempo oder bessere Technik; Pausen und Rundenzahl nicht eigenmächtig ändern." ) else: constraints.append( "Vorgegebene Wiederholungs-, Haltezeit-, Satz- und Pausenbereiche bleiben bindend; nach Erreichen der Obergrenze die planmäßige Schwierigkeit anpassen statt unbegrenzt Volumen zu addieren." ) return { "mode": mode, "is_tabata": is_tabata, "fixed_interval": fixed_interval, "work_seconds": work_seconds, "rest_seconds": rest_seconds, "rounds": rounds, "rounds_scope": "block", "source": "text_fallback", "session_method": _plain_text(front.get("session_how_body") or ""), "timer_note": _plain_text(front.get("timer_note") or ""), "constraints": constraints, } def _published_config_hash(config: dict[str, Any]) -> str: raw = json.dumps(config, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") return hashlib.sha256(raw).hexdigest() def _normalize_plan(path: Path) -> dict[str, Any]: raw = _read_json(path) plan_name, cfg = _unwrap_plan(raw, path.stem) wrapper = raw if isinstance(raw, dict) and isinstance(raw.get("config"), dict) else {} if not _is_training_config(cfg): raise ValueError("Keine Trainingsplan-Datei") raw_schema_version = int(cfg.get("schema_version") or 1) raw_contract_version = int(cfg.get("contract_version") or 1) if raw_schema_version > PLAN_SCHEMA_VERSION or raw_contract_version > CONTRACT_VERSION: raise ValueError( f"Der Trainingsplan verwendet Schema {raw_schema_version}/Vertrag {raw_contract_version}; " f"dieser Tracker unterstützt höchstens {PLAN_SCHEMA_VERSION}/{CONTRACT_VERSION}." ) compatibility_warnings = [] if raw_schema_version < PLAN_SCHEMA_VERSION: compatibility_warnings.append( f"Legacy-Plan-Schema {raw_schema_version}; Fallback-Erkennung bleibt aktiv. Im Trainingsplan-Editor neu veröffentlichen." ) if raw_contract_version < CONTRACT_VERSION: compatibility_warnings.append( f"Legacy-Trainingsvertrag {raw_contract_version}; einige stabile IDs oder Ergebnisschemata können fehlen." ) meta = cfg.get("meta") if isinstance(cfg.get("meta"), dict) else {} front = cfg.get("front") if isinstance(cfg.get("front"), dict) else {} prepost = cfg.get("prepost") if isinstance(cfg.get("prepost"), dict) else {} library = _library_lookup(cfg) phases = _normalize_phases(cfg) stages = _normalize_stages(cfg) days: list[dict[str, Any]] = [] for fallback_index, day in enumerate(cfg.get("days", []), start=1): 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"(? 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//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/") 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//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//tracker/session", methods=["PATCH"]) def api_patch_tracker_session(plan_id: str): path = _validated_plan_path(plan_id) if path is None: return jsonify({"error": "Trainingsplan nicht gefunden"}), 404 try: plan = _normalize_plan(path) saved = _patch_tracker(plan, request.get_json(force=True)) except RuntimeError as exc: if str(exc).startswith("revision_conflict:"): current = str(exc).split(":", 1)[1] return jsonify({"error": "Die Session wurde in einem anderen Tab oder Gerät geändert.", "code": "revision_conflict", "current_revision": int(current)}), 409 return jsonify({"error": str(exc)}), 409 except (ValueError, TypeError) as exc: return jsonify({"error": str(exc)}), 400 return jsonify({ "ok": True, "updated_at": saved["updated_at"], "revision": saved.get("revision", 1), "analysis_catalog": _analysis_catalog(plan, saved), "analysis_state": _read_analysis_state(plan), }) @app.route("/api/plans//analysis", methods=["POST"]) def api_progress_analysis(plan_id: str): """Startet auf expliziten Button-Druck eine Wochen- oder Gesamtanalyse.""" path = _validated_plan_path(plan_id) if path is None: return jsonify({"error": "Trainingsplan nicht gefunden"}), 404 if not _openai_ready(): return jsonify({"error": "OpenAI-Key oder Modell fehlt – siehe boehmitools-Einstellungen."}), 400 try: payload = request.get_json(silent=True) or {} scope = str(payload.get("scope") or "overall") week = _as_int(payload.get("week"), 0) if scope == "week" else None if scope == "week" and week <= 0: raise ValueError("Eine gültige Woche muss ausgewählt werden.") plan = _normalize_plan(path) tracker = _load_tracker(plan) outcome = _start_analysis(plan, tracker, scope, week) latest = _load_tracker(plan) catalog = _analysis_catalog(plan, latest) analysis_state = _read_analysis_state(plan) analysis_cache = _read_analysis_cache(plan) except RuntimeError as exc: return jsonify({"error": str(exc), "analysis_state": _read_analysis_state(plan)}), 409 except (ValueError, TypeError) as exc: return jsonify({"error": str(exc)}), 400 if outcome.get("cached"): return jsonify({ "ok": True, "cached": True, "selection": outcome.get("selection"), "analysis_state": analysis_state, "analysis_cache": analysis_cache, "analysis_catalog": catalog, }) return jsonify({ "ok": True, "cached": False, "job": outcome.get("job"), "analysis_state": analysis_state, "analysis_catalog": catalog, }), 202 @app.route("/api/plans//analysis/status") def api_progress_analysis_status(plan_id: str): path = _validated_plan_path(plan_id) if path is None: return jsonify({"error": "Trainingsplan nicht gefunden"}), 404 try: plan = _normalize_plan(path) tracker = _load_tracker(plan) catalog = _analysis_catalog(plan, tracker) except Exception as exc: # noqa: BLE001 return jsonify({"error": f"Analysestatus konnte nicht gelesen werden: {exc}"}), 400 return jsonify({ "analysis_state": _read_analysis_state(plan), "analysis_cache": _read_analysis_cache(plan), "analysis_catalog": catalog, "updated_at": tracker.get("updated_at", ""), }) @app.route("/api/plans//tracker/export") def api_export_tracker(plan_id: str): path = _validated_plan_path(plan_id) if path is None: return jsonify({"error": "Trainingsplan nicht gefunden"}), 404 tracker_path = _tracker_path(path.name) if not tracker_path.exists(): plan = _normalize_plan(path) _atomic_json_write(tracker_path, _default_tracker(plan)) return send_file( tracker_path, mimetype="application/json", as_attachment=True, download_name=f"tracking-{path.name}", ) 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", )