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 ? (