diff --git a/.claude/docs/technical/SKILL_SCORING_SPEC.md b/.claude/docs/technical/SKILL_SCORING_SPEC.md
index 7a138ba..8e6e852 100644
--- a/.claude/docs/technical/SKILL_SCORING_SPEC.md
+++ b/.claude/docs/technical/SKILL_SCORING_SPEC.md
@@ -1,7 +1,7 @@
# Gewichtetes Fähigkeiten-Scoring (Phase 3)
**Stand:** 2026-05-20
-**Status:** Variante A (regelbasiert) umgesetzt — v1.0
+**Status:** Variante A (regelbasiert) umgesetzt — **v1.1** (Intensität + Stufen-Spanne, ohne Primär)
**Modul:** `backend/skill_scoring.py`, Router `skill_profiles`
## Ziel
@@ -19,14 +19,39 @@ Trainer wählen **Schwerpunkt-Fähigkeiten** und erhalten Vorschläge für **Rah
Fähigkeiten je Übung: `exercise_skills` → `skills` (nur `status = active`).
-## Gewichtungsformel (v1.0)
+## Gewichtungsformel (v1.1)
Pro **Übungsvorkommen** (eine Zeile im Ablauf / Modul / Kanten-Endpunkt):
1. **Basis-Minuten** = `planned_duration_min` der Position, sonst Default (Einheit/Modul: 8 Min, Graph: 10 Min).
2. Pro verknüpfte Fähigkeit der Übung:
- - `Beitrag = Basis-Minuten × Anzahl Vorkommen dieser Übung × Link-Faktor`
- - Link-Faktor = 1.0 × (1.5 wenn `is_primary`) × Intensität (`niedrig` 0.85, `mittel` 1.0, `hoch` 1.2) × Entwicklungsbeitrag (`low` 0.9, `medium` 1.0, `high` 1.15)
+ - `Beitrag = Basis-Minuten × Anzahl Vorkommen × Link-Faktor`
+ - **Link-Faktor** = Intensität × Stufen-Faktor
+
+### Intensität (Nutzeneinschätzung, UI-Feld)
+
+| Wert | Faktor |
+|------|--------|
+| niedrig | 0,85 |
+| mittel / leer | 1,0 |
+| hoch | 1,2 |
+
+### Stufen-Spanne (`required_level` → `target_level`, UI „von/bis“)
+
+Kanonische Slugs: basis … optimierung (1–5). Fehlen beide: Faktor 1,0.
+
+- **Spanne** = Anzahl Stufen von „von“ bis „bis“ (1–5)
+- **Mittelpunkt** = durchschnittliche Stufe
+- Faktor ≈ `(0,92 + 0,04 × Spanne) × (0,95 + 0,025 × Mittelpunkt)` → typisch 0,96–1,20
+
+Beispiel: von Grundlagen bis Aufbau (Spanne 2) > nur Basis (Spanne 1).
+
+### Bewusst nicht im Scoring
+
+| Feld | Grund |
+|------|--------|
+| `is_primary` | Perspektivabhängig; bleibt in Übungs-UI, fließt nicht ins Profil ein |
+| `development_contribution` | Legacy-DB-Feld, in UI nicht gepflegt |
Aggregation:
diff --git a/backend/skill_scoring.py b/backend/skill_scoring.py
index fe18c34..90fdd04 100644
--- a/backend/skill_scoring.py
+++ b/backend/skill_scoring.py
@@ -1,9 +1,11 @@
"""
Gewichtetes Fähigkeiten-Scoring aus Übungsvorkommen (Phase 3, regelbasiert).
-Aggregiert exercise_skills über alle Übungen eines Artefakts (Rahmenprogramm, Modul,
-Progressionsgraph) mit Gewichten aus geplanter Dauer, Vorkommen, Primär-Fähigkeit,
-Intensität und Entwicklungsbeitrag.
+Aggregiert exercise_skills über alle Übungen eines Artefakts mit Gewichten aus:
+geplanter Dauer, Vorkommen, Intensität (Nutzeneinschätzung) und Stufen-Spanne (von/bis).
+
+is_primary wird bewusst nicht genutzt (perspektivabhängig). development_contribution ist
+in der UI nicht gepflegt und wird ignoriert.
"""
from __future__ import annotations
@@ -25,16 +27,58 @@ _INTENSITY_MULT = {
"high": 1.2,
}
-_DEV_CONTRIB_MULT = {
- "low": 0.9,
- "niedrig": 0.9,
- "medium": 1.0,
- "mittel": 1.0,
- "high": 1.15,
- "hoch": 1.15,
+# Synchron zu backend/routers/exercises.py _EXERCISE_SKILL_LEVEL_RANK_SQL / skillLevels.js
+_LEVEL_RANK = {
+ "basis": 1,
+ "grundlagen": 2,
+ "aufbau": 3,
+ "fortgeschritten": 4,
+ "optimierung": 5,
+ "einsteiger": 1,
+ "experte": 5,
+ "1": 1,
+ "2": 2,
+ "3": 3,
+ "4": 4,
+ "5": 5,
}
+def _level_rank(value: Optional[str]) -> Optional[int]:
+ if value is None:
+ return None
+ key = str(value).strip().lower()
+ if not key:
+ return None
+ rank = _LEVEL_RANK.get(key)
+ return rank if rank is not None else None
+
+
+def _level_range_multiplier(
+ required_level: Optional[str] = None,
+ target_level: Optional[str] = None,
+) -> float:
+ """
+ Stufen-Spanne (von/bis): breitere und höhere Entwicklungsstufen → etwas höheres Gewicht.
+ Fehlen beide Angaben: neutral (1.0).
+ """
+ rr = _level_rank(required_level)
+ rt = _level_rank(target_level)
+ if rr is None and rt is None:
+ return 1.0
+ if rr is None:
+ rr = rt
+ if rt is None:
+ rt = rr
+ if rr > rt:
+ rr, rt = rt, rr
+ span = max(1, min(5, rt - rr + 1))
+ midpoint = (rr + rt) / 2.0
+ span_mult = 0.92 + 0.04 * span
+ depth_mult = 0.95 + 0.025 * midpoint
+ return span_mult * depth_mult
+
+
@dataclass(frozen=True)
class ExerciseOccurrence:
exercise_id: int
@@ -56,19 +100,15 @@ def _item_base_minutes(planned: Optional[int], default: int = DEFAULT_ITEM_MINUT
def _skill_link_multiplier(
*,
- is_primary: bool = False,
intensity: Optional[str] = None,
- development_contribution: Optional[str] = None,
+ required_level: Optional[str] = None,
+ target_level: Optional[str] = None,
) -> float:
mult = 1.0
- if is_primary:
- mult *= 1.5
if intensity:
key = str(intensity).strip().lower()
mult *= _INTENSITY_MULT.get(key, 1.0)
- if development_contribution:
- key = str(development_contribution).strip().lower()
- mult *= _DEV_CONTRIB_MULT.get(key, 1.0)
+ mult *= _level_range_multiplier(required_level, target_level)
return mult
@@ -116,9 +156,9 @@ def compute_skill_profile(
continue
sid = int(sid)
link_mult = _skill_link_multiplier(
- is_primary=bool(link.get("is_primary")),
intensity=link.get("intensity"),
- development_contribution=link.get("development_contribution"),
+ required_level=link.get("required_level"),
+ target_level=link.get("target_level"),
)
contribution = minutes_per_occ * occ_count * link_mult
if contribution <= 0:
@@ -132,14 +172,11 @@ def compute_skill_profile(
"focus_areas": link.get("focus_areas"),
"weight": 0.0,
"occurrence_count": 0,
- "primary_link_count": 0,
"exercises": {},
}
acc = skill_acc[sid]
acc["weight"] += contribution
acc["occurrence_count"] += occ_count
- if link.get("is_primary"):
- acc["primary_link_count"] += occ_count
ex_key = str(eid)
if ex_key not in acc["exercises"]:
acc["exercises"][ex_key] = {
@@ -173,7 +210,6 @@ def compute_skill_profile(
"weight": _round2(acc["weight"]),
"share_percent": _round2(share),
"occurrence_count": acc["occurrence_count"],
- "primary_link_count": acc["primary_link_count"],
"top_exercises": ex_list,
}
)
@@ -193,7 +229,7 @@ def compute_skill_profile(
unique_exercises = len(exercise_meta)
return {
"computed_at": datetime.now(timezone.utc).isoformat(),
- "scoring_version": "1.0",
+ "scoring_version": "1.1",
"total_weight": _round2(total_weight),
"exercise_occurrence_count": total_occurrences,
"distinct_exercise_count": unique_exercises,
@@ -221,7 +257,7 @@ def fetch_exercise_skills_bulk(
JOIN exercises e ON e.id = es.exercise_id
WHERE es.exercise_id IN ({ph})
AND (s.status = 'active' OR s.status IS NULL)
- ORDER BY es.exercise_id, es.is_primary DESC, es.skill_id
+ ORDER BY es.exercise_id, s.name, es.skill_id
""",
ids,
)
diff --git a/backend/tests/test_skill_scoring.py b/backend/tests/test_skill_scoring.py
index e7640ab..df3695c 100644
--- a/backend/tests/test_skill_scoring.py
+++ b/backend/tests/test_skill_scoring.py
@@ -3,13 +3,30 @@ from skill_scoring import (
ExerciseOccurrence,
compute_skill_profile,
match_score_for_skill_ids,
+ _level_range_multiplier,
_skill_link_multiplier,
)
-def test_skill_link_multiplier_primary_and_intensity():
- assert _skill_link_multiplier(is_primary=True, intensity="hoch") == 1.5 * 1.2
- assert _skill_link_multiplier(is_primary=False, intensity="niedrig") == 0.85
+def test_skill_link_multiplier_intensity_and_levels():
+ assert _skill_link_multiplier(intensity="hoch") == 1.2
+ assert _skill_link_multiplier(intensity="niedrig") == 0.85
+ wide = _skill_link_multiplier(
+ intensity="mittel",
+ required_level="basis",
+ target_level="optimierung",
+ )
+ narrow = _skill_link_multiplier(
+ intensity="mittel",
+ required_level="grundlagen",
+ target_level="grundlagen",
+ )
+ assert wide > narrow
+
+
+def test_level_range_multiplier_span():
+ assert _level_range_multiplier(None, None) == 1.0
+ assert _level_range_multiplier("aufbau", "fortgeschritten") > _level_range_multiplier("basis", "basis")
def test_compute_skill_profile_aggregates_weights():
@@ -23,21 +40,24 @@ def test_compute_skill_profile_aggregates_weights():
"skill_id": 10,
"skill_name": "Distanz",
"category": "kihon",
- "is_primary": True,
"intensity": "hoch",
+ "required_level": "grundlagen",
+ "target_level": "aufbau",
"exercise_title": "Übung A",
},
{
"skill_id": 11,
"skill_name": "Balance",
"category": "kihon",
- "is_primary": False,
- "intensity": "mittel",
+ "intensity": "niedrig",
+ "required_level": "basis",
+ "target_level": "basis",
"exercise_title": "Übung A",
},
],
}
profile = compute_skill_profile(occurrences, skills_map)
+ assert profile["scoring_version"] == "1.1"
assert profile["exercise_occurrence_count"] == 2
assert profile["distinct_exercise_count"] == 1
assert len(profile["skills"]) == 2
diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx
index 983bd08..51c1cf8 100644
--- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx
+++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx
@@ -684,7 +684,7 @@ export default function ExerciseProgressionGraphPanel({
<>
- {skill.primary_link_count > 0 ? (
-
- {skill.primary_link_count}× als Primär-Fähigkeit in Übungen
-
- ) : null}
)
}
@@ -34,7 +29,7 @@ export default function SkillProfilePanel({
loading = false,
error = '',
title = 'Fähigkeiten-Profil',
- hint = 'Aus verknüpften Übungen berechnet (Dauer, Vorkommen, Primär-Fähigkeit, Intensität).',
+ hint = 'Aus verknüpften Übungen berechnet (Dauer, Vorkommen, Intensität, Stufen von/bis).',
defaultExpanded = true,
}) {
const [expanded, setExpanded] = useState(defaultExpanded)
diff --git a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
index 322660f..141e5c1 100644
--- a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
+++ b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx
@@ -975,7 +975,7 @@ export default function TrainingFrameworkProgramEditPage() {
{!isNew ? (