shinkan-jinkendo/frontend/src/components/ExerciseProgressionPathBuilder.jsx
Lars dd0fae4bf5
Some checks failed
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Failing after 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 44s
Test Suite / playwright-tests (push) Successful in 1m15s
Enhance Planning AI with Roadmap-First Architecture and New Features
- Introduced a roadmap-first approach for the planning AI, allowing for a structured progression graph that aligns with the overall project roadmap.
- Added new functionality to strip off-topic steps from exercise paths, improving the relevance of generated exercise suggestions.
- Implemented a detailed goal text generation for AI proposals, enhancing the context provided for new exercises.
- Updated the ExerciseProgressionPathBuilder component to support new features, including roadmap previews and improved focus area handling.
- Incremented application version to 0.8.205 and updated database schema version to 20260606086 to reflect these changes.
2026-06-08 08:10:53 +02:00

832 lines
31 KiB
JavaScript

/**
* 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 (
<div
className="card"
style={{
marginBottom: '12px',
borderColor: 'color-mix(in srgb, var(--accent) 35%, var(--border))',
}}
>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>KI: Pfad zum Ziel</h3>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
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.
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end' }}>
<div className="form-row" style={{ flex: '2 1 240px', marginBottom: 0 }}>
<label className="form-label">Ziel / Entwicklungsrichtung</label>
<input
className="form-input"
value={goalQuery}
onChange={(e) => setGoalQuery(e.target.value)}
placeholder="z. B. Von Erlernen bis zur Perfektion des Fußtritts Mae Geri …"
disabled={disabled || loading || saving}
/>
</div>
<div className="form-row" style={{ flex: '0 1 120px', marginBottom: 0 }}>
<label className="form-label">Schritte</label>
<input
type="number"
min={2}
max={10}
className="form-input"
value={maxSteps}
onChange={(e) => setMaxSteps(Math.max(2, Math.min(10, Number(e.target.value) || 5)))}
disabled={disabled || loading || saving}
/>
</div>
<button
type="button"
className="btn btn-primary"
disabled={disabled || loading || saving || !graphId}
onClick={suggestPath}
>
{loading ? 'Vorschlag …' : 'Pfad vorschlagen'}
</button>
</div>
{error ? (
<p className="form-error" style={{ marginTop: '10px' }}>
{error}
</p>
) : null}
{(semanticBrief || targetSummary) && pathSteps.length > 0 ? (
<div style={{ marginTop: '10px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{semanticBrief?.primary_topic ? (
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
Thema: {semanticBrief.primary_topic}
</span>
) : null}
{Array.isArray(semanticBrief?.development_arc) &&
semanticBrief.development_arc.slice(0, 3).map((phase) => (
<span key={phase} className="exercise-tag">
{phase}
</span>
))}
{Array.isArray(targetSummary?.focus_areas) &&
targetSummary.focus_areas.slice(0, 1).map((fa) => (
<span key={fa} className="exercise-tag">
Fokus: {fa}
</span>
))}
</div>
) : null}
{progressionRoadmap?.roadmap?.major_steps?.length > 0 ? (
<div
style={{
marginTop: '12px',
padding: '12px',
borderRadius: '8px',
border: '1px solid color-mix(in srgb, var(--accent) 45%, var(--border))',
background: 'color-mix(in srgb, var(--accent) 6%, var(--surface))',
}}
>
<strong style={{ fontSize: '13px' }}>Didaktische Roadmap (Phase F)</strong>
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '6px 0 10px', lineHeight: 1.45 }}>
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).
</p>
<ol style={{ margin: 0, paddingLeft: '18px', fontSize: '13px', lineHeight: 1.5 }}>
{progressionRoadmap.roadmap.major_steps.map((step) => (
<li key={step.index} style={{ marginBottom: '6px' }}>
<span className="exercise-tag" style={{ marginRight: '6px' }}>
{step.phase}
</span>
{step.learning_goal}
</li>
))}
</ol>
</div>
) : null}
{pathQa && pathSteps.length > 0 ? (
<div
style={{
marginTop: '10px',
padding: '10px 12px',
borderRadius: '8px',
background: pathQa.overall_ok ? 'color-mix(in srgb, var(--accent) 8%, var(--surface2))' : 'color-mix(in srgb, var(--danger) 8%, var(--surface2))',
fontSize: '12px',
lineHeight: 1.45,
}}
>
<strong>
Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'}
{pathQa.quality_score != null ? ` (${Math.round(Number(pathQa.quality_score) * 100)} %)` : ''}
</strong>
{pathQa.topic_coverage ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{pathQa.topic_coverage}</p>
) : null}
{Array.isArray(pathQa.issues) && pathQa.issues.length > 0 ? (
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
{pathQa.issues.slice(0, 4).map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
) : null}
{Number(pathQa.bridge_insert_count) > 0 ? (
<p style={{ margin: '6px 0 0', color: 'var(--accent-dark)' }}>
{pathQa.bridge_insert_count} Brücken-Übung(en) aus der Bibliothek eingefügt.
</p>
) : null}
{Array.isArray(pathQa.stripped_off_topic_steps) && pathQa.stripped_off_topic_steps.length > 0 ? (
<p style={{ margin: '6px 0 0', color: 'var(--accent-dark)' }}>
{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(', ')}.
</p>
) : Number(pathQa.off_topic_count) > 0 ? (
<p style={{ margin: '6px 0 0', color: 'var(--danger)' }}>
{pathQa.off_topic_count} Schritt(e) ohne Bezug zum Pfad-Thema siehe Lücken-Angebote unten.
</p>
) : null}
{pathQa.reorder_applied ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>
Reihenfolge nach QS angepasst.
{Array.isArray(pathQa.reorder_notes) && pathQa.reorder_notes[0]
? ` ${pathQa.reorder_notes[0]}`
: ''}
</p>
) : null}
</div>
) : null}
{gapFillOffers.length > 0 ? (
<div
style={{
marginTop: '12px',
padding: '12px',
borderRadius: '8px',
border: '1px solid color-mix(in srgb, var(--accent) 40%, var(--border))',
background: 'color-mix(in srgb, var(--accent) 5%, var(--surface))',
}}
>
<strong style={{ fontSize: '13px' }}>Fehlende Schritte mit KI anlegen</strong>
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '6px 0 10px', lineHeight: 1.45 }}>
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.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{gapFillOffers.map((offer) => (
<div
key={offer.offer_id}
style={{
padding: '10px 12px',
borderRadius: '8px',
background: 'var(--surface2)',
border: '1px solid var(--border)',
}}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'flex-start' }}>
<div style={{ flex: '1 1 200px' }}>
<span className="exercise-tag" style={{ marginBottom: '6px', display: 'inline-block' }}>
{OFFER_SOURCE_LABELS[offer.source] || offer.source || 'Lücke'}
{offer.phase ? ` · ${offer.phase}` : ''}
</span>
<div style={{ fontSize: '13px', fontWeight: 600 }}>{offer.title_hint}</div>
{offer.rationale ? (
<p style={{ fontSize: '12px', color: 'var(--text2)', margin: '4px 0 0' }}>{offer.rationale}</p>
) : null}
{offer.from_title && offer.to_title ? (
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '4px 0 0' }}>
Zwischen {offer.from_title} und {offer.to_title}
{offer.replace_step_index != null ? ' (ersetzt themenfremden Schritt)' : ''}
</p>
) : null}
</div>
<button
type="button"
className="btn btn-primary"
style={{ fontSize: '12px', flexShrink: 0 }}
disabled={quickSaving || pathSteps.length >= maxSteps}
onClick={() => runGapFillAiSuggest(offer)}
title={
pathSteps.length >= maxSteps
? `Pfad hat bereits ${maxSteps} Schritte — zuerst einen Schritt entfernen.`
: 'KI-Entwurf mit Pfad-Kontext generieren'
}
>
{generatingOfferId === offer.offer_id
? 'KI erstellt Entwurf …'
: 'Mit KI anlegen'}
</button>
</div>
</div>
))}
</div>
</div>
) : null}
{pathSteps.length > 0 ? (
<>
<div style={{ marginTop: '14px' }}>
{pathSteps.map((step, idx) => (
<div
key={`${step.exerciseId}-${step.proposalKey || ''}-${idx}`}
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '10px',
alignItems: 'end',
marginBottom: '12px',
paddingBottom: '12px',
borderBottom: idx < pathSteps.length - 1 ? '1px dashed var(--border)' : 'none',
background: step.isOffTopic
? 'color-mix(in srgb, var(--danger) 6%, transparent)'
: undefined,
borderRadius: step.isOffTopic ? '8px' : undefined,
padding: step.isOffTopic ? '8px' : undefined,
}}
>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">
Schritt {idx + 1}
{step.isOffTopic ? ' (themenfremd)' : ''}
{step.isAiProposal ? ' (KI-Neu)' : step.isBridge ? ' (Brücke)' : ''}
{!step.isAiProposal && !step.isOffTopic && idx === 0 ? ' (Einstieg)' : ''}
{!step.isAiProposal && idx === pathSteps.length - 1 ? ' (Zielnähe)' : ''}
</label>
<div style={{ fontSize: '13px' }}>
<strong>{step.exerciseTitle}</strong>
{step.exerciseId ? (
<span style={{ color: 'var(--text3)' }}> (#{step.exerciseId})</span>
) : (
<span style={{ color: 'var(--text3)' }}> noch nicht in Bibliothek</span>
)}
</div>
{step.reasons?.length ? (
<ul
style={{
margin: '6px 0 0',
paddingLeft: '16px',
fontSize: '11px',
color: 'var(--accent-dark)',
}}
>
{step.reasons.slice(0, 2).map((r) => (
<li key={r}>{r}</li>
))}
</ul>
) : null}
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Variante</label>
{step.isAiProposal ? (
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>
Nach Anlage der Übung im Graph wählbar.
</p>
) : (
<select
className="form-input"
value={step.variantId ?? ''}
onChange={(e) =>
patchStep(idx, {
variantId: e.target.value === '' ? null : parseInt(e.target.value, 10),
})
}
disabled={!step.exerciseId}
>
<option value="">Gesamte Übung</option>
{(step.variants || []).map((v) => (
<option key={v.id} value={v.id}>
{v.variant_name || `Variante #${v.id}`}
</option>
))}
</select>
)}
</div>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => moveStep(idx, -1)}>
</button>
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => moveStep(idx, 1)}>
</button>
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => removeStep(idx)}>
Entfernen
</button>
</div>
</div>
))}
</div>
<div className="form-row">
<label className="form-label">Notiz für Kanten (Fallback, optional)</label>
<textarea
className="form-input"
rows={2}
value={segmentNotes}
onChange={(e) => setSegmentNotes(e.target.value)}
placeholder="Wird pro Kante genutzt, wenn keine KI-Begründung vorliegt."
/>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
<button
type="button"
className="btn btn-primary"
disabled={disabled || saving || pathSteps.filter((s) => s.exerciseId).length < 2}
onClick={savePathToGraph}
>
{saving ? 'Speichern …' : 'Pfad in Graph speichern'}
</button>
<button
type="button"
className="btn btn-secondary"
disabled={loading || saving}
onClick={() => {
setPathSteps([])
setTargetSummary(null)
setSemanticBrief(null)
setPathQa(null)
setGapFillOffers([])
setProgressionRoadmap(null)
}}
>
Vorschlag verwerfen
</button>
</div>
</>
) : null}
<ExerciseAiQuickCreateModal
open={quickCreateOpen}
onClose={closeQuickCreate}
searchLabel={activeOffer?.title_hint || goalQuery}
title={quickTitle}
onTitleChange={setQuickTitle}
sketch={quickSketch}
onSketchChange={setQuickSketch}
focusAreaId={quickFocusAreaId}
onFocusAreaChange={setQuickFocusAreaId}
focusAreas={focusAreas}
catalogsReady={focusAreas.length > 0}
busy={quickSaving}
error={quickAiError}
onRunAi={runQuickCreateAiSuggest}
/>
<ExerciseAiSuggestPreviewModal
draft={quickCreateDraft}
onDraftChange={setQuickCreateDraft}
onDiscard={() => {
setQuickCreateDraft(null)
if (activeOffer) setQuickCreateOpen(true)
}}
onApply={applyQuickCreateDraft}
focusAreas={focusAreas}
skillsCatalog={skillsCatalog}
dialogTitle="Pfad-Lücke — KI-Entwurf bearbeiten"
hint="Texte anpassen, dann als Übung speichern und in den Pfad einfügen."
applyLabel={quickSaving ? 'Wird angelegt …' : 'Anlegen und in Pfad einfügen'}
applyDisabled={quickSaving}
zIndex={2100}
/>
</div>
)
}