shinkan-jinkendo/backend/routers/skill_profiles.py
Lars 5200895a73
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m27s
Update Skill Scoring Specification and Implementation to v1.2
- Enhanced the skill scoring system with category grouping and a universal scale for improved comparability across programs.
- Introduced new calculations for artifact share percentage and universal percent, allowing for a more nuanced understanding of skill contributions.
- Updated the API to reflect changes in the skill profile structure, including main category and top skill details.
- Improved frontend components to display skills by main category, enhancing user experience in skill discovery and profile visualization.
- Adjusted tests to validate the new scoring logic and ensure accurate representation of skills and their weights.
2026-05-21 08:37:58 +02:00

410 lines
15 KiB
Python

"""
Fähigkeiten-Profile und Vorschläge (Phase 3) für Planungsartefakte.
GET …/skill-profile — gewichtetes Profil aus verknüpften Übungen.
GET /api/skill-discovery/suggestions — Rahmenprogramme, Module, Progressionsgraphen nach Fähigkeiten.
"""
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from db import get_db, get_cursor, r2d
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
from skill_scoring import (
GRAPH_DEFAULT_ITEM_MINUTES,
ExerciseOccurrence,
collect_module_exercise_occurrences,
collect_progression_graph_exercise_occurrences,
collect_unit_exercise_occurrences,
compute_corpus_skill_max_weights,
compute_skill_profile,
match_score_for_skill_ids,
profile_for_occurrences,
)
from routers.training_framework_programs import _framework_access
from routers.training_modules import _module_access
from routers.exercise_progression_graphs import _require_graph_read
router = APIRouter(prefix="/api", tags=["skill_profiles"])
def _parse_skill_ids_param(raw: Optional[str]) -> List[int]:
if not raw or not str(raw).strip():
return []
out: List[int] = []
for part in str(raw).split(","):
part = part.strip()
if not part:
continue
try:
n = int(part)
except ValueError:
raise HTTPException(status_code=400, detail="skill_ids: ungültige ID") from None
if n > 0 and n not in out:
out.append(n)
return out
@router.get("/training-framework-programs/{framework_id}/skill-profile")
def framework_program_skill_profile(
framework_id: int,
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
row = _framework_access(cur, framework_id, profile_id, role)
cur.execute(
"""
SELECT s.id, s.sort_order, s.title,
tu.id AS blueprint_unit_id
FROM training_framework_slots s
LEFT JOIN training_units tu ON tu.framework_slot_id = s.id
WHERE s.framework_program_id = %s
ORDER BY s.sort_order
""",
(framework_id,),
)
slots_raw = [r2d(r) for r in cur.fetchall()]
ref_max = compute_corpus_skill_max_weights(
cur,
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
all_occurrences: List[ExerciseOccurrence] = []
slot_profiles: List[Dict[str, Any]] = []
for slot in slots_raw:
uid = slot.get("blueprint_unit_id")
slot_occ: List[ExerciseOccurrence] = []
slot_label = (slot.get("title") or "").strip() or f"Session {(slot.get('sort_order') or 0) + 1}"
if uid:
raw_occ = collect_unit_exercise_occurrences(cur, int(uid))
slot_occ = [
ExerciseOccurrence(
exercise_id=o.exercise_id,
planned_duration_min=o.planned_duration_min,
context_label=slot_label,
)
for o in raw_occ
]
all_occurrences.extend(slot_occ)
else:
slot_occ = []
slot_profile = (
profile_for_occurrences(cur, slot_occ, reference_max_by_skill=ref_max)
if slot_occ
else _empty_profile()
)
slot_profiles.append(
{
"slot_id": slot["id"],
"slot_title": slot.get("title"),
"sort_order": slot.get("sort_order"),
"blueprint_training_unit_id": uid,
"exercise_occurrence_count": len(slot_occ),
"profile": slot_profile,
}
)
overall = (
profile_for_occurrences(cur, all_occurrences, reference_max_by_skill=ref_max)
if all_occurrences
else _empty_profile()
)
return {
"artifact_type": "framework_program",
"artifact_id": framework_id,
"artifact_title": row.get("title"),
"reference_scale": {
"skills_in_corpus": len(ref_max),
"description": "universal_percent = Anteil am höchsten Trainingsgewicht dieser Fähigkeit in der sichtbaren Bibliothek",
},
"overall": overall,
"slots": slot_profiles,
}
@router.get("/training-modules/{module_id}/skill-profile")
def training_module_skill_profile(
module_id: int,
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
row = _module_access(cur, module_id, profile_id, role)
ref_max = compute_corpus_skill_max_weights(
cur,
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
occurrences = collect_module_exercise_occurrences(cur, module_id)
overall = (
profile_for_occurrences(cur, occurrences, reference_max_by_skill=ref_max)
if occurrences
else _empty_profile()
)
return {
"artifact_type": "training_module",
"artifact_id": module_id,
"artifact_title": row.get("title"),
"reference_scale": {
"skills_in_corpus": len(ref_max),
},
"overall": overall,
}
@router.get("/exercise-progression-graphs/{graph_id}/skill-profile")
def progression_graph_skill_profile(
graph_id: int,
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
row = _require_graph_read(cur, graph_id, profile_id, role)
ref_max = compute_corpus_skill_max_weights(
cur,
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
occurrences = collect_progression_graph_exercise_occurrences(cur, graph_id)
overall = (
profile_for_occurrences(
cur,
occurrences,
default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES,
reference_max_by_skill=ref_max,
)
if occurrences
else _empty_profile()
)
return {
"artifact_type": "progression_graph",
"artifact_id": graph_id,
"artifact_title": row.get("name"),
"reference_scale": {
"skills_in_corpus": len(ref_max),
},
"overall": overall,
}
@router.get("/skill-discovery/suggestions")
def skill_discovery_suggestions(
skill_ids: str = Query(..., description="Komma-getrennte skill-IDs"),
types: Optional[str] = Query(
default="framework_program,training_module,progression_graph",
description="Artefakttypen, komma-getrennt",
),
limit: int = Query(default=20, ge=1, le=50),
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Findet Bibliotheksartefakte, deren Übungs-Fähigkeiten-Profil die gewünschten Fähigkeiten stark abdeckt.
"""
wanted = _parse_skill_ids_param(skill_ids)
if not wanted:
raise HTTPException(status_code=400, detail="skill_ids ist Pflicht (mindestens eine ID)")
type_set = {t.strip() for t in (types or "").split(",") if t.strip()}
profile_id = tenant.profile_id
role = tenant.global_role
results: List[Dict[str, Any]] = []
with get_db() as conn:
cur = get_cursor(conn)
if "framework_program" in type_set:
vis_clause, vis_params = library_content_visibility_sql(
alias="fp",
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
cur.execute(
f"""
SELECT fp.id, fp.title
FROM training_framework_programs fp
WHERE ({vis_clause})
ORDER BY fp.updated_at DESC NULLS LAST
LIMIT 80
""",
vis_params,
)
for fp_row in cur.fetchall():
fid = int(fp_row["id"])
try:
_framework_access(cur, fid, profile_id, role)
except HTTPException:
continue
cur.execute(
"""
SELECT tu.id
FROM training_framework_slots s
INNER JOIN training_units tu ON tu.framework_slot_id = s.id
WHERE s.framework_program_id = %s
""",
(fid,),
)
occ: List[ExerciseOccurrence] = []
for u in cur.fetchall():
occ.extend(collect_unit_exercise_occurrences(cur, int(u["id"])))
if not occ:
continue
prof = profile_for_occurrences(cur, occ)
match = match_score_for_skill_ids(prof, wanted)
if match["match_weight"] <= 0:
continue
results.append(
{
"artifact_type": "framework_program",
"artifact_id": fid,
"artifact_title": fp_row["title"],
"path": f"/planning/framework-programs/{fid}",
"match": match,
"skill_profile_summary": {
"total_score": prof.get("total_score"),
"top_by_category": _top_categories_summary(prof),
},
}
)
if "training_module" in type_set:
vis_clause, vis_params = library_content_visibility_sql(
alias="m",
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
cur.execute(
f"""
SELECT m.id, m.title
FROM training_modules m
WHERE ({vis_clause})
ORDER BY m.updated_at DESC NULLS LAST
LIMIT 80
""",
vis_params,
)
for m_row in cur.fetchall():
mid = int(m_row["id"])
try:
_module_access(cur, mid, profile_id, role)
except HTTPException:
continue
occ = collect_module_exercise_occurrences(cur, mid)
if not occ:
continue
prof = profile_for_occurrences(cur, occ)
match = match_score_for_skill_ids(prof, wanted)
if match["match_weight"] <= 0:
continue
results.append(
{
"artifact_type": "training_module",
"artifact_id": mid,
"artifact_title": m_row["title"],
"path": f"/planning/training-modules/{mid}",
"match": match,
"skill_profile_summary": {
"total_score": prof.get("total_score"),
"top_by_category": _top_categories_summary(prof),
},
}
)
if "progression_graph" in type_set:
vis_clause, vis_params = library_content_visibility_sql(
alias="g",
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
cur.execute(
f"""
SELECT g.id, g.name
FROM exercise_progression_graphs g
WHERE ({vis_clause})
ORDER BY g.updated_at DESC NULLS LAST
LIMIT 80
""",
vis_params,
)
for g_row in cur.fetchall():
gid = int(g_row["id"])
try:
_require_graph_read(cur, gid, profile_id, role)
except HTTPException:
continue
occ = collect_progression_graph_exercise_occurrences(cur, gid)
if not occ:
continue
prof = profile_for_occurrences(
cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES
)
match = match_score_for_skill_ids(prof, wanted)
if match["match_weight"] <= 0:
continue
results.append(
{
"artifact_type": "progression_graph",
"artifact_id": gid,
"artifact_title": g_row["name"],
"path": None,
"match": match,
"skill_profile_summary": {
"total_score": prof.get("total_score"),
"top_by_category": _top_categories_summary(prof),
},
}
)
results.sort(
key=lambda x: -float(x.get("match", {}).get("match_score") or x.get("match", {}).get("match_weight") or 0),
)
return {
"skill_ids": wanted,
"types": sorted(type_set),
"suggestions": results[:limit],
}
def _top_categories_summary(profile: Dict[str, Any], limit: int = 6) -> List[Dict[str, Any]]:
"""Kurzliste Top-Fähigkeit je Unterkategorie für Discovery-Treffer."""
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"),
}
)
if len(out) >= limit:
return out
return out
def _empty_profile() -> Dict[str, Any]:
return compute_skill_profile([], {})