/** * Planungs-KI Phase C3/E3: Ziel → Übungspfad vorschlagen → Lücken mit KI anlegen → in Graph speichern. */ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import api from '../utils/api' import ExerciseAiQuickCreateModal from './exercises/ExerciseAiQuickCreateModal' import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal' import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal' import { aiPreviewToQuickCreateDraft, buildQuickCreateAiPreview, buildQuickCreateExercisePayloadFromDraft, } from '../utils/exerciseAiQuickCreate' import { buildPathGapPlanningContextForAi, gapOfferContextDisplayLines, initialStageLearningGoalFromOffer, } from '../utils/planningContextForExerciseAi' function applyResolvedStructuredFromRoadmap(progressionRoadmap, setters) { const rs = progressionRoadmap?.resolved_structured if (!rs) return if (rs.start_situation) setters.setStartSituation(String(rs.start_situation)) if (rs.target_state) setters.setTargetState(String(rs.target_state)) if (rs.roadmap_notes) setters.setRoadmapNotes(String(rs.roadmap_notes)) } function GapOfferContextPreview({ lines }) { if (!Array.isArray(lines) || lines.length === 0) return null return (
KI-Kontext für diese Übung ({lines.length} Punkte)
{lines.map(({ label, value }) => (
{label}
{value}
))}

Dieser Kontext wird an die Übungs-KI übergeben (Ziel, Fähigkeiten, Anleitung) — nicht nur das Stufen-Lernziel oben.

) } function sourceLabel(source) { const map = { user: 'manuell', llm: 'KI-Extraktion', regex: 'Muster (von … bis …)', merged: 'manuell + KI', heuristic: 'heuristisch', none: '—', } return map[source] || source || '—' } function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) { const start = (startSituation || '').trim() const target = (targetState || '').trim() const notes = (roadmapNotes || '').trim() const body = {} if (start) body.start_situation = start if (target) body.target_state = target if (notes) body.roadmap_notes = notes return body } function emptyPathStep() { return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [], reasons: [] } } function mapApiStepToRow(step) { const variants = Array.isArray(step?.variants) ? step.variants : [] const rawVid = step?.variant_id ?? step?.suggested_variant_id ?? null const variantId = rawVid != null && Number.isFinite(Number(rawVid)) && Number(rawVid) > 0 ? Number(rawVid) : null const isAiProposal = Boolean(step?.is_ai_proposal) || step?.exercise_id == null return { exerciseId: step?.exercise_id != null ? Number(step.exercise_id) : null, proposalKey: step?.proposal_key || null, exerciseTitle: (step?.title || '').trim() || (step?.exercise_id ? `Übung #${step.exercise_id}` : 'KI-Vorschlag'), variantId: isAiProposal ? null : variantId, variants: isAiProposal ? [] : variants, reasons: Array.isArray(step?.reasons) ? step.reasons : [], isBridge: Boolean(step?.is_bridge), isAiProposal, aiSuggestion: step?.ai_suggestion || null, semanticScore: step?.semantic_score, isOffTopic: false, roadmapMajorStepIndex: step?.roadmap_major_step_index != null ? Number(step.roadmap_major_step_index) : null, roadmapPhase: step?.roadmap_phase || null, roadmapLearningGoal: step?.roadmap_learning_goal || null, skillExpectations: step?.skill_expectations || null, } } function mapCreatedExerciseToRow(ex, offer) { return { exerciseId: Number(ex.id), proposalKey: null, exerciseTitle: (ex.title || offer?.title_hint || '').trim() || `Übung #${ex.id}`, variantId: null, variants: [], reasons: ['Neu angelegt zur Schließung einer Pfad-Lücke'], isBridge: true, isAiProposal: false, aiSuggestion: null, semanticScore: null, isOffTopic: false, } } const OFFER_SOURCE_LABELS = { unfilled_gap: 'Lücke', off_topic: 'Themenfremd', llm_suggested: 'QS-Empfehlung', roadmap_unfilled: 'Roadmap-Stufe', } function normalizeTitleKey(text) { return String(text || '') .trim() .toLowerCase() .replace(/\s+/g, ' ') } function mergeGraphIntoPathSteps(pathRows, graphNodes) { if (!Array.isArray(graphNodes) || !graphNodes.length || !pathRows.length) return pathRows return pathRows.map((row, i) => { const node = graphNodes[i] if (!node?.exercise_id) return row if (row.exerciseId != null) return row return { ...row, exerciseId: Number(node.exercise_id), exerciseTitle: node.title || `Übung #${node.exercise_id}`, variantId: node.variant_id != null ? Number(node.variant_id) : null, variants: row.variants || [], isFromGraph: true, reasons: [...(row.reasons || []), 'Aus bestehendem Graph übernommen'], } }) } function filterGapOffersForGraph(offers, pathRows, graphNodes) { if (!Array.isArray(offers) || !offers.length) return offers const graphIds = new Set( (graphNodes || []).map((n) => Number(n.exercise_id)).filter(Number.isFinite), ) const graphTitles = new Set( (graphNodes || []).map((n) => normalizeTitleKey(n.title)).filter(Boolean), ) const pathIds = new Set( (pathRows || []).map((r) => r.exerciseId).filter((id) => id != null), ) return offers.filter((offer) => { const hint = normalizeTitleKey(offer?.title_hint) const majorIdx = offer?.roadmap_major_step_index != null ? Number(offer.roadmap_major_step_index) : null if (majorIdx != null && Number.isFinite(majorIdx) && graphNodes?.[majorIdx]) { const gid = Number(graphNodes[majorIdx].exercise_id) if (graphIds.has(gid) && pathIds.has(gid)) return false } for (const gid of graphIds) { if (pathIds.has(gid) && hint) { const node = graphNodes.find((n) => Number(n.exercise_id) === gid) const nodeTitle = normalizeTitleKey(node?.title) if (nodeTitle && (nodeTitle === hint || nodeTitle.includes(hint) || hint.includes(nodeTitle))) { return false } } } for (const t of graphTitles) { if (hint && (t === hint || t.includes(hint) || hint.includes(t))) return false } return true }) } function SavedGraphPathStrip({ nodes, hasDraft }) { if (!Array.isArray(nodes) || nodes.length === 0) { return (
Im Graph gespeichert: noch kein Pfad — der erste Speichervorgang legt die Übungsfolge an.
) } return (
Im Graph gespeichert ({nodes.length} Schritte) {hasDraft ? ( — KI-Entwurf unten; Speichern ersetzt diesen Pfad ) : null}
    {nodes.map((node, idx) => (
  1. {node.title || `Übung #${node.exercise_id}`} {node.variant_name ? ( {` · ${node.variant_name}`} ) : null}
  2. ))}
) } const PATH_STEPS_HARD_MAX = 10 const WIZARD_STEPS = [ { id: 1, label: 'Ziel & Start/Ziel', short: 'Ziel' }, { id: 2, label: 'Roadmap', short: 'Roadmap' }, { id: 3, label: 'Match', short: 'Match' }, { id: 4, label: 'Lücken & Speichern', short: 'Speichern' }, ] function computeMaxReachableStep(editableMajorSteps, pathSteps) { if (pathSteps.length > 0) return 4 if (editableMajorSteps.length >= 2) return 2 return 1 } function PlanningWizardStepper({ currentStep, maxReachable, onStepChange, disabled }) { return ( ) } const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion'] const LOAD_PROFILE_OPTIONS = [ 'koordination', 'präzision', 'kraft', 'geschwindigkeit', 'timing', 'reaktion', 'distanz', 'gleichgewicht', 'kime', 'ausdauer', 'beweglichkeit', ] 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: '', })) return mergeStageSpecsIntoMajorSteps(rows, apiRoadmap) } function mergeStageSpecsIntoMajorSteps(rows, apiRoadmap) { 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(), } }) } function linesToStringList(text) { return String(text || '') .split('\n') .map((s) => s.trim()) .filter(Boolean) } function listToMultiline(arr) { return Array.isArray(arr) ? arr.join('\n') : '' } function reindexMajorSteps(rows) { return rows.map((row, i) => ({ ...row, index: i })) } 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 formatExpectedSkillNames(skillExpectations, limit = 4) { const items = skillExpectations?.expected_skills if (!Array.isArray(items)) return [] return items .map((s) => String(s?.skill_name || '').trim()) .filter(Boolean) .slice(0, limit) } const PLANNING_ARTIFACT_SCHEMA = 1 function buildPlanningRoadmapArtifactSnapshot({ goalQuery, startSituation, targetState, roadmapNotes, maxSteps, progressionRoadmap, pathSkillExpectations, }) { const q = (goalQuery || '').trim() if (!q && !progressionRoadmap) return null return { schema_version: PLANNING_ARTIFACT_SCHEMA, goal_query: q, start_situation: (startSituation || '').trim() || null, target_state: (targetState || '').trim() || null, roadmap_notes: (roadmapNotes || '').trim() || null, max_steps: Number(maxSteps) || 5, progression_roadmap: progressionRoadmap || null, path_skill_expectations: pathSkillExpectations || null, } } /** Einfügen wächst den Pfad; Ersetzen (replace_step_index) nicht. */ function offerGrowsPath(offer) { const replaceIdx = offer?.replace_step_index return !(replaceIdx != null && Number.isFinite(Number(replaceIdx))) } function isGapOfferBlockedByPathCapacity(offer, pathLen, maxSteps) { return offerGrowsPath(offer) && pathLen >= maxSteps } function neededMaxStepsAfterInsert(pathLen) { return Math.min(PATH_STEPS_HARD_MAX, pathLen + 1) } /** * Pfad voll, aber Einfügen gewünscht → Nutzer fragen, ob maxSteps dynamisch wächst. * @returns {boolean} true = fortfahren (ggf. maxSteps erhöht), false = abgebrochen */ function confirmPathExpansionIfNeeded(offer, pathLen, maxSteps, setMaxSteps) { if (!isGapOfferBlockedByPathCapacity(offer, pathLen, maxSteps)) { return true } if (maxSteps >= PATH_STEPS_HARD_MAX) { alert( `Maximale Pfadlänge (${PATH_STEPS_HARD_MAX} Schritte) erreicht. Bitte zuerst einen Schritt entfernen.`, ) return false } const newMax = neededMaxStepsAfterInsert(pathLen) const titleHint = (offer?.title_hint || 'diese Übung').trim() const ok = window.confirm( `Maximale Pfadlänge (${maxSteps}) ist erreicht.\n\n` + `Soll die Pfadlänge auf ${newMax} Schritte vergrößert werden, um „${titleHint}“ einzufügen?\n\n` + 'Es wird kein neuer Pfad-Vorschlag generiert.', ) if (!ok) return false setMaxSteps(newMax) return true } 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 ExerciseProgressionPathBuilder({ graphId, disabled = false, onSaved, graphChainNodes = null, graphChainEdgeIds = null, }) { const [goalQuery, setGoalQuery] = useState('') const [startSituation, setStartSituation] = useState('') const [targetState, setTargetState] = useState('') const [roadmapNotes, setRoadmapNotes] = useState('') const [maxSteps, setMaxSteps] = useState(5) const [segmentNotes, setSegmentNotes] = useState('') const [saving, setSaving] = useState(false) const [error, setError] = useState('') const [targetSummary, setTargetSummary] = useState(null) const [semanticBrief, setSemanticBrief] = useState(null) const [pathQa, setPathQa] = useState(null) const [pathSteps, setPathSteps] = useState([]) const [gapFillOffers, setGapFillOffers] = useState([]) const [progressionRoadmap, setProgressionRoadmap] = useState(null) const [pathSkillExpectations, setPathSkillExpectations] = useState(null) const [editableMajorSteps, setEditableMajorSteps] = useState([]) const [roadmapDirty, setRoadmapDirty] = useState(false) const [loadingRoadmap, setLoadingRoadmap] = useState(false) const [loadingStartTarget, setLoadingStartTarget] = useState(false) const [loadingMatch, setLoadingMatch] = useState(false) const [startTargetAnalyzed, setStartTargetAnalyzed] = useState(false) const loading = loadingRoadmap || loadingStartTarget || loadingMatch const [focusAreas, setFocusAreas] = useState([]) const [skillsCatalog, setSkillsCatalog] = useState([]) const [generatingOfferId, setGeneratingOfferId] = useState(null) const [quickCreateOpen, setQuickCreateOpen] = useState(false) const [activeOffer, setActiveOffer] = useState(null) const [quickTitle, setQuickTitle] = useState('') const [quickSketch, setQuickSketch] = useState('') const [quickFocusAreaId, setQuickFocusAreaId] = useState('') const [quickCreateDraft, setQuickCreateDraft] = useState(null) const [quickSaving, setQuickSaving] = useState(false) const [quickAiError, setQuickAiError] = useState('') const [activePlanningContextLines, setActivePlanningContextLines] = useState([]) 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 [loadedPlanningHint, setLoadedPlanningHint] = useState(false) const [wizardStep, setWizardStep] = useState(1) const [pathInsertNotice, setPathInsertNotice] = useState('') const maxReachableStep = useMemo( () => computeMaxReachableStep(editableMajorSteps, pathSteps), [editableMajorSteps, pathSteps], ) const buildPlanningArtifact = useCallback( () => buildPlanningRoadmapArtifactSnapshot({ goalQuery, startSituation, targetState, roadmapNotes, maxSteps, progressionRoadmap, pathSkillExpectations, }), [ goalQuery, startSituation, targetState, roadmapNotes, maxSteps, progressionRoadmap, pathSkillExpectations, ], ) const persistPlanningRoadmapToGraph = useCallback(async () => { if (!graphId) return const artifact = buildPlanningArtifact() if (!artifact?.goal_query && !artifact?.progression_roadmap) return try { await api.updateExerciseProgressionGraph(Number(graphId), { planning_roadmap: artifact }) } catch (e) { console.warn('Planungs-Artefakt konnte nicht gespeichert werden', e) } }, [graphId, buildPlanningArtifact]) useEffect(() => { if (!graphId) return let cancelled = false setLoadedPlanningHint(false) setPathSteps([]) setTargetSummary(null) setSemanticBrief(null) setPathQa(null) setGapFillOffers([]) setPathSkillExpectations(null) setEditableMajorSteps([]) setProgressionRoadmap(null) setRoadmapDirty(false) setStartTargetAnalyzed(false) setError('') setWizardStep(1) setPathInsertNotice('') api .getExerciseProgressionGraph(Number(graphId)) .then((g) => { if (cancelled) return const art = g?.planning_roadmap if (!art) return if (art.goal_query) setGoalQuery(String(art.goal_query)) if (art.start_situation) setStartSituation(String(art.start_situation)) if (art.target_state) setTargetState(String(art.target_state)) if (art.roadmap_notes) setRoadmapNotes(String(art.roadmap_notes)) if (art.max_steps) setMaxSteps(Number(art.max_steps)) if (art.path_skill_expectations) setPathSkillExpectations(art.path_skill_expectations) if (art.progression_roadmap) { setProgressionRoadmap(art.progression_roadmap) const majors = mapMajorStepsFromApi(art.progression_roadmap) if (majors.length >= 2) { setEditableMajorSteps(majors) setWizardStep(2) } } if ( art.start_situation || art.target_state || art.progression_roadmap?.resolved_structured ) { setStartTargetAnalyzed(true) } setLoadedPlanningHint(true) }) .catch((e) => { console.warn(e) }) return () => { cancelled = true } }, [graphId]) useEffect(() => { if (wizardStep > maxReachableStep) { setWizardStep(maxReachableStep) } }, [wizardStep, maxReachableStep]) useEffect(() => { let cancelled = false Promise.all([ api.listFocusAreas({ status: 'active' }), api.listSkillsCatalog({ status: 'active' }), ]) .then(([fa, sk]) => { if (cancelled) return setFocusAreas(Array.isArray(fa) ? fa : []) setSkillsCatalog(Array.isArray(sk) ? sk : []) }) .catch(() => { if (!cancelled) { setFocusAreas([]) setSkillsCatalog([]) } }) return () => { cancelled = true } }, []) const patchStep = useCallback((idx, patch) => { setPathSteps((prev) => prev.map((row, i) => (i === idx ? { ...row, ...patch } : row))) }, []) const removeStep = useCallback((idx) => { setPathSteps((prev) => (prev.length <= 2 ? prev : prev.filter((_, i) => i !== idx))) }, []) const patchMajorStep = useCallback((idx, patch) => { setEditableMajorSteps((prev) => reindexMajorSteps(prev.map((row, i) => (i === idx ? { ...row, ...patch } : row))), ) setRoadmapDirty(true) }, []) const moveMajorStep = useCallback((idx, dir) => { setEditableMajorSteps((prev) => { const j = idx + dir if (j < 0 || j >= prev.length) return prev const next = [...prev] const t = next[idx] next[idx] = next[j] next[j] = t return reindexMajorSteps(next) }) setRoadmapDirty(true) }, []) const removeMajorStep = useCallback((idx) => { setEditableMajorSteps((prev) => { if (prev.length <= 2) return prev return reindexMajorSteps(prev.filter((_, i) => i !== idx)) }) setRoadmapDirty(true) }, []) const addMajorStep = useCallback(() => { setEditableMajorSteps((prev) => { if (prev.length >= PATH_STEPS_HARD_MAX) return prev const phase = ROADMAP_PHASES[Math.min(prev.length, ROADMAP_PHASES.length - 1)] return reindexMajorSteps([ ...prev, { index: prev.length, phase, learning_goal: '', consolidates: [], rationale: '', load_profile: [], success_criteria: [], anti_patterns: [], exercise_type: '', }, ]) }) setRoadmapDirty(true) }, []) const moveStep = useCallback((idx, dir) => { setPathSteps((prev) => { const j = idx + dir if (j < 0 || j >= prev.length) return prev const next = [...prev] const t = next[idx] next[idx] = next[j] next[j] = t return next }) }, []) const applyOffTopicFlags = (rows, qa) => { const off = Array.isArray(qa?.off_topic_steps) ? qa.off_topic_steps : [] const indices = new Set(off.map((o) => Number(o.step_index)).filter(Number.isFinite)) return rows.map((row, idx) => ({ ...row, isOffTopic: indices.has(idx) })) } const trimPathToMaxSteps = useCallback((rows, limit) => { let next = [...rows] while (next.length > limit) { const offIdx = next.findIndex((s) => s.isOffTopic) if (offIdx >= 0) { next.splice(offIdx, 1) continue } next.pop() } return next.map((r) => ({ ...r, isOffTopic: false })) }, []) const insertExerciseFromOffer = useCallback( (created, offer) => { const row = mapCreatedExerciseToRow(created, offer) setPathSteps((prev) => { let next = [...prev] const afterIdx = Number(offer?.insert_after_index) const replaceIdx = offer?.replace_step_index != null ? Number(offer.replace_step_index) : null if (Number.isFinite(replaceIdx) && replaceIdx >= 0 && replaceIdx < next.length) { next.splice(replaceIdx, 1, row) } else if (Number.isFinite(afterIdx) && afterIdx >= 0 && afterIdx < next.length) { next.splice(afterIdx + 1, 0, row) } else { next.push(row) } return trimPathToMaxSteps(next, maxSteps) }) setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id)) }, [maxSteps, trimPathToMaxSteps], ) const closeQuickCreate = () => { if (quickSaving) return setQuickCreateOpen(false) setActiveOffer(null) setQuickCreateDraft(null) setQuickAiError('') } const gapContextFallbackParams = { goalQuery, semanticBrief, graphId, pathSteps, editableMajorSteps, progressionRoadmap, startSituation, targetState, roadmapNotes, } const closeGapFillPrep = () => { if (quickSaving) return setGapPrepOpen(false) setGapPrepError('') } const openGapFillPrep = (offer) => { const defaultFocus = resolveDefaultFocusAreaId(targetSummary, focusAreas) setActiveOffer(offer) setGapPrepTitle((offer?.title_hint || '').trim()) setGapPrepStageGoal(initialStageLearningGoalFromOffer(offer, gapContextFallbackParams)) setGapPrepSupplements('') setGapPrepFocusAreaId(defaultFocus ? String(defaultFocus) : '') setActivePlanningContextLines(gapOfferContextDisplayLines(offer, gapContextFallbackParams)) setGapPrepError('') setGapPrepOpen(true) } const handleGapFillClick = (offer) => { if (!confirmPathExpansionIfNeeded(offer, pathSteps.length, maxSteps, setMaxSteps)) { return } openGapFillPrep(offer) } 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 setGapPrepError('') await runGapFillAiSuggest(activeOffer, { title, stageLearningGoal: (gapPrepStageGoal || '').trim(), supplements: (gapPrepSupplements || '').trim(), focusAreaId: focusId, }) } const runGapFillAiSuggest = async (offer, prep = null) => { 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 für diese Übung:\n${supplements}`.trim() } const focusId = prep?.focusAreaId != null && Number.isFinite(Number(prep.focusAreaId)) ? Number(prep.focusAreaId) : resolveDefaultFocusAreaId(targetSummary, focusAreas) if (!focusId) { alert('Kein Fokusbereich verfügbar — bitte im Vorbereitungsdialog wählen.') return } const focusRow = (focusAreas || []).find((x) => Number(x.id) === focusId) const focusHint = (focusRow?.name || offer?.primary_topic || '').trim() setActiveOffer(offer) setQuickTitle(title) setQuickSketch(goalText) setQuickFocusAreaId(String(focusId)) setQuickAiError('') setQuickCreateDraft(null) setQuickSaving(true) setGeneratingOfferId(offer?.offer_id || null) const contextParams = { ...gapContextFallbackParams, stageLearningGoalOverride: stageGoal, gapTrainerSupplements: supplements, } const contextLines = gapOfferContextDisplayLines(offer, contextParams) setActivePlanningContextLines(contextLines) const planningContext = buildPathGapPlanningContextForAi({ offer, ...contextParams, }) try { 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.') } setQuickCreateDraft( aiPreviewToQuickCreateDraft(preview, { title, focusAreaId: focusId, sketchPlain: goalText, }), ) setGapPrepOpen(false) setQuickCreateOpen(false) } catch (e) { console.error(e) const msg = e?.message || String(e) setGapPrepError(msg) setQuickAiError(msg) } finally { setQuickSaving(false) setGeneratingOfferId(null) } } const runQuickCreateAiSuggest = async () => { const title = (quickTitle || '').trim() if (title.length < 3) { alert('Titel: mindestens 3 Zeichen.') return } const sketch = (quickSketch || '').trim() const focusId = parseInt(String(quickFocusAreaId).trim(), 10) if (!Number.isFinite(focusId) || focusId < 1) { alert('Bitte einen Fokusbereich wählen.') return } const focusRow = (focusAreas || []).find((x) => Number(x.id) === focusId) const focusHint = (focusRow?.name || '').trim() setQuickAiError('') setQuickCreateDraft(null) setQuickSaving(true) try { const aiRes = await api.suggestExerciseAi({ title, goal: sketch || undefined, execution: '', preparation: '', trainer_notes: '', focus_area_hint: focusHint || undefined, focus_areas_context: [{ focus_area_id: focusId, is_primary: true }], include_summary: true, include_skills: true, include_instructions: true, }) const preview = buildQuickCreateAiPreview(aiRes, { sketchPlain: sketch }) if (!preview.hasSummaryProposal && !preview.hasInstructionChoices && !preview.hasSkillChoices) { throw new Error('Die KI lieferte keinen verwertbaren Vorschlag.') } setQuickCreateDraft( aiPreviewToQuickCreateDraft(preview, { title, focusAreaId: focusId, sketchPlain: sketch }), ) setQuickCreateOpen(false) } catch (e) { console.error(e) const msg = e?.message || String(e) setQuickAiError(msg) alert(msg || 'KI-Vorschlag fehlgeschlagen') } finally { setQuickSaving(false) } } const applyQuickCreateDraft = async () => { if (!quickCreateDraft || !activeOffer) return setQuickSaving(true) setQuickAiError('') try { const payload = buildQuickCreateExercisePayloadFromDraft(quickCreateDraft) const created = await api.createExercise(payload) if (!created?.id) throw new Error('Anlegen fehlgeschlagen') insertExerciseFromOffer(created, activeOffer) setQuickCreateDraft(null) setActiveOffer(null) const title = (created.title || quickTitle || 'Übung').trim() setPathInsertNotice( `„${title}" wurde in den KI-Entwurf eingefügt. Mit «Pfad im Graph speichern» wird der gesamte Pfad übernommen.`, ) setWizardStep(4) } catch (e) { console.error(e) const msg = e?.message || String(e) setQuickAiError(msg) alert(msg || 'Übung konnte nicht angelegt werden') } finally { setQuickSaving(false) } } const applyPathMatchResponse = (res, q) => { const qa = res?.path_qa || null const rawRows = (Array.isArray(res?.steps) ? res.steps : []).map(mapApiStepToRow) const rows = Array.isArray(qa?.stripped_off_topic_steps) && qa.stripped_off_topic_steps.length > 0 ? rawRows : applyOffTopicFlags(rawRows, qa) if (rows.length < 2) { throw new Error('Zu wenig Schritte im Vorschlag.') } const mergedRows = mergeGraphIntoPathSteps(rows, graphChainNodes) const rawGaps = Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : Array.isArray(qa?.gap_fill_offers) ? qa.gap_fill_offers : [] const gaps = filterGapOffersForGraph(rawGaps, mergedRows, graphChainNodes) setPathSteps(mergedRows) setTargetSummary(res?.target_profile_summary || null) setSemanticBrief(res?.semantic_brief_summary || null) setPathQa(qa) setGapFillOffers(gaps) setProgressionRoadmap(res?.progression_roadmap || null) setPathSkillExpectations(res?.path_skill_expectations || null) setRoadmapDirty(false) if (!segmentNotes.trim() && q) setSegmentNotes(q.slice(0, 400)) if (res?.progression_roadmap?.stage_specs?.length) { setEditableMajorSteps((prev) => prev.length ? mergeStageSpecsIntoMajorSteps(prev, res.progression_roadmap) : prev, ) } } const applyStartTargetResponse = (res) => { const roadmap = res?.progression_roadmap || null setProgressionRoadmap((prev) => ({ ...(prev || {}), ...roadmap, roadmap: prev?.roadmap || roadmap?.roadmap || null, stage_specs: prev?.stage_specs || roadmap?.stage_specs || [], })) applyResolvedStructuredFromRoadmap(roadmap, { setStartSituation, setTargetState, setRoadmapNotes, }) setSemanticBrief(res?.semantic_brief_summary || null) setStartTargetAnalyzed(true) } const analyzeStartTarget = async () => { const q = (goalQuery || '').trim() if (q.length < 3) { alert('Ziel-Anfrage: mindestens 3 Zeichen.') return } if (!graphId) { alert('Zuerst einen Graphen wählen.') return } setLoadingStartTarget(true) setError('') try { const res = await api.suggestProgressionPath({ query: q, max_steps: Number(maxSteps), include_llm_intent: false, include_path_qa: false, include_llm_path_qa: false, include_path_reorder: false, include_ai_gap_fill: false, include_roadmap_preview: false, include_llm_roadmap: false, include_llm_start_target: true, start_target_only: true, progression_graph_id: Number(graphId), ...roadmapStructuredPayload(startSituation, targetState, roadmapNotes), }) applyStartTargetResponse(res) } catch (e) { console.error(e) setError(e.message || 'Start/Ziel-Analyse fehlgeschlagen') } finally { setLoadingStartTarget(false) } } const suggestRoadmap = async () => { const q = (goalQuery || '').trim() if (q.length < 3) { alert('Ziel-Anfrage: mindestens 3 Zeichen.') return } if (!graphId) { alert('Zuerst einen Graphen wählen.') return } const fieldsEmpty = !startSituation.trim() && !targetState.trim() setLoadingRoadmap(true) setError('') try { const res = await api.suggestProgressionPath({ query: q, max_steps: Number(maxSteps), 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, include_llm_start_target: fieldsEmpty, roadmap_only: true, progression_graph_id: Number(graphId), ...roadmapStructuredPayload(startSituation, targetState, roadmapNotes), }) const majors = mapMajorStepsFromApi(res?.progression_roadmap) if (majors.length < 2) { throw new Error('Roadmap hat zu wenig Major Steps.') } setEditableMajorSteps(majors) setMaxSteps(majors.length) const roadmap = res?.progression_roadmap || null setProgressionRoadmap(roadmap) if (fieldsEmpty) { applyResolvedStructuredFromRoadmap(roadmap, { setStartSituation, setTargetState, setRoadmapNotes, }) setStartTargetAnalyzed(true) } setSemanticBrief(res?.semantic_brief_summary || null) setPathSteps([]) setTargetSummary(null) setPathQa(null) setGapFillOffers([]) setPathSkillExpectations(null) setRoadmapDirty(false) setLoadedPlanningHint(false) setWizardStep(2) await persistPlanningRoadmapToGraph() } catch (e) { console.error(e) setError(e.message || 'Roadmap-Vorschlag fehlgeschlagen') setEditableMajorSteps([]) setProgressionRoadmap(null) setPathSkillExpectations(null) } finally { setLoadingRoadmap(false) } } const matchExercisesFromRoadmap = async () => { const q = (goalQuery || '').trim() if (q.length < 3) { alert('Ziel-Anfrage: mindestens 3 Zeichen.') return } if (!graphId) { alert('Zuerst einen Graphen wählen.') return } const validSteps = editableMajorSteps.filter((s) => (s.learning_goal || '').trim().length >= 3) if (validSteps.length < 2) { alert('Mindestens zwei Major Steps mit Lernziel (je 3+ Zeichen) nötig.') return } setLoadingMatch(true) setError('') try { const override = majorStepsToOverridePayload(validSteps) const res = await api.suggestProgressionPath({ query: q, max_steps: validSteps.length, include_llm_intent: true, include_path_qa: true, include_llm_path_qa: true, include_path_reorder: true, 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(startSituation, targetState, roadmapNotes), }) applyPathMatchResponse(res, q) setMaxSteps(validSteps.length) setLoadedPlanningHint(false) setWizardStep(3) await persistPlanningRoadmapToGraph() } catch (e) { console.error(e) setError(e.message || 'Übungs-Match fehlgeschlagen') } finally { setLoadingMatch(false) } } const savePathToGraph = async () => { if (!graphId) { alert('Zuerst einen Graphen wählen.') return } const steps = pathSteps.filter((s) => s.exerciseId != null) const skippedAi = pathSteps.filter((s) => s.isAiProposal).length if (steps.length < 2) { alert( skippedAi > 0 ? 'Mindestens zwei gespeicherte Übungen nötig. KI-Vorschläge zuerst als Übung anlegen.' : 'Mindestens zwei Schritte mit Übung nötig.', ) return } const n = steps.length - 1 const noteRaw = segmentNotes.trim() const segment_notes = Array.from({ length: n }, (_, i) => { const reasons = (steps[i + 1]?.reasons || []).slice(0, 2).join(' · ') if (reasons) return reasons return noteRaw || null }) setSaving(true) setError('') try { const edgeIds = Array.isArray(graphChainEdgeIds) ? graphChainEdgeIds.filter((id) => Number.isFinite(Number(id))) : [] if (edgeIds.length > 0) { await api.deleteExerciseProgressionEdgesBatch(Number(graphId), edgeIds) } const planningArtifact = buildPlanningArtifact() await api.createExerciseProgressionSequence(Number(graphId), { steps: steps.map((s) => ({ exercise_id: s.exerciseId, variant_id: s.variantId || null, })), segment_notes, ...(planningArtifact ? { planning_roadmap: planningArtifact } : {}), }) setPathSteps([]) setTargetSummary(null) setSemanticBrief(null) setPathQa(null) setGapFillOffers([]) setProgressionRoadmap(null) setPathSkillExpectations(null) setEditableMajorSteps([]) setRoadmapDirty(false) setWizardStep(1) setPathInsertNotice('') if (typeof onSaved === 'function') await onSaved() const msg = skippedAi > 0 ? `Pfad gespeichert (${n} Kante(n)). ${skippedAi} KI-Vorschlag/Vorschläge noch nicht angelegt.` : edgeIds.length > 0 ? `Progressionspfad aktualisiert (${n} Kante(n)).` : `Progressionspfad angelegt (${n} Kante(n)).` alert(msg) } catch (e) { console.error(e) const detail = e?.message || String(e) setError( detail.includes('409') || detail.toLowerCase().includes('duplikat') ? 'Speichern fehlgeschlagen: Pfad-Konflikt. Bitte erneut versuchen — bestehende Kanten werden beim Speichern ersetzt.' : detail || 'Speichern fehlgeschlagen', ) } finally { setSaving(false) } } return (

Progressionspfad planen (KI)

Ein Graph hat einen linearen Pfad. Oben der gespeicherte Stand, darunter der KI-Entwurf in vier Schritten. Speichern übernimmt den Entwurf und ersetzt den bisherigen Pfad.

0} /> {error ? (

{error}

) : null} {wizardStep === 1 ? (

Schritt 1 — Ziel & Start/Ziel

{loadedPlanningHint && editableMajorSteps.length > 0 && pathSteps.length === 0 ? (

Gespeicherte Planung geladen — Sie können bei Schritt 2 weitermachen oder hier neu starten.

) : null}
setGoalQuery(e.target.value)} placeholder="z. B. Von Erlernen bis zur Perfektion des Fußtritts Mae Geri …" disabled={disabled || loading || saving} />
setMaxSteps(Math.max(2, Math.min(10, Number(e.target.value) || 5)))} disabled={disabled || loading || saving} />