Refactor Skill Scoring Functions and Enhance Corpus Handling
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m17s

- Introduced new helper functions for managing artifact type corpus, improving code organization and readability.
- Updated the `compute_club_corpus_reference` function to utilize the new corpus handling methods, enhancing clarity and maintainability.
- Refactored skill profile functions to leverage the new corpus structure, ensuring consistent data retrieval across different artifact types.
- Improved the handling of visibility clauses for library content, streamlining database queries for skill profiles.
- Enhanced the batch skill profile summary function to aggregate reference data by artifact type, improving performance and accuracy.
This commit is contained in:
Lars 2026-05-21 10:17:22 +02:00
parent 34966b9e84
commit 2de4c0b7c9
13 changed files with 375 additions and 180 deletions

View File

@ -21,9 +21,11 @@ from skill_scoring import (
collect_progression_graph_exercise_occurrences, collect_progression_graph_exercise_occurrences,
collect_unit_exercise_occurrences, collect_unit_exercise_occurrences,
compact_profile_summary, compact_profile_summary,
compute_planning_corpus_by_type,
compute_club_corpus_reference, compute_club_corpus_reference,
compute_corpus_skill_max_weights, compute_corpus_skill_max_weights,
compute_skill_profile, compute_skill_profile,
corpus_for_artifact_type,
fetch_exercise_skills_bulk, fetch_exercise_skills_bulk,
match_score_for_skill_ids, match_score_for_skill_ids,
profile_for_occurrences, profile_for_occurrences,
@ -78,9 +80,10 @@ def framework_program_skill_profile(
) )
slots_raw = [r2d(r) for r in cur.fetchall()] slots_raw = [r2d(r) for r in cur.fetchall()]
corpus = _load_club_corpus(cur, tenant) bundle = _load_planning_corpus(cur, tenant)
ref_max = corpus["max_by_skill"] tc = corpus_for_artifact_type(bundle, "framework_program")
ref_by_skill = corpus["ref_by_skill"] ref_max = tc["max_by_skill"]
ref_by_skill = tc["ref_by_skill"]
all_occurrences: List[ExerciseOccurrence] = [] all_occurrences: List[ExerciseOccurrence] = []
slot_profiles: List[Dict[str, Any]] = [] slot_profiles: List[Dict[str, Any]] = []
@ -136,7 +139,9 @@ def framework_program_skill_profile(
"artifact_type": "framework_program", "artifact_type": "framework_program",
"artifact_id": framework_id, "artifact_id": framework_id,
"artifact_title": row.get("title"), "artifact_title": row.get("title"),
"reference_scale": reference_scale_meta(corpus), "reference_scale": reference_scale_meta(
tc, "framework_program", effective_club_id=tenant.effective_club_id
),
"club_best_by_skill": { "club_best_by_skill": {
str(k): v for k, v in ref_by_skill.items() str(k): v for k, v in ref_by_skill.items()
}, },
@ -155,9 +160,10 @@ def training_module_skill_profile(
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
row = _module_access(cur, module_id, profile_id, role) row = _module_access(cur, module_id, profile_id, role)
corpus = _load_club_corpus(cur, tenant) bundle = _load_planning_corpus(cur, tenant)
ref_max = corpus["max_by_skill"] tc = corpus_for_artifact_type(bundle, "training_module")
ref_by_skill = corpus["ref_by_skill"] ref_max = tc["max_by_skill"]
ref_by_skill = tc["ref_by_skill"]
occurrences = collect_module_exercise_occurrences(cur, module_id) occurrences = collect_module_exercise_occurrences(cur, module_id)
overall = ( overall = (
profile_for_occurrences(cur, occurrences, reference_max_by_skill=ref_max) profile_for_occurrences(cur, occurrences, reference_max_by_skill=ref_max)
@ -169,7 +175,9 @@ def training_module_skill_profile(
"artifact_type": "training_module", "artifact_type": "training_module",
"artifact_id": module_id, "artifact_id": module_id,
"artifact_title": row.get("title"), "artifact_title": row.get("title"),
"reference_scale": reference_scale_meta(corpus), "reference_scale": reference_scale_meta(
tc, "training_module", effective_club_id=tenant.effective_club_id
),
"club_best_by_skill": { "club_best_by_skill": {
str(k): v for k, v in ref_by_skill.items() str(k): v for k, v in ref_by_skill.items()
}, },
@ -187,9 +195,10 @@ def progression_graph_skill_profile(
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
row = _require_graph_read(cur, graph_id, profile_id, role) row = _require_graph_read(cur, graph_id, profile_id, role)
corpus = _load_club_corpus(cur, tenant) bundle = _load_planning_corpus(cur, tenant)
ref_max = corpus["max_by_skill"] tc = corpus_for_artifact_type(bundle, "progression_graph")
ref_by_skill = corpus["ref_by_skill"] ref_max = tc["max_by_skill"]
ref_by_skill = tc["ref_by_skill"]
occurrences = collect_progression_graph_exercise_occurrences(cur, graph_id) occurrences = collect_progression_graph_exercise_occurrences(cur, graph_id)
overall = ( overall = (
profile_for_occurrences( profile_for_occurrences(
@ -206,7 +215,9 @@ def progression_graph_skill_profile(
"artifact_type": "progression_graph", "artifact_type": "progression_graph",
"artifact_id": graph_id, "artifact_id": graph_id,
"artifact_title": row.get("name"), "artifact_title": row.get("name"),
"reference_scale": reference_scale_meta(corpus), "reference_scale": reference_scale_meta(
tc, "progression_graph", effective_club_id=tenant.effective_club_id
),
"club_best_by_skill": { "club_best_by_skill": {
str(k): v for k, v in ref_by_skill.items() str(k): v for k, v in ref_by_skill.items()
}, },
@ -237,13 +248,13 @@ def batch_skill_profile_summaries(
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
corpus = compute_club_corpus_reference( bundle = compute_planning_corpus_by_type(
cur, cur,
profile_id=tenant.profile_id, profile_id=tenant.profile_id,
role=role,
effective_club_id=tenant.effective_club_id, effective_club_id=tenant.effective_club_id,
include_artifact_summaries=True, include_artifact_summaries=True,
) )
ref_by_skill = corpus["ref_by_skill"]
allowed_fp: List[int] = [] allowed_fp: List[int] = []
if fp_ids: if fp_ids:
@ -265,10 +276,13 @@ def batch_skill_profile_summaries(
summaries = _merge_batch_summaries( summaries = _merge_batch_summaries(
cur, cur,
corpus=corpus, bundle=bundle,
allowed_fp=allowed_fp, allowed_fp=allowed_fp,
allowed_mod=allowed_mod, allowed_mod=allowed_mod,
) )
ref_by_skill = {}
for t in ("framework_program", "training_module", "progression_graph"):
ref_by_skill.update(corpus_for_artifact_type(bundle, t).get("ref_by_skill") or {})
skill_ids_seen: set[int] = set() skill_ids_seen: set[int] = set()
for summary in summaries.values(): for summary in summaries.values():
@ -283,7 +297,14 @@ def batch_skill_profile_summaries(
} }
return { return {
"reference_scale": reference_scale_meta(corpus), "reference_scale_by_type": {
t: reference_scale_meta(
corpus_for_artifact_type(bundle, t),
t,
effective_club_id=tenant.effective_club_id,
)
for t in ("framework_program", "training_module", "progression_graph")
},
"club_best_by_skill": club_best_subset, "club_best_by_skill": club_best_subset,
"summaries": summaries, "summaries": summaries,
} }
@ -313,6 +334,10 @@ def skill_discovery_suggestions(
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
planning_bundle = _load_planning_corpus(cur, tenant)
fw_ref = corpus_for_artifact_type(planning_bundle, "framework_program")["max_by_skill"]
mod_ref = corpus_for_artifact_type(planning_bundle, "training_module")["max_by_skill"]
graph_ref = corpus_for_artifact_type(planning_bundle, "progression_graph")["max_by_skill"]
if "framework_program" in type_set: if "framework_program" in type_set:
vis_clause, vis_params = library_content_visibility_sql( vis_clause, vis_params = library_content_visibility_sql(
@ -351,7 +376,7 @@ def skill_discovery_suggestions(
occ.extend(collect_unit_exercise_occurrences(cur, int(u["id"]))) occ.extend(collect_unit_exercise_occurrences(cur, int(u["id"])))
if not occ: if not occ:
continue continue
prof = profile_for_occurrences(cur, occ) prof = profile_for_occurrences(cur, occ, reference_max_by_skill=fw_ref)
match = match_score_for_skill_ids(prof, wanted) match = match_score_for_skill_ids(prof, wanted)
if match["match_weight"] <= 0: if match["match_weight"] <= 0:
continue continue
@ -395,7 +420,7 @@ def skill_discovery_suggestions(
occ = collect_module_exercise_occurrences(cur, mid) occ = collect_module_exercise_occurrences(cur, mid)
if not occ: if not occ:
continue continue
prof = profile_for_occurrences(cur, occ) prof = profile_for_occurrences(cur, occ, reference_max_by_skill=mod_ref)
match = match_score_for_skill_ids(prof, wanted) match = match_score_for_skill_ids(prof, wanted)
if match["match_weight"] <= 0: if match["match_weight"] <= 0:
continue continue
@ -440,7 +465,8 @@ def skill_discovery_suggestions(
if not occ: if not occ:
continue continue
prof = profile_for_occurrences( prof = profile_for_occurrences(
cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES,
reference_max_by_skill=graph_ref,
) )
match = match_score_for_skill_ids(prof, wanted) match = match_score_for_skill_ids(prof, wanted)
if match["match_weight"] <= 0: if match["match_weight"] <= 0:
@ -509,10 +535,11 @@ def _parse_id_list(raw: Any, *, max_count: int = 120) -> List[int]:
return out return out
def _load_club_corpus(cur, tenant: TenantContext) -> Dict[str, Any]: def _load_planning_corpus(cur, tenant: TenantContext) -> Dict[str, Any]:
return compute_club_corpus_reference( return compute_planning_corpus_by_type(
cur, cur,
profile_id=tenant.profile_id, profile_id=tenant.profile_id,
role=tenant.global_role,
effective_club_id=tenant.effective_club_id, effective_club_id=tenant.effective_club_id,
) )
@ -602,52 +629,61 @@ def _summarize_training_module(
def _merge_batch_summaries( def _merge_batch_summaries(
cur, cur,
*, *,
corpus: Dict[str, Any], bundle: Dict[str, Any],
allowed_fp: List[int], allowed_fp: List[int],
allowed_mod: List[int], allowed_mod: List[int],
) -> Dict[str, Dict[str, Any]]: ) -> Dict[str, Dict[str, Any]]:
"""Summaries für angeforderte IDs — auch official/private (nicht nur Vereins-Corpus-Cache).""" """Summaries für angeforderte IDs — Referenz je Planungs-Kontext (Typ getrennt)."""
ref_max = corpus["max_by_skill"] fw_tc = corpus_for_artifact_type(bundle, "framework_program")
ref_by_skill = corpus["ref_by_skill"] mod_tc = corpus_for_artifact_type(bundle, "training_module")
cached = corpus.get("artifact_summaries") or {} fw_cached = fw_tc.get("artifact_summaries") or {}
mod_cached = mod_tc.get("artifact_summaries") or {}
out: Dict[str, Dict[str, Any]] = {} out: Dict[str, Dict[str, Any]] = {}
missing_fp = [fid for fid in allowed_fp if f"framework_program:{fid}" not in cached] fw_ref_max = fw_tc["max_by_skill"]
fw_ref_by = fw_tc["ref_by_skill"]
missing_fp = [fid for fid in allowed_fp if f"framework_program:{fid}" not in fw_cached]
if missing_fp: if missing_fp:
occ_map = batch_framework_occurrences_by_id(cur, missing_fp) occ_map = batch_framework_occurrences_by_id(cur, missing_fp)
all_eids = {o.exercise_id for occs in occ_map.values() for o in occs} all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {} skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {}
profiles = batch_compute_profiles(occ_map, skills_map, reference_max_by_skill=ref_max) profiles = batch_compute_profiles(
occ_map, skills_map, reference_max_by_skill=fw_ref_max
)
for fid in missing_fp: for fid in missing_fp:
key = f"framework_program:{fid}" key = f"framework_program:{fid}"
prof = profiles.get(fid) or _empty_profile() prof = profiles.get(fid) or _empty_profile()
_enrich_profile_club_best(prof, ref_by_skill, "framework_program", fid) _enrich_profile_club_best(prof, fw_ref_by, "framework_program", fid)
out[key] = compact_profile_summary(prof, ref_by_skill) out[key] = compact_profile_summary(prof, fw_ref_by)
missing_mod = [mid for mid in allowed_mod if f"training_module:{mid}" not in cached] mod_ref_max = mod_tc["max_by_skill"]
mod_ref_by = mod_tc["ref_by_skill"]
missing_mod = [mid for mid in allowed_mod if f"training_module:{mid}" not in mod_cached]
if missing_mod: if missing_mod:
occ_map = batch_module_occurrences_by_id(cur, missing_mod) occ_map = batch_module_occurrences_by_id(cur, missing_mod)
all_eids = {o.exercise_id for occs in occ_map.values() for o in occs} all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {} skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {}
profiles = batch_compute_profiles(occ_map, skills_map, reference_max_by_skill=ref_max) profiles = batch_compute_profiles(
occ_map, skills_map, reference_max_by_skill=mod_ref_max
)
for mid in missing_mod: for mid in missing_mod:
key = f"training_module:{mid}" key = f"training_module:{mid}"
prof = profiles.get(mid) or _empty_profile() prof = profiles.get(mid) or _empty_profile()
_enrich_profile_club_best(prof, ref_by_skill, "training_module", mid) _enrich_profile_club_best(prof, mod_ref_by, "training_module", mid)
out[key] = compact_profile_summary(prof, ref_by_skill) out[key] = compact_profile_summary(prof, mod_ref_by)
for fid in allowed_fp: for fid in allowed_fp:
key = f"framework_program:{fid}" key = f"framework_program:{fid}"
if key in cached: if key in fw_cached:
out[key] = cached[key] out[key] = fw_cached[key]
elif key not in out: elif key not in out:
out[key] = _summarize_framework_program(cur, fid, ref_max, ref_by_skill) out[key] = _summarize_framework_program(cur, fid, fw_ref_max, fw_ref_by)
for mid in allowed_mod: for mid in allowed_mod:
key = f"training_module:{mid}" key = f"training_module:{mid}"
if key in cached: if key in mod_cached:
out[key] = cached[key] out[key] = mod_cached[key]
elif key not in out: elif key not in out:
out[key] = _summarize_training_module(cur, mid, ref_max, ref_by_skill) out[key] = _summarize_training_module(cur, mid, mod_ref_max, mod_ref_by)
return out return out

View File

@ -686,34 +686,41 @@ def batch_compute_profiles(
} }
def compute_club_corpus_reference( def _empty_type_corpus() -> Dict[str, Any]:
return {
"max_by_skill": {},
"ref_by_skill": {},
"artifact_count": 0,
"artifact_summaries": {},
}
def corpus_for_artifact_type(
bundle: Dict[str, Any],
artifact_type: str,
) -> Dict[str, Any]:
by_type = bundle.get("by_type") or {}
return by_type.get(artifact_type) or _empty_type_corpus()
def _scan_artifact_type_corpus(
cur, cur,
*, *,
artifact_type: str,
profile_id: int, profile_id: int,
role: Optional[str],
effective_club_id: Optional[int], effective_club_id: Optional[int],
include_artifact_summaries: bool = False, include_artifact_summaries: bool,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """Referenz je Fähigkeit nur innerhalb eines Planungs-Kontexts (ein Artefakttyp, sichtbare Bibliothek)."""
Stärkstes Trainingsgewicht je Fähigkeit über alle Vereins-Artefakte (sichtbar im aktiven Verein). from tenant_context import library_content_visibility_sql
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] = {} max_by_skill: Dict[int, float] = {}
ref_by_skill: Dict[int, Dict[str, Any]] = {} ref_by_skill: Dict[int, Dict[str, Any]] = {}
artifact_count = 0 artifact_count = 0
raw_profiles: Dict[str, Dict[str, Any]] = {} raw_profiles: Dict[str, Dict[str, Any]] = {}
if effective_club_id is None: def ingest(aid: int, title: Optional[str], prof: Dict[str, Any]) -> 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 nonlocal artifact_count
if not prof.get("skills"): if not prof.get("skills"):
return return
@ -723,15 +730,17 @@ def compute_club_corpus_reference(
ref_by_skill, ref_by_skill,
prof, prof,
artifact_type=artifact_type, artifact_type=artifact_type,
artifact_id=artifact_id, artifact_id=aid,
artifact_title=title, artifact_title=title,
) )
if include_artifact_summaries: if include_artifact_summaries:
raw_profiles[f"{artifact_type}:{artifact_id}"] = prof raw_profiles[f"{artifact_type}:{aid}"] = prof
vis_clause, vis_params = club_library_visibility_sql( if artifact_type == "framework_program":
vis_clause, vis_params = library_content_visibility_sql(
alias="fp", alias="fp",
profile_id=profile_id, profile_id=profile_id,
role=role or "",
effective_club_id=effective_club_id, effective_club_id=effective_club_id,
) )
cur.execute( cur.execute(
@ -743,20 +752,22 @@ def compute_club_corpus_reference(
""", """,
vis_params, vis_params,
) )
fw_rows = cur.fetchall() rows = cur.fetchall()
if fw_rows: if rows:
fw_ids = [int(r["id"]) for r in fw_rows] ids = [int(r["id"]) for r in rows]
titles = {int(r["id"]): r.get("title") for r in fw_rows} titles = {int(r["id"]): r.get("title") for r in rows}
occ_map = batch_framework_occurrences_by_id(cur, fw_ids) occ_map = batch_framework_occurrences_by_id(cur, ids)
all_eids = {o.exercise_id for occs in occ_map.values() for o in occs} all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
skills_map = fetch_exercise_skills_bulk(cur, all_eids) skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {}
profiles = batch_compute_profiles(occ_map, skills_map) profiles = batch_compute_profiles(occ_map, skills_map)
for fid, prof in profiles.items(): for aid, prof in profiles.items():
ingest("framework_program", fid, titles.get(fid), prof) ingest(aid, titles.get(aid), prof)
vis_clause, vis_params = club_library_visibility_sql( elif artifact_type == "training_module":
vis_clause, vis_params = library_content_visibility_sql(
alias="m", alias="m",
profile_id=profile_id, profile_id=profile_id,
role=role or "",
effective_club_id=effective_club_id, effective_club_id=effective_club_id,
) )
cur.execute( cur.execute(
@ -768,20 +779,22 @@ def compute_club_corpus_reference(
""", """,
vis_params, vis_params,
) )
mod_rows = cur.fetchall() rows = cur.fetchall()
if mod_rows: if rows:
mod_ids = [int(r["id"]) for r in mod_rows] ids = [int(r["id"]) for r in rows]
titles = {int(r["id"]): r.get("title") for r in mod_rows} titles = {int(r["id"]): r.get("title") for r in rows}
occ_map = batch_module_occurrences_by_id(cur, mod_ids) occ_map = batch_module_occurrences_by_id(cur, ids)
all_eids = {o.exercise_id for occs in occ_map.values() for o in occs} all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
skills_map = fetch_exercise_skills_bulk(cur, all_eids) skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {}
profiles = batch_compute_profiles(occ_map, skills_map) profiles = batch_compute_profiles(occ_map, skills_map)
for mid, prof in profiles.items(): for aid, prof in profiles.items():
ingest("training_module", mid, titles.get(mid), prof) ingest(aid, titles.get(aid), prof)
vis_clause, vis_params = club_library_visibility_sql( elif artifact_type == "progression_graph":
vis_clause, vis_params = library_content_visibility_sql(
alias="g", alias="g",
profile_id=profile_id, profile_id=profile_id,
role=role or "",
effective_club_id=effective_club_id, effective_club_id=effective_club_id,
) )
cur.execute( cur.execute(
@ -801,7 +814,7 @@ def compute_club_corpus_reference(
prof = profile_for_occurrences( prof = profile_for_occurrences(
cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES
) )
ingest("progression_graph", gid, row.get("name"), prof) ingest(gid, row.get("name"), prof)
artifact_summaries: Dict[str, Dict[str, Any]] = {} artifact_summaries: Dict[str, Dict[str, Any]] = {}
if include_artifact_summaries and raw_profiles: if include_artifact_summaries and raw_profiles:
@ -810,7 +823,6 @@ def compute_club_corpus_reference(
artifact_summaries[key] = compact_profile_summary(prof, ref_by_skill) artifact_summaries[key] = compact_profile_summary(prof, ref_by_skill)
return { return {
"club_id": effective_club_id,
"max_by_skill": max_by_skill, "max_by_skill": max_by_skill,
"ref_by_skill": ref_by_skill, "ref_by_skill": ref_by_skill,
"artifact_count": artifact_count, "artifact_count": artifact_count,
@ -818,15 +830,106 @@ def compute_club_corpus_reference(
} }
def reference_scale_meta(corpus: Dict[str, Any]) -> Dict[str, Any]: def compute_planning_corpus_by_type(
cur,
*,
profile_id: int,
role: Optional[str],
effective_club_id: Optional[int],
include_artifact_summaries: bool = False,
) -> Dict[str, Any]:
"""
Referenz je Fähigkeit getrennt nach Planungs-Kontext:
Rahmenprogramme, Trainingsmodule und Regressionspfade jeweils für sich,
jeweils über die sichtbare Bibliothek (library_content_visibility_sql).
"""
by_type = {
"framework_program": _scan_artifact_type_corpus(
cur,
artifact_type="framework_program",
profile_id=profile_id,
role=role,
effective_club_id=effective_club_id,
include_artifact_summaries=include_artifact_summaries,
),
"training_module": _scan_artifact_type_corpus(
cur,
artifact_type="training_module",
profile_id=profile_id,
role=role,
effective_club_id=effective_club_id,
include_artifact_summaries=include_artifact_summaries,
),
"progression_graph": _scan_artifact_type_corpus(
cur,
artifact_type="progression_graph",
profile_id=profile_id,
role=role,
effective_club_id=effective_club_id,
include_artifact_summaries=include_artifact_summaries,
),
}
return { return {
"scope": "club", "effective_club_id": effective_club_id,
"club_id": corpus.get("club_id"), "by_type": by_type,
"skills_in_corpus": len(corpus.get("max_by_skill") or {}), }
"artifacts_scanned": corpus.get("artifact_count") or 0,
def compute_club_corpus_reference(
cur,
*,
profile_id: int,
effective_club_id: Optional[int],
include_artifact_summaries: bool = False,
role: Optional[str] = None,
) -> Dict[str, Any]:
"""Legacy-Hülle — merged summaries über alle Typen (vermeiden für neue Aufrufer)."""
bundle = compute_planning_corpus_by_type(
cur,
profile_id=profile_id,
role=role,
effective_club_id=effective_club_id,
include_artifact_summaries=include_artifact_summaries,
)
tc = corpus_for_artifact_type(bundle, "framework_program")
merged_summaries: Dict[str, Dict[str, Any]] = {}
for t in ("framework_program", "training_module", "progression_graph"):
merged_summaries.update((bundle["by_type"][t].get("artifact_summaries") or {}))
return {
"club_id": effective_club_id,
"max_by_skill": tc["max_by_skill"],
"ref_by_skill": tc["ref_by_skill"],
"artifact_count": sum(
(bundle["by_type"][t].get("artifact_count") or 0)
for t in bundle["by_type"]
),
"artifact_summaries": merged_summaries,
}
_ARTIFACT_TYPE_LABELS = {
"framework_program": "Rahmenprogrammen",
"training_module": "Trainingsmodulen",
"progression_graph": "Regressionspfaden",
}
def reference_scale_meta(
type_corpus: Dict[str, Any],
artifact_type: str,
*,
effective_club_id: Optional[int] = None,
) -> Dict[str, Any]:
label = _ARTIFACT_TYPE_LABELS.get(artifact_type, artifact_type)
return {
"scope": "planning_peer",
"artifact_type": artifact_type,
"effective_club_id": effective_club_id,
"skills_in_corpus": len(type_corpus.get("max_by_skill") or {}),
"artifacts_scanned": type_corpus.get("artifact_count") or 0,
"description": ( "description": (
"universal_percent = Anteil am stärksten genutzten Vereins-Artefakt je Fähigkeit " f"Prozent = Anteil am stärksten sichtbaren Eintrag unter {label} je Fähigkeit "
"(nur visibility=club im aktiven Verein)" f"(nicht gemischt mit anderen Planungs-Artefakttypen)"
), ),
} }
@ -838,18 +941,17 @@ def compute_corpus_skill_max_weights(
role: Optional[str], role: Optional[str],
effective_club_id: Optional[int], effective_club_id: Optional[int],
limit_per_type: int = 50, limit_per_type: int = 50,
artifact_type: str = "framework_program",
) -> Dict[int, float]: ) -> Dict[int, float]:
""" """Referenz je Fähigkeit innerhalb eines Planungs-Kontexts (Legacy-Hülle)."""
Vereins-Referenz je Fähigkeit (Legacy-Hülle nutzt compute_club_corpus_reference). del limit_per_type
role/limit_per_type werden ignoriert (Vereinskontext, alle Vereins-Artefakte). bundle = compute_planning_corpus_by_type(
"""
del role, limit_per_type
corpus = compute_club_corpus_reference(
cur, cur,
profile_id=profile_id, profile_id=profile_id,
role=role,
effective_club_id=effective_club_id, effective_club_id=effective_club_id,
) )
return corpus["max_by_skill"] return corpus_for_artifact_type(bundle, artifact_type)["max_by_skill"]
def match_score_for_skill_ids(profile: Dict[str, Any], skill_ids: Sequence[int]) -> Dict[str, Any]: def match_score_for_skill_ids(profile: Dict[str, Any], skill_ids: Sequence[int]) -> Dict[str, Any]:

View File

@ -689,6 +689,7 @@ export default function ExerciseProgressionGraphPanel({
loading={skillProfileLoading} loading={skillProfileLoading}
error={skillProfileError} error={skillProfileError}
defaultExpanded defaultExpanded
artifactType="progression_graph"
/> />
<div className="card" style={{ marginBottom: '12px' }}> <div className="card" style={{ marginBottom: '12px' }}>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Sequenz / Reihe anlegen</h3> <h3 style={{ marginTop: 0, fontSize: '1rem' }}>Sequenz / Reihe anlegen</h3>

View File

@ -137,6 +137,7 @@ export default function FrameworkProgramListCard({
</div> </div>
<SkillProfileCompact <SkillProfileCompact
summary={skillSummary} summary={skillSummary}
artifactType="framework_program"
loading={skillSummaryLoading} loading={skillSummaryLoading}
displayLimit={skillDisplayLimit} displayLimit={skillDisplayLimit}
highlightSkillIds={skillFilterIds} highlightSkillIds={skillFilterIds}

View File

@ -291,10 +291,10 @@ export default function FrameworkProgramsFilterBlock({
{catalogSkills.length > 0 ? ( {catalogSkills.length > 0 ? (
<div className="fw-import-catalog-block fw-import-catalog-block--skills"> <div className="fw-import-catalog-block fw-import-catalog-block--skills">
<span className="form-label">Fähigkeiten (Vereinsvergleich)</span> <span className="form-label">Fähigkeiten (Rahmenprogramme)</span>
<p className="form-sub" style={{ margin: '0 0 8px' }}> <p className="form-sub" style={{ margin: '0 0 8px' }}>
Filtert nach Trainingsgewicht relativ zum stärksten Vereins-Programm je Fähigkeit ohne Filtert nach Trainingsgewicht relativ zum stärksten sichtbaren Rahmenprogramm je Fähigkeit nur
Punktewerte eingeben. unter Rahmenprogrammen, nicht gegen Module.
</p> </p>
<div className="framework-catalog-checkgrid fw-import-skill-grid"> <div className="framework-catalog-checkgrid fw-import-skill-grid">
{catalogSkills.map((sk) => ( {catalogSkills.map((sk) => (
@ -312,7 +312,7 @@ export default function FrameworkProgramsFilterBlock({
{(filters.skillIds || []).length > 0 ? ( {(filters.skillIds || []).length > 0 ? (
<div className="fw-import-skill-options"> <div className="fw-import-skill-options">
<div className="form-row" style={{ marginBottom: 8 }}> <div className="form-row" style={{ marginBottom: 8 }}>
<label className="form-label">Mindest-Anteil am Vereins-Maximum</label> <label className="form-label">Mindest-Anteil am Rahmenprogramm-Maximum</label>
<select <select
className="form-input" className="form-input"
value={String(filters.skillMinClubPercent ?? 0)} value={String(filters.skillMinClubPercent ?? 0)}

View File

@ -1,11 +1,17 @@
import React from 'react' import React from 'react'
import { formatClubPercent, formatSkillWeight, kpiRowsFromSummary } from '../../utils/skillProfileListHelpers' import {
formatClubPercent,
formatSkillWeight,
kpiRowsFromSummary,
peerPercentSuffix,
} from '../../utils/skillProfileListHelpers'
/** /**
* Kleine KPI-Kacheln: je Unterkategorie die Top-Fähigkeit (Listen/Karten). * Kleine KPI-Kacheln: je Unterkategorie die Top-Fähigkeit (Listen/Karten).
*/ */
export default function SkillProfileCompact({ export default function SkillProfileCompact({
summary, summary,
artifactType = 'training_module',
loading = false, loading = false,
emptyText = 'Keine Fähigkeiten', emptyText = 'Keine Fähigkeiten',
displayLimit = 24, displayLimit = 24,
@ -21,6 +27,7 @@ export default function SkillProfileCompact({
const rows = kpiRowsFromSummary(summary, { limit: displayLimit }) const rows = kpiRowsFromSummary(summary, { limit: displayLimit })
const highlight = new Set((highlightSkillIds || []).map(String)) const highlight = new Set((highlightSkillIds || []).map(String))
const peerLabel = peerPercentSuffix(artifactType)
if (!rows.length) { if (!rows.length) {
return summary ? <p className="form-sub skill-kpi-grid--empty">{emptyText}</p> : null return summary ? <p className="form-sub skill-kpi-grid--empty">{emptyText}</p> : null
@ -39,14 +46,14 @@ export default function SkillProfileCompact({
(highlighted ? ' skill-kpi-tile--highlight' : '') + (highlighted ? ' skill-kpi-tile--highlight' : '') +
(isBest ? ' skill-kpi-tile--best' : '') (isBest ? ' skill-kpi-tile--best' : '')
} }
title={`${row.category_name}: ${row.skill_name} — Gewicht ${formatSkillWeight(row.weight ?? row.score)}, ${formatClubPercent(row.universal_percent)} vom Vereins-Maximum`} title={`${row.category_name}: ${row.skill_name} — Gewicht ${formatSkillWeight(row.weight ?? row.score)}, ${formatClubPercent(row.universal_percent)} unter ${peerLabel}`}
> >
<span className="skill-kpi-tile__cat">{row.category_name}</span> <span className="skill-kpi-tile__cat">{row.category_name}</span>
<span className="skill-kpi-tile__name">{row.skill_name}</span> <span className="skill-kpi-tile__name">{row.skill_name}</span>
<span className="skill-kpi-tile__score">{formatSkillWeight(row.weight ?? row.score)}</span> <span className="skill-kpi-tile__score">{formatSkillWeight(row.weight ?? row.score)}</span>
<span className="skill-kpi-tile__pct"> <span className="skill-kpi-tile__pct">
{isBest ? '★ ' : ''} {isBest ? '★ ' : ''}
{formatClubPercent(row.universal_percent)} {formatClubPercent(row.universal_percent)} {peerLabel}
</span> </span>
</li> </li>
) )

View File

@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import api from '../../utils/api' import api from '../../utils/api'
import { peerCorpusCountLabel } from '../../utils/skillProfileListHelpers'
import SkillProfilePanel from './SkillProfilePanel' import SkillProfilePanel from './SkillProfilePanel'
/** /**
@ -45,6 +46,9 @@ export default function SkillProfileFullModal({
if (!open) return null if (!open) return null
const peerCountLabel = peerCorpusCountLabel(artifactType)
const scale = data?.reference_scale
return ( return (
<div className="skill-profile-modal" role="dialog" aria-modal="true" aria-labelledby="skill-profile-modal-title"> <div className="skill-profile-modal" role="dialog" aria-modal="true" aria-labelledby="skill-profile-modal-title">
<button type="button" className="skill-profile-modal__backdrop" aria-label="Schließen" onClick={onClose} /> <button type="button" className="skill-profile-modal__backdrop" aria-label="Schließen" onClick={onClose} />
@ -66,11 +70,13 @@ export default function SkillProfileFullModal({
displayMode="full" displayMode="full"
embedded embedded
defaultExpanded defaultExpanded
artifactType={artifactType}
/> />
{data?.reference_scale?.scope === 'club' && !loading ? ( {scale && !loading ? (
<p className="form-sub skill-profile-modal__scale"> <p className="form-sub skill-profile-modal__scale">
Vergleichsbasis: {data.reference_scale.artifacts_scanned ?? 0} Vereins-Artefakte ( Vergleichsbasis: {scale.artifacts_scanned ?? 0} sichtbare {peerCountLabel} (
{data.reference_scale.skills_in_corpus ?? 0} Fähigkeiten mit Referenz). {scale.skills_in_corpus ?? 0} Fähigkeiten mit Referenz).
{scale.description ? ` ${scale.description}` : null}
</p> </p>
) : null} ) : null}
</div> </div>

View File

@ -1,4 +1,8 @@
import React, { useMemo, useState } from 'react' import React, { useMemo, useState } from 'react'
import {
formatClubPercent,
peerPercentSuffix,
} from '../../utils/skillProfileListHelpers'
function skillWeight(skill) { function skillWeight(skill) {
return Number(skill?.weight ?? skill?.score ?? 0) return Number(skill?.weight ?? skill?.score ?? 0)
@ -10,12 +14,6 @@ function formatWeight(value) {
return n % 1 === 0 ? String(n) : n.toFixed(1) return n % 1 === 0 ? String(n) : n.toFixed(1)
} }
function formatClubPercent(value) {
if (value == null || !Number.isFinite(Number(value))) return '—'
const n = Math.min(100, Number(value))
return n % 1 === 0 ? `${n}%` : `${n.toFixed(1)}%`
}
function barFillPercent(skill, maxWeight, hasReferenceScale) { function barFillPercent(skill, maxWeight, hasReferenceScale) {
if (hasReferenceScale && skill?.universal_percent != null) { if (hasReferenceScale && skill?.universal_percent != null) {
return Math.min(100, Number(skill.universal_percent)) return Math.min(100, Number(skill.universal_percent))
@ -24,15 +22,15 @@ function barFillPercent(skill, maxWeight, hasReferenceScale) {
return maxWeight > 0 ? Math.min(100, (w / maxWeight) * 100) : 0 return maxWeight > 0 ? Math.min(100, (w / maxWeight) * 100) : 0
} }
function metricLabel(skill, hasReferenceScale) { function metricLabel(skill, hasReferenceScale, peerLabel) {
if (hasReferenceScale && skill?.universal_percent != null) { if (hasReferenceScale && skill?.universal_percent != null) {
const best = skill.is_club_best_for_skill ? ' ★' : '' const best = skill.is_club_best_for_skill ? ' ★' : ''
return `${formatClubPercent(skill.universal_percent)} Verein${best}` return `${formatClubPercent(skill.universal_percent)} ${peerLabel}${best}`
} }
return formatWeight(skillWeight(skill)) return formatWeight(skillWeight(skill))
} }
function SkillRow({ skill, maxWeight, hasReferenceScale }) { function SkillRow({ skill, maxWeight, hasReferenceScale, peerLabel }) {
if (!skill) return null if (!skill) return null
const pct = barFillPercent(skill, maxWeight, hasReferenceScale) const pct = barFillPercent(skill, maxWeight, hasReferenceScale)
return ( return (
@ -45,7 +43,7 @@ function SkillRow({ skill, maxWeight, hasReferenceScale }) {
{skill.skill_name} {skill.skill_name}
</span> </span>
<span className="skill-profile__pct" title="Trainingsgewicht (gewichtete Minuten)"> <span className="skill-profile__pct" title="Trainingsgewicht (gewichtete Minuten)">
{metricLabel(skill, hasReferenceScale)} {metricLabel(skill, hasReferenceScale, peerLabel)}
</span> </span>
</div> </div>
<div className="skill-profile__bar-track" aria-hidden="true"> <div className="skill-profile__bar-track" aria-hidden="true">
@ -57,11 +55,11 @@ function SkillRow({ skill, maxWeight, hasReferenceScale }) {
{skill.club_best ? ( {skill.club_best ? (
<> <>
{' '} {' '}
· Vereins-Top: {skill.club_best.artifact_title || skill.club_best.artifact_id} ( · Top unter {peerLabel}: {skill.club_best.artifact_title || skill.club_best.artifact_id} (
{formatWeight(skill.club_best.weight)}) {formatWeight(skill.club_best.weight)})
</> </>
) : skill.is_club_best_for_skill ? ( ) : skill.is_club_best_for_skill ? (
' · Stärkste Vereins-Nutzung' ' · Stärkster unter ' + peerLabel
) : null} ) : null}
</span> </span>
) : null} ) : null}
@ -69,12 +67,19 @@ function SkillRow({ skill, maxWeight, hasReferenceScale }) {
) )
} }
function CategoryTopSkill({ skill, maxWeight, hasReferenceScale }) { function CategoryTopSkill({ skill, maxWeight, hasReferenceScale, peerLabel }) {
if (!skill) return null if (!skill) return null
return <SkillRow skill={skill} maxWeight={maxWeight} hasReferenceScale={hasReferenceScale} /> return (
<SkillRow
skill={skill}
maxWeight={maxWeight}
hasReferenceScale={hasReferenceScale}
peerLabel={peerLabel}
/>
)
} }
function CategoryGroupedProfile({ profile, ariaLabel }) { function CategoryGroupedProfile({ profile, ariaLabel, peerLabel }) {
const groups = profile?.by_main_category || [] const groups = profile?.by_main_category || []
const hasReferenceScale = Boolean(profile?.has_reference_scale) const hasReferenceScale = Boolean(profile?.has_reference_scale)
const maxWeight = useMemo(() => { const maxWeight = useMemo(() => {
@ -104,6 +109,7 @@ function CategoryGroupedProfile({ profile, ariaLabel }) {
skill={cat.top_skill} skill={cat.top_skill}
maxWeight={maxWeight} maxWeight={maxWeight}
hasReferenceScale={hasReferenceScale} hasReferenceScale={hasReferenceScale}
peerLabel={peerLabel}
/> />
</div> </div>
</li> </li>
@ -115,7 +121,7 @@ function CategoryGroupedProfile({ profile, ariaLabel }) {
) )
} }
function FullSkillsProfile({ profile, ariaLabel }) { function FullSkillsProfile({ profile, ariaLabel, peerLabel }) {
const skills = profile?.skills || [] const skills = profile?.skills || []
const hasReferenceScale = Boolean(profile?.has_reference_scale) const hasReferenceScale = Boolean(profile?.has_reference_scale)
const maxWeight = useMemo( const maxWeight = useMemo(
@ -133,6 +139,7 @@ function FullSkillsProfile({ profile, ariaLabel }) {
skill={sk} skill={sk}
maxWeight={maxWeight} maxWeight={maxWeight}
hasReferenceScale={hasReferenceScale} hasReferenceScale={hasReferenceScale}
peerLabel={peerLabel}
/> />
))} ))}
</ul> </ul>
@ -166,14 +173,17 @@ export default function SkillProfilePanel({
defaultExpanded = true, defaultExpanded = true,
displayMode = 'summary', displayMode = 'summary',
embedded = false, embedded = false,
artifactType = 'framework_program',
}) { }) {
const [expanded, setExpanded] = useState(defaultExpanded) const [expanded, setExpanded] = useState(defaultExpanded)
const [slotOpenId, setSlotOpenId] = useState(null) const [slotOpenId, setSlotOpenId] = useState(null)
const peerLabel = peerPercentSuffix(artifactType)
const defaultHint = const defaultHint =
displayMode === 'full' displayMode === 'full'
? 'Alle verknüpften Fähigkeiten nach Trainingsgewicht. Vergleich zum Vereins-Maximum je Fähigkeit (max. 100 %).' ? `Alle verknüpften Fähigkeiten nach Trainingsgewicht. Prozent = Anteil am stärksten sichtbaren Eintrag unter ${peerLabel} je Fähigkeit.`
: 'Trainingsgewicht aus Dauer, Häufigkeit, Intensität und Stufen-Spanne. Pro Kategorie die stärkste Fähigkeit; Balken = Anteil am Vereins-Maximum.' : `Trainingsgewicht aus Dauer, Häufigkeit, Intensität und Stufen-Spanne. Prozent vergleicht nur unter ${peerLabel}, nicht mit anderen Planungs-Artefakttypen.`
const hintText = hint || defaultHint const hintText = hint || defaultHint
const badge = useMemo(() => topCategoryBadge(profile), [profile]) const badge = useMemo(() => topCategoryBadge(profile), [profile])
@ -237,9 +247,17 @@ export default function SkillProfilePanel({
</div> </div>
{displayMode === 'full' ? ( {displayMode === 'full' ? (
<FullSkillsProfile profile={profile} ariaLabel="Alle Fähigkeiten nach Gewicht" /> <FullSkillsProfile
profile={profile}
ariaLabel="Alle Fähigkeiten nach Gewicht"
peerLabel={peerLabel}
/>
) : ( ) : (
<CategoryGroupedProfile profile={profile} ariaLabel="Top-Fähigkeit je Kategorie" /> <CategoryGroupedProfile
profile={profile}
ariaLabel="Top-Fähigkeit je Kategorie"
peerLabel={peerLabel}
/>
)} )}
</> </>
)} )}
@ -275,11 +293,13 @@ export default function SkillProfilePanel({
<FullSkillsProfile <FullSkillsProfile
profile={sl.profile} profile={sl.profile}
ariaLabel={`Alle Fähigkeiten Session ${(sl.sort_order ?? 0) + 1}`} ariaLabel={`Alle Fähigkeiten Session ${(sl.sort_order ?? 0) + 1}`}
peerLabel={peerLabel}
/> />
) : ( ) : (
<CategoryGroupedProfile <CategoryGroupedProfile
profile={sl.profile} profile={sl.profile}
ariaLabel={`Fähigkeiten Session ${(sl.sort_order ?? 0) + 1}`} ariaLabel={`Fähigkeiten Session ${(sl.sort_order ?? 0) + 1}`}
peerLabel={peerLabel}
/> />
) )
) : null} ) : null}

View File

@ -975,11 +975,11 @@ export default function TrainingFrameworkProgramEditPage() {
{!isNew ? ( {!isNew ? (
<SkillProfilePanel <SkillProfilePanel
title="Fähigkeiten-Schwerpunkte (aus Übungen)" title="Fähigkeiten-Schwerpunkte (aus Übungen)"
hint="Trainingsgewicht aus Dauer, Häufigkeit, Intensität und Stufen-Spanne. Pro Kategorie die stärkste Fähigkeit — Balken relativ zur Bibliothek, nicht 100% innerhalb des Plans."
profile={skillProfileData?.overall} profile={skillProfileData?.overall}
slots={skillProfileData?.slots} slots={skillProfileData?.slots}
loading={skillProfileLoading} loading={skillProfileLoading}
error={skillProfileError} error={skillProfileError}
artifactType="framework_program"
/> />
) : null} ) : null}

View File

@ -429,6 +429,7 @@ export default function TrainingModuleEditPage() {
profile={skillProfileData?.overall} profile={skillProfileData?.overall}
loading={skillProfileLoading} loading={skillProfileLoading}
error={skillProfileError} error={skillProfileError}
artifactType="training_module"
/> />
) : null} ) : null}
<div className="form-row"> <div className="form-row">

View File

@ -86,7 +86,7 @@ export default function TrainingModulesListPage() {
Trainingsmodule Trainingsmodule
</h1> </h1>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '38rem', margin: 0 }}> <p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '38rem', margin: 0 }}>
Wiederverwendbare Übungsfolgen für die Trainingsplanung. Fähigkeiten werden im Vereinskontext verglichen. Wiederverwendbare Übungsfolgen für die Trainingsplanung. Prozentwerte vergleichen Module nur unter sichtbaren Modulen.
</p> </p>
</div> </div>
<NavStateLink <NavStateLink
@ -144,8 +144,9 @@ export default function TrainingModulesListPage() {
<div style={{ marginTop: '0.5rem' }}> <div style={{ marginTop: '0.5rem' }}>
<SkillProfileCompact <SkillProfileCompact
summary={skillSummaries[`training_module:${r.id}`]} summary={skillSummaries[`training_module:${r.id}`]}
artifactType="training_module"
loading={summariesLoading} loading={summariesLoading}
displayLimit={6} displayLimit={12}
/> />
</div> </div>
</div> </div>

View File

@ -153,7 +153,7 @@ export function summarizeFrameworkImportFilters(filters = {}, catalogs = {}) {
parts.push(`Fähigkeiten: ${names.join(', ')}`) parts.push(`Fähigkeiten: ${names.join(', ')}`)
} }
if (Number(f.skillMinClubPercent) > 0) { if (Number(f.skillMinClubPercent) > 0) {
parts.push(`mind. ${f.skillMinClubPercent}% vom Vereins-Maximum`) parts.push(`mind. ${f.skillMinClubPercent}% vom Rahmenprogramm-Maximum`)
} }
if (f.skillSort === 'skill_strength' && (f.skillIds || []).length) { if (f.skillSort === 'skill_strength' && (f.skillIds || []).length) {
parts.push('Sortierung: Fähigkeiten-Stärke') parts.push('Sortierung: Fähigkeiten-Stärke')

View File

@ -47,6 +47,26 @@ export function formatSkillWeight(value) {
return n % 1 === 0 ? String(n) : n.toFixed(1) return n % 1 === 0 ? String(n) : n.toFixed(1)
} }
const PEER_LABELS = {
framework_program: 'Rahmenpr.',
training_module: 'Module',
progression_graph: 'Pfade',
}
const PEER_COUNT_LABELS = {
framework_program: 'Rahmenprogramme',
training_module: 'Module',
progression_graph: 'Regressionspfade',
}
export function peerPercentSuffix(artifactType = 'training_module') {
return PEER_LABELS[artifactType] || 'Peers'
}
export function peerCorpusCountLabel(artifactType = 'training_module') {
return PEER_COUNT_LABELS[artifactType] || 'Planungs-Artefakte'
}
/** KPI-Zeilen: immer Top je Unterkategorie; bei Skill-Filter nur passende Kategorien. */ /** KPI-Zeilen: immer Top je Unterkategorie; bei Skill-Filter nur passende Kategorien. */
export function kpiRowsFromSummary(summary, { skillIds = [], limit = 8 } = {}) { export function kpiRowsFromSummary(summary, { skillIds = [], limit = 8 } = {}) {
if (!summary) return [] if (!summary) return []