diff --git a/.claude/docs/technical/SKILL_SCORING_SPEC.md b/.claude/docs/technical/SKILL_SCORING_SPEC.md index 8e6e852..eda832e 100644 --- a/.claude/docs/technical/SKILL_SCORING_SPEC.md +++ b/.claude/docs/technical/SKILL_SCORING_SPEC.md @@ -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 diff --git a/backend/routers/skill_profiles.py b/backend/routers/skill_profiles.py index 49474cd..6253cab 100644 --- a/backend/routers/skill_profiles.py +++ b/backend/routers/skill_profiles.py @@ -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([], {}) diff --git a/backend/skill_scoring.py b/backend/skill_scoring.py index 90fdd04..8340a21 100644 --- a/backend/skill_scoring.py +++ b/backend/skill_scoring.py @@ -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, } diff --git a/backend/tests/test_skill_scoring.py b/backend/tests/test_skill_scoring.py index df3695c..4e0f0c7 100644 --- a/backend/tests/test_skill_scoring.py +++ b/backend/tests/test_skill_scoring.py @@ -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] diff --git a/frontend/src/app.css b/frontend/src/app.css index 9e693c5..f684c21 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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; } diff --git a/frontend/src/components/skills/SkillDiscoveryPanel.jsx b/frontend/src/components/skills/SkillDiscoveryPanel.jsx index dd09642..203fdc3 100644 --- a/frontend/src/components/skills/SkillDiscoveryPanel.jsx +++ b/frontend/src/components/skills/SkillDiscoveryPanel.jsx @@ -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 = [] }) {

Planungs-Vorschläge

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).

@@ -122,35 +128,51 @@ export default function SkillDiscoveryPanel({ skills = [] }) { {result?.suggestions?.length > 0 ? ( ) : result && !loading ? (

diff --git a/frontend/src/components/skills/SkillProfilePanel.jsx b/frontend/src/components/skills/SkillProfilePanel.jsx index 1e90101..c268b06 100644 --- a/frontend/src/components/skills/SkillProfilePanel.jsx +++ b/frontend/src/components/skills/SkillProfilePanel.jsx @@ -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 ( -

  • +
  • {skill.skill_name} - {skill.share_percent}% + + {metricLabel(skill, hasReferenceScale)} +
  • ) } +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 ( +
    + {groups.map((mc) => ( +
    +

    {mc.main_category_name}

    +
      + {(mc.categories || []).map((cat) => ( +
    • + {cat.category_name} + +
    • + ))} +
    +
    + ))} +
    + ) +} + +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 (
    - - - {profile.by_category?.length > 1 ? ( -
    - Nach Kategorie -
    - {profile.by_category.slice(0, 6).map((c) => ( - - {c.category} {c.share_percent}% - - ))} -
    -
    - ) : null} + )} @@ -135,7 +205,7 @@ export default function SkillProfilePanel({