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