+ Prüfen und anpassen, was an die KI geht. Ergänzungen fließen in Ziel, Trainerhinweise und
+ Planungskontext ein — erst danach wird der Entwurf erzeugt.
+
{step.exerciseTitle}
{step.exerciseId ? (
@@ -1454,6 +1645,7 @@ export default function ExerciseProgressionPathBuilder({
setPathQa(null)
setGapFillOffers([])
setProgressionRoadmap(null)
+ setPathSkillExpectations(null)
}}
>
Vorschlag verwerfen
From 5692931d07ee8540dcd8ea7f4a4d2c52c2ad0c18 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 10 Jun 2026 07:25:57 +0200
Subject: [PATCH 04/33] Update version to 0.8.217 and enhance Exercise
Progression Path Builder with planning roadmap features
- Incremented application version to 0.8.217 to reflect recent changes.
- Added support for a planning roadmap in the Exercise Progression Path Builder, allowing users to save and load structured planning artifacts.
- Enhanced the persistence logic for the planning roadmap, ensuring updates are correctly handled during graph modifications.
- Improved the user interface to display saved planning hints, enriching the user experience and interaction with the progression graphs.
---
...ise_progression_graph_planning_roadmap.sql | 8 ++
.../progression_graph_planning_artifact.py | 49 +++++++
.../routers/exercise_progression_graphs.py | 46 ++++--
...est_progression_graph_planning_artifact.py | 36 +++++
backend/version.py | 4 +-
.../ExerciseProgressionPathBuilder.jsx | 136 +++++++++++++++++-
6 files changed, 268 insertions(+), 11 deletions(-)
create mode 100644 backend/migrations/088_exercise_progression_graph_planning_roadmap.sql
create mode 100644 backend/progression_graph_planning_artifact.py
create mode 100644 backend/tests/test_progression_graph_planning_artifact.py
diff --git a/backend/migrations/088_exercise_progression_graph_planning_roadmap.sql b/backend/migrations/088_exercise_progression_graph_planning_roadmap.sql
new file mode 100644
index 0000000..12eb27a
--- /dev/null
+++ b/backend/migrations/088_exercise_progression_graph_planning_roadmap.sql
@@ -0,0 +1,8 @@
+-- Migration 088: Planungs-Roadmap-Artefakt am Progressionsgraph (JSONB, optional).
+-- Speichert Ziel, Start/Ziel, progression_roadmap + stage_specs für Wiederaufnahme der KI-Planung.
+
+ALTER TABLE exercise_progression_graphs
+ ADD COLUMN IF NOT EXISTS planning_roadmap JSONB;
+
+COMMENT ON COLUMN exercise_progression_graphs.planning_roadmap IS
+ 'Optionales Planungs-Artefakt (goal_query, resolved_structured, progression_roadmap, stage_specs) — Schema v1 im App-Code.';
diff --git a/backend/progression_graph_planning_artifact.py b/backend/progression_graph_planning_artifact.py
new file mode 100644
index 0000000..64020d4
--- /dev/null
+++ b/backend/progression_graph_planning_artifact.py
@@ -0,0 +1,49 @@
+"""Validierung und Normalisierung des Planungs-Artefakts am Progressionsgraph."""
+from __future__ import annotations
+
+import json
+from typing import Any, Dict, Optional
+
+from pydantic import BaseModel, Field, field_validator
+
+ARTIFACT_SCHEMA_VERSION = 1
+_MAX_JSON_BYTES = 64_000
+
+
+class GraphPlanningRoadmapArtifact(BaseModel):
+ schema_version: int = Field(default=ARTIFACT_SCHEMA_VERSION, ge=1, le=1)
+ goal_query: str = Field(default="", max_length=2000)
+ start_situation: Optional[str] = Field(default=None, max_length=2000)
+ target_state: Optional[str] = Field(default=None, max_length=2000)
+ roadmap_notes: Optional[str] = Field(default=None, max_length=2000)
+ max_steps: int = Field(default=5, ge=2, le=10)
+ progression_roadmap: Optional[Dict[str, Any]] = None
+ path_skill_expectations: Optional[Dict[str, Any]] = None
+
+ @field_validator("progression_roadmap", "path_skill_expectations", mode="before")
+ @classmethod
+ def _empty_dict_to_none(cls, v):
+ if v == {}:
+ return None
+ return v
+
+
+def normalize_planning_roadmap_payload(raw: Any) -> Optional[Dict[str, Any]]:
+ """None erlaubt (löschen); sonst validiertes Dict."""
+ if raw is None:
+ return None
+ if not isinstance(raw, dict):
+ raise ValueError("planning_roadmap muss ein JSON-Objekt sein")
+ artifact = GraphPlanningRoadmapArtifact.model_validate(raw)
+ out = artifact.model_dump(exclude_none=True)
+ blob = json.dumps(out, ensure_ascii=False)
+ if len(blob.encode("utf-8")) > _MAX_JSON_BYTES:
+ raise ValueError("planning_roadmap ist zu groß (max. 64 KB)")
+ return out
+
+
+__all__ = [
+ "ARTIFACT_SCHEMA_VERSION",
+ "GraphPlanningRoadmapArtifact",
+ "normalize_planning_roadmap_payload",
+]
diff --git a/backend/routers/exercise_progression_graphs.py b/backend/routers/exercise_progression_graphs.py
index 212359f..16c1380 100644
--- a/backend/routers/exercise_progression_graphs.py
+++ b/backend/routers/exercise_progression_graphs.py
@@ -3,13 +3,15 @@ Progressionsgraph zwischen Übungen (Übung → Übung), Migration 032–034.
Optional Übungsvarianten als Knoten-Endpunkte; Sequenz-Bulk-Anlage.
AuthZ analog training_plan_templates: Bearbeiten/Löschen wie Übungen; Governance-Übergänge zentral.
"""
-from typing import Any, List, Optional
+from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field, model_validator
from psycopg2 import IntegrityError
+from psycopg2.extras import Json
from db import get_db, get_cursor, r2d
+from progression_graph_planning_artifact import normalize_planning_roadmap_payload
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
from club_tenancy import (
assert_library_content_deletable,
@@ -36,6 +38,7 @@ class ProgressionGraphUpdate(BaseModel):
description: Optional[str] = None
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
club_id: Optional[int] = None
+ planning_roadmap: Optional[Dict[str, Any]] = None
class ProgressionEdgeCreate(BaseModel):
@@ -59,6 +62,7 @@ class SequenceStep(BaseModel):
class ProgressionSequenceCreate(BaseModel):
steps: List[SequenceStep] = Field(..., min_length=2)
segment_notes: Optional[List[Optional[str]]] = None
+ planning_roadmap: Optional[Dict[str, Any]] = None
"""Länge muss len(steps)-1 sein, wenn gesetzt; Notiz pro Kante Zwischen je zwei Schritten."""
@model_validator(mode="after")
@@ -116,6 +120,17 @@ def _assert_graph_writable(cur, row: dict, profile_id: int, role: str) -> None:
assert_library_content_editable(cur, profile_id, role, row)
+def _persist_graph_planning_roadmap(cur, graph_id: int, raw: Optional[Dict[str, Any]]) -> None:
+ try:
+ normalized = normalize_planning_roadmap_payload(raw)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ cur.execute(
+ "UPDATE exercise_progression_graphs SET planning_roadmap = %s WHERE id = %s",
+ (Json(normalized) if normalized is not None else None, graph_id),
+ )
+
+
def _require_graph_read(cur, graph_id: int, profile_id: int, role: str) -> dict:
row = _graph_row(cur, graph_id)
_assert_graph_readable(cur, row, profile_id, role)
@@ -353,15 +368,24 @@ def update_progression_graph(
fields.append("club_id = %s")
params.append(next_club if next_vis == "club" else None)
- if not fields:
+ if "planning_roadmap" in original:
+ _persist_graph_planning_roadmap(cur, graph_id, original.get("planning_roadmap"))
+
+ if not fields and "planning_roadmap" not in original:
return get_progression_graph(graph_id, include_edges=False, tenant=tenant)
- fields.append("updated_at = NOW()")
- params.append(graph_id)
- cur.execute(
- f"UPDATE exercise_progression_graphs SET {', '.join(fields)} WHERE id = %s",
- tuple(params),
- )
+ if fields:
+ fields.append("updated_at = NOW()")
+ params.append(graph_id)
+ cur.execute(
+ f"UPDATE exercise_progression_graphs SET {', '.join(fields)} WHERE id = %s",
+ tuple(params),
+ )
+ elif "planning_roadmap" in original:
+ cur.execute(
+ "UPDATE exercise_progression_graphs SET updated_at = NOW() WHERE id = %s",
+ (graph_id,),
+ )
conn.commit()
return get_progression_graph(graph_id, include_edges=False, tenant=tenant)
@@ -488,6 +512,12 @@ def create_progression_sequence(
note,
)
created.append(row)
+ if body.planning_roadmap is not None:
+ _persist_graph_planning_roadmap(cur, graph_id, body.planning_roadmap)
+ cur.execute(
+ "UPDATE exercise_progression_graphs SET updated_at = NOW() WHERE id = %s",
+ (graph_id,),
+ )
conn.commit()
except IntegrityError as e:
conn.rollback()
diff --git a/backend/tests/test_progression_graph_planning_artifact.py b/backend/tests/test_progression_graph_planning_artifact.py
new file mode 100644
index 0000000..523e6ea
--- /dev/null
+++ b/backend/tests/test_progression_graph_planning_artifact.py
@@ -0,0 +1,36 @@
+"""Tests Planungs-Artefakt am Progressionsgraph."""
+import pytest
+
+from progression_graph_planning_artifact import (
+ ARTIFACT_SCHEMA_VERSION,
+ normalize_planning_roadmap_payload,
+)
+
+
+def test_normalize_planning_roadmap_minimal():
+ out = normalize_planning_roadmap_payload(
+ {
+ "schema_version": ARTIFACT_SCHEMA_VERSION,
+ "goal_query": "Mae Geri Perfektion",
+ "max_steps": 5,
+ }
+ )
+ assert out["goal_query"] == "Mae Geri Perfektion"
+ assert out["max_steps"] == 5
+
+
+def test_normalize_planning_roadmap_with_progression_roadmap():
+ out = normalize_planning_roadmap_payload(
+ {
+ "goal_query": "Kumite Beinarbeit",
+ "progression_roadmap": {
+ "stage_specs": [{"major_step_index": 0, "learning_goal": "Grundstellung"}],
+ },
+ }
+ )
+ assert out["progression_roadmap"]["stage_specs"][0]["learning_goal"] == "Grundstellung"
+
+
+def test_normalize_rejects_invalid_type():
+ with pytest.raises(ValueError, match="JSON-Objekt"):
+ normalize_planning_roadmap_payload("not-json")
diff --git a/backend/version.py b/backend/version.py
index f33ed91..742ae6b 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.216"
+APP_VERSION = "0.8.217"
BUILD_DATE = "2026-06-07"
-DB_SCHEMA_VERSION = "20260607087"
+DB_SCHEMA_VERSION = "20260607088"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
diff --git a/frontend/src/components/ExerciseProgressionPathBuilder.jsx b/frontend/src/components/ExerciseProgressionPathBuilder.jsx
index 52734f8..5784f75 100644
--- a/frontend/src/components/ExerciseProgressionPathBuilder.jsx
+++ b/frontend/src/components/ExerciseProgressionPathBuilder.jsx
@@ -233,6 +233,31 @@ function formatExpectedSkillNames(skillExpectations, limit = 4) {
.slice(0, limit)
}
+const PLANNING_ARTIFACT_SCHEMA = 1
+
+function buildPlanningRoadmapArtifactSnapshot({
+ goalQuery,
+ startSituation,
+ targetState,
+ roadmapNotes,
+ maxSteps,
+ progressionRoadmap,
+ pathSkillExpectations,
+}) {
+ const q = (goalQuery || '').trim()
+ if (!q && !progressionRoadmap) return null
+ return {
+ schema_version: PLANNING_ARTIFACT_SCHEMA,
+ goal_query: q,
+ start_situation: (startSituation || '').trim() || null,
+ target_state: (targetState || '').trim() || null,
+ roadmap_notes: (roadmapNotes || '').trim() || null,
+ max_steps: Number(maxSteps) || 5,
+ progression_roadmap: progressionRoadmap || null,
+ path_skill_expectations: pathSkillExpectations || null,
+ }
+}
+
/** Einfügen wächst den Pfad; Ersetzen (replace_step_index) nicht. */
function offerGrowsPath(offer) {
const replaceIdx = offer?.replace_step_index
@@ -329,6 +354,93 @@ export default function ExerciseProgressionPathBuilder({
const [gapPrepSupplements, setGapPrepSupplements] = useState('')
const [gapPrepFocusAreaId, setGapPrepFocusAreaId] = useState('')
const [gapPrepError, setGapPrepError] = useState('')
+ const [loadedPlanningHint, setLoadedPlanningHint] = useState(false)
+
+ const buildPlanningArtifact = useCallback(
+ () =>
+ buildPlanningRoadmapArtifactSnapshot({
+ goalQuery,
+ startSituation,
+ targetState,
+ roadmapNotes,
+ maxSteps,
+ progressionRoadmap,
+ pathSkillExpectations,
+ }),
+ [
+ goalQuery,
+ startSituation,
+ targetState,
+ roadmapNotes,
+ maxSteps,
+ progressionRoadmap,
+ pathSkillExpectations,
+ ],
+ )
+
+ const persistPlanningRoadmapToGraph = useCallback(async () => {
+ if (!graphId) return
+ const artifact = buildPlanningArtifact()
+ if (!artifact?.goal_query && !artifact?.progression_roadmap) return
+ try {
+ await api.updateExerciseProgressionGraph(Number(graphId), { planning_roadmap: artifact })
+ } catch (e) {
+ console.warn('Planungs-Artefakt konnte nicht gespeichert werden', e)
+ }
+ }, [graphId, buildPlanningArtifact])
+
+ useEffect(() => {
+ if (!graphId) return
+ let cancelled = false
+ setLoadedPlanningHint(false)
+ setPathSteps([])
+ setTargetSummary(null)
+ setSemanticBrief(null)
+ setPathQa(null)
+ setGapFillOffers([])
+ setPathSkillExpectations(null)
+ setEditableMajorSteps([])
+ setProgressionRoadmap(null)
+ setRoadmapDirty(false)
+ setStartTargetAnalyzed(false)
+ setError('')
+
+ api
+ .getExerciseProgressionGraph(Number(graphId))
+ .then((g) => {
+ if (cancelled) return
+ const art = g?.planning_roadmap
+ if (!art) return
+ if (art.goal_query) setGoalQuery(String(art.goal_query))
+ if (art.start_situation) setStartSituation(String(art.start_situation))
+ if (art.target_state) setTargetState(String(art.target_state))
+ if (art.roadmap_notes) setRoadmapNotes(String(art.roadmap_notes))
+ if (art.max_steps) setMaxSteps(Number(art.max_steps))
+ if (art.path_skill_expectations) setPathSkillExpectations(art.path_skill_expectations)
+ if (art.progression_roadmap) {
+ setProgressionRoadmap(art.progression_roadmap)
+ const majors = mapMajorStepsFromApi(art.progression_roadmap)
+ if (majors.length >= 2) {
+ setEditableMajorSteps(majors)
+ }
+ }
+ if (
+ art.start_situation ||
+ art.target_state ||
+ art.progression_roadmap?.resolved_structured
+ ) {
+ setStartTargetAnalyzed(true)
+ }
+ setLoadedPlanningHint(true)
+ })
+ .catch((e) => {
+ console.warn(e)
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [graphId])
useEffect(() => {
let cancelled = false
@@ -783,7 +895,7 @@ export default function ExerciseProgressionPathBuilder({
const res = await api.suggestProgressionPath({
query: q,
max_steps: Number(maxSteps),
- include_llm_intent: false,
+ include_llm_intent: true,
include_path_qa: false,
include_llm_path_qa: false,
include_path_reorder: false,
@@ -818,6 +930,8 @@ export default function ExerciseProgressionPathBuilder({
setGapFillOffers([])
setPathSkillExpectations(null)
setRoadmapDirty(false)
+ setLoadedPlanningHint(false)
+ await persistPlanningRoadmapToGraph()
} catch (e) {
console.error(e)
setError(e.message || 'Roadmap-Vorschlag fehlgeschlagen')
@@ -865,6 +979,8 @@ export default function ExerciseProgressionPathBuilder({
})
applyPathMatchResponse(res, q)
setMaxSteps(validSteps.length)
+ setLoadedPlanningHint(false)
+ await persistPlanningRoadmapToGraph()
} catch (e) {
console.error(e)
setError(e.message || 'Übungs-Match fehlgeschlagen')
@@ -899,12 +1015,14 @@ export default function ExerciseProgressionPathBuilder({
setSaving(true)
setError('')
try {
+ const planningArtifact = buildPlanningArtifact()
await api.createExerciseProgressionSequence(Number(graphId), {
steps: steps.map((s) => ({
exercise_id: s.exerciseId,
variant_id: s.variantId || null,
})),
segment_notes,
+ ...(planningArtifact ? { planning_roadmap: planningArtifact } : {}),
})
setPathSteps([])
setTargetSummary(null)
@@ -942,6 +1060,22 @@ export default function ExerciseProgressionPathBuilder({
Zuerst didaktische Roadmap vorschlagen und anpassen, dann Übungen je Major Step aus der Bibliothek matchen.
Lücken können mit KI als Übung angelegt werden.
+ {loadedPlanningHint && editableMajorSteps.length > 0 && pathSteps.length === 0 ? (
+
+ Gespeicherte Planung für diesen Graph geladen — Roadmap anpassen und erneut matchen, oder neuen Vorschlag
+ starten.
+
+ ) : null}
From ca3a9c6fa4f000f43ae63117020f1c02946b8ca1 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 10 Jun 2026 07:30:01 +0200
Subject: [PATCH 05/33] Enhance Exercise Progression Path Builder with Planning
Wizard Stepper
- Introduced a new Planning Wizard Stepper component to guide users through the exercise planning process in four steps.
- Implemented logic to compute the maximum reachable step based on user input and current progress.
- Updated state management to track the current wizard step and ensure it aligns with user interactions.
- Enhanced the user interface to improve clarity and navigation through the planning stages.
- Incremented application version to reflect these changes.
---
.../ExerciseProgressionPathBuilder.jsx | 704 ++++++++++++------
1 file changed, 487 insertions(+), 217 deletions(-)
diff --git a/frontend/src/components/ExerciseProgressionPathBuilder.jsx b/frontend/src/components/ExerciseProgressionPathBuilder.jsx
index 5784f75..b6a2deb 100644
--- a/frontend/src/components/ExerciseProgressionPathBuilder.jsx
+++ b/frontend/src/components/ExerciseProgressionPathBuilder.jsx
@@ -1,7 +1,7 @@
/**
* Planungs-KI Phase C3/E3: Ziel → Übungspfad vorschlagen → Lücken mit KI anlegen → in Graph speichern.
*/
-import React, { useCallback, useEffect, useState } from 'react'
+import React, { useCallback, useEffect, useMemo, useState } from 'react'
import api from '../utils/api'
import ExerciseAiQuickCreateModal from './exercises/ExerciseAiQuickCreateModal'
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
@@ -136,6 +136,93 @@ const OFFER_SOURCE_LABELS = {
const PATH_STEPS_HARD_MAX = 10
+const WIZARD_STEPS = [
+ { id: 1, label: 'Ziel & Start/Ziel', short: 'Ziel' },
+ { id: 2, label: 'Roadmap', short: 'Roadmap' },
+ { id: 3, label: 'Match', short: 'Match' },
+ { id: 4, label: 'Lücken & Speichern', short: 'Speichern' },
+]
+
+function computeMaxReachableStep(editableMajorSteps, pathSteps) {
+ if (pathSteps.length > 0) return 4
+ if (editableMajorSteps.length >= 2) return 2
+ return 1
+}
+
+function PlanningWizardStepper({ currentStep, maxReachable, onStepChange, disabled }) {
+ return (
+
+ )
+}
+
const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion']
const LOAD_PROFILE_OPTIONS = [
@@ -355,6 +442,12 @@ export default function ExerciseProgressionPathBuilder({
const [gapPrepFocusAreaId, setGapPrepFocusAreaId] = useState('')
const [gapPrepError, setGapPrepError] = useState('')
const [loadedPlanningHint, setLoadedPlanningHint] = useState(false)
+ const [wizardStep, setWizardStep] = useState(1)
+
+ const maxReachableStep = useMemo(
+ () => computeMaxReachableStep(editableMajorSteps, pathSteps),
+ [editableMajorSteps, pathSteps],
+ )
const buildPlanningArtifact = useCallback(
() =>
@@ -404,6 +497,7 @@ export default function ExerciseProgressionPathBuilder({
setRoadmapDirty(false)
setStartTargetAnalyzed(false)
setError('')
+ setWizardStep(1)
api
.getExerciseProgressionGraph(Number(graphId))
@@ -422,6 +516,7 @@ export default function ExerciseProgressionPathBuilder({
const majors = mapMajorStepsFromApi(art.progression_roadmap)
if (majors.length >= 2) {
setEditableMajorSteps(majors)
+ setWizardStep(2)
}
}
if (
@@ -442,6 +537,12 @@ export default function ExerciseProgressionPathBuilder({
}
}, [graphId])
+ useEffect(() => {
+ if (wizardStep > maxReachableStep) {
+ setWizardStep(maxReachableStep)
+ }
+ }, [wizardStep, maxReachableStep])
+
useEffect(() => {
let cancelled = false
Promise.all([
@@ -931,6 +1032,7 @@ export default function ExerciseProgressionPathBuilder({
setPathSkillExpectations(null)
setRoadmapDirty(false)
setLoadedPlanningHint(false)
+ setWizardStep(2)
await persistPlanningRoadmapToGraph()
} catch (e) {
console.error(e)
@@ -980,6 +1082,7 @@ export default function ExerciseProgressionPathBuilder({
applyPathMatchResponse(res, q)
setMaxSteps(validSteps.length)
setLoadedPlanningHint(false)
+ setWizardStep(3)
await persistPlanningRoadmapToGraph()
} catch (e) {
console.error(e)
@@ -1033,6 +1136,7 @@ export default function ExerciseProgressionPathBuilder({
setPathSkillExpectations(null)
setEditableMajorSteps([])
setRoadmapDirty(false)
+ setWizardStep(1)
if (typeof onSaved === 'function') await onSaved()
const msg =
skippedAi > 0
@@ -1057,26 +1161,43 @@ export default function ExerciseProgressionPathBuilder({
>
KI: Pfad zum Ziel
- Zuerst didaktische Roadmap vorschlagen und anpassen, dann Übungen je Major Step aus der Bibliothek matchen.
- Lücken können mit KI als Übung angelegt werden.
+ In vier Schritten: Ziel festlegen → Roadmap bearbeiten → Übungen matchen → Lücken schließen und speichern.
+ Gespeicherte Planung geladen — Sie können bei Schritt 2 weitermachen oder hier neu starten.
+
+ ) : null}
+
-
- Optional zuerst „Start/Ziel analysieren“, anpassen, dann Roadmap-Stufen. Sind Start und Ziel leer,
- geschieht die Analyse beim Roadmap-Vorschlag automatisch mit. Manuelle Eingaben haben immer Vorrang.
-
+ Optional zuerst „Start/Ziel analysieren“, anpassen, dann Roadmap-Stufen. Sind Start und Ziel leer,
+ geschieht die Analyse beim Roadmap-Vorschlag automatisch mit. Manuelle Eingaben haben immer Vorrang.
+
- {pathQa.off_topic_count} Schritt(e) ohne Bezug zum Pfad-Thema — siehe Lücken-Angebote unten.
+ {pathQa.off_topic_count} Schritt(e) ohne Bezug zum Pfad-Thema — Lücken in Schritt 4 schließen.
- Fehlende oder zu ersetzende Schritte ({pathSteps.length}/{maxSteps} im Pfad).
- {pathSteps.length >= maxSteps
- ? ' Der Pfad ist voll — beim Einfügen können Sie die Pfadlänge dynamisch vergrößern (ohne neuen Vorschlag); Ersatz-Angebote ersetzen einen Schritt.'
- : ' Zuerst Kontext prüfen und ergänzen, dann KI-Entwurf erstellen und einfügen.'}
-
+ Noch kein Pfad — zuerst Schritt 3 abschließen.
+
+
+ ) : (
+ <>
+ {gapFillOffers.length > 0 ? (
+
+ Fehlende Schritte — mit KI anlegen
+
+ Fehlende oder zu ersetzende Schritte ({pathSteps.length}/{maxSteps} im Pfad).
+ {pathSteps.length >= maxSteps
+ ? ' Der Pfad ist voll — beim Einfügen können Sie die Pfadlänge dynamisch vergrößern (ohne neuen Vorschlag); Ersatz-Angebote ersetzen einen Schritt.'
+ : ' Zuerst Kontext prüfen und ergänzen, dann KI-Entwurf erstellen und einfügen.'}
+
- Pro Graph mehrere Reihen und Alternativen: eine{' '}
- Sequenz legt automatisch alle Schritte Übung1 → Übung2 → … als Nachfolger-Kanten an.
- Optional pro Schritt eine Variante — sie wirkt wie ein eigener Knoten. Verzweigungen und
- Schwestern trennst du weiterhin mit Einzelkanten oder mehreren Sequenzen aus dem gleichen Knoten.
+ Ein Graph enthält eine oder mehrere Reihen (lineare Pfade Übung → Übung) sowie optional{' '}
+ Schwester-Alternativen. Reihen bearbeiten Sie direkt in der Liste; mit dem KI-Planer legen
+ Sie neue Pfade in vier Schritten an.
- Reihenfolge von links nach rechts: jede Zeile ein Schritt. Es werden automatisch alle Nachfolger-Kanten
- zwischen benachbarten Schritten erzeugt (ein API-Vorgang).
-
+
+ >
)}
computeMaxReachableStep(editableMajorSteps, pathSteps),
@@ -498,6 +499,7 @@ export default function ExerciseProgressionPathBuilder({
setStartTargetAnalyzed(false)
setError('')
setWizardStep(1)
+ setPathInsertNotice('')
api
.getExerciseProgressionGraph(Number(graphId))
@@ -883,6 +885,11 @@ export default function ExerciseProgressionPathBuilder({
insertExerciseFromOffer(created, activeOffer)
setQuickCreateDraft(null)
setActiveOffer(null)
+ const title = (created.title || quickTitle || 'Übung').trim()
+ setPathInsertNotice(
+ `„${title}" wurde in den KI-Pfad eingefügt. Speichern Sie jetzt mit «Pfad in Graph speichern» — die Reihe erscheint dann oben unter «Reihen im Graph».`,
+ )
+ setWizardStep(4)
} catch (e) {
console.error(e)
const msg = e?.message || String(e)
@@ -1137,6 +1144,7 @@ export default function ExerciseProgressionPathBuilder({
setEditableMajorSteps([])
setRoadmapDirty(false)
setWizardStep(1)
+ setPathInsertNotice('')
if (typeof onSaved === 'function') await onSaved()
const msg =
skippedAi > 0
@@ -1902,6 +1910,23 @@ export default function ExerciseProgressionPathBuilder({
) : (
<>
+
+ Wichtig: Der KI-Pfad ist noch nicht im Graph gespeichert. KI-Übungen werden erst nach
+ «Pfad in Graph speichern» als neue Reihe oben sichtbar.
+ {pathInsertNotice ?
+ )
+})
+
+export default ProgressionChainEditor
From 8d5f0b533c6d43eaa58bdeb75236c774b74855cc Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 10 Jun 2026 11:17:05 +0200
Subject: [PATCH 08/33] Enhance Exercise Progression Graph Panel and Path
Builder with New Features
- Introduced a primary chain selection in the Exercise Progression Graph Panel to streamline exercise path management.
- Updated the ProgressionChainEditor to support single path mode, allowing users to manage a single progression path more effectively.
- Enhanced the ExerciseProgressionPathBuilder with improved logic for merging graph nodes into path steps and filtering gap offers.
- Updated UI elements for better clarity and user experience, including new notifications and styling adjustments.
- Incremented application version to reflect these updates.
---
.../ExerciseProgressionGraphPanel.jsx | 67 ++++---
.../ExerciseProgressionPathBuilder.jsx | 182 ++++++++++++++++--
.../src/components/ProgressionChainEditor.jsx | 30 +--
3 files changed, 224 insertions(+), 55 deletions(-)
diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx
index a124300..e62a75f 100644
--- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx
+++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx
@@ -277,6 +277,13 @@ export default function ExerciseProgressionGraphPanel({
const flowChains = useMemo(() => maximalLinearChains(nextEdgesFiltered), [nextEdgesFiltered])
+ const primaryChain = useMemo(() => {
+ if (!flowChains.length) return null
+ return flowChains.reduce((best, chain) =>
+ chain.nodes.length >= best.nodes.length ? chain : best,
+ )
+ }, [flowChains])
+
const handleCreateGraph = async (e) => {
e.preventDefault()
const name = newGraphName.trim()
@@ -566,17 +573,42 @@ export default function ExerciseProgressionGraphPanel({
{selectedGraphId && (
<>
- refreshEdges(selectedGraphId)}
- onPickExercise={setPickContext}
- loadVariantsForExercise={loadVariantsForExercise}
- />
+
+
Progressionspfad
+
+ Der Graph enthält typischerweise einen linearen Pfad (Übung → Übung). Unten
+ manuell bearbeiten oder mit dem KI-Planer erweitern — Speichern im KI-Wizard ersetzt den Pfad.
+
+ )
+}
+
const PATH_STEPS_HARD_MAX = 10
const WIZARD_STEPS = [
@@ -399,6 +516,8 @@ export default function ExerciseProgressionPathBuilder({
graphId,
disabled = false,
onSaved,
+ graphChainNodes = null,
+ graphChainEdgeIds = null,
}) {
const [goalQuery, setGoalQuery] = useState('')
const [startSituation, setStartSituation] = useState('')
@@ -887,7 +1006,7 @@ export default function ExerciseProgressionPathBuilder({
setActiveOffer(null)
const title = (created.title || quickTitle || 'Übung').trim()
setPathInsertNotice(
- `„${title}" wurde in den KI-Pfad eingefügt. Speichern Sie jetzt mit «Pfad in Graph speichern» — die Reihe erscheint dann oben unter «Reihen im Graph».`,
+ `„${title}" wurde in den KI-Entwurf eingefügt. Mit «Pfad im Graph speichern» wird der gesamte Pfad übernommen.`,
)
setWizardStep(4)
} catch (e) {
@@ -910,17 +1029,18 @@ export default function ExerciseProgressionPathBuilder({
if (rows.length < 2) {
throw new Error('Zu wenig Schritte im Vorschlag.')
}
- setPathSteps(rows)
+ const mergedRows = mergeGraphIntoPathSteps(rows, graphChainNodes)
+ const rawGaps = Array.isArray(res?.gap_fill_offers)
+ ? res.gap_fill_offers
+ : Array.isArray(qa?.gap_fill_offers)
+ ? qa.gap_fill_offers
+ : []
+ const gaps = filterGapOffersForGraph(rawGaps, mergedRows, graphChainNodes)
+ setPathSteps(mergedRows)
setTargetSummary(res?.target_profile_summary || null)
setSemanticBrief(res?.semantic_brief_summary || null)
setPathQa(qa)
- setGapFillOffers(
- Array.isArray(res?.gap_fill_offers)
- ? res.gap_fill_offers
- : Array.isArray(qa?.gap_fill_offers)
- ? qa.gap_fill_offers
- : [],
- )
+ setGapFillOffers(gaps)
setProgressionRoadmap(res?.progression_roadmap || null)
setPathSkillExpectations(res?.path_skill_expectations || null)
setRoadmapDirty(false)
@@ -1125,6 +1245,12 @@ export default function ExerciseProgressionPathBuilder({
setSaving(true)
setError('')
try {
+ const edgeIds = Array.isArray(graphChainEdgeIds)
+ ? graphChainEdgeIds.filter((id) => Number.isFinite(Number(id)))
+ : []
+ if (edgeIds.length > 0) {
+ await api.deleteExerciseProgressionEdgesBatch(Number(graphId), edgeIds)
+ }
const planningArtifact = buildPlanningArtifact()
await api.createExerciseProgressionSequence(Number(graphId), {
steps: steps.map((s) => ({
@@ -1148,12 +1274,19 @@ export default function ExerciseProgressionPathBuilder({
if (typeof onSaved === 'function') await onSaved()
const msg =
skippedAi > 0
- ? `${n} Kante(n) gespeichert. ${skippedAi} KI-Vorschlag/Vorschläge nicht im Graph (noch nicht angelegt).`
- : `${n} Nachfolger-Kante(n) aus KI-Pfad gespeichert.`
+ ? `Pfad gespeichert (${n} Kante(n)). ${skippedAi} KI-Vorschlag/Vorschläge noch nicht angelegt.`
+ : edgeIds.length > 0
+ ? `Progressionspfad aktualisiert (${n} Kante(n)).`
+ : `Progressionspfad angelegt (${n} Kante(n)).`
alert(msg)
} catch (e) {
console.error(e)
- setError(e.message || 'Speichern fehlgeschlagen')
+ const detail = e?.message || String(e)
+ setError(
+ detail.includes('409') || detail.toLowerCase().includes('duplikat')
+ ? 'Speichern fehlgeschlagen: Pfad-Konflikt. Bitte erneut versuchen — bestehende Kanten werden beim Speichern ersetzt.'
+ : detail || 'Speichern fehlgeschlagen',
+ )
} finally {
setSaving(false)
}
@@ -1161,17 +1294,20 @@ export default function ExerciseProgressionPathBuilder({
return (
-
KI: Pfad zum Ziel
+
Progressionspfad planen (KI)
- In vier Schritten: Ziel festlegen → Roadmap bearbeiten → Übungen matchen → Lücken schließen und speichern.
+ Ein Graph hat einen linearen Pfad. Oben der gespeicherte Stand, darunter der KI-Entwurf in vier Schritten.
+ Speichern übernimmt den Entwurf und ersetzt den bisherigen Pfad.
+ 0} />
+
{step.roadmapLearningGoal ? (
@@ -1922,8 +2059,8 @@ export default function ExerciseProgressionPathBuilder({
color: 'var(--accent-dark)',
}}
>
- Wichtig: Der KI-Pfad ist noch nicht im Graph gespeichert. KI-Übungen werden erst nach
- «Pfad in Graph speichern» als neue Reihe oben sichtbar.
+ Wichtig: Der KI-Entwurf ist noch nicht gespeichert. «Pfad im Graph speichern» übernimmt
+ ihn und ersetzt den oben gezeigten Pfad.
{pathInsertNotice ?
{pathInsertNotice}
: null}
@@ -2042,6 +2179,7 @@ export default function ExerciseProgressionPathBuilder({
— noch nicht angelegt
)}
{step.roadmapPhase ? ` · ${step.roadmapPhase}` : ''}
+ {step.isFromGraph ? ' · bereits im Graph' : ''}
))}
@@ -2072,7 +2210,11 @@ export default function ExerciseProgressionPathBuilder({
disabled={disabled || saving || pathSteps.filter((s) => s.exerciseId).length < 2}
onClick={savePathToGraph}
>
- {saving ? 'Speichern …' : 'Pfad in Graph speichern'}
+ {saving
+ ? 'Speichern …'
+ : graphChainEdgeIds?.length
+ ? 'Pfad im Graph speichern (ersetzen)'
+ : 'Pfad im Graph speichern'}
- Ein Graph enthält eine oder mehrere Reihen (lineare Pfade Übung → Übung) sowie optional{' '}
- Schwester-Alternativen. Reihen bearbeiten Sie direkt in der Liste; mit dem KI-Planer legen
- Sie neue Pfade in vier Schritten an.
+ Ein Graph = ein linearer Primärpfad (Roadmap-Slots) plus optionale{' '}
+ Schwestern. Für die integrierte Bearbeitung (Slots, KI-Entwürfe, Graph-Bewertung){' '}
+ Slot-Editor öffnen — unten weiterhin Kurzansicht und KI-Wizard.