shinkan-jinkendo/frontend/src/utils/progressionGraphDraft.js
Lars 48d51c07c5
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m26s
Enhance Exercise Progression Graph Panel and Editor with New Features
- Refactored `ExerciseProgressionGraphPanel` to support a create dialog for new progression graphs, improving user experience.
- Integrated `ProgressionGraphListCard` for better visualization of existing graphs and streamlined management.
- Updated `ProgressionGraphEditor` to handle start/target analysis and improved draft hydration with AI suggestions.
- Added utility functions for managing structured responses from AI, enhancing the planning process.
- Incremented application version to reflect these updates.
2026-06-10 16:17:40 +02:00

940 lines
31 KiB
JavaScript

/**
* 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 || [])],
}))
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
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,
})
}
}
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) {
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
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) {
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)
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 }
}