Update
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Versionierter Datenvertrag zwischen Trainingsplan-Editor und Session-Tracker."""
|
||||
"""Schlanker Datenvertrag fuer den Trainingsplan-Editor."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
@@ -7,15 +7,8 @@ import hashlib
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
PLAN_SCHEMA_VERSION = 3
|
||||
CONTRACT_VERSION = 2
|
||||
RESULT_SCHEMA_VERSION = 2
|
||||
|
||||
VALID_RESULT_MODES = {"auto", "reps", "seconds", "minutes", "none"}
|
||||
VALID_WEIGHT_MODES = {"none", "optional", "required"}
|
||||
VALID_LATERALITY = {"bilateral", "unilateral"}
|
||||
VALID_SIDES_MODES = {"same", "separate"}
|
||||
VALID_TRAINING_MODES = {"auto", "sets_reps", "tabata", "fixed_interval"}
|
||||
PLAN_SCHEMA_VERSION = 4
|
||||
CONTRACT_VERSION = 3
|
||||
|
||||
|
||||
def slug(value: Any) -> str:
|
||||
@@ -30,289 +23,220 @@ def stable_id(prefix: str, *parts: Any) -> str:
|
||||
return f"{prefix}-{slug(parts[-1] if parts else prefix)[:30]}-{digest}"
|
||||
|
||||
|
||||
def normalize_result_schema(value: Any, partial: bool = False) -> dict[str, Any] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, dict):
|
||||
value = {}
|
||||
mode = str(value.get("mode") or ("auto" if not partial else ""))
|
||||
weight_mode = str(value.get("weight_mode") or ("none" if not partial else ""))
|
||||
laterality = str(value.get("laterality") or ("bilateral" if not partial else ""))
|
||||
sides_mode = str(value.get("sides_mode") or ("same" if not partial else ""))
|
||||
result: dict[str, Any] = {}
|
||||
if mode in VALID_RESULT_MODES:
|
||||
result["mode"] = mode
|
||||
elif not partial:
|
||||
result["mode"] = "auto"
|
||||
if weight_mode in VALID_WEIGHT_MODES:
|
||||
result["weight_mode"] = weight_mode
|
||||
elif not partial:
|
||||
result["weight_mode"] = "none"
|
||||
if laterality in VALID_LATERALITY:
|
||||
result["laterality"] = laterality
|
||||
elif not partial:
|
||||
result["laterality"] = "bilateral"
|
||||
if sides_mode in VALID_SIDES_MODES:
|
||||
result["sides_mode"] = sides_mode
|
||||
elif not partial:
|
||||
result["sides_mode"] = "same"
|
||||
def default_config() -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": PLAN_SCHEMA_VERSION,
|
||||
"contract_version": CONTRACT_VERSION,
|
||||
"plan_id": "neuer-trainingsplan",
|
||||
"meta": {"title": "Neuer Trainingsplan"},
|
||||
"exercises": [],
|
||||
"days": [],
|
||||
}
|
||||
|
||||
|
||||
def _string(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _int(value: Any, fallback: int = 0) -> int:
|
||||
try:
|
||||
sets = int(value.get("sets"))
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
sets = 0
|
||||
if 1 <= sets <= 40:
|
||||
result["sets"] = sets
|
||||
if "locked_sets" in value:
|
||||
result["locked_sets"] = bool(value.get("locked_sets"))
|
||||
if result.get("laterality") == "bilateral":
|
||||
result["sides_mode"] = "same"
|
||||
return result or None
|
||||
return fallback
|
||||
|
||||
|
||||
|
||||
def infer_result_schema(name: Any, cue: Any = "", *, stage: bool = False) -> dict[str, Any]:
|
||||
"""Leitet für Legacy- und neue Standardpläne ein explizites Basisschema ab.
|
||||
|
||||
Die Ableitung ist nur eine Migrationshilfe. Nach dem Speichern liegt das
|
||||
Ergebnis fest im Plan und kann im Editor jederzeit korrigiert werden.
|
||||
"""
|
||||
text = f"{name or ''} {cue or ''}".casefold()
|
||||
no_measure = re.search(
|
||||
r"(?:regeneration|schlaf|schmerzcheck|pausieren|protokollieren|prüfen|check|"
|
||||
r"mobility|mobilität|dehnen|stretch|cool.?down|warm.?up|spaziergang locker)$",
|
||||
text,
|
||||
)
|
||||
if no_measure:
|
||||
mode = "none"
|
||||
elif re.search(r"(?:spaziergang|gehen|walk).*(?:min|minute)|(?:min|minute).*(?:spaziergang|gehen|walk)", text):
|
||||
mode = "minutes"
|
||||
elif re.search(r"(?:hold|plank|hang|wall\s*sit|wandsitz|isometr|halte|stützposition|deep\s+squat)", text):
|
||||
mode = "seconds"
|
||||
elif re.search(r"(?:sek(?:unde)?|\d+\s*s)", text) and not re.search(r"(?:reps?|wiederhol|negativ|push|pull|squat|row|rudern|dip)", text):
|
||||
mode = "seconds"
|
||||
def _text_items(value: Any, fallback: Any = None) -> dict[str, list[str]]:
|
||||
source = value if value not in (None, "") else fallback
|
||||
if isinstance(source, dict):
|
||||
source = source.get("items")
|
||||
if isinstance(source, list):
|
||||
items = [_string(item) for item in source if _string(item)]
|
||||
else:
|
||||
mode = "reps"
|
||||
text = _string(source)
|
||||
parts = re.split(r"<br\s*/?>|\n|·", text, flags=re.I)
|
||||
items = [_string(re.sub(r"<[^>]+>", "", item)) for item in parts]
|
||||
items = [item for item in items if item]
|
||||
return {"items": items}
|
||||
|
||||
has_load = bool(re.search(r"(?:kb|kettlebell|kurzhantel|dumbbell|rucksack|gewicht|last)", text))
|
||||
bodyweight_mix = bool(re.search(r"(?:optional|wahlweise|ggf\.?|bodyweight|bw|ungewichtet|körpergewicht)", text))
|
||||
weight_mode = "optional" if has_load and bodyweight_mix else ("required" if has_load else "none")
|
||||
# Bei generischen Stufen mit „gewichtet“ bleibt Gewicht optional, wenn eine
|
||||
# ungewichtete Variante im selben Text genannt wird.
|
||||
if "gewichtet" in text and ("ungewichtet" in text or "bw" in text):
|
||||
weight_mode = "optional"
|
||||
|
||||
unilateral = bool(re.search(r"(?:einarm|einbein|je\s+seite|pro\s+seite|links.?/?rechts|seitenwechsel|side\s+plank|wechsel)", text))
|
||||
schema = {
|
||||
"mode": mode,
|
||||
"weight_mode": weight_mode,
|
||||
"laterality": "unilateral" if unilateral else "bilateral",
|
||||
"sides_mode": "separate" if unilateral else "same",
|
||||
}
|
||||
# Nicht messbare Checklistenpunkte benötigen nie Gewicht oder Seitenfelder.
|
||||
if mode == "none":
|
||||
schema.update({"weight_mode": "none", "laterality": "bilateral", "sides_mode": "same"})
|
||||
return normalize_result_schema(schema, partial=False) or {
|
||||
"mode": "auto", "weight_mode": "none", "laterality": "bilateral", "sides_mode": "same"
|
||||
def _normalize_exercise(raw: Any, plan_id: str, index: int) -> dict[str, Any] | None:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
name = _string(raw.get("name") or raw.get("title"))
|
||||
exercise_id = _string(raw.get("id") or raw.get("exercise_id"))
|
||||
if not exercise_id:
|
||||
exercise_id = stable_id("exercise", plan_id, index, name or "uebung")
|
||||
exercise = {"id": exercise_id, "name": name}
|
||||
for key in ("cue", "description", "equipment", "notes"):
|
||||
value = _string(raw.get(key))
|
||||
if value:
|
||||
exercise[key] = value
|
||||
tags = raw.get("tags")
|
||||
if isinstance(tags, list):
|
||||
clean_tags = [_string(tag) for tag in tags if _string(tag)]
|
||||
if clean_tags:
|
||||
exercise["tags"] = clean_tags
|
||||
return exercise
|
||||
|
||||
|
||||
def _catalog_sources(cfg: dict[str, Any]) -> list[Any]:
|
||||
sources: list[Any] = []
|
||||
raw_exercises = cfg.get("exercises")
|
||||
if isinstance(raw_exercises, list):
|
||||
sources.extend(raw_exercises)
|
||||
raw_catalog = cfg.get("exercise_catalog")
|
||||
if isinstance(raw_catalog, dict):
|
||||
sources.extend(raw_catalog.values())
|
||||
elif isinstance(raw_catalog, list):
|
||||
sources.extend(raw_catalog)
|
||||
return sources
|
||||
|
||||
|
||||
def _legacy_day_exercises(day: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
result: list[dict[str, Any]] = []
|
||||
direct = day.get("exercises")
|
||||
if isinstance(direct, list):
|
||||
result.extend(item for item in direct if isinstance(item, dict))
|
||||
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"]})
|
||||
for rotation in rotations:
|
||||
if not isinstance(rotation, dict):
|
||||
continue
|
||||
label = _string(rotation.get("label"))
|
||||
exercises = rotation.get("exercises") if isinstance(rotation.get("exercises"), list) else []
|
||||
for exercise in exercises:
|
||||
if not isinstance(exercise, dict):
|
||||
continue
|
||||
item = copy.deepcopy(exercise)
|
||||
item["__legacy_catalog"] = True
|
||||
if label and not _string(item.get("notes")):
|
||||
item["notes"] = label
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
def _ensure_catalog_entry(
|
||||
catalog: dict[str, dict[str, Any]],
|
||||
raw: dict[str, Any],
|
||||
plan_id: str,
|
||||
index: int,
|
||||
) -> str:
|
||||
exercise_id = _string(raw.get("exercise_id") or raw.get("id"))
|
||||
name = _string(raw.get("name") or raw.get("title"))
|
||||
if not exercise_id:
|
||||
exercise_id = stable_id("exercise", plan_id, index, name or "uebung")
|
||||
if exercise_id not in catalog:
|
||||
source = {
|
||||
"id": exercise_id,
|
||||
"name": name,
|
||||
"cue": raw.get("cue"),
|
||||
"description": raw.get("description"),
|
||||
"equipment": raw.get("equipment"),
|
||||
"tags": raw.get("tags"),
|
||||
}
|
||||
catalog[exercise_id] = _normalize_exercise(source, plan_id, index) or {
|
||||
"id": exercise_id,
|
||||
"name": name,
|
||||
}
|
||||
return exercise_id
|
||||
|
||||
|
||||
def _normalize_day_exercise(raw: dict[str, Any], day_id: str, exercise_id: str, index: int) -> dict[str, Any]:
|
||||
item_id = _string(raw.get("id"))
|
||||
if not item_id or item_id == exercise_id:
|
||||
item_id = stable_id("day-exercise", day_id, exercise_id, index)
|
||||
item: dict[str, Any] = {"id": item_id, "exercise_id": exercise_id}
|
||||
for key in ("sets", "duration_seconds"):
|
||||
value = _int(raw.get(key), 0)
|
||||
if value > 0:
|
||||
item[key] = value
|
||||
for key in ("reps", "tempo", "rest", "notes", "legacy_id"):
|
||||
value = _string(raw.get(key))
|
||||
if value:
|
||||
item[key] = value
|
||||
return item
|
||||
|
||||
|
||||
def normalize_training_config(config: Any, plan_id: str | None = None) -> dict[str, Any]:
|
||||
cfg = copy.deepcopy(config) if isinstance(config, dict) else {}
|
||||
meta = cfg.get("meta") if isinstance(cfg.get("meta"), dict) else {}
|
||||
resolved_plan_id = _string(cfg.get("plan_id") or plan_id or stable_id("plan", meta.get("title") or "training"))
|
||||
title = _string(meta.get("title") or cfg.get("title") or "Neuer Trainingsplan")
|
||||
normalized: dict[str, Any] = {
|
||||
"schema_version": PLAN_SCHEMA_VERSION,
|
||||
"contract_version": CONTRACT_VERSION,
|
||||
"plan_id": resolved_plan_id,
|
||||
"meta": {"title": title},
|
||||
"exercises": [],
|
||||
"days": [],
|
||||
}
|
||||
subtitle = _string(meta.get("subtitle") or cfg.get("subtitle"))
|
||||
if subtitle:
|
||||
normalized["meta"]["subtitle"] = subtitle
|
||||
|
||||
def infer_cluster(name: str, key: str = "") -> tuple[str, str]:
|
||||
text = f"{key} {name}".lower()
|
||||
rules = [
|
||||
(r"liegest|push.?up|\bls\b", "horizontal_push", "Push-up"),
|
||||
(r"dip", "vertical_push", "Dip"),
|
||||
(r"pike|overhead|floor press", "vertical_push", "Schulterdrücken"),
|
||||
(r"pull.?up|klimm|hang", "vertical_pull", "Pull-up / Hang"),
|
||||
(r"rudern|row", "horizontal_pull", "Rudern"),
|
||||
(r"squat|kniebeuge|goblet|lunge|ausfallschritt|wall sit", "knee_dominant", "Kniebeuge / Ausfallschritt"),
|
||||
(r"deadlift|rdl|hinge|swing|glute bridge", "hip_hinge", "Hip Hinge / Hüftstreckung"),
|
||||
(r"plank|hollow|dead bug|side plank|core", "core", "Core"),
|
||||
(r"curl", "elbow_flexion", "Bizeps"),
|
||||
(r"waden|calf", "calf", "Waden"),
|
||||
(r"halo|around|carry|bottoms", "loaded_mobility", "Kettlebell-Kontrolle"),
|
||||
]
|
||||
for pattern, cid, label in rules:
|
||||
if re.search(pattern, text):
|
||||
return cid, label
|
||||
return "general", "Allgemein"
|
||||
catalog: dict[str, dict[str, Any]] = {}
|
||||
for index, raw in enumerate(_catalog_sources(cfg)):
|
||||
exercise = _normalize_exercise(raw, resolved_plan_id, index)
|
||||
if exercise:
|
||||
catalog[exercise["id"]] = exercise
|
||||
|
||||
legacy_prepost = cfg.get("prepost") if isinstance(cfg.get("prepost"), dict) else {}
|
||||
days = cfg.get("days") if isinstance(cfg.get("days"), list) else []
|
||||
exercise_counter = len(catalog)
|
||||
for day_index, raw_day in enumerate(days):
|
||||
if not isinstance(raw_day, dict):
|
||||
continue
|
||||
day_num = _int(raw_day.get("num"), day_index + 1) or day_index + 1
|
||||
name = _string(raw_day.get("name") or raw_day.get("focus") or f"Tag {day_num}")
|
||||
day_id = _string(raw_day.get("id")) or stable_id("day", resolved_plan_id, day_num, name)
|
||||
prepost = legacy_prepost.get(str(day_num)) if isinstance(legacy_prepost.get(str(day_num)), dict) else {}
|
||||
day: dict[str, Any] = {
|
||||
"id": day_id,
|
||||
"num": day_num,
|
||||
"name": name,
|
||||
"warmup": _text_items(raw_day.get("warmup"), prepost.get("warmup")),
|
||||
"cooldown": _text_items(raw_day.get("cooldown"), prepost.get("cooldown")),
|
||||
"exercises": [],
|
||||
}
|
||||
focus = _string(raw_day.get("focus"))
|
||||
if focus and focus != name:
|
||||
day["focus"] = focus
|
||||
notes = _string(raw_day.get("notes") or raw_day.get("note"))
|
||||
if notes:
|
||||
day["notes"] = notes
|
||||
|
||||
def infer_factor(name: str, cluster_id: str) -> float:
|
||||
text = str(name or "").lower()
|
||||
if cluster_id == "horizontal_push":
|
||||
if "wand" in text: return 0.05
|
||||
if "hoch" in text and ("incline" in text or "inkline" in text): return 0.10
|
||||
if "mittel" in text and ("incline" in text or "inkline" in text): return 0.20
|
||||
if "tief" in text and ("incline" in text or "inkline" in text): return 0.35
|
||||
if "negativ" in text and ("voll" in text or "erste" in text): return 0.85
|
||||
if "negativ" in text: return 0.70
|
||||
if "assist" in text: return 0.55
|
||||
if "knie" in text: return 0.45
|
||||
if "teil" in text or "partial" in text: return 0.35
|
||||
if "voll" in text or "standard" in text or "normal" in text: return 1.00
|
||||
if cluster_id == "vertical_pull":
|
||||
if "active hang" in text or "hang" in text: return 0.20
|
||||
if "scapular" in text: return 0.25
|
||||
if "negativ" in text: return 0.70
|
||||
if "leicht assist" in text: return 0.80
|
||||
if "assist" in text: return 0.55
|
||||
if "voll" in text or "klimm" in text or "pull-up" in text: return 1.00
|
||||
if cluster_id == "horizontal_pull":
|
||||
if "aufrecht" in text or "stark assist" in text: return 0.25
|
||||
if "bent" in text or "gebeugt" in text: return 0.50
|
||||
if "straight" in text or "gestreckt" in text: return 1.00
|
||||
if "füße erhöht" in text: return 1.15
|
||||
if cluster_id == "knee_dominant":
|
||||
if "hold" in text: return 1.00
|
||||
if "chair" in text or "box" in text or "stuhl" in text: return 0.35
|
||||
if "negativ" in text: return 0.75
|
||||
if "assist" in text or "festhalten" in text: return 0.55
|
||||
if "voll" in text or "goblet" in text or "körpergewicht" in text: return 1.00
|
||||
if cluster_id == "vertical_push":
|
||||
if "hold" in text: return 0.30
|
||||
if "assist" in text: return 0.60
|
||||
if "negativ" in text: return 0.80
|
||||
if "voll" in text: return 1.00
|
||||
if cluster_id == "core":
|
||||
if "knie" in text or "tuck" in text: return 0.65
|
||||
if "halb" in text: return 0.82
|
||||
if "voll" in text: return 1.00
|
||||
return 1.00
|
||||
for item_index, raw_item in enumerate(_legacy_day_exercises(raw_day)):
|
||||
should_create = bool(raw_item.pop("__legacy_catalog", False) or _string(raw_item.get("name")))
|
||||
if should_create:
|
||||
exercise_id = _ensure_catalog_entry(catalog, raw_item, resolved_plan_id, exercise_counter)
|
||||
exercise_counter += 1
|
||||
else:
|
||||
exercise_id = _string(raw_item.get("exercise_id"))
|
||||
day["exercises"].append(_normalize_day_exercise(raw_item, day_id, exercise_id, item_index))
|
||||
normalized["days"].append(day)
|
||||
|
||||
normalized["exercises"] = sorted(catalog.values(), key=lambda item: (slug(item.get("name")), item.get("id")))
|
||||
normalized["days"] = sorted(normalized["days"], key=lambda item: item.get("num", 0))
|
||||
return normalized
|
||||
|
||||
|
||||
def migrate_training_config(config: Any, plan_id: str | None = None) -> dict[str, Any]:
|
||||
cfg = copy.deepcopy(config or {})
|
||||
if cfg.get("type") == "recipe":
|
||||
return cfg
|
||||
cfg["schema_version"] = PLAN_SCHEMA_VERSION
|
||||
cfg["contract_version"] = CONTRACT_VERSION
|
||||
meta = cfg.setdefault("meta", {})
|
||||
resolved_plan_id = str(cfg.get("plan_id") or plan_id or stable_id("plan", meta.get("title") or "training"))
|
||||
cfg["plan_id"] = resolved_plan_id
|
||||
|
||||
phases = cfg.setdefault("phases", {}).setdefault("items", [])
|
||||
for index, phase in enumerate(phases):
|
||||
if not isinstance(phase, dict):
|
||||
phases[index] = phase = {"name": str(phase or "")}
|
||||
phase.setdefault("id", stable_id("phase", resolved_plan_id, index, phase.get("name")))
|
||||
phase_ids = [phase.get("id") for phase in phases]
|
||||
|
||||
fmt = cfg.get("training_format") if isinstance(cfg.get("training_format"), dict) else {}
|
||||
mode = str(fmt.get("mode") or "auto")
|
||||
if mode not in VALID_TRAINING_MODES:
|
||||
mode = "auto"
|
||||
fmt["mode"] = mode
|
||||
fmt["fixed_interval"] = bool(fmt.get("fixed_interval") or mode in {"tabata", "fixed_interval"})
|
||||
fmt["rounds_scope"] = str(fmt.get("rounds_scope") or "block")
|
||||
fmt["schema_version"] = 1
|
||||
cfg["training_format"] = fmt
|
||||
|
||||
days = cfg.get("days") if isinstance(cfg.get("days"), list) else []
|
||||
for di, day in enumerate(days):
|
||||
if not isinstance(day, dict):
|
||||
continue
|
||||
day.setdefault("id", stable_id("day", resolved_plan_id, day.get("num", di + 1), day.get("focus")))
|
||||
rotations = day.get("rotations") if isinstance(day.get("rotations"), list) else []
|
||||
for ri, rotation in enumerate(rotations):
|
||||
if not isinstance(rotation, dict):
|
||||
continue
|
||||
rotation.setdefault("id", stable_id("rotation", day["id"], ri, rotation.get("label")))
|
||||
exercises = rotation.get("exercises") if isinstance(rotation.get("exercises"), list) else []
|
||||
day_num = day.get("num", di + 1)
|
||||
for ei, exercise in enumerate(exercises):
|
||||
if not isinstance(exercise, dict):
|
||||
continue
|
||||
# Persist the original positional key once so legacy session data can
|
||||
# still be migrated safely even after the published plan is reordered.
|
||||
exercise.setdefault("legacy_id", f"d{day_num}-r{ri}-e{ei}")
|
||||
explicit_progression = str(exercise.get("progression_id") or exercise.get("key") or "").strip()
|
||||
progression_id = explicit_progression or f"name:{slug(exercise.get('name'))}"
|
||||
exercise["progression_id"] = progression_id
|
||||
exercise["key"] = str(exercise.get("key") or progression_id)
|
||||
exercise.setdefault("id", stable_id("exercise", rotation["id"], progression_id, exercise.get("name"), ei))
|
||||
exercise.setdefault("exercise_id", stable_id("movement", progression_id, exercise.get("name")))
|
||||
cid, label = infer_cluster(str(exercise.get("name") or ""), progression_id)
|
||||
exercise.setdefault("movement_cluster", cid)
|
||||
exercise.setdefault("movement_label", label)
|
||||
if exercise.get("result_schema") is not None:
|
||||
exercise["result_schema"] = normalize_result_schema(exercise.get("result_schema"), partial=False)
|
||||
else:
|
||||
exercise["result_schema"] = infer_result_schema(exercise.get("name"), exercise.get("cue"))
|
||||
cfg["days"] = days
|
||||
|
||||
old_stages = cfg.get("stages") if isinstance(cfg.get("stages"), dict) else {}
|
||||
new_stages: dict[str, Any] = {}
|
||||
for key, raw_stage in old_stages.items():
|
||||
stage = copy.deepcopy(raw_stage) if isinstance(raw_stage, dict) else {"steps": raw_stage if isinstance(raw_stage, list) else []}
|
||||
stage_id = str(stage.get("id") or stable_id("progression", resolved_plan_id, key, stage.get("name")))
|
||||
stage["id"] = stage_id
|
||||
stage["key"] = str(stage.get("key") or key)
|
||||
raw_steps = stage.get("steps") if isinstance(stage.get("steps"), list) else []
|
||||
schemas = stage.get("result_schemas") if isinstance(stage.get("result_schemas"), list) else []
|
||||
steps: list[dict[str, Any]] = []
|
||||
for index, raw_step in enumerate(raw_steps):
|
||||
if isinstance(raw_step, dict):
|
||||
step = copy.deepcopy(raw_step)
|
||||
step_name = str(step.get("name") or step.get("label") or "")
|
||||
else:
|
||||
step_name = str(raw_step or "")
|
||||
step = {"name": step_name}
|
||||
step.setdefault("id", stable_id("step", stage_id, index, step_name))
|
||||
step.setdefault("phase_id", phase_ids[index] if index < len(phase_ids) else "")
|
||||
schema = step.get("result_schema")
|
||||
if schema is None and index < len(schemas):
|
||||
schema = schemas[index]
|
||||
if schema is not None:
|
||||
step["result_schema"] = normalize_result_schema(schema, partial=False)
|
||||
else:
|
||||
step["result_schema"] = infer_result_schema(step_name, stage.get("name"), stage=True)
|
||||
cid, _ = infer_cluster(str(stage.get("name") or step_name), key)
|
||||
step.setdefault("movement_cluster", cid)
|
||||
step.setdefault("factor", infer_factor(step_name, cid))
|
||||
steps.append(step)
|
||||
stage["steps"] = steps
|
||||
stage.pop("result_schemas", None)
|
||||
new_stages[str(key)] = stage
|
||||
cfg["stages"] = new_stages
|
||||
|
||||
# Zentrale, planbezogene Übungsbibliothek. Der Tracker nutzt diese vor Regex-Fallbacks.
|
||||
catalog: dict[str, Any] = {}
|
||||
for day in days:
|
||||
for rotation in day.get("rotations", []):
|
||||
for exercise in rotation.get("exercises", []):
|
||||
eid = str(exercise.get("exercise_id"))
|
||||
pid = str(exercise.get("progression_id") or "")
|
||||
entry = catalog.setdefault(eid, {
|
||||
"id": eid,
|
||||
"name": str(exercise.get("name") or ""),
|
||||
"progression_id": pid,
|
||||
"movement_cluster": exercise.get("movement_cluster") or "general",
|
||||
"movement_label": exercise.get("movement_label") or "Allgemein",
|
||||
"result_schema": copy.deepcopy(exercise.get("result_schema")),
|
||||
"variants": [],
|
||||
})
|
||||
stage = new_stages.get(pid)
|
||||
if stage:
|
||||
entry["variants"] = [
|
||||
{
|
||||
"id": step.get("id"), "name": step.get("name", ""),
|
||||
"factor": step.get("factor", 1.0),
|
||||
"movement_cluster": step.get("movement_cluster") or entry["movement_cluster"],
|
||||
"result_schema": copy.deepcopy(step.get("result_schema")),
|
||||
}
|
||||
for step in stage.get("steps", [])
|
||||
]
|
||||
cfg["exercise_catalog"] = catalog
|
||||
return cfg
|
||||
return normalize_training_config(config, plan_id=plan_id)
|
||||
|
||||
|
||||
def validate_training_config(config: Any) -> dict[str, list[dict[str, Any]]]:
|
||||
cfg = migrate_training_config(config)
|
||||
def validate_training_config(config: Any) -> dict[str, Any]:
|
||||
cfg = normalize_training_config(config)
|
||||
errors: list[dict[str, Any]] = []
|
||||
warnings: list[dict[str, Any]] = []
|
||||
ids: dict[str, str] = {}
|
||||
|
||||
def register(value: Any, location: str) -> None:
|
||||
sid = str(value or "")
|
||||
sid = _string(value)
|
||||
if not sid:
|
||||
errors.append({"code": "missing_id", "location": location, "message": "Stabile ID fehlt."})
|
||||
elif sid in ids:
|
||||
@@ -320,72 +244,38 @@ def validate_training_config(config: Any) -> dict[str, list[dict[str, Any]]]:
|
||||
else:
|
||||
ids[sid] = location
|
||||
|
||||
def validate_result_schema(schema: Any, location: str, *, required: bool) -> None:
|
||||
if not isinstance(schema, dict):
|
||||
target = errors if required else warnings
|
||||
target.append({"code": "missing_result_schema", "location": location, "message": "Ergebniserfassung ist nicht eindeutig festgelegt."})
|
||||
return
|
||||
mode = str(schema.get("mode") or "auto")
|
||||
weight = str(schema.get("weight_mode") or "none")
|
||||
laterality = str(schema.get("laterality") or "bilateral")
|
||||
sides = str(schema.get("sides_mode") or "same")
|
||||
if mode == "none" and weight != "none":
|
||||
errors.append({"code": "invalid_result_schema", "location": location, "message": "Ohne Messwert darf kein Gewicht verlangt werden."})
|
||||
if laterality == "bilateral" and sides == "separate":
|
||||
errors.append({"code": "invalid_sides", "location": location, "message": "Getrennte Seitenwerte sind nur bei einseitigen Übungen möglich."})
|
||||
if laterality == "unilateral" and sides not in {"same", "separate"}:
|
||||
errors.append({"code": "invalid_sides", "location": location, "message": "Für einseitige Übungen muss die Seitenlogik festgelegt sein."})
|
||||
try:
|
||||
sets = int(schema.get("sets") or 0)
|
||||
except (TypeError, ValueError):
|
||||
sets = 0
|
||||
if "sets" in schema and not 1 <= sets <= 40:
|
||||
errors.append({"code": "invalid_sets", "location": location, "message": "Die feste Anzahl muss zwischen 1 und 40 liegen."})
|
||||
if not _string(cfg.get("meta", {}).get("title")):
|
||||
errors.append({"code": "empty_title", "location": "Plan", "message": "Plantitel fehlt."})
|
||||
|
||||
exercise_ids: set[str] = set()
|
||||
for index, exercise in enumerate(cfg.get("exercises", [])):
|
||||
location = f"Übung {index + 1}"
|
||||
register(exercise.get("id"), location)
|
||||
exercise_ids.add(_string(exercise.get("id")))
|
||||
if not _string(exercise.get("name")):
|
||||
errors.append({"code": "empty_exercise", "location": location, "message": "Übungsname fehlt."})
|
||||
|
||||
if not cfg.get("days"):
|
||||
warnings.append({"code": "no_days", "location": "Plan", "message": "Noch keine Trainingstage angelegt."})
|
||||
|
||||
for day_index, day in enumerate(cfg.get("days", [])):
|
||||
location = f"Tag {day_index + 1}"
|
||||
register(day.get("id"), location)
|
||||
if not _string(day.get("name")):
|
||||
errors.append({"code": "empty_day", "location": location, "message": "Tagesname fehlt."})
|
||||
if not day.get("exercises"):
|
||||
warnings.append({"code": "empty_day", "location": location, "message": "Tag enthält keine Übungen."})
|
||||
for item_index, item in enumerate(day.get("exercises", [])):
|
||||
item_location = f"{location}, Übung {item_index + 1}"
|
||||
register(item.get("id"), item_location)
|
||||
exercise_id = _string(item.get("exercise_id"))
|
||||
if exercise_id not in exercise_ids:
|
||||
errors.append({"code": "unknown_exercise", "location": item_location, "message": "Hinterlegte Übung existiert nicht in der Übungsbibliothek."})
|
||||
sets = _int(item.get("sets"), 0)
|
||||
if "sets" in item and not 1 <= sets <= 40:
|
||||
errors.append({"code": "invalid_sets", "location": item_location, "message": "Sätze müssen zwischen 1 und 40 liegen."})
|
||||
duration = _int(item.get("duration_seconds"), 0)
|
||||
if "duration_seconds" in item and duration <= 0:
|
||||
errors.append({"code": "invalid_duration", "location": item_location, "message": "Dauer muss größer als 0 sein."})
|
||||
|
||||
for pi, phase in enumerate(cfg.get("phases", {}).get("items", [])):
|
||||
register(phase.get("id"), f"Phase {pi + 1}")
|
||||
if not str(phase.get("name") or "").strip():
|
||||
errors.append({"code": "empty_phase", "location": f"Phase {pi + 1}", "message": "Phasenname fehlt."})
|
||||
for di, day in enumerate(cfg.get("days", [])):
|
||||
register(day.get("id"), f"Tag {di + 1}")
|
||||
for ri, rotation in enumerate(day.get("rotations", [])):
|
||||
register(rotation.get("id"), f"Tag {di + 1}, Rotation {ri + 1}")
|
||||
exercises = rotation.get("exercises", [])
|
||||
if not exercises:
|
||||
warnings.append({"code": "empty_rotation", "location": f"Tag {di + 1}, Rotation {ri + 1}", "message": "Rotation enthält keine Übungen."})
|
||||
for ei, exercise in enumerate(exercises):
|
||||
location = f"Tag {di + 1}, Rotation {ri + 1}, Übung {ei + 1}"
|
||||
register(exercise.get("id"), location)
|
||||
if not str(exercise.get("name") or "").strip():
|
||||
errors.append({"code": "empty_exercise", "location": location, "message": "Übungsname fehlt."})
|
||||
pid = str(exercise.get("progression_id") or "")
|
||||
if pid and not pid.startswith("name:") and pid not in cfg.get("stages", {}):
|
||||
warnings.append({"code": "unknown_progression", "location": location, "message": f"Progression „{pid}“ ist nicht definiert."})
|
||||
validate_result_schema(exercise.get("result_schema"), f"{location}, Ergebniserfassung", required=True)
|
||||
for key, stage in cfg.get("stages", {}).items():
|
||||
register(stage.get("id"), f"Progression {key}")
|
||||
for si, step in enumerate(stage.get("steps", [])):
|
||||
register(step.get("id"), f"Progression {key}, Stufe {si + 1}")
|
||||
step_location = f"Progression {key}, Stufe {si + 1}"
|
||||
if not str(step.get("name") or "").strip():
|
||||
warnings.append({"code": "empty_step", "location": step_location, "message": "Stufenname ist leer."})
|
||||
validate_result_schema(step.get("result_schema"), f"{step_location}, Ergebniserfassung", required=False)
|
||||
try:
|
||||
factor = float(step.get("factor") or 0)
|
||||
except (TypeError, ValueError):
|
||||
factor = 0
|
||||
if factor <= 0:
|
||||
errors.append({"code": "invalid_factor", "location": step_location, "message": "Der Analysefaktor muss größer als 0 sein."})
|
||||
fmt = cfg.get("training_format", {})
|
||||
if fmt.get("mode") in {"tabata", "fixed_interval"}:
|
||||
if not fmt.get("work_seconds"):
|
||||
errors.append({"code": "missing_work_seconds", "location": "Trainingsformat", "message": "Arbeitszeit fehlt."})
|
||||
if not fmt.get("rounds"):
|
||||
errors.append({"code": "missing_rounds", "location": "Trainingsformat", "message": "Intervallzahl fehlt."})
|
||||
rounds = int(fmt.get("rounds") or 0)
|
||||
for di, day in enumerate(cfg.get("days", [])):
|
||||
for ri, rotation in enumerate(day.get("rotations", [])):
|
||||
count = len(rotation.get("exercises", []))
|
||||
if rounds and count and rounds % count:
|
||||
warnings.append({"code": "uneven_intervals", "location": f"Tag {di + 1}, Rotation {ri + 1}", "message": f"{rounds} Intervalle sind nicht durch {count} Übungen teilbar. Feste Satzanzahl im Ergebnisschema setzen."})
|
||||
return {"errors": errors, "warnings": warnings, "config": cfg}
|
||||
|
||||
Reference in New Issue
Block a user