diff --git a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md index f0309d2..5d337d1 100644 --- a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md +++ b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md @@ -2,7 +2,7 @@ **Version:** 0.1 **Datum:** 2026-05-22 -**Status:** P0 in Umsetzung (Hybrid-Retrieval ohne LLM-Intent) +**Status:** P0.1 — Hybrid-Retrieval + Phase-1-Profil-Score (`profile_v1`); LLM-Rerank P2 **Bezüge:** `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` · `AI_PROMPT_TARGET_ARCHITECTURE.md` · `SKILL_SCORING_SPEC.md` · `TRAINING_FRAMEWORK_SPEC.md` §3 (Progressionsgraph) --- @@ -26,7 +26,8 @@ Trainer in der **Trainingsplanung** sollen Übungen finden oder anlegen können |-------|------|---------|-----| | **S0** | Kontext-Pack | SQL/API, deterministisch | ✅ | | **S1a** | Intent strukturieren | Optional LLM `planning_exercise_search_intent` | Heuristik | -| **S1b** | Hybrid-Retrieval | Score: Volltext + Graph + Skills + Plan | ✅ | +| **S1b** | Hybrid-Retrieval | Score: Volltext + Graph + Skills + Plan + **Profil** | ✅ | +| **S1b+** | Profil-Vorselektion | `ExerciseMatchProfile` × `PlanningTargetProfile` | ✅ `profile_v1` | | **S1c** | Rerank + Begründung | Optional LLM `planning_exercise_search_rank` | Regelbasierte `reasons[]` | | **S2** | Neu-Anlage | Bestehende `suggestExerciseAi` + Pack als Zusatzkontext | Später | @@ -81,11 +82,14 @@ score = w_ft * fulltext_rank + w_prog * progression_hit + w_skill * skill_jaccard(anchor, candidate) + w_plan * plan_affinity + + w_profile * profile_match(exercise, target) + w_repeat * (candidate in unit_plan ? -1 : 0) + w_group_repeat * (candidate in group_recent ? -0.5 : 0) ``` -**`reasons[]`** (regelbasiert, Deutsch): z. B. „Nachfolger im Progressionsgraph“, „Fähigkeiten passen zur Anker-Übung“, „Volltext-Treffer“. +**`profile_match`** (0–1): siehe §12–§13 — Katalog-Dimensionen + Skill-Gewichte + Skill-Gap. + +**`reasons[]`** (regelbasiert, Deutsch): z. B. „Nachfolger im Progressionsgraph“, „Fähigkeiten passen zur Anker-Übung“, „Fokusbereich passend zum Planungsziel“, „Deckt Skill-Lücke im bisherigen Plan“, „Volltext-Treffer“. --- @@ -121,6 +125,13 @@ score = w_ft * fulltext_rank "planned_count": 4, "anchor_title": "Partner-Fangspiel" }, + "target_profile_summary": { + "sources": ["framework_catalog", "current_unit_plan", "anchor_exercise"], + "focus_areas": ["Reaktion & Abwehr"], + "top_skills": [{ "skill_id": 12, "name": "Reaktionsgeschwindigkeit", "weight": 1.0 }], + "has_skill_gap": true + }, + "retrieval_phase": "profile_v1", "intent_resolved": "suggest_next", "hits": [ { @@ -128,14 +139,14 @@ score = w_ft * fulltext_rank "title": "…", "summary": "…", "score": 0.78, - "reasons": ["Nachfolger im Progressionsgraph", "3 gemeinsame Fähigkeiten mit Anker-Übung"], + "reasons": ["Nachfolger im Progressionsgraph", "Fokusbereich passend zum Planungsziel"], "focus_area": "…" } ] } ``` -**Modul:** `backend/planning_exercise_suggest.py` · Router `backend/routers/planning_exercise_suggest.py` +**Modul:** `backend/planning_exercise_suggest.py` · `backend/planning_exercise_profiles.py` · Router `backend/routers/planning_exercise_suggest.py` --- @@ -166,6 +177,7 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“: | Phase | Inhalt | |-------|--------| | **P0** ✅ | Context-Pack, Hybrid-Score, API, Picker in Planung | +| **P0.1** ✅ | `ExerciseMatchProfile` / `PlanningTargetProfile`, `profile_v1`, `target_profile_summary` | | **P1** | LLM Intent-JSON; Neu-Anlage mit Pack | | **P2** | LLM-Rerank + Kurzbegründung | | **P3** | Skill-Discovery / Framework-Ziele im Pack | @@ -176,6 +188,7 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“: - **2026-05-22:** Erstfassung; P0 API + Planungs-Picker. - **2026-05-22:** P0 implementiert (`planning_exercise_suggest.py`, Router, Picker); unsaved Formular-Plan noch nicht an API (nur persistierte Einheit). +- **2026-05-22:** P0.1 — `planning_exercise_profiles.py`, Profil-Score in Hybrid-Retrieval, `retrieval_phase: profile_v1`, `target_profile_summary`. --- @@ -184,3 +197,84 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“: - **Ungespeicherte Plan-Änderungen:** API liest DB-Stand der Einheit — offene Formular-Items folgen in P0.1 (Client übergibt `planned_exercise_ids[]`). - **Progressionsgraph-ID:** noch nicht aus UI wählbar (`progression_graph_id` nur per API). - **LLM-Intent / Rerank:** P1/P2 laut Roadmap §9. + +--- + +## 12. ExerciseMatchProfile & PlanningTargetProfile (Phase 1) + +Ziel: deterministische Vorselektion über **Profil-Dimensionen** statt nur Titel/Jaccard. + +### 12.1 ExerciseMatchProfile (pro Übung) + +| Feld | Quelle | +|------|--------| +| `focus_area_ids` | `exercise_focus_areas` (Primary = 1.0, sonst 0.85) | +| `style_direction_ids` | `exercise_style_directions` | +| `training_type_ids` | `exercise_training_types` | +| `target_group_ids` | `exercise_target_groups` | +| `skill_weights` | `exercise_skills` × Intensitäts-Multiplikator (`skill_scoring._skill_link_multiplier`) | + +Bulk-Lader: `load_exercise_match_profiles_bulk(cur, exercise_ids)`. + +### 12.2 PlanningTargetProfile (Planungsziel) + +Zusammensetzung aus mehreren Quellen (`sources[]`): + +| Quelle | Inhalt | +|--------|--------| +| `framework_catalog` | Fokus/Stil/Trainingsstil/Zielgruppe aus `training_framework_program_*` | +| `framework_slot_skill_profile` | Skill-Profil des Slot-Blueprints (`profile_for_occurrences`) | +| `framework_overall_skill_profile` | Fallback: alle Blueprint-Einheiten des Rahmens | +| `current_unit_plan` | Skill-Profil der bereits eingeplanten Übungen dieser Einheit | +| `anchor_exercise` | Katalog + Skills der Anker-Übung (Intent-abhängig) | +| `skill_gap_vs_plan` | `target_skills − plan_skills` (normalisiert, Schwelle > 0.08) | + +Builder: `build_planning_target_profile(cur, unit=…, planned_exercise_ids=…, anchor_exercise_id=…, intent=…)`. + +Rahmen-Anbindung über `unit.framework_slot_id` oder `origin_framework_slot_id`. + +--- + +## 13. Profil-Score (Formeln) + +**Gewichtete Überlappung** (Katalog + Skills): + +``` +overlap(a, b) = Σ min(a[k], b[k]) / Σ max(a[k], b[k]) +``` + +**Skill-Gap-Abdeckung:** + +``` +gap_coverage(gap, candidate) = Σ min(gap[k], candidate[k]) / Σ gap[k] +``` + +**Profil-Score** (intent-gewichtet, Summe Dimensionen = 1.0): + +``` +profile_score = w_focus * overlap(focus) + + w_style * overlap(style) + + w_tt * overlap(training_type) + + w_tg * overlap(target_group) + + w_skill * overlap(skill_weights) + + w_gap * gap_coverage(skill_gap) +``` + +Intent-Gewichte (Auszug): `deepen_exercise` → Skill hoch; `continue_plan_goal` → Gap hoch; `free_search` → Gap + Skill moderat. + +Scorer: `score_exercise_against_target(exercise_profile, target_profile, intent=…) → (score, reasons[])`. + +--- + +## 14. Hybrid + Profil (P0.1) + +Im Hybrid-Score kommt **`w_profile * profile_score`** hinzu (Intent-abhängig ~0.15–0.35). Jaccard auf Anker-Skills bleibt parallel (schneller Anker-Fokus). + +**Response-Felder:** + +| Feld | Bedeutung | +|------|-----------| +| `retrieval_phase` | `"profile_v1"` — Phase-1 aktiv, kein LLM-Rerank | +| `target_profile_summary` | Lesbare Kurzinfo für UI-Chips (Fokus, Top-Skills, Quellen) | + +**Phase 2 (P2):** Top 20–40 Kandidaten nach Hybrid+Profil → LLM `planning_exercise_search_rank` mit **Titel + summary + goal**; nur IDs aus Kandidatenliste. diff --git a/backend/planning_exercise_profiles.py b/backend/planning_exercise_profiles.py new file mode 100644 index 0000000..9f503ed --- /dev/null +++ b/backend/planning_exercise_profiles.py @@ -0,0 +1,448 @@ +""" +ExerciseMatchProfile / PlanningTargetProfile — Phase-1-Vorselektion Planungs-Übungssuche. + +Siehe .claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md §12–§14 +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Sequence, Set, Tuple + +from skill_scoring import ( + ExerciseOccurrence, + collect_unit_exercise_occurrences, + fetch_exercise_skills_bulk, + profile_for_occurrences, + _skill_link_multiplier, + DEFAULT_ITEM_MINUTES, +) + + +def _ids_to_weights(ids: Sequence[int], primary_id: Optional[int] = None) -> Dict[int, float]: + out: Dict[int, float] = {} + for raw in ids or []: + try: + fid = int(raw) + except (TypeError, ValueError): + continue + if fid < 1: + continue + w = 1.0 if primary_id is not None and fid == int(primary_id) else 0.85 + out[fid] = max(out.get(fid, 0.0), w) + return out + + +def _merge_weight_maps(*maps: Optional[Dict[int, float]], scale: float = 1.0) -> Dict[int, float]: + out: Dict[int, float] = {} + for m in maps: + if not m: + continue + for k, v in m.items(): + try: + kid = int(k) + val = float(v) * scale + except (TypeError, ValueError): + continue + if kid < 1 or val <= 0: + continue + out[kid] = max(out.get(kid, 0.0), val) + return out + + +def _normalize_weight_map(m: Dict[int, float]) -> Dict[int, float]: + if not m: + return {} + mx = max(m.values()) + if mx <= 0: + return {} + return {k: v / mx for k, v in m.items() if v > 0} + + +def weighted_overlap(a: Dict[int, float], b: Dict[int, float]) -> float: + """Gewichtete Überlappung 0..1 (min-Summe / max-Summe).""" + if not a or not b: + return 0.0 + keys = set(a) | set(b) + num = sum(min(a.get(k, 0.0), b.get(k, 0.0)) for k in keys) + den = sum(max(a.get(k, 0.0), b.get(k, 0.0)) for k in keys) + return num / den if den > 0 else 0.0 + + +def gap_coverage(gap: Dict[int, float], candidate: Dict[int, float]) -> float: + """Anteil der Skill-Lücke, den der Kandidat abdeckt (0..1).""" + if not gap: + return 0.0 + total_gap = sum(gap.values()) + if total_gap <= 0: + return 0.0 + covered = sum(min(gap.get(k, 0.0), candidate.get(k, 0.0)) for k in gap) + return covered / total_gap + + +@dataclass +class ExerciseMatchProfile: + exercise_id: int + focus_area_ids: Dict[int, float] = field(default_factory=dict) + style_direction_ids: Dict[int, float] = field(default_factory=dict) + training_type_ids: Dict[int, float] = field(default_factory=dict) + target_group_ids: Dict[int, float] = field(default_factory=dict) + skill_weights: Dict[int, float] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + return { + "exercise_id": self.exercise_id, + "focus_area_ids": self.focus_area_ids, + "style_direction_ids": self.style_direction_ids, + "training_type_ids": self.training_type_ids, + "target_group_ids": self.target_group_ids, + "skill_weights": self.skill_weights, + } + + +@dataclass +class PlanningTargetProfile: + focus_area_ids: Dict[int, float] = field(default_factory=dict) + style_direction_ids: Dict[int, float] = field(default_factory=dict) + training_type_ids: Dict[int, float] = field(default_factory=dict) + target_group_ids: Dict[int, float] = field(default_factory=dict) + skill_weights: Dict[int, float] = field(default_factory=dict) + skill_gap_weights: Dict[int, float] = field(default_factory=dict) + sources: List[str] = field(default_factory=list) + + def to_summary_dict(self, cur, limit_skills: int = 5) -> Dict[str, Any]: + focus_labels = _load_focus_labels(cur, list(self.focus_area_ids.keys())[:6]) + top_skills = sorted(self.skill_weights.items(), key=lambda x: -x[1])[:limit_skills] + skill_names = _load_skill_names(cur, [s[0] for s in top_skills]) + return { + "sources": list(self.sources), + "focus_areas": focus_labels, + "top_skills": [ + {"skill_id": sid, "name": skill_names.get(sid, f"#{sid}"), "weight": round(w, 2)} + for sid, w in top_skills + ], + "has_skill_gap": bool(self.skill_gap_weights), + } + + +def _load_focus_labels(cur, ids: Sequence[int]) -> List[str]: + if not ids: + return [] + ph = ",".join(["%s"] * len(ids)) + cur.execute( + f"SELECT id, name FROM focus_areas WHERE id IN ({ph}) ORDER BY name", + list(ids), + ) + return [f"{r['name'] or r['id']}" for r in cur.fetchall()] + + +def _load_skill_names(cur, ids: Sequence[int]) -> Dict[int, str]: + if not ids: + return {} + ph = ",".join(["%s"] * len(ids)) + cur.execute(f"SELECT id, name FROM skills WHERE id IN ({ph})", list(ids)) + return {int(r["id"]): str(r["name"] or "") for r in cur.fetchall()} + + +def _skill_weights_from_profile(skills_out: Sequence[Dict[str, Any]]) -> Dict[int, float]: + out: Dict[int, float] = {} + for row in skills_out or []: + sid = row.get("skill_id") + if sid is None: + continue + w = float(row.get("weight") or row.get("score") or 0) + if w > 0: + out[int(sid)] = w + return out + + +def _single_exercise_skill_weights( + skill_rows: Sequence[Dict[str, Any]], + *, + minutes: float = DEFAULT_ITEM_MINUTES, +) -> Dict[int, float]: + out: Dict[int, float] = {} + for link in skill_rows or []: + sid = link.get("skill_id") + if sid is None: + continue + sid = int(sid) + mult = _skill_link_multiplier( + intensity=link.get("intensity"), + required_level=link.get("required_level"), + target_level=link.get("target_level"), + ) + w = minutes * mult + if w > 0: + out[sid] = out.get(sid, 0.0) + w + return out + + +def _load_relation_maps_bulk( + cur, + exercise_ids: Sequence[int], + table: str, + id_column: str, +) -> Dict[int, Dict[int, float]]: + ids = [int(x) for x in exercise_ids if int(x) > 0] + if not ids: + return {} + ph = ",".join(["%s"] * len(ids)) + cur.execute( + f""" + SELECT exercise_id, {id_column} AS rel_id, is_primary + FROM {table} + WHERE exercise_id IN ({ph}) + """, + ids, + ) + out: Dict[int, Dict[int, float]] = {eid: {} for eid in ids} + for row in cur.fetchall(): + eid = int(row["exercise_id"]) + rid = int(row["rel_id"]) + w = 1.0 if row.get("is_primary") else 0.85 + out.setdefault(eid, {})[rid] = max(out[eid].get(rid, 0.0), w) + return out + + +def load_exercise_match_profiles_bulk(cur, exercise_ids: Sequence[int]) -> Dict[int, ExerciseMatchProfile]: + ids = sorted({int(x) for x in exercise_ids if int(x) > 0}) + if not ids: + return {} + + focus_map = _load_relation_maps_bulk(cur, ids, "exercise_focus_areas", "focus_area_id") + style_map = _load_relation_maps_bulk(cur, ids, "exercise_style_directions", "style_direction_id") + type_map = _load_relation_maps_bulk(cur, ids, "exercise_training_types", "training_type_id") + tg_map = _load_relation_maps_bulk(cur, ids, "exercise_target_groups", "target_group_id") + skills_bulk = fetch_exercise_skills_bulk(cur, ids) + + profiles: Dict[int, ExerciseMatchProfile] = {} + for eid in ids: + profiles[eid] = ExerciseMatchProfile( + exercise_id=eid, + focus_area_ids=focus_map.get(eid, {}), + style_direction_ids=style_map.get(eid, {}), + training_type_ids=type_map.get(eid, {}), + target_group_ids=tg_map.get(eid, {}), + skill_weights=_single_exercise_skill_weights(skills_bulk.get(eid, [])), + ) + return profiles + + +def _resolve_framework_for_unit(cur, unit: Dict[str, Any]) -> Optional[Dict[str, Any]]: + slot_id = unit.get("framework_slot_id") or unit.get("origin_framework_slot_id") + if not slot_id: + return None + cur.execute( + """ + SELECT s.id AS slot_id, s.framework_program_id, s.sort_order, s.title AS slot_title, + fp.title AS framework_title, fp.focus_area_id AS header_focus_area_id + FROM training_framework_slots s + JOIN training_framework_programs fp ON fp.id = s.framework_program_id + WHERE s.id = %s + """, + (int(slot_id),), + ) + row = cur.fetchone() + return dict(row) if row else None + + +def _framework_catalog_weights(cur, framework_id: int) -> Tuple[Dict[int, float], Dict[int, float], Dict[int, float], Dict[int, float]]: + cur.execute( + "SELECT focus_area_id FROM training_framework_programs WHERE id = %s", + (framework_id,), + ) + hdr = cur.fetchone() + header_fa = int(hdr["focus_area_id"]) if hdr and hdr.get("focus_area_id") else None + + cur.execute( + "SELECT focus_area_id FROM training_framework_program_focus_areas WHERE framework_program_id = %s", + (framework_id,), + ) + fa_ids = [int(r["focus_area_id"]) for r in cur.fetchall()] + if header_fa and header_fa not in fa_ids: + fa_ids.insert(0, header_fa) + focus = _ids_to_weights(fa_ids, primary_id=header_fa) + + cur.execute( + "SELECT style_direction_id FROM training_framework_program_style_directions WHERE framework_program_id = %s", + (framework_id,), + ) + style = _ids_to_weights([int(r["style_direction_id"]) for r in cur.fetchall()]) + + cur.execute( + "SELECT training_type_id FROM training_framework_program_training_types WHERE framework_program_id = %s", + (framework_id,), + ) + tt = _ids_to_weights([int(r["training_type_id"]) for r in cur.fetchall()]) + + cur.execute( + "SELECT target_group_id FROM training_framework_program_target_groups WHERE framework_program_id = %s", + (framework_id,), + ) + tg = _ids_to_weights([int(r["target_group_id"]) for r in cur.fetchall()]) + + return focus, style, tt, tg + + +def _profile_from_unit_occurrences(cur, unit_id: int) -> Dict[int, float]: + occ = collect_unit_exercise_occurrences(cur, int(unit_id)) + if not occ: + return {} + prof = profile_for_occurrences(cur, occ, reference_max_by_skill=None) + return _skill_weights_from_profile(prof.get("skills") or []) + + +def build_planning_target_profile( + cur, + *, + unit: Dict[str, Any], + planned_exercise_ids: Sequence[int], + anchor_exercise_id: Optional[int], + intent: str, +) -> PlanningTargetProfile: + sources: List[str] = [] + focus: Dict[int, float] = {} + style: Dict[int, float] = {} + tt: Dict[int, float] = {} + tg: Dict[int, float] = {} + skill_target: Dict[int, float] = {} + skill_plan: Dict[int, float] = {} + + fw = _resolve_framework_for_unit(cur, unit) + if fw: + fid = int(fw["framework_program_id"]) + f_focus, f_style, f_tt, f_tg = _framework_catalog_weights(cur, fid) + focus = _merge_weight_maps(focus, f_focus) + style = _merge_weight_maps(style, f_style) + tt = _merge_weight_maps(tt, f_tt) + tg = _merge_weight_maps(tg, f_tg) + sources.append("framework_catalog") + + slot_id = fw.get("slot_id") + cur.execute( + "SELECT id FROM training_units WHERE framework_slot_id = %s LIMIT 1", + (int(slot_id),), + ) + bp = cur.fetchone() + if bp and bp.get("id"): + slot_skills = _profile_from_unit_occurrences(cur, int(bp["id"])) + if slot_skills: + skill_target = _merge_weight_maps(skill_target, slot_skills, scale=1.0) + sources.append("framework_slot_skill_profile") + if not skill_target: + cur.execute( + """ + SELECT tu.id FROM training_framework_slots s + LEFT JOIN training_units tu ON tu.framework_slot_id = s.id + WHERE s.framework_program_id = %s AND tu.id IS NOT NULL + """, + (fid,), + ) + all_occ: List[ExerciseOccurrence] = [] + for r in cur.fetchall(): + all_occ.extend(collect_unit_exercise_occurrences(cur, int(r["id"]))) + if all_occ: + prof = profile_for_occurrences(cur, all_occ, reference_max_by_skill=None) + skill_target = _merge_weight_maps( + skill_target, _skill_weights_from_profile(prof.get("skills") or []), scale=0.85 + ) + sources.append("framework_overall_skill_profile") + + if planned_exercise_ids: + occ = [ExerciseOccurrence(exercise_id=int(eid)) for eid in planned_exercise_ids] + prof = profile_for_occurrences(cur, occ, reference_max_by_skill=None) + skill_plan = _skill_weights_from_profile(prof.get("skills") or []) + if skill_plan: + sources.append("current_unit_plan") + + if anchor_exercise_id: + anchor_profiles = load_exercise_match_profiles_bulk(cur, [int(anchor_exercise_id)]) + ap = anchor_profiles.get(int(anchor_exercise_id)) + if ap: + if intent in ("deepen_exercise", "suggest_next", "progression_next", "continue_plan_goal"): + skill_target = _merge_weight_maps(skill_target, ap.skill_weights, scale=1.0) + focus = _merge_weight_maps(focus, ap.focus_area_ids, scale=0.9) + style = _merge_weight_maps(style, ap.style_direction_ids, scale=0.75) + tt = _merge_weight_maps(tt, ap.training_type_ids, scale=0.75) + tg = _merge_weight_maps(tg, ap.target_group_ids, scale=0.75) + sources.append("anchor_exercise") + + skill_target = _normalize_weight_map(skill_target) + skill_plan_norm = _normalize_weight_map(skill_plan) + skill_gap: Dict[int, float] = {} + for sid, tw in skill_target.items(): + pw = skill_plan_norm.get(sid, 0.0) + gap = tw - pw * 0.85 + if gap > 0.08: + skill_gap[sid] = gap + if skill_gap: + sources.append("skill_gap_vs_plan") + + return PlanningTargetProfile( + focus_area_ids=_normalize_weight_map(focus) if focus else focus, + style_direction_ids=_normalize_weight_map(style) if style else style, + training_type_ids=_normalize_weight_map(tt) if tt else tt, + target_group_ids=_normalize_weight_map(tg) if tg else tg, + skill_weights=skill_target, + skill_gap_weights=_normalize_weight_map(skill_gap) if skill_gap else skill_gap, + sources=sources, + ) + + +def score_exercise_against_target( + exercise: ExerciseMatchProfile, + target: PlanningTargetProfile, + *, + intent: str, +) -> Tuple[float, List[str]]: + """Profil-Match 0..1 + deutschsprachige Gründe.""" + reasons: List[str] = [] + + focus_sim = weighted_overlap(exercise.focus_area_ids, target.focus_area_ids) + style_sim = weighted_overlap(exercise.style_direction_ids, target.style_direction_ids) + tt_sim = weighted_overlap(exercise.training_type_ids, target.training_type_ids) + tg_sim = weighted_overlap(exercise.target_group_ids, target.target_group_ids) + skill_sim = weighted_overlap( + _normalize_weight_map(exercise.skill_weights), + target.skill_weights, + ) + gap_sim = gap_coverage(target.skill_gap_weights, _normalize_weight_map(exercise.skill_weights)) + + if focus_sim >= 0.5 and target.focus_area_ids: + reasons.append("Fokusbereich passend zum Planungsziel") + if style_sim >= 0.5 and target.style_direction_ids: + reasons.append("Stilrichtung passend") + if tt_sim >= 0.5 and target.training_type_ids: + reasons.append("Trainingsstil passend") + if tg_sim >= 0.5 and target.target_group_ids: + reasons.append("Zielgruppe passend") + if skill_sim >= 0.35 and target.skill_weights: + reasons.append("Fähigkeiten-Schwerpunkt passend (Profilmetrik)") + if gap_sim >= 0.25 and target.skill_gap_weights: + reasons.append("Deckt Skill-Lücke im bisherigen Plan") + + # Intent-gewichtete Dimensionen (Summe = 1.0) + if intent == INTENT_FREE_SEARCH: + weights = {"focus": 0.15, "style": 0.10, "tt": 0.10, "tg": 0.10, "skill": 0.25, "gap": 0.30} + elif intent == INTENT_DEEPEN_EXERCISE: + weights = {"focus": 0.15, "style": 0.10, "tt": 0.10, "tg": 0.05, "skill": 0.45, "gap": 0.15} + elif intent == INTENT_PROGRESSION_NEXT: + weights = {"focus": 0.20, "style": 0.10, "tt": 0.10, "tg": 0.05, "skill": 0.35, "gap": 0.20} + else: + weights = {"focus": 0.20, "style": 0.10, "tt": 0.10, "tg": 0.10, "skill": 0.30, "gap": 0.20} + + score = ( + weights["focus"] * focus_sim + + weights["style"] * style_sim + + weights["tt"] * tt_sim + + weights["tg"] * tg_sim + + weights["skill"] * skill_sim + + weights["gap"] * gap_sim + ) + return max(0.0, min(1.0, score)), reasons + + +# Re-export intent constants for typing (avoid circular import at runtime in suggest module) +INTENT_FREE_SEARCH = "free_search" +INTENT_DEEPEN_EXERCISE = "deepen_exercise" +INTENT_PROGRESSION_NEXT = "progression_next" diff --git a/backend/planning_exercise_suggest.py b/backend/planning_exercise_suggest.py index 0e49c01..f0538a3 100644 --- a/backend/planning_exercise_suggest.py +++ b/backend/planning_exercise_suggest.py @@ -11,8 +11,12 @@ from typing import Any, Dict, List, Optional, Sequence, Set, Tuple from fastapi import HTTPException from pydantic import BaseModel, Field -from tenant_context import TenantContext -from club_tenancy import library_content_visibility_sql +from tenant_context import TenantContext, library_content_visibility_sql +from planning_exercise_profiles import ( + build_planning_target_profile, + load_exercise_match_profiles_bulk, + score_exercise_against_target, +) # Planungs-Berechtigung + Sektionen (bestehende Implementierung) from routers.training_planning import ( @@ -71,23 +75,31 @@ def resolve_planning_exercise_intent(query: Optional[str], intent_hint: Optional def _intent_weights(intent: str) -> Dict[str, float]: base = { - "fulltext": 0.25, - "progression": 0.25, - "skill": 0.20, - "plan": 0.10, + "fulltext": 0.18, + "progression": 0.18, + "skill": 0.12, + "plan": 0.08, + "profile": 0.22, "repeat_unit": -0.30, "repeat_group": -0.15, } if intent == INTENT_SUGGEST_NEXT: - return {**base, "progression": 0.35, "skill": 0.25, "plan": 0.15, "fulltext": 0.10} + return { + **base, + "progression": 0.28, + "skill": 0.12, + "plan": 0.10, + "profile": 0.25, + "fulltext": 0.08, + } if intent == INTENT_PROGRESSION_NEXT: - return {**base, "progression": 0.50, "fulltext": 0.15, "skill": 0.15} + return {**base, "progression": 0.42, "fulltext": 0.12, "skill": 0.10, "profile": 0.20} if intent == INTENT_DEEPEN_EXERCISE: - return {**base, "skill": 0.40, "fulltext": 0.20, "progression": 0.15} + return {**base, "skill": 0.15, "profile": 0.35, "fulltext": 0.15, "progression": 0.10} if intent == INTENT_CONTINUE_PLAN: - return {**base, "plan": 0.30, "skill": 0.25, "fulltext": 0.15, "progression": 0.10} + return {**base, "plan": 0.12, "skill": 0.10, "profile": 0.30, "fulltext": 0.10, "progression": 0.08} if intent == INTENT_FREE_SEARCH: - return {**base, "fulltext": 0.55, "progression": 0.10, "skill": 0.10} + return {**base, "fulltext": 0.45, "progression": 0.08, "skill": 0.08, "profile": 0.15} return base @@ -287,6 +299,11 @@ def build_planning_exercise_context_pack( return { "unit_id": int(body.unit_id), + "unit": { + "id": int(body.unit_id), + "framework_slot_id": unit.get("framework_slot_id"), + "origin_framework_slot_id": unit.get("origin_framework_slot_id"), + }, "unit_title": (unit.get("title") or unit.get("planned_focus") or "").strip() or None, "group_id": unit.get("group_id"), "group_name": (unit.get("group_name") or "").strip() or None, @@ -313,6 +330,14 @@ def suggest_planning_exercises( query = _normalize_query(body.query) intent = resolve_planning_exercise_intent(query, body.intent_hint) weights = _intent_weights(intent) + target_profile = build_planning_target_profile( + cur, + unit=pack["unit"], + planned_exercise_ids=pack["planned_exercise_ids"], + anchor_exercise_id=pack.get("anchor_exercise_id"), + intent=intent, + ) + target_profile_summary = target_profile.to_summary_dict(cur) profile_id = tenant.profile_id role = tenant.global_role @@ -374,9 +399,10 @@ def suggest_planning_exercises( if pack["planned_exercise_ids"]: last_planned_skills = _load_skill_ids_for_exercise(cur, pack["planned_exercise_ids"][-1]) - # Skill-IDs pro Kandidat (Batch) + # Skill-IDs + ExerciseMatchProfile pro Kandidat (Batch) cand_ids = [int(r["id"]) for r in rows] skills_by_ex: Dict[int, Set[int]] = {cid: set() for cid in cand_ids} + match_profiles = load_exercise_match_profiles_bulk(cur, cand_ids) if cand_ids: ph = ",".join(["%s"] * len(cand_ids)) cur.execute( @@ -416,12 +442,20 @@ def suggest_planning_exercises( plan_aff = _skill_jaccard(last_planned_skills, item["skills"]) repeat_unit = 1.0 if eid in planned_set else 0.0 repeat_group = 1.0 if eid in group_recent_set else 0.0 + profile_score = 0.0 + profile_reasons: List[str] = [] + emp = match_profiles.get(eid) + if emp: + profile_score, profile_reasons = score_exercise_against_target( + emp, target_profile, intent=intent + ) score = ( weights["fulltext"] * ft_norm + weights["progression"] * prog_hit + weights["skill"] * skill_sim + weights["plan"] * plan_aff + + weights["profile"] * profile_score + weights["repeat_unit"] * repeat_unit + weights["repeat_group"] * repeat_group ) @@ -442,11 +476,14 @@ def suggest_planning_exercises( reasons.append("Bereits in dieser Einheit eingeplant") if repeat_group > 0 and repeat_unit <= 0: reasons.append("Kürzlich in der Gruppe verwendet") + for pr in profile_reasons: + if pr not in reasons: + reasons.append(pr) if score <= 0 and not reasons and not query: # Leere Query: trotzdem schwache Kandidaten mit Skill/Progression - if prog_hit or skill_sim or plan_aff: - score = 0.05 + prog_hit * 0.3 + skill_sim * 0.2 + if prog_hit or skill_sim or plan_aff or profile_score: + score = 0.05 + prog_hit * 0.3 + skill_sim * 0.2 + profile_score * 0.25 hits.append( { @@ -474,6 +511,8 @@ def suggest_planning_exercises( return { "context_summary": context_summary, + "target_profile_summary": target_profile_summary, + "retrieval_phase": "profile_v1", "intent_resolved": intent, "query_normalized": query or None, "hits": hits, diff --git a/backend/version.py b/backend/version.py index de47331..233cd86 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.167" +APP_VERSION = "0.8.169" BUILD_DATE = "2026-05-22" DB_SCHEMA_VERSION = "20260531071" @@ -27,8 +27,8 @@ MODULE_VERSIONS = { "skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "methods": "0.1.0", - "exercises": "2.34.0", # Planungs-KI P0: POST /planning/exercise-suggest; Picker Kontext - "planning_exercise_suggest": "0.1.0", # Kontext-Pack + Hybrid-Retrieval Übungssuche + "exercises": "2.35.0", # Planungs-KI P0.1: Profil-Score profile_v1 + target_profile_summary + "planning_exercise_suggest": "0.2.1", # Fix Import library_content_visibility_sql aus tenant_context "training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint "training_programs": "0.1.0", "planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung @@ -43,6 +43,21 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.169", + "date": "2026-05-22", + "changes": [ + "Fix: planning_exercise_suggest Import library_content_visibility_sql aus tenant_context (Backend-Start/502).", + ], + }, + { + "version": "0.8.168", + "date": "2026-05-22", + "changes": [ + "Planungs-KI P0.1: ExerciseMatchProfile + PlanningTargetProfile — Profil-Score (Fokus, Stil, Skills, Gap) im Hybrid-Retrieval.", + "API exercise-suggest: retrieval_phase profile_v1, target_profile_summary; Doku §12–§14.", + ], + }, { "version": "0.8.167", "date": "2026-05-22", diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 76309f7..a965601 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -89,10 +89,10 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl - **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`) - **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions -### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.167**) +### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.168**) - **Zielarchitektur (Pflicht fuer Erweiterungen):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Einbindung Planung/Rahmen; Phasenplan P0–P4. -- **Planungs-Übungssuche (P0):** `.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` — Intent-Typen, Context-Pack, Hybrid-Retrieval; **`POST /api/planning/exercise-suggest`**; Frontend **`ExercisePickerModal`** + **`planningContext`** aus **`TrainingUnitEditPage`**. +- **Planungs-Übungssuche (P0.1):** `.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` — Context-Pack, Hybrid-Retrieval + **Profil-Score** (`profile_v1`, `ExerciseMatchProfile` / `PlanningTargetProfile`); **`POST /api/planning/exercise-suggest`**; Frontend **`ExercisePickerModal`** + **`planningContext`** aus **`TrainingUnitEditPage`**. - **Doku:** Umsetzung `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md`; Profil-/JSON-Konzept `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; Ist-Prompt/UI **`AI_PROMPT_SYSTEM_SPEC.md`**; API-Felder **`KI_FEATURES_SPEC.md`** §5.2 - **Kontext / Job:** **`ai_prompt_context`** (Titel, Ziel, Durchführung, Vorbereitung, Trainer-Hinweise, Fokus); **`ai_prompt_job`** — **`run_exercise_form_ai_suggestion`**; **`ai_prompt_runtime`**; **`exercise_ai`** — OpenRouter - **DB:** **`067`** ai_prompts · **`069`** default_template · **`068`** ai_skill_retrieval_profiles · **`070`** openrouter_model · **`071`** **`exercise_instruction_rewrite`** diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx index 3ee890f..e8b0cd4 100644 --- a/frontend/src/components/ExercisePickerModal.jsx +++ b/frontend/src/components/ExercisePickerModal.jsx @@ -67,6 +67,7 @@ export default function ExercisePickerModal({ const [quickAiError, setQuickAiError] = useState('') const [quickCreateDraft, setQuickCreateDraft] = useState(null) const [planningContextSummary, setPlanningContextSummary] = useState(null) + const [planningTargetProfileSummary, setPlanningTargetProfileSummary] = useState(null) const [planningIntentResolved, setPlanningIntentResolved] = useState(null) const pickerScrollRef = useRef(null) @@ -153,6 +154,7 @@ export default function ExercisePickerModal({ setQuickAiError('') setQuickCreateDraft(null) setPlanningContextSummary(null) + setPlanningTargetProfileSummary(null) setPlanningIntentResolved(null) return } @@ -276,6 +278,7 @@ export default function ExercisePickerModal({ Array.isArray(exerciseKindAny) && exerciseKindAny.length > 0 ? exerciseKindAny : undefined, }) setPlanningContextSummary(res?.context_summary || null) + setPlanningTargetProfileSummary(res?.target_profile_summary || null) setPlanningIntentResolved(res?.intent_resolved || null) const hits = (Array.isArray(res?.hits) ? res.hits : []).map((h) => ({ id: h.id, @@ -290,6 +293,7 @@ export default function ExercisePickerModal({ setHasMore(false) } else { setPlanningContextSummary(null) + setPlanningTargetProfileSummary(null) setPlanningIntentResolved(null) const batch = await api.listExercises({ ...queryBase, @@ -307,6 +311,7 @@ export default function ExercisePickerModal({ setList([]) setHasMore(false) setPlanningContextSummary(null) + setPlanningTargetProfileSummary(null) setPlanningIntentResolved(null) } finally { setLoading(false) @@ -508,7 +513,28 @@ export default function ExercisePickerModal({ Anker: {planningContextSummary.anchor_title} ) : null} + {Array.isArray(planningTargetProfileSummary?.focus_areas) && + planningTargetProfileSummary.focus_areas.length > 0 + ? planningTargetProfileSummary.focus_areas.map((fa) => ( + + Fokus: {fa} + + )) + : null} + {Array.isArray(planningTargetProfileSummary?.top_skills) && + planningTargetProfileSummary.top_skills.length > 0 + ? planningTargetProfileSummary.top_skills.slice(0, 3).map((sk) => ( + + {sk.name} + + )) + : null} + {planningTargetProfileSummary?.has_skill_gap ? ( +
+ Skill-Lücke zum bisherigen Plan berücksichtigt +
+ ) : null} {planningIntentResolved ? (Modus: {planningIntentResolved.replace(/_/g, ' ')}