Update version to 0.8.217 and enhance Exercise Progression Path Builder with planning roadmap features
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m15s

- 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.
This commit is contained in:
Lars 2026-06-10 07:25:57 +02:00
parent 98b279fa89
commit 5692931d07
6 changed files with 268 additions and 11 deletions

View File

@ -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.';

View File

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

View File

@ -3,13 +3,15 @@ Progressionsgraph zwischen Übungen (Übung → Übung), Migration 032034.
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()

View File

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

View File

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

View File

@ -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.
</p>
{loadedPlanningHint && editableMajorSteps.length > 0 && pathSteps.length === 0 ? (
<p
style={{
fontSize: '12px',
color: 'var(--accent-dark)',
margin: '0 0 10px',
padding: '8px 10px',
borderRadius: '8px',
background: 'color-mix(in srgb, var(--accent) 8%, var(--surface))',
lineHeight: 1.45,
}}
>
Gespeicherte Planung für diesen Graph geladen Roadmap anpassen und erneut matchen, oder neuen Vorschlag
starten.
</p>
) : null}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end' }}>
<div className="form-row" style={{ flex: '2 1 240px', marginBottom: 0 }}>
<label className="form-label">Ziel / Entwicklungsrichtung</label>