shinkan-jinkendo/frontend/src/utils/progressionGraphDraft.js

542 lines
17 KiB
JavaScript
Raw Normal View History

/**
* 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 }
}