/** * Integrierter Slot-Editor für Progressionsgraphen (Phase B). */ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import api from '../utils/api' import ExercisePickerModal from './ExercisePickerModal' import ProgressionSlotCard from './ProgressionSlotCard' import ProgressionFindingsPanel from './ProgressionFindingsPanel' import { applyGapOfferToSlot, applyMatchStepsToSlots, buildPlanningArtifactFromDraft, hydrateProgressionGraphDraft, librarySlotExercise, majorStepsToOverridePayload, reindexMajorSteps, saveProgressionGraphDraft, slotsToEvaluateSteps, } from '../utils/progressionGraphDraft' const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion'] function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) { const body = {} const start = (startSituation || '').trim() const target = (targetState || '').trim() const notes = (roadmapNotes || '').trim() if (start) body.start_situation = start if (target) body.target_state = target if (notes) body.roadmap_notes = notes return body } export default function ProgressionGraphEditor({ graphId }) { const [graphMeta, setGraphMeta] = useState(null) const [draft, setDraft] = useState(null) const [busy, setBusy] = useState(false) const [loadErr, setLoadErr] = useState('') const [actionErr, setActionErr] = useState('') const [pickContext, setPickContext] = useState(null) const [pathQa, setPathQa] = useState(null) const [gapFillOffers, setGapFillOffers] = useState([]) const [evaluating, setEvaluating] = useState(false) const [matching, setMatching] = useState(false) const [roadmapLoading, setRoadmapLoading] = useState(false) const loadGraph = useCallback(async () => { if (!graphId) return setBusy(true) setLoadErr('') try { const [graph, edges] = await Promise.all([ api.getExerciseProgressionGraph(Number(graphId)), api.listExerciseProgressionEdges(Number(graphId)), ]) setGraphMeta(graph) setDraft( hydrateProgressionGraphDraft({ artifact: graph?.planning_roadmap, edges: Array.isArray(edges) ? edges : [], graphName: graph?.name, }), ) const findings = graph?.planning_roadmap?.last_findings if (findings) setPathQa(findings) } catch (e) { setLoadErr(e.message || 'Graph konnte nicht geladen werden') setDraft(null) } finally { setBusy(false) } }, [graphId]) useEffect(() => { loadGraph() }, [loadGraph]) const patchDraft = useCallback((patchFn) => { setDraft((prev) => { if (!prev) return prev const next = patchFn(prev) return { ...next, dirty: true } }) }, []) const handlePickExercise = async (exercise) => { if (!pickContext || !exercise?.id) return const { slotIndex, role } = pickContext const entry = librarySlotExercise({ exerciseId: exercise.id, exerciseTitle: exercise.title || `Übung #${exercise.id}`, }) patchDraft((d) => { const slots = d.slots.map((s, i) => { if (i !== slotIndex) return s if (role === 'primary') return { ...s, primary: entry } const siblings = [...(s.siblings || [])] if (!siblings.some((x) => x.exerciseId === entry.exerciseId)) siblings.push(entry) return { ...s, siblings } }) return { ...d, slots } }) setPickContext(null) } const handlePatchLearningGoal = (slotIndex, value) => { patchDraft((d) => { const slots = d.slots.map((s, i) => (i === slotIndex ? { ...s, learning_goal: value } : s)) const majorSteps = reindexMajorSteps( (d.majorSteps || []).map((m, i) => (i === slotIndex ? { ...m, learning_goal: value } : m)), ) return { ...d, slots, majorSteps } }) } const handleClearPrimary = (slotIndex) => { patchDraft((d) => { const slots = d.slots.map((s, i) => i === slotIndex ? { ...s, primary: { kind: 'empty', exerciseId: null, variantId: null, exerciseTitle: '', variantName: null, proposalKey: null, aiSuggestion: null }, siblings: [] } : s, ) return { ...d, slots } }) } const handleRemoveSibling = (slotIndex, sibIdx) => { patchDraft((d) => { const slots = d.slots.map((s, i) => { if (i !== slotIndex) return s return { ...s, siblings: s.siblings.filter((_, j) => j !== sibIdx) } }) return { ...d, slots } }) } const handleAddSlot = () => { patchDraft((d) => { const idx = d.slots.length const phase = ROADMAP_PHASES[Math.min(idx, ROADMAP_PHASES.length - 1)] const slot = { majorStepIndex: idx, phase, learning_goal: '', consolidates: [], rationale: '', load_profile: [], success_criteria: [], anti_patterns: [], exercise_type: '', primary: { kind: 'empty', exerciseId: null, variantId: null, exerciseTitle: '', variantName: null, proposalKey: null, aiSuggestion: null }, siblings: [], } const major = { index: idx, phase, learning_goal: '', consolidates: [], rationale: '', load_profile: [], success_criteria: [], anti_patterns: [], exercise_type: '', } return { ...d, slots: [...d.slots, slot], majorSteps: [...(d.majorSteps || []), major], maxSteps: Math.max(d.maxSteps, idx + 1), } }) } const validMajorSteps = useMemo(() => { if (!draft?.slots) return [] return draft.slots.filter((s) => (s.learning_goal || '').trim().length >= 3) }, [draft?.slots]) const runRoadmapGenerate = async () => { const q = (draft?.goalQuery || '').trim() if (q.length < 3) { alert('Ziel-Anfrage: mindestens 3 Zeichen.') return } setRoadmapLoading(true) setActionErr('') try { const res = await api.suggestProgressionPath({ query: q, max_steps: draft.maxSteps || 5, include_llm_intent: true, include_path_qa: false, include_llm_path_qa: false, include_path_reorder: false, include_ai_gap_fill: false, include_roadmap_preview: true, include_llm_roadmap: true, roadmap_only: true, progression_graph_id: Number(graphId), ...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes), }) const roadmap = res?.progression_roadmap if (!roadmap) throw new Error('Keine Roadmap in der Antwort') const majors = (roadmap?.roadmap?.major_steps || []).map((s, i) => ({ index: i, phase: s.phase || ROADMAP_PHASES[Math.min(i, ROADMAP_PHASES.length - 1)], 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 hydrated = hydrateProgressionGraphDraft({ artifact: { ...buildPlanningArtifactFromDraft({ ...draft, progressionRoadmap: roadmap }), progression_roadmap: roadmap, }, edges: [], graphName: draft.graphName, }) setDraft({ ...hydrated, goalQuery: q, dirty: true }) } catch (e) { setActionErr(e.message || 'Roadmap-Generierung fehlgeschlagen') } finally { setRoadmapLoading(false) } } const runMatch = async () => { const q = (draft?.goalQuery || '').trim() if (q.length < 3) { alert('Ziel-Anfrage: mindestens 3 Zeichen.') return } if (validMajorSteps.length < 2) { alert('Mindestens zwei Slots mit Lernziel (je 3+ Zeichen) nötig.') return } setMatching(true) setActionErr('') try { const override = majorStepsToOverridePayload(draft.slots.map((s) => ({ index: s.majorStepIndex, 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, }))) const res = await api.suggestProgressionPath({ query: q, max_steps: validMajorSteps.length, include_llm_intent: true, include_path_qa: true, include_llm_path_qa: true, include_path_reorder: false, include_ai_gap_fill: true, include_roadmap_preview: true, include_llm_roadmap: false, roadmap_first: true, roadmap_override: override, progression_graph_id: Number(graphId), ...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes), }) const next = applyMatchStepsToSlots( { ...draft, progressionRoadmap: res?.progression_roadmap || draft.progressionRoadmap, pathSkillExpectations: res?.path_skill_expectations || draft.pathSkillExpectations, }, res?.steps, ) setDraft(next) setPathQa(res?.path_qa || null) setGapFillOffers(Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : []) } catch (e) { setActionErr(e.message || 'Übungs-Match fehlgeschlagen') } finally { setMatching(false) } } const runEvaluate = async () => { const q = (draft?.goalQuery || '').trim() if (q.length < 3) { alert('Ziel-Anfrage: mindestens 3 Zeichen.') return } setEvaluating(true) setActionErr('') try { const override = validMajorSteps.length >= 2 ? majorStepsToOverridePayload( draft.slots.map((s) => ({ index: s.majorStepIndex, 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, })), ) : undefined const res = await api.suggestProgressionPath({ query: q, max_steps: draft.slots.length || draft.maxSteps || 5, include_path_qa: true, include_llm_path_qa: true, include_ai_gap_fill: true, include_path_reorder: false, include_llm_intent: false, evaluate_only: true, evaluate_steps: slotsToEvaluateSteps(draft), roadmap_override: override, progression_graph_id: Number(graphId), ...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes), }) setPathQa(res?.path_qa || null) setGapFillOffers(Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : []) setDraft((prev) => (prev ? { ...prev, lastFindings: res?.path_qa || null } : prev)) } catch (e) { setActionErr(e.message || 'Bewertung fehlgeschlagen') } finally { setEvaluating(false) } } const handleSave = async () => { if (!draft || !graphId) return setBusy(true) setActionErr('') try { await saveProgressionGraphDraft(api, graphId, { ...draft, lastFindings: pathQa }) await loadGraph() alert('Progressionsgraph gespeichert.') } catch (e) { setActionErr(e.message || 'Speichern fehlgeschlagen') } finally { setBusy(false) } } const handleApplyGapOffer = (offer) => { const idx = offer?.roadmap_major_step_index != null ? Number(offer.roadmap_major_step_index) : null if (idx == null || !Number.isFinite(idx)) { alert('Angebot ohne Slot-Zuordnung — bitte manuell zuweisen.') return } setDraft((prev) => applyGapOfferToSlot(prev, idx, offer)) } if (loadErr) { return (

{loadErr}

Zurück
) } if (!draft) { return (
) } return (

{graphMeta?.name || draft.graphName || `Graph #${graphId}`}

Slot-Editor · Roadmap = Struktur · ein Primärpfad + Schwestern

Zur Übersicht
{actionErr ? (

{actionErr}

) : null}

Ziel & Roadmap