Implement EvaluateStepPayload and SlotContentEntry for Enhanced Planning Features
Some checks failed
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Failing after 45s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m21s

- Introduced EvaluateStepPayload class to facilitate evaluation of exercise steps with optional attributes for AI proposals and roadmap details.
- Added SlotContentEntry and SlotExerciseContent classes to manage exercise content within the progression graph planning artifact.
- Updated GraphPlanningRoadmapArtifact to include new slot contents and last findings attributes for improved data handling.
- Enhanced Exercise Progression Graph Panel with links to the new Slot Editor for streamlined editing of progression graphs.
- Incremented application version to reflect these updates.
This commit is contained in:
Lars 2026-06-10 13:05:49 +02:00
parent 8d5f0b533c
commit 97efe66306
10 changed files with 1719 additions and 4 deletions

View File

@ -0,0 +1,79 @@
# Progressionsgraph — Slot-Editor (Phase B)
**Stand:** 2026-06-10 · **Status:** In Umsetzung
## Ziel
Ein Progressionsgraph = **ein linearer Hauptpfad** (Roadmap = strukturgebend). Jeder **Major Step** ist ein **Slot** mit:
- **primary** — Hauptübung des Slots (Pfadknoten)
- **siblings** — 0..n Schwestern (gleiche Stufe, `edge_type: sibling`)
KI-Entwürfe und Bibliotheksübungen leben **im selben Slot-Modell**, ohne sofortige Übungsanlage.
## Slot-Zustände (`kind`)
| kind | Bedeutung |
|------|-----------|
| `empty` | Noch keine Übung |
| `library` | `exercise_id` (+ optional `variant_id`) |
| `proposal` | KI-Entwurf (`ai_suggestion`, kein `exercise_id`) |
## Kanten
- `primary(n) → primary(n+1)``next_exercise` (nur befüllte Primärkette, lückenlos verbunden)
- `primary ↔ sibling``sibling` (pro Slot)
Leere Slots in der Roadmap sind erlaubt; Kanten nur zwischen aufeinanderfolgenden befüllten Primär-Slots.
## Editor-Zustand (`ProgressionGraphDraft`)
```ts
{
goalQuery, startSituation, targetState, roadmapNotes, maxSteps,
majorSteps: MajorStep[],
slots: Slot[], // index = major_step_index
pathSkillExpectations?,
lastFindings?, // path_qa-Snapshot
dirty: boolean,
}
```
**Hydration:** `planning_roadmap` + Kanten → Slots; `slot_contents[]` für Entwürfe; Primärkette aus `next_exercise`.
**Speichern:** Batch-Delete bestehender Pfad-/Schwester-Kanten → `edges/sequence` (Primärkette) → einzelne `sibling`-Kanten → `PUT`/`sequence` mit Artefakt inkl. `slot_contents`, optional `last_findings`.
## Findings-Panel
Nutzt `path_qa` (`overall_ok`, `quality_score`, `issues`, `recommendations`, `gap_fill_offers`, …).
**API:** `POST /api/planning/progression-path-suggest` mit `evaluate_only: true` und `evaluate_steps[]` — QA ohne Re-Match.
Persistenz: `planning_roadmap.last_findings`.
## Artefakt-Erweiterung (`GraphPlanningRoadmapArtifact`)
Zusätzlich optional:
- `slot_contents[]``{ major_step_index, primary, siblings[] }`
- `last_findings` — letzter `path_qa`-Snapshot
## UI & Routing
- **B.4:** Route `/progression-graphs/:id` — Slots links, Findings rechts
- **Phase C:** Übersicht mit Kacheln (Name, Start, Ziel)
## Ersetzt schrittweise
- Getrennte `ExerciseProgressionPathBuilder`-Wizard-UI + `ProgressionChainEditor` → integrierter `ProgressionGraphEditor`
## Implementierungsreihenfolge
| ID | Inhalt |
|----|--------|
| B.0 | Draft + Laden/Speichern Slots ↔ Kanten |
| B.1 | Slot-Karten, Bibliothek + Entwurf |
| B.2 | Findings-Panel + `evaluate_only` |
| B.3 | Entwürfe im Artefakt + „Übung anlegen“ |
| B.4 | Route + Panel vereinfachen |
| B.5 | `last_findings` + Phase-C-Vorbereitung |

View File

@ -74,6 +74,18 @@ from planning_progression_roadmap import (
from routers.training_planning import _has_planning_role
class EvaluateStepPayload(BaseModel):
exercise_id: Optional[int] = Field(default=None, ge=1)
variant_id: Optional[int] = Field(default=None, ge=1)
title: Optional[str] = Field(default=None, max_length=500)
is_ai_proposal: bool = False
ai_suggestion: Optional[Dict[str, Any]] = None
proposal_key: Optional[str] = Field(default=None, max_length=120)
roadmap_major_step_index: Optional[int] = Field(default=None, ge=0, le=20)
roadmap_phase: Optional[str] = Field(default=None, max_length=80)
roadmap_learning_goal: Optional[str] = Field(default=None, max_length=2000)
class ProgressionPathSuggestRequest(BaseModel):
query: str = Field(..., min_length=3, max_length=2000)
max_steps: int = Field(default=5, ge=2, le=10)
@ -88,6 +100,8 @@ class ProgressionPathSuggestRequest(BaseModel):
roadmap_first: bool = False
roadmap_only: bool = False
start_target_only: bool = False
evaluate_only: bool = False
evaluate_steps: Optional[List[EvaluateStepPayload]] = None
roadmap_override: Optional[RoadmapOverridePayload] = None
start_situation: Optional[str] = Field(default=None, max_length=2000)
target_state: Optional[str] = Field(default=None, max_length=2000)
@ -606,6 +620,162 @@ def _build_steps_roadmap_first(
return steps, unfilled
def _evaluate_steps_from_payload(
cur,
payloads: List[EvaluateStepPayload],
) -> List[Dict[str, Any]]:
steps: List[Dict[str, Any]] = []
for raw in payloads:
is_proposal = bool(raw.is_ai_proposal) or raw.exercise_id is None
title = (raw.title or "").strip() or None
if is_proposal:
steps.append(
{
"exercise_id": None,
"variant_id": None,
"title": title or "KI-Vorschlag",
"is_ai_proposal": True,
"ai_suggestion": raw.ai_suggestion,
"proposal_key": raw.proposal_key,
"roadmap_major_step_index": raw.roadmap_major_step_index,
"roadmap_phase": raw.roadmap_phase,
"roadmap_learning_goal": raw.roadmap_learning_goal,
"reasons": [],
}
)
continue
eid = int(raw.exercise_id)
cur.execute(
"SELECT id, title, summary FROM exercises WHERE id = %s",
(eid,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=400, detail=f"Übung {eid} nicht gefunden")
steps.append(
{
"exercise_id": eid,
"variant_id": raw.variant_id,
"title": title or row.get("title"),
"summary": row.get("summary"),
"is_ai_proposal": False,
"roadmap_major_step_index": raw.roadmap_major_step_index,
"roadmap_phase": raw.roadmap_phase,
"roadmap_learning_goal": raw.roadmap_learning_goal,
"reasons": [],
}
)
return steps
def _run_evaluate_only_path_qa(
cur,
*,
body: ProgressionPathSuggestRequest,
goal_query: str,
semantic_brief: PlanningSemanticBrief,
steps: List[Dict[str, Any]],
roadmap_ctx: Optional[ProgressionRoadmapContext],
) -> Dict[str, Any]:
roadmap_first = roadmap_ctx is not None
gaps: List[Dict[str, Any]] = []
bridge_inserts: List[Dict[str, Any]] = []
unfilled_gaps: List[Dict[str, Any]] = []
llm_qa: Optional[Dict[str, Any]] = None
llm_qa_applied = False
off_topic_steps: List[Dict[str, Any]] = []
stripped_off_topic: List[Dict[str, Any]] = []
ai_proposals: List[Dict[str, Any]] = []
gap_fill_offers: List[Dict[str, Any]] = []
roadmap_qa_mode: Optional[str] = None
if body.include_path_qa:
if roadmap_first:
roadmap_qa_mode = "roadmap_first_lite"
gaps = detect_path_gaps(
cur,
steps,
brief=semantic_brief,
roadmap_first=roadmap_first,
)
if gaps and roadmap_first:
unfilled_gaps = list(gaps)
if body.include_llm_path_qa:
llm_qa, llm_qa_applied = try_llm_qa_progression_path(
cur,
goal_query=goal_query,
brief=semantic_brief,
steps=steps,
gaps=gaps,
bridge_inserts=bridge_inserts,
)
off_topic_steps = detect_off_topic_steps(cur, steps, brief=semantic_brief)
llm_gap_specs = parse_llm_suggested_new_exercises(
llm_qa,
brief=semantic_brief,
step_count=len(steps),
)
if body.include_ai_gap_fill:
fresh_large_gaps = [g for g in gaps if g.get("is_large_gap")]
gap_specs = collect_gap_fill_specs(
steps=steps,
unfilled_gaps=fresh_large_gaps or unfilled_gaps,
off_topic_steps=off_topic_steps,
llm_specs=llm_gap_specs,
brief=semantic_brief,
goal_query=goal_query,
)
path_roadmap_snapshot = None
if roadmap_ctx:
path_roadmap_snapshot = build_progression_gap_snapshot(
goal_analysis=(
roadmap_ctx.goal_analysis.model_dump()
if roadmap_ctx.goal_analysis
else None
),
resolved_structured=(
roadmap_ctx.resolved_structured.model_dump()
if roadmap_ctx.resolved_structured
else None
),
semantic_brief=roadmap_ctx.semantic_brief
or brief_to_summary_dict(semantic_brief),
)
_, ai_proposals, gap_fill_offers = apply_gap_fill_after_qa(
cur,
steps,
gap_specs,
goal_query=goal_query,
brief=semantic_brief,
include_ai_calls=False,
max_ai_proposals=0,
auto_insert_proposals=False,
roadmap_snapshot=path_roadmap_snapshot,
)
path_qa = build_path_qa_summary(
gaps=gaps,
bridge_inserts=bridge_inserts,
ai_proposals=ai_proposals,
gap_fill_offers=gap_fill_offers,
off_topic_steps=off_topic_steps,
stripped_off_topic=stripped_off_topic,
llm_qa=llm_qa,
llm_applied=llm_qa_applied,
reorder_applied=False,
reorder_notes=[],
roadmap_qa_mode=roadmap_qa_mode,
)
return {
"path_qa": path_qa,
"gap_fill_offers": gap_fill_offers,
"steps": steps,
}
def suggest_progression_path(
cur,
*,
@ -631,6 +801,7 @@ def suggest_progression_path(
roadmap_first = bool(body.roadmap_first)
roadmap_only = bool(body.roadmap_only)
start_target_only = bool(body.start_target_only)
evaluate_only = bool(body.evaluate_only)
include_roadmap = (
roadmap_first or body.include_roadmap_preview or roadmap_only or start_target_only
)
@ -719,6 +890,42 @@ def suggest_progression_path(
"retrieval_phase": "roadmap_only",
}
if evaluate_only:
if not body.evaluate_steps:
raise HTTPException(
status_code=400,
detail="evaluate_only erfordert evaluate_steps",
)
eval_steps = _evaluate_steps_from_payload(cur, body.evaluate_steps)
qa_pack = _run_evaluate_only_path_qa(
cur,
body=body,
goal_query=goal_query,
semantic_brief=semantic_brief,
steps=eval_steps,
roadmap_ctx=roadmap_ctx,
)
return {
"goal_query": goal_query,
"max_steps_requested": max_steps,
"steps": qa_pack["steps"],
"step_count": len(qa_pack["steps"]),
"target_profile_summary": None,
"semantic_brief_summary": brief_to_summary_dict(semantic_brief),
"semantic_llm_applied": semantic_llm_applied,
"query_intent_summary": {},
"progression_graph_id": body.progression_graph_id,
"path_qa": qa_pack["path_qa"],
"gap_fill_offers": qa_pack["gap_fill_offers"],
"progression_roadmap": progression_roadmap,
"roadmap_first": bool(roadmap_ctx),
"roadmap_only": False,
"roadmap_edited": roadmap_edited,
"roadmap_unfilled_count": 0,
"path_skill_expectations": None,
"retrieval_phase": "evaluate_only",
}
path_target_profile, first_intent_summary, path_intent = _build_path_target_profile(
cur,
goal_query=goal_query,
@ -1030,6 +1237,7 @@ def suggest_progression_path(
__all__ = [
"EvaluateStepPayload",
"ProgressionPathSuggestRequest",
"suggest_progression_path",
"_pick_best_path_hit",

View File

@ -10,6 +10,22 @@ ARTIFACT_SCHEMA_VERSION = 1
_MAX_JSON_BYTES = 64_000
class SlotExerciseContent(BaseModel):
kind: str = Field(default="empty", pattern=r"^(empty|library|proposal)$")
exercise_id: Optional[int] = Field(default=None, ge=1)
variant_id: Optional[int] = Field(default=None, ge=1)
title: Optional[str] = Field(default=None, max_length=500)
variant_name: Optional[str] = Field(default=None, max_length=200)
proposal_key: Optional[str] = Field(default=None, max_length=120)
ai_suggestion: Optional[Dict[str, Any]] = None
class SlotContentEntry(BaseModel):
major_step_index: int = Field(ge=0, le=20)
primary: SlotExerciseContent = Field(default_factory=SlotExerciseContent)
siblings: List[SlotExerciseContent] = Field(default_factory=list)
class GraphPlanningRoadmapArtifact(BaseModel):
schema_version: int = Field(default=ARTIFACT_SCHEMA_VERSION, ge=1, le=1)
goal_query: str = Field(default="", max_length=2000)
@ -19,14 +35,23 @@ class GraphPlanningRoadmapArtifact(BaseModel):
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
slot_contents: Optional[List[SlotContentEntry]] = None
last_findings: Optional[Dict[str, Any]] = None
@field_validator("progression_roadmap", "path_skill_expectations", mode="before")
@field_validator("progression_roadmap", "path_skill_expectations", "last_findings", mode="before")
@classmethod
def _empty_dict_to_none(cls, v):
if v == {}:
return None
return v
@field_validator("slot_contents", mode="before")
@classmethod
def _empty_slot_list_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."""
@ -45,5 +70,7 @@ def normalize_planning_roadmap_payload(raw: Any) -> Optional[Dict[str, Any]]:
__all__ = [
"ARTIFACT_SCHEMA_VERSION",
"GraphPlanningRoadmapArtifact",
"SlotContentEntry",
"SlotExerciseContent",
"normalize_planning_roadmap_payload",
]

View File

@ -31,6 +31,7 @@ const SettingsSystemInfoPage = lazy(() => import('./pages/SettingsSystemInfoPage
const ExercisesListPage = lazy(() => import('./pages/ExercisesListPage'))
const ExerciseDetailPage = lazy(() => import('./pages/ExerciseDetailPage'))
const ExerciseFormPage = lazy(() => import('./pages/ExerciseFormPage'))
const ProgressionGraphEditPage = lazy(() => import('./pages/ProgressionGraphEditPage'))
const ClubsPage = lazy(() => import('./pages/ClubsPage'))
const InboxPage = lazy(() => import('./pages/InboxPage'))
const SkillsPage = lazy(() => import('./pages/SkillsPage'))
@ -244,6 +245,7 @@ const appRouter = createBrowserRouter([
{ path: 'settings/system', element: <SettingsSystemInfoPage /> },
{ path: 'settings/legal', element: <SettingsLegalPage /> },
{ path: 'media', element: <MediaLibraryPage /> },
{ path: 'progression-graphs/:id', element: <ProgressionGraphEditPage /> },
{
path: 'exercises',
children: [

View File

@ -485,9 +485,9 @@ export default function ExerciseProgressionGraphPanel({
)}
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '16px' }}>
Ein Graph enthält eine oder mehrere <strong>Reihen</strong> (lineare Pfade Übung Übung) sowie optional{' '}
<strong>Schwester-Alternativen</strong>. Reihen bearbeiten Sie direkt in der Liste; mit dem KI-Planer legen
Sie neue Pfade in vier Schritten an.
Ein Graph = ein linearer <strong>Primärpfad</strong> (Roadmap-Slots) plus optionale{' '}
<strong>Schwestern</strong>. Für die integrierte Bearbeitung (Slots, KI-Entwürfe, Graph-Bewertung){' '}
<strong>Slot-Editor öffnen</strong> unten weiterhin Kurzansicht und KI-Wizard.
</p>
{loadErr && (
@ -525,6 +525,15 @@ export default function ExerciseProgressionGraphPanel({
<button type="button" className="btn" disabled={busy || !selectedGraphId} onClick={handleDeleteGraph}>
Graph löschen
</button>
{selectedGraphId ? (
<Link
to={`/progression-graphs/${selectedGraphId}`}
className="btn btn-primary"
style={{ textDecoration: 'none' }}
>
Slot-Editor öffnen
</Link>
) : null}
</div>
<form onSubmit={handleCreateGraph} style={{ marginTop: '16px', paddingTop: '16px', borderTop: '1px solid var(--border)' }}>

View File

@ -0,0 +1,141 @@
/**
* Zentrales Findings-Panel für Progressionsgraph-QA (Phase B.2).
*/
import React from 'react'
function severityStyle(pathQa) {
if (!pathQa) return {}
return {
background: pathQa.overall_ok
? 'color-mix(in srgb, var(--accent) 8%, var(--surface2))'
: 'color-mix(in srgb, var(--danger) 8%, var(--surface2))',
}
}
export default function ProgressionFindingsPanel({
pathQa = null,
gapFillOffers = [],
loading = false,
error = '',
onEvaluate,
onApplyGapOffer,
evaluateDisabled = false,
}) {
return (
<div className="card" style={{ position: 'sticky', top: '12px' }}>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Graph-Bewertung</h3>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
Prüft den aktuellen Slot-Stand (inkl. KI-Entwürfe) ohne erneutes Übungs-Matching.
</p>
<button
type="button"
className="btn btn-primary btn-full"
disabled={loading || evaluateDisabled}
onClick={onEvaluate}
style={{ marginBottom: '12px' }}
>
{loading ? 'Bewertung läuft…' : 'Graph bewerten'}
</button>
{error ? (
<p className="form-error" style={{ marginTop: 0 }}>
{error}
</p>
) : null}
{pathQa ? (
<div
style={{
padding: '10px 12px',
borderRadius: '8px',
fontSize: '12px',
lineHeight: 1.45,
...severityStyle(pathQa),
}}
>
<strong>
Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'}
{pathQa.quality_score != null
? ` (${Math.round(Number(pathQa.quality_score) * 100)} %)`
: ''}
</strong>
{pathQa.topic_coverage ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{pathQa.topic_coverage}</p>
) : null}
{Array.isArray(pathQa.issues) && pathQa.issues.length > 0 ? (
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
{pathQa.issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
) : null}
{Array.isArray(pathQa.recommendations) && pathQa.recommendations.length > 0 ? (
<>
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)' }}>Empfehlungen</p>
<ul style={{ margin: 0, paddingLeft: '16px', color: 'var(--text2)' }}>
{pathQa.recommendations.map((rec) => (
<li key={rec}>{rec}</li>
))}
</ul>
</>
) : null}
{Number(pathQa.off_topic_count) > 0 ? (
<p style={{ margin: '6px 0 0', color: 'var(--danger)' }}>
{pathQa.off_topic_count} Schritt(e) ohne Bezug zum Pfad-Thema.
</p>
) : null}
{pathQa.roadmap_qa_mode === 'roadmap_first_lite' ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>
QS an Roadmap gekoppelt (keine Brücken zwischen Major Steps).
</p>
) : null}
</div>
) : (
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>
Noch keine Bewertung. Slots befüllen und Graph bewerten ausführen.
</p>
)}
{Array.isArray(gapFillOffers) && gapFillOffers.length > 0 ? (
<div style={{ marginTop: '14px' }}>
<h4 style={{ margin: '0 0 8px', fontSize: '0.9rem' }}>Lücken-Angebote</h4>
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '8px' }}>
{gapFillOffers.slice(0, 6).map((offer) => (
<li
key={offer.offer_id || offer.title_hint}
style={{
padding: '8px 10px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
fontSize: '12px',
}}
>
<div style={{ fontWeight: 600 }}>
{offer.title_hint || 'Übungsvorschlag'}
{offer.roadmap_major_step_index != null
? ` · Slot ${Number(offer.roadmap_major_step_index) + 1}`
: ''}
</div>
{offer.rationale ? (
<p style={{ margin: '4px 0 0', color: 'var(--text2)' }}>{offer.rationale}</p>
) : null}
{typeof onApplyGapOffer === 'function' ? (
<button
type="button"
className="btn btn-secondary"
style={{ marginTop: '6px', fontSize: '11px', padding: '4px 8px' }}
onClick={() => onApplyGapOffer(offer)}
>
Als Entwurf in Slot
</button>
) : null}
</li>
))}
</ul>
</div>
) : null}
</div>
)
}

View File

@ -0,0 +1,523 @@
/**
* Integrierter Slot-Editor für Progressionsgraphen (Phase B).
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import ExercisePickerModal from './ExercisePickerModal'
import ProgressionSlotCard from './ProgressionSlotCard'
import ProgressionFindingsPanel from './ProgressionFindingsPanel'
import {
applyGapOfferToSlot,
applyMatchStepsToSlots,
buildPlanningArtifactFromDraft,
hydrateProgressionGraphDraft,
librarySlotExercise,
majorStepsToOverridePayload,
reindexMajorSteps,
saveProgressionGraphDraft,
slotsToEvaluateSteps,
} from '../utils/progressionGraphDraft'
const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion']
function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) {
const body = {}
const start = (startSituation || '').trim()
const target = (targetState || '').trim()
const notes = (roadmapNotes || '').trim()
if (start) body.start_situation = start
if (target) body.target_state = target
if (notes) body.roadmap_notes = notes
return body
}
export default function ProgressionGraphEditor({ graphId }) {
const [graphMeta, setGraphMeta] = useState(null)
const [draft, setDraft] = useState(null)
const [busy, setBusy] = useState(false)
const [loadErr, setLoadErr] = useState('')
const [actionErr, setActionErr] = useState('')
const [pickContext, setPickContext] = useState(null)
const [pathQa, setPathQa] = useState(null)
const [gapFillOffers, setGapFillOffers] = useState([])
const [evaluating, setEvaluating] = useState(false)
const [matching, setMatching] = useState(false)
const [roadmapLoading, setRoadmapLoading] = useState(false)
const loadGraph = useCallback(async () => {
if (!graphId) return
setBusy(true)
setLoadErr('')
try {
const [graph, edges] = await Promise.all([
api.getExerciseProgressionGraph(Number(graphId)),
api.listExerciseProgressionEdges(Number(graphId)),
])
setGraphMeta(graph)
setDraft(
hydrateProgressionGraphDraft({
artifact: graph?.planning_roadmap,
edges: Array.isArray(edges) ? edges : [],
graphName: graph?.name,
}),
)
const findings = graph?.planning_roadmap?.last_findings
if (findings) setPathQa(findings)
} catch (e) {
setLoadErr(e.message || 'Graph konnte nicht geladen werden')
setDraft(null)
} finally {
setBusy(false)
}
}, [graphId])
useEffect(() => {
loadGraph()
}, [loadGraph])
const patchDraft = useCallback((patchFn) => {
setDraft((prev) => {
if (!prev) return prev
const next = patchFn(prev)
return { ...next, dirty: true }
})
}, [])
const handlePickExercise = async (exercise) => {
if (!pickContext || !exercise?.id) return
const { slotIndex, role } = pickContext
const entry = librarySlotExercise({
exerciseId: exercise.id,
exerciseTitle: exercise.title || `Übung #${exercise.id}`,
})
patchDraft((d) => {
const slots = d.slots.map((s, i) => {
if (i !== slotIndex) return s
if (role === 'primary') return { ...s, primary: entry }
const siblings = [...(s.siblings || [])]
if (!siblings.some((x) => x.exerciseId === entry.exerciseId)) siblings.push(entry)
return { ...s, siblings }
})
return { ...d, slots }
})
setPickContext(null)
}
const handlePatchLearningGoal = (slotIndex, value) => {
patchDraft((d) => {
const slots = d.slots.map((s, i) => (i === slotIndex ? { ...s, learning_goal: value } : s))
const majorSteps = reindexMajorSteps(
(d.majorSteps || []).map((m, i) => (i === slotIndex ? { ...m, learning_goal: value } : m)),
)
return { ...d, slots, majorSteps }
})
}
const handleClearPrimary = (slotIndex) => {
patchDraft((d) => {
const slots = d.slots.map((s, i) =>
i === slotIndex ? { ...s, primary: { kind: 'empty', exerciseId: null, variantId: null, exerciseTitle: '', variantName: null, proposalKey: null, aiSuggestion: null }, siblings: [] } : s,
)
return { ...d, slots }
})
}
const handleRemoveSibling = (slotIndex, sibIdx) => {
patchDraft((d) => {
const slots = d.slots.map((s, i) => {
if (i !== slotIndex) return s
return { ...s, siblings: s.siblings.filter((_, j) => j !== sibIdx) }
})
return { ...d, slots }
})
}
const handleAddSlot = () => {
patchDraft((d) => {
const idx = d.slots.length
const phase = ROADMAP_PHASES[Math.min(idx, ROADMAP_PHASES.length - 1)]
const slot = {
majorStepIndex: idx,
phase,
learning_goal: '',
consolidates: [],
rationale: '',
load_profile: [],
success_criteria: [],
anti_patterns: [],
exercise_type: '',
primary: { kind: 'empty', exerciseId: null, variantId: null, exerciseTitle: '', variantName: null, proposalKey: null, aiSuggestion: null },
siblings: [],
}
const major = {
index: idx,
phase,
learning_goal: '',
consolidates: [],
rationale: '',
load_profile: [],
success_criteria: [],
anti_patterns: [],
exercise_type: '',
}
return {
...d,
slots: [...d.slots, slot],
majorSteps: [...(d.majorSteps || []), major],
maxSteps: Math.max(d.maxSteps, idx + 1),
}
})
}
const validMajorSteps = useMemo(() => {
if (!draft?.slots) return []
return draft.slots.filter((s) => (s.learning_goal || '').trim().length >= 3)
}, [draft?.slots])
const runRoadmapGenerate = async () => {
const q = (draft?.goalQuery || '').trim()
if (q.length < 3) {
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
return
}
setRoadmapLoading(true)
setActionErr('')
try {
const res = await api.suggestProgressionPath({
query: q,
max_steps: draft.maxSteps || 5,
include_llm_intent: true,
include_path_qa: false,
include_llm_path_qa: false,
include_path_reorder: false,
include_ai_gap_fill: false,
include_roadmap_preview: true,
include_llm_roadmap: true,
roadmap_only: true,
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
})
const roadmap = res?.progression_roadmap
if (!roadmap) throw new Error('Keine Roadmap in der Antwort')
const majors = (roadmap?.roadmap?.major_steps || []).map((s, i) => ({
index: i,
phase: s.phase || ROADMAP_PHASES[Math.min(i, ROADMAP_PHASES.length - 1)],
learning_goal: (s.learning_goal || '').trim(),
consolidates: Array.isArray(s.consolidates) ? s.consolidates : [],
rationale: s.rationale || '',
load_profile: [],
success_criteria: [],
anti_patterns: [],
exercise_type: '',
}))
const hydrated = hydrateProgressionGraphDraft({
artifact: {
...buildPlanningArtifactFromDraft({ ...draft, progressionRoadmap: roadmap }),
progression_roadmap: roadmap,
},
edges: [],
graphName: draft.graphName,
})
setDraft({ ...hydrated, goalQuery: q, dirty: true })
} catch (e) {
setActionErr(e.message || 'Roadmap-Generierung fehlgeschlagen')
} finally {
setRoadmapLoading(false)
}
}
const runMatch = async () => {
const q = (draft?.goalQuery || '').trim()
if (q.length < 3) {
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
return
}
if (validMajorSteps.length < 2) {
alert('Mindestens zwei Slots mit Lernziel (je 3+ Zeichen) nötig.')
return
}
setMatching(true)
setActionErr('')
try {
const override = majorStepsToOverridePayload(draft.slots.map((s) => ({
index: s.majorStepIndex,
phase: s.phase,
learning_goal: s.learning_goal,
consolidates: s.consolidates,
rationale: s.rationale,
load_profile: s.load_profile,
success_criteria: s.success_criteria,
anti_patterns: s.anti_patterns,
exercise_type: s.exercise_type,
})))
const res = await api.suggestProgressionPath({
query: q,
max_steps: validMajorSteps.length,
include_llm_intent: true,
include_path_qa: true,
include_llm_path_qa: true,
include_path_reorder: false,
include_ai_gap_fill: true,
include_roadmap_preview: true,
include_llm_roadmap: false,
roadmap_first: true,
roadmap_override: override,
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
})
const next = applyMatchStepsToSlots(
{
...draft,
progressionRoadmap: res?.progression_roadmap || draft.progressionRoadmap,
pathSkillExpectations: res?.path_skill_expectations || draft.pathSkillExpectations,
},
res?.steps,
)
setDraft(next)
setPathQa(res?.path_qa || null)
setGapFillOffers(Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : [])
} catch (e) {
setActionErr(e.message || 'Übungs-Match fehlgeschlagen')
} finally {
setMatching(false)
}
}
const runEvaluate = async () => {
const q = (draft?.goalQuery || '').trim()
if (q.length < 3) {
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
return
}
setEvaluating(true)
setActionErr('')
try {
const override =
validMajorSteps.length >= 2
? majorStepsToOverridePayload(
draft.slots.map((s) => ({
index: s.majorStepIndex,
phase: s.phase,
learning_goal: s.learning_goal,
consolidates: s.consolidates,
rationale: s.rationale,
load_profile: s.load_profile,
success_criteria: s.success_criteria,
anti_patterns: s.anti_patterns,
exercise_type: s.exercise_type,
})),
)
: undefined
const res = await api.suggestProgressionPath({
query: q,
max_steps: draft.slots.length || draft.maxSteps || 5,
include_path_qa: true,
include_llm_path_qa: true,
include_ai_gap_fill: true,
include_path_reorder: false,
include_llm_intent: false,
evaluate_only: true,
evaluate_steps: slotsToEvaluateSteps(draft),
roadmap_override: override,
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
})
setPathQa(res?.path_qa || null)
setGapFillOffers(Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : [])
setDraft((prev) => (prev ? { ...prev, lastFindings: res?.path_qa || null } : prev))
} catch (e) {
setActionErr(e.message || 'Bewertung fehlgeschlagen')
} finally {
setEvaluating(false)
}
}
const handleSave = async () => {
if (!draft || !graphId) return
setBusy(true)
setActionErr('')
try {
await saveProgressionGraphDraft(api, graphId, { ...draft, lastFindings: pathQa })
await loadGraph()
alert('Progressionsgraph gespeichert.')
} catch (e) {
setActionErr(e.message || 'Speichern fehlgeschlagen')
} finally {
setBusy(false)
}
}
const handleApplyGapOffer = (offer) => {
const idx =
offer?.roadmap_major_step_index != null ? Number(offer.roadmap_major_step_index) : null
if (idx == null || !Number.isFinite(idx)) {
alert('Angebot ohne Slot-Zuordnung — bitte manuell zuweisen.')
return
}
setDraft((prev) => applyGapOfferToSlot(prev, idx, offer))
}
if (loadErr) {
return (
<div className="card">
<p className="form-error">{loadErr}</p>
<Link to="/exercises" className="btn btn-secondary">
Zurück
</Link>
</div>
)
}
if (!draft) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: '40px' }}>
<div className="spinner" />
</div>
)
}
return (
<div>
<div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '8px' }}>
<div>
<h2 style={{ margin: 0, fontSize: '1.15rem' }}>
{graphMeta?.name || draft.graphName || `Graph #${graphId}`}
</h2>
<p style={{ margin: '4px 0 0', fontSize: '12px', color: 'var(--text3)' }}>
Slot-Editor · Roadmap = Struktur · ein Primärpfad + Schwestern
</p>
</div>
<Link to="/exercises" className="btn btn-secondary" style={{ fontSize: '12px' }}>
Zur Übersicht
</Link>
</div>
{actionErr ? (
<p className="form-error" style={{ marginTop: 0 }}>
{actionErr}
</p>
) : null}
<div
className="progression-graph-editor-grid"
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) minmax(260px, 320px)',
gap: '14px',
alignItems: 'start',
}}
>
<div>
<div className="card" style={{ marginBottom: '12px' }}>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Ziel & Roadmap</h3>
<div className="form-row">
<label className="form-label">Ziel-Anfrage</label>
<textarea
className="form-input"
rows={2}
value={draft.goalQuery}
disabled={busy}
onChange={(e) => patchDraft((d) => ({ ...d, goalQuery: e.target.value }))}
placeholder="z. B. Vom Anfänger zum sauberen Gerade-Tritt"
/>
</div>
<div className="form-row" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div>
<label className="form-label">Start-Situation</label>
<input
className="form-input"
value={draft.startSituation}
disabled={busy}
onChange={(e) => patchDraft((d) => ({ ...d, startSituation: e.target.value }))}
/>
</div>
<div>
<label className="form-label">Ziel-Zustand</label>
<input
className="form-input"
value={draft.targetState}
disabled={busy}
onChange={(e) => patchDraft((d) => ({ ...d, targetState: e.target.value }))}
/>
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '8px' }}>
<button
type="button"
className="btn btn-secondary"
disabled={busy || roadmapLoading}
onClick={runRoadmapGenerate}
>
{roadmapLoading ? 'Roadmap…' : 'Roadmap generieren'}
</button>
<button
type="button"
className="btn btn-secondary"
disabled={busy || matching}
onClick={runMatch}
>
{matching ? 'Match…' : 'Übungen matchen'}
</button>
<button type="button" className="btn btn-primary" disabled={busy} onClick={handleSave}>
{busy ? 'Speichern…' : 'Graph speichern'}
</button>
</div>
{draft.dirty ? (
<p style={{ margin: '8px 0 0', fontSize: '11px', color: 'var(--accent-dark)' }}>
Ungespeicherte Änderungen
</p>
) : null}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<h3 style={{ margin: 0, fontSize: '1rem' }}>Slots ({draft.slots.length})</h3>
<button type="button" className="btn btn-secondary" style={{ fontSize: '12px' }} disabled={busy || draft.slots.length >= 10} onClick={handleAddSlot}>
Slot hinzufügen
</button>
</div>
{draft.slots.map((slot, idx) => (
<ProgressionSlotCard
key={`slot-${slot.majorStepIndex}-${idx}`}
slot={slot}
slotIndex={idx}
disabled={busy}
onPickPrimary={(i) => setPickContext({ slotIndex: i, role: 'primary' })}
onPickSibling={(i) => setPickContext({ slotIndex: i, role: 'sibling' })}
onClearPrimary={handleClearPrimary}
onRemoveSibling={handleRemoveSibling}
onPatchLearningGoal={handlePatchLearningGoal}
/>
))}
</div>
<ProgressionFindingsPanel
pathQa={pathQa}
gapFillOffers={gapFillOffers}
loading={evaluating}
error={evaluating ? '' : ''}
onEvaluate={runEvaluate}
onApplyGapOffer={handleApplyGapOffer}
evaluateDisabled={busy || !draft.goalQuery?.trim()}
/>
</div>
{pickContext ? (
<ExercisePickerModal
open
onClose={() => setPickContext(null)}
onSelectExercise={handlePickExercise}
/>
) : null}
<style>{`
@media (max-width: 900px) {
.progression-graph-editor-grid {
grid-template-columns: 1fr !important;
}
}
`}</style>
</div>
)
}

View File

@ -0,0 +1,160 @@
/**
* Einzelner Roadmap-Slot im Progressionsgraph-Editor.
*/
import React from 'react'
import { Link } from 'react-router-dom'
function exerciseLabel(entry) {
if (!entry || entry.kind === 'empty') return '— noch leer —'
if (entry.kind === 'proposal') return entry.exerciseTitle || 'KI-Entwurf'
return entry.exerciseTitle || `Übung #${entry.exerciseId}`
}
export default function ProgressionSlotCard({
slot,
slotIndex,
onPickPrimary,
onPickSibling,
onClearPrimary,
onRemoveSibling,
onPatchLearningGoal,
disabled = false,
}) {
const { primary, siblings = [], phase, learning_goal: learningGoal } = slot
return (
<div
className="card"
style={{
marginBottom: '10px',
borderColor: primary.kind === 'empty'
? 'var(--border)'
: 'color-mix(in srgb, var(--accent) 25%, var(--border))',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '8px', flexWrap: 'wrap' }}>
<div>
<h4 style={{ margin: 0, fontSize: '0.95rem' }}>
Slot {slotIndex + 1}
{phase ? <span style={{ color: 'var(--text3)', fontWeight: 400 }}>{` · ${phase}`}</span> : null}
</h4>
</div>
<span
className="exercise-tag"
style={{
fontSize: '11px',
borderColor: primary.kind === 'proposal' ? 'var(--danger)' : undefined,
}}
>
{primary.kind === 'empty' ? 'leer' : primary.kind === 'proposal' ? 'KI-Entwurf' : 'Bibliothek'}
</span>
</div>
<div className="form-row" style={{ marginTop: '10px', marginBottom: '8px' }}>
<label className="form-label">Lernziel (Major Step)</label>
<input
className="form-input"
value={learningGoal || ''}
disabled={disabled}
onChange={(e) => onPatchLearningGoal(slotIndex, e.target.value)}
placeholder="Was soll in dieser Stufe erreicht werden?"
/>
</div>
<div
style={{
padding: '10px 12px',
borderRadius: '8px',
background: 'var(--surface2)',
border: '1px solid var(--border)',
}}
>
<div style={{ fontSize: '11px', color: 'var(--text3)', marginBottom: '4px' }}>Hauptpfad (primary)</div>
<div style={{ fontSize: '13px', fontWeight: 600 }}>
{primary.kind === 'library' && primary.exerciseId ? (
<Link to={`/exercises/${primary.exerciseId}`}>{exerciseLabel(primary)}</Link>
) : (
exerciseLabel(primary)
)}
{primary.variantName ? (
<span style={{ color: 'var(--text3)', fontWeight: 400 }}>{` · ${primary.variantName}`}</span>
) : null}
</div>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', marginTop: '8px' }}>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px', padding: '4px 10px' }}
disabled={disabled}
onClick={() => onPickPrimary(slotIndex)}
>
{primary.kind === 'empty' ? 'Übung wählen' : 'Übung ändern'}
</button>
{primary.kind !== 'empty' ? (
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '4px 10px' }}
disabled={disabled}
onClick={() => onClearPrimary(slotIndex)}
>
Leeren
</button>
) : null}
</div>
</div>
<div style={{ marginTop: '10px' }}>
<div style={{ fontSize: '11px', color: 'var(--text3)', marginBottom: '6px' }}>Schwestern (Alternativen)</div>
{siblings.length === 0 ? (
<p style={{ margin: 0, fontSize: '12px', color: 'var(--text2)' }}>Keine Schwestern.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 8px', display: 'flex', flexDirection: 'column', gap: '6px' }}>
{siblings.map((sib, sibIdx) => (
<li
key={`${sib.exerciseId || sib.proposalKey}-${sibIdx}`}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '8px',
padding: '6px 8px',
borderRadius: '6px',
background: 'var(--surface2)',
fontSize: '12px',
}}
>
<span>
{sib.kind === 'library' && sib.exerciseId ? (
<Link to={`/exercises/${sib.exerciseId}`}>{exerciseLabel(sib)}</Link>
) : (
exerciseLabel(sib)
)}
</span>
<button
type="button"
className="btn"
style={{ fontSize: '11px', padding: '2px 8px' }}
disabled={disabled}
onClick={() => onRemoveSibling(slotIndex, sibIdx)}
>
Entfernen
</button>
</li>
))}
</ul>
)}
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px', padding: '4px 10px' }}
disabled={disabled || primary.kind !== 'library'}
onClick={() => onPickSibling(slotIndex)}
title={primary.kind !== 'library' ? 'Zuerst eine Hauptübung wählen' : undefined}
>
Schwester hinzufügen
</button>
</div>
</div>
)
}

View File

@ -0,0 +1,25 @@
import React from 'react'
import { Link, useParams } from 'react-router-dom'
import ProgressionGraphEditor from '../components/ProgressionGraphEditor'
export default function ProgressionGraphEditPage() {
const { id } = useParams()
const graphId = Number(id)
if (!Number.isFinite(graphId) || graphId < 1) {
return (
<div className="page" style={{ padding: '16px' }}>
<p className="form-error">Ungültige Graph-ID.</p>
<Link to="/exercises" className="btn btn-secondary">
Zurück
</Link>
</div>
)
}
return (
<div className="page" style={{ padding: '16px', paddingBottom: '80px' }}>
<ProgressionGraphEditor graphId={graphId} />
</div>
)
}

View File

@ -0,0 +1,541 @@
/**
* Progressionsgraph Slot-Editor Draft-Hydration und Speichern (Phase B).
*/
const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion']
export const PLANNING_ARTIFACT_SCHEMA = 1
export function emptySlotExercise() {
return {
kind: 'empty',
exerciseId: null,
variantId: null,
exerciseTitle: '',
variantName: null,
proposalKey: null,
aiSuggestion: null,
}
}
export function librarySlotExercise({ exerciseId, exerciseTitle, variantId = null, variantName = null }) {
return {
kind: 'library',
exerciseId: Number(exerciseId),
variantId: variantId != null ? Number(variantId) : null,
exerciseTitle: exerciseTitle || `Übung #${exerciseId}`,
variantName: variantName || null,
proposalKey: null,
aiSuggestion: null,
}
}
export function proposalSlotExercise({ title, proposalKey = null, aiSuggestion = null }) {
return {
kind: 'proposal',
exerciseId: null,
variantId: null,
exerciseTitle: (title || 'KI-Vorschlag').trim(),
variantName: null,
proposalKey: proposalKey || `proposal-${Date.now()}`,
aiSuggestion: aiSuggestion || null,
}
}
export function slotExerciseFromApi(raw) {
if (!raw || typeof raw !== 'object') return emptySlotExercise()
const kind = raw.kind || (raw.exercise_id != null ? 'library' : raw.ai_suggestion ? 'proposal' : 'empty')
if (kind === 'proposal' || raw.ai_suggestion) {
return proposalSlotExercise({
title: raw.title || raw.exercise_title,
proposalKey: raw.proposal_key,
aiSuggestion: raw.ai_suggestion,
})
}
if (kind === 'library' && raw.exercise_id != null) {
return librarySlotExercise({
exerciseId: raw.exercise_id,
exerciseTitle: raw.title || raw.exercise_title,
variantId: raw.variant_id,
variantName: raw.variant_name,
})
}
return emptySlotExercise()
}
export function slotExerciseToApi(entry) {
if (!entry || entry.kind === 'empty') {
return { kind: 'empty' }
}
if (entry.kind === 'proposal') {
return {
kind: 'proposal',
title: entry.exerciseTitle || 'KI-Vorschlag',
proposal_key: entry.proposalKey || null,
ai_suggestion: entry.aiSuggestion || null,
}
}
return {
kind: 'library',
exercise_id: entry.exerciseId,
variant_id: entry.variantId || null,
title: entry.exerciseTitle || null,
variant_name: entry.variantName || null,
}
}
export function mapMajorStepsFromApi(apiRoadmap) {
const raw = apiRoadmap?.roadmap?.major_steps
if (!Array.isArray(raw)) return []
const rows = raw.map((s, i) => ({
index: i,
phase: s.phase || 'vertiefung',
learning_goal: (s.learning_goal || '').trim(),
consolidates: Array.isArray(s.consolidates) ? s.consolidates : [],
rationale: s.rationale || '',
load_profile: [],
success_criteria: [],
anti_patterns: [],
exercise_type: '',
}))
const specs = apiRoadmap?.stage_specs
if (!Array.isArray(specs) || !rows.length) return rows
return rows.map((row, i) => {
const spec =
specs.find((s) => Number(s.major_step_index) === i) ||
specs.find((s) => Number(s.major_step_index) === row.index) ||
specs[i]
if (!spec) return row
return {
...row,
load_profile: Array.isArray(spec.load_profile) ? [...spec.load_profile] : [],
success_criteria: Array.isArray(spec.success_criteria) ? [...spec.success_criteria] : [],
anti_patterns: Array.isArray(spec.anti_patterns) ? [...spec.anti_patterns] : [],
exercise_type: (spec.exercise_type || '').trim(),
}
})
}
export function reindexMajorSteps(rows) {
return rows.map((row, i) => ({ ...row, index: i }))
}
export function majorStepsToOverridePayload(rows) {
const indexed = reindexMajorSteps(rows)
return {
major_steps: indexed.map((row) => ({
index: row.index,
phase: row.phase || 'vertiefung',
learning_goal: row.learning_goal.trim(),
consolidates: row.consolidates || [],
rationale: row.rationale || '',
})),
stage_specs: indexed.map((row, i) => ({
major_step_index: i,
learning_goal: row.learning_goal.trim(),
load_profile: Array.isArray(row.load_profile) ? row.load_profile : [],
exercise_type: (row.exercise_type || '').trim(),
success_criteria: Array.isArray(row.success_criteria) ? row.success_criteria : [],
anti_patterns: Array.isArray(row.anti_patterns) ? row.anti_patterns : [],
})),
}
}
function nodeKey(exerciseId, variantId) {
return `${exerciseId}:${variantId ?? ''}`
}
/** Maximale lineare Segmente aus next_exercise-Kanten. */
export function maximalLinearChains(nextEdges) {
if (!nextEdges?.length) return []
const outMap = new Map()
const inMap = new Map()
for (const e of nextEdges) {
const f = nodeKey(e.from_exercise_id, e.from_exercise_variant_id)
const t = nodeKey(e.to_exercise_id, e.to_exercise_variant_id)
if (!outMap.has(f)) outMap.set(f, [])
outMap.get(f).push(e)
if (!inMap.has(t)) inMap.set(t, [])
inMap.get(t).push(e)
}
const used = new Set()
const chains = []
for (const startEdge of nextEdges) {
if (used.has(startEdge.id)) continue
const edgesSeq = [startEdge]
let fk = nodeKey(startEdge.from_exercise_id, startEdge.from_exercise_variant_id)
while (true) {
const preds = inMap.get(fk)
if (!preds || preds.length !== 1) break
const pred = preds[0]
if (used.has(pred.id)) break
edgesSeq.unshift(pred)
fk = nodeKey(pred.from_exercise_id, pred.from_exercise_variant_id)
}
let tk = nodeKey(startEdge.to_exercise_id, startEdge.to_exercise_variant_id)
while (true) {
const outs = outMap.get(tk)
if (!outs || outs.length !== 1) break
const nx = outs[0]
if (used.has(nx.id)) break
edgesSeq.push(nx)
tk = nodeKey(nx.to_exercise_id, nx.to_exercise_variant_id)
}
edgesSeq.forEach((ed) => used.add(ed.id))
const first = edgesSeq[0]
const nodes = [
{
exercise_id: first.from_exercise_id,
variant_id: first.from_exercise_variant_id ?? null,
title: first.from_exercise_title,
variant_name: first.from_variant_name ?? null,
},
]
for (const ed of edgesSeq) {
nodes.push({
exercise_id: ed.to_exercise_id,
variant_id: ed.to_exercise_variant_id ?? null,
title: ed.to_exercise_title,
variant_name: ed.to_variant_name ?? null,
})
}
chains.push({ nodes, edges: edgesSeq })
}
return chains
}
function chainNodeToLibrary(node) {
if (!node?.exercise_id) return emptySlotExercise()
return librarySlotExercise({
exerciseId: node.exercise_id,
exerciseTitle: node.title || `Übung #${node.exercise_id}`,
variantId: node.variant_id,
variantName: node.variant_name,
})
}
function buildSlotsFromSources({ majorSteps, slotContents, primaryChain, siblingEdges }) {
const slotCount = Math.max(majorSteps.length, primaryChain?.nodes?.length || 0, 2)
const slots = []
for (let i = 0; i < slotCount; i += 1) {
const major = majorSteps[i] || {
index: i,
phase: ROADMAP_PHASES[Math.min(i, ROADMAP_PHASES.length - 1)],
learning_goal: '',
consolidates: [],
rationale: '',
load_profile: [],
success_criteria: [],
anti_patterns: [],
exercise_type: '',
}
const saved = Array.isArray(slotContents)
? slotContents.find((s) => Number(s.major_step_index) === i)
: null
let primary = saved?.primary
? slotExerciseFromApi(saved.primary)
: chainNodeToLibrary(primaryChain?.nodes?.[i])
if (primary.kind === 'empty' && saved?.primary) {
primary = slotExerciseFromApi(saved.primary)
}
const siblings = []
const seenSiblingKeys = new Set()
if (Array.isArray(saved?.siblings)) {
for (const sib of saved.siblings) {
const entry = slotExerciseFromApi(sib)
if (entry.kind !== 'empty') {
const key = entry.kind === 'library' ? `lib-${entry.exerciseId}` : `prop-${entry.proposalKey}`
if (!seenSiblingKeys.has(key)) {
seenSiblingKeys.add(key)
siblings.push(entry)
}
}
}
}
if (primary.kind === 'library' && Array.isArray(siblingEdges)) {
const pid = primary.exerciseId
for (const edge of siblingEdges) {
let other = null
if (Number(edge.from_exercise_id) === pid) {
other = librarySlotExercise({
exerciseId: edge.to_exercise_id,
exerciseTitle: edge.to_exercise_title,
variantId: edge.to_exercise_variant_id,
variantName: edge.to_variant_name,
})
} else if (Number(edge.to_exercise_id) === pid) {
other = librarySlotExercise({
exerciseId: edge.from_exercise_id,
exerciseTitle: edge.from_exercise_title,
variantId: edge.from_exercise_variant_id,
variantName: edge.from_variant_name,
})
}
if (other) {
const key = `lib-${other.exerciseId}`
if (!seenSiblingKeys.has(key)) {
seenSiblingKeys.add(key)
siblings.push(other)
}
}
}
}
slots.push({
majorStepIndex: i,
phase: major.phase,
learning_goal: major.learning_goal,
consolidates: major.consolidates,
rationale: major.rationale,
load_profile: major.load_profile,
success_criteria: major.success_criteria,
anti_patterns: major.anti_patterns,
exercise_type: major.exercise_type,
primary,
siblings,
})
}
return slots
}
export function hydrateProgressionGraphDraft({
artifact = null,
edges = [],
graphName = '',
}) {
const nextEdges = (edges || []).filter((e) => (e.edge_type || 'next_exercise') === 'next_exercise')
const siblingEdges = (edges || []).filter((e) => e.edge_type === 'sibling')
const chains = maximalLinearChains(nextEdges)
const primaryChain = chains.sort((a, b) => b.nodes.length - a.nodes.length)[0] || null
const majorSteps = artifact?.progression_roadmap
? mapMajorStepsFromApi(artifact.progression_roadmap)
: []
const slots = buildSlotsFromSources({
majorSteps,
slotContents: artifact?.slot_contents,
primaryChain,
siblingEdges,
})
return {
graphName: graphName || '',
goalQuery: artifact?.goal_query || '',
startSituation: artifact?.start_situation || '',
targetState: artifact?.target_state || '',
roadmapNotes: artifact?.roadmap_notes || '',
maxSteps: Number(artifact?.max_steps) || Math.max(slots.length, 5),
majorSteps: majorSteps.length ? majorSteps : slots.map((s, i) => ({
index: i,
phase: s.phase,
learning_goal: s.learning_goal,
consolidates: s.consolidates,
rationale: s.rationale,
load_profile: s.load_profile,
success_criteria: s.success_criteria,
anti_patterns: s.anti_patterns,
exercise_type: s.exercise_type,
})),
slots,
pathSkillExpectations: artifact?.path_skill_expectations || null,
progressionRoadmap: artifact?.progression_roadmap || null,
lastFindings: artifact?.last_findings || null,
primaryChainEdgeIds: primaryChain?.edges?.map((e) => e.id) || [],
siblingEdgeIds: siblingEdges.map((e) => e.id),
dirty: false,
}
}
export function buildPlanningArtifactFromDraft(draft, { lastFindings = undefined } = {}) {
const q = (draft.goalQuery || '').trim()
const progressionRoadmap = draft.progressionRoadmap || null
if (!q && !progressionRoadmap && !draft.slots?.length) return null
const slot_contents = (draft.slots || []).map((slot) => ({
major_step_index: slot.majorStepIndex,
primary: slotExerciseToApi(slot.primary),
siblings: (slot.siblings || []).map(slotExerciseToApi).filter((s) => s.kind !== 'empty'),
}))
const artifact = {
schema_version: PLANNING_ARTIFACT_SCHEMA,
goal_query: q,
start_situation: (draft.startSituation || '').trim() || null,
target_state: (draft.targetState || '').trim() || null,
roadmap_notes: (draft.roadmapNotes || '').trim() || null,
max_steps: Number(draft.maxSteps) || draft.slots?.length || 5,
progression_roadmap: progressionRoadmap,
path_skill_expectations: draft.pathSkillExpectations || null,
slot_contents,
}
const findings = lastFindings !== undefined ? lastFindings : draft.lastFindings
if (findings) artifact.last_findings = findings
return artifact
}
/** Befüllte Primärkette (nur library) für edges/sequence. */
export function draftPrimaryChainSteps(draft) {
const steps = []
for (const slot of draft.slots || []) {
if (slot.primary?.kind === 'library' && slot.primary.exerciseId != null) {
steps.push({
exerciseId: slot.primary.exerciseId,
variantId: slot.primary.variantId,
exerciseTitle: slot.primary.exerciseTitle,
majorStepIndex: slot.majorStepIndex,
phase: slot.phase,
learningGoal: slot.learning_goal,
})
}
}
return steps
}
export function draftSiblingEdgePairs(draft) {
const pairs = []
for (const slot of draft.slots || []) {
if (slot.primary?.kind !== 'library' || slot.primary.exerciseId == null) continue
for (const sib of slot.siblings || []) {
if (sib.kind !== 'library' || sib.exerciseId == null) continue
pairs.push({
from: { exerciseId: slot.primary.exerciseId, variantId: slot.primary.variantId },
to: { exerciseId: sib.exerciseId, variantId: sib.variantId },
})
}
}
return pairs
}
export function slotsToEvaluateSteps(draft) {
return (draft.slots || []).map((slot) => {
const p = slot.primary
if (p.kind === 'proposal') {
return {
exercise_id: null,
variant_id: null,
title: p.exerciseTitle,
is_ai_proposal: true,
ai_suggestion: p.aiSuggestion,
proposal_key: p.proposalKey,
roadmap_major_step_index: slot.majorStepIndex,
roadmap_phase: slot.phase,
roadmap_learning_goal: slot.learning_goal,
}
}
if (p.kind === 'library' && p.exerciseId != null) {
return {
exercise_id: p.exerciseId,
variant_id: p.variantId || null,
title: p.exerciseTitle,
is_ai_proposal: false,
roadmap_major_step_index: slot.majorStepIndex,
roadmap_phase: slot.phase,
roadmap_learning_goal: slot.learning_goal,
}
}
return {
exercise_id: null,
variant_id: null,
title: `(leer: ${slot.learning_goal || `Slot ${slot.majorStepIndex + 1}`})`,
is_ai_proposal: true,
roadmap_major_step_index: slot.majorStepIndex,
roadmap_phase: slot.phase,
roadmap_learning_goal: slot.learning_goal,
}
})
}
export function applyMatchStepsToSlots(draft, apiSteps) {
const steps = Array.isArray(apiSteps) ? apiSteps : []
const nextSlots = (draft.slots || []).map((slot) => ({ ...slot, primary: { ...slot.primary }, siblings: [...(slot.siblings || [])] }))
for (const step of steps) {
const idx =
step.roadmap_major_step_index != null
? Number(step.roadmap_major_step_index)
: steps.indexOf(step)
if (!Number.isFinite(idx) || idx < 0 || idx >= nextSlots.length) continue
const isProposal = Boolean(step.is_ai_proposal) || step.exercise_id == null
if (isProposal) {
nextSlots[idx].primary = proposalSlotExercise({
title: step.title,
proposalKey: step.proposal_key,
aiSuggestion: step.ai_suggestion,
})
} else {
nextSlots[idx].primary = librarySlotExercise({
exerciseId: step.exercise_id,
exerciseTitle: step.title || `Übung #${step.exercise_id}`,
variantId: step.variant_id,
})
}
}
return { ...draft, slots: nextSlots, dirty: true }
}
export function applyGapOfferToSlot(draft, slotIndex, offer, aiSuggestion = null) {
const nextSlots = (draft.slots || []).map((s) => ({ ...s, primary: { ...s.primary }, siblings: [...(s.siblings || [])] }))
if (slotIndex < 0 || slotIndex >= nextSlots.length) return draft
nextSlots[slotIndex].primary = proposalSlotExercise({
title: offer?.title_hint || offer?.title || 'KI-Vorschlag',
proposalKey: offer?.offer_id || offer?.proposal_key,
aiSuggestion: aiSuggestion || offer?.ai_suggestion || null,
})
return { ...draft, slots: nextSlots, dirty: true }
}
export async function saveProgressionGraphDraft(api, graphId, draft) {
const primarySteps = draftPrimaryChainSteps(draft)
const siblingPairs = draftSiblingEdgePairs(draft)
const artifact = buildPlanningArtifactFromDraft(draft)
const edgeIds = [
...(draft.primaryChainEdgeIds || []),
...(draft.siblingEdgeIds || []),
].filter((id) => Number.isFinite(Number(id)))
if (edgeIds.length > 0) {
await api.deleteExerciseProgressionEdgesBatch(Number(graphId), edgeIds)
}
if (primarySteps.length >= 2) {
await api.createExerciseProgressionSequence(Number(graphId), {
steps: primarySteps.map((s) => ({
exercise_id: s.exerciseId,
variant_id: s.variantId || null,
})),
segment_notes: primarySteps.slice(1).map(() => null),
...(artifact ? { planning_roadmap: artifact } : {}),
})
} else if (artifact) {
await api.updateExerciseProgressionGraph(Number(graphId), { planning_roadmap: artifact })
}
for (const pair of siblingPairs) {
await api.createExerciseProgressionEdge(Number(graphId), {
from_exercise_id: pair.from.exerciseId,
from_exercise_variant_id: pair.from.variantId,
to_exercise_id: pair.to.exerciseId,
to_exercise_variant_id: pair.to.variantId,
edge_type: 'sibling',
})
}
return { primaryCount: primarySteps.length, siblingCount: siblingPairs.length }
}