From 5692931d07ee8540dcd8ea7f4a4d2c52c2ad0c18 Mon Sep 17 00:00:00 2001
From: Lars
Date: Wed, 10 Jun 2026 07:25:57 +0200
Subject: [PATCH] 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}