Update Skill Scoring Specification and Implementation to v1.2
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 1s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m27s

- Enhanced the skill scoring system with category grouping and a universal scale for improved comparability across programs.
- Introduced new calculations for artifact share percentage and universal percent, allowing for a more nuanced understanding of skill contributions.
- Updated the API to reflect changes in the skill profile structure, including main category and top skill details.
- Improved frontend components to display skills by main category, enhancing user experience in skill discovery and profile visualization.
- Adjusted tests to validate the new scoring logic and ensure accurate representation of skills and their weights.
This commit is contained in:
Lars 2026-05-21 08:37:58 +02:00
parent 8f8bdf6d8b
commit 5200895a73
8 changed files with 604 additions and 139 deletions

View File

@ -1,7 +1,7 @@
# Gewichtetes Fähigkeiten-Scoring (Phase 3)
**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`
## Ziel
@ -55,8 +55,12 @@ Beispiel: von Grundlagen bis Aufbau (Spanne 2) > nur Basis (Spanne 1).
Aggregation:
- Summe pro `skill_id``weight`
- `share_percent` = Anteil an `total_weight` (100 % über alle Skills im Profil)
- Summe pro `skill_id``weight` / `score` (Trainingsgewicht in gewichteten Minuten — **absolut, über Programme vergleichbar**)
- `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

View File

@ -17,6 +17,7 @@ from skill_scoring import (
collect_module_exercise_occurrences,
collect_progression_graph_exercise_occurrences,
collect_unit_exercise_occurrences,
compute_corpus_skill_max_weights,
compute_skill_profile,
match_score_for_skill_ids,
profile_for_occurrences,
@ -69,6 +70,13 @@ def framework_program_skill_profile(
)
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] = []
slot_profiles: List[Dict[str, Any]] = []
@ -89,7 +97,11 @@ def framework_program_skill_profile(
all_occurrences.extend(slot_occ)
else:
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_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 {
"artifact_type": "framework_program",
"artifact_id": framework_id,
"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,
"slots": slot_profiles,
}
@ -122,12 +142,25 @@ def training_module_skill_profile(
with get_db() as conn:
cur = get_cursor(conn)
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)
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 {
"artifact_type": "training_module",
"artifact_id": module_id,
"artifact_title": row.get("title"),
"reference_scale": {
"skills_in_corpus": len(ref_max),
},
"overall": overall,
}
@ -142,22 +175,34 @@ def progression_graph_skill_profile(
with get_db() as conn:
cur = get_cursor(conn)
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)
overall = profile_for_occurrences(
cur, occurrences, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES
) if occurrences else _empty_profile()
overall = (
profile_for_occurrences(
cur,
occurrences,
default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES,
reference_max_by_skill=ref_max,
)
if occurrences
else _empty_profile()
)
return {
"artifact_type": "progression_graph",
"artifact_id": graph_id,
"artifact_title": row.get("name"),
"reference_scale": {
"skills_in_corpus": len(ref_max),
},
"overall": overall,
}
def _empty_profile() -> Dict[str, Any]:
return compute_skill_profile([], {})
@router.get("/skill-discovery/suggestions")
def skill_discovery_suggestions(
skill_ids: str = Query(..., description="Komma-getrennte skill-IDs"),
@ -232,11 +277,8 @@ def skill_discovery_suggestions(
"path": f"/planning/framework-programs/{fid}",
"match": match,
"skill_profile_summary": {
"total_weight": prof.get("total_weight"),
"top_skills": [
{"skill_id": s["skill_id"], "skill_name": s["skill_name"], "share_percent": s["share_percent"]}
for s in (prof.get("skills") or [])[:5]
],
"total_score": prof.get("total_score"),
"top_by_category": _top_categories_summary(prof),
},
}
)
@ -279,11 +321,8 @@ def skill_discovery_suggestions(
"path": f"/planning/training-modules/{mid}",
"match": match,
"skill_profile_summary": {
"total_weight": prof.get("total_weight"),
"top_skills": [
{"skill_id": s["skill_id"], "skill_name": s["skill_name"], "share_percent": s["share_percent"]}
for s in (prof.get("skills") or [])[:5]
],
"total_score": prof.get("total_score"),
"top_by_category": _top_categories_summary(prof),
},
}
)
@ -328,20 +367,14 @@ def skill_discovery_suggestions(
"path": None,
"match": match,
"skill_profile_summary": {
"total_weight": prof.get("total_weight"),
"top_skills": [
{"skill_id": s["skill_id"], "skill_name": s["skill_name"], "share_percent": s["share_percent"]}
for s in (prof.get("skills") or [])[:5]
],
"total_score": prof.get("total_score"),
"top_by_category": _top_categories_summary(prof),
},
}
)
results.sort(
key=lambda x: (
-float(x.get("match", {}).get("match_weight") or 0),
-(float(x.get("match", {}).get("match_percent") or 0)),
)
key=lambda x: -float(x.get("match", {}).get("match_score") or x.get("match", {}).get("match_weight") or 0),
)
return {
"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]:
return compute_skill_profile([], {})

View File

@ -116,11 +116,93 @@ def _round2(val: float) -> float:
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(
occurrences: Sequence[ExerciseOccurrence],
skill_rows_by_exercise: Dict[int, List[Dict[str, Any]]],
*,
default_item_minutes: int = DEFAULT_ITEM_MINUTES,
reference_max_by_skill: Optional[Dict[int, float]] = None,
) -> Dict[str, Any]:
"""
Erzeugt ein normalisiertes Fähigkeiten-Profil aus Übungsvorkommen und exercise_skills.
@ -168,7 +250,11 @@ def compute_skill_profile(
skill_acc[sid] = {
"skill_id": 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"),
"weight": 0.0,
"occurrence_count": 0,
@ -205,9 +291,15 @@ def compute_skill_profile(
{
"skill_id": sid,
"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"),
"weight": _round2(acc["weight"]),
"score": _round2(acc["weight"]),
"artifact_share_percent": _round2(share),
"share_percent": _round2(share),
"occurrence_count": acc["occurrence_count"],
"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 ""))
by_category: Dict[str, float] = defaultdict(float)
for sk in skills_out:
cat = (sk.get("category") or "").strip() or ""
by_category[cat] += sk["weight"]
category_rows = []
for cat, w in sorted(by_category.items(), key=lambda x: (-x[1], x[0])):
share = (w / total_weight * 100.0) if total_weight > 0 else 0.0
category_rows.append(
{"category": cat, "weight": _round2(w), "share_percent": _round2(share)}
)
_apply_reference_universal_percent(skills_out, reference_max_by_skill)
by_main_category = _build_by_main_category(skills_out)
for mc in by_main_category:
for cat in mc.get("categories") or []:
top = cat.get("top_skill")
if top and reference_max_by_skill:
sid = int(top["skill_id"])
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)
return {
"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_score": _round2(total_weight),
"exercise_occurrence_count": total_occurrences,
"distinct_exercise_count": unique_exercises,
"exercises_with_skills_count": len(exercises_with_skills),
"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"""
SELECT es.exercise_id, es.skill_id, es.is_primary, es.intensity,
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
FROM exercise_skills es
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
WHERE es.exercise_id IN ({ph})
AND (s.status = 'active' OR s.status IS NULL)
@ -346,21 +448,145 @@ def profile_for_occurrences(
occurrences: Sequence[ExerciseOccurrence],
*,
default_item_minutes: int = DEFAULT_ITEM_MINUTES,
reference_max_by_skill: Optional[Dict[int, float]] = None,
) -> Dict[str, Any]:
eids = [o.exercise_id for o in occurrences]
skills_map = fetch_exercise_skills_bulk(cur, eids)
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]:
"""Ü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}
if not wanted:
return {
"match_weight": 0.0,
"match_score": 0.0,
"match_percent": 0.0,
"artifact_focus_percent": 0.0,
"matched_skill_ids": [],
"matched_skills": [],
}
@ -372,10 +598,12 @@ def match_score_for_skill_ids(profile: Dict[str, Any], skill_ids: Sequence[int])
if sid in wanted:
matched.append(sk)
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 {
"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_skills": matched,
}

View File

@ -40,6 +40,10 @@ def test_compute_skill_profile_aggregates_weights():
"skill_id": 10,
"skill_name": "Distanz",
"category": "kihon",
"category_id": 1,
"category_name": "kihon",
"main_category_id": 100,
"main_category_name": "Technik",
"intensity": "hoch",
"required_level": "grundlagen",
"target_level": "aufbau",
@ -49,6 +53,10 @@ def test_compute_skill_profile_aggregates_weights():
"skill_id": 11,
"skill_name": "Balance",
"category": "kihon",
"category_id": 1,
"category_name": "kihon",
"main_category_id": 100,
"main_category_name": "Technik",
"intensity": "niedrig",
"required_level": "basis",
"target_level": "basis",
@ -57,13 +65,40 @@ def test_compute_skill_profile_aggregates_weights():
],
}
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["distinct_exercise_count"] == 1
assert len(profile["skills"]) == 2
assert profile["skills"][0]["skill_id"] == 10
assert profile["total_weight"] > profile["skills"][1]["weight"]
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():
@ -76,5 +111,7 @@ def test_match_score_for_skill_ids():
}
m = match_score_for_skill_ids(profile, [1])
assert m["match_weight"] == 40.0
assert m["match_score"] == 40.0
assert m["match_percent"] == 40.0
assert m["artifact_focus_percent"] == 40.0
assert m["matched_skill_ids"] == [1]

View File

@ -2591,6 +2591,46 @@ html.modal-scroll-locked .app-main {
color: var(--text3);
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 {
margin-top: 0.85rem;
}
@ -2744,6 +2784,25 @@ html.modal-scroll-locked .app-main {
font-size: 0.85rem;
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 {
text-decoration: none;
}

View File

@ -8,6 +8,12 @@ const ARTIFACT_LABELS = {
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).
*/
@ -60,8 +66,8 @@ export default function SkillDiscoveryPanel({ skills = [] }) {
<h2 className="skill-discovery__title">Planungs-Vorschläge</h2>
<p className="form-sub skill-discovery__lead">
Wähle Fähigkeiten, die du schwerpunktmäßig entwickeln willst Shinkan schlägt passende
Rahmenprogramme, Trainingsmodule und Regressionspfade aus der Bibliothek vor (gewichtet nach
Übungs-Verknüpfungen).
Rahmenprogramme, Trainingsmodule und Regressionspfade vor. Sortierung nach absolutem
Trainingsgewicht (nicht nach Anteil innerhalb des Plans).
</p>
<div className="form-row">
@ -122,35 +128,51 @@ export default function SkillDiscoveryPanel({ skills = [] }) {
{result?.suggestions?.length > 0 ? (
<ul className="skill-discovery__results">
{result.suggestions.map((item) => (
<li key={`${item.artifact_type}-${item.artifact_id}`} className="skill-discovery__result card">
<div className="skill-discovery__result-head">
<span className="skill-discovery__result-type">
{ARTIFACT_LABELS[item.artifact_type] || item.artifact_type}
</span>
<span className="skill-discovery__result-match">
Passung {item.match?.match_percent ?? 0}%
</span>
</div>
<strong className="skill-discovery__result-title">
{item.artifact_title || `#${item.artifact_id}`}
</strong>
{item.match?.matched_skills?.length > 0 ? (
<p className="skill-discovery__result-skills">
{item.match.matched_skills.map((m) => m.skill_name).join(' · ')}
</p>
) : 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>
))}
{result.suggestions.map((item) => {
const matchScore = item.match?.match_score ?? item.match?.match_weight ?? 0
const focusPct = item.match?.artifact_focus_percent ?? item.match?.match_percent
const topByCat = item.skill_profile_summary?.top_by_category || []
return (
<li key={`${item.artifact_type}-${item.artifact_id}`} className="skill-discovery__result card">
<div className="skill-discovery__result-head">
<span className="skill-discovery__result-type">
{ARTIFACT_LABELS[item.artifact_type] || item.artifact_type}
</span>
<span className="skill-discovery__result-match" title="Summe der Trainingsgewichte der gewählten Fähigkeiten">
Gewicht {formatScore(matchScore)}
</span>
</div>
<strong className="skill-discovery__result-title">
{item.artifact_title || `#${item.artifact_id}`}
</strong>
{item.match?.matched_skills?.length > 0 ? (
<p className="skill-discovery__result-skills">
{item.match.matched_skills.map((m) => m.skill_name).join(' · ')}
{focusPct != null ? ` (${formatScore(focusPct)}% des Plans)` : null}
</p>
) : null}
{topByCat.length > 0 ? (
<ul className="skill-discovery__result-cats">
{topByCat.slice(0, 4).map((row) => (
<li key={`${row.category_name}-${row.skill_id}`}>
<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>
) : result && !loading ? (
<p className="form-sub skill-discovery__no-hit">

View File

@ -1,25 +1,107 @@
import React, { useMemo, useState } from 'react'
function SkillBar({ skill, maxShare }) {
const pct = maxShare > 0 ? Math.min(100, (skill.share_percent / maxShare) * 100) : 0
function skillWeight(skill) {
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 (
<li className="skill-profile__row">
<li className="skill-profile__cat-row">
<div className="skill-profile__row-head">
<span className="skill-profile__name" title={skill.skill_name}>
{skill.skill_name}
</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 className="skill-profile__bar-track" aria-hidden="true">
<div
className="skill-profile__bar-fill"
style={{ width: `${pct}%` }}
/>
<div className="skill-profile__bar-fill" style={{ width: `${pct}%` }} />
</div>
{hasReferenceScale ? (
<span className="skill-profile__meta-hint">
Absolut: {formatWeight(skillWeight(skill))} · Skala über alle Programme
</span>
) : null}
</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.
*/
@ -29,17 +111,13 @@ export default function SkillProfilePanel({
loading = false,
error = '',
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,
}) {
const [expanded, setExpanded] = useState(defaultExpanded)
const [slotOpenId, setSlotOpenId] = useState(null)
const skills = profile?.skills || []
const maxShare = useMemo(
() => Math.max(...skills.map((s) => s.share_percent || 0), 1),
[skills]
)
const badge = useMemo(() => topCategoryBadge(profile), [profile])
if (loading) {
return (
@ -61,6 +139,11 @@ export default function SkillProfilePanel({
!profile ||
(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 (
<div className="card skill-profile">
<button
@ -70,10 +153,8 @@ export default function SkillProfilePanel({
aria-expanded={expanded}
>
<span className="skill-profile__toggle-title">{title}</span>
{!noData && skills.length > 0 ? (
<span className="skill-profile__toggle-badge">
Top: {skills[0].skill_name} ({skills[0].share_percent}%)
</span>
{!noData && badge ? (
<span className="skill-profile__toggle-badge">{badge}</span>
) : null}
<span className="skill-profile__toggle-icon" aria-hidden="true">
{expanded ? '▾' : '▸'}
@ -101,31 +182,20 @@ export default function SkillProfilePanel({
<strong>{profile.distinct_exercise_count}</strong> Übungen
</span>
<span>
<strong>{skills.length}</strong> Fähigkeiten
<strong>{profile.skills?.length ?? 0}</strong> Fähigkeiten
</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>
</div>
<ul className="skill-profile__list" aria-label="Fähigkeiten nach Gewicht">
{skills.slice(0, 12).map((sk) => (
<SkillBar key={sk.skill_id} skill={sk} maxShare={maxShare} />
))}
</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}
<CategoryGroupedProfile
profile={profile}
ariaLabel="Top-Fähigkeit je Kategorie"
/>
</>
)}
@ -135,7 +205,7 @@ export default function SkillProfilePanel({
<ul className="skill-profile__slot-list">
{slots.map((sl) => {
const open = slotOpenId === sl.slot_id
const top = sl.profile?.skills?.[0]
const slotBadge = topCategoryBadge(sl.profile)
return (
<li key={sl.slot_id} className="skill-profile__slot-item">
<button
@ -147,29 +217,19 @@ export default function SkillProfilePanel({
<span>
{(sl.slot_title || '').trim() || `Session ${(sl.sort_order ?? 0) + 1}`}
</span>
{top ? (
<span className="skill-profile__slot-top">
{top.skill_name} {top.share_percent}%
</span>
{slotBadge ? (
<span className="skill-profile__slot-top">{slotBadge}</span>
) : (
<span className="skill-profile__slot-top skill-profile__slot-top--muted">
{sl.exercise_occurrence_count > 0 ? 'ohne Fähigkeiten' : 'kein Ablauf'}
</span>
)}
</button>
{open && sl.profile?.skills?.length > 0 ? (
<ul className="skill-profile__list skill-profile__list--nested">
{sl.profile.skills.slice(0, 6).map((sk) => (
<SkillBar
key={sk.skill_id}
skill={sk}
maxShare={Math.max(
...sl.profile.skills.map((x) => x.share_percent || 0),
1
)}
/>
))}
</ul>
{open && sl.profile?.by_main_category?.length > 0 ? (
<CategoryGroupedProfile
profile={sl.profile}
ariaLabel={`Fähigkeiten Session ${(sl.sort_order ?? 0) + 1}`}
/>
) : null}
</li>
)

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, 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}
slots={skillProfileData?.slots}
loading={skillProfileLoading}