Update Skill Scoring Specification and Implementation to v1.1
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m21s

- Enhanced the skill scoring formula to incorporate intensity and level range factors, improving the accuracy of skill contributions.
- Removed the use of `is_primary` and `development_contribution` from calculations, streamlining the scoring process.
- Updated documentation to reflect changes in the scoring logic and versioning.
- Adjusted frontend components to align with the new scoring criteria, ensuring consistent user experience across the application.
This commit is contained in:
Lars 2026-05-21 08:24:23 +02:00
parent f67bf280c3
commit 8f8bdf6d8b
6 changed files with 119 additions and 43 deletions

View File

@ -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 (15). Fehlen beide: Faktor 1,0.
- **Spanne** = Anzahl Stufen von „von“ bis „bis“ (15)
- **Mittelpunkt** = durchschnittliche Stufe
- Faktor ≈ `(0,92 + 0,04 × Spanne) × (0,95 + 0,025 × Mittelpunkt)` → typisch 0,961,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:

View File

@ -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,
)

View File

@ -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

View File

@ -684,7 +684,7 @@ export default function ExerciseProgressionGraphPanel({
<>
<SkillProfilePanel
title="Fähigkeiten entlang des Pfads"
hint="Alle Übungen als Knoten im Graph (ohne Einzel-Dauer — Standardgewicht pro Übung)."
hint="Alle Übungen als Knoten im Graph (Standardgewicht pro Übung; Intensität und Stufen aus der Übungsverknüpfung)."
profile={skillProfileData?.overall}
loading={skillProfileLoading}
error={skillProfileError}

View File

@ -16,11 +16,6 @@ function SkillBar({ skill, maxShare }) {
style={{ width: `${pct}%` }}
/>
</div>
{skill.primary_link_count > 0 ? (
<span className="skill-profile__meta-hint">
{skill.primary_link_count}× als Primär-Fähigkeit in Übungen
</span>
) : null}
</li>
)
}
@ -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)

View File

@ -975,7 +975,7 @@ export default function TrainingFrameworkProgramEditPage() {
{!isNew ? (
<SkillProfilePanel
title="Fähigkeiten-Schwerpunkte (aus Übungen)"
hint="Gewichtung nach geplanter Dauer, Häufigkeit der Übung im Programm, Primär-Fähigkeit und Intensität. Vergleichbar mit manuell gesetzten Fokusbereichen in den Stammdaten."
hint="Gewichtung nach geplanter Dauer, Häufigkeit, Intensität (niedrig/mittel/hoch) und Stufen-Spanne (von/bis). Vergleichbar mit manuell gesetzten Fokusbereichen in den Stammdaten."
profile={skillProfileData?.overall}
slots={skillProfileData?.slots}
loading={skillProfileLoading}