Implement Planning Context Integration for Exercise AI Suggestions
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 43s
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

- Added `planning_context` to the `suggestExerciseAi` endpoint, enabling structured planning context for new exercise creation.
- Updated relevant components and backend logic to handle the new planning context, enhancing the AI's exercise suggestion capabilities.
- Incremented application version to 0.8.208 to reflect these changes.
This commit is contained in:
Lars 2026-06-08 15:15:03 +02:00
parent f074a8bef0
commit 779e2477ba
14 changed files with 503 additions and 9 deletions

View File

@ -172,7 +172,7 @@ score = w_ft * fulltext_rank
Wenn `hits` leer oder Trainer wählt „Mit KI anlegen“: 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 - 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** | | **C3** | Graph-Builder (Ziel → Pfad → speichern) | ✅ **0.8.185** |
| **E** | Semantik-Schicht + Pfad-QA (Lücken/Brücken/LLM-QS) | ✅ **0.8.186** | | **E** | Semantik-Schicht + Pfad-QA (Lücken/Brücken/LLM-QS) | ✅ **0.8.186** |
| **E2** | Pfad-Neuordnung + KI-Lückenfüller | ✅ **0.8.187** | | **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** |
--- ---

View File

@ -5,7 +5,7 @@ Keine Imports aus exercise_ai — vermeidet Zirkelimporte mit ai_prompt_job / ex
""" """
from __future__ import annotations 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 from pydantic import BaseModel, Field
@ -31,6 +31,7 @@ class ExerciseFormAiPromptContext(BaseModel):
trainer_notes: Optional[str] = None trainer_notes: Optional[str] = None
focus_hint: Optional[str] = None focus_hint: Optional[str] = None
focus_areas_context: Optional[List[ExerciseFormAiFocusRow]] = 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]]]: def focus_area_tuples(self) -> Optional[List[Tuple[int, bool]]]:
if not self.focus_areas_context: if not self.focus_areas_context:
@ -57,6 +58,7 @@ class ExerciseFormAiPromptContext(BaseModel):
trainer_notes: Optional[str] = None, trainer_notes: Optional[str] = None,
focus_area_hint: Optional[str] = None, focus_area_hint: Optional[str] = None,
focus_areas_context: Optional[Sequence[ExerciseFormAiFocusRow]] = None, focus_areas_context: Optional[Sequence[ExerciseFormAiFocusRow]] = None,
planning_context: Optional[Dict[str, Any]] = None,
) -> ExerciseFormAiPromptContext: ) -> ExerciseFormAiPromptContext:
"""Mappt Felder aus POST /exercises/ai/suggest (focus_area_hint → focus_hint).""" """Mappt Felder aus POST /exercises/ai/suggest (focus_area_hint → focus_hint)."""
hint = (focus_area_hint or "").strip() or None hint = (focus_area_hint or "").strip() or None
@ -68,6 +70,7 @@ class ExerciseFormAiPromptContext(BaseModel):
trainer_notes=trainer_notes, trainer_notes=trainer_notes,
focus_hint=hint, focus_hint=hint,
focus_areas_context=list(focus_areas_context) if focus_areas_context else None, focus_areas_context=list(focus_areas_context) if focus_areas_context else None,
planning_context=dict(planning_context) if planning_context else None,
) )
@classmethod @classmethod

View File

@ -23,6 +23,7 @@ def resolve_exercise_form_variables(cur, slug: str, ctx: ExerciseFormAiPromptCon
focus_areas_context=ctx.focus_area_tuples(), focus_areas_context=ctx.focus_area_tuples(),
preparation=ctx.preparation, preparation=ctx.preparation,
trainer_notes=ctx.trainer_notes, trainer_notes=ctx.trainer_notes,
planning_context=ctx.planning_context,
) )

View File

@ -650,10 +650,13 @@ def build_exercise_placeholder_variables(
focus_areas_context: Optional[Sequence[Tuple[int, bool]]], focus_areas_context: Optional[Sequence[Tuple[int, bool]]],
preparation: Optional[str] = None, preparation: Optional[str] = None,
trainer_notes: Optional[str] = None, trainer_notes: Optional[str] = None,
planning_context: Optional[Mapping[str, Any]] = None,
) -> Dict[str, str]: ) -> Dict[str, str]:
""" """
Baut die Variable-Map fuer {{platzhalter}} passend zur Slug fuer Uebungs-KI. 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() s = (slug or "").strip().lower()
if s == "pipeline": if s == "pipeline":
return {} return {}
@ -671,8 +674,19 @@ def build_exercise_placeholder_variables(
"exercise_preparation": p_plain or "-", "exercise_preparation": p_plain or "-",
"exercise_trainer_notes": n_plain or "-", "exercise_trainer_notes": n_plain or "-",
} }
ctx.update(planning_context_prompt_variables(planning_context))
if s == "exercise_summary": 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": if s == "exercise_instruction_rewrite":
return ctx return ctx
if s == "exercise_skill_suggestions": if s == "exercise_skill_suggestions":
@ -893,6 +907,7 @@ def run_exercise_ai_suggestion(
execution=execution, execution=execution,
focus_area_hint=focus_area_hint, focus_area_hint=focus_area_hint,
focus_areas_context=focus_areas_context, focus_areas_context=focus_areas_context,
planning_context=form_ctx.planning_context,
) )
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=500, detail=str(e)) from e raise HTTPException(status_code=500, detail=str(e)) from e
@ -938,6 +953,7 @@ def run_exercise_ai_suggestion(
execution=execution, execution=execution,
focus_area_hint=focus_area_hint, focus_area_hint=focus_area_hint,
focus_areas_context=focus_areas_context, focus_areas_context=focus_areas_context,
planning_context=form_ctx.planning_context,
) )
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=500, detail=str(e)) from e raise HTTPException(status_code=500, detail=str(e)) from e
@ -1015,6 +1031,7 @@ def run_exercise_ai_suggestion(
trainer_notes=trainer_notes, trainer_notes=trainer_notes,
focus_area_hint=focus_area_hint, focus_area_hint=focus_area_hint,
focus_areas_context=focus_areas_context, focus_areas_context=focus_areas_context,
planning_context=form_ctx.planning_context,
) )
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=500, detail=str(e)) from e raise HTTPException(status_code=500, detail=str(e)) from e

View File

@ -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: 13 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: <p>, <ul>, <ol>, <li>, <strong>, <em>, <br>
- Keine Ueberschriften (h1h6), 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": "<p>…</p>",
"execution": "<ol><li>…</li></ol>",
"preparation": "<p>…</p> oder \"\"",
"trainer_notes": "<ul><li>…</li></ul> 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: 13 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: <p>, <ul>, <ol>, <li>, <strong>, <em>, <br>
- Keine Ueberschriften (h1h6), 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": "<p>…</p>",
"execution": "<ol><li>…</li></ol>",
"preparation": "<p>…</p> oder \"\"",
"trainer_notes": "<ul><li>…</li></ul> oder \"\""
}
Leere Felder als leerer String "" wenn nichts Sinnvolles ergibt.$t$
WHERE slug = 'exercise_instruction_rewrite';

View File

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

View File

@ -385,6 +385,10 @@ class ExerciseAiSuggestBody(BaseModel):
include_summary: bool = True include_summary: bool = True
include_skills: bool = True include_skills: bool = True
include_instructions: bool = False 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") @model_validator(mode="after")
def check_include_any(self): def check_include_any(self):
@ -403,6 +407,7 @@ class ExerciseAiSuggestBody(BaseModel):
trainer_notes=self.trainer_notes, trainer_notes=self.trainer_notes,
focus_area_hint=self.focus_area_hint, focus_area_hint=self.focus_area_hint,
focus_areas_context=self.focus_areas_context, focus_areas_context=self.focus_areas_context,
planning_context=self.planning_context,
) )

View File

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

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.207" APP_VERSION = "0.8.208"
BUILD_DATE = "2026-06-07" BUILD_DATE = "2026-06-07"
DB_SCHEMA_VERSION = "20260606086" DB_SCHEMA_VERSION = "20260606086"
@ -38,7 +38,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.1", # KI-Endpoints: feature_usage nach ai_calls consume "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_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
@ -53,6 +53,15 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ 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", "version": "0.8.207",
"date": "2026-06-07", "date": "2026-06-07",

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover # Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-06-07 **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**. 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
| **F0F2** | Roadmap-Pipeline + LLM-Prompts (078/079) | ✅ **0.8.205** | | **F0F2** | Roadmap-Pipeline + LLM-Prompts (078/079) | ✅ **0.8.205** |
| **F3** | `roadmap_first` — Retrieval pro `stage_spec` | ✅ **0.8.206** | | **F3** | `roadmap_first` — Retrieval pro `stage_spec` | ✅ **0.8.206** |
| **F4** | Roadmap-Review UI + `roadmap_override` | ✅ **0.8.207** | | **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`** **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`**

View File

@ -28,7 +28,7 @@ Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROA
| EE3 | Progressionsgraph | Semantik, QA, Lücken-Angebote | ✅ | | EE3 | Progressionsgraph | Semantik, QA, Lücken-Angebote | ✅ |
| **F0F1** | Progressionsgraph | Roadmap-Pipeline Scaffold + API-Preview | 🔄 **0.8.204** | | **F0F1** | Progressionsgraph | Roadmap-Pipeline Scaffold + API-Preview | 🔄 **0.8.204** |
| **F2F4** | Progressionsgraph | LLM Roadmap, roadmap-first Retrieval, UI Review | 🔲 | | **F2F4** | 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, S0S4 | 🔲 | | G | Trainingsplanung | Kontext-Pack Gruppe/Historie, S0S4 | 🔲 |
| H | Plattform | Mitai-Workflow-Engine (optional) | 🔲 Backlog | | H | Plattform | Mitai-Workflow-Engine (optional) | 🔲 Backlog |

View File

@ -26,6 +26,7 @@ import {
aiPreviewToQuickCreateDraft, aiPreviewToQuickCreateDraft,
} from '../utils/exerciseAiQuickCreate' } from '../utils/exerciseAiQuickCreate'
import { resolveExercisePickVariantId } from '../utils/exercisePlanningPick' import { resolveExercisePickVariantId } from '../utils/exercisePlanningPick'
import { buildPickerPlanningContextForAi } from '../utils/planningContextForExerciseAi'
const PAGE_SIZE = 100 const PAGE_SIZE = 100
/** Backend POST /api/planning/exercise-suggest erlaubt max. 50 */ /** Backend POST /api/planning/exercise-suggest erlaubt max. 50 */
@ -707,6 +708,12 @@ export default function ExercisePickerModal({
setQuickAiError('') setQuickAiError('')
setQuickCreateDraft(null) setQuickCreateDraft(null)
const planningContextPayload = buildPickerPlanningContextForAi({
planningContextSummary,
planningContext,
searchQuery: planningSubmittedQuery || searchInput || aiSearchInput,
})
setQuickSaving(true) setQuickSaving(true)
try { try {
const aiRes = await api.suggestExerciseAi({ const aiRes = await api.suggestExerciseAi({
@ -717,6 +724,7 @@ export default function ExercisePickerModal({
trainer_notes: '', trainer_notes: '',
focus_area_hint: focusHint || undefined, focus_area_hint: focusHint || undefined,
focus_areas_context: [{ focus_area_id: focusId, is_primary: true }], focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
planning_context: planningContextPayload || undefined,
include_summary: true, include_summary: true,
include_skills: true, include_skills: true,
include_instructions: true, include_instructions: true,

View File

@ -10,6 +10,7 @@ import {
buildQuickCreateAiPreview, buildQuickCreateAiPreview,
buildQuickCreateExercisePayloadFromDraft, buildQuickCreateExercisePayloadFromDraft,
} from '../utils/exerciseAiQuickCreate' } from '../utils/exerciseAiQuickCreate'
import { buildPathGapPlanningContextForAi } from '../utils/planningContextForExerciseAi'
function emptyPathStep() { function emptyPathStep() {
return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [], reasons: [] } return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [], reasons: [] }
@ -354,6 +355,15 @@ export default function ExerciseProgressionPathBuilder({
setQuickCreateDraft(null) setQuickCreateDraft(null)
setQuickSaving(true) setQuickSaving(true)
setGeneratingOfferId(offer?.offer_id || null) setGeneratingOfferId(offer?.offer_id || null)
const planningContext = buildPathGapPlanningContextForAi({
goalQuery,
semanticBrief,
offer,
graphId,
pathSteps,
editableMajorSteps,
progressionRoadmap,
})
try { try {
const aiRes = await api.suggestExerciseAi({ const aiRes = await api.suggestExerciseAi({
title, title,
@ -363,6 +373,7 @@ export default function ExerciseProgressionPathBuilder({
trainer_notes: '', trainer_notes: '',
focus_area_hint: focusHint || undefined, focus_area_hint: focusHint || undefined,
focus_areas_context: [{ focus_area_id: focusId, is_primary: true }], focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
planning_context: planningContext || undefined,
include_summary: true, include_summary: true,
include_skills: true, include_skills: true,
include_instructions: true, include_instructions: true,

View File

@ -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 !== ''))
}