Verbesserung Suche und Neuanlage von Übungen #50

Merged
Lars merged 3 commits from develop into main 2026-05-23 10:52:42 +02:00
22 changed files with 1404 additions and 155 deletions

View File

@ -1,8 +1,8 @@
# Planungs-KI: Übungssuche & Kontext für Neu-Anlage
**Version:** 0.1
**Datum:** 2026-05-22
**Status:** P1 — Szenario-Pipeline + LLM Query-Intent-Overlay; P2 LLM-Rerank optional
**Version:** 0.2
**Datum:** 2026-05-23
**Status:** P0P2 ✅ · Phase A/B/B2 ✅ · **Phase C1 ✅** (Graph auto-match + variantenbewusste Nachfolger) · C2C3 geplant
**Bezüge:** `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` · `AI_PROMPT_TARGET_ARCHITECTURE.md` · `SKILL_SCORING_SPEC.md` · `TRAINING_FRAMEWORK_SPEC.md` §3 (Progressionsgraph)
---
@ -62,8 +62,11 @@ Serverseitig aus Request + DB (tokenbewusst für spätere LLM-Stufen):
| `planned_exercise_ids[]` | Items der Einheit (Reihenfolge) | „N Übungen im Plan“ |
| `anchor_exercise_id`, Titel | Request oder letzte Übung vor Einfügepunkt | Anker |
| `anchor_skill_ids[]` | `exercise_skills` | (intern) |
| `progression_graph_id` | Request (optional) | Graph |
| `progression_successor_ids[]` | `exercise_progression_edges` ab Anker | (intern) |
| `progression_graph_id` | Request oder **Auto-Match** vom Anker (sichtbarer Graph mit passenden Ausgangskanten) | Graph |
| `progression_graph_name`, `progression_graph_auto_resolved` | Response `context_summary` | Graph (auto) |
| `anchor_exercise_variant_id` | Request / Abschnitt-Item / DB | (intern) |
| `progression_successor_ids[]` | `exercise_progression_edges` ab Anker (variantenbewusst, Migration **034**) | (intern) |
| `progression_successor_variants` | `to_exercise_variant_id` pro Nachfolger | (intern) |
| `group_recent_exercise_ids[]` | Letzte Einheiten derselben Gruppe | Wiederholungsstrafe |
| `framework_slot_notes` | Rahmen-Slot falls `framework_slot_id` | (später) |
@ -106,6 +109,7 @@ score = w_ft * fulltext_rank
"phase_order_index": null,
"parallel_stream_order_index": null,
"anchor_exercise_id": 456,
"anchor_exercise_variant_id": 12,
"progression_graph_id": 7,
"query": "Schlage mir die nächste Übung vor",
"intent_hint": "suggest_next",
@ -156,8 +160,9 @@ score = w_ft * fulltext_rank
|-----|-----------|
| `ExercisePickerModal` | Prop `planningContext` → Planungs-API statt reiner `listExercises`; Kontext-Chips; `reasons` unter Treffer |
| `TrainingUnitEditPage` | `planningContext` aus Einheit + Picker-Ziel (Anker = letzte Übung im Abschnitt) |
| **`ExercisesListPageRoot`** | Schalter **„Neu mit KI-Assistent“**: Planungs-KI-Suche (frei, ohne `unit_id`) + Neuanlage im Modal; **„+ Neu“** ausgeblendet |
| Rahmen / Kombi-Formular | analog, sobald `unit_id` / Slot-Blueprint bekannt |
| Übungsliste | weiter Volltext; Schalter „Neu mit KI-Assistent“ ohne Planungs-Pack |
| Übungsliste (ohne KI-Schalter) | weiter Volltext |
**Zweites Suchfeld** im Picker: Query = Volltext + ergänzender Begriff (ODER in P0 als Konkatenation an Backend).
@ -174,18 +179,26 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“:
## 9. Phasen-Roadmap
| Phase | Inhalt |
|-------|--------|
| **P0** ✅ | Context-Pack, Hybrid-Score, API, Picker in Planung |
| **P0.1** ✅ | `ExerciseMatchProfile` / `PlanningTargetProfile`, `profile_v1`, `target_profile_summary` |
| **P2** ✅ (optional) | LLM-Rerank `planning_exercise_search_rank`, `include_llm_rank`, `llm_rank_applied` |
| **P1** ✅ | Szenario-Pipeline + LLM Query-Intent → Erwartungsprofil-Overlay |
| **P3** | Skill-Discovery / Framework-Ziele im Pack |
| Phase | Inhalt | Status |
|-------|--------|--------|
| **P0** | Context-Pack, Hybrid-Score, API, Picker in Planung | ✅ |
| **P0.1** | `ExerciseMatchProfile` / `PlanningTargetProfile`, `profile_v1` | ✅ |
| **P1** | Szenario-Pipeline + LLM Query-Intent → Erwartungsprofil | ✅ |
| **P2 / B2** | LLM-Rerank bei engem Top-Feld (max. 2 Calls) | ✅ |
| **P3** | Skill-Discovery / Framework-Ziele im Pack | 🔲 |
| **A** | Voll-Library Hybrid-Ranking | ✅ **0.8.177** |
| **B** | Text-Signale guidance/Rahmen-Ziele | ✅ **0.8.181** |
| **C1** | Graph auto-match + variantenbewusste Nachfolger | ✅ **0.8.183** |
| **C2** | Varianten in Trefferliste / Picker | 🔲 |
| **C3** | Graph-Builder (Ziel → Pfad → speichern) | 🔲 |
| **D** | Neu-Anlage: Pack an `suggestExerciseAi` | 🔲 |
---
## 10. Changelog
- **2026-05-23:** Phase C1 — Graph auto-match, variantenbewusste Nachfolger (`planning_exercise_progression.py`).
- **2026-05-23:** Phase B2 — Rerank bei engem Top-Feld; Phase B — Text-Signale; Phase A — Voll-Library (siehe §17§19).
- **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`.
@ -193,11 +206,16 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“:
---
## 11. Bekannte P0-Lücken
## 11. Bekannte Lücken & Backlog
- **Ungespeicherte Plan-Änderungen:** ✅ Client übergibt `planned_exercise_ids[]` aus Formular (TrainingUnitEditPage).
- **Progressionsgraph-ID:** noch nicht aus UI wählbar (`progression_graph_id` nur per API).
- **Progressionsgraph-ID:** ✅ Auto-Match vom Anker (**C1**); manuelle Auswahl in UI noch offen.
- **Anker-Variante:** ✅ Client + DB (**C1**); Picker wählt Variante bei Treffer noch nicht (**C2**).
- **Graph-Builder:** Ziel eingeben → aufbauende Übungen → in Graph speichern (**C3**) — Compound-Nutzen über viele Pläne.
- **Varianten-Suche:** Library-Picker nutzt `include_variants`; Planungs-KI rankt primär **Übungsebene** — Varianten-Expansion nur gezielt (**C2**).
- **Enrichment:** Superadmin-Tool für Skills; Datenqualität der Bibliothek entscheidend für Profil-Score.
- **LLM-Intent:** ✅ P1 Szenario-Pipeline + `planning_exercise_search_intent` (Migration 073).
- **Preset + LLM:** ✅ Erwartungs-LLM (074) bei Planungsbezug; Preset ohne Plan = kein Erwartungs-LLM.
---
@ -209,7 +227,7 @@ Komplexe Planungsanfragen brauchen **Schritte vor** dem Profil-Match — nicht j
| `scenario_kind` | Typische Anfrage | LLM Intent? |
|-----------------|------------------|-------------|
| `preset_next` | „Nächste Übung vorschlagen“ (Preset) | Nein — nur Basis-Profil |
| `preset_next` | „Nächste Übung vorschlagen“ (Preset) | Erwartungs-LLM (074) wenn Planungsbezug |
| `progression` | Progressionsgraph / Pfad | Ja (wenn Freitext) |
| `deepen` | Vertiefung Anker | Ja |
| `continue_plan` | Auf bisherigen Plan aufbauen | Ja |
@ -256,7 +274,7 @@ Module: `planning_exercise_target_pipeline.py` · `planning_exercise_intent.py`
| Feld | Typ | Default | Bedeutung |
|------|-----|---------|-----------|
| `planned_exercise_ids` | `int[]` | — | Optional: Reihenfolge aus Formular (überschreibt DB-Plan) |
| `include_llm_rank` | `bool` | `false` | Top-32 Hybrid-Kandidaten → OpenRouter Prompt `planning_exercise_search_rank` |
| `include_llm_rank` | `bool` | `true` (Client) | Backend gated (B2): Rerank nur bei engem Top-Feld, max. 2 LLM-Calls |
**Response:**
@ -349,4 +367,60 @@ Im Hybrid-Score kommt **`w_profile * profile_score`** hinzu (Intent-abhängig ~0
| `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):** siehe §15 — optional per `include_llm_rank`.
**Phase 2 (P2 / B2):** siehe §15 und §18 — `include_llm_rank: true` vom Client, Backend entscheidet.
---
## 17. Phase A — Voll-Library-Ranking (0.8.177)
- Kein OR-Profil-Pool (~500 Übungen) mehr.
- Alle sichtbaren Übungen (bis 8000) werden hybrid gescored (`fetch_all_visible_exercise_rows` + `rank_visible_library_hits`).
- API: `full_library_ranked: true`, `retrieval_phase` enthält `+full_library+`.
---
## 18. Phase B / B2 — Text-Signale & Rerank-Gates (0.8.1810.8.182)
**B — Text-Signale (`planning_exercise_text_signals.py`):**
- `section_guidance_notes`, Rahmen-Ziele/Notizen → Skill-/Katalog-Gewichte ohne LLM.
- `requires_partner` aus Intent filtert Kandidaten.
- `retrieval_phase +text_signals`.
**B2 — Rerank bei unklarem Ranking:**
- `hybrid_ranking_ambiguous(hits)` (Top-4-/Top-10-Gap).
- Rerank auch nach Erwartungs-/Intent-LLM, wenn Scores eng beieinander.
- Budget: max. **2** LLM-Calls (Profil + optional Rerank).
---
## 19. Phase C1 — Progressionsgraph im Planungskontext (0.8.183)
**Modul:** `planning_exercise_progression.py`
### Auto-Match Graph
Wenn `progression_graph_id` fehlt und Anker-Übung gesetzt: sichtbarer Graph mit passender `next_exercise`-Kante vom Anker (variantenbewusst). Bevorzugung: variantenspezifische Kanten > Anzahl Kanten.
### Variantenbewusste Nachfolger (Migration 034)
Generische Kante (`from_exercise_variant_id IS NULL`) gilt für jeden Anker; variantenspezifische Kante nur bei passender Anker-Variante.
Treffer: optional `hits[].suggested_variant_id`.
### Request / Response
| Feld | Bedeutung |
|------|-----------|
| `anchor_exercise_variant_id` | Request — Variante der Anker-Übung |
| `progression_graph_name` | Response — Name des (auto-)Graphs |
| `progression_graph_auto_resolved` | Response — Auto-Match aktiv |
---
## 20. Phase C2 / C3 — Roadmap (offen)
**C2:** Varianten in Trefferliste / Picker-Auswahl bei Graph-Treffern.
**C3:** Graph-Builder — Ziel eingeben, aufbauende Übungen vorschlagen, nach Review in Graph speichern (`POST …/edges/sequence`). Nutzen über viele Pläne hinweg.

View File

@ -8,6 +8,10 @@ from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
from planning_exercise_text_signals import (
load_framework_planning_text_parts,
resolve_planning_text_to_catalog_weights,
)
from skill_scoring import (
ExerciseOccurrence,
collect_unit_exercise_occurrences,
@ -339,6 +343,8 @@ def build_planning_target_profile(
section_planned_exercise_ids: Optional[Sequence[int]] = None,
anchor_exercise_id: Optional[int],
intent: str,
section_guidance_notes: Optional[str] = None,
section_title: Optional[str] = None,
) -> PlanningTargetProfile:
sources: List[str] = []
focus: Dict[int, float] = {}
@ -414,6 +420,30 @@ def build_planning_target_profile(
tg = _merge_weight_maps(tg, ap.target_group_ids, scale=0.75)
sources.append("anchor_exercise")
text_parts: List[str] = []
if (section_title or "").strip():
text_parts.append(str(section_title).strip())
if (section_guidance_notes or "").strip():
text_parts.append(str(section_guidance_notes).strip())
if fw:
text_parts.extend(
load_framework_planning_text_parts(
cur,
int(fw["framework_program_id"]),
slot_id=int(fw["slot_id"]) if fw.get("slot_id") else None,
)
)
if text_parts:
blob = "\n".join(text_parts)
tf, ts, ttt, ttg, tsk = resolve_planning_text_to_catalog_weights(cur, blob)
if tf or ts or ttt or ttg or tsk:
focus = _merge_weight_maps(focus, tf, scale=0.88)
style = _merge_weight_maps(style, ts, scale=0.8)
tt = _merge_weight_maps(tt, ttt, scale=0.8)
tg = _merge_weight_maps(tg, ttg, scale=0.8)
skill_target = _merge_weight_maps(skill_target, tsk, scale=0.92)
sources.append("planning_text_signals")
skill_target = _normalize_weight_map(skill_target)
skill_plan_norm = _normalize_weight_map(skill_plan)
skill_gap: Dict[int, float] = {}
@ -470,6 +500,8 @@ def score_exercise_against_target(
reasons.append("Deckt Skill-Lücke im bisherigen Plan")
if "query_intent" in (target.sources or []):
reasons.append("Passt zur KI-interpretierten Suchanfrage")
if "planning_text_signals" in (target.sources or []):
reasons.append("Passt zu Abschnitts- oder Rahmen-Zieltext")
# Intent-gewichtete Dimensionen (Summe = 1.0)
if intent == INTENT_FREE_SEARCH:

View File

@ -0,0 +1,210 @@
"""
Progressionsgraph-Auflösung für Planungs-KI (Phase C1).
Variantenbewusste Nachfolger-Kanten (Migration 034) und Auto-Match eines sichtbaren Graphen
anhand der Anker-Übung, wenn der Client keine graph_id sendet.
"""
from __future__ import annotations
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
from tenant_context import TenantContext, library_content_visibility_sql
ProgressionSuccessorBundle = Tuple[Set[int], Dict[int, str], Dict[int, Optional[int]]]
def edge_matches_anchor_from(
edge: Mapping[str, Any],
from_variant_id: Optional[int],
) -> bool:
"""Kante gilt als Ausgang vom Anker: generische Kante oder passende Varianten-Kante."""
edge_var = edge.get("from_exercise_variant_id")
if edge_var is None:
return True
if from_variant_id is None:
return False
try:
return int(edge_var) == int(from_variant_id)
except (TypeError, ValueError):
return False
def filter_outgoing_progression_edges(
edges: Sequence[Mapping[str, Any]],
*,
from_variant_id: Optional[int],
) -> List[Mapping[str, Any]]:
return [e for e in edges if edge_matches_anchor_from(e, from_variant_id)]
def parse_successors_from_edges(
edges: Sequence[Mapping[str, Any]],
) -> ProgressionSuccessorBundle:
ids: Set[int] = set()
notes: Dict[int, str] = {}
variants: Dict[int, Optional[int]] = {}
for row in edges:
tid = int(row["to_exercise_id"])
ids.add(tid)
n = (row.get("notes") or "").strip()
if n:
notes[tid] = n
raw_v = row.get("to_exercise_variant_id")
variants[tid] = int(raw_v) if raw_v is not None else None
return ids, notes, variants
def rank_progression_graph_rows(rows: Sequence[Mapping[str, Any]]) -> Optional[Mapping[str, Any]]:
if not rows:
return None
def _key(row: Mapping[str, Any]) -> Tuple[int, int, int]:
var_match = int(row.get("variant_match_count") or 0)
out_count = int(row.get("outgoing_count") or 0)
gid = int(row.get("id") or 0)
return (var_match, out_count, gid)
return max(rows, key=_key)
def resolve_progression_graph_for_planning(
cur,
tenant: TenantContext,
*,
from_exercise_id: Optional[int],
from_variant_id: Optional[int],
explicit_graph_id: Optional[int],
) -> Tuple[Optional[int], Optional[str], bool]:
"""
Liefert (graph_id, graph_name, auto_resolved).
Bei explicit_graph_id: Sichtbarkeit prüfen, kein Auto-Match.
Sonst: sichtbarer Graph mit passenden Ausgangskanten vom Anker.
"""
profile_id = tenant.profile_id
role = tenant.global_role
vis_sql, vis_params = library_content_visibility_sql(
alias="g",
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
if explicit_graph_id and int(explicit_graph_id) > 0:
gid = int(explicit_graph_id)
cur.execute(
f"""
SELECT g.id, g.name
FROM exercise_progression_graphs g
WHERE g.id = %s AND ({vis_sql})
""",
[gid, *vis_params],
)
row = cur.fetchone()
if not row:
return None, None, False
name = (row.get("name") or "").strip() or None
return gid, name, False
if not from_exercise_id or int(from_exercise_id) < 1:
return None, None, False
anchor_var = int(from_variant_id) if from_variant_id is not None else None
cur.execute(
f"""
SELECT g.id, g.name,
COUNT(*)::int AS outgoing_count,
COUNT(*) FILTER (
WHERE e.from_exercise_variant_id IS NOT NULL
AND (%s IS NOT NULL)
AND e.from_exercise_variant_id = %s
)::int AS variant_match_count
FROM exercise_progression_edges e
INNER JOIN exercise_progression_graphs g ON g.id = e.graph_id
WHERE e.from_exercise_id = %s
AND LOWER(TRIM(e.edge_type)) = 'next_exercise'
AND ({vis_sql})
AND (
e.from_exercise_variant_id IS NULL
OR (%s IS NULL)
OR e.from_exercise_variant_id = %s
)
GROUP BY g.id, g.name
""",
[anchor_var, anchor_var, int(from_exercise_id), *vis_params, anchor_var, anchor_var],
)
picked = rank_progression_graph_rows(cur.fetchall())
if not picked:
return None, None, False
gid = int(picked["id"])
name = (picked.get("name") or "").strip() or None
return gid, name, True
def load_progression_successors_for_anchor(
cur,
*,
graph_id: Optional[int],
from_exercise_id: Optional[int],
from_variant_id: Optional[int],
) -> ProgressionSuccessorBundle:
if not graph_id or not from_exercise_id:
return set(), {}, {}
cur.execute(
"""
SELECT to_exercise_id, to_exercise_variant_id, notes, from_exercise_variant_id
FROM exercise_progression_edges
WHERE graph_id = %s AND from_exercise_id = %s
AND LOWER(TRIM(edge_type)) = 'next_exercise'
""",
(int(graph_id), int(from_exercise_id)),
)
rows = [dict(r) for r in cur.fetchall()]
filtered = filter_outgoing_progression_edges(rows, from_variant_id=from_variant_id)
return parse_successors_from_edges(filtered)
def apply_progression_context_to_pack(
cur,
tenant: TenantContext,
pack: Dict[str, Any],
*,
explicit_graph_id: Optional[int],
anchor_variant_id: Optional[int],
) -> Dict[str, Any]:
"""Pack um aufgelösten Graph und Nachfolger anreichern."""
anchor_id = pack.get("anchor_exercise_id")
pack["anchor_exercise_variant_id"] = anchor_variant_id
graph_id, graph_name, auto_resolved = resolve_progression_graph_for_planning(
cur,
tenant,
from_exercise_id=anchor_id,
from_variant_id=anchor_variant_id,
explicit_graph_id=explicit_graph_id,
)
pack["progression_graph_id"] = graph_id
pack["progression_graph_name"] = graph_name
pack["progression_graph_auto_resolved"] = bool(auto_resolved)
succ_ids, notes, succ_variants = load_progression_successors_for_anchor(
cur,
graph_id=graph_id,
from_exercise_id=anchor_id,
from_variant_id=anchor_variant_id,
)
pack["progression_successor_ids"] = sorted(succ_ids)
pack["progression_edge_notes"] = notes
pack["progression_successor_variants"] = succ_variants
return pack
__all__ = [
"apply_progression_context_to_pack",
"edge_matches_anchor_from",
"filter_outgoing_progression_edges",
"load_progression_successors_for_anchor",
"parse_successors_from_edges",
"rank_progression_graph_rows",
"resolve_progression_graph_for_planning",
]

View File

@ -17,6 +17,17 @@ from planning_exercise_profiles import (
_MAX_LIBRARY_ROWS = 8000
_PROFILE_LOAD_BATCH = 400
_PARTNER_TEXT_MARKERS = ("partner", "paar", "paarweise", "zu zweit")
def _exercise_looks_partner_related(row: Mapping[str, Any]) -> bool:
parts = [
str(row.get("method_archetype") or ""),
str(row.get("title") or ""),
str(row.get("summary") or ""),
]
blob = " ".join(parts).lower()
return any(m in blob for m in _PARTNER_TEXT_MARKERS)
def _skill_jaccard(a: Set[int], b: Set[int]) -> float:
@ -72,7 +83,7 @@ def fetch_all_visible_exercise_rows(
params.extend(ek_filtered)
sql = f"""
SELECT e.id, e.title, e.summary,
SELECT e.id, e.title, e.summary, e.method_archetype,
(
SELECT fa.name FROM exercise_focus_areas efa
JOIN focus_areas fa ON fa.id = efa.focus_area_id
@ -139,6 +150,7 @@ def rank_visible_library_hits(
anchor_skills = set(pack.get("anchor_skill_ids") or [])
anchor_id = pack.get("anchor_exercise_id")
progression_notes = pack.get("progression_edge_notes") or {}
requires_partner = pack.get("requires_partner")
last_planned_skills: Set[int] = set()
planned_ids = pack.get("planned_exercise_ids") or []
@ -154,6 +166,10 @@ def rank_visible_library_hits(
eid = int(row["id"])
if anchor_id and eid == int(anchor_id):
continue
if requires_partner is True and not _exercise_looks_partner_related(row):
continue
if requires_partner is False and _exercise_looks_partner_related(row):
continue
cand_rows.append(row)
cand_ids = [int(r["id"]) for r in cand_rows]
@ -241,6 +257,10 @@ def rank_visible_library_hits(
"reasons": reasons,
}
)
succ_variants = pack.get("progression_successor_variants") or {}
suggested_vid = succ_variants.get(eid)
if suggested_vid:
hits[-1]["suggested_variant_id"] = int(suggested_vid)
hits.sort(key=lambda h: (-h["score"], h.get("title") or ""))
return hits, skills_by_ex

View File

@ -6,7 +6,7 @@ Siehe .claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md
from __future__ import annotations
import re
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
from fastapi import HTTPException
from pydantic import BaseModel, Field
@ -15,6 +15,7 @@ from tenant_context import TenantContext, library_content_visibility_sql
from planning_exercise_profiles import skill_profile_summary_from_exercise_ids
from planning_exercise_retrieval import run_multistage_planning_retrieval
from planning_exercise_llm_rank import try_llm_rerank_planning_hits
from planning_exercise_progression import apply_progression_context_to_pack
from planning_exercise_target_pipeline import (
build_planning_target_with_query_pipeline,
compose_retrieval_phase,
@ -53,6 +54,7 @@ class PlanningExerciseSuggestRequest(BaseModel):
phase_order_index: Optional[int] = Field(default=None, ge=0)
parallel_stream_order_index: Optional[int] = Field(default=None, ge=0)
anchor_exercise_id: Optional[int] = Field(default=None, ge=1)
anchor_exercise_variant_id: Optional[int] = Field(default=None, ge=1)
progression_graph_id: Optional[int] = Field(default=None, ge=1)
query: Optional[str] = ""
intent_hint: Optional[str] = None
@ -169,31 +171,62 @@ def _load_skill_ids_for_exercise(cur, exercise_id: Optional[int]) -> Set[int]:
return {int(r["skill_id"]) for r in cur.fetchall() if r.get("skill_id")}
def _load_progression_successors(
def _resolve_anchor_variant_id(
pack: Mapping[str, Any],
body: PlanningExerciseSuggestRequest,
sections: Optional[Sequence[Dict[str, Any]]] = None,
) -> Optional[int]:
raw = body.anchor_exercise_variant_id
if raw is not None:
try:
vid = int(raw)
except (TypeError, ValueError):
vid = 0
if vid > 0:
return vid
anchor_id = pack.get("anchor_exercise_id")
if not anchor_id or not sections:
return None
sec = _section_for_context(sections, pack.get("section_order_index"))
if not sec:
return None
target = int(anchor_id)
for it in sorted(sec.get("items") or [], key=lambda x: int(x.get("order_index") or 0), reverse=True):
if str(it.get("item_type") or "").strip().lower() == "note":
continue
try:
eid = int(it.get("exercise_id"))
except (TypeError, ValueError):
continue
if eid != target:
continue
raw_v = it.get("exercise_variant_id")
if raw_v is None:
return None
try:
vid = int(raw_v)
except (TypeError, ValueError):
return None
return vid if vid > 0 else None
return None
def _finalize_progression_context(
cur,
graph_id: Optional[int],
from_exercise_id: Optional[int],
) -> Tuple[Set[int], Dict[int, str]]:
if not graph_id or not from_exercise_id:
return set(), {}
cur.execute(
"""
SELECT to_exercise_id, notes
FROM exercise_progression_edges
WHERE graph_id = %s AND from_exercise_id = %s
AND LOWER(TRIM(edge_type)) = 'next_exercise'
""",
(int(graph_id), int(from_exercise_id)),
tenant: TenantContext,
pack: Dict[str, Any],
body: PlanningExerciseSuggestRequest,
*,
sections: Optional[Sequence[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
anchor_variant = _resolve_anchor_variant_id(pack, body, sections)
return apply_progression_context_to_pack(
cur,
tenant,
pack,
explicit_graph_id=body.progression_graph_id,
anchor_variant_id=anchor_variant,
)
ids: Set[int] = set()
notes: Dict[int, str] = {}
for row in cur.fetchall():
tid = int(row["to_exercise_id"])
ids.add(tid)
n = (row.get("notes") or "").strip()
if n:
notes[tid] = n
return ids, notes
def _load_group_recent_exercise_ids(
@ -469,9 +502,6 @@ def build_planning_exercise_context_pack(
planned_ids = _collect_planned_exercise_ids(sections)
anchor_id = _resolve_anchor_from_plan(planned_ids, body.anchor_exercise_id)
anchor_skills = _load_skill_ids_for_exercise(cur, anchor_id)
progression_ids, progression_notes = _load_progression_successors(
cur, body.progression_graph_id, anchor_id
)
group_recent = _load_group_recent_exercise_ids(cur, unit.get("group_id"), int(body.unit_id))
titles = _load_exercise_titles(cur, [x for x in [anchor_id] if x])
@ -493,9 +523,6 @@ def build_planning_exercise_context_pack(
"anchor_exercise_id": anchor_id,
"anchor_title": anchor_title,
"anchor_skill_ids": sorted(anchor_skills),
"progression_graph_id": body.progression_graph_id,
"progression_successor_ids": sorted(progression_ids),
"progression_edge_notes": progression_notes,
"group_recent_exercise_ids": sorted(group_recent),
}
return _attach_planning_context_details(cur, pack, sections=sections, body=body)
@ -527,9 +554,6 @@ def build_client_planning_context_pack(
anchor_id = _resolve_anchor_from_plan(planned_ids, body.anchor_exercise_id)
anchor_skills = _load_skill_ids_for_exercise(cur, anchor_id)
progression_ids, progression_notes = _load_progression_successors(
cur, body.progression_graph_id, anchor_id
)
group_id = body.group_id
group_name = None
@ -559,9 +583,6 @@ def build_client_planning_context_pack(
"anchor_exercise_id": anchor_id,
"anchor_title": anchor_title,
"anchor_skill_ids": sorted(anchor_skills),
"progression_graph_id": body.progression_graph_id,
"progression_successor_ids": sorted(progression_ids),
"progression_edge_notes": progression_notes,
"group_recent_exercise_ids": sorted(group_recent),
"context_mode": "client_free",
}
@ -580,6 +601,12 @@ def suggest_planning_exercises(
pack = build_client_planning_context_pack(cur, tenant=tenant, body=body)
pack = _apply_client_planned_override(cur, pack, body)
pack = _attach_planning_context_details(cur, pack, body=body)
sections_for_variant = None
if body.unit_id and not (body.anchor_exercise_variant_id and int(body.anchor_exercise_variant_id) > 0):
sections_for_variant = _fetch_sections(cur, int(body.unit_id))
pack = _finalize_progression_context(
cur, tenant, pack, body, sections=sections_for_variant
)
query = _normalize_query(body.query)
heuristic_intent = resolve_planning_exercise_intent(query, body.intent_hint)
@ -638,14 +665,20 @@ def suggest_planning_exercises(
target=target_profile,
intent=intent,
intent_weights=weights,
pack=pack,
pack={
**pack,
"requires_partner": query_intent_summary.get("requires_partner"),
},
)
text_signals_applied = "planning_text_signals" in (target_profile.sources or [])
planned_set = set(pack["planned_exercise_ids"])
llm_rank_applied = False
retrieval_phase = compose_retrieval_phase(
full_library=full_library_ranked,
text_signals=text_signals_applied,
query_intent=query_intent_applied,
llm_expectation=llm_expectation_applied,
llm_rank=False,
@ -656,6 +689,7 @@ def suggest_planning_exercises(
include_llm_rank=body.include_llm_rank,
query_intent_applied=query_intent_applied,
llm_expectation_applied=llm_expectation_applied,
has_planning_reference=has_plan_ref,
hits=hits,
)
if run_llm_rank:
@ -681,6 +715,7 @@ def suggest_planning_exercises(
if llm_rank_applied:
retrieval_phase = compose_retrieval_phase(
full_library=full_library_ranked,
text_signals=text_signals_applied,
query_intent=query_intent_applied,
llm_expectation=llm_expectation_applied,
llm_rank=True,
@ -705,6 +740,9 @@ def suggest_planning_exercises(
"anchor_exercise_id": pack.get("anchor_exercise_id"),
"last_section_exercise_title": pack.get("last_section_exercise_title"),
"progression_graph_id": pack.get("progression_graph_id"),
"progression_graph_name": pack.get("progression_graph_name"),
"progression_graph_auto_resolved": pack.get("progression_graph_auto_resolved"),
"anchor_exercise_variant_id": pack.get("anchor_exercise_variant_id"),
"context_mode": pack.get("context_mode") or ("unit" if pack.get("unit_id") else "client_free"),
"unit_skill_profile": pack.get("unit_skill_profile_summary"),
"section_skill_profile": pack.get("section_skill_profile_summary"),
@ -719,6 +757,7 @@ def suggest_planning_exercises(
"query_intent_summary": query_intent_summary,
"retrieval_phase": retrieval_phase,
"full_library_ranked": full_library_ranked,
"text_signals_applied": text_signals_applied,
"profile_preselect_applied": False,
"llm_rank_applied": llm_rank_applied,
"llm_intent_applied": query_intent_applied,

View File

@ -10,7 +10,7 @@ Ablauf:
from __future__ import annotations
import re
from typing import Any, Dict, List, Mapping, Optional, Tuple
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
from planning_exercise_expectation import try_build_planning_expectation_from_context
from planning_exercise_intent import (
@ -135,6 +135,31 @@ def deterministic_rank_confident(hits: Sequence[Mapping[str, Any]], *, gap_thres
return (top - fourth) >= gap_threshold
def hybrid_ranking_ambiguous(
hits: Sequence[Mapping[str, Any]],
*,
top_four_gap: float = 0.08,
top_ten_gap: float = 0.055,
) -> bool:
"""True wenn Top-Kandidaten scores zu nah beieinander liegen — Rerank lohnt sich."""
if len(hits) < 3:
return False
top = float(hits[0].get("score") or 0.0)
if len(hits) >= 4:
fourth = float(hits[3].get("score") or 0.0)
if (top - fourth) < top_four_gap:
return True
if len(hits) >= 10:
tenth = float(hits[9].get("score") or 0.0)
if (top - tenth) < top_ten_gap:
return True
elif len(hits) >= 2:
tail = float(hits[min(len(hits) - 1, 9)].get("score") or 0.0)
if (top - tail) < top_four_gap:
return True
return False
def should_run_llm_rank_pipeline(
query: Optional[str],
scenario: str,
@ -142,26 +167,38 @@ def should_run_llm_rank_pipeline(
include_llm_rank: bool,
query_intent_applied: bool,
llm_expectation_applied: bool = False,
has_planning_reference: bool = True,
hits: Sequence[Mapping[str, Any]],
) -> bool:
"""
Maximal ein LLM-Call pro Request: wenn Intent- oder Erwartungs-LLM lief, kein Rerank.
Rerank nur bei längerer, komplexer Anfrage und unklarem Hybrid-Ranking.
Phase B2: Rerank bei unklarem Hybrid-Ranking auch nach Erwartungs-/Intent-LLM.
Budget: max. 2 LLM-Calls pro Suche (Profil-LLM + optional Rerank).
"""
if not include_llm_rank:
return False
if query_intent_applied or llm_expectation_applied:
if len(hits) < 3:
return False
if scenario == SCENARIO_PRESET_NEXT:
if not hybrid_ranking_ambiguous(hits):
return False
q = _normalize_query(query)
if not q:
return False
profile_llm = query_intent_applied or llm_expectation_applied
if scenario == SCENARIO_PRESET_NEXT:
return has_planning_reference
if scenario == SCENARIO_FREE_SEARCH:
if len(q) < 10 and not profile_llm:
return False
return True
if scenario == SCENARIO_ADDITIVE:
return len(q) >= 12 and not deterministic_rank_confident(hits)
if len(q) < 22:
return False
return not deterministic_rank_confident(hits)
return len(q) >= 8 or profile_llm
if profile_llm:
return True
return len(q) >= 14
def _recalculate_skill_gap(target: PlanningTargetProfile) -> PlanningTargetProfile:
@ -271,6 +308,8 @@ def build_planning_target_with_query_pipeline(
section_planned_exercise_ids=section_planned_exercise_ids or [],
anchor_exercise_id=anchor_exercise_id,
intent=heuristic_intent,
section_guidance_notes=(context_summary.get("section_guidance_notes") or None),
section_title=(context_summary.get("section_title") or None),
)
else:
base = PlanningTargetProfile(sources=["query_only"])
@ -387,6 +426,7 @@ def compose_retrieval_phase(
*,
full_library: bool = False,
profile_preselect: bool = False,
text_signals: bool = False,
query_intent: bool = False,
llm_expectation: bool = False,
llm_rank: bool = False,
@ -394,6 +434,8 @@ def compose_retrieval_phase(
parts = ["profile_v1"]
if full_library or profile_preselect:
parts.append("full_library")
if text_signals:
parts.append("text_signals")
if llm_expectation:
parts.append("llm_expectation")
elif query_intent:
@ -415,4 +457,5 @@ __all__ = [
"should_run_llm_intent_pipeline",
"should_run_llm_rank_pipeline",
"deterministic_rank_confident",
"hybrid_ranking_ambiguous",
]

View File

@ -0,0 +1,201 @@
"""
Phase B: Deterministische TextKatalog-Signale für PlanningTargetProfile.
Mappt Abschnitts-guidance, Rahmen-Ziele/-Notizen und Programmbeschreibung
auf Skill-/Katalog-Gewichte (ohne LLM).
"""
from __future__ import annotations
import re
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
_MIN_SKILL_NAME_LEN = 3
_MAX_SKILL_MATCHES = 12
_MAX_CATALOG_MATCHES = 6
def _normalize_text_blob(*parts: Optional[str]) -> str:
chunks: List[str] = []
for p in parts:
s = (p or "").strip()
if s:
chunks.append(s)
return "\n".join(chunks).lower()
def _load_skills_for_text_match(cur) -> List[Tuple[int, str, int]]:
cur.execute(
"""
SELECT id, name FROM skills
WHERE (status IS NULL OR status = 'active')
AND name IS NOT NULL AND TRIM(name) <> ''
ORDER BY LENGTH(name) DESC, name ASC
"""
)
out: List[Tuple[int, str, int]] = []
for row in cur.fetchall():
name = str(row.get("name") or "").strip()
if len(name) < _MIN_SKILL_NAME_LEN:
continue
out.append((int(row["id"]), name.lower(), len(name)))
return out
def _load_catalog_names(cur, table: str, id_col: str = "id", name_col: str = "name") -> List[Tuple[int, str, int]]:
cur.execute(
f"""
SELECT {id_col} AS id, {name_col} AS name
FROM {table}
WHERE {name_col} IS NOT NULL AND TRIM({name_col}) <> ''
ORDER BY LENGTH({name_col}) DESC, {name_col} ASC
"""
)
out: List[Tuple[int, str, int]] = []
for row in cur.fetchall():
name = str(row.get("name") or "").strip()
if len(name) < 2:
continue
out.append((int(row["id"]), name.lower(), len(name)))
return out
def _match_catalog_names_in_text(
text: str,
catalog_rows: Sequence[Tuple[int, str, int]],
*,
weight: float = 0.85,
limit: int = _MAX_CATALOG_MATCHES,
) -> Dict[int, float]:
if not text or not catalog_rows:
return {}
out: Dict[int, float] = {}
for cid, name_lower, _ in catalog_rows:
if len(out) >= limit:
break
if len(name_lower) < 2:
continue
if name_lower in text:
out[cid] = max(out.get(cid, 0.0), weight)
return out
def _match_skills_in_text(
text: str,
skill_rows: Sequence[Tuple[int, str, int]],
*,
limit: int = _MAX_SKILL_MATCHES,
) -> Dict[int, float]:
if not text or not skill_rows:
return {}
out: Dict[int, float] = {}
for sid, name_lower, name_len in skill_rows:
if len(out) >= limit:
break
if name_len < _MIN_SKILL_NAME_LEN:
continue
if name_lower in text:
w = min(1.0, 0.72 + min(name_len, 20) * 0.012)
out[sid] = max(out.get(sid, 0.0), w)
return out
def load_framework_planning_text_parts(
cur,
framework_program_id: int,
*,
slot_id: Optional[int] = None,
) -> List[str]:
"""Sammelt Rahmen-Texte für Text-Signal-Matching."""
parts: List[str] = []
cur.execute(
"SELECT description FROM training_framework_programs WHERE id = %s",
(int(framework_program_id),),
)
row = cur.fetchone()
if row and (row.get("description") or "").strip():
parts.append(str(row["description"]).strip())
cur.execute(
"""
SELECT title, notes FROM training_framework_goals
WHERE framework_program_id = %s
ORDER BY sort_order ASC
""",
(int(framework_program_id),),
)
for g in cur.fetchall():
t = (g.get("title") or "").strip()
n = (g.get("notes") or "").strip()
if t:
parts.append(t)
if n:
parts.append(n)
if slot_id:
cur.execute(
"SELECT title, notes FROM training_framework_slots WHERE id = %s",
(int(slot_id),),
)
srow = cur.fetchone()
if srow:
st = (srow.get("title") or "").strip()
sn = (srow.get("notes") or "").strip()
if st:
parts.append(st)
if sn:
parts.append(sn)
return parts
def resolve_planning_text_to_catalog_weights(
cur,
text_blob: str,
) -> Tuple[Dict[int, float], Dict[int, float], Dict[int, float], Dict[int, float], Dict[int, float]]:
"""
Returns: focus, style, training_type, target_group, skill weight maps.
"""
text = _normalize_text_blob(text_blob)
if not text or len(text) < 3:
return {}, {}, {}, {}, {}
skill_rows = _load_skills_for_text_match(cur)
focus_rows = _load_catalog_names(cur, "focus_areas")
style_rows = _load_catalog_names(cur, "style_directions")
tt_rows = _load_catalog_names(cur, "training_types")
tg_rows = _load_catalog_names(cur, "target_groups")
skills = _match_skills_in_text(text, skill_rows)
focus = _match_catalog_names_in_text(text, focus_rows, weight=0.88)
style = _match_catalog_names_in_text(text, style_rows, weight=0.82)
tt = _match_catalog_names_in_text(text, tt_rows, weight=0.82)
tg = _match_catalog_names_in_text(text, tg_rows, weight=0.8)
if re.search(r"\bpartner\b|\bpaar\b|\bpaarweise\b|\bzu zweit\b", text):
for gid, name_lower, _ in tg_rows:
if "partner" in name_lower or "paar" in name_lower:
tg[gid] = max(tg.get(gid, 0.0), 0.9)
break
return focus, style, tt, tg, skills
def merge_text_signal_summary(
summary: Mapping[str, Any],
*,
text_sources: Sequence[str],
matched_skills: Sequence[Mapping[str, Any]],
) -> Dict[str, Any]:
out = dict(summary)
if text_sources:
out["text_signal_sources"] = list(text_sources)
if matched_skills:
out["text_signal_skills"] = list(matched_skills)[:8]
return out
__all__ = [
"load_framework_planning_text_parts",
"merge_text_signal_summary",
"resolve_planning_text_to_catalog_weights",
]

View File

@ -0,0 +1,56 @@
"""Tests Progressionsgraph-Auflösung für Planungs-KI (Phase C1)."""
from planning_exercise_progression import (
edge_matches_anchor_from,
filter_outgoing_progression_edges,
parse_successors_from_edges,
rank_progression_graph_rows,
)
def test_edge_matches_anchor_from_generic_edge():
assert edge_matches_anchor_from({"from_exercise_variant_id": None}, None)
assert edge_matches_anchor_from({"from_exercise_variant_id": None}, 5)
def test_edge_matches_anchor_from_variant_specific():
assert edge_matches_anchor_from({"from_exercise_variant_id": 3}, 3)
assert not edge_matches_anchor_from({"from_exercise_variant_id": 3}, None)
assert not edge_matches_anchor_from({"from_exercise_variant_id": 3}, 4)
def test_filter_outgoing_progression_edges():
edges = [
{"from_exercise_variant_id": None, "to_exercise_id": 10},
{"from_exercise_variant_id": 2, "to_exercise_id": 11},
]
filtered = filter_outgoing_progression_edges(edges, from_variant_id=2)
assert len(filtered) == 2
only_generic = filter_outgoing_progression_edges(edges, from_variant_id=None)
assert len(only_generic) == 1
assert only_generic[0]["to_exercise_id"] == 10
def test_parse_successors_from_edges():
ids, notes, variants = parse_successors_from_edges(
[
{
"to_exercise_id": 20,
"to_exercise_variant_id": 7,
"notes": " leicht ",
},
{"to_exercise_id": 21, "to_exercise_variant_id": None, "notes": ""},
]
)
assert ids == {20, 21}
assert notes[20] == "leicht"
assert variants[20] == 7
assert variants[21] is None
def test_rank_progression_graph_rows_prefers_variant_match():
rows = [
{"id": 1, "variant_match_count": 0, "outgoing_count": 5},
{"id": 2, "variant_match_count": 2, "outgoing_count": 1},
]
best = rank_progression_graph_rows(rows)
assert best["id"] == 2

View File

@ -0,0 +1,7 @@
"""Tests Partner-Filter im Planungs-Retrieval."""
from planning_exercise_retrieval import _exercise_looks_partner_related
def test_exercise_partner_heuristic():
assert _exercise_looks_partner_related({"title": "Partner-Fangspiel", "summary": ""})
assert not _exercise_looks_partner_related({"title": "Kihon Solo", "summary": "Allein"})

View File

@ -56,11 +56,11 @@ def test_should_skip_llm_intent_short_free_search():
)
def test_should_skip_llm_rank_when_intent_already_applied():
def test_should_run_llm_rank_when_intent_applied_and_ambiguous():
from planning_exercise_target_pipeline import SCENARIO_ADDITIVE, should_run_llm_rank_pipeline
hits = [{"score": 0.5}, {"score": 0.48}, {"score": 0.47}, {"score": 0.46}]
assert not should_run_llm_rank_pipeline(
assert should_run_llm_rank_pipeline(
"Baut auf dem Plan auf und trainiert zusätzlich Schnellkraft mit Partner",
SCENARIO_ADDITIVE,
include_llm_rank=True,
@ -69,6 +69,36 @@ def test_should_skip_llm_rank_when_intent_already_applied():
)
def test_should_skip_llm_rank_when_ranking_confident():
from planning_exercise_target_pipeline import SCENARIO_PRESET_NEXT, should_run_llm_rank_pipeline
hits = [{"score": 0.9}, {"score": 0.5}, {"score": 0.4}, {"score": 0.3}]
assert not should_run_llm_rank_pipeline(
"",
SCENARIO_PRESET_NEXT,
include_llm_rank=True,
query_intent_applied=False,
llm_expectation_applied=True,
has_planning_reference=True,
hits=hits,
)
def test_should_run_llm_rank_for_preset_when_ambiguous():
from planning_exercise_target_pipeline import SCENARIO_PRESET_NEXT, should_run_llm_rank_pipeline
hits = [{"score": 0.42}, {"score": 0.41}, {"score": 0.4}, {"score": 0.39}]
assert should_run_llm_rank_pipeline(
"",
SCENARIO_PRESET_NEXT,
include_llm_rank=True,
query_intent_applied=False,
llm_expectation_applied=True,
has_planning_reference=True,
hits=hits,
)
def test_compose_retrieval_phase():
assert compose_retrieval_phase(query_intent=False, llm_rank=False) == "profile_v1"
assert compose_retrieval_phase(query_intent=True, llm_rank=True) == "profile_v1+query_intent+llm_rank"
@ -99,10 +129,10 @@ def test_should_run_llm_expectation_for_preset_with_planning_ref():
)
def test_should_skip_llm_rank_when_expectation_applied():
def test_should_skip_llm_rank_when_expectation_applied_but_confident():
from planning_exercise_target_pipeline import SCENARIO_PRESET_NEXT, should_run_llm_rank_pipeline
hits = [{"score": 0.5}, {"score": 0.48}, {"score": 0.47}, {"score": 0.46}]
hits = [{"score": 0.85}, {"score": 0.4}, {"score": 0.35}, {"score": 0.3}]
assert not should_run_llm_rank_pipeline(
"",
SCENARIO_PRESET_NEXT,

View File

@ -0,0 +1,47 @@
"""Tests Phase B: Text-Signale für PlanningTargetProfile."""
from planning_exercise_text_signals import resolve_planning_text_to_catalog_weights
def test_resolve_planning_text_matches_skill_and_partner_hint():
class _Cur:
def execute(self, sql, params=None):
self._sql = sql
def fetchall(self):
sql = getattr(self, "_sql", "")
if "FROM skills" in sql:
return [
{"id": 5, "name": "Kime"},
{"id": 8, "name": "Schnellkraft"},
{"id": 2, "name": "Ab"},
]
if "FROM focus_areas" in sql:
return [{"id": 10, "name": "Karate Technik"}]
if "FROM style_directions" in sql:
return []
if "FROM training_types" in sql:
return []
if "FROM target_groups" in sql:
return [{"id": 3, "name": "Partnerübung"}]
return []
focus, style, tt, tg, skills = resolve_planning_text_to_catalog_weights(
_Cur(),
"Abschnitt: Kime vertiefen mit Partnerübung",
)
assert skills.get(5, 0) > 0
assert 8 not in skills
assert tg.get(3, 0) > 0
assert not style and not tt
def test_resolve_planning_text_empty():
class _Cur:
def execute(self, sql, params=None):
pass
def fetchall(self):
return []
focus, style, tt, tg, skills = resolve_planning_text_to_catalog_weights(_Cur(), " ")
assert not focus and not skills

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.180"
APP_VERSION = "0.8.183"
BUILD_DATE = "2026-05-23"
DB_SCHEMA_VERSION = "20260531074"
@ -29,7 +29,7 @@ MODULE_VERSIONS = {
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0",
"exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay
"planning_exercise_suggest": "0.8.0", # Phase A: Voll-Library-Ranking gegen Erwartungsprofil
"planning_exercise_suggest": "0.11.0", # Phase C1: Graph auto-match + variantenbewusste Nachfolger
"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
@ -44,6 +44,31 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.183",
"date": "2026-05-23",
"changes": [
"Planungs-KI Phase C1: Progressionsgraph auto-match vom Anker; variantenbewusste Nachfolger-Kanten (034).",
"Request/Response: anchor_exercise_variant_id, progression_graph_name, suggested_variant_id in Treffern.",
"Frontend Übungsliste: Planungs-KI-Suche im KI-Assistent-Modus; Neuanlage im Dialog statt „+ Neu“.",
],
},
{
"version": "0.8.182",
"date": "2026-05-23",
"changes": [
"Planungs-KI Phase B2: LLM-Rerank bei engem Top-Feld — auch nach Erwartungs-/Intent-LLM (max. 2 Calls).",
"Preset „Nächste aus Kontext“: Rerank wenn Ranking unklar; Frontend sendet include_llm_rank immer.",
],
},
{
"version": "0.8.181",
"date": "2026-05-23",
"changes": [
"Planungs-KI Phase B: guidance_notes + Rahmen-Ziele/Notizen → Text-Signale im Erwartungsprofil (planning_text_signals).",
"requires_partner aus Intent filtert Übungen; retrieval_phase +text_signals; Grund „Passt zu Abschnitts-/Rahmen-Zieltext“.",
],
},
{
"version": "0.8.180",
"date": "2026-05-23",

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-05-31
**App-Version / DB-Schema:** App **`0.8.167`** (Planungs-KI Übungssuche P0); DB **`20260531071`** — maßgeblich **`backend/version.py`**.
**Stand:** 2026-05-23
**App-Version / DB-Schema:** App **`0.8.183`** (Planungs-KI Phase C1); DB **`20260531074`** — maßgeblich **`backend/version.py`**.
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
@ -89,9 +89,34 @@ 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.171**)
### 2.8 KI Assistenz Übungen & Planungs-KI Übungssuche (Stand **0.8.183**)
- **Planungs-Übungssuche (P1):** Szenario-Pipeline + **LLM Query-Intent** (`planning_exercise_search_intent`) → Erwartungsprofil-Overlay; danach Hybrid + optional LLM-Rerank — `.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` §16.
**Spec / Pipeline:** `.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md`
| Phase | Inhalt | Status |
|-------|--------|--------|
| **P0** | Kontext-Pack, Hybrid-Score, Planungs-Picker | ✅ |
| **P0.1** | `ExerciseMatchProfile` / `PlanningTargetProfile`, `profile_v1` | ✅ |
| **P1** | Szenario-Pipeline + LLM Intent (`073`) + Erwartungsprofil (`074`) | ✅ |
| **P2 / B2** | LLM-Rerank (`072`) bei engem Top-Feld, max. 2 LLM-Calls | ✅ **0.8.182** |
| **A** | Voll-Library deterministisch ranken (kein OR-Profil-Pool) | ✅ **0.8.177** |
| **B** | Text-Signale aus guidance/Rahmen-Zielen (`planning_text_signals`) | ✅ **0.8.181** |
| **C1** | Progressionsgraph auto-match + variantenbewusste Nachfolger | ✅ **0.8.183** |
| **C2** | Varianten in Trefferliste / Picker-Auswahl | 🔲 |
| **C3** | Graph-Builder: Ziel → Pfad vorschlagen → in Graph speichern | 🔲 |
| **D** | `planning_context` an `suggestExerciseAi` (Neu-Anlage) | 🔲 |
**Backend:** `planning_exercise_suggest.py`, `planning_exercise_retrieval.py`, `planning_exercise_profiles.py`, `planning_exercise_target_pipeline.py`, `planning_exercise_progression.py` · Router `POST /api/planning/exercise-suggest`
**Frontend:** `ExercisePickerModal` (Planung) · **`ExercisesListPageRoot`** — Schalter „Neu mit KI-Assistent“: Planungs-KI-Suche + Neuanlage-Modal (statt „+ Neu“) · `TrainingUnitEditPage``planningContext`
**Superadmin:** Übungs-Anreicherung (Skills) — `exercise_enrichment_admin` (**0.8.178+**), separater Admin-Flow
**Offen (Qualität):** Bibliothek durchgängig mit Skills (Enrichment-Datenarbeit); manuelle Graph-Auswahl in UI; Progressionsgraph-Builder; Skill-Discovery/Framework-Pfade im Pack (P3)
#### Übungs-KI Formular / Schnellanlage (Stand **0.8.171**)
- **Planungs-Übungssuche:** siehe Tabelle oben — getrennt von Formular-KI.
- **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`**
@ -100,7 +125,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
- **Pflege:** Superadmin **`/admin/ai-prompts`**, **`/admin/ai-skill-retrieval`**
- **Diagnose:** **`SHINKAN_AI_DEBUG=1`** — Logs `shinkan.exercise_ai`, `shinkan.openrouter`
- **Frontend Formular:** Tab **Anleitung****„KI: Anleitung überarbeiten“**; Vorschau-Dialog pro Feld (**`ExerciseFormPageRoot.jsx`**)
- **Frontend Schnellanlage:** **`ExercisePickerModal`** (Planung/Rahmen) — Volltextsuche; bei keinem Treffer **„Mit KI anlegen“** (Suchstring → Titel/Skizze); Entwurf im **Rich-Text-Dialog** bearbeiten, dann speichern & übernehmen. **`ExercisesListPageRoot`** — gleiches Muster + Schalter **„KI-Anlage“** in der Suchleiste.
- **Frontend Schnellanlage:** **`ExercisePickerModal`** (Planung/Rahmen) — Volltext vs. Planungs-KI; KI-Neuanlage im Picker. **`ExercisesListPageRoot`** — Schalter **„Neu mit KI-Assistent“**: Planungs-KI-Suche (`usePlanningExerciseSuggestSearch`) + **`ExerciseAiQuickCreateModal`**; normales **„+ Neu“** ausgeblendet solange aktiv.
---
@ -220,18 +245,28 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
## 7. Nächste Session — sinnvolle Arbeitspakete
1. **Coaching & Breakout (Regression):** Mehrphasen-Einheit mit zwei Splits und Ganzgruppen dazwischen — Rejoin-Karten, Nachbereitung speichern, Anzeige in Plan & Ablauf (`docs/HANDOVER.md` Arbeitspaket-Tabelle).
2. **P-13 Frontend-Verifikation:** Melde-Flow in Medienbibliothek, Inbox-Workflow (Status, Archiv, Wiedereröffnen), Club-Admin-Ansicht manuell auf Dev-System durchspielen. E-Mail-Benachrichtigungen verifizieren (SMTP-Log).
3. **Inline (Spec Abschnitt 11):** Basis umgesetzt — verbleibend: gezielte UX-Politik; optional Server-Normalisierung/Absicherung prüfen, falls Produkt es verlangt.
4. **Tests:** pytest für `media_assets`-Router (Leserechte, Lifecycle, `from-asset`); ggf. Snapshot der Pfad-Umzug-Logik.
5. **Retention:** Job-Dokumentation + Betrieb (ENV, Intervall); Dry-Run beschreiben.
6. **S3/Adapter:** Speicher-Abstraktion (Spec Abschnitt 7) — wenn Produkt es verlangt.
7. **Rahmen/UI:** Kalender „aus Rahmen übernehmen” weiter anbinden (parallel, unabhängig von Medien).
8. **Fachlicher Nutzerüberblick:** bei größeren UX-Änderungen **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** mitpflegen.
9. **Kombinations-Coach (Archetyp B/C):** Fachspez §10.4 / **§10.6**; nach Implementierung **Anhang A** + `TRAINING_MODULES_IMPLEMENTATION_PLAN.md` aktualisieren (kein Doc-Drift).
10. **Archetyp-Administration:** Konfiguration oder DB statt nur `COMBINATION_ARCHETYPE_IDS` / `combinationArchetypes.js` (Paket **4e**).
11. **Kombi-Zeitfelder:** Massen-Vorbelegung aller Slots aus Archetyp/Global + optionales Modal beim Archetypwechsel (Paket **4f**, `COMBINATION_TIMING_PROFILE_PLAN.md`).
12. **Backend-Validierung** `method_profile` / `planning_method_profile` je Archetyp (Paket **4g**).
### Planungs-KI (priorisiert)
1. **C2 — Varianten in Treffern:** Planungs-Picker: bei `suggested_variant_id` Variante vorauswählen; optional Varianten-Ranking bei `deepen`/`progression`.
2. **C3 — Graph-Builder:** Modus „Pfad zum Ziel“ → sequenzielle Vorschläge → `POST …/edges/sequence` nach Review.
3. **Graph-Auswahl UI:** Dropdown neben Auto-Match; Rahmen-Slot / Framework mit Default-Graph verknüpfen.
4. **Enrichment:** Skills für Kern-Übungen nachziehen (sonst schwaches Profil-Ranking).
5. **D — Neu-Anlage:** `planning_context_json` an `POST /api/exercises/ai/suggest`.
### Allgemein
6. **Coaching & Breakout (Regression):** Mehrphasen-Einheit mit zwei Splits und Ganzgruppen dazwischen — Rejoin-Karten, Nachbereitung speichern, Anzeige in Plan & Ablauf (`docs/HANDOVER.md` Arbeitspaket-Tabelle).
7. **P-13 Frontend-Verifikation:** Melde-Flow in Medienbibliothek, Inbox-Workflow (Status, Archiv, Wiedereröffnen), Club-Admin-Ansicht manuell auf Dev-System durchspielen. E-Mail-Benachrichtigungen verifizieren (SMTP-Log).
8. **Inline (Spec Abschnitt 11):** Basis umgesetzt — verbleibend: gezielte UX-Politik; optional Server-Normalisierung/Absicherung prüfen, falls Produkt es verlangt.
9. **Tests:** pytest für `media_assets`-Router (Leserechte, Lifecycle, `from-asset`); ggf. Snapshot der Pfad-Umzug-Logik.
10. **Retention:** Job-Dokumentation + Betrieb (ENV, Intervall); Dry-Run beschreiben.
11. **S3/Adapter:** Speicher-Abstraktion (Spec Abschnitt 7) — wenn Produkt es verlangt.
12. **Rahmen/UI:** Kalender „aus Rahmen übernehmen” weiter anbinden (parallel, unabhängig von Medien).
13. **Fachlicher Nutzerüberblick:** bei größeren UX-Änderungen **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** mitpflegen.
14. **Kombinations-Coach (Archetyp B/C):** Fachspez §10.4 / **§10.6**; nach Implementierung **Anhang A** + `TRAINING_MODULES_IMPLEMENTATION_PLAN.md` aktualisieren (kein Doc-Drift).
15. **Archetyp-Administration:** Konfiguration oder DB statt nur `COMBINATION_ARCHETYPE_IDS` / `combinationArchetypes.js` (Paket **4e**).
16. **Kombi-Zeitfelder:** Massen-Vorbelegung aller Slots aus Archetyp/Global + optionales Modal beim Archetypwechsel (Paket **4f**, `COMBINATION_TIMING_PROFILE_PLAN.md`).
17. **Backend-Validierung** `method_profile` / `planning_method_profile` je Archetyp (Paket **4g**).
---

View File

@ -31,7 +31,6 @@ const PAGE_SIZE = 100
const PLANNING_SUGGEST_LIMIT = 50
/** Client-Hinweis — Backend entscheidet final über LLM-Gates (max. 1 Call). */
const PLANNING_LLM_INTENT_MIN_CHARS = 10
const PLANNING_LLM_RANK_MIN_CHARS = 24
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
const INITIAL_FILTERS = { ...INITIAL_EXERCISE_LIST_FILTERS }
@ -115,6 +114,7 @@ export default function ExercisePickerModal({
phaseOrderIndex: planningContext?.phaseOrderIndex ?? null,
parallelStreamOrderIndex: planningContext?.parallelStreamOrderIndex ?? null,
anchorExerciseId: planningContext?.anchorExerciseId ?? null,
anchorExerciseVariantId: planningContext?.anchorExerciseVariantId ?? null,
progressionGraphId: planningContext?.progressionGraphId ?? null,
plannedExerciseIds: Array.isArray(planningContext?.plannedExerciseIds)
? planningContext.plannedExerciseIds
@ -447,6 +447,10 @@ export default function ExercisePickerModal({
activePlanningContext.anchorExerciseId != null
? Number(activePlanningContext.anchorExerciseId)
: null,
anchor_exercise_variant_id:
activePlanningContext.anchorExerciseVariantId != null
? Number(activePlanningContext.anchorExerciseVariantId)
: undefined,
progression_graph_id:
activePlanningContext.progressionGraphId != null
? Number(activePlanningContext.progressionGraphId)
@ -460,7 +464,7 @@ export default function ExercisePickerModal({
: undefined,
include_llm_intent:
query.length >= PLANNING_LLM_INTENT_MIN_CHARS || !(query || '').trim(),
include_llm_rank: query.length >= PLANNING_LLM_RANK_MIN_CHARS,
include_llm_rank: true,
query,
intent_hint:
activePlanningContext.intentHint || (useFreePlanningSearch && query ? 'free_search' : null),
@ -501,6 +505,7 @@ export default function ExercisePickerModal({
title: h.title,
summary: h.summary,
focus_area: h.focus_area,
suggested_variant_id: h.suggested_variant_id ?? null,
_planningScore: h.score,
_planningReasons: Array.isArray(h.reasons) ? h.reasons : [],
updated_at: new Date().toISOString(),
@ -741,6 +746,12 @@ export default function ExercisePickerModal({
Anker: {planningContextSummary.anchor_title}
</span>
) : null}
{planningContextSummary.progression_graph_name ? (
<span className="exercise-tag">
Graph: {planningContextSummary.progression_graph_name}
{planningContextSummary.progression_graph_auto_resolved ? ' (auto)' : ''}
</span>
) : null}
{Array.isArray(planningTargetProfileSummary?.focus_areas) &&
planningTargetProfileSummary.focus_areas.length > 0
? planningTargetProfileSummary.focus_areas.map((fa) => (

View File

@ -0,0 +1,90 @@
import React, { useEffect } from 'react'
import ExerciseAiQuickCreateOffer from '../ExerciseAiQuickCreateOffer'
/**
* KI-gestützte Neuanlage in einem Dialog statt normalem + Neu-Formular.
*/
export default function ExerciseAiQuickCreateModal({
open,
onClose,
searchLabel,
title,
onTitleChange,
sketch,
onSketchChange,
focusAreaId,
onFocusAreaChange,
focusAreas = [],
catalogsReady = true,
busy = false,
error = '',
onRunAi,
}) {
useEffect(() => {
if (!open) return undefined
const onKey = (e) => {
if (e.key === 'Escape' && !busy) {
e.preventDefault()
onClose()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, busy, onClose])
if (!open) return null
return (
<div
className="admin-modal-backdrop"
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget && !busy) onClose()
}}
>
<div
className="admin-modal-sheet exercise-ai-quick-create-modal"
role="dialog"
aria-modal="true"
aria-labelledby="exercise-ai-quick-create-title"
onClick={(e) => e.stopPropagation()}
style={{ maxWidth: '640px' }}
>
<div className="admin-modal-sheet__header">
<h3 id="exercise-ai-quick-create-title" className="admin-modal-sheet__title">
Neue Übung mit KI
</h3>
<button type="button" className="btn btn-secondary admin-modal-sheet__close" disabled={busy} onClick={onClose}>
Schließen
</button>
</div>
<div className="admin-modal-sheet__body">
<p className="muted" style={{ marginTop: 0, marginBottom: '12px', lineHeight: 1.45 }}>
Die KI schlägt Titel, Ziel, Anleitung und Fähigkeiten vor danach bearbeiten und als Entwurf speichern.
Für die normale manuelle Anlage den KI-Assistenten ausschalten.
</p>
<ExerciseAiQuickCreateOffer
searchLabel={searchLabel}
title={title}
onTitleChange={onTitleChange}
sketch={sketch}
onSketchChange={onSketchChange}
focusAreaId={focusAreaId}
onFocusAreaChange={onFocusAreaChange}
focusAreas={focusAreas}
catalogsReady={catalogsReady}
busy={busy}
error={error}
onRunAi={onRunAi}
headline="Übung vorschlagen lassen"
hint={
searchLabel
? `Optional aus Suchanfrage „${searchLabel}“ — Titel mindestens 3 Zeichen, Fokusbereich Pflicht.`
: 'Titel mindestens 3 Zeichen, Fokusbereich Pflicht; Kurzbeschreibung optional.'
}
/>
</div>
</div>
</div>
)
}

View File

@ -241,6 +241,22 @@ export default function ExerciseListCard({
/>
</div>
) : null}
{Array.isArray(exercise._planningReasons) && exercise._planningReasons.length > 0 ? (
<ul
className="exercise-card__planning-reasons"
style={{
margin: '8px 0 0',
paddingLeft: '18px',
fontSize: '12px',
color: 'var(--accent-dark)',
lineHeight: 1.4,
}}
>
{exercise._planningReasons.slice(0, 3).map((r) => (
<li key={r}>{r}</li>
))}
</ul>
) : null}
</div>
</div>
<div className="exercise-card__footer">

View File

@ -14,7 +14,110 @@ export default function ExerciseListSearchBar({
exerciseCount,
allOnPageSelected,
onToggleSelectAllPage,
kiSearchMode = false,
planningSearchInput = '',
onPlanningSearchInputChange,
onSubmitPlanningSearch,
planningSearchLoading = false,
planningHasSearched = false,
planningRetrievalPhase = '',
planningContextSummary = null,
planningTargetProfileSummary = null,
planningSearchError = '',
}) {
if (kiSearchMode) {
return (
<div className="card exercise-search-bar exercise-search-bar--ki">
<label className="form-label">Planungs-Anfrage (KI)</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'stretch' }}>
<input
type="search"
className="form-input exercise-search-bar__primary"
placeholder="z. B. Partnerübung Reaktion, Vertiefung Schnellkraft …"
value={planningSearchInput}
onChange={(e) => onPlanningSearchInputChange?.(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
onSubmitPlanningSearch?.()
}
}}
autoComplete="off"
enterKeyHint="search"
/>
<button
type="button"
className="btn btn-primary"
disabled={planningSearchLoading}
onClick={() => onSubmitPlanningSearch?.()}
>
{planningSearchLoading ? 'Suche …' : 'Vorschläge laden'}
</button>
</div>
<p className="exercise-search-hint" style={{ marginTop: '10px' }}>
<strong>Planungs-KI</strong> durchsucht die sichtbare Bibliothek mit Profil-Score und optional LLM nicht
die einfache Volltextsuche. Filter und Meine Übungen gelten hier nicht; KI-Assistent ausschalten für
klassische Liste.
{planningRetrievalPhase ? (
<>
{' '}
· Phase: <code>{planningRetrievalPhase}</code>
</>
) : null}
</p>
{planningSearchError ? (
<p className="form-error" style={{ marginTop: '8px' }}>
{planningSearchError}
</p>
) : null}
{planningHasSearched && planningContextSummary ? (
<div
style={{
marginTop: '10px',
padding: '8px 10px',
borderRadius: '8px',
background: 'var(--surface2)',
border: '1px solid var(--border)',
fontSize: '12px',
color: 'var(--text2)',
lineHeight: 1.45,
}}
>
<strong style={{ color: 'var(--text1)', fontSize: '13px' }}>KI-Kontext</strong>
<div style={{ marginTop: '4px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{planningContextSummary.expectation_mode ? (
<span className="exercise-tag">
{planningContextSummary.expectation_mode === 'query_only' ? 'Freitext-Profil' : 'Hybrid-Profil'}
</span>
) : null}
{Array.isArray(planningTargetProfileSummary?.focus_areas) &&
planningTargetProfileSummary.focus_areas.length > 0
? planningTargetProfileSummary.focus_areas.slice(0, 2).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, 2).map((sk) => (
<span key={sk.skill_id} className="exercise-tag">
{sk.name}
</span>
))
: null}
</div>
</div>
) : null}
{!planningHasSearched ? (
<p className="muted" style={{ marginTop: '10px', marginBottom: 0, fontSize: '13px' }}>
Anfrage formulieren und Vorschläge laden klicken.
</p>
) : null}
</div>
)
}
return (
<div className="card exercise-search-bar">
<label className="form-label">Volltextsuche (Titel, Ziel, )</label>

View File

@ -12,7 +12,8 @@ import ExerciseListBulkToolbar from './ExerciseListBulkToolbar'
import SaveSelectedExercisesAsModuleModal from './SaveSelectedExercisesAsModuleModal'
import ExercisePeekModal from '../ExercisePeekModal'
import NavStateLink from '../NavStateLink'
import ExerciseAiQuickCreateOffer from '../ExerciseAiQuickCreateOffer'
import { ExerciseAiQuickCreateTeaser } from '../ExerciseAiQuickCreateOffer'
import ExerciseAiQuickCreateModal from './ExerciseAiQuickCreateModal'
import ExerciseAiSuggestPreviewModal from '../ExerciseAiSuggestPreviewModal'
import {
buildQuickCreateAiPreview,
@ -31,6 +32,7 @@ import {
snapshotExerciseForSelection,
} from '../../utils/exerciseListSelection'
import { useExerciseListCatalogsAndQuery } from '../../hooks/useExerciseListCatalogsAndQuery'
import { usePlanningExerciseSuggestSearch } from '../../hooks/usePlanningExerciseSuggestSearch'
import {
INITIAL_EXERCISE_LIST_FILTERS,
mergeExerciseListPrefsFromApi,
@ -89,10 +91,15 @@ function ExercisesListPageRoot() {
const [peekExercise, setPeekExercise] = useState(null)
const [saveModuleModalOpen, setSaveModuleModalOpen] = useState(false)
const [aiQuickCreateEnabled, setAiQuickCreateEnabled] = useState(false)
const [aiQuickCreateModalOpen, setAiQuickCreateModalOpen] = useState(false)
const [quickSaving, setQuickSaving] = useState(false)
const [quickAiError, setQuickAiError] = useState('')
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
const planningKi = usePlanningExerciseSuggestSearch({
enabled: pageTab === 'list' && aiQuickCreateEnabled,
})
const {
title: quickTitle,
sketch: quickSketch,
@ -100,9 +107,14 @@ function ExercisesListPageRoot() {
setTitle: setQuickTitle,
setSketch: setQuickSketch,
setFocusAreaId: setQuickFocusAreaId,
} = useExerciseAiQuickCreateFields(debouncedSearch, {
enabled: pageTab === 'list' && (aiQuickCreateEnabled || debouncedSearch.length >= 3),
})
} = useExerciseAiQuickCreateFields(
aiQuickCreateModalOpen
? planningKi.submittedQuery || planningKi.searchInput || debouncedSearch
: debouncedSearch,
{
enabled: pageTab === 'list' && aiQuickCreateModalOpen,
},
)
useEffect(() => {
if (!user?.id) return
@ -174,14 +186,18 @@ function ExercisesListPageRoot() {
loadingMore,
hasMore,
loadMore,
} = useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey })
} = useExerciseListCatalogsAndQuery({
queryBase,
pageTab,
tenantClubDepKey,
skipListFetch: aiQuickCreateEnabled,
})
const showQuickCreateOffer =
pageTab === 'list' &&
catalogsReady &&
!listFetching &&
(aiQuickCreateEnabled ||
(exercises.length === 0 && selectedEntries.length === 0 && debouncedSearch.length >= 3))
const listExercises = exercises
const listFetchingResolved = listFetching
const exercisesForDisplay = aiQuickCreateEnabled ? planningKi.rows : listExercises
const listFetchingDisplay = aiQuickCreateEnabled ? planningKi.loading : listFetchingResolved
const hasMoreDisplay = aiQuickCreateEnabled ? false : hasMore
const selectedIds = useMemo(
() => new Set(selectedEntries.map((e) => Number(e.id)).filter((id) => Number.isFinite(id) && id > 0)),
@ -189,15 +205,22 @@ function ExercisesListPageRoot() {
)
const selectedExercisesDisplay = useMemo(
() => mergeSelectedWithListEntries(selectedEntries, exercises),
[selectedEntries, exercises]
() => mergeSelectedWithListEntries(selectedEntries, exercisesForDisplay),
[selectedEntries, exercisesForDisplay],
)
const filterResultExercises = useMemo(
() => exercises.filter((e) => !selectedIds.has(Number(e.id))),
[exercises, selectedIds]
() => exercisesForDisplay.filter((e) => !selectedIds.has(Number(e.id))),
[exercisesForDisplay, selectedIds],
)
const showKiCreateTeaser =
aiQuickCreateEnabled &&
planningKi.hasSearched &&
!planningKi.loading &&
catalogsReady &&
filterResultExercises.length > 0
const focusOptions = useMemo(
() =>
catalogs.focusAreas.map((fa) => ({
@ -274,9 +297,9 @@ function ExercisesListPageRoot() {
/** Für Browser-/datalist-Vorschläge (aktuelle Treffer-Titel, begrenzt) */
const searchTitleSuggestions = useMemo(() => {
const titles = exercises.map((e) => (e.title || '').trim()).filter(Boolean)
const titles = listExercises.map((e) => (e.title || '').trim()).filter(Boolean)
return [...new Set(titles)].slice(0, 80)
}, [exercises])
}, [listExercises])
const clubNameById = useMemo(() => {
const m = {}
@ -377,6 +400,7 @@ function ExercisesListPageRoot() {
setQuickCreateDraft(
aiPreviewToQuickCreateDraft(preview, { title, focusAreaId: focusId, sketchPlain: sketch }),
)
setAiQuickCreateModalOpen(false)
} catch (e) {
console.error(e)
const msg = e?.message || String(e)
@ -597,23 +621,36 @@ function ExercisesListPageRoot() {
<div className="exercises-page__header-actions">
<label
className="exercises-ai-assistant-toggle"
title="Neue Übung per KI vorschlagen — Titel, optional Kurzbeschreibung, Fokusbereich"
title="Planungs-KI-Suche in der Bibliothek und Neuanlage per KI-Dialog (ohne normales Formular „+ Neu“)"
>
<input
type="checkbox"
checked={aiQuickCreateEnabled}
onChange={(e) => setAiQuickCreateEnabled(e.target.checked)}
onChange={(e) => {
setAiQuickCreateEnabled(e.target.checked)
if (!e.target.checked) setAiQuickCreateModalOpen(false)
}}
/>
<span className="exercises-ai-assistant-toggle__track" aria-hidden="true" />
<span>Neu mit KI-Assistent</span>
</label>
<NavStateLink
to="/exercises/new"
returnContext={exercisesModuleReturnContext}
className="btn btn-primary"
>
+ Neu
</NavStateLink>
{aiQuickCreateEnabled ? (
<button
type="button"
className="btn btn-primary"
onClick={() => setAiQuickCreateModalOpen(true)}
>
+ Neue Übung mit KI
</button>
) : (
<NavStateLink
to="/exercises/new"
returnContext={exercisesModuleReturnContext}
className="btn btn-primary"
>
+ Neu
</NavStateLink>
)}
</div>
) : (
<span aria-hidden="true" />
@ -644,6 +681,16 @@ function ExercisesListPageRoot() {
) : (
<>
<ExerciseListSearchBar
kiSearchMode={aiQuickCreateEnabled}
planningSearchInput={planningKi.searchInput}
onPlanningSearchInputChange={planningKi.setSearchInput}
onSubmitPlanningSearch={() => planningKi.submitSearch()}
planningSearchLoading={planningKi.loading}
planningHasSearched={planningKi.hasSearched}
planningRetrievalPhase={planningKi.retrievalPhase}
planningContextSummary={planningKi.contextSummary}
planningTargetProfileSummary={planningKi.targetProfileSummary}
planningSearchError={planningKi.error}
searchTitleSuggestions={searchTitleSuggestions}
searchInput={searchInput}
onSearchInputChange={setSearchInput}
@ -654,30 +701,15 @@ function ExercisesListPageRoot() {
onOpenFilter={() => setFilterModalOpen(true)}
filterChips={filterChips}
onResetAllFilters={resetAllFilters}
exerciseCount={exercises.length}
exerciseCount={exercisesForDisplay.length}
allOnPageSelected={allOnPageSelected}
onToggleSelectAllPage={toggleSelectAllPage}
/>
{showQuickCreateOffer ? (
<ExerciseAiQuickCreateOffer
searchLabel={debouncedSearch || undefined}
title={quickTitle}
onTitleChange={setQuickTitle}
sketch={quickSketch}
onSketchChange={setQuickSketch}
focusAreaId={quickFocusAreaId}
onFocusAreaChange={setQuickFocusAreaId}
focusAreas={catalogs.focusAreas}
catalogsReady={catalogsReady}
busy={quickSaving}
error={quickAiError}
onRunAi={runQuickCreateAiSuggest}
hint={
aiQuickCreateEnabled
? 'Titel aus Suche oder manuell; Kurzbeschreibung optional — leer für freien KI-Vorschlag, ausgefüllt als deine Ausgangsidee.'
: undefined
}
{showKiCreateTeaser ? (
<ExerciseAiQuickCreateTeaser
onExpand={() => setAiQuickCreateModalOpen(true)}
disabled={quickSaving}
/>
) : null}
@ -762,22 +794,36 @@ function ExercisesListPageRoot() {
onClose={() => setPeekExercise(null)}
/>
{listFetching && exercises.length === 0 && selectedEntries.length === 0 ? (
{listFetchingDisplay && exercisesForDisplay.length === 0 && selectedEntries.length === 0 ? (
<div className="card empty-state" style={{ padding: '2rem 1rem' }}>
<div className="spinner" />
<p className="muted" style={{ marginTop: '12px' }}>
Lade Übungen
{aiQuickCreateEnabled ? 'Planungs-KI lädt Vorschläge…' : 'Lade Übungen…'}
</p>
</div>
) : exercises.length === 0 && selectedEntries.length === 0 && !showQuickCreateOffer ? (
) : exercisesForDisplay.length === 0 && selectedEntries.length === 0 ? (
<div className="card">
<p className="exercises-empty-text">
{debouncedSearch.length >= 3
? 'Keine Übungen gefunden.'
: 'Keine Übungen gefunden — Suchbegriff eingeben oder Filter anpassen.'}
{aiQuickCreateEnabled
? planningKi.hasSearched
? 'Keine passenden Übungen — neue Übung mit KI anlegen?'
: 'Planungs-Anfrage formulieren und „Vorschläge laden“ klicken — oder „Neue Übung mit KI“ oben.'
: debouncedSearch.length >= 3
? 'Keine Übungen gefunden.'
: 'Keine Übungen gefunden — Suchbegriff eingeben oder Filter anpassen.'}
</p>
{aiQuickCreateEnabled && planningKi.hasSearched ? (
<button
type="button"
className="btn btn-secondary"
style={{ marginTop: '12px' }}
onClick={() => setAiQuickCreateModalOpen(true)}
>
Neue Übung mit KI
</button>
) : null}
</div>
) : exercises.length === 0 && selectedEntries.length === 0 && showQuickCreateOffer ? null : (
) : (
<>
{selectedEntries.length > 0 ? (
<section className="exercises-selection-section" data-testid="exercises-selection-section">
@ -812,13 +858,14 @@ function ExercisesListPageRoot() {
) : null
) : (
<>
{listFetching ? (
{listFetchingDisplay ? (
<p className="exercises-meta-line exercises-meta-line--muted">Aktualisiere Treffer</p>
) : null}
<p className="exercises-meta-line">
{filterResultExercises.length} Treffer
{aiQuickCreateEnabled ? ' (Planungs-KI)' : ''}
{selectedEntries.length > 0 ? ' (ohne bereits Ausgewählte)' : ''}
{hasMore ? ' · es gibt weitere Einträge' : ''}
{hasMoreDisplay ? ' · es gibt weitere Einträge' : ''}
</p>
<div className="exercises-list-grid" data-testid="exercises-list-grid">
{filterResultExercises.map((exercise) => (
@ -833,7 +880,7 @@ function ExercisesListPageRoot() {
/>
))}
</div>
{hasMore && (
{hasMoreDisplay && (
<div className="exercises-load-more">
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
{loadingMore ? 'Laden…' : 'Mehr laden'}
@ -845,6 +892,27 @@ function ExercisesListPageRoot() {
</>
)}
<ExerciseAiQuickCreateModal
open={aiQuickCreateModalOpen}
onClose={() => {
if (!quickSaving) setAiQuickCreateModalOpen(false)
}}
searchLabel={
planningKi.submittedQuery || planningKi.searchInput || debouncedSearch || undefined
}
title={quickTitle}
onTitleChange={setQuickTitle}
sketch={quickSketch}
onSketchChange={setQuickSketch}
focusAreaId={quickFocusAreaId}
onFocusAreaChange={setQuickFocusAreaId}
focusAreas={catalogs.focusAreas}
catalogsReady={catalogsReady}
busy={quickSaving}
error={quickAiError}
onRunAi={runQuickCreateAiSuggest}
/>
<ExerciseAiSuggestPreviewModal
draft={quickCreateDraft}
onDraftChange={setQuickCreateDraft}

View File

@ -0,0 +1,5 @@
/** Backend POST /api/planning/exercise-suggest erlaubt max. 50 */
export const PLANNING_SUGGEST_LIMIT = 50
/** Client-Hinweis — Backend entscheidet final über LLM-Gates. */
export const PLANNING_LLM_INTENT_MIN_CHARS = 10

View File

@ -6,7 +6,7 @@ export const EXERCISE_LIST_PAGE_SIZE = 100
/**
* Lädt Kataloge für Filter/Bulk einmalig und hält die Übungsliste (Offset + Keyset Mehr laden) synchron zu queryBase.
*/
export function useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey }) {
export function useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey, skipListFetch = false }) {
const [catalogs, setCatalogs] = useState({
focusAreas: [],
styleDirections: [],
@ -55,7 +55,14 @@ export function useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClub
}, [])
useEffect(() => {
if (!catalogsReady || pageTab !== 'list') return
if (!catalogsReady || pageTab !== 'list' || skipListFetch) {
if (skipListFetch) {
setExercises([])
setHasMore(false)
setListFetching(false)
}
return
}
let cancelled = false
const run = async () => {
setListFetching(true)
@ -81,7 +88,7 @@ export function useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClub
return () => {
cancelled = true
}
}, [queryBase, catalogsReady, pageTab, tenantClubDepKey])
}, [queryBase, catalogsReady, pageTab, tenantClubDepKey, skipListFetch])
const loadMore = useCallback(async () => {
if (loadingMore || !hasMore) return

View File

@ -0,0 +1,120 @@
import { useState, useEffect, useCallback } from 'react'
import api from '../utils/api'
import { PLANNING_LLM_INTENT_MIN_CHARS, PLANNING_SUGGEST_LIMIT } from '../constants/planningExerciseSuggest'
export function mapPlanningHitsToListRows(hits) {
return (Array.isArray(hits) ? hits : []).map((h) => ({
id: h.id,
title: h.title,
summary: h.summary,
focus_area: h.focus_area,
focus_area_names: h.focus_area ? [h.focus_area] : [],
updated_at: new Date().toISOString(),
_planningScore: h.score,
_planningReasons: Array.isArray(h.reasons) ? h.reasons : [],
suggested_variant_id: h.suggested_variant_id ?? null,
}))
}
/**
* Freie Planungs-KI-Suche (ohne unit_id) z. B. Übungsliste im KI-Assistent-Modus.
*/
export function usePlanningExerciseSuggestSearch({ enabled, groupId = null } = {}) {
const [searchInput, setSearchInput] = useState('')
const [submittedQuery, setSubmittedQuery] = useState('')
const [searchTick, setSearchTick] = useState(0)
const [loading, setLoading] = useState(false)
const [hasSearched, setHasSearched] = useState(false)
const [rows, setRows] = useState([])
const [contextSummary, setContextSummary] = useState(null)
const [targetProfileSummary, setTargetProfileSummary] = useState(null)
const [retrievalPhase, setRetrievalPhase] = useState('')
const [error, setError] = useState('')
const reset = useCallback(() => {
setSearchInput('')
setSubmittedQuery('')
setSearchTick(0)
setHasSearched(false)
setRows([])
setContextSummary(null)
setTargetProfileSummary(null)
setRetrievalPhase('')
setError('')
}, [])
const submitSearch = useCallback(
(queryOverride) => {
const q =
queryOverride !== undefined && queryOverride !== null
? String(queryOverride).trim()
: searchInput.trim()
setSubmittedQuery(q)
setHasSearched(true)
setSearchTick((t) => t + 1)
},
[searchInput],
)
useEffect(() => {
if (!enabled) {
reset()
}
}, [enabled, reset])
useEffect(() => {
if (!enabled || searchTick === 0) return undefined
let cancelled = false
;(async () => {
setLoading(true)
setError('')
try {
const query = submittedQuery
const body = {
query,
include_llm_intent: query.length >= PLANNING_LLM_INTENT_MIN_CHARS || !query,
include_llm_rank: true,
intent_hint: query ? 'free_search' : 'suggest_next',
limit: PLANNING_SUGGEST_LIMIT,
}
const gid = Number(groupId)
if (Number.isFinite(gid) && gid > 0) body.group_id = gid
const res = await api.suggestPlanningExercises(body)
if (cancelled) return
setContextSummary(res?.context_summary || null)
setTargetProfileSummary(res?.target_profile_summary || null)
setRetrievalPhase(res?.retrieval_phase || '')
setRows(mapPlanningHitsToListRows(res?.hits))
} catch (e) {
if (!cancelled) {
console.error(e)
setError(e.message || 'Planungs-KI-Suche fehlgeschlagen')
setRows([])
setContextSummary(null)
setTargetProfileSummary(null)
setRetrievalPhase('')
}
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [enabled, searchTick, submittedQuery, groupId])
return {
searchInput,
setSearchInput,
submittedQuery,
submitSearch,
loading,
hasSearched,
rows,
contextSummary,
targetProfileSummary,
retrievalPhase,
error,
reset,
}
}

View File

@ -132,15 +132,24 @@ export default function TrainingUnitEditPage() {
const sIdx = target?.sIdx ?? 0
const sec = secs[sIdx]
let anchorExerciseId = null
let anchorExerciseVariantId = null
const pickAnchorFromItem = (item) => {
if (!item?.exercise_id) return
anchorExerciseId = Number(item.exercise_id)
const rawVar = item.exercise_variant_id
const vid = Number(rawVar)
anchorExerciseVariantId =
rawVar != null && Number.isFinite(vid) && vid > 0 ? vid : null
}
if (sec?.items?.length) {
if (typeof target?.iIdx === 'number') {
const item = sec.items[target.iIdx]
if (item?.exercise_id) {
anchorExerciseId = Number(item.exercise_id)
pickAnchorFromItem(item)
} else {
for (let i = target.iIdx - 1; i >= 0; i -= 1) {
if (sec.items[i]?.exercise_id) {
anchorExerciseId = Number(sec.items[i].exercise_id)
pickAnchorFromItem(sec.items[i])
break
}
}
@ -149,14 +158,14 @@ export default function TrainingUnitEditPage() {
const beforeIx = target.insertBeforeIndex
for (let i = beforeIx - 1; i >= 0; i -= 1) {
if (sec.items[i]?.exercise_id) {
anchorExerciseId = Number(sec.items[i].exercise_id)
pickAnchorFromItem(sec.items[i])
break
}
}
} else {
for (let i = sec.items.length - 1; i >= 0; i -= 1) {
if (sec.items[i]?.exercise_id) {
anchorExerciseId = Number(sec.items[i].exercise_id)
pickAnchorFromItem(sec.items[i])
break
}
}
@ -204,6 +213,7 @@ export default function TrainingUnitEditPage() {
sectionExerciseCount: sectionPlannedExerciseIds.length,
lastExerciseTitle,
anchorExerciseId: Number.isFinite(anchorExerciseId) && anchorExerciseId > 0 ? anchorExerciseId : null,
anchorExerciseVariantId,
progressionGraphId: null,
plannedExerciseIds,
}