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
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:
parent
f67bf280c3
commit
8f8bdf6d8b
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user