All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m26s
- Introduced new functions `_off_topic_semantic_scores_by_slot` and `_score_exercise_stage_fit_for_spec` to improve the evaluation of off-topic steps and exercise stage fit, enhancing the quality assessment process. - Updated `_run_unified_slot_improvement_review` to incorporate off-topic scores and exercise stage fit scoring, refining the decision-making process for slot suggestions. - Enhanced existing logic to streamline the handling of slot scores and improve the overall robustness of slot management in path evaluations.
1264 lines
46 KiB
JavaScript
1264 lines
46 KiB
JavaScript
/**
|
|
* Integrierter Slot-Editor für Progressionsgraphen (Phase B).
|
|
*/
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import api from '../utils/api'
|
|
import ExercisePickerModal from './ExercisePickerModal'
|
|
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
|
|
import ProgressionSlotCard from './ProgressionSlotCard'
|
|
import ProgressionFindingsPanel from './ProgressionFindingsPanel'
|
|
import PlanningCatalogContextFields from './PlanningCatalogContextFields'
|
|
import {
|
|
aiPreviewToQuickCreateDraft,
|
|
buildQuickCreateAiPreview,
|
|
buildQuickCreateExercisePayloadFromDraft,
|
|
ensureQuickCreateDraftFromAiSuggestion,
|
|
} from '../utils/exerciseAiQuickCreate'
|
|
import {
|
|
buildPathGapPlanningContextForAi,
|
|
buildSlotGapGoalForAi,
|
|
gapOfferContextDisplayLines,
|
|
initialStageLearningGoalFromOffer,
|
|
} from '../utils/planningContextForExerciseAi'
|
|
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
|
|
import ProgressionOptimizeCompareModal from './ProgressionOptimizeCompareModal'
|
|
import {
|
|
addSlotToDraft,
|
|
applyEvaluateResponseToDraft,
|
|
applyGapOfferToDraft,
|
|
applySelectedCompareSteps,
|
|
applySelectedSlotSuggestions,
|
|
applyResolvedStructuredToDraft,
|
|
buildPlanningArtifactFromDraft,
|
|
buildProgressionComparePayload,
|
|
collectGapOffersFromApiResponse,
|
|
compareSlotDiffs,
|
|
compareDiffsForDialog,
|
|
dedupeGapOffersBySlot,
|
|
draftHasLibrarySlotAssignments,
|
|
draftRetrievalBoostExerciseIds,
|
|
EMPTY_PLANNING_CATALOG_CONTEXT,
|
|
filterGapOffersForUnfilledSlots,
|
|
hydrateProgressionGraphDraft,
|
|
insertSlotInDraft,
|
|
librarySlotExercise,
|
|
majorStepsToOverridePayload,
|
|
mergeGapOffersForDraft,
|
|
moveSlotInDraft,
|
|
patchSlotInDraft,
|
|
pathQaQualityPercent,
|
|
planningCatalogContextToApi,
|
|
rejectedCompareDiffs,
|
|
removeSlotFromDraft,
|
|
saveProgressionGraphDraft,
|
|
setCatalogSelectItems,
|
|
setSlotPrimaryLibrary,
|
|
SLOT_MAX,
|
|
SLOT_MIN,
|
|
slotsAsPathStepRows,
|
|
slotsToEvaluateSteps,
|
|
slotsToSlotAssignments,
|
|
syncProgressionRoadmapFromSlots,
|
|
syncSlotPhasesFromRoadmap,
|
|
} from '../utils/progressionGraphDraft'
|
|
|
|
function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) {
|
|
const body = {}
|
|
const start = (startSituation || '').trim()
|
|
const target = (targetState || '').trim()
|
|
const notes = (roadmapNotes || '').trim()
|
|
if (start) body.start_situation = start
|
|
if (target) body.target_state = target
|
|
if (notes) body.roadmap_notes = notes
|
|
return body
|
|
}
|
|
|
|
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 ProgressionGraphEditor({ graphId, embedded = false, onSaved }) {
|
|
const [graphMeta, setGraphMeta] = useState(null)
|
|
const [draft, setDraft] = useState(null)
|
|
const [busy, setBusy] = useState(false)
|
|
const [loadErr, setLoadErr] = useState('')
|
|
const [actionErr, setActionErr] = useState('')
|
|
const [matchNotice, setMatchNotice] = useState('')
|
|
const [pickContext, setPickContext] = useState(null)
|
|
const [pathQa, setPathQa] = useState(null)
|
|
const [gapFillOffers, setGapFillOffers] = useState([])
|
|
const [evaluating, setEvaluating] = useState(false)
|
|
const [matching, setMatching] = useState(false)
|
|
const [roadmapLoading, setRoadmapLoading] = useState(false)
|
|
const [startTargetLoading, setStartTargetLoading] = useState(false)
|
|
const [startTargetReady, setStartTargetReady] = useState(false)
|
|
const [semanticBrief, setSemanticBrief] = useState(null)
|
|
const [targetSummary, setTargetSummary] = useState(null)
|
|
const [focusAreas, setFocusAreas] = useState([])
|
|
const [styleDirections, setStyleDirections] = useState([])
|
|
const [trainingTypes, setTrainingTypes] = useState([])
|
|
const [targetGroups, setTargetGroups] = useState([])
|
|
const [skillsCatalog, setSkillsCatalog] = useState([])
|
|
|
|
const [activeOffer, setActiveOffer] = useState(null)
|
|
const [activeOfferSlotIndex, setActiveOfferSlotIndex] = useState(null)
|
|
const [gapPrepOpen, setGapPrepOpen] = useState(false)
|
|
const [gapPrepTitle, setGapPrepTitle] = useState('')
|
|
const [gapPrepStageGoal, setGapPrepStageGoal] = useState('')
|
|
const [gapPrepSupplements, setGapPrepSupplements] = useState('')
|
|
const [gapPrepFocusAreaId, setGapPrepFocusAreaId] = useState('')
|
|
const [gapPrepError, setGapPrepError] = useState('')
|
|
const [generatingOfferId, setGeneratingOfferId] = useState(null)
|
|
const [gapAiBusy, setGapAiBusy] = useState(false)
|
|
const [currentEdges, setCurrentEdges] = useState([])
|
|
const [slotQuickCreateIndex, setSlotQuickCreateIndex] = useState(null)
|
|
const [slotQuickCreateDraft, setSlotQuickCreateDraft] = useState(null)
|
|
const [slotQuickSaving, setSlotQuickSaving] = useState(false)
|
|
const [slotQuickError, setSlotQuickError] = useState('')
|
|
const [activePlanningContextLines, setActivePlanningContextLines] = useState([])
|
|
const [compareOpen, setCompareOpen] = useState(false)
|
|
const [comparePayload, setComparePayload] = useState(null)
|
|
const [compareSource, setCompareSource] = useState('manual')
|
|
const [comparing, setComparing] = useState(false)
|
|
const [compareApplying, setCompareApplying] = useState(false)
|
|
const [proposedPathQa, setProposedPathQa] = useState(null)
|
|
|
|
const loadGraph = useCallback(async () => {
|
|
if (!graphId) return
|
|
setBusy(true)
|
|
setLoadErr('')
|
|
try {
|
|
const [graph, edges] = await Promise.all([
|
|
api.getExerciseProgressionGraph(Number(graphId)),
|
|
api.listExerciseProgressionEdges(Number(graphId)),
|
|
])
|
|
const edgeList = Array.isArray(edges) ? edges : []
|
|
setCurrentEdges(edgeList)
|
|
setGraphMeta(graph)
|
|
const hydrated = hydrateProgressionGraphDraft({
|
|
artifact: graph?.planning_roadmap,
|
|
edges: edgeList,
|
|
graphName: graph?.name,
|
|
})
|
|
setDraft(hydrated)
|
|
setStartTargetReady(
|
|
Boolean((hydrated.startSituation || '').trim() && (hydrated.targetState || '').trim()),
|
|
)
|
|
const findings = graph?.planning_roadmap?.last_findings
|
|
if (findings) setPathQa(findings)
|
|
} catch (e) {
|
|
setLoadErr(e.message || 'Graph konnte nicht geladen werden')
|
|
setDraft(null)
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}, [graphId])
|
|
|
|
useEffect(() => {
|
|
loadGraph()
|
|
}, [loadGraph])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
Promise.all([
|
|
api.listFocusAreas({ status: 'active' }),
|
|
api.listStyleDirections({ status: 'active' }),
|
|
api.listTrainingTypes({ status: 'active' }),
|
|
api.listTargetGroups({ status: 'active' }),
|
|
api.listSkillsCatalog({ status: 'active' }),
|
|
])
|
|
.then(([fa, sd, tt, tg, sk]) => {
|
|
if (cancelled) return
|
|
setFocusAreas(Array.isArray(fa) ? fa : [])
|
|
setStyleDirections(Array.isArray(sd) ? sd : [])
|
|
setTrainingTypes(Array.isArray(tt) ? tt : [])
|
|
setTargetGroups(Array.isArray(tg) ? tg : [])
|
|
setSkillsCatalog(Array.isArray(sk) ? sk : [])
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) {
|
|
setFocusAreas([])
|
|
setStyleDirections([])
|
|
setTrainingTypes([])
|
|
setTargetGroups([])
|
|
setSkillsCatalog([])
|
|
}
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
const patchDraft = useCallback((patchFn) => {
|
|
setDraft((prev) => {
|
|
if (!prev) return prev
|
|
const next = patchFn(prev)
|
|
return { ...next, dirty: true }
|
|
})
|
|
}, [])
|
|
|
|
const gapContextParams = useMemo(() => {
|
|
if (!draft) return {}
|
|
return {
|
|
goalQuery: draft.goalQuery,
|
|
semanticBrief,
|
|
graphId,
|
|
pathSteps: slotsAsPathStepRows(draft),
|
|
editableMajorSteps: draft.majorSteps,
|
|
progressionRoadmap: draft.progressionRoadmap,
|
|
startSituation: draft.startSituation,
|
|
targetState: draft.targetState,
|
|
roadmapNotes: draft.roadmapNotes,
|
|
}
|
|
}, [draft, semanticBrief, graphId])
|
|
|
|
const handlePickExercise = async (exercise) => {
|
|
if (!pickContext || !exercise?.id) return
|
|
const { slotIndex, role } = pickContext
|
|
const entry = librarySlotExercise({
|
|
exerciseId: exercise.id,
|
|
exerciseTitle: exercise.title || `Übung #${exercise.id}`,
|
|
})
|
|
patchDraft((d) => {
|
|
const slots = d.slots.map((s, i) => {
|
|
if (i !== slotIndex) return s
|
|
if (role === 'primary') return { ...s, primary: entry }
|
|
const siblings = [...(s.siblings || [])]
|
|
if (!siblings.some((x) => x.exerciseId === entry.exerciseId)) siblings.push(entry)
|
|
return { ...s, siblings }
|
|
})
|
|
return syncProgressionRoadmapFromSlots({ ...d, slots })
|
|
})
|
|
setPickContext(null)
|
|
}
|
|
|
|
const handlePatchLearningGoal = (slotIndex, value) => {
|
|
patchDraft((d) => patchSlotInDraft(d, slotIndex, { learning_goal: value }))
|
|
}
|
|
|
|
const handlePatchPhase = (slotIndex, value) => {
|
|
patchDraft((d) => patchSlotInDraft(d, slotIndex, { phase: value }))
|
|
}
|
|
|
|
const handleMoveSlot = (slotIndex, dir) => {
|
|
patchDraft((d) => moveSlotInDraft(d, slotIndex, dir))
|
|
}
|
|
|
|
const handleRemoveSlot = (slotIndex) => {
|
|
if ((draft?.slots?.length || 0) <= 2) {
|
|
alert('Mindestens zwei Slots müssen bleiben.')
|
|
return
|
|
}
|
|
if (!window.confirm(`Slot ${slotIndex + 1} wirklich entfernen?`)) return
|
|
patchDraft((d) => removeSlotFromDraft(d, slotIndex))
|
|
}
|
|
|
|
const handleInsertAfter = (slotIndex) => {
|
|
if ((draft?.slots?.length || 0) >= SLOT_MAX) {
|
|
alert(`Maximal ${SLOT_MAX} Slots.`)
|
|
return
|
|
}
|
|
patchDraft((d) => insertSlotInDraft(d, slotIndex))
|
|
}
|
|
|
|
const handleAddSlot = () => {
|
|
if ((draft?.slots?.length || 0) >= SLOT_MAX) {
|
|
alert(`Maximal ${SLOT_MAX} Slots.`)
|
|
return
|
|
}
|
|
patchDraft((d) => addSlotToDraft(d))
|
|
}
|
|
|
|
const handleClearPrimary = (slotIndex) => {
|
|
patchDraft((d) => {
|
|
const slots = d.slots.map((s, i) =>
|
|
i === slotIndex
|
|
? {
|
|
...s,
|
|
primary: {
|
|
kind: 'empty',
|
|
exerciseId: null,
|
|
variantId: null,
|
|
exerciseTitle: '',
|
|
variantName: null,
|
|
proposalKey: null,
|
|
aiSuggestion: null,
|
|
},
|
|
siblings: [],
|
|
}
|
|
: s,
|
|
)
|
|
return syncProgressionRoadmapFromSlots({ ...d, slots })
|
|
})
|
|
}
|
|
|
|
const handleRemoveSibling = (slotIndex, sibIdx) => {
|
|
patchDraft((d) => {
|
|
const slots = d.slots.map((s, i) => {
|
|
if (i !== slotIndex) return s
|
|
return { ...s, siblings: s.siblings.filter((_, j) => j !== sibIdx) }
|
|
})
|
|
return { ...d, slots }
|
|
})
|
|
}
|
|
|
|
const validMajorSteps = useMemo(() => {
|
|
if (!draft?.slots) return []
|
|
return draft.slots.filter((s) => (s.learning_goal || '').trim().length >= 3)
|
|
}, [draft?.slots])
|
|
|
|
const catalogCtx = draft?.planningCatalogContext || EMPTY_PLANNING_CATALOG_CONTEXT
|
|
|
|
const patchCatalogDimension = (key, value) => {
|
|
patchDraft((d) => ({
|
|
...d,
|
|
dirty: true,
|
|
planningCatalogContext: {
|
|
...(d.planningCatalogContext || EMPTY_PLANNING_CATALOG_CONTEXT),
|
|
[key]: setCatalogSelectItems(d.planningCatalogContext?.[key], value),
|
|
},
|
|
}))
|
|
}
|
|
|
|
const catalogApiPayload = useMemo(
|
|
() => planningCatalogContextToApi(catalogCtx),
|
|
[catalogCtx],
|
|
)
|
|
|
|
const runAnalyzeStartTarget = async () => {
|
|
const q = (draft?.goalQuery || '').trim()
|
|
if (q.length < 3) {
|
|
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
|
|
return
|
|
}
|
|
setStartTargetLoading(true)
|
|
setActionErr('')
|
|
try {
|
|
const res = await api.suggestProgressionPath({
|
|
query: q,
|
|
max_steps: draft.maxSteps || 5,
|
|
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(draft.startSituation, draft.targetState, draft.roadmapNotes),
|
|
...catalogApiPayload,
|
|
})
|
|
const roadmap = res?.progression_roadmap
|
|
if (!roadmap) throw new Error('Keine Start/Ziel-Analyse in der Antwort')
|
|
setDraft((prev) => {
|
|
const structured = applyResolvedStructuredToDraft(
|
|
{ ...prev, progressionRoadmap: roadmap },
|
|
roadmap,
|
|
)
|
|
return { ...structured, dirty: true }
|
|
})
|
|
setStartTargetReady(true)
|
|
setSemanticBrief(res?.semantic_brief_summary || null)
|
|
} catch (e) {
|
|
setActionErr(e.message || 'Start/Ziel-Analyse fehlgeschlagen')
|
|
} finally {
|
|
setStartTargetLoading(false)
|
|
}
|
|
}
|
|
|
|
const runRoadmapGenerate = async () => {
|
|
const q = (draft?.goalQuery || '').trim()
|
|
if (q.length < 3) {
|
|
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
|
|
return
|
|
}
|
|
const fieldsEmpty = !(draft.startSituation || '').trim() && !(draft.targetState || '').trim()
|
|
setRoadmapLoading(true)
|
|
setActionErr('')
|
|
try {
|
|
const res = await api.suggestProgressionPath({
|
|
query: q,
|
|
max_steps: draft.maxSteps || 5,
|
|
include_llm_intent: true,
|
|
include_path_qa: false,
|
|
include_llm_path_qa: false,
|
|
include_path_reorder: false,
|
|
include_ai_gap_fill: false,
|
|
include_roadmap_preview: true,
|
|
include_llm_roadmap: true,
|
|
include_llm_start_target: fieldsEmpty,
|
|
roadmap_only: true,
|
|
progression_graph_id: Number(graphId),
|
|
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
|
|
...catalogApiPayload,
|
|
})
|
|
const roadmap = res?.progression_roadmap
|
|
if (!roadmap) throw new Error('Keine Roadmap in der Antwort')
|
|
const majorCount = (roadmap?.roadmap?.major_steps || []).length
|
|
if (majorCount < SLOT_MIN) throw new Error('Roadmap hat zu wenig Stufen.')
|
|
const preservedArtifact = buildPlanningArtifactFromDraft(draft) || {}
|
|
let startSituation = draft.startSituation
|
|
let targetState = draft.targetState
|
|
let roadmapNotes = draft.roadmapNotes
|
|
if (fieldsEmpty) {
|
|
const patch = applyResolvedStructuredToDraft(
|
|
{ startSituation, targetState, roadmapNotes },
|
|
roadmap,
|
|
)
|
|
startSituation = patch.startSituation
|
|
targetState = patch.targetState
|
|
roadmapNotes = patch.roadmapNotes
|
|
setStartTargetReady(true)
|
|
}
|
|
const hydrated = hydrateProgressionGraphDraft({
|
|
artifact: {
|
|
...preservedArtifact,
|
|
goal_query: q,
|
|
progression_roadmap: roadmap,
|
|
start_situation: startSituation,
|
|
target_state: targetState,
|
|
roadmap_notes: roadmapNotes,
|
|
max_steps: majorCount || draft.maxSteps,
|
|
},
|
|
edges: currentEdges,
|
|
graphName: draft.graphName,
|
|
})
|
|
const withPhases = syncSlotPhasesFromRoadmap(hydrated, roadmap)
|
|
setDraft({ ...withPhases, goalQuery: q, maxSteps: majorCount || withPhases.maxSteps, dirty: true })
|
|
setSemanticBrief(res?.semantic_brief_summary || null)
|
|
} catch (e) {
|
|
setActionErr(e.message || 'Roadmap-Generierung fehlgeschlagen')
|
|
} finally {
|
|
setRoadmapLoading(false)
|
|
}
|
|
}
|
|
|
|
const buildEvaluateRequest = (synced) => {
|
|
const override = majorStepsToOverridePayload(synced.slots)
|
|
return {
|
|
query: (synced.goalQuery || '').trim(),
|
|
max_steps: synced.slots.length || draft?.maxSteps || 5,
|
|
include_path_qa: true,
|
|
include_llm_path_qa: true,
|
|
include_ai_gap_fill: true,
|
|
include_path_reorder: false,
|
|
include_llm_intent: false,
|
|
evaluate_only: true,
|
|
evaluate_steps: slotsToEvaluateSteps(synced),
|
|
roadmap_override: override,
|
|
slot_assignments: slotsToSlotAssignments(synced),
|
|
progression_graph_id: Number(graphId),
|
|
...roadmapStructuredPayload(synced.startSituation, synced.targetState, synced.roadmapNotes),
|
|
...catalogApiPayload,
|
|
}
|
|
}
|
|
|
|
const fetchPathEvaluate = async (synced) => api.suggestProgressionPath(buildEvaluateRequest(synced))
|
|
|
|
const applyEvaluateResult = (synced, res) => {
|
|
setSemanticBrief(res?.semantic_brief_summary || null)
|
|
setPathQa(res?.path_qa || null)
|
|
const { draft: evaluated, remainingOffers } = applyEvaluateResponseToDraft(synced, res)
|
|
return { draft: { ...evaluated, lastFindings: res?.path_qa || null }, remainingOffers }
|
|
}
|
|
|
|
const buildMatchRequestBase = (synced) => {
|
|
const override = majorStepsToOverridePayload(synced.slots)
|
|
return {
|
|
query: (synced.goalQuery || '').trim(),
|
|
max_steps: synced.slots.length,
|
|
include_llm_intent: true,
|
|
include_path_qa: true,
|
|
include_llm_path_qa: true,
|
|
include_path_reorder: false,
|
|
include_ai_gap_fill: true,
|
|
include_roadmap_preview: true,
|
|
include_llm_roadmap: false,
|
|
roadmap_first: true,
|
|
roadmap_override: override,
|
|
slot_assignments: slotsToSlotAssignments(synced),
|
|
retrieval_boost_exercise_ids: draftRetrievalBoostExerciseIds(synced),
|
|
progression_graph_id: Number(graphId),
|
|
...roadmapStructuredPayload(synced.startSituation, synced.targetState, synced.roadmapNotes),
|
|
...catalogApiPayload,
|
|
}
|
|
}
|
|
|
|
const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => {
|
|
setMatchNotice('Schritt 1/2: Pfad bewerten (wie „Graph bewerten“)…')
|
|
const baselineRes = await fetchPathEvaluate(synced)
|
|
const { draft: evaluated, remainingOffers } = applyEvaluateResult(synced, baselineRes)
|
|
setDraft(evaluated)
|
|
const mergedAfterEval = mergeGapOffersForDraft(evaluated, baselineRes)
|
|
setGapFillOffers(mergedAfterEval.length > 0 ? mergedAfterEval : remainingOffers)
|
|
|
|
setMatchNotice('Schritt 2/2: Slot-Alternativen prüfen…')
|
|
let compareRes
|
|
let reviewError = null
|
|
try {
|
|
const reviewRes = await api.suggestProgressionPath({
|
|
...buildEvaluateRequest(synced),
|
|
evaluate_only: false,
|
|
unified_slot_review: true,
|
|
baseline_evaluate_steps: slotsToEvaluateSteps(synced),
|
|
baseline_path_qa_snapshot: baselineRes?.path_qa || null,
|
|
baseline_quality_score:
|
|
baselineRes?.path_qa?.quality_score != null
|
|
? Number(baselineRes.path_qa.quality_score)
|
|
: null,
|
|
include_llm_intent: false,
|
|
auto_rematch_after_qa: false,
|
|
})
|
|
if (!reviewRes?.unified_slot_review) {
|
|
reviewError =
|
|
'Slot-Review nicht verfügbar — Backend neu starten/deployen (unified_slot_review fehlt).'
|
|
compareRes = buildProgressionComparePayload(baselineRes, {
|
|
...reviewRes,
|
|
unified_slot_review: true,
|
|
slot_reviews: [],
|
|
review_error: reviewError,
|
|
})
|
|
} else {
|
|
compareRes = buildProgressionComparePayload(baselineRes, reviewRes)
|
|
}
|
|
setGapFillOffers(mergeGapOffersForDraft(evaluated, baselineRes, reviewRes))
|
|
} catch (e) {
|
|
reviewError = e.message || 'Slot-Review fehlgeschlagen'
|
|
compareRes = buildProgressionComparePayload(baselineRes, {
|
|
unified_slot_review: true,
|
|
slot_reviews: [],
|
|
review_error: reviewError,
|
|
path_qa: baselineRes?.path_qa,
|
|
})
|
|
}
|
|
|
|
presentMatchCompare(compareRes, { source, reviewError })
|
|
return compareRes
|
|
}
|
|
|
|
const presentMatchCompare = (res, { source = 'manual', reviewError = null } = {}) => {
|
|
setSemanticBrief(res?.semantic_brief_summary || null)
|
|
setTargetSummary(res?.target_profile_summary || null)
|
|
setComparePayload(reviewError ? { ...res, review_error: reviewError } : res)
|
|
setCompareSource(source)
|
|
setProposedPathQa(res?.proposed_path_qa_pipeline || null)
|
|
setCompareOpen(true)
|
|
|
|
const baselineQa = res?.baseline_path_qa || null
|
|
const slotReviews = res?.slot_reviews || []
|
|
const autoCount = slotReviews.filter((r) => r?.library_alternative?.auto_select).length
|
|
const diffCount = autoCount || res?.slot_diff_count || 0
|
|
const rejectedCount = res?.slot_diff_count_rejected ?? rejectedCompareDiffs(res).length
|
|
const problemCount = res?.match_summary?.problem_slot_count
|
|
?? (res?.problem_slots ? Object.keys(res.problem_slots).length : 0)
|
|
const bPct = pathQaQualityPercent(baselineQa)
|
|
let notice = reviewError
|
|
? `Match: Dialog geöffnet — ${reviewError}`
|
|
: slotReviews.length > 0
|
|
? `Match: ${slotReviews.length} Slot(s) geprüft, ${autoCount} Empfehlung(en) vorausgewählt.`
|
|
: diffCount > 0
|
|
? `Match: ${diffCount} Verbesserung(en).`
|
|
: problemCount > 0
|
|
? `Match: ${problemCount} Schachstelle(n), keine bessere Bibliotheks-Alternative.`
|
|
: 'Match: Pfad geprüft — siehe Dialog.'
|
|
if (rejectedCount > 0) {
|
|
notice += ` ${rejectedCount} Vorschlag/Vorschläge verworfen (Verschlechterung oder neutral).`
|
|
}
|
|
const gapCount = collectGapOffersFromApiResponse(res).length
|
|
if (gapCount > 0) {
|
|
notice += ` ${gapCount} KI-Angebot(e) für leere Slots im Panel „Graph-Bewertung“.`
|
|
}
|
|
setMatchNotice(notice)
|
|
}
|
|
|
|
const runMatch = async () => {
|
|
const q = (draft?.goalQuery || '').trim()
|
|
if (q.length < 3) {
|
|
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
|
|
return
|
|
}
|
|
if (validMajorSteps.length < 2) {
|
|
alert('Mindestens zwei Slots mit Lernziel (je 3+ Zeichen) nötig.')
|
|
return
|
|
}
|
|
setMatching(true)
|
|
setActionErr('')
|
|
setMatchNotice('')
|
|
try {
|
|
const synced = syncProgressionRoadmapFromSlots(draft)
|
|
setProposedPathQa(null)
|
|
await runMatchCompareFlow(synced, { source: 'match' })
|
|
} catch (e) {
|
|
setActionErr(e.message || 'Übungs-Match fehlgeschlagen')
|
|
} finally {
|
|
setMatching(false)
|
|
}
|
|
}
|
|
|
|
const runOptimizeCompare = async () => {
|
|
const q = (draft?.goalQuery || '').trim()
|
|
if (q.length < 3) {
|
|
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
|
|
return
|
|
}
|
|
if (validMajorSteps.length < 2) {
|
|
alert('Mindestens zwei Slots mit Lernziel (je 3+ Zeichen) nötig.')
|
|
return
|
|
}
|
|
setComparing(true)
|
|
setActionErr('')
|
|
setMatchNotice('')
|
|
try {
|
|
const synced = syncProgressionRoadmapFromSlots(draft)
|
|
setProposedPathQa(null)
|
|
await runMatchCompareFlow(synced, { source: 'manual' })
|
|
} catch (e) {
|
|
setActionErr(e.message || 'Optimierungs-Vergleich fehlgeschlagen')
|
|
} finally {
|
|
setComparing(false)
|
|
}
|
|
}
|
|
|
|
const applyOptimizeCompare = async (selectedMajorIndices) => {
|
|
if (!comparePayload || !draft) return
|
|
setCompareApplying(true)
|
|
try {
|
|
const synced = syncProgressionRoadmapFromSlots(draft)
|
|
const nextDraft = comparePayload?.unified_slot_review
|
|
? applySelectedSlotSuggestions(synced, comparePayload, selectedMajorIndices)
|
|
: applySelectedCompareSteps(
|
|
synced,
|
|
comparePayload.proposed_steps || comparePayload.steps,
|
|
selectedMajorIndices,
|
|
)
|
|
const syncedNext = syncProgressionRoadmapFromSlots(nextDraft)
|
|
const evalRes = await fetchPathEvaluate(syncedNext)
|
|
const { draft: evaluated, remainingOffers } = applyEvaluateResult(syncedNext, evalRes)
|
|
setDraft({ ...evaluated, dirty: true })
|
|
const mergedOffers = mergeGapOffersForDraft(evaluated, comparePayload, evalRes)
|
|
setGapFillOffers(mergedOffers.length > 0 ? mergedOffers : remainingOffers)
|
|
setProposedPathQa(null)
|
|
setCompareOpen(false)
|
|
setComparePayload(null)
|
|
setMatchNotice('Ausgewählte Optimierungen übernommen — Pfad-QS neu bewertet.')
|
|
await saveProgressionGraphDraft(api, graphId, {
|
|
...evaluated,
|
|
lastFindings: evalRes?.path_qa || null,
|
|
})
|
|
setDraft((prev) => (prev ? { ...prev, dirty: false } : prev))
|
|
} catch (e) {
|
|
setActionErr(e.message || 'Übernahme fehlgeschlagen')
|
|
} finally {
|
|
setCompareApplying(false)
|
|
}
|
|
}
|
|
|
|
const runEvaluate = async () => {
|
|
const q = (draft?.goalQuery || '').trim()
|
|
if (q.length < 3) {
|
|
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
|
|
return
|
|
}
|
|
setEvaluating(true)
|
|
setActionErr('')
|
|
setProposedPathQa(null)
|
|
try {
|
|
const synced = syncProgressionRoadmapFromSlots(draft)
|
|
const res = await fetchPathEvaluate(synced)
|
|
const { draft: evaluated, remainingOffers } = applyEvaluateResult(synced, res)
|
|
setDraft(evaluated)
|
|
const mergedOffers = mergeGapOffersForDraft(evaluated, res)
|
|
setGapFillOffers(mergedOffers.length > 0 ? mergedOffers : remainingOffers)
|
|
} catch (e) {
|
|
setActionErr(e.message || 'Bewertung fehlgeschlagen')
|
|
} finally {
|
|
setEvaluating(false)
|
|
}
|
|
}
|
|
|
|
const handleSave = async () => {
|
|
if (!draft || !graphId) return
|
|
setBusy(true)
|
|
setActionErr('')
|
|
try {
|
|
await saveProgressionGraphDraft(api, graphId, { ...draft, lastFindings: pathQa })
|
|
await loadGraph()
|
|
if (typeof onSaved === 'function') await onSaved()
|
|
alert('Progressionsgraph gespeichert.')
|
|
} catch (e) {
|
|
setActionErr(e.message || 'Speichern fehlgeschlagen')
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
const handleApplyGapOffer = (offer, slotIndex) => {
|
|
setDraft((prev) => {
|
|
const next = applyGapOfferToDraft(prev, offer, { slotIndex })
|
|
return { ...next, dirty: true }
|
|
})
|
|
setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
|
|
}
|
|
|
|
const handleInsertGapSlot = (offer) => {
|
|
if ((draft?.slots?.length || 0) >= SLOT_MAX) {
|
|
alert(`Maximal ${SLOT_MAX} Slots — zuerst einen Slot entfernen.`)
|
|
return
|
|
}
|
|
setDraft((prev) => {
|
|
const next = applyGapOfferToDraft(prev, offer, { insertNewSlot: true })
|
|
return { ...next, dirty: true }
|
|
})
|
|
setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
|
|
}
|
|
|
|
const slotOfferContext = (slotIndex) => {
|
|
const slot = draft?.slots?.[slotIndex]
|
|
if (!draft || !slot) return null
|
|
const goalForAi =
|
|
buildSlotGapGoalForAi(draft, slotIndex, { goalQuery: draft.goalQuery }) ||
|
|
slot.learning_goal
|
|
const priorSlot =
|
|
slotIndex > 0 && draft.slots[slotIndex - 1]
|
|
? draft.slots[slotIndex - 1]
|
|
: null
|
|
return {
|
|
offer_id: `slot-${slotIndex}`,
|
|
title_hint: slot.primary?.exerciseTitle || slot.learning_goal,
|
|
roadmap_major_step_index: slot.majorStepIndex,
|
|
phase: slot.phase,
|
|
source: 'roadmap_unfilled',
|
|
goal_for_ai: goalForAi,
|
|
sketch: goalForAi,
|
|
from_title: priorSlot?.primary?.exerciseTitle || null,
|
|
}
|
|
}
|
|
|
|
const openGapFillPrep = (offer, slotIndex = null) => {
|
|
const defaultFocus = resolveDefaultFocusAreaId(targetSummary, focusAreas)
|
|
setActiveOffer(offer)
|
|
setActiveOfferSlotIndex(slotIndex)
|
|
setGapPrepTitle((offer?.title_hint || '').trim())
|
|
setGapPrepStageGoal(initialStageLearningGoalFromOffer(offer, gapContextParams))
|
|
setGapPrepSupplements('')
|
|
setGapPrepFocusAreaId(defaultFocus ? String(defaultFocus) : '')
|
|
setActivePlanningContextLines(gapOfferContextDisplayLines(offer, gapContextParams))
|
|
setGapPrepError('')
|
|
setGapPrepOpen(true)
|
|
}
|
|
|
|
const runGapFillAiSuggest = async (offer, prep, slotIndex) => {
|
|
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:\n${supplements}`.trim()
|
|
}
|
|
const focusId =
|
|
prep?.focusAreaId != null && Number.isFinite(Number(prep.focusAreaId))
|
|
? Number(prep.focusAreaId)
|
|
: resolveDefaultFocusAreaId(targetSummary, focusAreas)
|
|
if (!focusId) {
|
|
alert('Bitte einen Fokusbereich wählen.')
|
|
return
|
|
}
|
|
const focusRow = (focusAreas || []).find((x) => Number(x.id) === focusId)
|
|
const focusHint = (focusRow?.name || offer?.primary_topic || '').trim()
|
|
|
|
setGapAiBusy(true)
|
|
setGeneratingOfferId(offer?.offer_id || null)
|
|
setGapPrepError('')
|
|
setSlotQuickError('')
|
|
const contextParams = {
|
|
...gapContextParams,
|
|
stageLearningGoalOverride: stageGoal,
|
|
gapTrainerSupplements: supplements,
|
|
}
|
|
setActivePlanningContextLines(gapOfferContextDisplayLines(offer, contextParams))
|
|
try {
|
|
const planningContext = buildPathGapPlanningContextForAi({
|
|
offer,
|
|
...contextParams,
|
|
})
|
|
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.')
|
|
}
|
|
const aiDraft = aiPreviewToQuickCreateDraft(preview, {
|
|
title,
|
|
focusAreaId: focusId,
|
|
sketchPlain: goalText,
|
|
})
|
|
const enrichedOffer = {
|
|
...offer,
|
|
proposal_title: title,
|
|
ai_suggestion: aiDraft,
|
|
has_ai_payload: true,
|
|
}
|
|
const resolvedSlot =
|
|
slotIndex != null && Number.isFinite(slotIndex)
|
|
? slotIndex
|
|
: activeOfferSlotIndex != null && Number.isFinite(activeOfferSlotIndex)
|
|
? activeOfferSlotIndex
|
|
: null
|
|
if (resolvedSlot != null) {
|
|
setSlotQuickCreateIndex(resolvedSlot)
|
|
setDraft((prev) => applyGapOfferToDraft(prev, enrichedOffer, { slotIndex: resolvedSlot }))
|
|
}
|
|
setSlotQuickCreateDraft(aiDraft)
|
|
setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
|
|
setGapPrepOpen(false)
|
|
} catch (e) {
|
|
setGapPrepError(e.message || 'KI-Anlage fehlgeschlagen')
|
|
} finally {
|
|
setGapAiBusy(false)
|
|
setGeneratingOfferId(null)
|
|
}
|
|
}
|
|
|
|
const openSlotQuickCreate = (slotIndex) => {
|
|
const slot = draft?.slots?.[slotIndex]
|
|
if (!slot) return
|
|
const primary = slot.primary
|
|
const offer = slotOfferContext(slotIndex)
|
|
setSlotQuickCreateIndex(slotIndex)
|
|
setSlotQuickError('')
|
|
setActiveOffer(offer)
|
|
setActiveOfferSlotIndex(slotIndex)
|
|
setActivePlanningContextLines(gapOfferContextDisplayLines(offer, gapContextParams))
|
|
|
|
if (primary?.kind === 'proposal' && primary.aiSuggestion) {
|
|
const focusId = resolveDefaultFocusAreaId(targetSummary, focusAreas)
|
|
const draftReady = ensureQuickCreateDraftFromAiSuggestion(primary.aiSuggestion, {
|
|
title: primary.exerciseTitle || slot.learning_goal,
|
|
focusAreaId: focusId,
|
|
sketchPlain: (offer?.goal_for_ai || slot.learning_goal || '').trim(),
|
|
})
|
|
if (draftReady) {
|
|
setSlotQuickCreateDraft(draftReady)
|
|
return
|
|
}
|
|
}
|
|
|
|
openGapFillPrep(offer, slotIndex)
|
|
}
|
|
|
|
const applySlotQuickCreate = async () => {
|
|
if (slotQuickCreateIndex == null || !slotQuickCreateDraft) return
|
|
setSlotQuickSaving(true)
|
|
setSlotQuickError('')
|
|
try {
|
|
const payload = buildQuickCreateExercisePayloadFromDraft(slotQuickCreateDraft)
|
|
const created = await api.createExercise(payload)
|
|
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
|
|
setDraft((prev) => setSlotPrimaryLibrary(prev, slotQuickCreateIndex, created))
|
|
setSlotQuickCreateDraft(null)
|
|
setSlotQuickCreateIndex(null)
|
|
setActiveOffer(null)
|
|
setActiveOfferSlotIndex(null)
|
|
setActivePlanningContextLines([])
|
|
} catch (e) {
|
|
const msg = e.message || 'Übung konnte nicht angelegt werden'
|
|
setSlotQuickError(msg)
|
|
alert(msg)
|
|
} finally {
|
|
setSlotQuickSaving(false)
|
|
}
|
|
}
|
|
|
|
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
|
|
await runGapFillAiSuggest(
|
|
activeOffer,
|
|
{
|
|
title,
|
|
stageLearningGoal: (gapPrepStageGoal || '').trim(),
|
|
supplements: (gapPrepSupplements || '').trim(),
|
|
focusAreaId: focusId,
|
|
},
|
|
activeOfferSlotIndex,
|
|
)
|
|
}
|
|
|
|
if (loadErr) {
|
|
return (
|
|
<div className="card">
|
|
<p className="form-error">{loadErr}</p>
|
|
<Link to="/exercises" className="btn btn-secondary">
|
|
Zurück
|
|
</Link>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!draft) {
|
|
return (
|
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '40px' }}>
|
|
<div className="spinner" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{!embedded ? (
|
|
<div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '8px' }}>
|
|
<div>
|
|
<h2 style={{ margin: 0, fontSize: '1.15rem' }}>
|
|
{graphMeta?.name || draft.graphName || `Graph #${graphId}`}
|
|
</h2>
|
|
<p style={{ margin: '4px 0 0', fontSize: '12px', color: 'var(--text3)' }}>
|
|
Roadmap-Slots · KI-Match · Graph-Bewertung (max. {SLOT_MAX} Slots)
|
|
</p>
|
|
</div>
|
|
<Link to="/exercises" className="btn btn-secondary" style={{ fontSize: '12px' }}>
|
|
Zur Übersicht
|
|
</Link>
|
|
</div>
|
|
) : null}
|
|
|
|
{actionErr ? (
|
|
<p className="form-error" style={{ marginTop: 0 }}>
|
|
{actionErr}
|
|
</p>
|
|
) : null}
|
|
|
|
<div
|
|
className="progression-graph-editor-grid"
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: 'minmax(0, 1fr) minmax(280px, 340px)',
|
|
gap: '14px',
|
|
alignItems: 'start',
|
|
}}
|
|
>
|
|
<div>
|
|
<div className="card" style={{ marginBottom: '12px' }}>
|
|
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Ziel & Roadmap</h3>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
flexWrap: 'wrap',
|
|
gap: '10px',
|
|
alignItems: 'flex-end',
|
|
marginBottom: '10px',
|
|
}}
|
|
>
|
|
<div className="form-row" style={{ flex: '2 1 240px', marginBottom: 0 }}>
|
|
<label className="form-label">Ziel / Entwicklungsrichtung</label>
|
|
<input
|
|
className="form-input"
|
|
value={draft.goalQuery}
|
|
disabled={busy}
|
|
onChange={(e) => patchDraft((d) => ({ ...d, goalQuery: e.target.value }))}
|
|
placeholder="z. B. Von Erlernen bis zur Perfektion des Fußtritts Mae Geri …"
|
|
/>
|
|
</div>
|
|
<div className="form-row" style={{ flex: '0 1 100px', marginBottom: 0 }}>
|
|
<label className="form-label">Stufen (Slots)</label>
|
|
<input
|
|
type="number"
|
|
min={SLOT_MIN}
|
|
max={SLOT_MAX}
|
|
className="form-input"
|
|
value={draft.maxSteps}
|
|
disabled={busy}
|
|
onChange={(e) =>
|
|
patchDraft((d) => ({
|
|
...d,
|
|
maxSteps: Math.max(SLOT_MIN, Math.min(SLOT_MAX, Number(e.target.value) || 5)),
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
|
|
gap: '10px',
|
|
}}
|
|
>
|
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
|
<label className="form-label">Startpunkt / Ausgangslage</label>
|
|
<textarea
|
|
className="form-input"
|
|
rows={2}
|
|
value={draft.startSituation}
|
|
disabled={busy}
|
|
onChange={(e) => patchDraft((d) => ({ ...d, startSituation: e.target.value }))}
|
|
placeholder="z. B. gleichartige Steppbewegung, vorhersehbar"
|
|
/>
|
|
</div>
|
|
<div className="form-row" style={{ marginBottom: 0 }}>
|
|
<label className="form-label">Zielzustand</label>
|
|
<textarea
|
|
className="form-input"
|
|
rows={2}
|
|
value={draft.targetState}
|
|
disabled={busy}
|
|
onChange={(e) => patchDraft((d) => ({ ...d, targetState: e.target.value }))}
|
|
placeholder="z. B. dynamische Bewegung mit explosivem Angriff und Ausweichen"
|
|
/>
|
|
</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={draft.roadmapNotes}
|
|
disabled={busy}
|
|
onChange={(e) => patchDraft((d) => ({ ...d, roadmapNotes: e.target.value }))}
|
|
placeholder="optional: Altersgruppe, Kumite-Kontext, Trainingsfokus …"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<PlanningCatalogContextFields
|
|
catalogCtx={catalogCtx}
|
|
onPatchDimension={patchCatalogDimension}
|
|
focusAreas={focusAreas}
|
|
styleDirections={styleDirections}
|
|
trainingTypes={trainingTypes}
|
|
targetGroups={targetGroups}
|
|
disabled={busy}
|
|
helperText="Planungskontext steuert Bibliotheks-Matching (Fokusbereich, Stil, Trainingsstil, Zielgruppe) — unabhängig von Technik-Pfaden wie Mae Geri. Wird mit dem Graph gespeichert."
|
|
/>
|
|
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '8px 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. Manuelle Eingaben haben Vorrang.
|
|
</p>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '10px', alignItems: 'center' }}>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
disabled={busy || startTargetLoading}
|
|
onClick={runAnalyzeStartTarget}
|
|
title="Nur Ausgangslage, Zielzustand und Ergänzungen per KI — ohne Roadmap-Stufen"
|
|
>
|
|
{startTargetLoading ? 'Analyse…' : 'Start/Ziel analysieren'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
disabled={busy || roadmapLoading}
|
|
onClick={runRoadmapGenerate}
|
|
>
|
|
{roadmapLoading ? 'Roadmap…' : 'Roadmap generieren'}
|
|
</button>
|
|
{startTargetReady ? (
|
|
<span className="exercise-tag" style={{ borderColor: 'var(--accent)' }}>
|
|
Start/Ziel bereit
|
|
</span>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
disabled={busy || matching}
|
|
onClick={runMatch}
|
|
title={
|
|
draftHasLibrarySlotAssignments(draft)
|
|
? 'Voller Match mit Auto-Optimierung — bei Abweichungen öffnet sich der Vergleichsdialog'
|
|
: 'Bibliotheks-Übungen für leere Slots finden'
|
|
}
|
|
>
|
|
{matching ? 'Match…' : 'Übungen matchen'}
|
|
</button>
|
|
{draftHasLibrarySlotAssignments(draft) ? (
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
disabled={busy || comparing || matching}
|
|
onClick={runOptimizeCompare}
|
|
title="Aktuellen Pfad vs. voller Match mit Auto-Optimierung — du wählst pro Slot"
|
|
>
|
|
{comparing ? 'Vergleich…' : 'Optimierung vergleichen'}
|
|
</button>
|
|
) : null}
|
|
<button type="button" className="btn btn-primary" disabled={busy} onClick={handleSave}>
|
|
{busy ? 'Speichern…' : 'Graph speichern'}
|
|
</button>
|
|
</div>
|
|
{matchNotice ? (
|
|
<p style={{ margin: '8px 0 0', fontSize: '11px', color: 'var(--accent-dark)' }}>{matchNotice}</p>
|
|
) : null}
|
|
{draft.dirty ? (
|
|
<p style={{ margin: '8px 0 0', fontSize: '11px', color: 'var(--accent-dark)' }}>
|
|
Ungespeicherte Änderungen
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
|
<h3 style={{ margin: 0, fontSize: '1rem' }}>Slots ({draft.slots.length})</h3>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
style={{ fontSize: '12px' }}
|
|
disabled={busy || draft.slots.length >= SLOT_MAX}
|
|
onClick={handleAddSlot}
|
|
>
|
|
Slot am Ende
|
|
</button>
|
|
</div>
|
|
|
|
{draft.slots.map((slot, idx) => (
|
|
<ProgressionSlotCard
|
|
key={`slot-${idx}-${slot.learning_goal?.slice(0, 12) || 'x'}`}
|
|
slot={slot}
|
|
slotIndex={idx}
|
|
slotCount={draft.slots.length}
|
|
disabled={busy}
|
|
onPickPrimary={(i) => setPickContext({ slotIndex: i, role: 'primary' })}
|
|
onPickSibling={(i) => setPickContext({ slotIndex: i, role: 'sibling' })}
|
|
onClearPrimary={handleClearPrimary}
|
|
onRemoveSibling={handleRemoveSibling}
|
|
onPatchLearningGoal={handlePatchLearningGoal}
|
|
onPatchPhase={handlePatchPhase}
|
|
onMoveUp={(i) => handleMoveSlot(i, -1)}
|
|
onMoveDown={(i) => handleMoveSlot(i, 1)}
|
|
onRemoveSlot={handleRemoveSlot}
|
|
onInsertAfter={handleInsertAfter}
|
|
onCreateFromProposal={openSlotQuickCreate}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<ProgressionFindingsPanel
|
|
pathQa={pathQa}
|
|
gapFillOffers={gapFillOffers}
|
|
draft={draft}
|
|
slotCount={draft.slots.length}
|
|
loading={evaluating}
|
|
error=""
|
|
onEvaluate={runEvaluate}
|
|
onApplyGapOffer={handleApplyGapOffer}
|
|
onInsertGapSlot={handleInsertGapSlot}
|
|
onGenerateGapAi={openGapFillPrep}
|
|
onRematchSlots={runMatch}
|
|
onOptimizeCompare={runOptimizeCompare}
|
|
canOptimizeCompare={validMajorSteps.length >= 2}
|
|
optimizeCompareBusy={comparing}
|
|
rematchBusy={matching}
|
|
generatingOfferId={generatingOfferId}
|
|
aiBusy={gapAiBusy}
|
|
evaluateDisabled={busy || !draft.goalQuery?.trim()}
|
|
/>
|
|
</div>
|
|
|
|
{pickContext ? (
|
|
<ExercisePickerModal
|
|
open
|
|
onClose={() => setPickContext(null)}
|
|
onSelectExercise={handlePickExercise}
|
|
/>
|
|
) : null}
|
|
|
|
<ExerciseAiSuggestPreviewModal
|
|
draft={slotQuickCreateDraft}
|
|
onDraftChange={setSlotQuickCreateDraft}
|
|
onDiscard={() => {
|
|
if (slotQuickSaving) return
|
|
setSlotQuickCreateDraft(null)
|
|
setSlotQuickError('')
|
|
if (activeOffer) {
|
|
setGapPrepOpen(true)
|
|
} else {
|
|
setActivePlanningContextLines([])
|
|
}
|
|
}}
|
|
planningContextLines={activePlanningContextLines}
|
|
onApply={applySlotQuickCreate}
|
|
focusAreas={focusAreas}
|
|
skillsCatalog={skillsCatalog}
|
|
dialogTitle="Progressions-Slot — KI-Entwurf bearbeiten"
|
|
hint="Texte prüfen und anpassen, dann als Übung speichern — sie wird dem Slot zugeordnet."
|
|
applyLabel={slotQuickSaving ? 'Wird angelegt …' : 'Anlegen und Slot zuweisen'}
|
|
applyDisabled={slotQuickSaving}
|
|
zIndex={2100}
|
|
/>
|
|
|
|
<ProgressionOptimizeCompareModal
|
|
open={compareOpen}
|
|
comparison={comparePayload}
|
|
mode={compareSource}
|
|
onClose={() => {
|
|
if (compareApplying) return
|
|
setCompareOpen(false)
|
|
setComparePayload(null)
|
|
setProposedPathQa(null)
|
|
}}
|
|
onApplySelected={applyOptimizeCompare}
|
|
applying={compareApplying}
|
|
/>
|
|
|
|
<ExerciseGapFillPrepModal
|
|
open={gapPrepOpen}
|
|
offer={activeOffer}
|
|
onClose={() => {
|
|
if (gapAiBusy) return
|
|
setGapPrepOpen(false)
|
|
setGapPrepError('')
|
|
}}
|
|
title={gapPrepTitle}
|
|
onTitleChange={setGapPrepTitle}
|
|
stageLearningGoal={gapPrepStageGoal}
|
|
onStageLearningGoalChange={setGapPrepStageGoal}
|
|
supplements={gapPrepSupplements}
|
|
onSupplementsChange={setGapPrepSupplements}
|
|
focusAreaId={gapPrepFocusAreaId}
|
|
onFocusAreaChange={setGapPrepFocusAreaId}
|
|
focusAreas={focusAreas}
|
|
contextLines={gapOfferContextDisplayLines(activeOffer, gapContextParams)}
|
|
error={gapPrepError}
|
|
busy={gapAiBusy}
|
|
onSubmit={submitGapFillPrep}
|
|
/>
|
|
|
|
<style>{`
|
|
@media (max-width: 900px) {
|
|
.progression-graph-editor-grid {
|
|
grid-template-columns: 1fr !important;
|
|
}
|
|
}
|
|
`}</style>
|
|
</div>
|
|
)
|
|
}
|