Add Gap Offer Handling and UI Enhancements in Progression Graph Components
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m18s

- Implemented `_build_evaluate_empty_slot_gap_specs` function to generate gap offer specifications for unfilled roadmap slots in evaluate-only mode.
- Enhanced `ProgressionFindingsPanel` to display AI offers for empty slots and gaps, improving user interaction and clarity.
- Updated `ProgressionGraphEditor` and `ProgressionSlotCard` components to support new functionalities for managing slots and offers.
- Refactored utility functions in `progressionGraphDraft.js` to streamline slot management and offer handling.
- Incremented application version to reflect these updates.
This commit is contained in:
Lars 2026-06-10 15:34:37 +02:00
parent 97efe66306
commit c1bf9279ad
7 changed files with 914 additions and 206 deletions

View File

@ -668,6 +668,47 @@ def _evaluate_steps_from_payload(
return steps
def _build_evaluate_empty_slot_gap_specs(
steps: List[Dict[str, Any]],
*,
goal_query: str,
) -> List[Dict[str, Any]]:
"""Gap-Angebote für leere Roadmap-Slots im evaluate_only-Modus."""
specs: List[Dict[str, Any]] = []
for step in steps:
if step.get("exercise_id") is not None:
continue
major_idx = step.get("roadmap_major_step_index")
if major_idx is None:
continue
try:
roadmap_idx = int(major_idx)
except (TypeError, ValueError):
continue
phase = (step.get("roadmap_phase") or "vertiefung").strip().lower()
learning_goal = (step.get("roadmap_learning_goal") or step.get("title") or "").strip()
title_hint = learning_goal[:120] if learning_goal else f"Slot {roadmap_idx + 1}"
specs.append(
{
"source": "roadmap_unfilled",
"insert_after_index": max(roadmap_idx - 1, -1),
"gap": {
"expected_phase": phase,
"roadmap_major_step_index": roadmap_idx,
"learning_goal": learning_goal,
},
"phase": phase,
"title_hint": title_hint,
"sketch": learning_goal or title_hint,
"rationale": (
f"Slot {roadmap_idx + 1} ohne Übung — KI-Entwurf für diese Roadmap-Stufe."
),
"roadmap_major_step_index": roadmap_idx,
}
)
return specs[:8]
def _run_evaluate_only_path_qa(
cur,
*,
@ -728,6 +769,27 @@ def _run_evaluate_only_path_qa(
brief=semantic_brief,
goal_query=goal_query,
)
empty_slot_specs = _build_evaluate_empty_slot_gap_specs(
steps,
goal_query=goal_query,
)
seen_spec_keys = {
(
s.get("source"),
s.get("roadmap_major_step_index"),
s.get("insert_after_index"),
)
for s in gap_specs
}
for spec in empty_slot_specs:
key = (
spec.get("source"),
spec.get("roadmap_major_step_index"),
spec.get("insert_after_index"),
)
if key not in seen_spec_keys:
gap_specs.append(spec)
seen_spec_keys.add(key)
path_roadmap_snapshot = None
if roadmap_ctx:
path_roadmap_snapshot = build_progression_gap_snapshot(

View File

@ -2,7 +2,7 @@
from __future__ import annotations
import json
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, field_validator

View File

@ -34,3 +34,26 @@ def test_normalize_planning_roadmap_with_progression_roadmap():
def test_normalize_rejects_invalid_type():
with pytest.raises(ValueError, match="JSON-Objekt"):
normalize_planning_roadmap_payload("not-json")
def test_normalize_slot_contents():
out = normalize_planning_roadmap_payload(
{
"goal_query": "Gerade-Tritt",
"max_steps": 3,
"slot_contents": [
{
"major_step_index": 0,
"primary": {"kind": "library", "exercise_id": 12, "title": "Grundstellung"},
"siblings": [],
},
{
"major_step_index": 1,
"primary": {"kind": "proposal", "title": "KI-Entwurf", "proposal_key": "p1"},
"siblings": [],
},
],
}
)
assert len(out["slot_contents"]) == 2
assert out["slot_contents"][1]["primary"]["kind"] == "proposal"

View File

@ -1,7 +1,13 @@
/**
* Zentrales Findings-Panel für Progressionsgraph-QA (Phase B.2).
*/
import React from 'react'
import React, { useMemo, useState } from 'react'
import {
offerCanExpandSlots,
offerNeedsNewSlot,
offerSourceLabel,
resolveOfferSlotIndex,
} from '../utils/progressionGraphDraft'
function severityStyle(pathQa) {
if (!pathQa) return {}
@ -12,20 +18,150 @@ function severityStyle(pathQa) {
}
}
function GapOfferCard({
offer,
slotCount,
draft,
onApplyDraft,
onInsertSlot,
onGenerateAi,
generatingOfferId,
aiBusy,
}) {
const defaultSlot = resolveOfferSlotIndex(draft, offer)
const [slotPick, setSlotPick] = useState(
defaultSlot != null && Number.isFinite(defaultSlot) ? String(defaultSlot) : '',
)
const needsInsert = offerNeedsNewSlot(offer)
const canInsert = offerCanExpandSlots(draft, offer)
const slotOptions = useMemo(() => {
const rows = []
for (let i = 0; i < slotCount; i += 1) {
rows.push({ value: String(i), label: `Slot ${i + 1}` })
}
return rows
}, [slotCount])
const applyToSlot = () => {
const idx = slotPick !== '' ? Number(slotPick) : defaultSlot
if (!Number.isFinite(idx)) {
alert('Bitte einen Slot wählen.')
return
}
onApplyDraft(offer, idx)
}
return (
<li
style={{
padding: '10px 12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
fontSize: '12px',
}}
>
<span className="exercise-tag" style={{ marginBottom: '6px', display: 'inline-block' }}>
{offerSourceLabel(offer.source)}
{offer.phase ? ` · ${offer.phase}` : ''}
{offer.has_ai_payload ? ' · KI-Entwurf bereit' : ''}
</span>
<div style={{ fontWeight: 600 }}>{offer.title_hint || offer.proposal_title || 'Übungsvorschlag'}</div>
{offer.rationale ? (
<p style={{ margin: '4px 0 0', color: 'var(--text2)' }}>{offer.rationale}</p>
) : null}
{offer.from_title && offer.to_title ? (
<p style={{ margin: '4px 0 0', color: 'var(--text3)', fontSize: '11px' }}>
Zwischen {offer.from_title} und {offer.to_title}
</p>
) : null}
<div style={{ marginTop: '8px', display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
<label style={{ fontSize: '11px', color: 'var(--text3)' }}>
Ziel-Slot
<select
className="form-input"
style={{ marginLeft: '6px', padding: '4px 8px', fontSize: '12px', minWidth: '100px' }}
value={slotPick}
onChange={(e) => setSlotPick(e.target.value)}
>
<option value=""> wählen </option>
{slotOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
</div>
<div style={{ marginTop: '8px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{offer.has_ai_payload ? (
<button
type="button"
className="btn btn-primary"
style={{ fontSize: '11px', padding: '4px 8px' }}
onClick={() => applyToSlot()}
>
Entwurf in Slot
</button>
) : (
<button
type="button"
className="btn btn-primary"
style={{ fontSize: '11px', padding: '4px 8px' }}
disabled={aiBusy}
onClick={() => onGenerateAi(offer, slotPick !== '' ? Number(slotPick) : defaultSlot)}
>
{generatingOfferId === offer.offer_id ? 'KI erstellt…' : 'KI anlegen'}
</button>
)}
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '4px 8px' }}
onClick={() => applyToSlot()}
>
Platzhalter in Slot
</button>
{needsInsert ? (
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '4px 8px' }}
disabled={!canInsert}
title={!canInsert ? `Maximal ${slotCount} Slots` : 'Neuen Slot zwischen zwei Stufen einfügen'}
onClick={() => onInsertSlot(offer)}
>
Neuen Slot einfügen
</button>
) : null}
</div>
</li>
)
}
export default function ProgressionFindingsPanel({
pathQa = null,
gapFillOffers = [],
draft = null,
slotCount = 0,
loading = false,
error = '',
onEvaluate,
onApplyGapOffer,
onInsertGapSlot,
onGenerateGapAi,
generatingOfferId = null,
aiBusy = false,
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.
Prüft den Slot-Stand und listet KI-Angebote für leere Stufen und Lücken.
</p>
<button
@ -85,57 +221,39 @@ export default function ProgressionFindingsPanel({
{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.
Noch keine Bewertung. Roadmap anlegen, dann Graph bewerten oder Übungen matchen.
</p>
)}
{Array.isArray(gapFillOffers) && gapFillOffers.length > 0 ? (
<div style={{ marginTop: '14px' }}>
<h4 style={{ margin: '0 0 8px', fontSize: '0.9rem' }}>Lücken-Angebote</h4>
<div style={{ marginTop: '14px' }}>
<h4 style={{ margin: '0 0 8px', fontSize: '0.9rem' }}>
KI-Angebote {gapFillOffers.length > 0 ? `(${gapFillOffers.length})` : ''}
</h4>
{gapFillOffers.length === 0 ? (
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>
Keine offenen Angebote. Nach Match oder Bewertung erscheinen Vorschläge für leere Slots und Lücken.
</p>
) : (
<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>
{gapFillOffers.map((offer) => (
<GapOfferCard
key={offer.offer_id || `${offer.source}-${offer.title_hint}`}
offer={offer}
slotCount={slotCount}
draft={draft}
generatingOfferId={generatingOfferId}
aiBusy={aiBusy}
onApplyDraft={(o, idx) => onApplyGapOffer(o, idx)}
onInsertSlot={onInsertGapSlot}
onGenerateAi={onGenerateGapAi}
/>
))}
</ul>
</div>
) : null}
)}
</div>
</div>
)
}

View File

@ -5,22 +5,37 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import ExercisePickerModal from './ExercisePickerModal'
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
import ProgressionSlotCard from './ProgressionSlotCard'
import ProgressionFindingsPanel from './ProgressionFindingsPanel'
import {
applyGapOfferToSlot,
aiPreviewToQuickCreateDraft,
buildQuickCreateAiPreview,
} from '../utils/exerciseAiQuickCreate'
import {
buildPathGapPlanningContextForAi,
gapOfferContextDisplayLines,
initialStageLearningGoalFromOffer,
} from '../utils/planningContextForExerciseAi'
import {
addSlotToDraft,
applyGapOfferToDraft,
applyMatchStepsToSlots,
buildPlanningArtifactFromDraft,
collectGapOffersFromApiResponse,
hydrateProgressionGraphDraft,
insertSlotInDraft,
librarySlotExercise,
majorStepsToOverridePayload,
reindexMajorSteps,
moveSlotInDraft,
patchSlotInDraft,
removeSlotFromDraft,
saveProgressionGraphDraft,
SLOT_MAX,
slotsAsPathStepRows,
slotsToEvaluateSteps,
syncProgressionRoadmapFromSlots,
} from '../utils/progressionGraphDraft'
const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion']
function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) {
const body = {}
const start = (startSituation || '').trim()
@ -32,6 +47,16 @@ function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) {
return body
}
function resolveDefaultFocusAreaId(targetSummary, focusAreas) {
const targetName = targetSummary?.focus_areas?.[0]
if (targetName && Array.isArray(focusAreas) && focusAreas.length) {
const norm = String(targetName).trim().toLowerCase()
const hit = focusAreas.find((fa) => String(fa.name || '').trim().toLowerCase() === norm)
if (hit?.id) return Number(hit.id)
}
return focusAreas?.[0]?.id ? Number(focusAreas[0].id) : null
}
export default function ProgressionGraphEditor({ graphId }) {
const [graphMeta, setGraphMeta] = useState(null)
const [draft, setDraft] = useState(null)
@ -44,6 +69,20 @@ export default function ProgressionGraphEditor({ graphId }) {
const [evaluating, setEvaluating] = useState(false)
const [matching, setMatching] = useState(false)
const [roadmapLoading, setRoadmapLoading] = useState(false)
const [semanticBrief, setSemanticBrief] = useState(null)
const [targetSummary, setTargetSummary] = useState(null)
const [focusAreas, setFocusAreas] = useState([])
const [activeOffer, setActiveOffer] = useState(null)
const [activeOfferSlotIndex, setActiveOfferSlotIndex] = useState(null)
const [gapPrepOpen, setGapPrepOpen] = useState(false)
const [gapPrepTitle, setGapPrepTitle] = useState('')
const [gapPrepStageGoal, setGapPrepStageGoal] = useState('')
const [gapPrepSupplements, setGapPrepSupplements] = useState('')
const [gapPrepFocusAreaId, setGapPrepFocusAreaId] = useState('')
const [gapPrepError, setGapPrepError] = useState('')
const [generatingOfferId, setGeneratingOfferId] = useState(null)
const [gapAiBusy, setGapAiBusy] = useState(false)
const loadGraph = useCallback(async () => {
if (!graphId) return
@ -76,6 +115,21 @@ export default function ProgressionGraphEditor({ graphId }) {
loadGraph()
}, [loadGraph])
useEffect(() => {
let cancelled = false
api
.listFocusAreas({ status: 'active' })
.then((fa) => {
if (!cancelled) setFocusAreas(Array.isArray(fa) ? fa : [])
})
.catch(() => {
if (!cancelled) setFocusAreas([])
})
return () => {
cancelled = true
}
}, [])
const patchDraft = useCallback((patchFn) => {
setDraft((prev) => {
if (!prev) return prev
@ -84,6 +138,21 @@ export default function ProgressionGraphEditor({ graphId }) {
})
}, [])
const gapContextParams = useMemo(() => {
if (!draft) return {}
return {
goalQuery: draft.goalQuery,
semanticBrief,
graphId,
pathSteps: slotsAsPathStepRows(draft),
editableMajorSteps: draft.majorSteps,
progressionRoadmap: draft.progressionRoadmap,
startSituation: draft.startSituation,
targetState: draft.targetState,
roadmapNotes: draft.roadmapNotes,
}
}, [draft, semanticBrief, graphId])
const handlePickExercise = async (exercise) => {
if (!pickContext || !exercise?.id) return
const { slotIndex, role } = pickContext
@ -99,27 +168,68 @@ export default function ProgressionGraphEditor({ graphId }) {
if (!siblings.some((x) => x.exerciseId === entry.exerciseId)) siblings.push(entry)
return { ...s, siblings }
})
return { ...d, slots }
return syncProgressionRoadmapFromSlots({ ...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 }
})
patchDraft((d) => patchSlotInDraft(d, slotIndex, { learning_goal: value }))
}
const handlePatchPhase = (slotIndex, value) => {
patchDraft((d) => patchSlotInDraft(d, slotIndex, { phase: value }))
}
const handleMoveSlot = (slotIndex, dir) => {
patchDraft((d) => moveSlotInDraft(d, slotIndex, dir))
}
const handleRemoveSlot = (slotIndex) => {
if ((draft?.slots?.length || 0) <= 2) {
alert('Mindestens zwei Slots müssen bleiben.')
return
}
if (!window.confirm(`Slot ${slotIndex + 1} wirklich entfernen?`)) return
patchDraft((d) => removeSlotFromDraft(d, slotIndex))
}
const handleInsertAfter = (slotIndex) => {
if ((draft?.slots?.length || 0) >= SLOT_MAX) {
alert(`Maximal ${SLOT_MAX} Slots.`)
return
}
patchDraft((d) => insertSlotInDraft(d, slotIndex))
}
const handleAddSlot = () => {
if ((draft?.slots?.length || 0) >= SLOT_MAX) {
alert(`Maximal ${SLOT_MAX} Slots.`)
return
}
patchDraft((d) => addSlotToDraft(d))
}
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,
i === slotIndex
? {
...s,
primary: {
kind: 'empty',
exerciseId: null,
variantId: null,
exerciseTitle: '',
variantName: null,
proposalKey: null,
aiSuggestion: null,
},
siblings: [],
}
: s,
)
return { ...d, slots }
return syncProgressionRoadmapFromSlots({ ...d, slots })
})
}
@ -133,48 +243,18 @@ export default function ProgressionGraphEditor({ graphId }) {
})
}
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 applyApiResponse = (res) => {
setSemanticBrief(res?.semantic_brief_summary || null)
setTargetSummary(res?.target_profile_summary || null)
setPathQa(res?.path_qa || null)
setGapFillOffers(collectGapOffersFromApiResponse(res))
}
const runRoadmapGenerate = async () => {
const q = (draft?.goalQuery || '').trim()
if (q.length < 3) {
@ -200,26 +280,20 @@ export default function ProgressionGraphEditor({ graphId }) {
})
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 }),
goal_query: q,
progression_roadmap: roadmap,
start_situation: draft.startSituation,
target_state: draft.targetState,
roadmap_notes: draft.roadmapNotes,
max_steps: (roadmap?.roadmap?.major_steps || []).length || draft.maxSteps,
},
edges: [],
graphName: draft.graphName,
})
setDraft({ ...hydrated, goalQuery: q, dirty: true })
setSemanticBrief(res?.semantic_brief_summary || null)
} catch (e) {
setActionErr(e.message || 'Roadmap-Generierung fehlgeschlagen')
} finally {
@ -240,20 +314,11 @@ export default function ProgressionGraphEditor({ graphId }) {
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 synced = syncProgressionRoadmapFromSlots(draft)
const override = majorStepsToOverridePayload(synced.slots)
const res = await api.suggestProgressionPath({
query: q,
max_steps: validMajorSteps.length,
max_steps: synced.slots.length,
include_llm_intent: true,
include_path_qa: true,
include_llm_path_qa: true,
@ -268,15 +333,14 @@ export default function ProgressionGraphEditor({ graphId }) {
})
const next = applyMatchStepsToSlots(
{
...draft,
progressionRoadmap: res?.progression_roadmap || draft.progressionRoadmap,
pathSkillExpectations: res?.path_skill_expectations || draft.pathSkillExpectations,
...synced,
progressionRoadmap: res?.progression_roadmap || synced.progressionRoadmap,
pathSkillExpectations: res?.path_skill_expectations || synced.pathSkillExpectations,
},
res?.steps,
)
setDraft(next)
setPathQa(res?.path_qa || null)
setGapFillOffers(Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : [])
applyApiResponse(res)
} catch (e) {
setActionErr(e.message || 'Übungs-Match fehlgeschlagen')
} finally {
@ -293,38 +357,24 @@ export default function ProgressionGraphEditor({ graphId }) {
setEvaluating(true)
setActionErr('')
try {
const synced = syncProgressionRoadmapFromSlots(draft)
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
validMajorSteps.length >= 2 ? majorStepsToOverridePayload(synced.slots) : undefined
const res = await api.suggestProgressionPath({
query: q,
max_steps: draft.slots.length || draft.maxSteps || 5,
max_steps: synced.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),
evaluate_steps: slotsToEvaluateSteps(synced),
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 : [])
applyApiResponse(res)
setDraft((prev) => (prev ? { ...prev, lastFindings: res?.path_qa || null } : prev))
} catch (e) {
setActionErr(e.message || 'Bewertung fehlgeschlagen')
@ -348,14 +398,136 @@ export default function ProgressionGraphEditor({ graphId }) {
}
}
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.')
const handleApplyGapOffer = (offer, slotIndex) => {
setDraft((prev) => {
const next = applyGapOfferToDraft(prev, offer, { slotIndex })
return { ...next, dirty: true }
})
setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
}
const handleInsertGapSlot = (offer) => {
if ((draft?.slots?.length || 0) >= SLOT_MAX) {
alert(`Maximal ${SLOT_MAX} Slots — zuerst einen Slot entfernen.`)
return
}
setDraft((prev) => applyGapOfferToSlot(prev, idx, offer))
setDraft((prev) => {
const next = applyGapOfferToDraft(prev, offer, { insertNewSlot: true })
return { ...next, dirty: true }
})
setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
}
const openGapFillPrep = (offer, slotIndex = null) => {
const defaultFocus = resolveDefaultFocusAreaId(targetSummary, focusAreas)
setActiveOffer(offer)
setActiveOfferSlotIndex(slotIndex)
setGapPrepTitle((offer?.title_hint || '').trim())
setGapPrepStageGoal(initialStageLearningGoalFromOffer(offer, gapContextParams))
setGapPrepSupplements('')
setGapPrepFocusAreaId(defaultFocus ? String(defaultFocus) : '')
setGapPrepError('')
setGapPrepOpen(true)
}
const runGapFillAiSuggest = async (offer, prep, slotIndex) => {
const title = (prep?.title || offer?.title_hint || '').trim()
if (title.length < 3) {
alert('Titel: mindestens 3 Zeichen.')
return
}
const supplements = (prep?.supplements || '').trim()
const stageGoal = (prep?.stageLearningGoal || '').trim()
let goalText = (offer?.goal_for_ai || offer?.sketch || '').trim()
if (supplements) {
goalText = `${goalText}\n\nTrainer-Ergänzungen:\n${supplements}`.trim()
}
const focusId =
prep?.focusAreaId != null && Number.isFinite(Number(prep.focusAreaId))
? Number(prep.focusAreaId)
: resolveDefaultFocusAreaId(targetSummary, focusAreas)
if (!focusId) {
alert('Bitte einen Fokusbereich wählen.')
return
}
const focusRow = (focusAreas || []).find((x) => Number(x.id) === focusId)
const focusHint = (focusRow?.name || offer?.primary_topic || '').trim()
setGapAiBusy(true)
setGeneratingOfferId(offer?.offer_id || null)
setGapPrepError('')
try {
const planningContext = buildPathGapPlanningContextForAi({
offer,
...gapContextParams,
stageLearningGoalOverride: stageGoal,
gapTrainerSupplements: supplements,
})
const aiRes = await api.suggestExerciseAi({
title,
goal: goalText || undefined,
execution: '',
preparation: '',
trainer_notes: supplements || '',
focus_area_hint: focusHint || undefined,
focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
planning_context: planningContext || undefined,
include_summary: true,
include_skills: true,
include_instructions: true,
})
const preview = buildQuickCreateAiPreview(aiRes, { sketchPlain: goalText })
if (!preview.hasSummaryProposal && !preview.hasInstructionChoices && !preview.hasSkillChoices) {
throw new Error('Die KI lieferte keinen verwertbaren Vorschlag.')
}
const aiDraft = aiPreviewToQuickCreateDraft(preview, {
title,
focusAreaId: focusId,
sketchPlain: goalText,
})
const enrichedOffer = {
...offer,
proposal_title: title,
ai_suggestion: aiDraft,
has_ai_payload: true,
}
setDraft((prev) => {
const next = applyGapOfferToDraft(prev, enrichedOffer, { slotIndex })
return { ...next, dirty: true }
})
setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
setGapPrepOpen(false)
setActiveOffer(null)
} catch (e) {
setGapPrepError(e.message || 'KI-Anlage fehlgeschlagen')
} finally {
setGapAiBusy(false)
setGeneratingOfferId(null)
}
}
const submitGapFillPrep = async () => {
const title = (gapPrepTitle || '').trim()
if (title.length < 3) {
alert('Titel: mindestens 3 Zeichen.')
return
}
const focusId = parseInt(String(gapPrepFocusAreaId).trim(), 10)
if (!Number.isFinite(focusId) || focusId < 1) {
alert('Bitte einen Fokusbereich wählen.')
return
}
if (!activeOffer) return
await runGapFillAiSuggest(
activeOffer,
{
title,
stageLearningGoal: (gapPrepStageGoal || '').trim(),
supplements: (gapPrepSupplements || '').trim(),
focusAreaId: focusId,
},
activeOfferSlotIndex,
)
}
if (loadErr) {
@ -385,7 +557,7 @@ export default function ProgressionGraphEditor({ graphId }) {
{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
Slots verschieben, ergänzen · KI-Angebote zuordnen · dynamisch erweitern (max. {SLOT_MAX})
</p>
</div>
<Link to="/exercises" className="btn btn-secondary" style={{ fontSize: '12px' }}>
@ -403,7 +575,7 @@ export default function ProgressionGraphEditor({ graphId }) {
className="progression-graph-editor-grid"
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) minmax(260px, 320px)',
gridTemplateColumns: 'minmax(0, 1fr) minmax(280px, 340px)',
gap: '14px',
alignItems: 'start',
}}
@ -472,22 +644,34 @@ export default function ProgressionGraphEditor({ graphId }) {
<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
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px' }}
disabled={busy || draft.slots.length >= SLOT_MAX}
onClick={handleAddSlot}
>
Slot am Ende
</button>
</div>
{draft.slots.map((slot, idx) => (
<ProgressionSlotCard
key={`slot-${slot.majorStepIndex}-${idx}`}
key={`slot-${idx}-${slot.learning_goal?.slice(0, 12) || 'x'}`}
slot={slot}
slotIndex={idx}
slotCount={draft.slots.length}
disabled={busy}
onPickPrimary={(i) => setPickContext({ slotIndex: i, role: 'primary' })}
onPickSibling={(i) => setPickContext({ slotIndex: i, role: 'sibling' })}
onClearPrimary={handleClearPrimary}
onRemoveSibling={handleRemoveSibling}
onPatchLearningGoal={handlePatchLearningGoal}
onPatchPhase={handlePatchPhase}
onMoveUp={(i) => handleMoveSlot(i, -1)}
onMoveDown={(i) => handleMoveSlot(i, 1)}
onRemoveSlot={handleRemoveSlot}
onInsertAfter={handleInsertAfter}
/>
))}
</div>
@ -495,10 +679,16 @@ export default function ProgressionGraphEditor({ graphId }) {
<ProgressionFindingsPanel
pathQa={pathQa}
gapFillOffers={gapFillOffers}
draft={draft}
slotCount={draft.slots.length}
loading={evaluating}
error={evaluating ? '' : ''}
error=""
onEvaluate={runEvaluate}
onApplyGapOffer={handleApplyGapOffer}
onInsertGapSlot={handleInsertGapSlot}
onGenerateGapAi={openGapFillPrep}
generatingOfferId={generatingOfferId}
aiBusy={gapAiBusy}
evaluateDisabled={busy || !draft.goalQuery?.trim()}
/>
</div>
@ -511,6 +701,29 @@ export default function ProgressionGraphEditor({ graphId }) {
/>
) : null}
<ExerciseGapFillPrepModal
open={gapPrepOpen}
offer={activeOffer}
onClose={() => {
if (gapAiBusy) return
setGapPrepOpen(false)
setGapPrepError('')
}}
title={gapPrepTitle}
onTitleChange={setGapPrepTitle}
stageLearningGoal={gapPrepStageGoal}
onStageLearningGoalChange={setGapPrepStageGoal}
supplements={gapPrepSupplements}
onSupplementsChange={setGapPrepSupplements}
focusAreaId={gapPrepFocusAreaId}
onFocusAreaChange={setGapPrepFocusAreaId}
focusAreas={focusAreas}
contextLines={gapOfferContextDisplayLines(activeOffer, gapContextParams)}
error={gapPrepError}
busy={gapAiBusy}
onSubmit={submitGapFillPrep}
/>
<style>{`
@media (max-width: 900px) {
.progression-graph-editor-grid {

View File

@ -3,6 +3,7 @@
*/
import React from 'react'
import { Link } from 'react-router-dom'
import { ROADMAP_PHASES } from '../utils/progressionGraphDraft'
function exerciseLabel(entry) {
if (!entry || entry.kind === 'empty') return '— noch leer —'
@ -13,11 +14,17 @@ function exerciseLabel(entry) {
export default function ProgressionSlotCard({
slot,
slotIndex,
slotCount = 1,
onPickPrimary,
onPickSibling,
onClearPrimary,
onRemoveSibling,
onPatchLearningGoal,
onPatchPhase,
onMoveUp,
onMoveDown,
onRemoveSlot,
onInsertAfter,
disabled = false,
}) {
const { primary, siblings = [], phase, learning_goal: learningGoal } = slot
@ -34,31 +41,77 @@ export default function ProgressionSlotCard({
>
<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>
<h4 style={{ margin: 0, fontSize: '0.95rem' }}>Slot {slotIndex + 1}</h4>
</div>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap', alignItems: 'center' }}>
<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>
<button
type="button"
className="btn"
style={{ fontSize: '11px', padding: '2px 6px' }}
disabled={disabled || slotIndex === 0}
onClick={() => onMoveUp(slotIndex)}
title="Nach oben"
>
</button>
<button
type="button"
className="btn"
style={{ fontSize: '11px', padding: '2px 6px' }}
disabled={disabled || slotIndex >= slotCount - 1}
onClick={() => onMoveDown(slotIndex)}
title="Nach unten"
>
</button>
<button
type="button"
className="btn"
style={{ fontSize: '11px', padding: '2px 6px' }}
disabled={disabled || slotCount <= 2}
onClick={() => onRemoveSlot(slotIndex)}
title="Slot entfernen"
>
</button>
</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 className="form-row" style={{ marginTop: '10px', marginBottom: '8px', display: 'grid', gridTemplateColumns: '1fr 140px', gap: '8px' }}>
<div>
<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>
<label className="form-label">Phase</label>
<select
className="form-input"
value={phase || 'vertiefung'}
disabled={disabled}
onChange={(e) => onPatchPhase(slotIndex, e.target.value)}
>
{ROADMAP_PHASES.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
</div>
<div
@ -144,16 +197,27 @@ export default function ProgressionSlotCard({
))}
</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 style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
<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>
<button
type="button"
className="btn"
style={{ fontSize: '12px', padding: '4px 10px' }}
disabled={disabled || slotCount >= 10}
onClick={() => onInsertAfter(slotIndex)}
>
Slot darunter einfügen
</button>
</div>
</div>
</div>
)

View File

@ -2,9 +2,191 @@
* Progressionsgraph Slot-Editor Draft-Hydration und Speichern (Phase B).
*/
const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion']
export const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion']
export const SLOT_MAX = 10
export const PLANNING_ARTIFACT_SCHEMA = 1
const OFFER_SOURCE_LABELS = {
unfilled_gap: 'Lücke',
off_topic: 'Themenfremd',
llm_suggested: 'QS-Empfehlung',
roadmap_unfilled: 'Roadmap-Stufe',
}
export function offerSourceLabel(source) {
return OFFER_SOURCE_LABELS[source] || source || 'Angebot'
}
function createEmptySlot(index) {
const phase = ROADMAP_PHASES[Math.min(index, ROADMAP_PHASES.length - 1)]
return {
majorStepIndex: index,
phase,
learning_goal: '',
consolidates: [],
rationale: '',
load_profile: [],
success_criteria: [],
anti_patterns: [],
exercise_type: '',
primary: emptySlotExercise(),
siblings: [],
}
}
function majorStepFromSlot(slot, index) {
return {
index,
phase: slot.phase || ROADMAP_PHASES[Math.min(index, ROADMAP_PHASES.length - 1)],
learning_goal: slot.learning_goal || '',
consolidates: slot.consolidates || [],
rationale: slot.rationale || '',
load_profile: slot.load_profile || [],
success_criteria: slot.success_criteria || [],
anti_patterns: slot.anti_patterns || [],
exercise_type: slot.exercise_type || '',
}
}
export function reindexSlots(slots) {
return (slots || []).map((slot, i) => ({
...slot,
majorStepIndex: i,
primary: { ...slot.primary },
siblings: [...(slot.siblings || [])],
}))
}
/** progression_roadmap aus aktuellen Slots ableiten (nach Verschieben/Hinzufügen). */
export function syncProgressionRoadmapFromSlots(draft) {
const slots = reindexSlots(draft.slots || [])
const existing = draft.progressionRoadmap || {}
const major_steps = slots.map((s, i) => ({
index: i,
phase: s.phase || 'vertiefung',
learning_goal: (s.learning_goal || '').trim(),
consolidates: s.consolidates || [],
rationale: s.rationale || '',
}))
const stage_specs = slots.map((s, i) => ({
major_step_index: i,
learning_goal: (s.learning_goal || '').trim(),
load_profile: Array.isArray(s.load_profile) ? s.load_profile : [],
exercise_type: (s.exercise_type || '').trim(),
success_criteria: Array.isArray(s.success_criteria) ? s.success_criteria : [],
anti_patterns: Array.isArray(s.anti_patterns) ? s.anti_patterns : [],
}))
return {
...draft,
slots,
majorSteps: slots.map(majorStepFromSlot),
maxSteps: slots.length,
progressionRoadmap: {
...existing,
major_step_count: slots.length,
max_steps: slots.length,
roadmap: { ...(existing.roadmap || {}), major_steps },
stage_specs,
},
}
}
export function moveSlotInDraft(draft, slotIndex, direction) {
const slots = [...(draft.slots || [])]
const j = slotIndex + direction
if (j < 0 || j >= slots.length) return draft
const tmp = slots[slotIndex]
slots[slotIndex] = slots[j]
slots[j] = tmp
return syncProgressionRoadmapFromSlots({ ...draft, slots, dirty: true })
}
export function removeSlotFromDraft(draft, slotIndex) {
const slots = draft.slots || []
if (slots.length <= 2) return draft
const next = slots.filter((_, i) => i !== slotIndex)
return syncProgressionRoadmapFromSlots({ ...draft, slots: next, dirty: true })
}
export function insertSlotInDraft(draft, afterIndex, partial = {}) {
const slots = [...(draft.slots || [])]
if (slots.length >= SLOT_MAX) return draft
const insertAt = afterIndex < 0 ? 0 : Math.min(afterIndex + 1, slots.length)
const newSlot = {
...createEmptySlot(insertAt),
...partial,
primary: partial.primary || emptySlotExercise(),
siblings: partial.siblings || [],
}
slots.splice(insertAt, 0, newSlot)
return syncProgressionRoadmapFromSlots({ ...draft, slots, dirty: true })
}
export function addSlotToDraft(draft) {
const slots = draft.slots || []
if (slots.length >= SLOT_MAX) return draft
return syncProgressionRoadmapFromSlots({
...draft,
slots: [...slots, createEmptySlot(slots.length)],
dirty: true,
})
}
export function patchSlotInDraft(draft, slotIndex, patch) {
const slots = (draft.slots || []).map((s, i) => (i === slotIndex ? { ...s, ...patch } : s))
return syncProgressionRoadmapFromSlots({ ...draft, slots, dirty: true })
}
export function resolveOfferSlotIndex(draft, offer) {
if (offer?.roadmap_major_step_index != null && Number.isFinite(Number(offer.roadmap_major_step_index))) {
return Number(offer.roadmap_major_step_index)
}
if (offer?.replace_step_index != null && Number.isFinite(Number(offer.replace_step_index))) {
return Number(offer.replace_step_index)
}
if (offer?.insert_after_index != null && Number.isFinite(Number(offer.insert_after_index))) {
const after = Number(offer.insert_after_index)
if (offer.source === 'roadmap_unfilled') return after + 1
return Math.min(after + 1, (draft.slots?.length || 1) - 1)
}
return null
}
export function offerNeedsNewSlot(offer) {
return offer?.source === 'unfilled_gap' || offer?.source === 'llm_suggested'
}
export function offerCanExpandSlots(draft, offer) {
if (!offerNeedsNewSlot(offer)) return false
return (draft.slots?.length || 0) < SLOT_MAX
}
export function collectGapOffersFromApiResponse(res) {
const qa = res?.path_qa || {}
const merged = [
...(Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : []),
...(Array.isArray(qa?.gap_fill_offers) ? qa.gap_fill_offers : []),
]
const seen = new Set()
return merged.filter((offer) => {
const key = offer?.offer_id || `${offer?.source}-${offer?.title_hint}`
if (!key || seen.has(key)) return false
seen.add(key)
return true
})
}
export function slotsAsPathStepRows(draft) {
return (draft.slots || []).map((slot) => ({
exerciseId: slot.primary?.exerciseId ?? null,
exerciseTitle: slot.primary?.exerciseTitle || '',
roadmapMajorStepIndex: slot.majorStepIndex,
roadmapPhase: slot.phase,
roadmapLearningGoal: slot.learning_goal,
isAiProposal: slot.primary?.kind === 'proposal',
}))
}
export function emptySlotExercise() {
return {
kind: 'empty',
@ -487,34 +669,74 @@ export function applyMatchStepsToSlots(draft, apiSteps) {
}
}
return { ...draft, slots: nextSlots, dirty: true }
return syncProgressionRoadmapFromSlots({ ...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
const title =
offer?.proposal_title ||
offer?.title_hint ||
offer?.title ||
nextSlots[slotIndex].learning_goal ||
'KI-Vorschlag'
nextSlots[slotIndex].primary = proposalSlotExercise({
title: offer?.title_hint || offer?.title || 'KI-Vorschlag',
proposalKey: offer?.offer_id || offer?.proposal_key,
title,
proposalKey: offer?.proposal_key || offer?.offer_id || null,
aiSuggestion: aiSuggestion || offer?.ai_suggestion || null,
})
return { ...draft, slots: nextSlots, dirty: true }
return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true })
}
/**
* Angebot einem Slot zuordnen optional neuen Slot einfügen (Brücke / QS-Neuanlage).
*/
export function applyGapOfferToDraft(draft, offer, { slotIndex = null, insertNewSlot = false } = {}) {
let next = { ...draft }
if (insertNewSlot && offerNeedsNewSlot(offer)) {
const afterIdx = Number(offer?.insert_after_index)
if (Number.isFinite(afterIdx)) {
if ((next.slots?.length || 0) >= SLOT_MAX) return next
next = insertSlotInDraft(next, afterIdx, {
learning_goal: (offer?.title_hint || offer?.sketch || '').trim().split('\n')[0] || '',
phase: offer?.phase || offer?.gap?.expected_phase || 'vertiefung',
})
slotIndex = afterIdx + 1
}
}
const idx = slotIndex != null ? slotIndex : resolveOfferSlotIndex(next, offer)
if (idx == null || !Number.isFinite(idx) || idx < 0 || idx >= (next.slots?.length || 0)) {
return next
}
return applyGapOfferToSlot(next, idx, offer)
}
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)))
const synced = syncProgressionRoadmapFromSlots(draft)
const primarySteps = draftPrimaryChainSteps(synced)
const siblingPairs = draftSiblingEdgePairs(synced)
const artifact = buildPlanningArtifactFromDraft(synced)
if (edgeIds.length > 0) {
await api.deleteExerciseProgressionEdgesBatch(Number(graphId), edgeIds)
}
// Kanten frisch laden — hydrate-Arrays können nach Zwischen-Speichern veraltet sein.
const currentEdges = await api.listExerciseProgressionEdges(Number(graphId))
const nextEdgeIds = (currentEdges || [])
.filter((e) => (e.edge_type || 'next_exercise') === 'next_exercise')
.map((e) => e.id)
.filter((id) => Number.isFinite(Number(id)))
const siblingEdgeIds = (currentEdges || [])
.filter((e) => e.edge_type === 'sibling')
.map((e) => e.id)
.filter((id) => Number.isFinite(Number(id)))
let artifactPersisted = false
// Primärkette nur ersetzen, wenn mindestens zwei Bibliotheks-Übungen im Pfad sind.
// Sonst bestehende next_exercise-Kanten erhalten (nur Artefakt/Slots speichern).
if (primarySteps.length >= 2) {
if (nextEdgeIds.length > 0) {
await api.deleteExerciseProgressionEdgesBatch(Number(graphId), nextEdgeIds)
}
await api.createExerciseProgressionSequence(Number(graphId), {
steps: primarySteps.map((s) => ({
exercise_id: s.exerciseId,
@ -523,10 +745,12 @@ export async function saveProgressionGraphDraft(api, graphId, draft) {
segment_notes: primarySteps.slice(1).map(() => null),
...(artifact ? { planning_roadmap: artifact } : {}),
})
} else if (artifact) {
await api.updateExerciseProgressionGraph(Number(graphId), { planning_roadmap: artifact })
artifactPersisted = Boolean(artifact)
}
if (siblingEdgeIds.length > 0) {
await api.deleteExerciseProgressionEdgesBatch(Number(graphId), siblingEdgeIds)
}
for (const pair of siblingPairs) {
await api.createExerciseProgressionEdge(Number(graphId), {
from_exercise_id: pair.from.exerciseId,
@ -537,5 +761,9 @@ export async function saveProgressionGraphDraft(api, graphId, draft) {
})
}
if (artifact && !artifactPersisted) {
await api.updateExerciseProgressionGraph(Number(graphId), { planning_roadmap: artifact })
}
return { primaryCount: primarySteps.length, siblingCount: siblingPairs.length }
}