diff --git a/.claude/docs/technical/SKILL_SCORING_SPEC.md b/.claude/docs/technical/SKILL_SCORING_SPEC.md
index eda832e..17ef945 100644
--- a/.claude/docs/technical/SKILL_SCORING_SPEC.md
+++ b/.claude/docs/technical/SKILL_SCORING_SPEC.md
@@ -58,7 +58,9 @@ Aggregation:
- 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)
+- `universal_percent` = `weight / max_weight_in_corpus(skill_id) × 100` — Referenz aus **Vereins-Artefakten** (`visibility=club`, aktiver Verein): Rahmenprogramme, Module, Regressionspfade
+- `club_best` / `club_best_by_skill`: stärkstes Vereins-Element je Fähigkeit (Titel, Typ, Gewicht)
+- Listen: `POST /api/skill-profiles/batch-summaries` — ein Corpus-Durchlauf, kompakte Profile für viele IDs
Discovery-Sortierung und Vorschläge nutzen **`match_score`** (= Summe absoluter Gewichte der gewählten Fähigkeiten), nicht den Plan-internen Anteil.
diff --git a/backend/routers/skill_profiles.py b/backend/routers/skill_profiles.py
index 6253cab..b640213 100644
--- a/backend/routers/skill_profiles.py
+++ b/backend/routers/skill_profiles.py
@@ -17,10 +17,13 @@ from skill_scoring import (
collect_module_exercise_occurrences,
collect_progression_graph_exercise_occurrences,
collect_unit_exercise_occurrences,
+ compute_club_corpus_reference,
compute_corpus_skill_max_weights,
compute_skill_profile,
match_score_for_skill_ids,
profile_for_occurrences,
+ reference_scale_meta,
+ top_categories_summary,
)
from routers.training_framework_programs import _framework_access
@@ -70,12 +73,9 @@ 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,
- )
+ corpus = _load_club_corpus(cur, tenant)
+ ref_max = corpus["max_by_skill"]
+ ref_by_skill = corpus["ref_by_skill"]
all_occurrences: List[ExerciseOccurrence] = []
slot_profiles: List[Dict[str, Any]] = []
@@ -118,14 +118,22 @@ def framework_program_skill_profile(
if all_occurrences
else _empty_profile()
)
+ _enrich_profile_club_best(overall, ref_by_skill, "framework_program", framework_id)
+ for slot in slot_profiles:
+ _enrich_profile_club_best(
+ slot.get("profile") or {},
+ ref_by_skill,
+ "framework_program",
+ framework_id,
+ )
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",
+ "reference_scale": reference_scale_meta(corpus),
+ "club_best_by_skill": {
+ str(k): v for k, v in ref_by_skill.items()
},
"overall": overall,
"slots": slot_profiles,
@@ -142,24 +150,23 @@ 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,
- )
+ corpus = _load_club_corpus(cur, tenant)
+ ref_max = corpus["max_by_skill"]
+ ref_by_skill = corpus["ref_by_skill"]
occurrences = collect_module_exercise_occurrences(cur, module_id)
overall = (
profile_for_occurrences(cur, occurrences, reference_max_by_skill=ref_max)
if occurrences
else _empty_profile()
)
+ _enrich_profile_club_best(overall, ref_by_skill, "training_module", module_id)
return {
"artifact_type": "training_module",
"artifact_id": module_id,
"artifact_title": row.get("title"),
- "reference_scale": {
- "skills_in_corpus": len(ref_max),
+ "reference_scale": reference_scale_meta(corpus),
+ "club_best_by_skill": {
+ str(k): v for k, v in ref_by_skill.items()
},
"overall": overall,
}
@@ -175,12 +182,9 @@ 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,
- )
+ corpus = _load_club_corpus(cur, tenant)
+ ref_max = corpus["max_by_skill"]
+ ref_by_skill = corpus["ref_by_skill"]
occurrences = collect_progression_graph_exercise_occurrences(cur, graph_id)
overall = (
profile_for_occurrences(
@@ -192,17 +196,96 @@ def progression_graph_skill_profile(
if occurrences
else _empty_profile()
)
+ _enrich_profile_club_best(overall, ref_by_skill, "progression_graph", graph_id)
return {
"artifact_type": "progression_graph",
"artifact_id": graph_id,
"artifact_title": row.get("name"),
- "reference_scale": {
- "skills_in_corpus": len(ref_max),
+ "reference_scale": reference_scale_meta(corpus),
+ "club_best_by_skill": {
+ str(k): v for k, v in ref_by_skill.items()
},
"overall": overall,
}
+@router.post("/skill-profiles/batch-summaries")
+def batch_skill_profile_summaries(
+ data: dict,
+ tenant: TenantContext = Depends(get_tenant_context),
+):
+ """
+ Kompakte Fähigkeiten-Profile für Listen (ein Corpus-Scan, Batch-SQL).
+ Body: { framework_program_ids?: number[], training_module_ids?: number[] }
+ """
+ fp_ids = _parse_id_list(data.get("framework_program_ids"))
+ mod_ids = _parse_id_list(data.get("training_module_ids"))
+ if not fp_ids and not mod_ids:
+ raise HTTPException(
+ status_code=400,
+ detail="framework_program_ids oder training_module_ids erforderlich",
+ )
+
+ profile_id = tenant.profile_id
+ role = tenant.global_role
+ summaries: Dict[str, Dict[str, Any]] = {}
+
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ corpus = compute_club_corpus_reference(
+ cur,
+ profile_id=tenant.profile_id,
+ effective_club_id=tenant.effective_club_id,
+ include_artifact_summaries=True,
+ )
+ ref_by_skill = corpus["ref_by_skill"]
+ all_summaries = corpus.get("artifact_summaries") or {}
+
+ if fp_ids:
+ allowed_fp = []
+ for fid in fp_ids:
+ try:
+ _framework_access(cur, fid, profile_id, role)
+ allowed_fp.append(fid)
+ except HTTPException:
+ pass
+ for fid in allowed_fp:
+ key = f"framework_program:{fid}"
+ if key in all_summaries:
+ summaries[key] = all_summaries[key]
+
+ if mod_ids:
+ allowed_mod = []
+ for mid in mod_ids:
+ try:
+ _module_access(cur, mid, profile_id, role)
+ allowed_mod.append(mid)
+ except HTTPException:
+ pass
+ for mid in allowed_mod:
+ key = f"training_module:{mid}"
+ if key in all_summaries:
+ summaries[key] = all_summaries[key]
+
+ skill_ids_seen: set[int] = set()
+ for summary in summaries.values():
+ for sk in summary.get("skills") or []:
+ if sk.get("skill_id") is not None:
+ skill_ids_seen.add(int(sk["skill_id"]))
+
+ club_best_subset = {
+ str(sid): ref_by_skill[sid]
+ for sid in skill_ids_seen
+ if sid in ref_by_skill
+ }
+
+ return {
+ "reference_scale": reference_scale_meta(corpus),
+ "club_best_by_skill": club_best_subset,
+ "summaries": summaries,
+ }
+
+
@router.get("/skill-discovery/suggestions")
def skill_discovery_suggestions(
skill_ids: str = Query(..., description="Komma-getrennte skill-IDs"),
@@ -278,7 +361,7 @@ def skill_discovery_suggestions(
"match": match,
"skill_profile_summary": {
"total_score": prof.get("total_score"),
- "top_by_category": _top_categories_summary(prof),
+ "top_by_category": top_categories_summary(prof),
},
}
)
@@ -322,7 +405,7 @@ def skill_discovery_suggestions(
"match": match,
"skill_profile_summary": {
"total_score": prof.get("total_score"),
- "top_by_category": _top_categories_summary(prof),
+ "top_by_category": top_categories_summary(prof),
},
}
)
@@ -368,7 +451,7 @@ def skill_discovery_suggestions(
"match": match,
"skill_profile_summary": {
"total_score": prof.get("total_score"),
- "top_by_category": _top_categories_summary(prof),
+ "top_by_category": top_categories_summary(prof),
},
}
)
@@ -405,5 +488,66 @@ def _top_categories_summary(profile: Dict[str, Any], limit: int = 6) -> List[Dic
return out
+def _parse_id_list(raw: Any, *, max_count: int = 120) -> List[int]:
+ if not raw:
+ return []
+ if not isinstance(raw, list):
+ raise HTTPException(status_code=400, detail="ID-Listen müssen Arrays sein")
+ out: List[int] = []
+ for item in raw:
+ try:
+ n = int(item)
+ except (TypeError, ValueError):
+ raise HTTPException(status_code=400, detail="Ungültige ID in Liste") from None
+ if n > 0 and n not in out:
+ out.append(n)
+ if len(out) >= max_count:
+ break
+ return out
+
+
+def _load_club_corpus(cur, tenant: TenantContext) -> Dict[str, Any]:
+ return compute_club_corpus_reference(
+ cur,
+ profile_id=tenant.profile_id,
+ effective_club_id=tenant.effective_club_id,
+ )
+
+
+def _enrich_profile_club_best(
+ profile: Dict[str, Any],
+ ref_by_skill: Dict[int, Dict[str, Any]],
+ artifact_type: Optional[str] = None,
+ artifact_id: Optional[int] = None,
+) -> None:
+ """Hängt Vereins-Referenz-Artefakt an Fähigkeiten an (wenn nicht selbst Spitze)."""
+ if not profile or not ref_by_skill:
+ return
+
+ def attach(sk: Optional[Dict[str, Any]]) -> None:
+ if not sk or sk.get("skill_id") is None:
+ return
+ sid = int(sk["skill_id"])
+ ref = ref_by_skill.get(sid)
+ if not ref:
+ return
+ if (
+ artifact_type
+ and artifact_id is not None
+ and ref.get("artifact_type") == artifact_type
+ and int(ref.get("artifact_id") or 0) == int(artifact_id)
+ ):
+ return
+ w = float(sk.get("weight") or 0)
+ if w < float(ref.get("weight") or 0) - 0.01:
+ sk["club_best"] = ref
+
+ for sk in profile.get("skills") or []:
+ attach(sk)
+ for mc in profile.get("by_main_category") or []:
+ for cat in mc.get("categories") or []:
+ attach(cat.get("top_skill"))
+
+
def _empty_profile() -> Dict[str, Any]:
return compute_skill_profile([], {})
diff --git a/backend/skill_scoring.py b/backend/skill_scoring.py
index 8340a21..77ef858 100644
--- a/backend/skill_scoring.py
+++ b/backend/skill_scoring.py
@@ -471,6 +471,337 @@ def merge_skill_weights_into_max(
target[sid] = w
+def merge_skill_weights_with_reference(
+ max_by_skill: Dict[int, float],
+ ref_by_skill: Dict[int, Dict[str, Any]],
+ profile: Dict[str, Any],
+ *,
+ artifact_type: str,
+ artifact_id: int,
+ artifact_title: Optional[str] = None,
+) -> None:
+ """Aktualisiert Vereins-Maximum je Fähigkeit inkl. Quell-Artefakt."""
+ for sk in profile.get("skills") or []:
+ sid = int(sk["skill_id"])
+ w = float(sk.get("weight") or 0)
+ if w <= 0:
+ continue
+ if w > max_by_skill.get(sid, 0.0):
+ max_by_skill[sid] = w
+ ref_by_skill[sid] = {
+ "artifact_type": artifact_type,
+ "artifact_id": int(artifact_id),
+ "artifact_title": (artifact_title or "").strip() or None,
+ "weight": _round2(w),
+ }
+
+
+def top_categories_summary(profile: Dict[str, Any], limit: int = 6) -> List[Dict[str, Any]]:
+ """Top-Fähigkeit je Unterkategorie (kompakt für Listen/Discovery)."""
+ 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"),
+ "weight": top.get("weight"),
+ "universal_percent": top.get("universal_percent"),
+ }
+ )
+ if len(out) >= limit:
+ return out
+ return out
+
+
+def _apply_reference_to_profile(
+ profile: Dict[str, Any],
+ reference_max_by_skill: Optional[Dict[int, float]],
+) -> None:
+ _apply_reference_universal_percent(profile.get("skills") or [], reference_max_by_skill)
+ profile["by_main_category"] = _build_by_main_category(profile.get("skills") or [])
+ for mc in profile.get("by_main_category") or []:
+ 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)
+ profile["has_reference_scale"] = bool(reference_max_by_skill)
+
+
+def compact_profile_summary(
+ profile: Dict[str, Any],
+ ref_by_skill: Optional[Dict[int, Dict[str, Any]]] = None,
+ *,
+ skills_limit: int = 20,
+) -> Dict[str, Any]:
+ """Leichtgewichtiges Profil für Listen — ohne Übungsdetails."""
+ skills_out: List[Dict[str, Any]] = []
+ for s in profile.get("skills") or []:
+ sid = int(s["skill_id"])
+ w = float(s.get("weight") or 0)
+ entry: Dict[str, Any] = {
+ "skill_id": sid,
+ "skill_name": s.get("skill_name"),
+ "category_name": s.get("category_name") or s.get("category"),
+ "main_category_name": s.get("main_category_name"),
+ "weight": s.get("weight"),
+ "universal_percent": s.get("universal_percent"),
+ }
+ ref = (ref_by_skill or {}).get(sid)
+ if ref and w < float(ref.get("weight") or 0) - 0.01:
+ entry["club_best"] = ref
+ skills_out.append(entry)
+ if len(skills_out) >= skills_limit:
+ break
+ return {
+ "total_score": profile.get("total_score"),
+ "total_weight": profile.get("total_weight"),
+ "exercise_occurrence_count": profile.get("exercise_occurrence_count"),
+ "skills_count": len(profile.get("skills") or []),
+ "top_by_category": top_categories_summary(profile),
+ "skills": skills_out,
+ }
+
+
+def batch_framework_occurrences_by_id(
+ cur, framework_ids: Sequence[int]
+) -> Dict[int, List[ExerciseOccurrence]]:
+ ids = sorted({int(x) for x in framework_ids if x})
+ if not ids:
+ return {}
+ ph = ",".join(["%s"] * len(ids))
+ cur.execute(
+ f"""
+ SELECT s.framework_program_id,
+ COALESCE(NULLIF(TRIM(s.title), ''), 'Session ' || (s.sort_order + 1)::text) AS slot_label,
+ tusi.exercise_id,
+ tusi.planned_duration_min
+ FROM training_framework_slots s
+ INNER JOIN training_units tu ON tu.framework_slot_id = s.id
+ INNER JOIN training_unit_sections tus ON tus.training_unit_id = tu.id
+ INNER JOIN training_unit_section_items tusi ON tusi.section_id = tus.id
+ WHERE s.framework_program_id IN ({ph})
+ AND tusi.item_type = 'exercise'
+ AND tusi.exercise_id IS NOT NULL
+ ORDER BY s.framework_program_id, s.sort_order, tus.order_index, tusi.order_index
+ """,
+ ids,
+ )
+ out: Dict[int, List[ExerciseOccurrence]] = defaultdict(list)
+ for row in cur.fetchall():
+ fid = int(row["framework_program_id"])
+ out[fid].append(
+ ExerciseOccurrence(
+ exercise_id=int(row["exercise_id"]),
+ planned_duration_min=row.get("planned_duration_min"),
+ context_label=row.get("slot_label"),
+ )
+ )
+ return dict(out)
+
+
+def batch_module_occurrences_by_id(
+ cur, module_ids: Sequence[int]
+) -> Dict[int, List[ExerciseOccurrence]]:
+ ids = sorted({int(x) for x in module_ids if x})
+ if not ids:
+ return {}
+ ph = ",".join(["%s"] * len(ids))
+ cur.execute(
+ f"""
+ SELECT module_id, exercise_id, planned_duration_min
+ FROM training_module_items
+ WHERE module_id IN ({ph})
+ AND item_type = 'exercise'
+ AND exercise_id IS NOT NULL
+ ORDER BY module_id, order_index
+ """,
+ ids,
+ )
+ out: Dict[int, List[ExerciseOccurrence]] = defaultdict(list)
+ for row in cur.fetchall():
+ mid = int(row["module_id"])
+ out[mid].append(
+ ExerciseOccurrence(
+ exercise_id=int(row["exercise_id"]),
+ planned_duration_min=row.get("planned_duration_min"),
+ )
+ )
+ return dict(out)
+
+
+def batch_compute_profiles(
+ occ_by_artifact: Dict[int, List[ExerciseOccurrence]],
+ skills_map: Dict[int, List[Dict[str, Any]]],
+ *,
+ reference_max_by_skill: Optional[Dict[int, float]] = None,
+ default_item_minutes: int = DEFAULT_ITEM_MINUTES,
+) -> Dict[int, Dict[str, Any]]:
+ return {
+ aid: compute_skill_profile(
+ occ,
+ skills_map,
+ default_item_minutes=default_item_minutes,
+ reference_max_by_skill=reference_max_by_skill,
+ )
+ for aid, occ in occ_by_artifact.items()
+ }
+
+
+def compute_club_corpus_reference(
+ cur,
+ *,
+ profile_id: int,
+ effective_club_id: Optional[int],
+ include_artifact_summaries: bool = False,
+) -> Dict[str, Any]:
+ """
+ Stärkstes Trainingsgewicht je Fähigkeit über alle Vereins-Artefakte (sichtbar im aktiven Verein).
+ Optional: kompakte Profile aller gescannten Artefakte (ein Durchlauf für Listen).
+ """
+ from tenant_context import club_library_visibility_sql
+
+ max_by_skill: Dict[int, float] = {}
+ ref_by_skill: Dict[int, Dict[str, Any]] = {}
+ artifact_count = 0
+ raw_profiles: Dict[str, Dict[str, Any]] = {}
+
+ if effective_club_id is None:
+ return {
+ "club_id": None,
+ "max_by_skill": max_by_skill,
+ "ref_by_skill": ref_by_skill,
+ "artifact_count": 0,
+ "artifact_summaries": {},
+ }
+
+ def ingest(artifact_type: str, artifact_id: int, title: Optional[str], prof: Dict[str, Any]) -> None:
+ nonlocal artifact_count
+ if not prof.get("skills"):
+ return
+ artifact_count += 1
+ merge_skill_weights_with_reference(
+ max_by_skill,
+ ref_by_skill,
+ prof,
+ artifact_type=artifact_type,
+ artifact_id=artifact_id,
+ artifact_title=title,
+ )
+ if include_artifact_summaries:
+ raw_profiles[f"{artifact_type}:{artifact_id}"] = prof
+
+ vis_clause, vis_params = club_library_visibility_sql(
+ alias="fp",
+ profile_id=profile_id,
+ effective_club_id=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
+ """,
+ vis_params,
+ )
+ fw_rows = cur.fetchall()
+ if fw_rows:
+ fw_ids = [int(r["id"]) for r in fw_rows]
+ titles = {int(r["id"]): r.get("title") for r in fw_rows}
+ occ_map = batch_framework_occurrences_by_id(cur, fw_ids)
+ all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
+ skills_map = fetch_exercise_skills_bulk(cur, all_eids)
+ profiles = batch_compute_profiles(occ_map, skills_map)
+ for fid, prof in profiles.items():
+ ingest("framework_program", fid, titles.get(fid), prof)
+
+ vis_clause, vis_params = club_library_visibility_sql(
+ alias="m",
+ profile_id=profile_id,
+ effective_club_id=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
+ """,
+ vis_params,
+ )
+ mod_rows = cur.fetchall()
+ if mod_rows:
+ mod_ids = [int(r["id"]) for r in mod_rows]
+ titles = {int(r["id"]): r.get("title") for r in mod_rows}
+ occ_map = batch_module_occurrences_by_id(cur, mod_ids)
+ all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
+ skills_map = fetch_exercise_skills_bulk(cur, all_eids)
+ profiles = batch_compute_profiles(occ_map, skills_map)
+ for mid, prof in profiles.items():
+ ingest("training_module", mid, titles.get(mid), prof)
+
+ vis_clause, vis_params = club_library_visibility_sql(
+ alias="g",
+ profile_id=profile_id,
+ effective_club_id=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
+ """,
+ vis_params,
+ )
+ 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
+ )
+ ingest("progression_graph", gid, row.get("name"), prof)
+
+ artifact_summaries: Dict[str, Dict[str, Any]] = {}
+ if include_artifact_summaries and raw_profiles:
+ for key, prof in raw_profiles.items():
+ _apply_reference_to_profile(prof, max_by_skill)
+ artifact_summaries[key] = compact_profile_summary(prof, ref_by_skill)
+
+ return {
+ "club_id": effective_club_id,
+ "max_by_skill": max_by_skill,
+ "ref_by_skill": ref_by_skill,
+ "artifact_count": artifact_count,
+ "artifact_summaries": artifact_summaries,
+ }
+
+
+def reference_scale_meta(corpus: Dict[str, Any]) -> Dict[str, Any]:
+ return {
+ "scope": "club",
+ "club_id": corpus.get("club_id"),
+ "skills_in_corpus": len(corpus.get("max_by_skill") or {}),
+ "artifacts_scanned": corpus.get("artifact_count") or 0,
+ "description": (
+ "universal_percent = Anteil am stärksten genutzten Vereins-Artefakt je Fähigkeit "
+ "(nur visibility=club im aktiven Verein)"
+ ),
+ }
+
+
def compute_corpus_skill_max_weights(
cur,
*,
@@ -480,102 +811,16 @@ def compute_corpus_skill_max_weights(
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).
+ Vereins-Referenz je Fähigkeit (Legacy-Hülle — nutzt compute_club_corpus_reference).
+ role/limit_per_type werden ignoriert (Vereinskontext, alle Vereins-Artefakte).
"""
- 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
+ del role, limit_per_type
+ corpus = compute_club_corpus_reference(
+ cur,
+ profile_id=profile_id,
+ effective_club_id=effective_club_id,
+ )
+ return corpus["max_by_skill"]
def match_score_for_skill_ids(profile: Dict[str, Any], skill_ids: Sequence[int]) -> Dict[str, Any]:
diff --git a/backend/tenant_context.py b/backend/tenant_context.py
index e1c9309..fe76393 100644
--- a/backend/tenant_context.py
+++ b/backend/tenant_context.py
@@ -107,6 +107,33 @@ def library_content_visibility_sql(
return "(" + " OR ".join(parts) + ")", params
+def club_library_visibility_sql(
+ *,
+ alias: str,
+ profile_id: int,
+ effective_club_id: Optional[int],
+) -> tuple[str, List[Any]]:
+ """
+ Nur Inhalte des aktiven Vereins (visibility=club, club_id=active).
+ Für Skill-Vergleiche im Vereinskontext — ohne official/private anderer Mandanten.
+ """
+ if effective_club_id is None:
+ return "(1=0)", []
+ return (
+ f"""(
+ {alias}.visibility = 'club'
+ AND {alias}.club_id = %s
+ AND EXISTS (
+ SELECT 1 FROM club_members cm
+ WHERE cm.profile_id = %s
+ AND cm.club_id = {alias}.club_id
+ AND cm.status = 'active'
+ )
+ )""",
+ [effective_club_id, profile_id],
+ )
+
+
@dataclass
class TenantContext:
profile_id: int
diff --git a/frontend/src/api/skillProfiles.js b/frontend/src/api/skillProfiles.js
index 7c20c3c..db67033 100644
--- a/frontend/src/api/skillProfiles.js
+++ b/frontend/src/api/skillProfiles.js
@@ -24,3 +24,17 @@ export async function getSkillDiscoverySuggestions(skillIds, opts = {}) {
if (opts.limit != null) params.set('limit', String(opts.limit))
return request(`/api/skill-discovery/suggestions?${params.toString()}`)
}
+
+/**
+ * Batch-Summaries für Listen (ein Vereins-Corpus-Scan).
+ * @param {{ frameworkProgramIds?: number[], trainingModuleIds?: number[] }} payload
+ */
+export async function batchSkillProfileSummaries(payload = {}) {
+ return request('/api/skill-profiles/batch-summaries', {
+ method: 'POST',
+ body: JSON.stringify({
+ framework_program_ids: payload.frameworkProgramIds || [],
+ training_module_ids: payload.trainingModuleIds || [],
+ }),
+ })
+}
diff --git a/frontend/src/app.css b/frontend/src/app.css
index f684c21..f442a8b 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -2690,6 +2690,107 @@ html.modal-scroll-locked .app-main {
font-weight: 500;
}
+.skill-profile-compact {
+ margin-top: 4px;
+}
+.skill-profile-compact__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-compact__list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.skill-profile-compact__item {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 6px 8px;
+ border-radius: 8px;
+ background: var(--surface2);
+ border: 1px solid var(--border);
+}
+.skill-profile-compact__name {
+ font-weight: 600;
+ font-size: 0.86rem;
+ color: var(--text1);
+}
+.skill-profile-compact__metric {
+ font-size: 0.78rem;
+ font-weight: 700;
+ color: var(--accent-dark);
+}
+.skill-profile-compact__best {
+ margin: 0;
+ font-size: 0.72rem;
+ line-height: 1.35;
+}
+.fw-prog-card__section-head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 6px;
+}
+.fw-prog-card__section--skills {
+ margin-top: 0.5rem;
+}
+.fw-import-skill-grid {
+ max-height: 180px;
+ overflow-y: auto;
+}
+.skill-profile-modal {
+ position: fixed;
+ inset: 0;
+ z-index: 1200;
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding: 24px 12px;
+ overflow-y: auto;
+}
+.skill-profile-modal__backdrop {
+ position: fixed;
+ inset: 0;
+ border: none;
+ background: rgba(0, 0, 0, 0.45);
+ cursor: pointer;
+}
+.skill-profile-modal__panel {
+ position: relative;
+ z-index: 1;
+ width: min(42rem, 100%);
+ max-height: calc(100vh - 48px);
+ overflow-y: auto;
+ padding: 1rem 1.1rem 1.25rem;
+ margin-top: 2vh;
+}
+.skill-profile-modal__head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 0.75rem;
+}
+.skill-profile-modal__title {
+ margin: 0;
+ font-size: 1.05rem;
+}
+.skill-profile-modal__scale {
+ margin: 0.5rem 0 0;
+}
+
.skill-discovery {
padding: 1.15rem 1.25rem;
max-width: 52rem;
diff --git a/frontend/src/components/planning/FrameworkProgramListCard.jsx b/frontend/src/components/planning/FrameworkProgramListCard.jsx
index 2ccd4ed..1d390d3 100644
--- a/frontend/src/components/planning/FrameworkProgramListCard.jsx
+++ b/frontend/src/components/planning/FrameworkProgramListCard.jsx
@@ -1,5 +1,6 @@
import React from 'react'
import NavStateLink from '../NavStateLink'
+import SkillProfileCompact from '../skills/SkillProfileCompact'
import {
frameworkSessionDurationLabel,
splitFrameworkCommaAgg,
@@ -26,7 +27,16 @@ function CatalogGroup({ label, items, variant }) {
/**
* Einzelkarte für die Rahmenprogramm-Bibliothek.
*/
-export default function FrameworkProgramListCard({ row, returnContext, onDelete }) {
+export default function FrameworkProgramListCard({
+ row,
+ returnContext,
+ onDelete,
+ skillSummary = null,
+ skillSummaryLoading = false,
+ skillFilterIds = [],
+ skillDisplayLimit = 6,
+ onShowSkillProfile,
+}) {
const title = (row.title || '').trim() || `Rahmen #${row.id}`
const description = (row.description || '').trim()
const durationLabel = frameworkSessionDurationLabel(row)
@@ -112,6 +122,27 @@ export default function FrameworkProgramListCard({ row, returnContext, onDelete
) : null}
+ Fähigkeiten
+ {onShowSkillProfile ? (
+
+ ) : null}
+
- Sichtbarkeit: {r.visibility || '—'} -
+