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
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:
parent
98b279fa89
commit
5692931d07
|
|
@ -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.';
|
||||
49
backend/progression_graph_planning_artifact.py
Normal file
49
backend/progression_graph_planning_artifact.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
36
backend/tests/test_progression_graph_planning_artifact.py
Normal file
36
backend/tests/test_progression_graph_planning_artifact.py
Normal 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")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user