KPI-Scroing, Filter, etc, #43
|
|
@ -1,7 +1,7 @@
|
||||||
# Gewichtetes Fähigkeiten-Scoring (Phase 3)
|
# Gewichtetes Fähigkeiten-Scoring (Phase 3)
|
||||||
|
|
||||||
**Stand:** 2026-05-20
|
**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`
|
**Modul:** `backend/skill_scoring.py`, Router `skill_profiles`
|
||||||
|
|
||||||
## Ziel
|
## 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`).
|
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):
|
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).
|
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:
|
2. Pro verknüpfte Fähigkeit der Übung:
|
||||||
- `Beitrag = Basis-Minuten × Anzahl Vorkommen dieser Übung × Link-Faktor`
|
- `Beitrag = Basis-Minuten × Anzahl Vorkommen × 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)
|
- **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:
|
Aggregation:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
"""
|
"""
|
||||||
Gewichtetes Fähigkeiten-Scoring aus Übungsvorkommen (Phase 3, regelbasiert).
|
Gewichtetes Fähigkeiten-Scoring aus Übungsvorkommen (Phase 3, regelbasiert).
|
||||||
|
|
||||||
Aggregiert exercise_skills über alle Übungen eines Artefakts (Rahmenprogramm, Modul,
|
Aggregiert exercise_skills über alle Übungen eines Artefakts mit Gewichten aus:
|
||||||
Progressionsgraph) mit Gewichten aus geplanter Dauer, Vorkommen, Primär-Fähigkeit,
|
geplanter Dauer, Vorkommen, Intensität (Nutzeneinschätzung) und Stufen-Spanne (von/bis).
|
||||||
Intensität und Entwicklungsbeitrag.
|
|
||||||
|
is_primary wird bewusst nicht genutzt (perspektivabhängig). development_contribution ist
|
||||||
|
in der UI nicht gepflegt und wird ignoriert.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -25,16 +27,58 @@ _INTENSITY_MULT = {
|
||||||
"high": 1.2,
|
"high": 1.2,
|
||||||
}
|
}
|
||||||
|
|
||||||
_DEV_CONTRIB_MULT = {
|
# Synchron zu backend/routers/exercises.py _EXERCISE_SKILL_LEVEL_RANK_SQL / skillLevels.js
|
||||||
"low": 0.9,
|
_LEVEL_RANK = {
|
||||||
"niedrig": 0.9,
|
"basis": 1,
|
||||||
"medium": 1.0,
|
"grundlagen": 2,
|
||||||
"mittel": 1.0,
|
"aufbau": 3,
|
||||||
"high": 1.15,
|
"fortgeschritten": 4,
|
||||||
"hoch": 1.15,
|
"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)
|
@dataclass(frozen=True)
|
||||||
class ExerciseOccurrence:
|
class ExerciseOccurrence:
|
||||||
exercise_id: int
|
exercise_id: int
|
||||||
|
|
@ -56,19 +100,15 @@ def _item_base_minutes(planned: Optional[int], default: int = DEFAULT_ITEM_MINUT
|
||||||
|
|
||||||
def _skill_link_multiplier(
|
def _skill_link_multiplier(
|
||||||
*,
|
*,
|
||||||
is_primary: bool = False,
|
|
||||||
intensity: Optional[str] = None,
|
intensity: Optional[str] = None,
|
||||||
development_contribution: Optional[str] = None,
|
required_level: Optional[str] = None,
|
||||||
|
target_level: Optional[str] = None,
|
||||||
) -> float:
|
) -> float:
|
||||||
mult = 1.0
|
mult = 1.0
|
||||||
if is_primary:
|
|
||||||
mult *= 1.5
|
|
||||||
if intensity:
|
if intensity:
|
||||||
key = str(intensity).strip().lower()
|
key = str(intensity).strip().lower()
|
||||||
mult *= _INTENSITY_MULT.get(key, 1.0)
|
mult *= _INTENSITY_MULT.get(key, 1.0)
|
||||||
if development_contribution:
|
mult *= _level_range_multiplier(required_level, target_level)
|
||||||
key = str(development_contribution).strip().lower()
|
|
||||||
mult *= _DEV_CONTRIB_MULT.get(key, 1.0)
|
|
||||||
return mult
|
return mult
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -116,9 +156,9 @@ def compute_skill_profile(
|
||||||
continue
|
continue
|
||||||
sid = int(sid)
|
sid = int(sid)
|
||||||
link_mult = _skill_link_multiplier(
|
link_mult = _skill_link_multiplier(
|
||||||
is_primary=bool(link.get("is_primary")),
|
|
||||||
intensity=link.get("intensity"),
|
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
|
contribution = minutes_per_occ * occ_count * link_mult
|
||||||
if contribution <= 0:
|
if contribution <= 0:
|
||||||
|
|
@ -132,14 +172,11 @@ def compute_skill_profile(
|
||||||
"focus_areas": link.get("focus_areas"),
|
"focus_areas": link.get("focus_areas"),
|
||||||
"weight": 0.0,
|
"weight": 0.0,
|
||||||
"occurrence_count": 0,
|
"occurrence_count": 0,
|
||||||
"primary_link_count": 0,
|
|
||||||
"exercises": {},
|
"exercises": {},
|
||||||
}
|
}
|
||||||
acc = skill_acc[sid]
|
acc = skill_acc[sid]
|
||||||
acc["weight"] += contribution
|
acc["weight"] += contribution
|
||||||
acc["occurrence_count"] += occ_count
|
acc["occurrence_count"] += occ_count
|
||||||
if link.get("is_primary"):
|
|
||||||
acc["primary_link_count"] += occ_count
|
|
||||||
ex_key = str(eid)
|
ex_key = str(eid)
|
||||||
if ex_key not in acc["exercises"]:
|
if ex_key not in acc["exercises"]:
|
||||||
acc["exercises"][ex_key] = {
|
acc["exercises"][ex_key] = {
|
||||||
|
|
@ -173,7 +210,6 @@ def compute_skill_profile(
|
||||||
"weight": _round2(acc["weight"]),
|
"weight": _round2(acc["weight"]),
|
||||||
"share_percent": _round2(share),
|
"share_percent": _round2(share),
|
||||||
"occurrence_count": acc["occurrence_count"],
|
"occurrence_count": acc["occurrence_count"],
|
||||||
"primary_link_count": acc["primary_link_count"],
|
|
||||||
"top_exercises": ex_list,
|
"top_exercises": ex_list,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -193,7 +229,7 @@ def compute_skill_profile(
|
||||||
unique_exercises = len(exercise_meta)
|
unique_exercises = len(exercise_meta)
|
||||||
return {
|
return {
|
||||||
"computed_at": datetime.now(timezone.utc).isoformat(),
|
"computed_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"scoring_version": "1.0",
|
"scoring_version": "1.1",
|
||||||
"total_weight": _round2(total_weight),
|
"total_weight": _round2(total_weight),
|
||||||
"exercise_occurrence_count": total_occurrences,
|
"exercise_occurrence_count": total_occurrences,
|
||||||
"distinct_exercise_count": unique_exercises,
|
"distinct_exercise_count": unique_exercises,
|
||||||
|
|
@ -221,7 +257,7 @@ def fetch_exercise_skills_bulk(
|
||||||
JOIN exercises e ON e.id = es.exercise_id
|
JOIN exercises e ON e.id = es.exercise_id
|
||||||
WHERE es.exercise_id IN ({ph})
|
WHERE es.exercise_id IN ({ph})
|
||||||
AND (s.status = 'active' OR s.status IS NULL)
|
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,
|
ids,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,30 @@ from skill_scoring import (
|
||||||
ExerciseOccurrence,
|
ExerciseOccurrence,
|
||||||
compute_skill_profile,
|
compute_skill_profile,
|
||||||
match_score_for_skill_ids,
|
match_score_for_skill_ids,
|
||||||
|
_level_range_multiplier,
|
||||||
_skill_link_multiplier,
|
_skill_link_multiplier,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_skill_link_multiplier_primary_and_intensity():
|
def test_skill_link_multiplier_intensity_and_levels():
|
||||||
assert _skill_link_multiplier(is_primary=True, intensity="hoch") == 1.5 * 1.2
|
assert _skill_link_multiplier(intensity="hoch") == 1.2
|
||||||
assert _skill_link_multiplier(is_primary=False, intensity="niedrig") == 0.85
|
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():
|
def test_compute_skill_profile_aggregates_weights():
|
||||||
|
|
@ -23,21 +40,24 @@ def test_compute_skill_profile_aggregates_weights():
|
||||||
"skill_id": 10,
|
"skill_id": 10,
|
||||||
"skill_name": "Distanz",
|
"skill_name": "Distanz",
|
||||||
"category": "kihon",
|
"category": "kihon",
|
||||||
"is_primary": True,
|
|
||||||
"intensity": "hoch",
|
"intensity": "hoch",
|
||||||
|
"required_level": "grundlagen",
|
||||||
|
"target_level": "aufbau",
|
||||||
"exercise_title": "Übung A",
|
"exercise_title": "Übung A",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"skill_id": 11,
|
"skill_id": 11,
|
||||||
"skill_name": "Balance",
|
"skill_name": "Balance",
|
||||||
"category": "kihon",
|
"category": "kihon",
|
||||||
"is_primary": False,
|
"intensity": "niedrig",
|
||||||
"intensity": "mittel",
|
"required_level": "basis",
|
||||||
|
"target_level": "basis",
|
||||||
"exercise_title": "Übung A",
|
"exercise_title": "Übung A",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
profile = compute_skill_profile(occurrences, skills_map)
|
profile = compute_skill_profile(occurrences, skills_map)
|
||||||
|
assert profile["scoring_version"] == "1.1"
|
||||||
assert profile["exercise_occurrence_count"] == 2
|
assert profile["exercise_occurrence_count"] == 2
|
||||||
assert profile["distinct_exercise_count"] == 1
|
assert profile["distinct_exercise_count"] == 1
|
||||||
assert len(profile["skills"]) == 2
|
assert len(profile["skills"]) == 2
|
||||||
|
|
|
||||||
|
|
@ -684,7 +684,7 @@ export default function ExerciseProgressionGraphPanel({
|
||||||
<>
|
<>
|
||||||
<SkillProfilePanel
|
<SkillProfilePanel
|
||||||
title="Fähigkeiten entlang des Pfads"
|
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}
|
profile={skillProfileData?.overall}
|
||||||
loading={skillProfileLoading}
|
loading={skillProfileLoading}
|
||||||
error={skillProfileError}
|
error={skillProfileError}
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,6 @@ function SkillBar({ skill, maxShare }) {
|
||||||
style={{ width: `${pct}%` }}
|
style={{ width: `${pct}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -34,7 +29,7 @@ export default function SkillProfilePanel({
|
||||||
loading = false,
|
loading = false,
|
||||||
error = '',
|
error = '',
|
||||||
title = 'Fähigkeiten-Profil',
|
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,
|
defaultExpanded = true,
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(defaultExpanded)
|
const [expanded, setExpanded] = useState(defaultExpanded)
|
||||||
|
|
|
||||||
|
|
@ -975,7 +975,7 @@ export default function TrainingFrameworkProgramEditPage() {
|
||||||
{!isNew ? (
|
{!isNew ? (
|
||||||
<SkillProfilePanel
|
<SkillProfilePanel
|
||||||
title="Fähigkeiten-Schwerpunkte (aus Übungen)"
|
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}
|
profile={skillProfileData?.overall}
|
||||||
slots={skillProfileData?.slots}
|
slots={skillProfileData?.slots}
|
||||||
loading={skillProfileLoading}
|
loading={skillProfileLoading}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user