/** * 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 export const EMPTY_PLANNING_CATALOG_CONTEXT = { focusAreas: [], styleDirections: [], trainingTypes: [], targetGroups: [], } function mapCatalogItemsFromApi(list) { if (!Array.isArray(list)) return [] return list .map((row) => ({ id: Number(row?.id), isPrimary: Boolean(row?.is_primary ?? row?.isPrimary), weight: row?.weight != null ? Number(row.weight) : 1, })) .filter((row) => Number.isFinite(row.id) && row.id > 0) } export function parsePlanningCatalogContextFromArtifact(artifact) { const raw = artifact?.planning_catalog_context if (!raw || typeof raw !== 'object') return { ...EMPTY_PLANNING_CATALOG_CONTEXT } return { focusAreas: mapCatalogItemsFromApi(raw.focus_areas), styleDirections: mapCatalogItemsFromApi(raw.style_directions), trainingTypes: mapCatalogItemsFromApi(raw.training_types), targetGroups: mapCatalogItemsFromApi(raw.target_groups), } } export function getCatalogSelectId(items) { const list = Array.isArray(items) ? items : [] const primary = list.find((x) => x.isPrimary) || list[0] return primary?.id != null && Number.isFinite(Number(primary.id)) ? String(primary.id) : '' } export function setCatalogSelectItems(_items, id) { const n = Number(id) if (!Number.isFinite(n) || n < 1) return [] return [{ id: n, isPrimary: true, weight: 1 }] } export function planningCatalogContextToApi(ctx) { const mapOut = (items) => (items || []) .filter((row) => row?.id != null && Number(row.id) > 0) .map((row) => ({ id: Number(row.id), is_primary: Boolean(row.isPrimary), weight: Number.isFinite(Number(row.weight)) ? Number(row.weight) : 1, })) const focus_areas = mapOut(ctx?.focusAreas) const style_directions = mapOut(ctx?.styleDirections) const training_types = mapOut(ctx?.trainingTypes) const target_groups = mapOut(ctx?.targetGroups) if (!focus_areas.length && !style_directions.length && !training_types.length && !target_groups.length) { return {} } return { planning_catalog_context: { focus_areas, style_directions, training_types, target_groups, }, } } /** 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' } const OPTIMIZATION_ACTION_LABELS = { rematch_slot: 'Slot neu matchen', bridge_or_gap_fill: 'Brücke / KI-Angebot', refine_stage_spec: 'Stufen-Spec verfeinern', review_roadmap: 'Roadmap prüfen', review: 'Prüfen', } export function optimizationHintActionLabel(action) { return OPTIMIZATION_ACTION_LABELS[action] || action || 'Hinweis' } /** LLM-Empfehlungen von technischen Fix-Hinweisen trennen (QS-UI). */ export function splitPathQaHints(pathQa) { const hints = Array.isArray(pathQa?.optimization_hints) ? pathQa.optimization_hints : [] const fixHints = hints.filter((h) => String(h?.issue || '') !== 'llm_recommendation') const highlightHints = hints.filter((h) => String(h?.issue || '') === 'llm_recommendation') const recommendations = Array.isArray(pathQa?.recommendations) ? pathQa.recommendations : [] const highlightTexts = [] const seen = new Set() for (const rec of recommendations) { const text = String(rec || '').trim() const key = text.toLowerCase() if (text && !seen.has(key)) { seen.add(key) highlightTexts.push({ text, source: 'recommendation' }) } } for (const hint of highlightHints) { const text = String(hint.reason || hint.title || '').trim() const key = text.toLowerCase() if (text && !seen.has(key)) { seen.add(key) highlightTexts.push({ text, source: 'hint', hint }) } } return { fixHints, highlightTexts } } export function pathQaQualityPercent(pathQa) { if (pathQa?.quality_score == null || !Number.isFinite(Number(pathQa.quality_score))) return null return Math.round(Number(pathQa.quality_score) * 100) } export function pathQaShowsStrongResult(pathQa) { const pct = pathQaQualityPercent(pathQa) if (pathQa?.overall_ok && pct != null && pct >= 85) return true return Boolean(pathQa?.overall_ok && pct != null && pct >= 80 && !(pathQa?.issues || []).length) } /** Slot-Index aus optimization_hint (roadmap_major_step_index oder step_index). */ export function resolveHintSlotIndex(hint, draft = null) { if (!hint || typeof hint !== 'object') return null const raw = hint.roadmap_major_step_index ?? hint.step_index if (raw == null || !Number.isFinite(Number(raw))) return null const idx = Number(raw) const slotCount = draft?.slots?.length if (slotCount != null && (idx < 0 || idx >= slotCount)) return null return idx } export function formatRematchLogEntry(entry) { if (!entry || typeof entry !== 'object') return '' const slot = Number.isFinite(Number(entry.roadmap_major_step_index)) ? `Slot ${Number(entry.roadmap_major_step_index) + 1}` : 'Slot' const round = entry.round != null ? ` (Runde ${entry.round})` : '' if (entry.action === 'replaced') { const from = entry.replaced_title || (entry.replaced_exercise_id ? `#${entry.replaced_exercise_id}` : '—') const to = entry.new_title || (entry.new_exercise_id ? `#${entry.new_exercise_id}` : '—') return `${slot}${round}: „${from}“ → „${to}“` } if (entry.action === 'rematch_unfilled') { return `${slot}${round}: kein passender Ersatz (${entry.reason || 'unfilled'})` } return `${slot}${round}: ${entry.reason || entry.action || 'Rematch'}` } export function formatRefineLogEntry(entry) { if (!entry || typeof entry !== 'object') return '' const slot = Number.isFinite(Number(entry.roadmap_major_step_index)) ? `Slot ${Number(entry.roadmap_major_step_index) + 1}` : 'Slot' const round = entry.round != null ? ` (Runde ${entry.round})` : '' const changes = Array.isArray(entry.changes) ? entry.changes.join('; ') : entry.reason return `${slot}${round}: Stufen-Spec geschärft — ${changes || 'refine_stage_spec'}` } export function hasRematchSlotHints(pathQa) { return (pathQa?.optimization_hints || []).some((h) => { const action = h?.action return action === 'rematch_slot' || action === 'refine_stage_spec' }) } 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 out = [] const seen = new Set() const add = (offer) => { if (!offer || typeof offer !== 'object') return const id = offer.offer_id || `${offer.source}-${offer.roadmap_major_step_index}` if (seen.has(id)) return seen.add(id) out.push(offer) } for (const offer of res?.gap_fill_offers || []) add(offer) for (const offer of res?.path_qa?.gap_fill_offers || []) add(offer) const stepSources = [ ...(res?.steps || []), ...(res?.proposed_steps || []), ...(res?.proposed_steps_pipeline || []), ] for (const step of stepSources) { if (step?.gap_offer) add(step.gap_offer) } return out } /** KI-Angebote aus einer oder mehreren Planungs-Antworten für leere Slots sammeln. */ export function mergeGapOffersForDraft(draft, ...responses) { const collected = [] for (const res of responses) { if (res) collected.push(...collectGapOffersFromApiResponse(res)) } return filterGapOffersForUnfilledSlots( draft, dedupeGapOffersBySlot(collected, draft), ) } /** 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' && p.aiSuggestion) 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 || '', title: slot.primary?.exerciseTitle || '', roadmap_major_step_index: slot.majorStepIndex, roadmapMajorStepIndex: slot.majorStepIndex, roadmap_phase: slot.phase, roadmapPhase: slot.phase, roadmap_learning_goal: slot.learning_goal, roadmapLearningGoal: slot.learning_goal, learning_goal: slot.learning_goal, success_criteria: Array.isArray(slot.success_criteria) ? slot.success_criteria : [], 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 const hasSavedSlotContents = Array.isArray(slotContents) && slotContents.length > 0 let primary = saved?.primary ? slotExerciseFromApi(saved.primary) : hasSavedSlotContents ? emptySlotExercise() : 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, planningCatalogContext: parsePlanningCatalogContextFromArtifact(artifact), 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 catalog = planningCatalogContextToApi(draft.planningCatalogContext || EMPTY_PLANNING_CATALOG_CONTEXT) if (catalog.planning_catalog_context) { artifact.planning_catalog_context = catalog.planning_catalog_context } 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 } /** Slot-Zuordnungen für Backend-Reconciliation (validiert, nicht blind gepinnt). */ export function slotsToSlotAssignments(draft) { return (draft.slots || []) .filter((slot) => slot.primary?.kind === 'library' && slot.primary.exerciseId != null) .map((slot) => ({ exercise_id: slot.primary.exerciseId, variant_id: slot.primary.variantId || null, title: slot.primary.exerciseTitle || null, is_ai_proposal: false, roadmap_major_step_index: slot.majorStepIndex, roadmap_phase: slot.phase || null, roadmap_learning_goal: slot.learning_goal || null, })) } /** Mindestens ein Bibliotheks-Slot belegt. */ export function draftHasLibrarySlotAssignments(draft) { return slotsToSlotAssignments(draft).length >= 1 } function normalizeCompareSlotTitle(title) { return (title || '').trim().toLowerCase() } function stepsByMajorIndex(steps) { const out = new Map() for (const step of steps || []) { if (step?.roadmap_major_step_index == null || !Number.isFinite(Number(step.roadmap_major_step_index))) { continue } out.set(Number(step.roadmap_major_step_index), step) } return out } function buildProgressionSlotDiffs(baselineSteps, proposedSteps) { const baseBy = stepsByMajorIndex(baselineSteps) const propBy = stepsByMajorIndex(proposedSteps) const indices = new Set([...baseBy.keys(), ...propBy.keys()]) const diffs = [] for (const midx of [...indices].sort((a, b) => a - b)) { const base = baseBy.get(midx) || {} const prop = propBy.get(midx) || {} const baseId = base.exercise_id const propId = prop.exercise_id if (baseId != null && propId != null && Number(baseId) === Number(propId)) continue const baseTitle = (base.title || '').trim() || null const propTitle = (prop.title || '').trim() || null diffs.push({ roadmap_major_step_index: midx, baseline_exercise_id: baseId != null ? Number(baseId) : null, baseline_title: baseTitle, proposed_exercise_id: propId != null ? Number(propId) : null, proposed_title: propTitle, baseline_slot_status: base.slot_status, proposed_slot_status: prop.slot_status, changed: baseId !== propId || baseTitle !== propTitle, }) } return diffs } function annotateCompareSlotDiffs(diffs) { return (diffs || []).map((raw) => { const bt = normalizeCompareSlotTitle(raw.baseline_title) const pt = normalizeCompareSlotTitle(raw.proposed_title) return { ...raw, trivial_id_swap: Boolean(bt && pt && bt === pt), } }) } function actionableCompareSlotDiffs(diffs) { return (diffs || []).filter((d) => !d.trivial_id_swap) } /** fill = leerer Slot + Bibliotheks-Treffer; replace = bestehende Übung tauschen; gap_only = nur KI-Angebot. */ export function compareDiffKind(diff) { if (!diff || diff.trivial_id_swap) return 'skip' const hasBase = diff.baseline_exercise_id != null const hasProp = diff.proposed_exercise_id != null if (!hasBase && hasProp) return 'fill' if (hasBase && hasProp) return 'replace' if (!hasBase && !hasProp) return 'gap_only' if (hasBase && !hasProp) return 'replace' return 'skip' } export function qualityDeltaPercent(diff) { const delta = diff?.quality_delta if (delta == null || !Number.isFinite(Number(delta))) return null return Math.round(Number(delta) * 100) } export function annotateCompareDiffKinds(diffs) { return (diffs || []).map((d) => ({ ...d, diff_kind: compareDiffKind(d), })) } /** Nur übernehmbare Verbesserungsvorschläge (Bibliothek oder KI-Angebot). */ export function compareDiffsForDialog(comparison) { const fromSuggestions = (comparison?.slot_suggestions || []).filter((s) => s?.improves_path) if (fromSuggestions.length > 0) { return fromSuggestions .map((s) => ({ ...s, diff_kind: suggestionDiffKind(s) })) .filter( (d) => d.proposed_exercise_id != null || (d.suggestion_type === 'ai_gap' && d.gap_offer), ) } if (Array.isArray(comparison?.slot_diffs_improving)) { return comparison.slot_diffs_improving.filter( (d) => d?.proposed_exercise_id != null && !d?.trivial_id_swap, ) } const diffs = annotateCompareDiffKinds( compareSlotDiffs(comparison, { actionableOnly: true }), ) return diffs.filter( (d) => (d.diff_kind === 'fill' || d.diff_kind === 'replace') && d.proposed_exercise_id != null, ) } export function suggestionDiffKind(suggestion) { if (!suggestion) return 'skip' if (suggestion.suggestion_type === 'ai_gap') return 'ai_gap' if (suggestion.baseline_exercise_id == null && suggestion.proposed_exercise_id != null) { return 'fill' } if (suggestion.baseline_exercise_id != null && suggestion.proposed_exercise_id != null) { return 'replace' } return 'skip' } export function recommendedCompareDiffs(comparison) { return compareDiffsForDialog(comparison) } export function optionalReplaceCompareDiffs(comparison) { return [] } export function rejectedCompareDiffs(comparison) { return Array.isArray(comparison?.slot_diffs_rejected) ? comparison.slot_diffs_rejected : [] } export function gapOnlyCompareDiffs(comparison) { return annotateCompareDiffKinds( compareSlotDiffs(comparison, { actionableOnly: true }), ).filter((d) => d.diff_kind === 'gap_only') } export function defaultSelectedCompareDiffs(comparison) { return compareDiffsForDialog(comparison).map((d) => Number(d.roadmap_major_step_index)) } function mergeGapFillOffersFromSteps(steps, offers) { const merged = (offers || []).map((o) => ({ ...o })) const seen = new Set(merged.map((o) => o.offer_id).filter(Boolean)) for (const step of steps || []) { const go = step?.gap_offer if (!go || typeof go !== 'object') continue if (go.offer_id && seen.has(go.offer_id)) continue if (go.offer_id) seen.add(go.offer_id) merged.push({ ...go }) } return merged } /** * Vergleich aus unified_slot_review oder kaskadierten Antworten (Evaluate → Match). */ export function buildProgressionComparePayload(baselineRes, proposedRes) { if (proposedRes?.unified_slot_review) { return buildUnifiedSlotReviewComparePayload(proposedRes, baselineRes) } const baselineSteps = Array.isArray(baselineRes?.steps) ? baselineRes.steps : [] const proposedSteps = Array.isArray(proposedRes?.steps) ? proposedRes.steps : [] const baselineQa = baselineRes?.path_qa || null const pipelineQa = proposedRes?.path_qa || null const scoring = proposedRes?.slot_diff_scoring const rawDiffs = annotateCompareDiffKinds( annotateCompareSlotDiffs( buildProgressionSlotDiffs(baselineSteps, proposedSteps), ), ) const improvingDiffs = annotateCompareDiffKinds( (scoring?.improvement_diffs || []).filter((d) => d?.proposed_exercise_id != null), ) const rejectedDiffs = annotateCompareDiffKinds(scoring?.rejected_diffs || []) const dialogDiffs = improvingDiffs.length > 0 ? improvingDiffs : rawDiffs.filter( (d) => !d.trivial_id_swap && (d.diff_kind === 'fill' || d.diff_kind === 'replace') && d.proposed_exercise_id != null && d.improves_path !== false, ) const actionableDiffs = dialogDiffs const gapFillOffers = mergeGapFillOffersFromSteps( proposedSteps, proposedRes?.gap_fill_offers || [], ) const baselineScore = scoring?.baseline_quality_score ?? baselineQa?.quality_score const proposedQa = baselineQa return { ...proposedRes, comparison_mode: true, baseline_steps: baselineSteps, baseline_path_qa: baselineQa, proposed_steps: proposedSteps, proposed_steps_pipeline: proposedSteps, proposed_path_qa: proposedQa, proposed_path_qa_pipeline: pipelineQa, gap_fill_offers: gapFillOffers, slot_diffs: rawDiffs, slot_diffs_actionable: actionableDiffs, slot_diffs_improving: improvingDiffs, slot_diffs_rejected: rejectedDiffs, slot_diffs_dialog: dialogDiffs, slot_diffs_recommended: dialogDiffs, slot_diff_count: dialogDiffs.length, slot_diff_count_recommended: dialogDiffs.length, slot_diff_count_rejected: rejectedDiffs.length, slot_diff_count_including_trivial: rawDiffs.length, slot_diffs_source: scoring ? 'incremental_scoring' : 'steps', slot_diff_scoring: scoring, baseline_quality_score: baselineScore, path_qa: proposedQa, steps: proposedSteps, } } /** Einheitlicher Match-Review (Bewertung + Slot-Vorschläge in einem Lauf). */ export function buildUnifiedSlotReviewComparePayload(res, baselineRes = null) { const baselineSteps = Array.isArray(baselineRes?.steps) ? baselineRes.steps : (Array.isArray(res?.baseline_steps) ? res.baseline_steps : (res?.steps || [])) const baselineQa = baselineRes?.path_qa || res?.baseline_path_qa || res?.path_qa || null const scoring = res?.slot_diff_scoring const suggestions = Array.isArray(res?.slot_suggestions) ? res.slot_suggestions : [] const improving = suggestions.filter((s) => s?.improves_path) const rejected = Array.isArray(scoring?.rejected_diffs) ? scoring.rejected_diffs : [] const proposedSteps = improving.map(suggestionToApplyStep).filter(Boolean) const gapFillOffers = Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : [] return { ...res, comparison_mode: true, unified_slot_review: true, baseline_steps: baselineSteps, baseline_path_qa: baselineQa, proposed_steps: proposedSteps, proposed_steps_pipeline: proposedSteps, proposed_path_qa: baselineQa, proposed_path_qa_pipeline: null, gap_fill_offers: gapFillOffers, slot_suggestions: suggestions, slot_diffs: improving, slot_diffs_improving: improving, slot_diffs_rejected: rejected, slot_diffs_dialog: improving, slot_diffs_recommended: improving, slot_diff_count: improving.length, slot_diff_count_recommended: improving.length, slot_diff_count_rejected: rejected.length, slot_diffs_source: 'unified_slot_review', slot_diff_scoring: scoring, baseline_quality_score: scoring?.baseline_quality_score ?? baselineQa?.quality_score, path_qa: baselineQa, steps: baselineSteps, } } function suggestionToApplyStep(suggestion) { if (!suggestion || suggestion.roadmap_major_step_index == null) return null const midx = Number(suggestion.roadmap_major_step_index) if (suggestion.suggestion_type === 'ai_gap' && suggestion.gap_offer) { const offer = suggestion.gap_offer return { roadmap_major_step_index: midx, exercise_id: null, title: offer.title_hint || suggestion.proposed_title || `Slot ${midx + 1}`, is_ai_proposal: true, proposal_key: offer.offer_id || `roadmap-unfilled-${midx}`, gap_offer: offer, slot_status: 'ai_proposal', } } if (suggestion.proposed_exercise_id == null) return null return { roadmap_major_step_index: midx, exercise_id: suggestion.proposed_exercise_id, title: suggestion.proposed_title, slot_status: suggestion.proposed_slot_status || 'matched', is_ai_proposal: false, } } /** Ausgewählte Slot-Vorschläge aus unified review übernehmen. */ export function applySelectedSlotSuggestions(draft, comparison, selectedMajorIndices) { const selected = new Set( (selectedMajorIndices || []) .map((x) => Number(x)) .filter((x) => Number.isFinite(x)), ) if (!selected.size) return draft const steps = (comparison?.slot_suggestions || []) .filter((s) => selected.has(Number(s.roadmap_major_step_index))) .map(suggestionToApplyStep) .filter(Boolean) if (!steps.length) { return applySelectedCompareSteps( draft, comparison?.proposed_steps || comparison?.steps, selectedMajorIndices, ) } return applyMatchStepsToSlots(draft, steps) } /** Alle Slot-Diffs inkl. reiner ID-Tausche (gleicher Titel). */ export function compareSlotDiffs(comparison, { actionableOnly = false } = {}) { if (actionableOnly && Array.isArray(comparison?.slot_diffs_actionable)) { return comparison.slot_diffs_actionable } return Array.isArray(comparison?.slot_diffs) ? comparison.slot_diffs : [] } /** Inhaltliche Abweichungen (nicht nur gleicher Titel, andere ID). */ export function compareResponseHasActionableSlotChanges(res) { const count = res?.slot_diff_count if (count != null) return Number(count) > 0 return compareSlotDiffs(res, { actionableOnly: true }).length > 0 } /** Diff-Einträge nur für Slots, die vorher schon eine Bibliotheks-Übung hatten. */ export function curatedSlotDiffs(comparison, { actionableOnly = true } = {}) { return compareSlotDiffs(comparison, { actionableOnly }).filter( (d) => d?.baseline_exercise_id != null, ) } /** Vergleich würde eine bestehende Zuordnung inhaltlich ändern (Dialog bei Match). */ export function compareResponseHasCuratedSlotChanges(res) { return curatedSlotDiffs(res, { actionableOnly: true }).length > 0 } export function compareResponseHadRematchWithoutActionableDiffs(res) { if (compareResponseHasActionableSlotChanges(res)) return false const rematch = res?.proposed_path_qa_pipeline?.rematch_log return Array.isArray(rematch) && rematch.length > 0 } /** Alle Graph-Übungs-IDs für Retriever-Boost (Slots + Geschwister + gespeichertes Artefakt). */ export function draftRetrievalBoostExerciseIds(draft) { const ids = new Set() for (const slot of draft.slots || []) { const p = slot.primary if (p?.kind === 'library' && p.exerciseId != null) ids.add(p.exerciseId) for (const sib of slot.siblings || []) { if (sib.kind === 'library' && sib.exerciseId != null) ids.add(sib.exerciseId) } } const saved = draft?.slot_contents || draft?.planningArtifact?.slot_contents if (Array.isArray(saved)) { for (const raw of saved) { const eid = raw?.primary?.exercise_id ?? raw?.exercise_id if (eid != null && Number.isFinite(Number(eid))) ids.add(Number(eid)) } } return [...ids] } 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 stepByMajor = new Map() for (const step of steps) { if (step.roadmap_major_step_index == null || !Number.isFinite(Number(step.roadmap_major_step_index))) { continue } stepByMajor.set(Number(step.roadmap_major_step_index), step) } const mapStepToPrimary = (step, slot) => { const midx = Number(slot.majorStepIndex) const isProposal = Boolean(step.is_ai_proposal) || step.exercise_id == null const hasAiPayload = Boolean(step.ai_suggestion) || Boolean(step.proposal_key) const isUnfilledSlot = step.slot_status === 'unfilled' || step.slot_status === 'stripped' || step.roadmap_match_source === 'unfilled' || Boolean(step.gap_offer) if (isProposal && !hasAiPayload && isUnfilledSlot) { const offer = step.gap_offer || {} return proposalSlotExercise({ title: offer.title_hint || step.roadmap_learning_goal || step.title || slot.learning_goal || `Slot ${midx + 1}`, proposalKey: offer.offer_id || step.proposal_key || `roadmap-unfilled-${midx}`, aiSuggestion: offer.ai_suggestion || null, }) } if (isProposal && !hasAiPayload) { return emptySlotExercise() } if (isProposal) { return proposalSlotExercise({ title: step.title || slot.learning_goal, proposalKey: step.proposal_key, aiSuggestion: step.ai_suggestion, }) } return librarySlotExercise({ exerciseId: step.exercise_id, exerciseTitle: step.title || `Übung #${step.exercise_id}`, variantId: step.variant_id, }) } const nextSlots = (draft.slots || []).map((slot) => { const base = { ...slot, primary: { ...slot.primary }, siblings: [...(slot.siblings || [])], } const step = stepByMajor.get(Number(slot.majorStepIndex)) if (!step) { return base } const mappedPrimary = mapStepToPrimary(step, slot) const apiUnfilled = step.exercise_id == null && (step.slot_status === 'unfilled' || step.roadmap_match_source === 'unfilled' || mappedPrimary.kind === 'empty') if ( apiUnfilled && slot.primary?.kind === 'library' && slot.primary.exerciseId != null ) { return base } return { ...base, primary: mappedPrimary, } }) return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true }) } /** Vergleichs-Antwort: mindestens ein inhaltlicher Slot-Unterschied. */ export function compareResponseHasSlotChanges(res) { return compareResponseHasActionableSlotChanges(res) } /** Nur ausgewählte Slots aus Optimierungs-Vorschlag übernehmen. */ export function applySelectedCompareSteps(draft, proposedSteps, selectedMajorIndices) { const selected = new Set( (selectedMajorIndices || []) .map((x) => Number(x)) .filter((x) => Number.isFinite(x)), ) if (!selected.size) return draft const stepByMajor = new Map() for (const step of proposedSteps || []) { if (step?.roadmap_major_step_index == null) continue stepByMajor.set(Number(step.roadmap_major_step_index), step) } const nextSlots = (draft.slots || []).map((slot) => { const midx = Number(slot.majorStepIndex) if (!selected.has(midx)) { return { ...slot, primary: { ...slot.primary }, siblings: [...(slot.siblings || [])] } } const step = stepByMajor.get(midx) if (!step) return slot const patched = applyMatchStepsToSlots({ ...draft, slots: [slot] }, [step]) return patched.slots[0] }) 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' && primary.aiSuggestion) 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 } }