/** * 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' import { buildPathGapPlanningContextForAi, gapOfferContextDisplayLines, } 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, } } 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', } const PATH_STEPS_HARD_MAX = 10 const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion'] function mapMajorStepsFromApi(apiRoadmap) { const raw = apiRoadmap?.roadmap?.major_steps if (!Array.isArray(raw)) return [] return 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 || '', })) } function reindexMajorSteps(rows) { return rows.map((row, i) => ({ ...row, index: i })) } function majorStepsToOverridePayload(rows) { return { major_steps: reindexMajorSteps(rows).map((row) => ({ index: row.index, phase: row.phase || 'vertiefung', learning_goal: row.learning_goal.trim(), consolidates: row.consolidates || [], rationale: row.rationale || '', })), } } /** 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, }) { 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 [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([]) 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: '', }, ]) }) 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 handleGapFillClick = async (offer) => { if (!confirmPathExpansionIfNeeded(offer, pathSteps.length, maxSteps, setMaxSteps)) { return } await runGapFillAiSuggest(offer) } const gapContextFallbackParams = { goalQuery, semanticBrief, graphId, pathSteps, editableMajorSteps, progressionRoadmap, startSituation, targetState, roadmapNotes, } 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) const contextLines = gapOfferContextDisplayLines(offer, gapContextFallbackParams) setActivePlanningContextLines(contextLines) const planningContext = buildPathGapPlanningContextForAi({ offer, ...gapContextFallbackParams, }) 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 }], 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, }), ) 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 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.') } 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) setRoadmapDirty(false) if (!segmentNotes.trim() && q) setSegmentNotes(q.slice(0, 400)) } 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: false, 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([]) setRoadmapDirty(false) } catch (e) { console.error(e) setError(e.message || 'Roadmap-Vorschlag fehlgeschlagen') setEditableMajorSteps([]) setProgressionRoadmap(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) } 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 { 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) setEditableMajorSteps([]) setRoadmapDirty(false) 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

Zuerst didaktische Roadmap vorschlagen und anpassen, dann Übungen je Major Step aus der Bibliothek matchen. Lücken 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} />