""" Gewichtetes Fähigkeiten-Scoring aus Übungsvorkommen (Phase 3, regelbasiert). Aggregiert exercise_skills über alle Übungen eines Artefakts mit Gewichten aus: geplanter Dauer, Vorkommen, Intensität (Nutzeneinschätzung) und Stufen-Spanne (von/bis). is_primary wird bewusst nicht genutzt (perspektivabhängig). development_contribution ist in der UI nicht gepflegt und wird ignoriert. """ 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, } # Synchron zu backend/routers/exercises.py _EXERCISE_SKILL_LEVEL_RANK_SQL / skillLevels.js _LEVEL_RANK = { "basis": 1, "grundlagen": 2, "aufbau": 3, "fortgeschritten": 4, "optimierung": 5, "einsteiger": 1, "experte": 5, "1": 1, "2": 2, "3": 3, "4": 4, "5": 5, } def _level_rank(value: Optional[str]) -> Optional[int]: if value is None: return None key = str(value).strip().lower() if not key: return None rank = _LEVEL_RANK.get(key) return rank if rank is not None else None def _level_range_multiplier( required_level: Optional[str] = None, target_level: Optional[str] = None, ) -> float: """ Stufen-Spanne (von/bis): breitere und höhere Entwicklungsstufen → etwas höheres Gewicht. Fehlen beide Angaben: neutral (1.0). """ rr = _level_rank(required_level) rt = _level_rank(target_level) if rr is None and rt is None: return 1.0 if rr is None: rr = rt if rt is None: rt = rr if rr > rt: rr, rt = rt, rr span = max(1, min(5, rt - rr + 1)) midpoint = (rr + rt) / 2.0 span_mult = 0.92 + 0.04 * span depth_mult = 0.95 + 0.025 * midpoint return span_mult * depth_mult @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( *, intensity: Optional[str] = None, required_level: Optional[str] = None, target_level: Optional[str] = None, ) -> float: mult = 1.0 if intensity: key = str(intensity).strip().lower() mult *= _INTENSITY_MULT.get(key, 1.0) mult *= _level_range_multiplier(required_level, target_level) return mult def _round2(val: float) -> float: return round(val, 2) def _build_by_main_category(skills_out: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Hierarchie Hauptkategorie → Unterkategorie, je Top-Fähigkeit nach absolutem Gewicht.""" main_map: Dict[int, Dict[str, Any]] = {} for sk in skills_out: mc_id = int(sk.get("main_category_id") or 0) mc_name = (sk.get("main_category_name") or "").strip() or "—" cat_id = int(sk.get("category_id") or 0) cat_name = (sk.get("category_name") or sk.get("category") or "").strip() or "—" if mc_id not in main_map: main_map[mc_id] = { "main_category_id": mc_id if mc_id else None, "main_category_name": mc_name, "weight": 0.0, "categories": {}, } main = main_map[mc_id] main["weight"] += float(sk.get("weight") or 0) if cat_id not in main["categories"]: main["categories"][cat_id] = { "category_id": cat_id if cat_id else None, "category_name": cat_name, "weight": 0.0, "skills": [], } cat = main["categories"][cat_id] cat["weight"] += float(sk.get("weight") or 0) cat["skills"].append(sk) result: List[Dict[str, Any]] = [] for mc in sorted(main_map.values(), key=lambda x: (-x["weight"], x.get("main_category_name") or "")): cats_out: List[Dict[str, Any]] = [] for cat in sorted( mc["categories"].values(), key=lambda x: (-x["weight"], x.get("category_name") or ""), ): cat_skills = sorted( cat["skills"], key=lambda x: (-float(x.get("weight") or 0), x.get("skill_name") or ""), ) top = cat_skills[0] if cat_skills else None cats_out.append( { "category_id": cat["category_id"], "category_name": cat["category_name"], "weight": _round2(cat["weight"]), "skills_count": len(cat_skills), "top_skill": top, } ) result.append( { "main_category_id": mc["main_category_id"], "main_category_name": mc["main_category_name"], "weight": _round2(mc["weight"]), "categories": cats_out, } ) return result def _club_universal_percent( weight: float, corpus_ref: float, ) -> tuple[Optional[float], bool]: """ Anteil am Vereins-Maximum (max. 100 %). effective_ref = max(Korpus-Max, eigenes Gewicht) — verhindert Werte >100 %, wenn das Artefakt stärker ist als der bisherige Vereins-Vergleich (z. B. official). """ w = float(weight or 0) ref = float(corpus_ref or 0) if w <= 0: return None, False effective_ref = max(ref, w) pct = min(100.0, w / effective_ref * 100.0) is_best = ref <= 0 or w >= ref - 0.01 return _round2(pct), is_best def _apply_reference_universal_percent( skills_out: List[Dict[str, Any]], reference_max_by_skill: Optional[Dict[int, float]] = None, ) -> None: """ Stärke relativ zum Vereins-Maximum je Fähigkeit (gecappt auf 100 %). """ if not reference_max_by_skill: for sk in skills_out: sk["universal_percent"] = None sk["is_club_best_for_skill"] = False return for sk in skills_out: sid = int(sk["skill_id"]) ref = float(reference_max_by_skill.get(sid) or 0) w = float(sk.get("weight") or 0) pct, is_best = _club_universal_percent(w, ref) sk["universal_percent"] = pct sk["is_club_best_for_skill"] = is_best def compute_skill_profile( occurrences: Sequence[ExerciseOccurrence], skill_rows_by_exercise: Dict[int, List[Dict[str, Any]]], *, default_item_minutes: int = DEFAULT_ITEM_MINUTES, reference_max_by_skill: Optional[Dict[int, float]] = None, ) -> Dict[str, Any]: """ Erzeugt ein normalisiertes Fähigkeiten-Profil aus Übungsvorkommen und exercise_skills. """ 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( intensity=link.get("intensity"), required_level=link.get("required_level"), target_level=link.get("target_level"), ) 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_name") or link.get("category"), "category_id": link.get("category_id"), "category_name": link.get("category_name") or link.get("category"), "main_category_id": link.get("main_category_id"), "main_category_name": link.get("main_category_name"), "focus_areas": link.get("focus_areas"), "weight": 0.0, "occurrence_count": 0, "exercises": {}, } acc = skill_acc[sid] acc["weight"] += contribution acc["occurrence_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_name") or acc.get("category"), "category_id": acc.get("category_id"), "category_name": acc.get("category_name") or acc.get("category"), "main_category_id": acc.get("main_category_id"), "main_category_name": acc.get("main_category_name"), "focus_areas": acc.get("focus_areas"), "weight": _round2(acc["weight"]), "score": _round2(acc["weight"]), "artifact_share_percent": _round2(share), "share_percent": _round2(share), "occurrence_count": acc["occurrence_count"], "top_exercises": ex_list, } ) skills_out.sort(key=lambda x: (-x["weight"], x.get("skill_name") or "")) _apply_reference_universal_percent(skills_out, reference_max_by_skill) by_main_category = _build_by_main_category(skills_out) for mc in by_main_category: for cat in mc.get("categories") or []: top = cat.get("top_skill") if top and reference_max_by_skill: sid = int(top["skill_id"]) ref = float(reference_max_by_skill.get(sid) or 0) pct, is_best = _club_universal_percent(float(top.get("weight") or 0), ref) top["universal_percent"] = pct top["is_club_best_for_skill"] = is_best unique_exercises = len(exercise_meta) return { "computed_at": datetime.now(timezone.utc).isoformat(), "scoring_version": "1.2", "score_unit": "weighted_minutes", "score_unit_label": "Trainingsgewicht (gewichtete Minuten, über Programme vergleichbar)", "total_weight": _round2(total_weight), "total_score": _round2(total_weight), "exercise_occurrence_count": total_occurrences, "distinct_exercise_count": unique_exercises, "exercises_with_skills_count": len(exercises_with_skills), "skills": skills_out, "by_main_category": by_main_category, "has_reference_scale": bool(reference_max_by_skill), } 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, sc.id AS category_id, sc.name AS category_name, mc.id AS main_category_id, mc.name AS main_category_name, s.focus_areas, e.title AS exercise_title FROM exercise_skills es JOIN skills s ON s.id = es.skill_id LEFT JOIN skill_categories sc ON sc.id = s.category_id LEFT JOIN skill_main_categories mc ON mc.id = COALESCE(s.main_category_id, sc.main_category_id) JOIN exercises e ON e.id = es.exercise_id WHERE es.exercise_id IN ({ph}) AND (s.status = 'active' OR s.status IS NULL) ORDER BY es.exercise_id, s.name, 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, reference_max_by_skill: Optional[Dict[int, float]] = None, ) -> Dict[str, Any]: eids = [o.exercise_id for o in occurrences] skills_map = fetch_exercise_skills_bulk(cur, eids) return compute_skill_profile( occurrences, skills_map, default_item_minutes=default_item_minutes, reference_max_by_skill=reference_max_by_skill, ) def merge_skill_weights_into_max( target: Dict[int, float], profile: Dict[str, Any], ) -> None: for sk in profile.get("skills") or []: sid = int(sk["skill_id"]) w = float(sk.get("weight") or 0) if w > target.get(sid, 0.0): target[sid] = w def 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"), "is_club_best_for_skill": top.get("is_club_best_for_skill"), } ) 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) pct, is_best = _club_universal_percent(float(top.get("weight") or 0), ref) top["universal_percent"] = pct top["is_club_best_for_skill"] = is_best 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 = 0, category_limit: int = 48, ) -> Dict[str, Any]: """Leichtgewichtiges Profil für Listen — ohne Übungsdetails. skills_limit=0 → alle Fähigkeiten.""" 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"), "score": s.get("score") or 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 if s.get("is_club_best_for_skill"): entry["is_club_best_for_skill"] = True skills_out.append(entry) if skills_limit > 0 and 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, limit=category_limit), "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 _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, *, artifact_type: str, profile_id: int, role: Optional[str], effective_club_id: Optional[int], include_artifact_summaries: bool, ) -> Dict[str, Any]: """Referenz je Fähigkeit nur innerhalb eines Planungs-Kontexts (ein Artefakttyp, sichtbare Bibliothek).""" from tenant_context import library_content_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]] = {} def ingest(aid: 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=aid, artifact_title=title, ) if include_artifact_summaries: raw_profiles[f"{artifact_type}:{aid}"] = prof if artifact_type == "framework_program": vis_clause, vis_params = library_content_visibility_sql( alias="fp", profile_id=profile_id, role=role or "", 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, ) rows = cur.fetchall() if rows: ids = [int(r["id"]) for r in rows] titles = {int(r["id"]): r.get("title") for r in rows} occ_map = batch_framework_occurrences_by_id(cur, 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) if all_eids else {} profiles = batch_compute_profiles(occ_map, skills_map) for aid, prof in profiles.items(): ingest(aid, titles.get(aid), prof) elif artifact_type == "training_module": vis_clause, vis_params = library_content_visibility_sql( alias="m", profile_id=profile_id, role=role or "", 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, ) rows = cur.fetchall() if rows: ids = [int(r["id"]) for r in rows] titles = {int(r["id"]): r.get("title") for r in rows} occ_map = batch_module_occurrences_by_id(cur, 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) if all_eids else {} profiles = batch_compute_profiles(occ_map, skills_map) for aid, prof in profiles.items(): ingest(aid, titles.get(aid), prof) elif artifact_type == "progression_graph": vis_clause, vis_params = library_content_visibility_sql( alias="g", profile_id=profile_id, role=role or "", 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(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 { "max_by_skill": max_by_skill, "ref_by_skill": ref_by_skill, "artifact_count": artifact_count, "artifact_summaries": artifact_summaries, } 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 { "effective_club_id": effective_club_id, "by_type": by_type, } 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": ( f"Prozent = Anteil am stärksten sichtbaren Eintrag unter {label} je Fähigkeit " f"(nicht gemischt mit anderen Planungs-Artefakttypen)" ), } def compute_corpus_skill_max_weights( cur, *, profile_id: int, role: Optional[str], effective_club_id: Optional[int], limit_per_type: int = 50, artifact_type: str = "framework_program", ) -> Dict[int, float]: """Referenz je Fähigkeit innerhalb eines Planungs-Kontexts (Legacy-Hülle).""" del limit_per_type bundle = compute_planning_corpus_by_type( cur, profile_id=profile_id, role=role, effective_club_id=effective_club_id, ) 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]: """Überlappung eines Profils mit gewünschten Fähigkeiten (für Vorschläge).""" wanted = {int(x) for x in skill_ids if x is not None} if not wanted: return { "match_weight": 0.0, "match_score": 0.0, "match_percent": 0.0, "artifact_focus_percent": 0.0, "matched_skill_ids": [], "matched_skills": [], } 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) artifact_focus = (match_weight / total * 100.0) if total > 0 else 0.0 return { "match_weight": _round2(match_weight), "match_score": _round2(match_weight), "match_percent": _round2(artifact_focus), "artifact_focus_percent": _round2(artifact_focus), "matched_skill_ids": [int(m["skill_id"]) for m in matched], "matched_skills": matched, }