/** * Progressionsgraph Slot-Editor — Draft-Hydration und Speichern (Phase B). */ export const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion'] export const SLOT_MAX = 10 export const SLOT_MIN = 2 export const PLANNING_ARTIFACT_SCHEMA = 1 /** Start/Ziel/Ergänzungen aus KI-Roadmap-Antwort (resolved_structured). */ export function resolvedStructuredFromRoadmap(progressionRoadmap) { const rs = progressionRoadmap?.resolved_structured if (!rs) return null const patch = {} if (rs.start_situation) patch.startSituation = String(rs.start_situation) if (rs.target_state) patch.targetState = String(rs.target_state) if (rs.roadmap_notes) patch.roadmapNotes = String(rs.roadmap_notes) return Object.keys(patch).length ? patch : null } export function applyResolvedStructuredToDraft(draft, progressionRoadmap, { onlyIfEmpty = false } = {}) { const patch = resolvedStructuredFromRoadmap(progressionRoadmap) if (!patch) return draft const next = { ...draft } if (patch.startSituation && (!onlyIfEmpty || !(draft.startSituation || '').trim())) { next.startSituation = patch.startSituation } if (patch.targetState && (!onlyIfEmpty || !(draft.targetState || '').trim())) { next.targetState = patch.targetState } if (patch.roadmapNotes && (!onlyIfEmpty || !(draft.roadmapNotes || '').trim())) { next.roadmapNotes = patch.roadmapNotes } return { ...next, dirty: true } } export function graphGoalQueryFromRow(graph) { const art = graph?.planning_roadmap if (!art || typeof art !== 'object') return '' return (art.goal_query || '').trim() } export function graphSlotCountFromRow(graph) { const art = graph?.planning_roadmap if (!art || typeof art !== 'object') return null const slots = art.slot_contents if (Array.isArray(slots) && slots.length) return slots.length const majors = art.progression_roadmap?.roadmap?.major_steps if (Array.isArray(majors) && majors.length) return majors.length const ms = Number(art.max_steps) return Number.isFinite(ms) && ms > 0 ? ms : null } 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 } const GAP_OFFER_SOURCE_PRIORITY = { roadmap_unfilled: 0, unfilled_gap: 1, llm_suggested: 2, off_topic: 3, } export function collectGapOffersFromApiResponse(res) { const top = Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : [] if (top.length) return top const qa = res?.path_qa || {} return Array.isArray(qa?.gap_fill_offers) ? qa.gap_fill_offers : [] } /** Maximal ein Angebot pro Slot — Roadmap-Lücken vor Brücken/QS. */ export function dedupeGapOffersBySlot(offers, draft) { const bySlot = new Map() for (const offer of offers || []) { const idx = resolveOfferSlotIndex(draft, offer) if (idx == null || !Number.isFinite(idx) || idx < 0) continue const existing = bySlot.get(idx) const prio = GAP_OFFER_SOURCE_PRIORITY[offer?.source] ?? 9 const existingPrio = existing ? (GAP_OFFER_SOURCE_PRIORITY[existing?.source] ?? 9) : 99 if (!existing || prio < existingPrio) { bySlot.set(idx, offer) } } return Array.from(bySlot.entries()) .sort(([a], [b]) => a - b) .map(([, offer]) => offer) } /** Angebote nur für Slots ohne belegte Primary (Bibliothek oder KI-Entwurf). */ export function filterGapOffersForUnfilledSlots(draft, offers) { return (offers || []).filter((offer) => { const idx = resolveOfferSlotIndex(draft, offer) if (idx == null || idx < 0 || idx >= (draft?.slots?.length || 0)) return true const p = draft.slots[idx]?.primary if (p?.kind === 'library' && p.exerciseId != null) return false if (p?.kind === 'proposal') return false return true }) } export function syncSlotPhasesFromRoadmap(draft, progressionRoadmap) { if (!progressionRoadmap) return draft const majors = mapMajorStepsFromApi(progressionRoadmap) if (!majors.length) return draft const slots = (draft.slots || []).map((slot, i) => { const m = majors[i] if (!m) return slot return { ...slot, phase: m.phase || slot.phase, learning_goal: m.learning_goal || slot.learning_goal, load_profile: m.load_profile?.length ? m.load_profile : slot.load_profile, success_criteria: m.success_criteria?.length ? m.success_criteria : slot.success_criteria, anti_patterns: m.anti_patterns?.length ? m.anti_patterns : slot.anti_patterns, exercise_type: m.exercise_type || slot.exercise_type, } }) return syncProgressionRoadmapFromSlots({ ...draft, slots, progressionRoadmap, majorSteps: majors, maxSteps: Math.max(slots.length, majors.length), }) } 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', 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 || [])], })) const touchedMajors = new Set() for (const step of steps) { if (step.roadmap_major_step_index == null || !Number.isFinite(Number(step.roadmap_major_step_index))) { continue } const idx = Number(step.roadmap_major_step_index) if (idx < 0 || idx >= nextSlots.length) continue touchedMajors.add(idx) const isProposal = Boolean(step.is_ai_proposal) || step.exercise_id == null if (isProposal) { nextSlots[idx].primary = proposalSlotExercise({ title: step.title || nextSlots[idx].learning_goal, 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, }) } } for (let i = 0; i < nextSlots.length; i += 1) { if (!touchedMajors.has(i)) { nextSlots[i].primary = emptySlotExercise() } } return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true }) } /** Lücken-Angebote in leere Slots legen; Panel nur für verbleibende Lücken. */ export function applyGapOffersFromResponse(draft, res, { replaceOffTopicSlots = false } = {}) { let next = draft if (res?.progression_roadmap) { next = syncSlotPhasesFromRoadmap(next, res.progression_roadmap) } const offers = dedupeGapOffersBySlot(collectGapOffersFromApiResponse(res), next) const placedIds = new Set() for (const offer of offers) { const idx = resolveOfferSlotIndex(next, offer) if (idx == null || idx < 0 || idx >= (next.slots?.length || 0)) continue const primary = next.slots[idx]?.primary const isOffTopicReplace = replaceOffTopicSlots && offer?.source === 'off_topic' && offer?.replace_step_index != null if (!isOffTopicReplace) { if (primary?.kind === 'library' && primary.exerciseId != null) continue if (primary?.kind === 'proposal') continue } next = applyGapOfferToSlot(next, idx, offer) if (offer?.offer_id) placedIds.add(offer.offer_id) } const remainingOffers = filterGapOffersForUnfilledSlots( next, dedupeGapOffersBySlot( collectGapOffersFromApiResponse(res).filter((o) => !placedIds.has(o?.offer_id)), next, ), ) return { draft: { ...next, dirty: true }, remainingOffers } } /** Match-Antwort: Schritte + Lücken-Angebote direkt in Slots (wie früher im Pfad-Wizard sichtbar). */ export function applyMatchResponseToDraft(draft, res, { replaceOffTopicSlots = true } = {}) { let next = applyMatchStepsToSlots(draft, res?.steps) if (res?.progression_roadmap) { next = { ...syncSlotPhasesFromRoadmap(next, res.progression_roadmap), pathSkillExpectations: res?.path_skill_expectations || next.pathSkillExpectations, } } const { draft: withOffers, remainingOffers } = applyGapOffersFromResponse(next, res, { replaceOffTopicSlots, }) return { draft: withOffers, remainingOffers } } /** Evaluate-Antwort: KI-Angebote in leere Slots (ohne Schritte neu zu matchen). */ export function applyEvaluateResponseToDraft(draft, res) { return applyGapOffersFromResponse(draft, res) } export function setSlotPrimaryLibrary(draft, slotIndex, exercise) { if (!exercise?.id) return draft const slots = (draft.slots || []).map((s, i) => i === slotIndex ? { ...s, primary: librarySlotExercise({ exerciseId: exercise.id, exerciseTitle: exercise.title || `Übung #${exercise.id}`, }), } : s, ) return syncProgressionRoadmapFromSlots({ ...draft, slots, 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, proposalKey: offer?.proposal_key || offer?.offer_id || null, aiSuggestion: aiSuggestion || offer?.ai_suggestion || null, }) 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 synced = syncProgressionRoadmapFromSlots(draft) const primarySteps = draftPrimaryChainSteps(synced) const siblingPairs = draftSiblingEdgePairs(synced) const artifact = buildPlanningArtifactFromDraft(synced) // 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, variant_id: s.variantId || null, })), segment_notes: primarySteps.slice(1).map(() => null), ...(artifact ? { 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, from_exercise_variant_id: pair.from.variantId, to_exercise_id: pair.to.exerciseId, to_exercise_variant_id: pair.to.variantId, edge_type: 'sibling', }) } if (artifact && !artifactPersisted) { await api.updateExerciseProgressionGraph(Number(graphId), { planning_roadmap: artifact }) } return { primaryCount: primarySteps.length, siblingCount: siblingPairs.length } }