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.
986 lines
34 KiB
Python
986 lines
34 KiB
Python
"""
|
|
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,
|
|
}
|