Update Planning Exercise Suggestion and Context Handling
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m13s
Test Suite / pytest-backend (pull_request) Successful in 37s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 13s
Test Suite / k6 /health Baseline (pull_request) Successful in 33s
Test Suite / playwright-tests (pull_request) Successful in 1m15s

- Incremented version to 0.8.183, reflecting the implementation of Phase C1 enhancements.
- Added support for progression graph auto-matching and variant-aware successors in exercise suggestions.
- Updated request and response structures to include `anchor_exercise_variant_id`, `progression_graph_name`, and `suggested_variant_id`.
- Enhanced frontend components to integrate planning AI search capabilities, including a new modal for exercise creation and improved context display in the exercise list.
- Updated changelog to document these significant improvements in planning AI functionality.
This commit is contained in:
Lars 2026-05-23 10:42:17 +02:00
parent 50aff849d8
commit b2157d8a40
16 changed files with 985 additions and 136 deletions

View File

@ -1,8 +1,8 @@
# Planungs-KI: Übungssuche & Kontext für Neu-Anlage # Planungs-KI: Übungssuche & Kontext für Neu-Anlage
**Version:** 0.1 **Version:** 0.2
**Datum:** 2026-05-22 **Datum:** 2026-05-23
**Status:** P1 — Szenario-Pipeline + LLM Query-Intent-Overlay; P2 LLM-Rerank optional **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) **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“ | | `planned_exercise_ids[]` | Items der Einheit (Reihenfolge) | „N Übungen im Plan“ |
| `anchor_exercise_id`, Titel | Request oder letzte Übung vor Einfügepunkt | Anker | | `anchor_exercise_id`, Titel | Request oder letzte Übung vor Einfügepunkt | Anker |
| `anchor_skill_ids[]` | `exercise_skills` | (intern) | | `anchor_skill_ids[]` | `exercise_skills` | (intern) |
| `progression_graph_id` | Request (optional) | Graph | | `progression_graph_id` | Request oder **Auto-Match** vom Anker (sichtbarer Graph mit passenden Ausgangskanten) | Graph |
| `progression_successor_ids[]` | `exercise_progression_edges` ab Anker | (intern) | | `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 | | `group_recent_exercise_ids[]` | Letzte Einheiten derselben Gruppe | Wiederholungsstrafe |
| `framework_slot_notes` | Rahmen-Slot falls `framework_slot_id` | (später) | | `framework_slot_notes` | Rahmen-Slot falls `framework_slot_id` | (später) |
@ -106,6 +109,7 @@ score = w_ft * fulltext_rank
"phase_order_index": null, "phase_order_index": null,
"parallel_stream_order_index": null, "parallel_stream_order_index": null,
"anchor_exercise_id": 456, "anchor_exercise_id": 456,
"anchor_exercise_variant_id": 12,
"progression_graph_id": 7, "progression_graph_id": 7,
"query": "Schlage mir die nächste Übung vor", "query": "Schlage mir die nächste Übung vor",
"intent_hint": "suggest_next", "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 | | `ExercisePickerModal` | Prop `planningContext` → Planungs-API statt reiner `listExercises`; Kontext-Chips; `reasons` unter Treffer |
| `TrainingUnitEditPage` | `planningContext` aus Einheit + Picker-Ziel (Anker = letzte Übung im Abschnitt) | | `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 | | 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). **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 ## 9. Phasen-Roadmap
| Phase | Inhalt | | Phase | Inhalt | Status |
|-------|--------| |-------|--------|--------|
| **P0** ✅ | Context-Pack, Hybrid-Score, API, Picker in Planung | | **P0** | Context-Pack, Hybrid-Score, API, Picker in Planung | ✅ |
| **P0.1** ✅ | `ExerciseMatchProfile` / `PlanningTargetProfile`, `profile_v1`, `target_profile_summary` | | **P0.1** | `ExerciseMatchProfile` / `PlanningTargetProfile`, `profile_v1` | ✅ |
| **P2** ✅ (optional) | LLM-Rerank `planning_exercise_search_rank`, `include_llm_rank`, `llm_rank_applied` | | **P1** | Szenario-Pipeline + LLM Query-Intent → Erwartungsprofil | ✅ |
| **P1** ✅ | Szenario-Pipeline + LLM Query-Intent → Erwartungsprofil-Overlay | | **P2 / B2** | LLM-Rerank bei engem Top-Feld (max. 2 Calls) | ✅ |
| **P3** | Skill-Discovery / Framework-Ziele im Pack | | **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 ## 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:** 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 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`. - **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). - **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). - **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? | | `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) | | `progression` | Progressionsgraph / Pfad | Ja (wenn Freitext) |
| `deepen` | Vertiefung Anker | Ja | | `deepen` | Vertiefung Anker | Ja |
| `continue_plan` | Auf bisherigen Plan aufbauen | 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 | | Feld | Typ | Default | Bedeutung |
|------|-----|---------|-----------| |------|-----|---------|-----------|
| `planned_exercise_ids` | `int[]` | — | Optional: Reihenfolge aus Formular (überschreibt DB-Plan) | | `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:** **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 | | `retrieval_phase` | `"profile_v1"` — Phase-1 aktiv, kein LLM-Rerank |
| `target_profile_summary` | Lesbare Kurzinfo für UI-Chips (Fokus, Top-Skills, Quellen) | | `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

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

@ -257,6 +257,10 @@ def rank_visible_library_hits(
"reasons": reasons, "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 "")) hits.sort(key=lambda h: (-h["score"], h.get("title") or ""))
return hits, skills_by_ex return hits, skills_by_ex

View File

@ -6,7 +6,7 @@ Siehe .claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md
from __future__ import annotations from __future__ import annotations
import re 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 fastapi import HTTPException
from pydantic import BaseModel, Field 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_profiles import skill_profile_summary_from_exercise_ids
from planning_exercise_retrieval import run_multistage_planning_retrieval from planning_exercise_retrieval import run_multistage_planning_retrieval
from planning_exercise_llm_rank import try_llm_rerank_planning_hits 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 ( from planning_exercise_target_pipeline import (
build_planning_target_with_query_pipeline, build_planning_target_with_query_pipeline,
compose_retrieval_phase, compose_retrieval_phase,
@ -53,6 +54,7 @@ class PlanningExerciseSuggestRequest(BaseModel):
phase_order_index: Optional[int] = Field(default=None, ge=0) phase_order_index: Optional[int] = Field(default=None, ge=0)
parallel_stream_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_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) progression_graph_id: Optional[int] = Field(default=None, ge=1)
query: Optional[str] = "" query: Optional[str] = ""
intent_hint: Optional[str] = None 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")} 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, cur,
graph_id: Optional[int], tenant: TenantContext,
from_exercise_id: Optional[int], pack: Dict[str, Any],
) -> Tuple[Set[int], Dict[int, str]]: body: PlanningExerciseSuggestRequest,
if not graph_id or not from_exercise_id: *,
return set(), {} sections: Optional[Sequence[Dict[str, Any]]] = None,
cur.execute( ) -> Dict[str, Any]:
""" anchor_variant = _resolve_anchor_variant_id(pack, body, sections)
SELECT to_exercise_id, notes return apply_progression_context_to_pack(
FROM exercise_progression_edges cur,
WHERE graph_id = %s AND from_exercise_id = %s tenant,
AND LOWER(TRIM(edge_type)) = 'next_exercise' pack,
""", explicit_graph_id=body.progression_graph_id,
(int(graph_id), int(from_exercise_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( def _load_group_recent_exercise_ids(
@ -469,9 +502,6 @@ def build_planning_exercise_context_pack(
planned_ids = _collect_planned_exercise_ids(sections) planned_ids = _collect_planned_exercise_ids(sections)
anchor_id = _resolve_anchor_from_plan(planned_ids, body.anchor_exercise_id) anchor_id = _resolve_anchor_from_plan(planned_ids, body.anchor_exercise_id)
anchor_skills = _load_skill_ids_for_exercise(cur, anchor_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)) 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]) 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_exercise_id": anchor_id,
"anchor_title": anchor_title, "anchor_title": anchor_title,
"anchor_skill_ids": sorted(anchor_skills), "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), "group_recent_exercise_ids": sorted(group_recent),
} }
return _attach_planning_context_details(cur, pack, sections=sections, body=body) 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_id = _resolve_anchor_from_plan(planned_ids, body.anchor_exercise_id)
anchor_skills = _load_skill_ids_for_exercise(cur, anchor_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_id = body.group_id
group_name = None group_name = None
@ -559,9 +583,6 @@ def build_client_planning_context_pack(
"anchor_exercise_id": anchor_id, "anchor_exercise_id": anchor_id,
"anchor_title": anchor_title, "anchor_title": anchor_title,
"anchor_skill_ids": sorted(anchor_skills), "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), "group_recent_exercise_ids": sorted(group_recent),
"context_mode": "client_free", "context_mode": "client_free",
} }
@ -580,6 +601,12 @@ def suggest_planning_exercises(
pack = build_client_planning_context_pack(cur, tenant=tenant, body=body) pack = build_client_planning_context_pack(cur, tenant=tenant, body=body)
pack = _apply_client_planned_override(cur, pack, body) pack = _apply_client_planned_override(cur, pack, body)
pack = _attach_planning_context_details(cur, pack, body=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) query = _normalize_query(body.query)
heuristic_intent = resolve_planning_exercise_intent(query, body.intent_hint) heuristic_intent = resolve_planning_exercise_intent(query, body.intent_hint)
@ -713,6 +740,9 @@ def suggest_planning_exercises(
"anchor_exercise_id": pack.get("anchor_exercise_id"), "anchor_exercise_id": pack.get("anchor_exercise_id"),
"last_section_exercise_title": pack.get("last_section_exercise_title"), "last_section_exercise_title": pack.get("last_section_exercise_title"),
"progression_graph_id": pack.get("progression_graph_id"), "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"), "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"), "unit_skill_profile": pack.get("unit_skill_profile_summary"),
"section_skill_profile": pack.get("section_skill_profile_summary"), "section_skill_profile": pack.get("section_skill_profile_summary"),

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

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.182" APP_VERSION = "0.8.183"
BUILD_DATE = "2026-05-23" BUILD_DATE = "2026-05-23"
DB_SCHEMA_VERSION = "20260531074" DB_SCHEMA_VERSION = "20260531074"
@ -29,7 +29,7 @@ MODULE_VERSIONS = {
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0", "methods": "0.1.0",
"exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay "exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay
"planning_exercise_suggest": "0.10.0", # Phase B2: Rerank bei engem Top-Feld, auch nach Profil-LLM "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_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
"training_programs": "0.1.0", "training_programs": "0.1.0",
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung "planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
@ -44,6 +44,15 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ 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", "version": "0.8.182",
"date": "2026-05-23", "date": "2026-05-23",

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover # Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-05-31 **Stand:** 2026-05-23
**App-Version / DB-Schema:** App **`0.8.167`** (Planungs-KI Übungssuche P0); DB **`20260531071`** — maßgeblich **`backend/version.py`**. **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**. 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`) - **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 - **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 - **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 - **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`** - **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`** - **Pflege:** Superadmin **`/admin/ai-prompts`**, **`/admin/ai-skill-retrieval`**
- **Diagnose:** **`SHINKAN_AI_DEBUG=1`** — Logs `shinkan.exercise_ai`, `shinkan.openrouter` - **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 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 ## 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). ### Planungs-KI (priorisiert)
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. 1. **C2 — Varianten in Treffern:** Planungs-Picker: bei `suggested_variant_id` Variante vorauswählen; optional Varianten-Ranking bei `deepen`/`progression`.
4. **Tests:** pytest für `media_assets`-Router (Leserechte, Lifecycle, `from-asset`); ggf. Snapshot der Pfad-Umzug-Logik. 2. **C3 — Graph-Builder:** Modus „Pfad zum Ziel“ → sequenzielle Vorschläge → `POST …/edges/sequence` nach Review.
5. **Retention:** Job-Dokumentation + Betrieb (ENV, Intervall); Dry-Run beschreiben. 3. **Graph-Auswahl UI:** Dropdown neben Auto-Match; Rahmen-Slot / Framework mit Default-Graph verknüpfen.
6. **S3/Adapter:** Speicher-Abstraktion (Spec Abschnitt 7) — wenn Produkt es verlangt. 4. **Enrichment:** Skills für Kern-Übungen nachziehen (sonst schwaches Profil-Ranking).
7. **Rahmen/UI:** Kalender „aus Rahmen übernehmen” weiter anbinden (parallel, unabhängig von Medien). 5. **D — Neu-Anlage:** `planning_context_json` an `POST /api/exercises/ai/suggest`.
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). ### Allgemein
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`). 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).
12. **Backend-Validierung** `method_profile` / `planning_method_profile` je Archetyp (Paket **4g**). 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

@ -114,6 +114,7 @@ export default function ExercisePickerModal({
phaseOrderIndex: planningContext?.phaseOrderIndex ?? null, phaseOrderIndex: planningContext?.phaseOrderIndex ?? null,
parallelStreamOrderIndex: planningContext?.parallelStreamOrderIndex ?? null, parallelStreamOrderIndex: planningContext?.parallelStreamOrderIndex ?? null,
anchorExerciseId: planningContext?.anchorExerciseId ?? null, anchorExerciseId: planningContext?.anchorExerciseId ?? null,
anchorExerciseVariantId: planningContext?.anchorExerciseVariantId ?? null,
progressionGraphId: planningContext?.progressionGraphId ?? null, progressionGraphId: planningContext?.progressionGraphId ?? null,
plannedExerciseIds: Array.isArray(planningContext?.plannedExerciseIds) plannedExerciseIds: Array.isArray(planningContext?.plannedExerciseIds)
? planningContext.plannedExerciseIds ? planningContext.plannedExerciseIds
@ -446,6 +447,10 @@ export default function ExercisePickerModal({
activePlanningContext.anchorExerciseId != null activePlanningContext.anchorExerciseId != null
? Number(activePlanningContext.anchorExerciseId) ? Number(activePlanningContext.anchorExerciseId)
: null, : null,
anchor_exercise_variant_id:
activePlanningContext.anchorExerciseVariantId != null
? Number(activePlanningContext.anchorExerciseVariantId)
: undefined,
progression_graph_id: progression_graph_id:
activePlanningContext.progressionGraphId != null activePlanningContext.progressionGraphId != null
? Number(activePlanningContext.progressionGraphId) ? Number(activePlanningContext.progressionGraphId)
@ -500,6 +505,7 @@ export default function ExercisePickerModal({
title: h.title, title: h.title,
summary: h.summary, summary: h.summary,
focus_area: h.focus_area, focus_area: h.focus_area,
suggested_variant_id: h.suggested_variant_id ?? null,
_planningScore: h.score, _planningScore: h.score,
_planningReasons: Array.isArray(h.reasons) ? h.reasons : [], _planningReasons: Array.isArray(h.reasons) ? h.reasons : [],
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
@ -740,6 +746,12 @@ export default function ExercisePickerModal({
Anker: {planningContextSummary.anchor_title} Anker: {planningContextSummary.anchor_title}
</span> </span>
) : null} ) : 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) && {Array.isArray(planningTargetProfileSummary?.focus_areas) &&
planningTargetProfileSummary.focus_areas.length > 0 planningTargetProfileSummary.focus_areas.length > 0
? planningTargetProfileSummary.focus_areas.map((fa) => ( ? 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> </div>
) : null} ) : 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> </div>
<div className="exercise-card__footer"> <div className="exercise-card__footer">

View File

@ -14,7 +14,110 @@ export default function ExerciseListSearchBar({
exerciseCount, exerciseCount,
allOnPageSelected, allOnPageSelected,
onToggleSelectAllPage, 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 ( return (
<div className="card exercise-search-bar"> <div className="card exercise-search-bar">
<label className="form-label">Volltextsuche (Titel, Ziel, )</label> <label className="form-label">Volltextsuche (Titel, Ziel, )</label>

View File

@ -12,7 +12,8 @@ import ExerciseListBulkToolbar from './ExerciseListBulkToolbar'
import SaveSelectedExercisesAsModuleModal from './SaveSelectedExercisesAsModuleModal' import SaveSelectedExercisesAsModuleModal from './SaveSelectedExercisesAsModuleModal'
import ExercisePeekModal from '../ExercisePeekModal' import ExercisePeekModal from '../ExercisePeekModal'
import NavStateLink from '../NavStateLink' import NavStateLink from '../NavStateLink'
import ExerciseAiQuickCreateOffer from '../ExerciseAiQuickCreateOffer' import { ExerciseAiQuickCreateTeaser } from '../ExerciseAiQuickCreateOffer'
import ExerciseAiQuickCreateModal from './ExerciseAiQuickCreateModal'
import ExerciseAiSuggestPreviewModal from '../ExerciseAiSuggestPreviewModal' import ExerciseAiSuggestPreviewModal from '../ExerciseAiSuggestPreviewModal'
import { import {
buildQuickCreateAiPreview, buildQuickCreateAiPreview,
@ -31,6 +32,7 @@ import {
snapshotExerciseForSelection, snapshotExerciseForSelection,
} from '../../utils/exerciseListSelection' } from '../../utils/exerciseListSelection'
import { useExerciseListCatalogsAndQuery } from '../../hooks/useExerciseListCatalogsAndQuery' import { useExerciseListCatalogsAndQuery } from '../../hooks/useExerciseListCatalogsAndQuery'
import { usePlanningExerciseSuggestSearch } from '../../hooks/usePlanningExerciseSuggestSearch'
import { import {
INITIAL_EXERCISE_LIST_FILTERS, INITIAL_EXERCISE_LIST_FILTERS,
mergeExerciseListPrefsFromApi, mergeExerciseListPrefsFromApi,
@ -89,10 +91,15 @@ function ExercisesListPageRoot() {
const [peekExercise, setPeekExercise] = useState(null) const [peekExercise, setPeekExercise] = useState(null)
const [saveModuleModalOpen, setSaveModuleModalOpen] = useState(false) const [saveModuleModalOpen, setSaveModuleModalOpen] = useState(false)
const [aiQuickCreateEnabled, setAiQuickCreateEnabled] = useState(false) const [aiQuickCreateEnabled, setAiQuickCreateEnabled] = useState(false)
const [aiQuickCreateModalOpen, setAiQuickCreateModalOpen] = useState(false)
const [quickSaving, setQuickSaving] = useState(false) const [quickSaving, setQuickSaving] = useState(false)
const [quickAiError, setQuickAiError] = useState('') const [quickAiError, setQuickAiError] = useState('')
const [quickCreateDraft, setQuickCreateDraft] = useState(null) const [quickCreateDraft, setQuickCreateDraft] = useState(null)
const planningKi = usePlanningExerciseSuggestSearch({
enabled: pageTab === 'list' && aiQuickCreateEnabled,
})
const { const {
title: quickTitle, title: quickTitle,
sketch: quickSketch, sketch: quickSketch,
@ -100,9 +107,14 @@ function ExercisesListPageRoot() {
setTitle: setQuickTitle, setTitle: setQuickTitle,
setSketch: setQuickSketch, setSketch: setQuickSketch,
setFocusAreaId: setQuickFocusAreaId, setFocusAreaId: setQuickFocusAreaId,
} = useExerciseAiQuickCreateFields(debouncedSearch, { } = useExerciseAiQuickCreateFields(
enabled: pageTab === 'list' && (aiQuickCreateEnabled || debouncedSearch.length >= 3), aiQuickCreateModalOpen
}) ? planningKi.submittedQuery || planningKi.searchInput || debouncedSearch
: debouncedSearch,
{
enabled: pageTab === 'list' && aiQuickCreateModalOpen,
},
)
useEffect(() => { useEffect(() => {
if (!user?.id) return if (!user?.id) return
@ -174,14 +186,18 @@ function ExercisesListPageRoot() {
loadingMore, loadingMore,
hasMore, hasMore,
loadMore, loadMore,
} = useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey }) } = useExerciseListCatalogsAndQuery({
queryBase,
pageTab,
tenantClubDepKey,
skipListFetch: aiQuickCreateEnabled,
})
const showQuickCreateOffer = const listExercises = exercises
pageTab === 'list' && const listFetchingResolved = listFetching
catalogsReady && const exercisesForDisplay = aiQuickCreateEnabled ? planningKi.rows : listExercises
!listFetching && const listFetchingDisplay = aiQuickCreateEnabled ? planningKi.loading : listFetchingResolved
(aiQuickCreateEnabled || const hasMoreDisplay = aiQuickCreateEnabled ? false : hasMore
(exercises.length === 0 && selectedEntries.length === 0 && debouncedSearch.length >= 3))
const selectedIds = useMemo( const selectedIds = useMemo(
() => new Set(selectedEntries.map((e) => Number(e.id)).filter((id) => Number.isFinite(id) && id > 0)), () => new Set(selectedEntries.map((e) => Number(e.id)).filter((id) => Number.isFinite(id) && id > 0)),
@ -189,15 +205,22 @@ function ExercisesListPageRoot() {
) )
const selectedExercisesDisplay = useMemo( const selectedExercisesDisplay = useMemo(
() => mergeSelectedWithListEntries(selectedEntries, exercises), () => mergeSelectedWithListEntries(selectedEntries, exercisesForDisplay),
[selectedEntries, exercises] [selectedEntries, exercisesForDisplay],
) )
const filterResultExercises = useMemo( const filterResultExercises = useMemo(
() => exercises.filter((e) => !selectedIds.has(Number(e.id))), () => exercisesForDisplay.filter((e) => !selectedIds.has(Number(e.id))),
[exercises, selectedIds] [exercisesForDisplay, selectedIds],
) )
const showKiCreateTeaser =
aiQuickCreateEnabled &&
planningKi.hasSearched &&
!planningKi.loading &&
catalogsReady &&
filterResultExercises.length > 0
const focusOptions = useMemo( const focusOptions = useMemo(
() => () =>
catalogs.focusAreas.map((fa) => ({ catalogs.focusAreas.map((fa) => ({
@ -274,9 +297,9 @@ function ExercisesListPageRoot() {
/** Für Browser-/datalist-Vorschläge (aktuelle Treffer-Titel, begrenzt) */ /** Für Browser-/datalist-Vorschläge (aktuelle Treffer-Titel, begrenzt) */
const searchTitleSuggestions = useMemo(() => { 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) return [...new Set(titles)].slice(0, 80)
}, [exercises]) }, [listExercises])
const clubNameById = useMemo(() => { const clubNameById = useMemo(() => {
const m = {} const m = {}
@ -377,6 +400,7 @@ function ExercisesListPageRoot() {
setQuickCreateDraft( setQuickCreateDraft(
aiPreviewToQuickCreateDraft(preview, { title, focusAreaId: focusId, sketchPlain: sketch }), aiPreviewToQuickCreateDraft(preview, { title, focusAreaId: focusId, sketchPlain: sketch }),
) )
setAiQuickCreateModalOpen(false)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
const msg = e?.message || String(e) const msg = e?.message || String(e)
@ -597,23 +621,36 @@ function ExercisesListPageRoot() {
<div className="exercises-page__header-actions"> <div className="exercises-page__header-actions">
<label <label
className="exercises-ai-assistant-toggle" 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 <input
type="checkbox" type="checkbox"
checked={aiQuickCreateEnabled} 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 className="exercises-ai-assistant-toggle__track" aria-hidden="true" />
<span>Neu mit KI-Assistent</span> <span>Neu mit KI-Assistent</span>
</label> </label>
<NavStateLink {aiQuickCreateEnabled ? (
to="/exercises/new" <button
returnContext={exercisesModuleReturnContext} type="button"
className="btn btn-primary" className="btn btn-primary"
> onClick={() => setAiQuickCreateModalOpen(true)}
+ Neu >
</NavStateLink> + Neue Übung mit KI
</button>
) : (
<NavStateLink
to="/exercises/new"
returnContext={exercisesModuleReturnContext}
className="btn btn-primary"
>
+ Neu
</NavStateLink>
)}
</div> </div>
) : ( ) : (
<span aria-hidden="true" /> <span aria-hidden="true" />
@ -644,6 +681,16 @@ function ExercisesListPageRoot() {
) : ( ) : (
<> <>
<ExerciseListSearchBar <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} searchTitleSuggestions={searchTitleSuggestions}
searchInput={searchInput} searchInput={searchInput}
onSearchInputChange={setSearchInput} onSearchInputChange={setSearchInput}
@ -654,30 +701,15 @@ function ExercisesListPageRoot() {
onOpenFilter={() => setFilterModalOpen(true)} onOpenFilter={() => setFilterModalOpen(true)}
filterChips={filterChips} filterChips={filterChips}
onResetAllFilters={resetAllFilters} onResetAllFilters={resetAllFilters}
exerciseCount={exercises.length} exerciseCount={exercisesForDisplay.length}
allOnPageSelected={allOnPageSelected} allOnPageSelected={allOnPageSelected}
onToggleSelectAllPage={toggleSelectAllPage} onToggleSelectAllPage={toggleSelectAllPage}
/> />
{showQuickCreateOffer ? ( {showKiCreateTeaser ? (
<ExerciseAiQuickCreateOffer <ExerciseAiQuickCreateTeaser
searchLabel={debouncedSearch || undefined} onExpand={() => setAiQuickCreateModalOpen(true)}
title={quickTitle} disabled={quickSaving}
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
}
/> />
) : null} ) : null}
@ -762,22 +794,36 @@ function ExercisesListPageRoot() {
onClose={() => setPeekExercise(null)} 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="card empty-state" style={{ padding: '2rem 1rem' }}>
<div className="spinner" /> <div className="spinner" />
<p className="muted" style={{ marginTop: '12px' }}> <p className="muted" style={{ marginTop: '12px' }}>
Lade Übungen {aiQuickCreateEnabled ? 'Planungs-KI lädt Vorschläge…' : 'Lade Übungen…'}
</p> </p>
</div> </div>
) : exercises.length === 0 && selectedEntries.length === 0 && !showQuickCreateOffer ? ( ) : exercisesForDisplay.length === 0 && selectedEntries.length === 0 ? (
<div className="card"> <div className="card">
<p className="exercises-empty-text"> <p className="exercises-empty-text">
{debouncedSearch.length >= 3 {aiQuickCreateEnabled
? 'Keine Übungen gefunden.' ? planningKi.hasSearched
: 'Keine Übungen gefunden — Suchbegriff eingeben oder Filter anpassen.'} ? '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> </p>
{aiQuickCreateEnabled && planningKi.hasSearched ? (
<button
type="button"
className="btn btn-secondary"
style={{ marginTop: '12px' }}
onClick={() => setAiQuickCreateModalOpen(true)}
>
Neue Übung mit KI
</button>
) : null}
</div> </div>
) : exercises.length === 0 && selectedEntries.length === 0 && showQuickCreateOffer ? null : ( ) : (
<> <>
{selectedEntries.length > 0 ? ( {selectedEntries.length > 0 ? (
<section className="exercises-selection-section" data-testid="exercises-selection-section"> <section className="exercises-selection-section" data-testid="exercises-selection-section">
@ -812,13 +858,14 @@ function ExercisesListPageRoot() {
) : null ) : null
) : ( ) : (
<> <>
{listFetching ? ( {listFetchingDisplay ? (
<p className="exercises-meta-line exercises-meta-line--muted">Aktualisiere Treffer</p> <p className="exercises-meta-line exercises-meta-line--muted">Aktualisiere Treffer</p>
) : null} ) : null}
<p className="exercises-meta-line"> <p className="exercises-meta-line">
{filterResultExercises.length} Treffer {filterResultExercises.length} Treffer
{aiQuickCreateEnabled ? ' (Planungs-KI)' : ''}
{selectedEntries.length > 0 ? ' (ohne bereits Ausgewählte)' : ''} {selectedEntries.length > 0 ? ' (ohne bereits Ausgewählte)' : ''}
{hasMore ? ' · es gibt weitere Einträge' : ''} {hasMoreDisplay ? ' · es gibt weitere Einträge' : ''}
</p> </p>
<div className="exercises-list-grid" data-testid="exercises-list-grid"> <div className="exercises-list-grid" data-testid="exercises-list-grid">
{filterResultExercises.map((exercise) => ( {filterResultExercises.map((exercise) => (
@ -833,7 +880,7 @@ function ExercisesListPageRoot() {
/> />
))} ))}
</div> </div>
{hasMore && ( {hasMoreDisplay && (
<div className="exercises-load-more"> <div className="exercises-load-more">
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}> <button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
{loadingMore ? 'Laden…' : 'Mehr laden'} {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 <ExerciseAiSuggestPreviewModal
draft={quickCreateDraft} draft={quickCreateDraft}
onDraftChange={setQuickCreateDraft} 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. * 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({ const [catalogs, setCatalogs] = useState({
focusAreas: [], focusAreas: [],
styleDirections: [], styleDirections: [],
@ -55,7 +55,14 @@ export function useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClub
}, []) }, [])
useEffect(() => { useEffect(() => {
if (!catalogsReady || pageTab !== 'list') return if (!catalogsReady || pageTab !== 'list' || skipListFetch) {
if (skipListFetch) {
setExercises([])
setHasMore(false)
setListFetching(false)
}
return
}
let cancelled = false let cancelled = false
const run = async () => { const run = async () => {
setListFetching(true) setListFetching(true)
@ -81,7 +88,7 @@ export function useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClub
return () => { return () => {
cancelled = true cancelled = true
} }
}, [queryBase, catalogsReady, pageTab, tenantClubDepKey]) }, [queryBase, catalogsReady, pageTab, tenantClubDepKey, skipListFetch])
const loadMore = useCallback(async () => { const loadMore = useCallback(async () => {
if (loadingMore || !hasMore) return 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 sIdx = target?.sIdx ?? 0
const sec = secs[sIdx] const sec = secs[sIdx]
let anchorExerciseId = null 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 (sec?.items?.length) {
if (typeof target?.iIdx === 'number') { if (typeof target?.iIdx === 'number') {
const item = sec.items[target.iIdx] const item = sec.items[target.iIdx]
if (item?.exercise_id) { if (item?.exercise_id) {
anchorExerciseId = Number(item.exercise_id) pickAnchorFromItem(item)
} else { } else {
for (let i = target.iIdx - 1; i >= 0; i -= 1) { for (let i = target.iIdx - 1; i >= 0; i -= 1) {
if (sec.items[i]?.exercise_id) { if (sec.items[i]?.exercise_id) {
anchorExerciseId = Number(sec.items[i].exercise_id) pickAnchorFromItem(sec.items[i])
break break
} }
} }
@ -149,14 +158,14 @@ export default function TrainingUnitEditPage() {
const beforeIx = target.insertBeforeIndex const beforeIx = target.insertBeforeIndex
for (let i = beforeIx - 1; i >= 0; i -= 1) { for (let i = beforeIx - 1; i >= 0; i -= 1) {
if (sec.items[i]?.exercise_id) { if (sec.items[i]?.exercise_id) {
anchorExerciseId = Number(sec.items[i].exercise_id) pickAnchorFromItem(sec.items[i])
break break
} }
} }
} else { } else {
for (let i = sec.items.length - 1; i >= 0; i -= 1) { for (let i = sec.items.length - 1; i >= 0; i -= 1) {
if (sec.items[i]?.exercise_id) { if (sec.items[i]?.exercise_id) {
anchorExerciseId = Number(sec.items[i].exercise_id) pickAnchorFromItem(sec.items[i])
break break
} }
} }
@ -204,6 +213,7 @@ export default function TrainingUnitEditPage() {
sectionExerciseCount: sectionPlannedExerciseIds.length, sectionExerciseCount: sectionPlannedExerciseIds.length,
lastExerciseTitle, lastExerciseTitle,
anchorExerciseId: Number.isFinite(anchorExerciseId) && anchorExerciseId > 0 ? anchorExerciseId : null, anchorExerciseId: Number.isFinite(anchorExerciseId) && anchorExerciseId > 0 ? anchorExerciseId : null,
anchorExerciseVariantId,
progressionGraphId: null, progressionGraphId: null,
plannedExerciseIds, plannedExerciseIds,
} }