All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 42s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m25s
- Added `stage_learning_goal_override` and `gap_trainer_supplements` parameters to `build_progression_path_gap_planning_context`, allowing for customized learning goals and additional trainer notes. - Updated `gapOfferContextDisplayLines` to include trainer supplements in the context display. - Enhanced `ExerciseProgressionPathBuilder` to utilize new parameters for improved gap fill offer handling. - Incremented application version to 0.8.214 to reflect these changes.
1525 lines
57 KiB
JavaScript
1525 lines
57 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 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 (
|
|
<details
|
|
style={{
|
|
marginTop: '8px',
|
|
fontSize: '12px',
|
|
color: 'var(--text2)',
|
|
borderTop: '1px dashed var(--border)',
|
|
paddingTop: '8px',
|
|
}}
|
|
>
|
|
<summary style={{ cursor: 'pointer', color: 'var(--accent-dark)', fontWeight: 600 }}>
|
|
KI-Kontext für diese Übung ({lines.length} Punkte)
|
|
</summary>
|
|
<dl style={{ margin: '8px 0 0', display: 'grid', gap: '6px' }}>
|
|
{lines.map(({ label, value }) => (
|
|
<div key={label}>
|
|
<dt style={{ margin: 0, fontSize: '11px', color: 'var(--text3)' }}>{label}</dt>
|
|
<dd style={{ margin: '2px 0 0', lineHeight: 1.45 }}>{value}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
<p style={{ margin: '8px 0 0', fontSize: '11px', color: 'var(--text3)', lineHeight: 1.4 }}>
|
|
Dieser Kontext wird an die Übungs-KI übergeben (Ziel, Fähigkeiten, Anleitung) — nicht nur das
|
|
Stufen-Lernziel oben.
|
|
</p>
|
|
</details>
|
|
)
|
|
}
|
|
|
|
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([])
|
|
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('')
|
|
|
|
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 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)
|
|
} 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 (
|
|
<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 }}>
|
|
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.
|
|
</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>
|
|
</div>
|
|
<div
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
|
|
gap: '10px',
|
|
marginTop: '10px',
|
|
}}
|
|
>
|
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
|
<label className="form-label">Startpunkt / Ausgangslage</label>
|
|
<textarea
|
|
className="form-input"
|
|
rows={2}
|
|
value={startSituation}
|
|
onChange={(e) => setStartSituation(e.target.value)}
|
|
placeholder="z. B. gleichartige Steppbewegung, vorhersehbar"
|
|
disabled={disabled || loading || saving}
|
|
/>
|
|
</div>
|
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
|
<label className="form-label">Zielzustand</label>
|
|
<textarea
|
|
className="form-input"
|
|
rows={2}
|
|
value={targetState}
|
|
onChange={(e) => setTargetState(e.target.value)}
|
|
placeholder="z. B. dynamische, unvorhersehbare Bewegung mit explosivem Angriff und Ausweichen"
|
|
disabled={disabled || loading || saving}
|
|
/>
|
|
</div>
|
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
|
<label className="form-label">Ergänzungen (Fokus, Gruppe, Besonderheiten)</label>
|
|
<textarea
|
|
className="form-input"
|
|
rows={2}
|
|
value={roadmapNotes}
|
|
onChange={(e) => setRoadmapNotes(e.target.value)}
|
|
placeholder="optional: Altersgruppe, Kumite-Kontext, Trainingsfokus …"
|
|
disabled={disabled || loading || saving}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '6px 0 0', lineHeight: 1.4 }}>
|
|
Optional zuerst „Start/Ziel analysieren“, anpassen, dann Roadmap-Stufen. Sind Start und Ziel leer,
|
|
geschieht die Analyse beim Roadmap-Vorschlag automatisch mit. Manuelle Eingaben haben immer Vorrang.
|
|
</p>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end', marginTop: '10px' }}>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
disabled={disabled || loading || saving || !graphId}
|
|
onClick={analyzeStartTarget}
|
|
title="Nur Ausgangslage, Zielzustand und Ergänzungen per KI — ohne Roadmap-Stufen"
|
|
>
|
|
{loadingStartTarget ? 'Analyse …' : 'Start/Ziel analysieren'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
disabled={disabled || loading || saving || !graphId}
|
|
onClick={suggestRoadmap}
|
|
title={
|
|
startSituation.trim() && targetState.trim()
|
|
? 'Roadmap-Stufen aus den gesetzten Start/Ziel-Feldern'
|
|
: 'Start/Ziel-Analyse und Roadmap-Stufen in einem Schritt'
|
|
}
|
|
>
|
|
{loadingRoadmap ? 'Roadmap …' : 'Roadmap vorschlagen'}
|
|
</button>
|
|
{startTargetAnalyzed && !editableMajorSteps.length ? (
|
|
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
|
|
Start/Ziel bereit — Roadmap als Nächstes
|
|
</span>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
disabled={
|
|
disabled ||
|
|
loading ||
|
|
saving ||
|
|
!graphId ||
|
|
editableMajorSteps.length < 2
|
|
}
|
|
onClick={matchExercisesFromRoadmap}
|
|
title={
|
|
editableMajorSteps.length < 2
|
|
? 'Zuerst Roadmap vorschlagen'
|
|
: roadmapDirty
|
|
? 'Roadmap wurde bearbeitet — erneut matchen'
|
|
: 'Bibliothek je Major Step durchsuchen'
|
|
}
|
|
>
|
|
{loadingMatch ? 'Match …' : roadmapDirty ? 'Übungen neu matchen' : 'Übungen matchen'}
|
|
</button>
|
|
</div>
|
|
|
|
{error ? (
|
|
<p className="form-error" style={{ marginTop: '10px' }}>
|
|
{error}
|
|
</p>
|
|
) : null}
|
|
|
|
{(progressionRoadmap?.goal_analysis ||
|
|
progressionRoadmap?.pipeline_phase === 'start_target_only') ? (
|
|
<div
|
|
style={{
|
|
marginTop: '12px',
|
|
padding: '12px',
|
|
borderRadius: '8px',
|
|
border: '1px solid var(--border)',
|
|
background: 'var(--surface2)',
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center', marginBottom: '8px' }}>
|
|
<strong style={{ fontSize: '13px' }}>Zielanalyse</strong>
|
|
{progressionRoadmap.llm_start_target_applied ? (
|
|
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
|
|
KI Start/Ziel
|
|
</span>
|
|
) : null}
|
|
{progressionRoadmap.llm_goal_analysis_applied ? (
|
|
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
|
|
KI-Zielanalyse
|
|
</span>
|
|
) : (
|
|
<span className="exercise-tag">heuristisch</span>
|
|
)}
|
|
{progressionRoadmap.start_target_sources ? (
|
|
<span className="exercise-tag" style={{ fontSize: '11px' }}>
|
|
Start: {sourceLabel(progressionRoadmap.start_target_sources.start)} · Ziel:{' '}
|
|
{sourceLabel(progressionRoadmap.start_target_sources.target)}
|
|
</span>
|
|
) : null}
|
|
{progressionRoadmap.goal_analysis.primary_topic ? (
|
|
<span className="exercise-tag">Thema: {progressionRoadmap.goal_analysis.primary_topic}</span>
|
|
) : null}
|
|
</div>
|
|
<div style={{ fontSize: '12px', color: 'var(--text2)', lineHeight: 1.5 }}>
|
|
<div>
|
|
<span style={{ color: 'var(--text3)' }}>Ausgang: </span>
|
|
{progressionRoadmap.goal_analysis.start_assumption}
|
|
</div>
|
|
<div style={{ marginTop: '6px' }}>
|
|
<span style={{ color: 'var(--text3)' }}>Ziel: </span>
|
|
{progressionRoadmap.goal_analysis.target_state}
|
|
</div>
|
|
{Array.isArray(progressionRoadmap.goal_analysis.success_criteria) &&
|
|
progressionRoadmap.goal_analysis.success_criteria.length > 0 ? (
|
|
<ul style={{ margin: '8px 0 0', paddingLeft: '18px' }}>
|
|
{progressionRoadmap.goal_analysis.success_criteria.slice(0, 4).map((c) => (
|
|
<li key={c}>{c}</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
{progressionRoadmap.start_target_extract?.extraction_notes ? (
|
|
<p style={{ margin: '8px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
|
|
{progressionRoadmap.start_target_extract.extraction_notes}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : 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}
|
|
|
|
{editableMajorSteps.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))',
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center', marginBottom: '8px' }}>
|
|
<strong style={{ fontSize: '13px' }}>Didaktische Roadmap — bearbeiten</strong>
|
|
{roadmapDirty ? (
|
|
<span className="exercise-tag" style={{ borderColor: 'var(--danger)' }}>
|
|
Geändert — bitte erneut matchen
|
|
</span>
|
|
) : pathSteps.length > 0 ? (
|
|
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
|
|
Gematcht
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '0 0 10px', lineHeight: 1.45 }}>
|
|
{progressionRoadmap?.micro_objective_count != null
|
|
? `${progressionRoadmap.micro_objective_count} Zwischenziele → `
|
|
: ''}
|
|
{editableMajorSteps.length} Major Steps
|
|
{progressionRoadmap?.llm_roadmap_applied
|
|
? ' (KI-Roadmap)'
|
|
: progressionRoadmap
|
|
? ' (heuristisch/KI)'
|
|
: ''}
|
|
. Phasen und Lernziele anpassen, dann „Übungen matchen“.
|
|
</p>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
|
{editableMajorSteps.map((step, idx) => (
|
|
<div
|
|
key={`major-${idx}-${step.index}`}
|
|
style={{
|
|
padding: '10px 12px',
|
|
borderRadius: '8px',
|
|
background: 'var(--surface2)',
|
|
border: '1px solid var(--border)',
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
|
|
gap: '10px',
|
|
alignItems: 'end',
|
|
}}
|
|
>
|
|
<div className="form-row" style={{ marginBottom: 0, flex: '0 0 120px' }}>
|
|
<label className="form-label">Stufe {idx + 1} · Phase</label>
|
|
<select
|
|
className="form-input"
|
|
value={step.phase}
|
|
onChange={(e) => patchMajorStep(idx, { phase: e.target.value })}
|
|
disabled={disabled || loading || saving}
|
|
>
|
|
{ROADMAP_PHASES.map((p) => (
|
|
<option key={p} value={p}>
|
|
{p}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="form-row" style={{ marginBottom: 0, flex: '2 1 240px' }}>
|
|
<label className="form-label">Lernziel</label>
|
|
<input
|
|
className="form-input"
|
|
value={step.learning_goal}
|
|
onChange={(e) => patchMajorStep(idx, { learning_goal: e.target.value })}
|
|
placeholder="z. B. Grundstellung und Hüftmobilität für Mae Geri"
|
|
disabled={disabled || loading || saving}
|
|
/>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
style={{ fontSize: '12px', padding: '4px 8px' }}
|
|
onClick={() => moveMajorStep(idx, -1)}
|
|
disabled={disabled || loading || saving || idx === 0}
|
|
>
|
|
↑
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
style={{ fontSize: '12px', padding: '4px 8px' }}
|
|
onClick={() => moveMajorStep(idx, 1)}
|
|
disabled={disabled || loading || saving || idx >= editableMajorSteps.length - 1}
|
|
>
|
|
↓
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
style={{ fontSize: '12px', padding: '4px 8px' }}
|
|
onClick={() => removeMajorStep(idx)}
|
|
disabled={disabled || loading || saving || editableMajorSteps.length <= 2}
|
|
>
|
|
Entfernen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{editableMajorSteps.length < PATH_STEPS_HARD_MAX ? (
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
style={{ marginTop: '10px', fontSize: '12px' }}
|
|
onClick={addMajorStep}
|
|
disabled={disabled || loading || saving}
|
|
>
|
|
Major Step hinzufügen
|
|
</button>
|
|
) : null}
|
|
</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}
|
|
{pathQa.roadmap_qa_mode === 'roadmap_first_lite' ? (
|
|
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>
|
|
QS an Roadmap gekoppelt: keine Brücken/Reihenfolge zwischen Major Steps (didaktisch bereits geplant).
|
|
</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 }}>
|
|
Fehlende oder zu ersetzende Schritte ({pathSteps.length}/{maxSteps} im Pfad).
|
|
{pathSteps.length >= maxSteps
|
|
? ' Der Pfad ist voll — beim Einfügen können Sie die Pfadlänge dynamisch vergrößern (ohne neuen Vorschlag); Ersatz-Angebote ersetzen einen Schritt.'
|
|
: ' Zuerst Kontext prüfen und ergänzen, dann KI-Entwurf erstellen und einfügen.'}
|
|
</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}
|
|
<GapOfferContextPreview
|
|
lines={gapOfferContextDisplayLines(offer, gapContextFallbackParams)}
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
style={{ fontSize: '12px', flexShrink: 0 }}
|
|
disabled={
|
|
quickSaving ||
|
|
(isGapOfferBlockedByPathCapacity(offer, pathSteps.length, maxSteps) &&
|
|
maxSteps >= PATH_STEPS_HARD_MAX)
|
|
}
|
|
onClick={() => handleGapFillClick(offer)}
|
|
title={
|
|
isGapOfferBlockedByPathCapacity(offer, pathSteps.length, maxSteps)
|
|
? maxSteps >= PATH_STEPS_HARD_MAX
|
|
? `Maximal ${PATH_STEPS_HARD_MAX} Schritte — zuerst einen Schritt entfernen.`
|
|
: 'Pfad voll — Klick fragt, ob die Pfadlänge vergrößert werden soll'
|
|
: offer.replace_step_index != null
|
|
? 'Ersetzt den themenfremden Schritt im Pfad'
|
|
: 'Kontext prüfen, Ergänzungen mitgeben, dann KI-Entwurf'
|
|
}
|
|
>
|
|
{generatingOfferId === offer.offer_id
|
|
? 'KI erstellt Entwurf …'
|
|
: 'Vorbereiten & 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.roadmapMajorStepIndex != null
|
|
? ` · Roadmap ${step.roadmapMajorStepIndex + 1}`
|
|
: ''}
|
|
{step.roadmapPhase ? ` (${step.roadmapPhase})` : ''}
|
|
{step.isOffTopic ? ' (themenfremd)' : ''}
|
|
{step.isAiProposal ? ' (KI-Neu)' : step.isBridge ? ' (Brücke)' : ''}
|
|
</label>
|
|
{step.roadmapLearningGoal ? (
|
|
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '4px 0 0' }}>
|
|
Ziel: {step.roadmapLearningGoal}
|
|
</p>
|
|
) : null}
|
|
<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}
|
|
|
|
<ExerciseGapFillPrepModal
|
|
open={gapPrepOpen}
|
|
onClose={closeGapFillPrep}
|
|
offer={activeOffer}
|
|
contextLines={activePlanningContextLines}
|
|
title={gapPrepTitle}
|
|
onTitleChange={setGapPrepTitle}
|
|
stageLearningGoal={gapPrepStageGoal}
|
|
onStageLearningGoalChange={setGapPrepStageGoal}
|
|
supplements={gapPrepSupplements}
|
|
onSupplementsChange={setGapPrepSupplements}
|
|
focusAreaId={gapPrepFocusAreaId}
|
|
onFocusAreaChange={setGapPrepFocusAreaId}
|
|
focusAreas={focusAreas}
|
|
busy={quickSaving}
|
|
error={gapPrepError}
|
|
onSubmit={submitGapFillPrep}
|
|
/>
|
|
|
|
<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) {
|
|
setGapPrepOpen(true)
|
|
} else {
|
|
setActivePlanningContextLines([])
|
|
}
|
|
}}
|
|
planningContextLines={activePlanningContextLines}
|
|
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>
|
|
)
|
|
}
|