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}