/** * Planungs-KI Phase C3/E3: Ziel → Übungspfad vorschlagen → Lücken mit KI anlegen → in Graph speichern. */ import React, { useCallback, useEffect, useState } from 'react' import api from '../utils/api' import ExerciseAiQuickCreateModal from './exercises/ExerciseAiQuickCreateModal' import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal' import { aiPreviewToQuickCreateDraft, buildQuickCreateAiPreview, buildQuickCreateExercisePayloadFromDraft, } from '../utils/exerciseAiQuickCreate' 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, } } 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', } 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, }) { const [goalQuery, setGoalQuery] = useState('') const [maxSteps, setMaxSteps] = useState(5) const [segmentNotes, setSegmentNotes] = useState('') const [loading, setLoading] = useState(false) 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 [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('') 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 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 runGapFillAiSuggest = async (offer) => { const title = (offer?.title_hint || '').trim() if (title.length < 3) { alert('Titel-Hinweis fehlt — bitte Pfad erneut vorschlagen.') return } const goalText = (offer?.goal_for_ai || offer?.sketch || '').trim() const focusId = resolveDefaultFocusAreaId(targetSummary, focusAreas) if (!focusId) { alert('Kein Fokusbereich verfügbar — bitte Kataloge laden oder manuell wählen.') setQuickTitle(title) setQuickSketch(goalText) setQuickFocusAreaId('') setActiveOffer(offer) setQuickCreateOpen(true) 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) try { const aiRes = await api.suggestExerciseAi({ title, goal: goalText || 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: 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, }), ) setQuickCreateOpen(false) } catch (e) { console.error(e) const msg = e?.message || String(e) setQuickAiError(msg) setQuickCreateOpen(true) } 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) } catch (e) { console.error(e) const msg = e?.message || String(e) setQuickAiError(msg) alert(msg || 'Übung konnte nicht angelegt werden') } finally { setQuickSaving(false) } } const suggestPath = 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 } setLoading(true) setError('') try { const res = await api.suggestProgressionPath({ query: q, max_steps: Number(maxSteps), 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: true, progression_graph_id: Number(graphId), }) 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.') } setPathSteps(rows) setTargetSummary(res?.target_profile_summary || null) setSemanticBrief(res?.semantic_brief_summary || null) setPathQa(qa) setGapFillOffers( Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : Array.isArray(qa?.gap_fill_offers) ? qa.gap_fill_offers : [], ) setProgressionRoadmap(res?.progression_roadmap || null) if (!segmentNotes.trim() && q) setSegmentNotes(q.slice(0, 400)) } catch (e) { console.error(e) setError(e.message || 'Pfad-Vorschlag fehlgeschlagen') setPathSteps([]) setTargetSummary(null) setSemanticBrief(null) setPathQa(null) setGapFillOffers([]) setProgressionRoadmap(null) } finally { setLoading(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 { await api.createExerciseProgressionSequence(Number(graphId), { steps: steps.map((s) => ({ exercise_id: s.exerciseId, variant_id: s.variantId || null, })), segment_notes, }) setPathSteps([]) setTargetSummary(null) setSemanticBrief(null) setPathQa(null) setGapFillOffers([]) setProgressionRoadmap(null) if (typeof onSaved === 'function') await onSaved() const msg = skippedAi > 0 ? `${n} Kante(n) gespeichert. ${skippedAi} KI-Vorschlag/Vorschläge nicht im Graph (noch nicht angelegt).` : `${n} Nachfolger-Kante(n) aus KI-Pfad gespeichert.` alert(msg) } catch (e) { console.error(e) setError(e.message || 'Speichern fehlgeschlagen') } finally { setSaving(false) } } return (

KI: Pfad zum Ziel

Ziel in Freitext formulieren — die Planungs-KI schlägt eine semantisch passende, aufbauende Reihenfolge vor, prüft Lücken (ggf. Brücken-Übungen) und optional per LLM-QS. Fehlende Schritte können mit KI als Übung angelegt werden.

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} />
{error ? (

{error}

) : null} {(semanticBrief || targetSummary) && pathSteps.length > 0 ? (
{semanticBrief?.primary_topic ? ( Thema: {semanticBrief.primary_topic} ) : null} {Array.isArray(semanticBrief?.development_arc) && semanticBrief.development_arc.slice(0, 3).map((phase) => ( {phase} ))} {Array.isArray(targetSummary?.focus_areas) && targetSummary.focus_areas.slice(0, 1).map((fa) => ( Fokus: {fa} ))}
) : null} {progressionRoadmap?.roadmap?.major_steps?.length > 0 ? (
Didaktische Roadmap (Phase F)

Ziel-zuerst-Planung: {progressionRoadmap.micro_objective_count ?? '?'} Zwischenziele →{' '} {progressionRoadmap.major_step_count ?? progressionRoadmap.roadmap.major_steps.length} Major Steps. {progressionRoadmap.llm_roadmap_applied ? ' (KI-Prompts aus Admin-Konfiguration)' : ' (heuristischer Fallback — KI-Prompts in ai_prompts)'} . Übungen unten: Bibliothekssuche (Übergangsphase).

    {progressionRoadmap.roadmap.major_steps.map((step) => (
  1. {step.phase} {step.learning_goal}
  2. ))}
) : null} {pathQa && pathSteps.length > 0 ? (
Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'} {pathQa.quality_score != null ? ` (${Math.round(Number(pathQa.quality_score) * 100)} %)` : ''} {pathQa.topic_coverage ? (

{pathQa.topic_coverage}

) : null} {Array.isArray(pathQa.issues) && pathQa.issues.length > 0 ? ( ) : null} {Number(pathQa.bridge_insert_count) > 0 ? (

{pathQa.bridge_insert_count} Brücken-Übung(en) aus der Bibliothek eingefügt.

) : null} {Array.isArray(pathQa.stripped_off_topic_steps) && pathQa.stripped_off_topic_steps.length > 0 ? (

{pathQa.stripped_off_topic_steps.length} themenfremde(r) Schritt(e) aus dem Pfad entfernt:{' '} {pathQa.stripped_off_topic_steps.map((s) => s.removed_title || s.title).join(', ')}.

) : Number(pathQa.off_topic_count) > 0 ? (

{pathQa.off_topic_count} Schritt(e) ohne Bezug zum Pfad-Thema — siehe Lücken-Angebote unten.

) : null} {pathQa.reorder_applied ? (

Reihenfolge nach QS angepasst. {Array.isArray(pathQa.reorder_notes) && pathQa.reorder_notes[0] ? ` ${pathQa.reorder_notes[0]}` : ''}

) : null}
) : null} {gapFillOffers.length > 0 ? (
Fehlende Schritte — mit KI anlegen

Die QS hat fehlende Zwischenschritte erkannt — sie sind noch nicht im Pfad ({pathSteps.length}/{maxSteps} Schritte). „Mit KI anlegen“ startet einen vollständigen KI-Entwurf (Ziel, Anleitung, Fähigkeiten) und fügt die Übung ein.

{gapFillOffers.map((offer) => (
{OFFER_SOURCE_LABELS[offer.source] || offer.source || 'Lücke'} {offer.phase ? ` · ${offer.phase}` : ''}
{offer.title_hint}
{offer.rationale ? (

{offer.rationale}

) : null} {offer.from_title && offer.to_title ? (

Zwischen „{offer.from_title}“ und „{offer.to_title}“ {offer.replace_step_index != null ? ' (ersetzt themenfremden Schritt)' : ''}

) : null}
))}
) : null} {pathSteps.length > 0 ? ( <>
{pathSteps.map((step, idx) => (
{step.exerciseTitle} {step.exerciseId ? ( (#{step.exerciseId}) ) : ( — noch nicht in Bibliothek )}
{step.reasons?.length ? (
    {step.reasons.slice(0, 2).map((r) => (
  • {r}
  • ))}
) : null}
{step.isAiProposal ? (

Nach Anlage der Übung im Graph wählbar.

) : ( )}
))}