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. Optional Übungsvarianten als Knoten-Endpunkte; Sequenz-Bulk-Anlage.
AuthZ analog training_plan_templates: Bearbeiten/Löschen wie Übungen; Governance-Übergänge zentral. 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 fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
from psycopg2 import IntegrityError from psycopg2 import IntegrityError
from psycopg2.extras import Json
from db import get_db, get_cursor, r2d 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 tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
from club_tenancy import ( from club_tenancy import (
assert_library_content_deletable, assert_library_content_deletable,
@ -36,6 +38,7 @@ class ProgressionGraphUpdate(BaseModel):
description: Optional[str] = None description: Optional[str] = None
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$") visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
club_id: Optional[int] = None club_id: Optional[int] = None
planning_roadmap: Optional[Dict[str, Any]] = None
class ProgressionEdgeCreate(BaseModel): class ProgressionEdgeCreate(BaseModel):
@ -59,6 +62,7 @@ class SequenceStep(BaseModel):
class ProgressionSequenceCreate(BaseModel): class ProgressionSequenceCreate(BaseModel):
steps: List[SequenceStep] = Field(..., min_length=2) steps: List[SequenceStep] = Field(..., min_length=2)
segment_notes: Optional[List[Optional[str]]] = None 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.""" """Länge muss len(steps)-1 sein, wenn gesetzt; Notiz pro Kante Zwischen je zwei Schritten."""
@model_validator(mode="after") @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) 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: def _require_graph_read(cur, graph_id: int, profile_id: int, role: str) -> dict:
row = _graph_row(cur, graph_id) row = _graph_row(cur, graph_id)
_assert_graph_readable(cur, row, profile_id, role) _assert_graph_readable(cur, row, profile_id, role)
@ -353,15 +368,24 @@ def update_progression_graph(
fields.append("club_id = %s") fields.append("club_id = %s")
params.append(next_club if next_vis == "club" else None) 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) return get_progression_graph(graph_id, include_edges=False, tenant=tenant)
fields.append("updated_at = NOW()") if fields:
params.append(graph_id) fields.append("updated_at = NOW()")
cur.execute( params.append(graph_id)
f"UPDATE exercise_progression_graphs SET {', '.join(fields)} WHERE id = %s", cur.execute(
tuple(params), 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() conn.commit()
return get_progression_graph(graph_id, include_edges=False, tenant=tenant) return get_progression_graph(graph_id, include_edges=False, tenant=tenant)
@ -488,6 +512,12 @@ def create_progression_sequence(
note, note,
) )
created.append(row) 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() conn.commit()
except IntegrityError as e: except IntegrityError as e:
conn.rollback() 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 # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.216" APP_VERSION = "0.8.217"
BUILD_DATE = "2026-06-07" BUILD_DATE = "2026-06-07"
DB_SCHEMA_VERSION = "20260607087" DB_SCHEMA_VERSION = "20260607088"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) "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) .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. */ /** Einfügen wächst den Pfad; Ersetzen (replace_step_index) nicht. */
function offerGrowsPath(offer) { function offerGrowsPath(offer) {
const replaceIdx = offer?.replace_step_index const replaceIdx = offer?.replace_step_index
@ -329,6 +354,93 @@ export default function ExerciseProgressionPathBuilder({
const [gapPrepSupplements, setGapPrepSupplements] = useState('') const [gapPrepSupplements, setGapPrepSupplements] = useState('')
const [gapPrepFocusAreaId, setGapPrepFocusAreaId] = useState('') const [gapPrepFocusAreaId, setGapPrepFocusAreaId] = useState('')
const [gapPrepError, setGapPrepError] = 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(() => { useEffect(() => {
let cancelled = false let cancelled = false
@ -783,7 +895,7 @@ export default function ExerciseProgressionPathBuilder({
const res = await api.suggestProgressionPath({ const res = await api.suggestProgressionPath({
query: q, query: q,
max_steps: Number(maxSteps), max_steps: Number(maxSteps),
include_llm_intent: false, include_llm_intent: true,
include_path_qa: false, include_path_qa: false,
include_llm_path_qa: false, include_llm_path_qa: false,
include_path_reorder: false, include_path_reorder: false,
@ -818,6 +930,8 @@ export default function ExerciseProgressionPathBuilder({
setGapFillOffers([]) setGapFillOffers([])
setPathSkillExpectations(null) setPathSkillExpectations(null)
setRoadmapDirty(false) setRoadmapDirty(false)
setLoadedPlanningHint(false)
await persistPlanningRoadmapToGraph()
} catch (e) { } catch (e) {
console.error(e) console.error(e)
setError(e.message || 'Roadmap-Vorschlag fehlgeschlagen') setError(e.message || 'Roadmap-Vorschlag fehlgeschlagen')
@ -865,6 +979,8 @@ export default function ExerciseProgressionPathBuilder({
}) })
applyPathMatchResponse(res, q) applyPathMatchResponse(res, q)
setMaxSteps(validSteps.length) setMaxSteps(validSteps.length)
setLoadedPlanningHint(false)
await persistPlanningRoadmapToGraph()
} catch (e) { } catch (e) {
console.error(e) console.error(e)
setError(e.message || 'Übungs-Match fehlgeschlagen') setError(e.message || 'Übungs-Match fehlgeschlagen')
@ -899,12 +1015,14 @@ export default function ExerciseProgressionPathBuilder({
setSaving(true) setSaving(true)
setError('') setError('')
try { try {
const planningArtifact = buildPlanningArtifact()
await api.createExerciseProgressionSequence(Number(graphId), { await api.createExerciseProgressionSequence(Number(graphId), {
steps: steps.map((s) => ({ steps: steps.map((s) => ({
exercise_id: s.exerciseId, exercise_id: s.exerciseId,
variant_id: s.variantId || null, variant_id: s.variantId || null,
})), })),
segment_notes, segment_notes,
...(planningArtifact ? { planning_roadmap: planningArtifact } : {}),
}) })
setPathSteps([]) setPathSteps([])
setTargetSummary(null) 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. 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. Lücken können mit KI als Übung angelegt werden.
</p> </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 style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end' }}>
<div className="form-row" style={{ flex: '2 1 240px', marginBottom: 0 }}> <div className="form-row" style={{ flex: '2 1 240px', marginBottom: 0 }}>
<label className="form-label">Ziel / Entwicklungsrichtung</label> <label className="form-label">Ziel / Entwicklungsrichtung</label>