/** * 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 ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal' import ProgressionSlotCard from './ProgressionSlotCard' import ProgressionFindingsPanel from './ProgressionFindingsPanel' import { aiPreviewToQuickCreateDraft, buildQuickCreateAiPreview, } from '../utils/exerciseAiQuickCreate' import { buildPathGapPlanningContextForAi, gapOfferContextDisplayLines, initialStageLearningGoalFromOffer, } from '../utils/planningContextForExerciseAi' import { addSlotToDraft, applyGapOfferToDraft, applyMatchStepsToSlots, collectGapOffersFromApiResponse, hydrateProgressionGraphDraft, insertSlotInDraft, librarySlotExercise, majorStepsToOverridePayload, moveSlotInDraft, patchSlotInDraft, removeSlotFromDraft, saveProgressionGraphDraft, SLOT_MAX, slotsAsPathStepRows, slotsToEvaluateSteps, syncProgressionRoadmapFromSlots, } from '../utils/progressionGraphDraft' 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 } function resolveDefaultFocusAreaId(targetSummary, focusAreas) { const targetName = targetSummary?.focus_areas?.[0] if (targetName && Array.isArray(focusAreas) && focusAreas.length) { const norm = String(targetName).trim().toLowerCase() const hit = focusAreas.find((fa) => String(fa.name || '').trim().toLowerCase() === norm) if (hit?.id) return Number(hit.id) } return focusAreas?.[0]?.id ? Number(focusAreas[0].id) : null } export default function ProgressionGraphEditor({ graphId, embedded = false, onSaved }) { 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 [semanticBrief, setSemanticBrief] = useState(null) const [targetSummary, setTargetSummary] = useState(null) const [focusAreas, setFocusAreas] = useState([]) const [activeOffer, setActiveOffer] = useState(null) const [activeOfferSlotIndex, setActiveOfferSlotIndex] = useState(null) const [gapPrepOpen, setGapPrepOpen] = useState(false) const [gapPrepTitle, setGapPrepTitle] = useState('') const [gapPrepStageGoal, setGapPrepStageGoal] = useState('') const [gapPrepSupplements, setGapPrepSupplements] = useState('') const [gapPrepFocusAreaId, setGapPrepFocusAreaId] = useState('') const [gapPrepError, setGapPrepError] = useState('') const [generatingOfferId, setGeneratingOfferId] = useState(null) const [gapAiBusy, setGapAiBusy] = 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]) useEffect(() => { let cancelled = false api .listFocusAreas({ status: 'active' }) .then((fa) => { if (!cancelled) setFocusAreas(Array.isArray(fa) ? fa : []) }) .catch(() => { if (!cancelled) setFocusAreas([]) }) return () => { cancelled = true } }, []) const patchDraft = useCallback((patchFn) => { setDraft((prev) => { if (!prev) return prev const next = patchFn(prev) return { ...next, dirty: true } }) }, []) const gapContextParams = useMemo(() => { if (!draft) return {} return { goalQuery: draft.goalQuery, semanticBrief, graphId, pathSteps: slotsAsPathStepRows(draft), editableMajorSteps: draft.majorSteps, progressionRoadmap: draft.progressionRoadmap, startSituation: draft.startSituation, targetState: draft.targetState, roadmapNotes: draft.roadmapNotes, } }, [draft, semanticBrief, graphId]) 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 syncProgressionRoadmapFromSlots({ ...d, slots }) }) setPickContext(null) } const handlePatchLearningGoal = (slotIndex, value) => { patchDraft((d) => patchSlotInDraft(d, slotIndex, { learning_goal: value })) } const handlePatchPhase = (slotIndex, value) => { patchDraft((d) => patchSlotInDraft(d, slotIndex, { phase: value })) } const handleMoveSlot = (slotIndex, dir) => { patchDraft((d) => moveSlotInDraft(d, slotIndex, dir)) } const handleRemoveSlot = (slotIndex) => { if ((draft?.slots?.length || 0) <= 2) { alert('Mindestens zwei Slots müssen bleiben.') return } if (!window.confirm(`Slot ${slotIndex + 1} wirklich entfernen?`)) return patchDraft((d) => removeSlotFromDraft(d, slotIndex)) } const handleInsertAfter = (slotIndex) => { if ((draft?.slots?.length || 0) >= SLOT_MAX) { alert(`Maximal ${SLOT_MAX} Slots.`) return } patchDraft((d) => insertSlotInDraft(d, slotIndex)) } const handleAddSlot = () => { if ((draft?.slots?.length || 0) >= SLOT_MAX) { alert(`Maximal ${SLOT_MAX} Slots.`) return } patchDraft((d) => addSlotToDraft(d)) } 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 syncProgressionRoadmapFromSlots({ ...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 validMajorSteps = useMemo(() => { if (!draft?.slots) return [] return draft.slots.filter((s) => (s.learning_goal || '').trim().length >= 3) }, [draft?.slots]) const applyApiResponse = (res) => { setSemanticBrief(res?.semantic_brief_summary || null) setTargetSummary(res?.target_profile_summary || null) setPathQa(res?.path_qa || null) setGapFillOffers(collectGapOffersFromApiResponse(res)) } 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 hydrated = hydrateProgressionGraphDraft({ artifact: { goal_query: q, progression_roadmap: roadmap, start_situation: draft.startSituation, target_state: draft.targetState, roadmap_notes: draft.roadmapNotes, max_steps: (roadmap?.roadmap?.major_steps || []).length || draft.maxSteps, }, edges: [], graphName: draft.graphName, }) setDraft({ ...hydrated, goalQuery: q, dirty: true }) setSemanticBrief(res?.semantic_brief_summary || null) } 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 synced = syncProgressionRoadmapFromSlots(draft) const override = majorStepsToOverridePayload(synced.slots) const res = await api.suggestProgressionPath({ query: q, max_steps: synced.slots.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( { ...synced, progressionRoadmap: res?.progression_roadmap || synced.progressionRoadmap, pathSkillExpectations: res?.path_skill_expectations || synced.pathSkillExpectations, }, res?.steps, ) setDraft(next) applyApiResponse(res) } 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 synced = syncProgressionRoadmapFromSlots(draft) const override = validMajorSteps.length >= 2 ? majorStepsToOverridePayload(synced.slots) : undefined const res = await api.suggestProgressionPath({ query: q, max_steps: synced.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(synced), roadmap_override: override, progression_graph_id: Number(graphId), ...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes), }) applyApiResponse(res) 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() if (typeof onSaved === 'function') await onSaved() alert('Progressionsgraph gespeichert.') } catch (e) { setActionErr(e.message || 'Speichern fehlgeschlagen') } finally { setBusy(false) } } const handleApplyGapOffer = (offer, slotIndex) => { setDraft((prev) => { const next = applyGapOfferToDraft(prev, offer, { slotIndex }) return { ...next, dirty: true } }) setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id)) } const handleInsertGapSlot = (offer) => { if ((draft?.slots?.length || 0) >= SLOT_MAX) { alert(`Maximal ${SLOT_MAX} Slots — zuerst einen Slot entfernen.`) return } setDraft((prev) => { const next = applyGapOfferToDraft(prev, offer, { insertNewSlot: true }) return { ...next, dirty: true } }) setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id)) } const openGapFillPrep = (offer, slotIndex = null) => { const defaultFocus = resolveDefaultFocusAreaId(targetSummary, focusAreas) setActiveOffer(offer) setActiveOfferSlotIndex(slotIndex) setGapPrepTitle((offer?.title_hint || '').trim()) setGapPrepStageGoal(initialStageLearningGoalFromOffer(offer, gapContextParams)) setGapPrepSupplements('') setGapPrepFocusAreaId(defaultFocus ? String(defaultFocus) : '') setGapPrepError('') setGapPrepOpen(true) } const runGapFillAiSuggest = async (offer, prep, slotIndex) => { const title = (prep?.title || offer?.title_hint || '').trim() if (title.length < 3) { alert('Titel: mindestens 3 Zeichen.') return } const supplements = (prep?.supplements || '').trim() const stageGoal = (prep?.stageLearningGoal || '').trim() let goalText = (offer?.goal_for_ai || offer?.sketch || '').trim() if (supplements) { goalText = `${goalText}\n\nTrainer-Ergänzungen:\n${supplements}`.trim() } const focusId = prep?.focusAreaId != null && Number.isFinite(Number(prep.focusAreaId)) ? Number(prep.focusAreaId) : resolveDefaultFocusAreaId(targetSummary, focusAreas) if (!focusId) { alert('Bitte einen Fokusbereich wählen.') return } const focusRow = (focusAreas || []).find((x) => Number(x.id) === focusId) const focusHint = (focusRow?.name || offer?.primary_topic || '').trim() setGapAiBusy(true) setGeneratingOfferId(offer?.offer_id || null) setGapPrepError('') try { const planningContext = buildPathGapPlanningContextForAi({ offer, ...gapContextParams, stageLearningGoalOverride: stageGoal, gapTrainerSupplements: supplements, }) const aiRes = await api.suggestExerciseAi({ title, goal: goalText || undefined, execution: '', preparation: '', trainer_notes: supplements || '', focus_area_hint: focusHint || undefined, focus_areas_context: [{ focus_area_id: focusId, is_primary: true }], planning_context: planningContext || undefined, include_summary: true, include_skills: true, include_instructions: true, }) const preview = buildQuickCreateAiPreview(aiRes, { sketchPlain: goalText }) if (!preview.hasSummaryProposal && !preview.hasInstructionChoices && !preview.hasSkillChoices) { throw new Error('Die KI lieferte keinen verwertbaren Vorschlag.') } const aiDraft = aiPreviewToQuickCreateDraft(preview, { title, focusAreaId: focusId, sketchPlain: goalText, }) const enrichedOffer = { ...offer, proposal_title: title, ai_suggestion: aiDraft, has_ai_payload: true, } setDraft((prev) => { const next = applyGapOfferToDraft(prev, enrichedOffer, { slotIndex }) return { ...next, dirty: true } }) setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id)) setGapPrepOpen(false) setActiveOffer(null) } catch (e) { setGapPrepError(e.message || 'KI-Anlage fehlgeschlagen') } finally { setGapAiBusy(false) setGeneratingOfferId(null) } } const submitGapFillPrep = async () => { const title = (gapPrepTitle || '').trim() if (title.length < 3) { alert('Titel: mindestens 3 Zeichen.') return } const focusId = parseInt(String(gapPrepFocusAreaId).trim(), 10) if (!Number.isFinite(focusId) || focusId < 1) { alert('Bitte einen Fokusbereich wählen.') return } if (!activeOffer) return await runGapFillAiSuggest( activeOffer, { title, stageLearningGoal: (gapPrepStageGoal || '').trim(), supplements: (gapPrepSupplements || '').trim(), focusAreaId: focusId, }, activeOfferSlotIndex, ) } if (loadErr) { return (
{loadErr}
ZurückRoadmap-Slots · KI-Match · Graph-Bewertung (max. {SLOT_MAX} Slots)
{actionErr}
) : null}Ungespeicherte Änderungen
) : null}