All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m18s
- Added `tzdata` installation in the Dockerfile to support time zone handling in Linux environments. - Increased `PIP_DEFAULT_TIMEOUT` and added retry logic for pip installations to enhance reliability during dependency installation. - Updated `requirements.txt` to conditionally include `tzdata` for Windows platforms, ensuring compatibility across different operating systems.
968 lines
32 KiB
JavaScript
968 lines
32 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' && 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,
|
|
lastFindings: artifact?.last_findings || null,
|
|
primaryChainEdgeIds: primaryChain?.edges?.map((e) => e.id) || [],
|
|
siblingEdgeIds: siblingEdges.map((e) => e.id),
|
|
dirty: false,
|
|
}
|
|
}
|
|
|
|
export function buildPlanningArtifactFromDraft(draft, { lastFindings = undefined } = {}) {
|
|
const q = (draft.goalQuery || '').trim()
|
|
const progressionRoadmap = draft.progressionRoadmap || null
|
|
if (!q && !progressionRoadmap && !draft.slots?.length) return null
|
|
|
|
const slot_contents = (draft.slots || []).map((slot) => ({
|
|
major_step_index: slot.majorStepIndex,
|
|
primary: slotExerciseToApi(slot.primary),
|
|
siblings: (slot.siblings || []).map(slotExerciseToApi).filter((s) => s.kind !== 'empty'),
|
|
}))
|
|
|
|
const artifact = {
|
|
schema_version: PLANNING_ARTIFACT_SCHEMA,
|
|
goal_query: q,
|
|
start_situation: (draft.startSituation || '').trim() || null,
|
|
target_state: (draft.targetState || '').trim() || null,
|
|
roadmap_notes: (draft.roadmapNotes || '').trim() || null,
|
|
max_steps: Number(draft.maxSteps) || draft.slots?.length || 5,
|
|
progression_roadmap: progressionRoadmap,
|
|
path_skill_expectations: draft.pathSkillExpectations || null,
|
|
slot_contents,
|
|
}
|
|
|
|
const findings = lastFindings !== undefined ? lastFindings : draft.lastFindings
|
|
if (findings) artifact.last_findings = findings
|
|
|
|
return artifact
|
|
}
|
|
|
|
/** Befüllte Primärkette (nur library) für edges/sequence. */
|
|
export function draftPrimaryChainSteps(draft) {
|
|
const steps = []
|
|
for (const slot of draft.slots || []) {
|
|
if (slot.primary?.kind === 'library' && slot.primary.exerciseId != null) {
|
|
steps.push({
|
|
exerciseId: slot.primary.exerciseId,
|
|
variantId: slot.primary.variantId,
|
|
exerciseTitle: slot.primary.exerciseTitle,
|
|
majorStepIndex: slot.majorStepIndex,
|
|
phase: slot.phase,
|
|
learningGoal: slot.learning_goal,
|
|
})
|
|
}
|
|
}
|
|
return steps
|
|
}
|
|
|
|
export function draftSiblingEdgePairs(draft) {
|
|
const pairs = []
|
|
for (const slot of draft.slots || []) {
|
|
if (slot.primary?.kind !== 'library' || slot.primary.exerciseId == null) continue
|
|
for (const sib of slot.siblings || []) {
|
|
if (sib.kind !== 'library' || sib.exerciseId == null) continue
|
|
pairs.push({
|
|
from: { exerciseId: slot.primary.exerciseId, variantId: slot.primary.variantId },
|
|
to: { exerciseId: sib.exerciseId, variantId: sib.variantId },
|
|
})
|
|
}
|
|
}
|
|
return pairs
|
|
}
|
|
|
|
export function slotsToEvaluateSteps(draft) {
|
|
return (draft.slots || []).map((slot) => {
|
|
const p = slot.primary
|
|
if (p.kind === 'proposal') {
|
|
return {
|
|
exercise_id: null,
|
|
variant_id: null,
|
|
title: p.exerciseTitle,
|
|
is_ai_proposal: true,
|
|
ai_suggestion: p.aiSuggestion,
|
|
proposal_key: p.proposalKey,
|
|
roadmap_major_step_index: slot.majorStepIndex,
|
|
roadmap_phase: slot.phase,
|
|
roadmap_learning_goal: slot.learning_goal,
|
|
}
|
|
}
|
|
if (p.kind === 'library' && p.exerciseId != null) {
|
|
return {
|
|
exercise_id: p.exerciseId,
|
|
variant_id: p.variantId || null,
|
|
title: p.exerciseTitle,
|
|
is_ai_proposal: false,
|
|
roadmap_major_step_index: slot.majorStepIndex,
|
|
roadmap_phase: slot.phase,
|
|
roadmap_learning_goal: slot.learning_goal,
|
|
}
|
|
}
|
|
return {
|
|
exercise_id: null,
|
|
variant_id: null,
|
|
title: `(leer: ${slot.learning_goal || `Slot ${slot.majorStepIndex + 1}`})`,
|
|
is_ai_proposal: true,
|
|
roadmap_major_step_index: slot.majorStepIndex,
|
|
roadmap_phase: slot.phase,
|
|
roadmap_learning_goal: slot.learning_goal,
|
|
}
|
|
})
|
|
}
|
|
|
|
export function applyMatchStepsToSlots(draft, apiSteps) {
|
|
const steps = Array.isArray(apiSteps) ? apiSteps : []
|
|
const nextSlots = (draft.slots || []).map((slot) => ({
|
|
...slot,
|
|
primary: { ...slot.primary },
|
|
siblings: [...(slot.siblings || [])],
|
|
}))
|
|
|
|
const touchedMajors = new Set()
|
|
for (const step of steps) {
|
|
if (step.roadmap_major_step_index == null || !Number.isFinite(Number(step.roadmap_major_step_index))) {
|
|
continue
|
|
}
|
|
const idx = Number(step.roadmap_major_step_index)
|
|
if (idx < 0 || idx >= nextSlots.length) continue
|
|
touchedMajors.add(idx)
|
|
|
|
const isProposal = Boolean(step.is_ai_proposal) || step.exercise_id == null
|
|
const hasAiPayload = Boolean(step.ai_suggestion) || Boolean(step.proposal_key)
|
|
if (isProposal && !hasAiPayload) {
|
|
nextSlots[idx].primary = emptySlotExercise()
|
|
} else if (isProposal) {
|
|
nextSlots[idx].primary = proposalSlotExercise({
|
|
title: step.title || nextSlots[idx].learning_goal,
|
|
proposalKey: step.proposal_key,
|
|
aiSuggestion: step.ai_suggestion,
|
|
})
|
|
} else {
|
|
nextSlots[idx].primary = librarySlotExercise({
|
|
exerciseId: step.exercise_id,
|
|
exerciseTitle: step.title || `Übung #${step.exercise_id}`,
|
|
variantId: step.variant_id,
|
|
})
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < nextSlots.length; i += 1) {
|
|
if (!touchedMajors.has(i)) {
|
|
nextSlots[i].primary = emptySlotExercise()
|
|
}
|
|
}
|
|
|
|
return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true })
|
|
}
|
|
|
|
/** Lücken-Angebote in leere Slots legen; Panel nur für verbleibende Lücken. */
|
|
export function applyGapOffersFromResponse(draft, res, { replaceOffTopicSlots = false } = {}) {
|
|
let next = draft
|
|
if (res?.progression_roadmap) {
|
|
next = syncSlotPhasesFromRoadmap(next, res.progression_roadmap)
|
|
}
|
|
|
|
const offers = dedupeGapOffersBySlot(collectGapOffersFromApiResponse(res), next)
|
|
const placedIds = new Set()
|
|
|
|
for (const offer of offers) {
|
|
const idx = resolveOfferSlotIndex(next, offer)
|
|
if (idx == null || idx < 0 || idx >= (next.slots?.length || 0)) continue
|
|
const primary = next.slots[idx]?.primary
|
|
const isOffTopicReplace =
|
|
replaceOffTopicSlots &&
|
|
offer?.source === 'off_topic' &&
|
|
offer?.replace_step_index != null
|
|
if (!isOffTopicReplace) {
|
|
if (primary?.kind === 'library' && primary.exerciseId != null) continue
|
|
if (primary?.kind === 'proposal' && 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 }
|
|
}
|