Enhance Planning Exercise Suggestion Features and Update Application Version to 0.8.169
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s

- Implemented Phase 1.1 of the planning exercise suggestion functionality, integrating `ExerciseMatchProfile` and `PlanningTargetProfile` for improved exercise scoring based on profile dimensions.
- Updated the `suggestPlanningExercises` API to include a new `retrieval_phase` and `target_profile_summary`, enhancing the context provided to the frontend.
- Enhanced the `ExercisePickerModal` to display additional information from the planning target profile, including focus areas and top skills, improving user experience during exercise selection.
- Incremented application version to 0.8.169 and updated changelog to reflect the new features and improvements in the planning AI capabilities.
This commit is contained in:
Lars 2026-05-22 22:04:34 +02:00
parent d7d45a8927
commit 128a9d752e
6 changed files with 646 additions and 24 deletions

View File

@ -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`** (01): 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.150.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 2040 Kandidaten nach Hybrid+Profil → LLM `planning_exercise_search_rank` mit **Titel + summary + goal**; nur IDs aus Kandidatenliste.

View File

@ -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"

View File

@ -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,

View File

@ -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",

View File

@ -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 P0P4.
- **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`**

View File

@ -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}
</span>
) : null}
{Array.isArray(planningTargetProfileSummary?.focus_areas) &&
planningTargetProfileSummary.focus_areas.length > 0
? planningTargetProfileSummary.focus_areas.map((fa) => (
<span key={fa} className="exercise-tag">
Fokus: {fa}
</span>
))
: null}
{Array.isArray(planningTargetProfileSummary?.top_skills) &&
planningTargetProfileSummary.top_skills.length > 0
? planningTargetProfileSummary.top_skills.slice(0, 3).map((sk) => (
<span key={sk.skill_id} className="exercise-tag">
{sk.name}
</span>
))
: null}
</div>
{planningTargetProfileSummary?.has_skill_gap ? (
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
Skill-Lücke zum bisherigen Plan berücksichtigt
</p>
) : null}
{planningIntentResolved ? (
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
Modus: {planningIntentResolved.replace(/_/g, ' ')}