KPI-Scroing, Filter, etc, #43

Merged
Lars merged 10 commits from develop into main 2026-05-21 10:36:49 +02:00
8 changed files with 604 additions and 139 deletions
Showing only changes of commit 5200895a73 - Show all commits

View File

@ -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.1** (Intensität + Stufen-Spanne, ohne Primär) **Status:** Variante A (regelbasiert) umgesetzt — **v1.2** (Kategorien-Gruppierung + universelle Skala)
**Modul:** `backend/skill_scoring.py`, Router `skill_profiles` **Modul:** `backend/skill_scoring.py`, Router `skill_profiles`
## Ziel ## Ziel
@ -55,8 +55,12 @@ Beispiel: von Grundlagen bis Aufbau (Spanne 2) > nur Basis (Spanne 1).
Aggregation: Aggregation:
- Summe pro `skill_id``weight` - Summe pro `skill_id``weight` / `score` (Trainingsgewicht in gewichteten Minuten — **absolut, über Programme vergleichbar**)
- `share_percent` = Anteil an `total_weight` (100 % über alle Skills im Profil) - `artifact_share_percent` / `share_percent` = Anteil an `total_weight` **innerhalb dieses Artefakts** (summiert 100 % — nur noch sekundär)
- `by_main_category[]` → je Unterkategorie `top_skill` (stärkste Fähigkeit nach absolutem Gewicht)
- `universal_percent` = `weight / max_weight_in_corpus(skill_id) × 100` — Referenz aus sichtbarer Bibliothek (`compute_corpus_skill_max_weights`, bis 50 Artefakte je Typ)
Discovery-Sortierung und Vorschläge nutzen **`match_score`** (= Summe absoluter Gewichte der gewählten Fähigkeiten), nicht den Plan-internen Anteil.
## API ## API

View File

@ -17,6 +17,7 @@ from skill_scoring import (
collect_module_exercise_occurrences, collect_module_exercise_occurrences,
collect_progression_graph_exercise_occurrences, collect_progression_graph_exercise_occurrences,
collect_unit_exercise_occurrences, collect_unit_exercise_occurrences,
compute_corpus_skill_max_weights,
compute_skill_profile, compute_skill_profile,
match_score_for_skill_ids, match_score_for_skill_ids,
profile_for_occurrences, profile_for_occurrences,
@ -69,6 +70,13 @@ def framework_program_skill_profile(
) )
slots_raw = [r2d(r) for r in cur.fetchall()] slots_raw = [r2d(r) for r in cur.fetchall()]
ref_max = compute_corpus_skill_max_weights(
cur,
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
all_occurrences: List[ExerciseOccurrence] = [] all_occurrences: List[ExerciseOccurrence] = []
slot_profiles: List[Dict[str, Any]] = [] slot_profiles: List[Dict[str, Any]] = []
@ -89,7 +97,11 @@ def framework_program_skill_profile(
all_occurrences.extend(slot_occ) all_occurrences.extend(slot_occ)
else: else:
slot_occ = [] slot_occ = []
slot_profile = profile_for_occurrences(cur, slot_occ) if slot_occ else _empty_profile() slot_profile = (
profile_for_occurrences(cur, slot_occ, reference_max_by_skill=ref_max)
if slot_occ
else _empty_profile()
)
slot_profiles.append( slot_profiles.append(
{ {
"slot_id": slot["id"], "slot_id": slot["id"],
@ -101,12 +113,20 @@ def framework_program_skill_profile(
} }
) )
overall = profile_for_occurrences(cur, all_occurrences) if all_occurrences else _empty_profile() overall = (
profile_for_occurrences(cur, all_occurrences, reference_max_by_skill=ref_max)
if all_occurrences
else _empty_profile()
)
return { return {
"artifact_type": "framework_program", "artifact_type": "framework_program",
"artifact_id": framework_id, "artifact_id": framework_id,
"artifact_title": row.get("title"), "artifact_title": row.get("title"),
"reference_scale": {
"skills_in_corpus": len(ref_max),
"description": "universal_percent = Anteil am höchsten Trainingsgewicht dieser Fähigkeit in der sichtbaren Bibliothek",
},
"overall": overall, "overall": overall,
"slots": slot_profiles, "slots": slot_profiles,
} }
@ -122,12 +142,25 @@ def training_module_skill_profile(
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
row = _module_access(cur, module_id, profile_id, role) row = _module_access(cur, module_id, profile_id, role)
ref_max = compute_corpus_skill_max_weights(
cur,
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
occurrences = collect_module_exercise_occurrences(cur, module_id) occurrences = collect_module_exercise_occurrences(cur, module_id)
overall = profile_for_occurrences(cur, occurrences) if occurrences else _empty_profile() overall = (
profile_for_occurrences(cur, occurrences, reference_max_by_skill=ref_max)
if occurrences
else _empty_profile()
)
return { return {
"artifact_type": "training_module", "artifact_type": "training_module",
"artifact_id": module_id, "artifact_id": module_id,
"artifact_title": row.get("title"), "artifact_title": row.get("title"),
"reference_scale": {
"skills_in_corpus": len(ref_max),
},
"overall": overall, "overall": overall,
} }
@ -142,22 +175,34 @@ def progression_graph_skill_profile(
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
row = _require_graph_read(cur, graph_id, profile_id, role) row = _require_graph_read(cur, graph_id, profile_id, role)
ref_max = compute_corpus_skill_max_weights(
cur,
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
occurrences = collect_progression_graph_exercise_occurrences(cur, graph_id) occurrences = collect_progression_graph_exercise_occurrences(cur, graph_id)
overall = profile_for_occurrences( overall = (
cur, occurrences, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES profile_for_occurrences(
) if occurrences else _empty_profile() cur,
occurrences,
default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES,
reference_max_by_skill=ref_max,
)
if occurrences
else _empty_profile()
)
return { return {
"artifact_type": "progression_graph", "artifact_type": "progression_graph",
"artifact_id": graph_id, "artifact_id": graph_id,
"artifact_title": row.get("name"), "artifact_title": row.get("name"),
"reference_scale": {
"skills_in_corpus": len(ref_max),
},
"overall": overall, "overall": overall,
} }
def _empty_profile() -> Dict[str, Any]:
return compute_skill_profile([], {})
@router.get("/skill-discovery/suggestions") @router.get("/skill-discovery/suggestions")
def skill_discovery_suggestions( def skill_discovery_suggestions(
skill_ids: str = Query(..., description="Komma-getrennte skill-IDs"), skill_ids: str = Query(..., description="Komma-getrennte skill-IDs"),
@ -232,11 +277,8 @@ def skill_discovery_suggestions(
"path": f"/planning/framework-programs/{fid}", "path": f"/planning/framework-programs/{fid}",
"match": match, "match": match,
"skill_profile_summary": { "skill_profile_summary": {
"total_weight": prof.get("total_weight"), "total_score": prof.get("total_score"),
"top_skills": [ "top_by_category": _top_categories_summary(prof),
{"skill_id": s["skill_id"], "skill_name": s["skill_name"], "share_percent": s["share_percent"]}
for s in (prof.get("skills") or [])[:5]
],
}, },
} }
) )
@ -279,11 +321,8 @@ def skill_discovery_suggestions(
"path": f"/planning/training-modules/{mid}", "path": f"/planning/training-modules/{mid}",
"match": match, "match": match,
"skill_profile_summary": { "skill_profile_summary": {
"total_weight": prof.get("total_weight"), "total_score": prof.get("total_score"),
"top_skills": [ "top_by_category": _top_categories_summary(prof),
{"skill_id": s["skill_id"], "skill_name": s["skill_name"], "share_percent": s["share_percent"]}
for s in (prof.get("skills") or [])[:5]
],
}, },
} }
) )
@ -328,20 +367,14 @@ def skill_discovery_suggestions(
"path": None, "path": None,
"match": match, "match": match,
"skill_profile_summary": { "skill_profile_summary": {
"total_weight": prof.get("total_weight"), "total_score": prof.get("total_score"),
"top_skills": [ "top_by_category": _top_categories_summary(prof),
{"skill_id": s["skill_id"], "skill_name": s["skill_name"], "share_percent": s["share_percent"]}
for s in (prof.get("skills") or [])[:5]
],
}, },
} }
) )
results.sort( results.sort(
key=lambda x: ( key=lambda x: -float(x.get("match", {}).get("match_score") or x.get("match", {}).get("match_weight") or 0),
-float(x.get("match", {}).get("match_weight") or 0),
-(float(x.get("match", {}).get("match_percent") or 0)),
)
) )
return { return {
"skill_ids": wanted, "skill_ids": wanted,
@ -350,5 +383,27 @@ def skill_discovery_suggestions(
} }
def _top_categories_summary(profile: Dict[str, Any], limit: int = 6) -> List[Dict[str, Any]]:
"""Kurzliste Top-Fähigkeit je Unterkategorie für Discovery-Treffer."""
out: List[Dict[str, Any]] = []
for mc in profile.get("by_main_category") or []:
for cat in mc.get("categories") or []:
top = cat.get("top_skill")
if not top:
continue
out.append(
{
"main_category_name": mc.get("main_category_name"),
"category_name": cat.get("category_name"),
"skill_id": top.get("skill_id"),
"skill_name": top.get("skill_name"),
"score": top.get("score") or top.get("weight"),
}
)
if len(out) >= limit:
return out
return out
def _empty_profile() -> Dict[str, Any]: def _empty_profile() -> Dict[str, Any]:
return compute_skill_profile([], {}) return compute_skill_profile([], {})

View File

@ -116,11 +116,93 @@ def _round2(val: float) -> float:
return round(val, 2) return round(val, 2)
def _build_by_main_category(skills_out: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Hierarchie Hauptkategorie → Unterkategorie, je Top-Fähigkeit nach absolutem Gewicht."""
main_map: Dict[int, Dict[str, Any]] = {}
for sk in skills_out:
mc_id = int(sk.get("main_category_id") or 0)
mc_name = (sk.get("main_category_name") or "").strip() or ""
cat_id = int(sk.get("category_id") or 0)
cat_name = (sk.get("category_name") or sk.get("category") or "").strip() or ""
if mc_id not in main_map:
main_map[mc_id] = {
"main_category_id": mc_id if mc_id else None,
"main_category_name": mc_name,
"weight": 0.0,
"categories": {},
}
main = main_map[mc_id]
main["weight"] += float(sk.get("weight") or 0)
if cat_id not in main["categories"]:
main["categories"][cat_id] = {
"category_id": cat_id if cat_id else None,
"category_name": cat_name,
"weight": 0.0,
"skills": [],
}
cat = main["categories"][cat_id]
cat["weight"] += float(sk.get("weight") or 0)
cat["skills"].append(sk)
result: List[Dict[str, Any]] = []
for mc in sorted(main_map.values(), key=lambda x: (-x["weight"], x.get("main_category_name") or "")):
cats_out: List[Dict[str, Any]] = []
for cat in sorted(
mc["categories"].values(),
key=lambda x: (-x["weight"], x.get("category_name") or ""),
):
cat_skills = sorted(
cat["skills"],
key=lambda x: (-float(x.get("weight") or 0), x.get("skill_name") or ""),
)
top = cat_skills[0] if cat_skills else None
cats_out.append(
{
"category_id": cat["category_id"],
"category_name": cat["category_name"],
"weight": _round2(cat["weight"]),
"skills_count": len(cat_skills),
"top_skill": top,
}
)
result.append(
{
"main_category_id": mc["main_category_id"],
"main_category_name": mc["main_category_name"],
"weight": _round2(mc["weight"]),
"categories": cats_out,
}
)
return result
def _apply_reference_universal_percent(
skills_out: List[Dict[str, Any]],
reference_max_by_skill: Optional[Dict[int, float]] = None,
) -> None:
"""
Optional: Stärke relativ zum Maximum in der sichtbaren Bibliothek (gleiche Skala über Artefakte).
"""
if not reference_max_by_skill:
for sk in skills_out:
sk["universal_percent"] = None
return
for sk in skills_out:
sid = int(sk["skill_id"])
ref = float(reference_max_by_skill.get(sid) or 0)
w = float(sk.get("weight") or 0)
sk["universal_percent"] = _round2(w / ref * 100.0) if ref > 0 else None
def compute_skill_profile( def compute_skill_profile(
occurrences: Sequence[ExerciseOccurrence], occurrences: Sequence[ExerciseOccurrence],
skill_rows_by_exercise: Dict[int, List[Dict[str, Any]]], skill_rows_by_exercise: Dict[int, List[Dict[str, Any]]],
*, *,
default_item_minutes: int = DEFAULT_ITEM_MINUTES, default_item_minutes: int = DEFAULT_ITEM_MINUTES,
reference_max_by_skill: Optional[Dict[int, float]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Erzeugt ein normalisiertes Fähigkeiten-Profil aus Übungsvorkommen und exercise_skills. Erzeugt ein normalisiertes Fähigkeiten-Profil aus Übungsvorkommen und exercise_skills.
@ -168,7 +250,11 @@ def compute_skill_profile(
skill_acc[sid] = { skill_acc[sid] = {
"skill_id": sid, "skill_id": sid,
"skill_name": link.get("skill_name") or f"Fähigkeit #{sid}", "skill_name": link.get("skill_name") or f"Fähigkeit #{sid}",
"category": link.get("category"), "category": link.get("category_name") or link.get("category"),
"category_id": link.get("category_id"),
"category_name": link.get("category_name") or link.get("category"),
"main_category_id": link.get("main_category_id"),
"main_category_name": link.get("main_category_name"),
"focus_areas": link.get("focus_areas"), "focus_areas": link.get("focus_areas"),
"weight": 0.0, "weight": 0.0,
"occurrence_count": 0, "occurrence_count": 0,
@ -205,9 +291,15 @@ def compute_skill_profile(
{ {
"skill_id": sid, "skill_id": sid,
"skill_name": acc["skill_name"], "skill_name": acc["skill_name"],
"category": acc.get("category"), "category": acc.get("category_name") or acc.get("category"),
"category_id": acc.get("category_id"),
"category_name": acc.get("category_name") or acc.get("category"),
"main_category_id": acc.get("main_category_id"),
"main_category_name": acc.get("main_category_name"),
"focus_areas": acc.get("focus_areas"), "focus_areas": acc.get("focus_areas"),
"weight": _round2(acc["weight"]), "weight": _round2(acc["weight"]),
"score": _round2(acc["weight"]),
"artifact_share_percent": _round2(share),
"share_percent": _round2(share), "share_percent": _round2(share),
"occurrence_count": acc["occurrence_count"], "occurrence_count": acc["occurrence_count"],
"top_exercises": ex_list, "top_exercises": ex_list,
@ -215,27 +307,32 @@ def compute_skill_profile(
) )
skills_out.sort(key=lambda x: (-x["weight"], x.get("skill_name") or "")) skills_out.sort(key=lambda x: (-x["weight"], x.get("skill_name") or ""))
by_category: Dict[str, float] = defaultdict(float) _apply_reference_universal_percent(skills_out, reference_max_by_skill)
for sk in skills_out:
cat = (sk.get("category") or "").strip() or "" by_main_category = _build_by_main_category(skills_out)
by_category[cat] += sk["weight"] for mc in by_main_category:
category_rows = [] for cat in mc.get("categories") or []:
for cat, w in sorted(by_category.items(), key=lambda x: (-x[1], x[0])): top = cat.get("top_skill")
share = (w / total_weight * 100.0) if total_weight > 0 else 0.0 if top and reference_max_by_skill:
category_rows.append( sid = int(top["skill_id"])
{"category": cat, "weight": _round2(w), "share_percent": _round2(share)} ref = float(reference_max_by_skill.get(sid) or 0)
) if ref > 0:
top["universal_percent"] = _round2(float(top["weight"]) / ref * 100.0)
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.1", "scoring_version": "1.2",
"score_unit": "weighted_minutes",
"score_unit_label": "Trainingsgewicht (gewichtete Minuten, über Programme vergleichbar)",
"total_weight": _round2(total_weight), "total_weight": _round2(total_weight),
"total_score": _round2(total_weight),
"exercise_occurrence_count": total_occurrences, "exercise_occurrence_count": total_occurrences,
"distinct_exercise_count": unique_exercises, "distinct_exercise_count": unique_exercises,
"exercises_with_skills_count": len(exercises_with_skills), "exercises_with_skills_count": len(exercises_with_skills),
"skills": skills_out, "skills": skills_out,
"by_category": category_rows, "by_main_category": by_main_category,
"has_reference_scale": bool(reference_max_by_skill),
} }
@ -250,10 +347,15 @@ def fetch_exercise_skills_bulk(
f""" f"""
SELECT es.exercise_id, es.skill_id, es.is_primary, es.intensity, SELECT es.exercise_id, es.skill_id, es.is_primary, es.intensity,
es.development_contribution, es.required_level, es.target_level, es.development_contribution, es.required_level, es.target_level,
s.name AS skill_name, s.category, s.focus_areas, s.name AS skill_name, s.category,
sc.id AS category_id, sc.name AS category_name,
mc.id AS main_category_id, mc.name AS main_category_name,
s.focus_areas,
e.title AS exercise_title e.title AS exercise_title
FROM exercise_skills es FROM exercise_skills es
JOIN skills s ON s.id = es.skill_id JOIN skills s ON s.id = es.skill_id
LEFT JOIN skill_categories sc ON sc.id = s.category_id
LEFT JOIN skill_main_categories mc ON mc.id = COALESCE(s.main_category_id, sc.main_category_id)
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)
@ -346,21 +448,145 @@ def profile_for_occurrences(
occurrences: Sequence[ExerciseOccurrence], occurrences: Sequence[ExerciseOccurrence],
*, *,
default_item_minutes: int = DEFAULT_ITEM_MINUTES, default_item_minutes: int = DEFAULT_ITEM_MINUTES,
reference_max_by_skill: Optional[Dict[int, float]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
eids = [o.exercise_id for o in occurrences] eids = [o.exercise_id for o in occurrences]
skills_map = fetch_exercise_skills_bulk(cur, eids) skills_map = fetch_exercise_skills_bulk(cur, eids)
return compute_skill_profile( return compute_skill_profile(
occurrences, skills_map, default_item_minutes=default_item_minutes occurrences,
skills_map,
default_item_minutes=default_item_minutes,
reference_max_by_skill=reference_max_by_skill,
) )
def merge_skill_weights_into_max(
target: Dict[int, float],
profile: Dict[str, Any],
) -> None:
for sk in profile.get("skills") or []:
sid = int(sk["skill_id"])
w = float(sk.get("weight") or 0)
if w > target.get(sid, 0.0):
target[sid] = w
def compute_corpus_skill_max_weights(
cur,
*,
profile_id: int,
role: Optional[str],
effective_club_id: Optional[int],
limit_per_type: int = 50,
) -> Dict[int, float]:
"""
Maximum absolutes Trainingsgewicht je Fähigkeit über sichtbare Bibliotheksartefakte.
Basis für universal_percent (Skala über alle Programme).
"""
from tenant_context import library_content_visibility_sql
max_by_skill: Dict[int, float] = {}
def scan_frameworks():
vis_clause, vis_params = library_content_visibility_sql(
alias="fp",
profile_id=profile_id,
role=role,
effective_club_id=effective_club_id,
)
cur.execute(
f"""
SELECT fp.id FROM training_framework_programs fp
WHERE ({vis_clause})
ORDER BY fp.updated_at DESC NULLS LAST
LIMIT %s
""",
(*vis_params, limit_per_type),
)
for row in cur.fetchall():
fid = int(row["id"])
cur.execute(
"""
SELECT tu.id
FROM training_framework_slots s
INNER JOIN training_units tu ON tu.framework_slot_id = s.id
WHERE s.framework_program_id = %s
""",
(fid,),
)
occ: List[ExerciseOccurrence] = []
for u in cur.fetchall():
occ.extend(collect_unit_exercise_occurrences(cur, int(u["id"])))
if not occ:
continue
prof = profile_for_occurrences(cur, occ)
merge_skill_weights_into_max(max_by_skill, prof)
def scan_modules():
vis_clause, vis_params = library_content_visibility_sql(
alias="m",
profile_id=profile_id,
role=role,
effective_club_id=effective_club_id,
)
cur.execute(
f"""
SELECT m.id FROM training_modules m
WHERE ({vis_clause})
ORDER BY m.updated_at DESC NULLS LAST
LIMIT %s
""",
(*vis_params, limit_per_type),
)
for row in cur.fetchall():
mid = int(row["id"])
occ = collect_module_exercise_occurrences(cur, mid)
if not occ:
continue
prof = profile_for_occurrences(cur, occ)
merge_skill_weights_into_max(max_by_skill, prof)
def scan_graphs():
vis_clause, vis_params = library_content_visibility_sql(
alias="g",
profile_id=profile_id,
role=role,
effective_club_id=effective_club_id,
)
cur.execute(
f"""
SELECT g.id FROM exercise_progression_graphs g
WHERE ({vis_clause})
ORDER BY g.updated_at DESC NULLS LAST
LIMIT %s
""",
(*vis_params, limit_per_type),
)
for row in cur.fetchall():
gid = int(row["id"])
occ = collect_progression_graph_exercise_occurrences(cur, gid)
if not occ:
continue
prof = profile_for_occurrences(
cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES
)
merge_skill_weights_into_max(max_by_skill, prof)
scan_frameworks()
scan_modules()
scan_graphs()
return max_by_skill
def match_score_for_skill_ids(profile: Dict[str, Any], skill_ids: Sequence[int]) -> Dict[str, Any]: def match_score_for_skill_ids(profile: Dict[str, Any], skill_ids: Sequence[int]) -> Dict[str, Any]:
"""Überlappung eines Profils mit gewünschten Fähigkeiten (für Vorschläge).""" """Überlappung eines Profils mit gewünschten Fähigkeiten (für Vorschläge)."""
wanted = {int(x) for x in skill_ids if x is not None} wanted = {int(x) for x in skill_ids if x is not None}
if not wanted: if not wanted:
return { return {
"match_weight": 0.0, "match_weight": 0.0,
"match_score": 0.0,
"match_percent": 0.0, "match_percent": 0.0,
"artifact_focus_percent": 0.0,
"matched_skill_ids": [], "matched_skill_ids": [],
"matched_skills": [], "matched_skills": [],
} }
@ -372,10 +598,12 @@ def match_score_for_skill_ids(profile: Dict[str, Any], skill_ids: Sequence[int])
if sid in wanted: if sid in wanted:
matched.append(sk) matched.append(sk)
match_weight += float(sk.get("weight") or 0) match_weight += float(sk.get("weight") or 0)
match_percent = (match_weight / total * 100.0) if total > 0 else 0.0 artifact_focus = (match_weight / total * 100.0) if total > 0 else 0.0
return { return {
"match_weight": _round2(match_weight), "match_weight": _round2(match_weight),
"match_percent": _round2(match_percent), "match_score": _round2(match_weight),
"match_percent": _round2(artifact_focus),
"artifact_focus_percent": _round2(artifact_focus),
"matched_skill_ids": [int(m["skill_id"]) for m in matched], "matched_skill_ids": [int(m["skill_id"]) for m in matched],
"matched_skills": matched, "matched_skills": matched,
} }

View File

@ -40,6 +40,10 @@ def test_compute_skill_profile_aggregates_weights():
"skill_id": 10, "skill_id": 10,
"skill_name": "Distanz", "skill_name": "Distanz",
"category": "kihon", "category": "kihon",
"category_id": 1,
"category_name": "kihon",
"main_category_id": 100,
"main_category_name": "Technik",
"intensity": "hoch", "intensity": "hoch",
"required_level": "grundlagen", "required_level": "grundlagen",
"target_level": "aufbau", "target_level": "aufbau",
@ -49,6 +53,10 @@ def test_compute_skill_profile_aggregates_weights():
"skill_id": 11, "skill_id": 11,
"skill_name": "Balance", "skill_name": "Balance",
"category": "kihon", "category": "kihon",
"category_id": 1,
"category_name": "kihon",
"main_category_id": 100,
"main_category_name": "Technik",
"intensity": "niedrig", "intensity": "niedrig",
"required_level": "basis", "required_level": "basis",
"target_level": "basis", "target_level": "basis",
@ -57,13 +65,40 @@ def test_compute_skill_profile_aggregates_weights():
], ],
} }
profile = compute_skill_profile(occurrences, skills_map) profile = compute_skill_profile(occurrences, skills_map)
assert profile["scoring_version"] == "1.1" assert profile["scoring_version"] == "1.2"
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
assert profile["skills"][0]["skill_id"] == 10 assert profile["skills"][0]["skill_id"] == 10
assert profile["total_weight"] > profile["skills"][1]["weight"] assert profile["total_weight"] > profile["skills"][1]["weight"]
assert abs(sum(s["share_percent"] for s in profile["skills"]) - 100.0) < 0.1 assert abs(sum(s["share_percent"] for s in profile["skills"]) - 100.0) < 0.1
assert len(profile["by_main_category"]) == 1
assert profile["by_main_category"][0]["categories"][0]["top_skill"]["skill_id"] == 10
def test_universal_percent_against_corpus_max():
occurrences = [ExerciseOccurrence(exercise_id=1, planned_duration_min=50)]
skills_map = {
1: [
{
"skill_id": 10,
"skill_name": "Koordination",
"category_name": "Koordination",
"category_id": 2,
"main_category_id": 200,
"main_category_name": "Körper",
},
],
}
profile = compute_skill_profile(
occurrences,
skills_map,
reference_max_by_skill={10: 100.0},
)
assert profile["has_reference_scale"] is True
assert profile["skills"][0]["universal_percent"] == 50.0
top = profile["by_main_category"][0]["categories"][0]["top_skill"]
assert top["universal_percent"] == 50.0
def test_match_score_for_skill_ids(): def test_match_score_for_skill_ids():
@ -76,5 +111,7 @@ def test_match_score_for_skill_ids():
} }
m = match_score_for_skill_ids(profile, [1]) m = match_score_for_skill_ids(profile, [1])
assert m["match_weight"] == 40.0 assert m["match_weight"] == 40.0
assert m["match_score"] == 40.0
assert m["match_percent"] == 40.0 assert m["match_percent"] == 40.0
assert m["artifact_focus_percent"] == 40.0
assert m["matched_skill_ids"] == [1] assert m["matched_skill_ids"] == [1]

View File

@ -2591,6 +2591,46 @@ html.modal-scroll-locked .app-main {
color: var(--text3); color: var(--text3);
margin-top: 3px; margin-top: 3px;
} }
.skill-profile__by-category {
display: flex;
flex-direction: column;
gap: 14px;
}
.skill-profile__main-cat {
padding: 10px 12px;
border-radius: 10px;
background: var(--surface2);
border: 1px solid var(--border);
}
.skill-profile__main-cat-title {
margin: 0 0 8px;
font-size: 0.82rem;
font-weight: 700;
color: var(--text2);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.skill-profile__cat-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.skill-profile__cat-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.skill-profile__cat-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text3);
}
.skill-profile__cat-row {
list-style: none;
}
.skill-profile__categories { .skill-profile__categories {
margin-top: 0.85rem; margin-top: 0.85rem;
} }
@ -2744,6 +2784,25 @@ html.modal-scroll-locked .app-main {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text2); color: var(--text2);
} }
.skill-discovery__result-cats {
list-style: none;
margin: 0 0 8px;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.8rem;
color: var(--text2);
}
.skill-discovery__result-cats li {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.skill-discovery__result-cat-name {
font-weight: 600;
color: var(--text3);
}
.skill-discovery__result-link { .skill-discovery__result-link {
text-decoration: none; text-decoration: none;
} }

View File

@ -8,6 +8,12 @@ const ARTIFACT_LABELS = {
progression_graph: 'Regressionspfad', progression_graph: 'Regressionspfad',
} }
function formatScore(value) {
const n = Number(value)
if (!Number.isFinite(n)) return '0'
return n % 1 === 0 ? String(n) : n.toFixed(1)
}
/** /**
* Vorschläge für Planungsartefakte anhand gewählter Fähigkeiten (Phase 3). * Vorschläge für Planungsartefakte anhand gewählter Fähigkeiten (Phase 3).
*/ */
@ -60,8 +66,8 @@ export default function SkillDiscoveryPanel({ skills = [] }) {
<h2 className="skill-discovery__title">Planungs-Vorschläge</h2> <h2 className="skill-discovery__title">Planungs-Vorschläge</h2>
<p className="form-sub skill-discovery__lead"> <p className="form-sub skill-discovery__lead">
Wähle Fähigkeiten, die du schwerpunktmäßig entwickeln willst Shinkan schlägt passende Wähle Fähigkeiten, die du schwerpunktmäßig entwickeln willst Shinkan schlägt passende
Rahmenprogramme, Trainingsmodule und Regressionspfade aus der Bibliothek vor (gewichtet nach Rahmenprogramme, Trainingsmodule und Regressionspfade vor. Sortierung nach absolutem
Übungs-Verknüpfungen). Trainingsgewicht (nicht nach Anteil innerhalb des Plans).
</p> </p>
<div className="form-row"> <div className="form-row">
@ -122,35 +128,51 @@ export default function SkillDiscoveryPanel({ skills = [] }) {
{result?.suggestions?.length > 0 ? ( {result?.suggestions?.length > 0 ? (
<ul className="skill-discovery__results"> <ul className="skill-discovery__results">
{result.suggestions.map((item) => ( {result.suggestions.map((item) => {
<li key={`${item.artifact_type}-${item.artifact_id}`} className="skill-discovery__result card"> const matchScore = item.match?.match_score ?? item.match?.match_weight ?? 0
<div className="skill-discovery__result-head"> const focusPct = item.match?.artifact_focus_percent ?? item.match?.match_percent
<span className="skill-discovery__result-type"> const topByCat = item.skill_profile_summary?.top_by_category || []
{ARTIFACT_LABELS[item.artifact_type] || item.artifact_type} return (
</span> <li key={`${item.artifact_type}-${item.artifact_id}`} className="skill-discovery__result card">
<span className="skill-discovery__result-match"> <div className="skill-discovery__result-head">
Passung {item.match?.match_percent ?? 0}% <span className="skill-discovery__result-type">
</span> {ARTIFACT_LABELS[item.artifact_type] || item.artifact_type}
</div> </span>
<strong className="skill-discovery__result-title"> <span className="skill-discovery__result-match" title="Summe der Trainingsgewichte der gewählten Fähigkeiten">
{item.artifact_title || `#${item.artifact_id}`} Gewicht {formatScore(matchScore)}
</strong> </span>
{item.match?.matched_skills?.length > 0 ? ( </div>
<p className="skill-discovery__result-skills"> <strong className="skill-discovery__result-title">
{item.match.matched_skills.map((m) => m.skill_name).join(' · ')} {item.artifact_title || `#${item.artifact_id}`}
</p> </strong>
) : null} {item.match?.matched_skills?.length > 0 ? (
{item.path ? ( <p className="skill-discovery__result-skills">
<Link to={item.path} className="btn btn-secondary btn-small skill-discovery__result-link"> {item.match.matched_skills.map((m) => m.skill_name).join(' · ')}
Öffnen {focusPct != null ? ` (${formatScore(focusPct)}% des Plans)` : null}
</Link> </p>
) : item.artifact_type === 'progression_graph' ? ( ) : null}
<p className="form-sub" style={{ margin: '8px 0 0' }}> {topByCat.length > 0 ? (
Regressionspfad in der Übungsliste unter Progressionsgraph bearbeiten. <ul className="skill-discovery__result-cats">
</p> {topByCat.slice(0, 4).map((row) => (
) : null} <li key={`${row.category_name}-${row.skill_id}`}>
</li> <span className="skill-discovery__result-cat-name">{row.category_name}</span>
))} <span>{row.skill_name}</span>
</li>
))}
</ul>
) : null}
{item.path ? (
<Link to={item.path} className="btn btn-secondary btn-small skill-discovery__result-link">
Öffnen
</Link>
) : item.artifact_type === 'progression_graph' ? (
<p className="form-sub" style={{ margin: '8px 0 0' }}>
Regressionspfad in der Übungsliste unter Progressionsgraph bearbeiten.
</p>
) : null}
</li>
)
})}
</ul> </ul>
) : result && !loading ? ( ) : result && !loading ? (
<p className="form-sub skill-discovery__no-hit"> <p className="form-sub skill-discovery__no-hit">

View File

@ -1,25 +1,107 @@
import React, { useMemo, useState } from 'react' import React, { useMemo, useState } from 'react'
function SkillBar({ skill, maxShare }) { function skillWeight(skill) {
const pct = maxShare > 0 ? Math.min(100, (skill.share_percent / maxShare) * 100) : 0 return Number(skill?.weight ?? skill?.score ?? 0)
}
function formatWeight(value) {
const n = Number(value)
if (!Number.isFinite(n)) return '0'
return n % 1 === 0 ? String(n) : n.toFixed(1)
}
function barFillPercent(skill, maxWeight, hasReferenceScale) {
if (hasReferenceScale && skill?.universal_percent != null) {
return Math.min(100, Number(skill.universal_percent))
}
const w = skillWeight(skill)
return maxWeight > 0 ? Math.min(100, (w / maxWeight) * 100) : 0
}
function metricLabel(skill, hasReferenceScale) {
if (hasReferenceScale && skill?.universal_percent != null) {
return `${skill.universal_percent}% Bibliothek`
}
return formatWeight(skillWeight(skill))
}
function CategoryTopSkill({ skill, maxWeight, hasReferenceScale }) {
if (!skill) return null
const pct = barFillPercent(skill, maxWeight, hasReferenceScale)
return ( return (
<li className="skill-profile__row"> <li className="skill-profile__cat-row">
<div className="skill-profile__row-head"> <div className="skill-profile__row-head">
<span className="skill-profile__name" title={skill.skill_name}> <span className="skill-profile__name" title={skill.skill_name}>
{skill.skill_name} {skill.skill_name}
</span> </span>
<span className="skill-profile__pct">{skill.share_percent}%</span> <span className="skill-profile__pct" title="Trainingsgewicht (gewichtete Minuten)">
{metricLabel(skill, hasReferenceScale)}
</span>
</div> </div>
<div className="skill-profile__bar-track" aria-hidden="true"> <div className="skill-profile__bar-track" aria-hidden="true">
<div <div className="skill-profile__bar-fill" style={{ width: `${pct}%` }} />
className="skill-profile__bar-fill"
style={{ width: `${pct}%` }}
/>
</div> </div>
{hasReferenceScale ? (
<span className="skill-profile__meta-hint">
Absolut: {formatWeight(skillWeight(skill))} · Skala über alle Programme
</span>
) : null}
</li> </li>
) )
} }
function CategoryGroupedProfile({ profile, ariaLabel }) {
const groups = profile?.by_main_category || []
const hasReferenceScale = Boolean(profile?.has_reference_scale)
const maxWeight = useMemo(() => {
let max = 1
for (const mc of groups) {
for (const cat of mc.categories || []) {
const w = skillWeight(cat.top_skill)
if (w > max) max = w
}
}
return max
}, [groups])
if (!groups.length) return null
return (
<div className="skill-profile__by-category" aria-label={ariaLabel}>
{groups.map((mc) => (
<section key={mc.main_category_id ?? mc.main_category_name} className="skill-profile__main-cat">
<h4 className="skill-profile__main-cat-title">{mc.main_category_name}</h4>
<ul className="skill-profile__cat-list">
{(mc.categories || []).map((cat) => (
<li key={cat.category_id ?? cat.category_name} className="skill-profile__cat-item">
<span className="skill-profile__cat-label">{cat.category_name}</span>
<CategoryTopSkill
skill={cat.top_skill}
maxWeight={maxWeight}
hasReferenceScale={hasReferenceScale}
/>
</li>
))}
</ul>
</section>
))}
</div>
)
}
function topCategoryBadge(profile) {
const parts = []
for (const mc of profile?.by_main_category || []) {
for (const cat of mc.categories || []) {
const top = cat.top_skill
if (!top) continue
parts.push(`${cat.category_name}: ${top.skill_name}`)
if (parts.length >= 2) return parts.join(' · ')
}
}
return parts.join(' · ')
}
/** /**
* Gewichtetes Fähigkeiten-Profil (Phase 3) Anzeige für Planungsartefakte. * Gewichtetes Fähigkeiten-Profil (Phase 3) Anzeige für Planungsartefakte.
*/ */
@ -29,17 +111,13 @@ 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, Intensität, Stufen von/bis).', hint = 'Aus verknüpften Übungen berechnet (Dauer, Vorkommen, Intensität, Stufen von/bis). Trainingsgewicht ist über Programme vergleichbar; die Balken zeigen die Stärke relativ zur Bibliothek.',
defaultExpanded = true, defaultExpanded = true,
}) { }) {
const [expanded, setExpanded] = useState(defaultExpanded) const [expanded, setExpanded] = useState(defaultExpanded)
const [slotOpenId, setSlotOpenId] = useState(null) const [slotOpenId, setSlotOpenId] = useState(null)
const skills = profile?.skills || [] const badge = useMemo(() => topCategoryBadge(profile), [profile])
const maxShare = useMemo(
() => Math.max(...skills.map((s) => s.share_percent || 0), 1),
[skills]
)
if (loading) { if (loading) {
return ( return (
@ -61,6 +139,11 @@ export default function SkillProfilePanel({
!profile || !profile ||
(profile.exercise_occurrence_count === 0 && profile.distinct_exercise_count === 0) (profile.exercise_occurrence_count === 0 && profile.distinct_exercise_count === 0)
const categoryCount = (profile?.by_main_category || []).reduce(
(n, mc) => n + (mc.categories?.length || 0),
0
)
return ( return (
<div className="card skill-profile"> <div className="card skill-profile">
<button <button
@ -70,10 +153,8 @@ export default function SkillProfilePanel({
aria-expanded={expanded} aria-expanded={expanded}
> >
<span className="skill-profile__toggle-title">{title}</span> <span className="skill-profile__toggle-title">{title}</span>
{!noData && skills.length > 0 ? ( {!noData && badge ? (
<span className="skill-profile__toggle-badge"> <span className="skill-profile__toggle-badge">{badge}</span>
Top: {skills[0].skill_name} ({skills[0].share_percent}%)
</span>
) : null} ) : null}
<span className="skill-profile__toggle-icon" aria-hidden="true"> <span className="skill-profile__toggle-icon" aria-hidden="true">
{expanded ? '▾' : '▸'} {expanded ? '▾' : '▸'}
@ -101,31 +182,20 @@ export default function SkillProfilePanel({
<strong>{profile.distinct_exercise_count}</strong> Übungen <strong>{profile.distinct_exercise_count}</strong> Übungen
</span> </span>
<span> <span>
<strong>{skills.length}</strong> Fähigkeiten <strong>{profile.skills?.length ?? 0}</strong> Fähigkeiten
</span> </span>
<span> <span>
<strong>{profile.exercise_occurrence_count}</strong> Positionen <strong>{categoryCount}</strong> Kategorien
</span>
<span>
<strong>{formatWeight(profile.total_score ?? profile.total_weight)}</strong> Gesamt-Gewicht
</span> </span>
</div> </div>
<ul className="skill-profile__list" aria-label="Fähigkeiten nach Gewicht"> <CategoryGroupedProfile
{skills.slice(0, 12).map((sk) => ( profile={profile}
<SkillBar key={sk.skill_id} skill={sk} maxShare={maxShare} /> ariaLabel="Top-Fähigkeit je Kategorie"
))} />
</ul>
{profile.by_category?.length > 1 ? (
<div className="skill-profile__categories">
<span className="skill-profile__categories-label">Nach Kategorie</span>
<div className="skill-profile__category-chips">
{profile.by_category.slice(0, 6).map((c) => (
<span key={c.category} className="skill-profile__category-chip">
{c.category} {c.share_percent}%
</span>
))}
</div>
</div>
) : null}
</> </>
)} )}
@ -135,7 +205,7 @@ export default function SkillProfilePanel({
<ul className="skill-profile__slot-list"> <ul className="skill-profile__slot-list">
{slots.map((sl) => { {slots.map((sl) => {
const open = slotOpenId === sl.slot_id const open = slotOpenId === sl.slot_id
const top = sl.profile?.skills?.[0] const slotBadge = topCategoryBadge(sl.profile)
return ( return (
<li key={sl.slot_id} className="skill-profile__slot-item"> <li key={sl.slot_id} className="skill-profile__slot-item">
<button <button
@ -147,29 +217,19 @@ export default function SkillProfilePanel({
<span> <span>
{(sl.slot_title || '').trim() || `Session ${(sl.sort_order ?? 0) + 1}`} {(sl.slot_title || '').trim() || `Session ${(sl.sort_order ?? 0) + 1}`}
</span> </span>
{top ? ( {slotBadge ? (
<span className="skill-profile__slot-top"> <span className="skill-profile__slot-top">{slotBadge}</span>
{top.skill_name} {top.share_percent}%
</span>
) : ( ) : (
<span className="skill-profile__slot-top skill-profile__slot-top--muted"> <span className="skill-profile__slot-top skill-profile__slot-top--muted">
{sl.exercise_occurrence_count > 0 ? 'ohne Fähigkeiten' : 'kein Ablauf'} {sl.exercise_occurrence_count > 0 ? 'ohne Fähigkeiten' : 'kein Ablauf'}
</span> </span>
)} )}
</button> </button>
{open && sl.profile?.skills?.length > 0 ? ( {open && sl.profile?.by_main_category?.length > 0 ? (
<ul className="skill-profile__list skill-profile__list--nested"> <CategoryGroupedProfile
{sl.profile.skills.slice(0, 6).map((sk) => ( profile={sl.profile}
<SkillBar ariaLabel={`Fähigkeiten Session ${(sl.sort_order ?? 0) + 1}`}
key={sk.skill_id} />
skill={sk}
maxShare={Math.max(
...sl.profile.skills.map((x) => x.share_percent || 0),
1
)}
/>
))}
</ul>
) : null} ) : null}
</li> </li>
) )

View File

@ -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, Intensität (niedrig/mittel/hoch) und Stufen-Spanne (von/bis). Vergleichbar mit manuell gesetzten Fokusbereichen in den Stammdaten." hint="Trainingsgewicht aus Dauer, Häufigkeit, Intensität und Stufen-Spanne. Pro Kategorie die stärkste Fähigkeit — Balken relativ zur Bibliothek, nicht 100% innerhalb des Plans."
profile={skillProfileData?.overall} profile={skillProfileData?.overall}
slots={skillProfileData?.slots} slots={skillProfileData?.slots}
loading={skillProfileLoading} loading={skillProfileLoading}