diff --git a/.claude/docs/technical/SKILL_SCORING_SPEC.md b/.claude/docs/technical/SKILL_SCORING_SPEC.md
new file mode 100644
index 0000000..7a138ba
--- /dev/null
+++ b/.claude/docs/technical/SKILL_SCORING_SPEC.md
@@ -0,0 +1,65 @@
+# Gewichtetes Fähigkeiten-Scoring (Phase 3)
+
+**Stand:** 2026-05-20
+**Status:** Variante A (regelbasiert) umgesetzt — v1.0
+**Modul:** `backend/skill_scoring.py`, Router `skill_profiles`
+
+## Ziel
+
+Trainer wählen **Schwerpunkt-Fähigkeiten** und erhalten Vorschläge für **Rahmenprogramme**, **Trainingsmodule** und **Regressionspfade** (Progressionsgraphen), deren Übungen diese Fähigkeiten stark abdecken.
+
+## Datenquellen
+
+| Artefakt | Übungen aus |
+|----------|-------------|
+| Rahmenprogramm (gesamt) | Alle Blueprint-`training_units` der Slots → `training_unit_section_items` |
+| Rahmenprogramm (pro Slot) | Blueprint einer Session |
+| Trainingsmodul | `training_module_items` (nur `item_type = exercise`) |
+| Progressionsgraph | `from_exercise_id` + `to_exercise_id` je Kante (Vorkommen zählt) |
+
+Fähigkeiten je Übung: `exercise_skills` → `skills` (nur `status = active`).
+
+## Gewichtungsformel (v1.0)
+
+Pro **Übungsvorkommen** (eine Zeile im Ablauf / Modul / Kanten-Endpunkt):
+
+1. **Basis-Minuten** = `planned_duration_min` der Position, sonst Default (Einheit/Modul: 8 Min, Graph: 10 Min).
+2. Pro verknüpfte Fähigkeit der Übung:
+ - `Beitrag = Basis-Minuten × Anzahl Vorkommen dieser Übung × Link-Faktor`
+ - Link-Faktor = 1.0 × (1.5 wenn `is_primary`) × Intensität (`niedrig` 0.85, `mittel` 1.0, `hoch` 1.2) × Entwicklungsbeitrag (`low` 0.9, `medium` 1.0, `high` 1.15)
+
+Aggregation:
+
+- Summe pro `skill_id` → `weight`
+- `share_percent` = Anteil an `total_weight` (100 % über alle Skills im Profil)
+
+## API
+
+| Methode | Pfad | Beschreibung |
+|---------|------|--------------|
+| GET | `/api/training-framework-programs/{id}/skill-profile` | `overall` + `slots[]` mit je `profile` |
+| GET | `/api/training-modules/{id}/skill-profile` | `overall` |
+| GET | `/api/exercise-progression-graphs/{id}/skill-profile` | `overall` |
+| GET | `/api/skill-discovery/suggestions?skill_ids=1,2,3` | Ranking sichtbarer Artefakte; Query `types`, `limit` |
+
+Zugriff: `get_tenant_context` + gleiche Sichtbarkeit wie Parent-Artefakt (`library_content_visibility_sql`).
+
+## UI
+
+- **Rahmenprogramm bearbeiten:** Panel „Fähigkeiten-Schwerpunkte“, inkl. Aufklapp pro Session
+- **Trainingsmodul bearbeiten:** Panel „Fähigkeiten im Modul“
+- **Progressionsgraph:** Panel „Fähigkeiten entlang des Pfads“
+- **Fähigkeiten-Seite → Planungs-Vorschläge:** Multi-Select + Bibliothekssuche
+
+Profil wird nach **Speichern** neu geladen (`skillProfileTick`).
+
+## Grenzen / später
+
+- Kein Cache in DB (`skill_profile_json`) — on-the-fly; bei Performance >50 Artefakte serverseitiger Index
+- Entwicklungsziele am Rahmenkopf bleiben Freitext (kein Scoring)
+- KI-Zusammenfassung (Variante B Roadmap) nicht Teil von v1.0
+- Trainings**einheiten** (Kalender) optional als nächste Erweiterung
+
+## Tests
+
+- `backend/tests/test_skill_scoring.py` — Multiplikator, Aggregation, Match-Score
diff --git a/.claude/docs/working/FRAMEWORK_PROGRAM_FILTER_AND_SKILLS_ROADMAP.md b/.claude/docs/working/FRAMEWORK_PROGRAM_FILTER_AND_SKILLS_ROADMAP.md
index d9d8663..312f74a 100644
--- a/.claude/docs/working/FRAMEWORK_PROGRAM_FILTER_AND_SKILLS_ROADMAP.md
+++ b/.claude/docs/working/FRAMEWORK_PROGRAM_FILTER_AND_SKILLS_ROADMAP.md
@@ -1,7 +1,7 @@
# Rahmenprogramm: Filter, Dauer, Fähigkeiten-Schwerpunkte (Roadmap)
**Stand:** 2026-05-20
-**Status:** Phase 1 umgesetzt (Listen + Import-Filter); Phase 2–3 offen
+**Status:** Phase 1 umgesetzt; Phase 3 v1.0 umgesetzt (regelbasiert); Phase 2 teilweise offen
## Phase 1 (umgesetzt)
@@ -29,23 +29,16 @@
**API-Erweiterung (optional):** `GET /api/training-framework-programs?focus_area_id=&training_type_id=&duration_min=` serverseitig — sinnvoll ab >50 Rahmen in der Bibliothek.
-## Phase 3 — Fähigkeiten aus Übungen (Schwerpunkte dynamisch)
+## Phase 3 — Fähigkeiten aus Übungen (umgesetzt v1.0)
-### Ziel
+**Spec:** `.claude/docs/technical/SKILL_SCORING_SPEC.md`
-Aus allen Übungen in allen Slots eines Rahmenprogramms die verknüpften **Fähigkeiten** (`exercise_skills` → `skills`, ggf. Fokusbereich der Fähigkeit) aggregieren, gewichten und als **Vorschlags-Schwerpunkte** oder Metadaten am Rahmen anzeigen (nicht zwingend automatisch in den Kopf schreiben).
+- Gewichtetes Profil: Rahmenprogramm (gesamt + pro Slot), Trainingsmodul, Progressionsgraph
+- `GET /api/skill-discovery/suggestions?skill_ids=…` für Bibliotheks-Vorschläge
+- UI: Profil-Panels in Editoren + Tab „Planungs-Vorschläge“ auf der Fähigkeiten-Seite
+- **Kein** automatisches Überschreiben der Stammdaten-Fokusbereiche
-### Variante A — Regelbasiert (ohne KI)
-
-1. Pro Blueprint-Unit alle `exercise_id` aus `training_unit_section_items` sammeln.
-2. Join `exercise_skills` (optional Gewicht: `planned_duration_min` der Zeile, Anzahl Vorkommen, Primär-Fähigkeit).
-3. Top-N Fähigkeiten / Fokusbereiche nach Summe oder Anteil an Gesamtminuten.
-4. Ergebnis cachen in `training_framework_programs.skill_profile_json` (Migration) oder nur on-the-fly bei GET Detail.
-
-**Vorteil:** reproduzierbar, offline, Governance-konform.
-**Aufwand:** ca. 1–2 Tage Backend + kleine UI-Karte „Fähigkeiten-Profil (aus Übungen)“.
-
-### Variante B — KI-Zusammenfassung (OpenRouter, optional)
+### Variante B — KI-Zusammenfassung (OpenRouter, optional, offen)
1. Input: Titel Rahmen, Ziele (Text), Liste Übungstitel + Dauer + vorhandene Skill-Namen.
2. Prompt: strukturiertes JSON (`suggested_focus_areas[]`, `skill_emphasis[]`, `rationale_de`).
diff --git a/backend/main.py b/backend/main.py
index 4fa97c7..d15db0b 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -193,7 +193,7 @@ def read_root():
return out
# Register routers
-from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports
+from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports
app.include_router(auth.router)
app.include_router(profiles.router)
@@ -208,6 +208,7 @@ app.include_router(media_assets.router)
app.include_router(media_assets.admin_rights_router)
app.include_router(media_assets.admin_legal_hold_router)
app.include_router(skills.router)
+app.include_router(skill_profiles.router)
app.include_router(training_planning.router)
app.include_router(dashboard.router)
app.include_router(training_modules.router)
diff --git a/backend/routers/skill_profiles.py b/backend/routers/skill_profiles.py
new file mode 100644
index 0000000..49474cd
--- /dev/null
+++ b/backend/routers/skill_profiles.py
@@ -0,0 +1,354 @@
+"""
+Fähigkeiten-Profile und Vorschläge (Phase 3) für Planungsartefakte.
+
+GET …/skill-profile — gewichtetes Profil aus verknüpften Übungen.
+GET /api/skill-discovery/suggestions — Rahmenprogramme, Module, Progressionsgraphen nach Fähigkeiten.
+"""
+from typing import Any, Dict, List, Optional
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+
+from db import get_db, get_cursor, r2d
+from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
+
+from skill_scoring import (
+ GRAPH_DEFAULT_ITEM_MINUTES,
+ ExerciseOccurrence,
+ collect_module_exercise_occurrences,
+ collect_progression_graph_exercise_occurrences,
+ collect_unit_exercise_occurrences,
+ compute_skill_profile,
+ match_score_for_skill_ids,
+ profile_for_occurrences,
+)
+
+from routers.training_framework_programs import _framework_access
+from routers.training_modules import _module_access
+from routers.exercise_progression_graphs import _require_graph_read
+
+router = APIRouter(prefix="/api", tags=["skill_profiles"])
+
+
+def _parse_skill_ids_param(raw: Optional[str]) -> List[int]:
+ if not raw or not str(raw).strip():
+ return []
+ out: List[int] = []
+ for part in str(raw).split(","):
+ part = part.strip()
+ if not part:
+ continue
+ try:
+ n = int(part)
+ except ValueError:
+ raise HTTPException(status_code=400, detail="skill_ids: ungültige ID") from None
+ if n > 0 and n not in out:
+ out.append(n)
+ return out
+
+
+@router.get("/training-framework-programs/{framework_id}/skill-profile")
+def framework_program_skill_profile(
+ framework_id: int,
+ tenant: TenantContext = Depends(get_tenant_context),
+):
+ profile_id = tenant.profile_id
+ role = tenant.global_role
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ row = _framework_access(cur, framework_id, profile_id, role)
+ cur.execute(
+ """
+ SELECT s.id, s.sort_order, s.title,
+ tu.id AS blueprint_unit_id
+ FROM training_framework_slots s
+ LEFT JOIN training_units tu ON tu.framework_slot_id = s.id
+ WHERE s.framework_program_id = %s
+ ORDER BY s.sort_order
+ """,
+ (framework_id,),
+ )
+ slots_raw = [r2d(r) for r in cur.fetchall()]
+
+ all_occurrences: List[ExerciseOccurrence] = []
+ slot_profiles: List[Dict[str, Any]] = []
+
+ for slot in slots_raw:
+ uid = slot.get("blueprint_unit_id")
+ slot_occ: List[ExerciseOccurrence] = []
+ slot_label = (slot.get("title") or "").strip() or f"Session {(slot.get('sort_order') or 0) + 1}"
+ if uid:
+ raw_occ = collect_unit_exercise_occurrences(cur, int(uid))
+ slot_occ = [
+ ExerciseOccurrence(
+ exercise_id=o.exercise_id,
+ planned_duration_min=o.planned_duration_min,
+ context_label=slot_label,
+ )
+ for o in raw_occ
+ ]
+ all_occurrences.extend(slot_occ)
+ else:
+ slot_occ = []
+ slot_profile = profile_for_occurrences(cur, slot_occ) if slot_occ else _empty_profile()
+ slot_profiles.append(
+ {
+ "slot_id": slot["id"],
+ "slot_title": slot.get("title"),
+ "sort_order": slot.get("sort_order"),
+ "blueprint_training_unit_id": uid,
+ "exercise_occurrence_count": len(slot_occ),
+ "profile": slot_profile,
+ }
+ )
+
+ overall = profile_for_occurrences(cur, all_occurrences) if all_occurrences else _empty_profile()
+
+ return {
+ "artifact_type": "framework_program",
+ "artifact_id": framework_id,
+ "artifact_title": row.get("title"),
+ "overall": overall,
+ "slots": slot_profiles,
+ }
+
+
+@router.get("/training-modules/{module_id}/skill-profile")
+def training_module_skill_profile(
+ module_id: int,
+ tenant: TenantContext = Depends(get_tenant_context),
+):
+ profile_id = tenant.profile_id
+ role = tenant.global_role
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ row = _module_access(cur, module_id, profile_id, role)
+ occurrences = collect_module_exercise_occurrences(cur, module_id)
+ overall = profile_for_occurrences(cur, occurrences) if occurrences else _empty_profile()
+ return {
+ "artifact_type": "training_module",
+ "artifact_id": module_id,
+ "artifact_title": row.get("title"),
+ "overall": overall,
+ }
+
+
+@router.get("/exercise-progression-graphs/{graph_id}/skill-profile")
+def progression_graph_skill_profile(
+ graph_id: int,
+ tenant: TenantContext = Depends(get_tenant_context),
+):
+ profile_id = tenant.profile_id
+ role = tenant.global_role
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ row = _require_graph_read(cur, graph_id, profile_id, role)
+ 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()
+ return {
+ "artifact_type": "progression_graph",
+ "artifact_id": graph_id,
+ "artifact_title": row.get("name"),
+ "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"),
+ types: Optional[str] = Query(
+ default="framework_program,training_module,progression_graph",
+ description="Artefakttypen, komma-getrennt",
+ ),
+ limit: int = Query(default=20, ge=1, le=50),
+ tenant: TenantContext = Depends(get_tenant_context),
+):
+ """
+ Findet Bibliotheksartefakte, deren Übungs-Fähigkeiten-Profil die gewünschten Fähigkeiten stark abdeckt.
+ """
+ wanted = _parse_skill_ids_param(skill_ids)
+ if not wanted:
+ raise HTTPException(status_code=400, detail="skill_ids ist Pflicht (mindestens eine ID)")
+
+ type_set = {t.strip() for t in (types or "").split(",") if t.strip()}
+ profile_id = tenant.profile_id
+ role = tenant.global_role
+ results: List[Dict[str, Any]] = []
+
+ with get_db() as conn:
+ cur = get_cursor(conn)
+
+ if "framework_program" in type_set:
+ vis_clause, vis_params = library_content_visibility_sql(
+ alias="fp",
+ profile_id=profile_id,
+ role=role,
+ effective_club_id=tenant.effective_club_id,
+ )
+ cur.execute(
+ f"""
+ SELECT fp.id, fp.title
+ FROM training_framework_programs fp
+ WHERE ({vis_clause})
+ ORDER BY fp.updated_at DESC NULLS LAST
+ LIMIT 80
+ """,
+ vis_params,
+ )
+ for fp_row in cur.fetchall():
+ fid = int(fp_row["id"])
+ try:
+ _framework_access(cur, fid, profile_id, role)
+ except HTTPException:
+ continue
+ 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)
+ match = match_score_for_skill_ids(prof, wanted)
+ if match["match_weight"] <= 0:
+ continue
+ results.append(
+ {
+ "artifact_type": "framework_program",
+ "artifact_id": fid,
+ "artifact_title": fp_row["title"],
+ "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]
+ ],
+ },
+ }
+ )
+
+ if "training_module" in type_set:
+ vis_clause, vis_params = library_content_visibility_sql(
+ alias="m",
+ profile_id=profile_id,
+ role=role,
+ effective_club_id=tenant.effective_club_id,
+ )
+ cur.execute(
+ f"""
+ SELECT m.id, m.title
+ FROM training_modules m
+ WHERE ({vis_clause})
+ ORDER BY m.updated_at DESC NULLS LAST
+ LIMIT 80
+ """,
+ vis_params,
+ )
+ for m_row in cur.fetchall():
+ mid = int(m_row["id"])
+ try:
+ _module_access(cur, mid, profile_id, role)
+ except HTTPException:
+ continue
+ occ = collect_module_exercise_occurrences(cur, mid)
+ if not occ:
+ continue
+ prof = profile_for_occurrences(cur, occ)
+ match = match_score_for_skill_ids(prof, wanted)
+ if match["match_weight"] <= 0:
+ continue
+ results.append(
+ {
+ "artifact_type": "training_module",
+ "artifact_id": mid,
+ "artifact_title": m_row["title"],
+ "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]
+ ],
+ },
+ }
+ )
+
+ if "progression_graph" in type_set:
+ vis_clause, vis_params = library_content_visibility_sql(
+ alias="g",
+ profile_id=profile_id,
+ role=role,
+ effective_club_id=tenant.effective_club_id,
+ )
+ cur.execute(
+ f"""
+ SELECT g.id, g.name
+ FROM exercise_progression_graphs g
+ WHERE ({vis_clause})
+ ORDER BY g.updated_at DESC NULLS LAST
+ LIMIT 80
+ """,
+ vis_params,
+ )
+ for g_row in cur.fetchall():
+ gid = int(g_row["id"])
+ try:
+ _require_graph_read(cur, gid, profile_id, role)
+ except HTTPException:
+ continue
+ 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
+ )
+ match = match_score_for_skill_ids(prof, wanted)
+ if match["match_weight"] <= 0:
+ continue
+ results.append(
+ {
+ "artifact_type": "progression_graph",
+ "artifact_id": gid,
+ "artifact_title": g_row["name"],
+ "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]
+ ],
+ },
+ }
+ )
+
+ results.sort(
+ key=lambda x: (
+ -float(x.get("match", {}).get("match_weight") or 0),
+ -(float(x.get("match", {}).get("match_percent") or 0)),
+ )
+ )
+ return {
+ "skill_ids": wanted,
+ "types": sorted(type_set),
+ "suggestions": results[:limit],
+ }
+
+
+def _empty_profile() -> Dict[str, Any]:
+ return compute_skill_profile([], {})
diff --git a/backend/skill_scoring.py b/backend/skill_scoring.py
new file mode 100644
index 0000000..fe18c34
--- /dev/null
+++ b/backend/skill_scoring.py
@@ -0,0 +1,345 @@
+"""
+Gewichtetes Fähigkeiten-Scoring aus Übungsvorkommen (Phase 3, regelbasiert).
+
+Aggregiert exercise_skills über alle Übungen eines Artefakts (Rahmenprogramm, Modul,
+Progressionsgraph) mit Gewichten aus geplanter Dauer, Vorkommen, Primär-Fähigkeit,
+Intensität und Entwicklungsbeitrag.
+"""
+from __future__ import annotations
+
+from collections import defaultdict
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
+
+
+DEFAULT_ITEM_MINUTES = 8
+GRAPH_DEFAULT_ITEM_MINUTES = 10
+
+_INTENSITY_MULT = {
+ "niedrig": 0.85,
+ "low": 0.85,
+ "mittel": 1.0,
+ "medium": 1.0,
+ "hoch": 1.2,
+ "high": 1.2,
+}
+
+_DEV_CONTRIB_MULT = {
+ "low": 0.9,
+ "niedrig": 0.9,
+ "medium": 1.0,
+ "mittel": 1.0,
+ "high": 1.15,
+ "hoch": 1.15,
+}
+
+
+@dataclass(frozen=True)
+class ExerciseOccurrence:
+ exercise_id: int
+ planned_duration_min: Optional[int] = None
+ """Optional label for UI (e.g. slot title)."""
+ context_label: Optional[str] = None
+
+
+def _item_base_minutes(planned: Optional[int], default: int = DEFAULT_ITEM_MINUTES) -> float:
+ if planned is not None:
+ try:
+ m = int(planned)
+ if m > 0:
+ return float(m)
+ except (TypeError, ValueError):
+ pass
+ return float(default)
+
+
+def _skill_link_multiplier(
+ *,
+ is_primary: bool = False,
+ intensity: Optional[str] = None,
+ development_contribution: Optional[str] = None,
+) -> float:
+ mult = 1.0
+ if is_primary:
+ mult *= 1.5
+ if intensity:
+ key = str(intensity).strip().lower()
+ mult *= _INTENSITY_MULT.get(key, 1.0)
+ if development_contribution:
+ key = str(development_contribution).strip().lower()
+ mult *= _DEV_CONTRIB_MULT.get(key, 1.0)
+ return mult
+
+
+def _round2(val: float) -> float:
+ return round(val, 2)
+
+
+def compute_skill_profile(
+ occurrences: Sequence[ExerciseOccurrence],
+ skill_rows_by_exercise: Dict[int, List[Dict[str, Any]]],
+ *,
+ default_item_minutes: int = DEFAULT_ITEM_MINUTES,
+) -> Dict[str, Any]:
+ """
+ Erzeugt ein normalisiertes Fähigkeiten-Profil aus Übungsvorkommen und exercise_skills.
+ """
+ exercise_meta: Dict[int, Dict[str, Any]] = defaultdict(
+ lambda: {"occurrence_count": 0, "minutes": 0.0, "context_labels": []}
+ )
+ total_occurrences = 0
+ for occ in occurrences or []:
+ eid = int(occ.exercise_id)
+ mins = _item_base_minutes(occ.planned_duration_min, default_item_minutes)
+ exercise_meta[eid]["occurrence_count"] += 1
+ exercise_meta[eid]["minutes"] += mins
+ total_occurrences += 1
+ if occ.context_label and occ.context_label not in exercise_meta[eid]["context_labels"]:
+ exercise_meta[eid]["context_labels"].append(occ.context_label)
+
+ skill_acc: Dict[int, Dict[str, Any]] = {}
+ total_weight = 0.0
+ exercises_with_skills: set[int] = set()
+
+ for eid, meta in exercise_meta.items():
+ links = skill_rows_by_exercise.get(eid) or []
+ if not links:
+ continue
+ exercises_with_skills.add(eid)
+ occ_count = meta["occurrence_count"]
+ minutes_per_occ = meta["minutes"] / occ_count if occ_count else float(default_item_minutes)
+
+ for link in links:
+ sid = link.get("skill_id")
+ if sid is None:
+ continue
+ sid = int(sid)
+ link_mult = _skill_link_multiplier(
+ is_primary=bool(link.get("is_primary")),
+ intensity=link.get("intensity"),
+ development_contribution=link.get("development_contribution"),
+ )
+ contribution = minutes_per_occ * occ_count * link_mult
+ if contribution <= 0:
+ continue
+
+ if sid not in skill_acc:
+ skill_acc[sid] = {
+ "skill_id": sid,
+ "skill_name": link.get("skill_name") or f"Fähigkeit #{sid}",
+ "category": link.get("category"),
+ "focus_areas": link.get("focus_areas"),
+ "weight": 0.0,
+ "occurrence_count": 0,
+ "primary_link_count": 0,
+ "exercises": {},
+ }
+ acc = skill_acc[sid]
+ acc["weight"] += contribution
+ acc["occurrence_count"] += occ_count
+ if link.get("is_primary"):
+ acc["primary_link_count"] += occ_count
+ ex_key = str(eid)
+ if ex_key not in acc["exercises"]:
+ acc["exercises"][ex_key] = {
+ "exercise_id": eid,
+ "title": link.get("exercise_title") or f"Übung #{eid}",
+ "weight": 0.0,
+ "occurrence_count": occ_count,
+ }
+ acc["exercises"][ex_key]["weight"] += contribution
+ total_weight += contribution
+
+ skills_out: List[Dict[str, Any]] = []
+ for sid, acc in skill_acc.items():
+ share = (acc["weight"] / total_weight * 100.0) if total_weight > 0 else 0.0
+ ex_list = sorted(
+ acc["exercises"].values(),
+ key=lambda x: (-x["weight"], x.get("title") or ""),
+ )[:8]
+ for ex in ex_list:
+ ex["weight"] = _round2(ex["weight"])
+ if total_weight > 0:
+ ex["share_percent"] = _round2(ex["weight"] / total_weight * 100.0)
+ else:
+ ex["share_percent"] = 0.0
+ skills_out.append(
+ {
+ "skill_id": sid,
+ "skill_name": acc["skill_name"],
+ "category": acc.get("category"),
+ "focus_areas": acc.get("focus_areas"),
+ "weight": _round2(acc["weight"]),
+ "share_percent": _round2(share),
+ "occurrence_count": acc["occurrence_count"],
+ "primary_link_count": acc["primary_link_count"],
+ "top_exercises": ex_list,
+ }
+ )
+ 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)}
+ )
+
+ unique_exercises = len(exercise_meta)
+ return {
+ "computed_at": datetime.now(timezone.utc).isoformat(),
+ "scoring_version": "1.0",
+ "total_weight": _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,
+ }
+
+
+def fetch_exercise_skills_bulk(
+ cur, exercise_ids: Iterable[int]
+) -> Dict[int, List[Dict[str, Any]]]:
+ ids = sorted({int(x) for x in exercise_ids if x})
+ if not ids:
+ return {}
+ ph = ",".join(["%s"] * len(ids))
+ cur.execute(
+ 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,
+ e.title AS exercise_title
+ FROM exercise_skills es
+ JOIN skills s ON s.id = es.skill_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)
+ ORDER BY es.exercise_id, es.is_primary DESC, es.skill_id
+ """,
+ ids,
+ )
+ out: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
+ for row in cur.fetchall():
+ d = dict(row)
+ eid = int(d["exercise_id"])
+ fa = d.get("focus_areas")
+ if fa is not None and not isinstance(fa, list):
+ try:
+ import json
+
+ fa = json.loads(fa) if isinstance(fa, str) else fa
+ except Exception:
+ fa = []
+ d["focus_areas"] = fa if isinstance(fa, list) else []
+ out[eid].append(d)
+ return dict(out)
+
+
+def collect_unit_exercise_occurrences(cur, unit_id: int) -> List[ExerciseOccurrence]:
+ cur.execute(
+ """
+ SELECT tusi.exercise_id, tusi.planned_duration_min
+ FROM training_unit_section_items tusi
+ INNER JOIN training_unit_sections tus ON tus.id = tusi.section_id
+ WHERE tus.training_unit_id = %s
+ AND tusi.item_type = 'exercise'
+ AND tusi.exercise_id IS NOT NULL
+ ORDER BY tus.order_index, tusi.order_index
+ """,
+ (int(unit_id),),
+ )
+ return [
+ ExerciseOccurrence(
+ exercise_id=int(r["exercise_id"]),
+ planned_duration_min=r.get("planned_duration_min"),
+ )
+ for r in cur.fetchall()
+ ]
+
+
+def collect_module_exercise_occurrences(cur, module_id: int) -> List[ExerciseOccurrence]:
+ cur.execute(
+ """
+ SELECT exercise_id, planned_duration_min
+ FROM training_module_items
+ WHERE module_id = %s
+ AND item_type = 'exercise'
+ AND exercise_id IS NOT NULL
+ ORDER BY order_index
+ """,
+ (int(module_id),),
+ )
+ return [
+ ExerciseOccurrence(
+ exercise_id=int(r["exercise_id"]),
+ planned_duration_min=r.get("planned_duration_min"),
+ )
+ for r in cur.fetchall()
+ ]
+
+
+def collect_progression_graph_exercise_occurrences(cur, graph_id: int) -> List[ExerciseOccurrence]:
+ """Jedes Vorkommen als from- oder to-Endpunkt einer Kante zählt (ohne Dauer → Default)."""
+ cur.execute(
+ """
+ SELECT from_exercise_id AS exercise_id FROM exercise_progression_edges WHERE graph_id = %s
+ UNION ALL
+ SELECT to_exercise_id AS exercise_id FROM exercise_progression_edges WHERE graph_id = %s
+ """,
+ (int(graph_id), int(graph_id)),
+ )
+ return [
+ ExerciseOccurrence(
+ exercise_id=int(r["exercise_id"]),
+ planned_duration_min=None,
+ context_label=None,
+ )
+ for r in cur.fetchall()
+ ]
+
+
+def profile_for_occurrences(
+ cur,
+ occurrences: Sequence[ExerciseOccurrence],
+ *,
+ default_item_minutes: int = DEFAULT_ITEM_MINUTES,
+) -> 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
+ )
+
+
+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_percent": 0.0,
+ "matched_skill_ids": [],
+ "matched_skills": [],
+ }
+ matched = []
+ match_weight = 0.0
+ total = float(profile.get("total_weight") or 0)
+ for sk in profile.get("skills") or []:
+ sid = int(sk["skill_id"])
+ 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
+ return {
+ "match_weight": _round2(match_weight),
+ "match_percent": _round2(match_percent),
+ "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
new file mode 100644
index 0000000..e7640ab
--- /dev/null
+++ b/backend/tests/test_skill_scoring.py
@@ -0,0 +1,60 @@
+"""Unit-Tests für gewichtetes Fähigkeiten-Scoring (Phase 3)."""
+from skill_scoring import (
+ ExerciseOccurrence,
+ compute_skill_profile,
+ match_score_for_skill_ids,
+ _skill_link_multiplier,
+)
+
+
+def test_skill_link_multiplier_primary_and_intensity():
+ assert _skill_link_multiplier(is_primary=True, intensity="hoch") == 1.5 * 1.2
+ assert _skill_link_multiplier(is_primary=False, intensity="niedrig") == 0.85
+
+
+def test_compute_skill_profile_aggregates_weights():
+ occurrences = [
+ ExerciseOccurrence(exercise_id=1, planned_duration_min=60),
+ ExerciseOccurrence(exercise_id=1, planned_duration_min=30),
+ ]
+ skills_map = {
+ 1: [
+ {
+ "skill_id": 10,
+ "skill_name": "Distanz",
+ "category": "kihon",
+ "is_primary": True,
+ "intensity": "hoch",
+ "exercise_title": "Übung A",
+ },
+ {
+ "skill_id": 11,
+ "skill_name": "Balance",
+ "category": "kihon",
+ "is_primary": False,
+ "intensity": "mittel",
+ "exercise_title": "Übung A",
+ },
+ ],
+ }
+ profile = compute_skill_profile(occurrences, skills_map)
+ 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
+
+
+def test_match_score_for_skill_ids():
+ profile = {
+ "total_weight": 100.0,
+ "skills": [
+ {"skill_id": 1, "skill_name": "A", "weight": 40.0},
+ {"skill_id": 2, "skill_name": "B", "weight": 60.0},
+ ],
+ }
+ m = match_score_for_skill_ids(profile, [1])
+ assert m["match_weight"] == 40.0
+ assert m["match_percent"] == 40.0
+ assert m["matched_skill_ids"] == [1]
diff --git a/backend/version.py b/backend/version.py
index 529fd57..2752721 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.150"
+APP_VERSION = "0.8.151"
BUILD_DATE = "2026-05-20"
DB_SCHEMA_VERSION = "20260520066"
@@ -20,6 +20,7 @@ MODULE_VERSIONS = {
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
"groups": "0.1.0",
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
+ "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0",
"exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
@@ -36,6 +37,15 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.151",
+ "date": "2026-05-20",
+ "changes": [
+ "Phase 3 Fähigkeiten-Scoring: skill_scoring.py (Dauer × Vorkommen × Primär/Intensität/Beitrag)",
+ "GET skill-profile für Rahmenprogramm (gesamt + pro Slot), Trainingsmodul, Progressionsgraph",
+ "GET /api/skill-discovery/suggestions nach skill_ids",
+ ],
+ },
{
"version": "0.8.149",
"date": "2026-05-19",
diff --git a/frontend/src/api/skillProfiles.js b/frontend/src/api/skillProfiles.js
new file mode 100644
index 0000000..7c20c3c
--- /dev/null
+++ b/frontend/src/api/skillProfiles.js
@@ -0,0 +1,26 @@
+import { request } from './client.js'
+
+export async function getFrameworkProgramSkillProfile(frameworkId) {
+ return request(`/api/training-framework-programs/${frameworkId}/skill-profile`)
+}
+
+export async function getTrainingModuleSkillProfile(moduleId) {
+ return request(`/api/training-modules/${moduleId}/skill-profile`)
+}
+
+export async function getProgressionGraphSkillProfile(graphId) {
+ return request(`/api/exercise-progression-graphs/${graphId}/skill-profile`)
+}
+
+/**
+ * @param {number[]} skillIds
+ * @param {{ types?: string, limit?: number }} opts
+ */
+export async function getSkillDiscoverySuggestions(skillIds, opts = {}) {
+ const ids = (skillIds || []).filter((x) => x != null && Number(x) > 0).join(',')
+ if (!ids) return { skill_ids: [], suggestions: [] }
+ const params = new URLSearchParams({ skill_ids: ids })
+ if (opts.types) params.set('types', opts.types)
+ if (opts.limit != null) params.set('limit', String(opts.limit))
+ return request(`/api/skill-discovery/suggestions?${params.toString()}`)
+}
diff --git a/frontend/src/app.css b/frontend/src/app.css
index 958b06a..9e693c5 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -2475,6 +2475,282 @@ html.modal-scroll-locked .app-main {
}
}
+/* Phase 3: Fähigkeiten-Profil & Planungs-Vorschläge */
+.skill-profile {
+ margin-bottom: 1rem;
+ padding: 0;
+ overflow: hidden;
+}
+.skill-profile--loading,
+.skill-profile--error {
+ padding: 1rem 1.15rem;
+}
+.skill-profile__status {
+ margin: 0;
+ color: var(--text2);
+ font-size: 0.9rem;
+}
+.skill-profile__toggle {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px 12px;
+ width: 100%;
+ padding: 0.85rem 1.1rem;
+ border: none;
+ background: transparent;
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+.skill-profile__toggle-title {
+ font-weight: 700;
+ color: var(--text1);
+}
+.skill-profile__toggle-badge {
+ font-size: 0.78rem;
+ color: var(--accent-dark);
+ background: var(--accent-light);
+ padding: 3px 8px;
+ border-radius: 999px;
+}
+.skill-profile__toggle-icon {
+ margin-left: auto;
+ color: var(--text3);
+}
+.skill-profile__body {
+ padding: 0 1.1rem 1rem;
+ border-top: 1px solid var(--border);
+}
+.skill-profile__hint {
+ margin: 0.65rem 0 0.75rem;
+}
+.skill-profile__empty {
+ margin: 0;
+ color: var(--text2);
+ font-size: 0.88rem;
+ line-height: 1.5;
+}
+.skill-profile__stats {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px 18px;
+ font-size: 0.82rem;
+ color: var(--text2);
+ margin-bottom: 0.75rem;
+}
+.skill-profile__list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+.skill-profile__list--nested {
+ margin: 8px 0 0;
+ padding-left: 8px;
+ border-left: 2px solid var(--border);
+}
+.skill-profile__row-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 4px;
+}
+.skill-profile__name {
+ font-weight: 600;
+ font-size: 0.88rem;
+ color: var(--text1);
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.skill-profile__pct {
+ font-size: 0.82rem;
+ font-weight: 700;
+ color: var(--accent-dark);
+ flex-shrink: 0;
+}
+.skill-profile__bar-track {
+ height: 8px;
+ border-radius: 4px;
+ background: var(--surface2);
+ overflow: hidden;
+}
+.skill-profile__bar-fill {
+ height: 100%;
+ border-radius: 4px;
+ background: linear-gradient(90deg, var(--accent) 0%, var(--accent-dark) 100%);
+ min-width: 2px;
+}
+.skill-profile__meta-hint {
+ display: block;
+ font-size: 0.72rem;
+ color: var(--text3);
+ margin-top: 3px;
+}
+.skill-profile__categories {
+ margin-top: 0.85rem;
+}
+.skill-profile__categories-label,
+.skill-profile__slots-label {
+ display: block;
+ font-size: 0.72rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text3);
+ margin-bottom: 6px;
+}
+.skill-profile__category-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+.skill-profile__category-chip {
+ font-size: 0.78rem;
+ padding: 3px 8px;
+ border-radius: 6px;
+ background: var(--surface2);
+ color: var(--text2);
+}
+.skill-profile__slots {
+ margin-top: 1rem;
+ padding-top: 0.85rem;
+ border-top: 1px solid var(--border);
+}
+.skill-profile__slot-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+.skill-profile__slot-btn {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: 6px;
+ width: 100%;
+ padding: 8px 0;
+ border: none;
+ background: transparent;
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+ color: var(--text1);
+}
+.skill-profile__slot-top {
+ font-size: 0.82rem;
+ color: var(--accent-dark);
+ font-weight: 600;
+}
+.skill-profile__slot-top--muted {
+ color: var(--text3);
+ font-weight: 500;
+}
+
+.skill-discovery {
+ padding: 1.15rem 1.25rem;
+ max-width: 52rem;
+}
+.skill-discovery__title {
+ margin: 0 0 0.35rem;
+ font-size: 1.1rem;
+}
+.skill-discovery__lead {
+ margin: 0 0 1rem;
+ max-width: 40rem;
+}
+.skill-discovery__pick-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
+ gap: 8px;
+ max-height: 220px;
+ overflow-y: auto;
+ padding: 8px;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ background: var(--surface2);
+ margin-bottom: 1rem;
+}
+.skill-discovery__pick {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 4px 8px;
+ font-size: 0.85rem;
+ cursor: pointer;
+ padding: 4px 6px;
+ border-radius: 6px;
+}
+.skill-discovery__pick:hover {
+ background: var(--surface);
+}
+.skill-discovery__pick-name {
+ font-weight: 600;
+ color: var(--text1);
+}
+.skill-discovery__pick-cat {
+ font-size: 0.72rem;
+ color: var(--text3);
+}
+.skill-discovery__actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 0.75rem;
+}
+.skill-discovery__error {
+ color: var(--danger);
+ margin: 0 0 0.75rem;
+}
+.skill-discovery__results {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+.skill-discovery__result {
+ padding: 0.85rem 1rem;
+ margin-bottom: 0;
+}
+.skill-discovery__result-head {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: 6px;
+ margin-bottom: 4px;
+}
+.skill-discovery__result-type {
+ font-size: 0.72rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ color: var(--text3);
+}
+.skill-discovery__result-match {
+ font-size: 0.82rem;
+ font-weight: 700;
+ color: var(--accent-dark);
+}
+.skill-discovery__result-title {
+ display: block;
+ margin-bottom: 4px;
+}
+.skill-discovery__result-skills {
+ margin: 0 0 8px;
+ font-size: 0.85rem;
+ color: var(--text2);
+}
+.skill-discovery__result-link {
+ text-decoration: none;
+}
+.skill-discovery__no-hit {
+ margin: 0;
+}
+
/* Rahmenprogramm-Editor (Vollseiten-Formular mit Action-Dock) */
.page-form-editor__body .framework-edit {
min-width: 0;
diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx
index dfd8661..983bd08 100644
--- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx
+++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx
@@ -5,6 +5,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
+import SkillProfilePanel from './skills/SkillProfilePanel'
import { useAuth } from '../context/AuthContext'
import { getTenantClubDependencyKey } from '../utils/activeClub'
import ExercisePickerModal from './ExercisePickerModal'
@@ -136,6 +137,9 @@ export default function ExerciseProgressionGraphPanel({
const [editingEdgeNotes, setEditingEdgeNotes] = useState(null)
const [notesDraft, setNotesDraft] = useState('')
const [uiTab, setUiTab] = useState('overview')
+ const [skillProfileData, setSkillProfileData] = useState(null)
+ const [skillProfileLoading, setSkillProfileLoading] = useState(false)
+ const [skillProfileError, setSkillProfileError] = useState('')
useEffect(() => {
setSelectedGraphId(null)
@@ -180,6 +184,32 @@ export default function ExerciseProgressionGraphPanel({
}
}, [refreshGraphs, tenantClubDepKey])
+ useEffect(() => {
+ if (!selectedGraphId) {
+ setSkillProfileData(null)
+ return undefined
+ }
+ let cancelled = false
+ ;(async () => {
+ setSkillProfileLoading(true)
+ setSkillProfileError('')
+ try {
+ const data = await api.getProgressionGraphSkillProfile(selectedGraphId)
+ if (!cancelled) setSkillProfileData(data)
+ } catch (e) {
+ if (!cancelled) {
+ setSkillProfileData(null)
+ setSkillProfileError(e.message || 'Fähigkeiten-Profil nicht geladen')
+ }
+ } finally {
+ if (!cancelled) setSkillProfileLoading(false)
+ }
+ })()
+ return () => {
+ cancelled = true
+ }
+ }, [selectedGraphId, edges.length])
+
useEffect(() => {
if (!selectedGraphId) {
setEdges([])
@@ -652,6 +682,14 @@ export default function ExerciseProgressionGraphPanel({
{selectedGraphId && uiTab === 'overview' && (
<>
+
diff --git a/frontend/src/components/skills/SkillDiscoveryPanel.jsx b/frontend/src/components/skills/SkillDiscoveryPanel.jsx new file mode 100644 index 0000000..dd09642 --- /dev/null +++ b/frontend/src/components/skills/SkillDiscoveryPanel.jsx @@ -0,0 +1,163 @@ +import React, { useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import api from '../../utils/api' + +const ARTIFACT_LABELS = { + framework_program: 'Rahmenprogramm', + training_module: 'Trainingsmodul', + progression_graph: 'Regressionspfad', +} + +/** + * Vorschläge für Planungsartefakte anhand gewählter Fähigkeiten (Phase 3). + */ +export default function SkillDiscoveryPanel({ skills = [] }) { + const [selectedIds, setSelectedIds] = useState([]) + const [query, setQuery] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [result, setResult] = useState(null) + + const filteredSkills = useMemo(() => { + const q = query.trim().toLowerCase() + const list = (skills || []).filter((s) => s.status !== 'inactive') + if (!q) return list + return list.filter( + (s) => + (s.name || '').toLowerCase().includes(q) || + (s.category || '').toLowerCase().includes(q) + ) + }, [skills, query]) + + const toggleSkill = (id) => { + const s = String(id) + setSelectedIds((prev) => + prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s] + ) + } + + async function handleSearch() { + const ids = selectedIds.map((x) => parseInt(x, 10)).filter((n) => n > 0) + if (!ids.length) { + setError('Wähle mindestens eine Fähigkeit.') + return + } + setLoading(true) + setError('') + setResult(null) + try { + const data = await api.getSkillDiscoverySuggestions(ids, { limit: 25 }) + setResult(data) + } catch (e) { + setError(e.message || 'Suche fehlgeschlagen') + } finally { + setLoading(false) + } + } + + return ( +
+ 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). +
+ +Keine Fähigkeiten gefunden.
+ ) : ( + filteredSkills.map((sk) => ( + + )) + )} +{error}
: null} + + {result?.suggestions?.length > 0 ? ( ++ {item.match.matched_skills.map((m) => m.skill_name).join(' · ')} +
+ ) : null} + {item.path ? ( + + Öffnen + + ) : item.artifact_type === 'progression_graph' ? ( ++ Regressionspfad in der Übungsliste unter „Progressionsgraph“ bearbeiten. +
+ ) : null} ++ Keine passenden Artefakte in deiner sichtbaren Bibliothek — prüfe Fähigkeiten-Verknüpfungen an + den Übungen oder erweitere die Auswahl. +
+ ) : null} +Fähigkeiten-Profil wird berechnet…
+{error}
+{hint}
+ + {noData ? ( ++ Noch keine Übungen mit Fähigkeiten-Verknüpfung — lege Übungen im Ablauf an und verknüpfe + Fähigkeiten in der Übungsbearbeitung. +
+ ) : profile.exercises_with_skills_count === 0 ? ( ++ {profile.distinct_exercise_count} Übung{profile.distinct_exercise_count === 1 ? '' : 'en'} im + Ablauf, aber keine Fähigkeiten an den Übungen hinterlegt. +
+ ) : ( + <> +