diff --git a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md
index 7d3dbbe..bbce56c 100644
--- a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md
+++ b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md
@@ -172,7 +172,7 @@ score = w_ft * fulltext_rank
Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“:
-- Gleiches `context_summary` an `suggestExerciseAi` anhängen (Felder `planning_context_json` o. ä. — noch offen)
+- `planning_context` im Request-Body → `planning_context_json` in Übungs-Prompts (Migration **085**); Pfad-Builder + Picker ✅ **0.8.208**
- Kurzbeschreibung optional leer (freier Vorschlag) oder aus Intent/Skizze
---
@@ -193,7 +193,7 @@ Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“:
| **C3** | Graph-Builder (Ziel → Pfad → speichern) | ✅ **0.8.185** |
| **E** | Semantik-Schicht + Pfad-QA (Lücken/Brücken/LLM-QS) | ✅ **0.8.186** |
| **E2** | Pfad-Neuordnung + KI-Lückenfüller | ✅ **0.8.187** |
-| **D** | Neu-Anlage: Pack an `suggestExerciseAi` | 🔲 |
+| **D** | Neu-Anlage: `planning_context` an `suggestExerciseAi` (Migration **085**) | ✅ **0.8.208** |
---
diff --git a/backend/ai_prompt_context.py b/backend/ai_prompt_context.py
index bd441b4..d3b34b6 100644
--- a/backend/ai_prompt_context.py
+++ b/backend/ai_prompt_context.py
@@ -5,7 +5,7 @@ Keine Imports aus exercise_ai — vermeidet Zirkelimporte mit ai_prompt_job / ex
"""
from __future__ import annotations
-from typing import List, Optional, Sequence, Tuple
+from typing import Any, Dict, List, Optional, Sequence, Tuple
from pydantic import BaseModel, Field
@@ -31,6 +31,7 @@ class ExerciseFormAiPromptContext(BaseModel):
trainer_notes: Optional[str] = None
focus_hint: Optional[str] = None
focus_areas_context: Optional[List[ExerciseFormAiFocusRow]] = None
+ planning_context: Optional[Dict[str, Any]] = None
def focus_area_tuples(self) -> Optional[List[Tuple[int, bool]]]:
if not self.focus_areas_context:
@@ -57,6 +58,7 @@ class ExerciseFormAiPromptContext(BaseModel):
trainer_notes: Optional[str] = None,
focus_area_hint: Optional[str] = None,
focus_areas_context: Optional[Sequence[ExerciseFormAiFocusRow]] = None,
+ planning_context: Optional[Dict[str, Any]] = None,
) -> ExerciseFormAiPromptContext:
"""Mappt Felder aus POST /exercises/ai/suggest (focus_area_hint → focus_hint)."""
hint = (focus_area_hint or "").strip() or None
@@ -68,6 +70,7 @@ class ExerciseFormAiPromptContext(BaseModel):
trainer_notes=trainer_notes,
focus_hint=hint,
focus_areas_context=list(focus_areas_context) if focus_areas_context else None,
+ planning_context=dict(planning_context) if planning_context else None,
)
@classmethod
diff --git a/backend/ai_prompt_job.py b/backend/ai_prompt_job.py
index 38074ba..f0595ca 100644
--- a/backend/ai_prompt_job.py
+++ b/backend/ai_prompt_job.py
@@ -23,6 +23,7 @@ def resolve_exercise_form_variables(cur, slug: str, ctx: ExerciseFormAiPromptCon
focus_areas_context=ctx.focus_area_tuples(),
preparation=ctx.preparation,
trainer_notes=ctx.trainer_notes,
+ planning_context=ctx.planning_context,
)
diff --git a/backend/exercise_ai.py b/backend/exercise_ai.py
index 5b1c73e..c744c9b 100644
--- a/backend/exercise_ai.py
+++ b/backend/exercise_ai.py
@@ -650,10 +650,13 @@ def build_exercise_placeholder_variables(
focus_areas_context: Optional[Sequence[Tuple[int, bool]]],
preparation: Optional[str] = None,
trainer_notes: Optional[str] = None,
+ planning_context: Optional[Mapping[str, Any]] = None,
) -> Dict[str, str]:
"""
Baut die Variable-Map fuer {{platzhalter}} passend zur Slug fuer Uebungs-KI.
"""
+ from planning_exercise_form_context import planning_context_prompt_variables
+
s = (slug or "").strip().lower()
if s == "pipeline":
return {}
@@ -671,8 +674,19 @@ def build_exercise_placeholder_variables(
"exercise_preparation": p_plain or "-",
"exercise_trainer_notes": n_plain or "-",
}
+ ctx.update(planning_context_prompt_variables(planning_context))
if s == "exercise_summary":
- return {k: ctx[k] for k in ("exercise_title", "exercise_focus_area", "exercise_goal", "exercise_execution")}
+ return {
+ k: ctx[k]
+ for k in (
+ "exercise_title",
+ "exercise_focus_area",
+ "exercise_goal",
+ "exercise_execution",
+ "planning_context_json",
+ "has_planning_context",
+ )
+ }
if s == "exercise_instruction_rewrite":
return ctx
if s == "exercise_skill_suggestions":
@@ -893,6 +907,7 @@ def run_exercise_ai_suggestion(
execution=execution,
focus_area_hint=focus_area_hint,
focus_areas_context=focus_areas_context,
+ planning_context=form_ctx.planning_context,
)
except ValueError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
@@ -938,6 +953,7 @@ def run_exercise_ai_suggestion(
execution=execution,
focus_area_hint=focus_area_hint,
focus_areas_context=focus_areas_context,
+ planning_context=form_ctx.planning_context,
)
except ValueError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
@@ -1015,6 +1031,7 @@ def run_exercise_ai_suggestion(
trainer_notes=trainer_notes,
focus_area_hint=focus_area_hint,
focus_areas_context=focus_areas_context,
+ planning_context=form_ctx.planning_context,
)
except ValueError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
diff --git a/backend/migrations/085_ai_prompt_exercise_planning_context.sql b/backend/migrations/085_ai_prompt_exercise_planning_context.sql
new file mode 100644
index 0000000..0811729
--- /dev/null
+++ b/backend/migrations/085_ai_prompt_exercise_planning_context.sql
@@ -0,0 +1,181 @@
+-- Migration 085: Planungskontext in Übungs-KI-Prompts (Phase D)
+-- Platzhalter: {{planning_context_json}}, {{#has_planning_context}} … {{/has_planning_context}}
+
+UPDATE ai_prompts
+SET template = $s$Du bist Assistent fuer Kampfsport-Trainer.
+Erstelle eine kurze Kurzbeschreibung fuer Listen und Trainingsplaene.
+
+Anforderungen:
+- Hochstens etwa 200 Zeichen (bei Bedarf gekuerzt fuer Mobile)
+- Kern: Welche Trainingsqualitaeten? Wie fuehrt man die Uebung kurz aus?
+- Sachlich, auf Deutsch
+
+Uebung: {{exercise_title}}
+Fokuskontext: {{exercise_focus_area}}
+Ziel (Fliesstext, kann HTML sein): {{exercise_goal}}
+Durchfuehrung (Fliesstext, kann HTML sein): {{exercise_execution}}
+{{#has_planning_context}}
+Planungskontext (JSON — Einordnung in Trainingsplan oder Progressionspfad):
+{{planning_context_json}}
+{{/has_planning_context}}
+
+Antworte NUR mit der Kurzbeschreibung als einfachen Text (keine Markdown-Codeblocks, keine Anfuehrungszeichen um den ganzen Text).$s$,
+ default_template = $s$Du bist Assistent fuer Kampfsport-Trainer.
+Erstelle eine kurze Kurzbeschreibung fuer Listen und Trainingsplaene.
+
+Anforderungen:
+- Hochstens etwa 200 Zeichen (bei Bedarf gekuerzt fuer Mobile)
+- Kern: Welche Trainingsqualitaeten? Wie fuehrt man die Uebung kurz aus?
+- Sachlich, auf Deutsch
+
+Uebung: {{exercise_title}}
+Fokuskontext: {{exercise_focus_area}}
+Ziel (Fliesstext, kann HTML sein): {{exercise_goal}}
+Durchfuehrung (Fliesstext, kann HTML sein): {{exercise_execution}}
+{{#has_planning_context}}
+Planungskontext (JSON — Einordnung in Trainingsplan oder Progressionspfad):
+{{planning_context_json}}
+{{/has_planning_context}}
+
+Antworte NUR mit der Kurzbeschreibung als einfachen Text (keine Markdown-Codeblocks, keine Anfuehrungszeichen um den ganzen Text).$s$
+WHERE slug = 'exercise_summary';
+
+UPDATE ai_prompts
+SET template = $j$Du bist Assistent fuer Kampfsport-Trainer.
+Ordne diese Uebung dem globalen Skill-Katalog zu.
+
+Daten zur Uebung:
+Titel: {{exercise_title}}
+Fokuskontext (optional): {{exercise_focus_area}}
+Ziel (gekuerzt_plain): {{exercise_goal}}
+Durchfuehrung (gekuerzt_plain): {{exercise_execution}}
+{{#has_planning_context}}
+Planungskontext (JSON):
+{{planning_context_json}}
+{{/has_planning_context}}
+
+Verfuegbare Faehigkeiten (Auswahl NUR ueber diese IDs — keine anderen IDs verwenden):
+{{skills_catalog}}
+
+Waehle hoechstens 5 passende Skills. Für jede Faehigkeit:
+- skill_id: ganze Zahl aus der Liste
+- required_level: eines von basis, grundlagen, aufbau, fortgeschritten, optimierung
+- target_level: derselbe Wertvorrat
+- intensity: eines von niedrig, mittel, hoch
+- is_primary (optional): true fuer die Hauptfaehigkeit der Uebung, sondern false/weglassen
+
+Antworte NUR mit einem JSON-Array ohne Erklaertext, keine Markdown-Fences.
+
+Beispielformat:
+[{"skill_id": 1, "required_level": "grundlagen", "target_level": "aufbau", "intensity": "hoch", "is_primary": true}]
+
+Wenn nichts gut passt, antworte mit [].$j$,
+ default_template = $j$Du bist Assistent fuer Kampfsport-Trainer.
+Ordne diese Uebung dem globalen Skill-Katalog zu.
+
+Daten zur Uebung:
+Titel: {{exercise_title}}
+Fokuskontext (optional): {{exercise_focus_area}}
+Ziel (gekuerzt_plain): {{exercise_goal}}
+Durchfuehrung (gekuerzt_plain): {{exercise_execution}}
+{{#has_planning_context}}
+Planungskontext (JSON):
+{{planning_context_json}}
+{{/has_planning_context}}
+
+Verfuegbare Faehigkeiten (Auswahl NUR ueber diese IDs — keine anderen IDs verwenden):
+{{skills_catalog}}
+
+Waehle hoechstens 5 passende Skills. Für jede Faehigkeit:
+- skill_id: ganze Zahl aus der Liste
+- required_level: eines von basis, grundlagen, aufbau, fortgeschritten, optimierung
+- target_level: derselbe Wertvorrat
+- intensity: eines von niedrig, mittel, hoch
+- is_primary (optional): true fuer die Hauptfaehigkeit der Uebung, sondern false/weglassen
+
+Antworte NUR mit einem JSON-Array ohne Erklaertext, keine Markdown-Fences.
+
+Beispielformat:
+[{"skill_id": 1, "required_level": "grundlagen", "target_level": "aufbau", "intensity": "hoch", "is_primary": true}]
+
+Wenn nichts gut passt, antworte mit [].$j$
+WHERE slug = 'exercise_skill_suggestions';
+
+UPDATE ai_prompts
+SET template = $t$Du bist Assistent fuer Kampfsport-Trainer.
+Ueberarbeite die Anleitung dieser Uebung: verbessere Formulierung, ergaenze fehlende Kernpunkte, kuerze ueberfluessige Passagen.
+Wichtig: Texte sollen praezise und nachvollziehbar bleiben — keine Fuellsaetze, keine Wiederholungen, kein Marketing.
+
+Stil:
+- Deutsch, sachlich, direkt an Trainer gerichtet (Durchfuehrung: Imperativ oder klare Schritte)
+- Ziel: 1–3 kurze Absaetze (Kern des Trainingsziels)
+- Durchfuehrung: klare Schritte (nummerierte Liste oder kurze Absaetze)
+- Vorbereitung/Aufbau: nur wenn noetig (Raum, Material, Aufbau) — sonst leerer String
+- Trainer-Hinweise: Sicherheit, typische Fehler, Coaching-Tipps — knapp, Stichpunkte oder kurze Absaetze
+
+Format (HTML fuer Rich-Text-Editor):
+- Erlaubt:
,
, , - , , ,
+- Keine Ueberschriften (h1–h6), keine Tabellen, kein Markdown, keine Code-Fences
+- Medienverweise {{exerciseMedia:ID}} aus den Eingabetexten UNVERAENDERT an passender Stelle uebernehmen
+
+Eingabe:
+Titel: {{exercise_title}}
+Fokuskontext: {{exercise_focus_area}}
+
+Ziel (Plaintext, Ausgang): {{exercise_goal}}
+Durchfuehrung (Plaintext, Ausgang): {{exercise_execution}}
+Vorbereitung/Aufbau (Plaintext, Ausgang): {{exercise_preparation}}
+Trainer-Hinweise (Plaintext, Ausgang): {{exercise_trainer_notes}}
+{{#has_planning_context}}
+Planungskontext (JSON):
+{{planning_context_json}}
+{{/has_planning_context}}
+
+Antworte NUR mit einem JSON-Objekt (kein Text davor/danach):
+{
+ "goal": "…
",
+ "execution": "- …
",
+ "preparation": "…
oder \"\"",
+ "trainer_notes": " oder \"\""
+}
+
+Leere Felder als leerer String "" wenn nichts Sinnvolles ergibt.$t$,
+ default_template = $t$Du bist Assistent fuer Kampfsport-Trainer.
+Ueberarbeite die Anleitung dieser Uebung: verbessere Formulierung, ergaenze fehlende Kernpunkte, kuerze ueberfluessige Passagen.
+Wichtig: Texte sollen praezise und nachvollziehbar bleiben — keine Fuellsaetze, keine Wiederholungen, kein Marketing.
+
+Stil:
+- Deutsch, sachlich, direkt an Trainer gerichtet (Durchfuehrung: Imperativ oder klare Schritte)
+- Ziel: 1–3 kurze Absaetze (Kern des Trainingsziels)
+- Durchfuehrung: klare Schritte (nummerierte Liste oder kurze Absaetze)
+- Vorbereitung/Aufbau: nur wenn noetig (Raum, Material, Aufbau) — sonst leerer String
+- Trainer-Hinweise: Sicherheit, typische Fehler, Coaching-Tipps — knapp, Stichpunkte oder kurze Absaetze
+
+Format (HTML fuer Rich-Text-Editor):
+- Erlaubt: ,
, , - , , ,
+- Keine Ueberschriften (h1–h6), keine Tabellen, kein Markdown, keine Code-Fences
+- Medienverweise {{exerciseMedia:ID}} aus den Eingabetexten UNVERAENDERT an passender Stelle uebernehmen
+
+Eingabe:
+Titel: {{exercise_title}}
+Fokuskontext: {{exercise_focus_area}}
+
+Ziel (Plaintext, Ausgang): {{exercise_goal}}
+Durchfuehrung (Plaintext, Ausgang): {{exercise_execution}}
+Vorbereitung/Aufbau (Plaintext, Ausgang): {{exercise_preparation}}
+Trainer-Hinweise (Plaintext, Ausgang): {{exercise_trainer_notes}}
+{{#has_planning_context}}
+Planungskontext (JSON):
+{{planning_context_json}}
+{{/has_planning_context}}
+
+Antworte NUR mit einem JSON-Objekt (kein Text davor/danach):
+{
+ "goal": "…
",
+ "execution": "- …
",
+ "preparation": "…
oder \"\"",
+ "trainer_notes": " oder \"\""
+}
+
+Leere Felder als leerer String "" wenn nichts Sinnvolles ergibt.$t$
+WHERE slug = 'exercise_instruction_rewrite';
diff --git a/backend/planning_exercise_form_context.py b/backend/planning_exercise_form_context.py
new file mode 100644
index 0000000..7e6bac0
--- /dev/null
+++ b/backend/planning_exercise_form_context.py
@@ -0,0 +1,138 @@
+"""
+Planungs-KI Phase D: strukturierter Planungskontext für POST /exercises/ai/suggest.
+
+Wird als ``planning_context_json`` in Übungs-Prompts (summary, skills, instructions) injiziert.
+"""
+from __future__ import annotations
+
+import json
+from typing import Any, Dict, Mapping, Optional
+
+_MAX_JSON_CHARS = 6000
+_MAX_STRING = 800
+
+
+def compact_planning_context_json(obj: Any) -> str:
+ return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
+
+
+def _trim_str(val: Any, *, limit: int = _MAX_STRING) -> Optional[str]:
+ if val is None:
+ return None
+ s = str(val).strip()
+ if not s:
+ return None
+ if len(s) > limit:
+ return s[: limit - 1] + "…"
+ return s
+
+
+def sanitize_planning_context_for_ai(ctx: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
+ """Reduziert Client-Payload auf prompt-taugliche, begrenzte Felder."""
+ if not ctx:
+ return {}
+ out: Dict[str, Any] = {}
+ for key, val in dict(ctx).items():
+ if val is None:
+ continue
+ k = str(key).strip()
+ if not k:
+ continue
+ if isinstance(val, str):
+ t = _trim_str(val)
+ if t:
+ out[k] = t
+ elif isinstance(val, (int, float, bool)):
+ out[k] = val
+ elif isinstance(val, list):
+ items = []
+ for item in val[:12]:
+ if isinstance(item, str):
+ t = _trim_str(item, limit=200)
+ if t:
+ items.append(t)
+ elif isinstance(item, (int, float, bool)):
+ items.append(item)
+ elif isinstance(item, dict):
+ sub = sanitize_planning_context_for_ai(item)
+ if sub:
+ items.append(sub)
+ if items:
+ out[k] = items
+ elif isinstance(val, dict):
+ sub = sanitize_planning_context_for_ai(val)
+ if sub:
+ out[k] = sub
+ raw = compact_planning_context_json(out)
+ if len(raw) > _MAX_JSON_CHARS:
+ out["truncated"] = True
+ out.pop("path_steps_preview", None)
+ raw = compact_planning_context_json(out)
+ if len(raw) > _MAX_JSON_CHARS:
+ return {"source": out.get("source"), "truncated": True, "goal_query": out.get("goal_query")}
+ return out
+
+
+def planning_context_prompt_variables(
+ planning_context: Optional[Mapping[str, Any]],
+) -> Dict[str, str]:
+ cleaned = sanitize_planning_context_for_ai(planning_context)
+ if not cleaned:
+ return {"planning_context_json": "-", "has_planning_context": ""}
+ return {
+ "planning_context_json": compact_planning_context_json(cleaned),
+ "has_planning_context": "true",
+ }
+
+
+def build_progression_path_gap_planning_context(
+ *,
+ goal_query: str,
+ primary_topic: Optional[str] = None,
+ progression_graph_id: Optional[int] = None,
+ offer: Optional[Mapping[str, Any]] = None,
+ neighbor_before: Optional[Mapping[str, Any]] = None,
+ neighbor_after: Optional[Mapping[str, Any]] = None,
+ path_step_count: int = 0,
+ major_step_count: Optional[int] = None,
+ roadmap_phase: Optional[str] = None,
+ roadmap_learning_goal: Optional[str] = None,
+) -> Dict[str, Any]:
+ """Kontext für KI-Neuanlage aus Progressionsgraph-Pfad-Lücke."""
+ offer = offer or {}
+ gap = offer.get("gap") if isinstance(offer.get("gap"), dict) else {}
+ major_idx = offer.get("roadmap_major_step_index")
+ if major_idx is None and isinstance(gap, dict):
+ major_idx = gap.get("roadmap_major_step_index")
+
+ ctx: Dict[str, Any] = {
+ "source": "progression_path_gap_fill",
+ "goal_query": _trim_str(goal_query, limit=2000),
+ "primary_topic": _trim_str(primary_topic),
+ "progression_graph_id": progression_graph_id,
+ "gap_source": _trim_str(offer.get("source")),
+ "gap_phase": _trim_str(offer.get("phase") or gap.get("expected_phase")),
+ "roadmap_major_step_index": major_idx,
+ "roadmap_phase": _trim_str(roadmap_phase or offer.get("phase")),
+ "roadmap_learning_goal": _trim_str(
+ roadmap_learning_goal or offer.get("title_hint") or gap.get("learning_goal"),
+ limit=1200,
+ ),
+ "neighbor_before_title": _trim_str(
+ (neighbor_before or {}).get("title") or offer.get("from_title")
+ ),
+ "neighbor_after_title": _trim_str(
+ (neighbor_after or {}).get("title") or offer.get("to_title")
+ ),
+ "path_step_count": path_step_count,
+ "major_step_count": major_step_count,
+ }
+ return sanitize_planning_context_for_ai(ctx)
+
+
+__all__ = [
+ "build_progression_path_gap_planning_context",
+ "compact_planning_context_json",
+ "planning_context_prompt_variables",
+ "sanitize_planning_context_for_ai",
+]
diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py
index 9941ce2..e86e005 100644
--- a/backend/routers/exercises.py
+++ b/backend/routers/exercises.py
@@ -385,6 +385,10 @@ class ExerciseAiSuggestBody(BaseModel):
include_summary: bool = True
include_skills: bool = True
include_instructions: bool = False
+ planning_context: Optional[dict] = Field(
+ default=None,
+ description="Optionaler Planungskontext (Einheit, Pfad, Roadmap-Stufe) für KI-Neuanlage",
+ )
@model_validator(mode="after")
def check_include_any(self):
@@ -403,6 +407,7 @@ class ExerciseAiSuggestBody(BaseModel):
trainer_notes=self.trainer_notes,
focus_area_hint=self.focus_area_hint,
focus_areas_context=self.focus_areas_context,
+ planning_context=self.planning_context,
)
diff --git a/backend/tests/test_planning_exercise_form_context.py b/backend/tests/test_planning_exercise_form_context.py
new file mode 100644
index 0000000..1cb6ebd
--- /dev/null
+++ b/backend/tests/test_planning_exercise_form_context.py
@@ -0,0 +1,46 @@
+"""Tests Planungs-KI Phase D — planning_context für suggestExerciseAi."""
+from planning_exercise_form_context import (
+ build_progression_path_gap_planning_context,
+ planning_context_prompt_variables,
+ sanitize_planning_context_for_ai,
+)
+
+
+def test_planning_context_prompt_variables_empty():
+ vars_ = planning_context_prompt_variables(None)
+ assert vars_["planning_context_json"] == "-"
+ assert vars_["has_planning_context"] == ""
+
+
+def test_planning_context_prompt_variables_with_data():
+ vars_ = planning_context_prompt_variables({"source": "test", "goal_query": "Mae Geri"})
+ assert vars_["has_planning_context"] == "true"
+ assert "Mae Geri" in vars_["planning_context_json"]
+
+
+def test_build_progression_path_gap_context():
+ ctx = build_progression_path_gap_planning_context(
+ goal_query="Mae Geri Perfektion",
+ primary_topic="Mae Geri",
+ progression_graph_id=3,
+ offer={
+ "source": "roadmap_unfilled",
+ "phase": "vertiefung",
+ "title_hint": "Koordination Mae Geri",
+ "roadmap_major_step_index": 2,
+ "from_title": "Schritt A",
+ "to_title": "Schritt B",
+ },
+ neighbor_before={"title": "Schritt A"},
+ neighbor_after={"title": "Schritt B"},
+ path_step_count=4,
+ major_step_count=5,
+ )
+ assert ctx["source"] == "progression_path_gap_fill"
+ assert ctx["roadmap_major_step_index"] == 2
+ assert ctx["neighbor_before_title"] == "Schritt A"
+
+
+def test_sanitize_truncates_long_strings():
+ ctx = sanitize_planning_context_for_ai({"goal_query": "x" * 900})
+ assert len(ctx["goal_query"]) <= 800
diff --git a/backend/version.py b/backend/version.py
index 9ae3136..919529e 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.207"
+APP_VERSION = "0.8.208"
BUILD_DATE = "2026-06-07"
DB_SCHEMA_VERSION = "20260606086"
@@ -38,7 +38,7 @@ MODULE_VERSIONS = {
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0",
"exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume
- "planning_exercise_suggest": "0.19.0", # F4: roadmap_only + roadmap_override, editierbare Roadmap-UI
+ "planning_exercise_suggest": "0.20.0", # Phase D: planning_context an suggestExerciseAi
"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
@@ -53,6 +53,15 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.208",
+ "date": "2026-06-07",
+ "changes": [
+ "Phase D: planning_context an POST /exercises/ai/suggest — Prompts Migration 085.",
+ "Pfad-Builder + Planungs-Picker senden strukturierten Planungskontext bei KI-Neuanlage.",
+ "Modul planning_exercise_form_context.py; Platzhalter planning_context_json in Übungs-Prompts.",
+ ],
+ },
{
"version": "0.8.207",
"date": "2026-06-07",
diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md
index 84593e1..35c6322 100644
--- a/docs/HANDOVER.md
+++ b/docs/HANDOVER.md
@@ -1,7 +1,7 @@
# Shinkan Jinkendo – Entwicklungsstand & Handover
**Stand:** 2026-06-07
-**App-Version / DB-Schema:** App **`0.8.207`** (Planungs-KI Phase F4); DB **`20260606086`** — maßgeblich **`backend/version.py`**.
+**App-Version / DB-Schema:** App **`0.8.208`** (Planungs-KI Phase D); DB **`20260606086`** — 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**.
@@ -110,7 +110,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
| **F0–F2** | Roadmap-Pipeline + LLM-Prompts (078/079) | ✅ **0.8.205** |
| **F3** | `roadmap_first` — Retrieval pro `stage_spec` | ✅ **0.8.206** |
| **F4** | Roadmap-Review UI + `roadmap_override` | ✅ **0.8.207** |
-| **D** | `planning_context` an `suggestExerciseAi` (Neu-Anlage) | 🔲 |
+| **D** | `planning_context` an `suggestExerciseAi` (Neu-Anlage) | ✅ **0.8.208** |
**Architektur-Entscheidung (2026-06-07):** Progressionsgraph = **Roadmap-first** (Ziel → Major Steps → Übungs-Match). **Keine Gruppenanalyse** im Graphen. Mitai Workflow-Engine **später** — jetzt `planning_progression_roadmap.py`. Spec: **`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`** · Roadmap: **`docs/architecture/PLANNING_KI_ROADMAP.md`**
diff --git a/docs/architecture/PLANNING_KI_ROADMAP.md b/docs/architecture/PLANNING_KI_ROADMAP.md
index 5e68101..dd19042 100644
--- a/docs/architecture/PLANNING_KI_ROADMAP.md
+++ b/docs/architecture/PLANNING_KI_ROADMAP.md
@@ -28,7 +28,7 @@ Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROA
| E–E3 | Progressionsgraph | Semantik, QA, Lücken-Angebote | ✅ |
| **F0–F1** | Progressionsgraph | Roadmap-Pipeline Scaffold + API-Preview | 🔄 **0.8.204** |
| **F2–F4** | Progressionsgraph | LLM Roadmap, roadmap-first Retrieval, UI Review | 🔲 |
-| D | Übungs-Neuanlage | `planning_context` an `suggestExerciseAi` | 🔲 |
+| D | Übungs-Neuanlage | `planning_context` an `suggestExerciseAi` | ✅ **0.8.208** |
| G | Trainingsplanung | Kontext-Pack Gruppe/Historie, S0–S4 | 🔲 |
| H | Plattform | Mitai-Workflow-Engine (optional) | 🔲 Backlog |
diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx
index 2a244d9..8239fba 100644
--- a/frontend/src/components/ExercisePickerModal.jsx
+++ b/frontend/src/components/ExercisePickerModal.jsx
@@ -26,6 +26,7 @@ import {
aiPreviewToQuickCreateDraft,
} from '../utils/exerciseAiQuickCreate'
import { resolveExercisePickVariantId } from '../utils/exercisePlanningPick'
+import { buildPickerPlanningContextForAi } from '../utils/planningContextForExerciseAi'
const PAGE_SIZE = 100
/** Backend POST /api/planning/exercise-suggest erlaubt max. 50 */
@@ -707,6 +708,12 @@ export default function ExercisePickerModal({
setQuickAiError('')
setQuickCreateDraft(null)
+ const planningContextPayload = buildPickerPlanningContextForAi({
+ planningContextSummary,
+ planningContext,
+ searchQuery: planningSubmittedQuery || searchInput || aiSearchInput,
+ })
+
setQuickSaving(true)
try {
const aiRes = await api.suggestExerciseAi({
@@ -717,6 +724,7 @@ export default function ExercisePickerModal({
trainer_notes: '',
focus_area_hint: focusHint || undefined,
focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
+ planning_context: planningContextPayload || undefined,
include_summary: true,
include_skills: true,
include_instructions: true,
diff --git a/frontend/src/components/ExerciseProgressionPathBuilder.jsx b/frontend/src/components/ExerciseProgressionPathBuilder.jsx
index 68ddc0d..4082596 100644
--- a/frontend/src/components/ExerciseProgressionPathBuilder.jsx
+++ b/frontend/src/components/ExerciseProgressionPathBuilder.jsx
@@ -10,6 +10,7 @@ import {
buildQuickCreateAiPreview,
buildQuickCreateExercisePayloadFromDraft,
} from '../utils/exerciseAiQuickCreate'
+import { buildPathGapPlanningContextForAi } from '../utils/planningContextForExerciseAi'
function emptyPathStep() {
return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [], reasons: [] }
@@ -354,6 +355,15 @@ export default function ExerciseProgressionPathBuilder({
setQuickCreateDraft(null)
setQuickSaving(true)
setGeneratingOfferId(offer?.offer_id || null)
+ const planningContext = buildPathGapPlanningContextForAi({
+ goalQuery,
+ semanticBrief,
+ offer,
+ graphId,
+ pathSteps,
+ editableMajorSteps,
+ progressionRoadmap,
+ })
try {
const aiRes = await api.suggestExerciseAi({
title,
@@ -363,6 +373,7 @@ export default function ExerciseProgressionPathBuilder({
trainer_notes: '',
focus_area_hint: focusHint || undefined,
focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
+ planning_context: planningContext || undefined,
include_summary: true,
include_skills: true,
include_instructions: true,
diff --git a/frontend/src/utils/planningContextForExerciseAi.js b/frontend/src/utils/planningContextForExerciseAi.js
new file mode 100644
index 0000000..e8b96b6
--- /dev/null
+++ b/frontend/src/utils/planningContextForExerciseAi.js
@@ -0,0 +1,75 @@
+/**
+ * Planungs-KI Phase D: strukturierter Kontext für suggestExerciseAi.
+ */
+
+export function buildPickerPlanningContextForAi({
+ planningContextSummary = null,
+ planningContext = null,
+ searchQuery = '',
+} = {}) {
+ if (!planningContextSummary && !planningContext) return null
+ const ctx = {
+ source: 'planning_picker',
+ search_query: (searchQuery || '').trim() || null,
+ unit_title: planningContextSummary?.unit_title || null,
+ group_name: planningContextSummary?.group_name || null,
+ section_title:
+ planningContextSummary?.section_title || planningContext?.sectionTitle || null,
+ section_guidance_notes: planningContext?.sectionGuidanceNotes || null,
+ section_exercise_count: planningContextSummary?.section_exercise_count ?? null,
+ last_section_exercise_title:
+ planningContextSummary?.last_section_exercise_title ||
+ planningContext?.lastExerciseTitle ||
+ null,
+ anchor_title: planningContextSummary?.anchor_title || null,
+ progression_graph_name: planningContextSummary?.progression_graph_name || null,
+ planned_count: planningContextSummary?.planned_count ?? null,
+ intent_resolved:
+ planningContextSummary?.intent_resolved || planningContext?.intentHint || null,
+ }
+ return Object.fromEntries(Object.entries(ctx).filter(([, v]) => v != null && v !== ''))
+}
+
+export function buildPathGapPlanningContextForAi({
+ goalQuery = '',
+ semanticBrief = null,
+ offer = null,
+ graphId = null,
+ pathSteps = [],
+ editableMajorSteps = [],
+ progressionRoadmap = null,
+} = {}) {
+ const afterIdx = Number(offer?.insert_after_index)
+ const stepA = Number.isFinite(afterIdx) && afterIdx >= 0 ? pathSteps[afterIdx] : null
+ const stepB =
+ Number.isFinite(afterIdx) && afterIdx >= 0 ? pathSteps[afterIdx + 1] : null
+ const majorIdxRaw =
+ offer?.roadmap_major_step_index ?? offer?.gap?.roadmap_major_step_index
+ const majorIdx =
+ majorIdxRaw != null && Number.isFinite(Number(majorIdxRaw)) ? Number(majorIdxRaw) : null
+ const majorStep =
+ majorIdx != null && editableMajorSteps[majorIdx] ? editableMajorSteps[majorIdx] : null
+
+ const ctx = {
+ source: 'progression_path_gap_fill',
+ goal_query: (goalQuery || '').trim() || null,
+ primary_topic: semanticBrief?.primary_topic || null,
+ progression_graph_id: graphId != null ? Number(graphId) : null,
+ gap_source: offer?.source || null,
+ gap_phase: offer?.phase || offer?.gap?.expected_phase || null,
+ roadmap_major_step_index: majorIdx,
+ roadmap_phase: majorStep?.phase || offer?.phase || null,
+ roadmap_learning_goal:
+ (majorStep?.learning_goal || offer?.title_hint || offer?.gap?.learning_goal || '').trim() ||
+ null,
+ neighbor_before_title: stepA?.exerciseTitle || offer?.from_title || null,
+ neighbor_after_title: stepB?.exerciseTitle || offer?.to_title || null,
+ path_step_count: Array.isArray(pathSteps) ? pathSteps.length : 0,
+ major_step_count:
+ editableMajorSteps?.length ||
+ progressionRoadmap?.major_step_count ||
+ progressionRoadmap?.roadmap?.major_steps?.length ||
+ null,
+ }
+ return Object.fromEntries(Object.entries(ctx).filter(([, v]) => v != null && v !== ''))
+}