Enhance Progression Graph Editor with Skills Catalog and AI Draft Handling
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m21s

- Introduced skills catalog management in the `ProgressionGraphEditor`, allowing for improved context in AI suggestions.
- Updated the loading mechanism to fetch both focus areas and skills catalog concurrently, enhancing performance.
- Implemented `ensureQuickCreateDraftFromAiSuggestion` utility to streamline the creation of drafts from AI suggestions.
- Enhanced slot management by integrating AI context into the gap fill preparation process, improving user experience.
- Incremented application version to reflect these updates.
This commit is contained in:
Lars 2026-06-10 16:04:15 +02:00
parent e0ddfa6ce5
commit 3b483346de
2 changed files with 111 additions and 46 deletions

View File

@ -12,13 +12,14 @@ import {
aiPreviewToQuickCreateDraft,
buildQuickCreateAiPreview,
buildQuickCreateExercisePayloadFromDraft,
ensureQuickCreateDraftFromAiSuggestion,
} from '../utils/exerciseAiQuickCreate'
import {
buildPathGapPlanningContextForAi,
gapOfferContextDisplayLines,
initialStageLearningGoalFromOffer,
} from '../utils/planningContextForExerciseAi'
import ExerciseAiQuickCreateModal from './exercises/ExerciseAiQuickCreateModal'
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
import {
addSlotToDraft,
applyEvaluateResponseToDraft,
@ -77,6 +78,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
const [semanticBrief, setSemanticBrief] = useState(null)
const [targetSummary, setTargetSummary] = useState(null)
const [focusAreas, setFocusAreas] = useState([])
const [skillsCatalog, setSkillsCatalog] = useState([])
const [activeOffer, setActiveOffer] = useState(null)
const [activeOfferSlotIndex, setActiveOfferSlotIndex] = useState(null)
@ -89,11 +91,11 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
const [generatingOfferId, setGeneratingOfferId] = useState(null)
const [gapAiBusy, setGapAiBusy] = useState(false)
const [currentEdges, setCurrentEdges] = useState([])
const [slotQuickCreateOpen, setSlotQuickCreateOpen] = useState(false)
const [slotQuickCreateIndex, setSlotQuickCreateIndex] = useState(null)
const [slotQuickCreateDraft, setSlotQuickCreateDraft] = useState(null)
const [slotQuickSaving, setSlotQuickSaving] = useState(false)
const [slotQuickError, setSlotQuickError] = useState('')
const [activePlanningContextLines, setActivePlanningContextLines] = useState([])
const loadGraph = useCallback(async () => {
if (!graphId) return
@ -130,13 +132,20 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
useEffect(() => {
let cancelled = false
api
.listFocusAreas({ status: 'active' })
.then((fa) => {
if (!cancelled) setFocusAreas(Array.isArray(fa) ? fa : [])
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([])
if (!cancelled) {
setFocusAreas([])
setSkillsCatalog([])
}
})
return () => {
cancelled = true
@ -434,6 +443,20 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
}
const slotOfferContext = (slotIndex) => {
const slot = draft?.slots?.[slotIndex]
if (!slot) return 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: slot.learning_goal,
sketch: slot.learning_goal,
}
}
const openGapFillPrep = (offer, slotIndex = null) => {
const defaultFocus = resolveDefaultFocusAreaId(targetSummary, focusAreas)
setActiveOffer(offer)
@ -442,6 +465,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
setGapPrepStageGoal(initialStageLearningGoalFromOffer(offer, gapContextParams))
setGapPrepSupplements('')
setGapPrepFocusAreaId(defaultFocus ? String(defaultFocus) : '')
setActivePlanningContextLines(gapOfferContextDisplayLines(offer, gapContextParams))
setGapPrepError('')
setGapPrepOpen(true)
}
@ -472,12 +496,17 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
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,
...gapContextParams,
stageLearningGoalOverride: stageGoal,
gapTrainerSupplements: supplements,
...contextParams,
})
const aiRes = await api.suggestExerciseAi({
title,
@ -507,15 +536,19 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
ai_suggestion: aiDraft,
has_ai_payload: true,
}
setDraft((prev) => applyGapOfferToDraft(prev, enrichedOffer, { slotIndex }))
if (slotIndex != null && Number.isFinite(slotIndex)) {
setSlotQuickCreateIndex(slotIndex)
setSlotQuickCreateDraft(aiDraft)
setSlotQuickCreateOpen(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)
setActiveOffer(null)
} catch (e) {
setGapPrepError(e.message || 'KI-Anlage fehlgeschlagen')
} finally {
@ -528,25 +561,27 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
const slot = draft?.slots?.[slotIndex]
if (!slot) return
const primary = slot.primary
const offer = slotOfferContext(slotIndex)
setSlotQuickCreateIndex(slotIndex)
setSlotQuickError('')
if (primary?.kind === 'proposal' && primary.aiSuggestion) {
setSlotQuickCreateDraft(primary.aiSuggestion)
setSlotQuickCreateOpen(true)
return
}
setActiveOffer(offer)
setActiveOfferSlotIndex(slotIndex)
openGapFillPrep(
{
offer_id: `slot-${slotIndex}`,
title_hint: primary?.exerciseTitle || slot.learning_goal,
roadmap_major_step_index: slot.majorStepIndex,
phase: slot.phase,
source: 'roadmap_unfilled',
goal_for_ai: slot.learning_goal,
},
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 () => {
@ -558,11 +593,15 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
const created = await api.createExercise(payload)
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
setDraft((prev) => setSlotPrimaryLibrary(prev, slotQuickCreateIndex, created))
setSlotQuickCreateOpen(false)
setSlotQuickCreateDraft(null)
setSlotQuickCreateIndex(null)
setActiveOffer(null)
setActiveOfferSlotIndex(null)
setActivePlanningContextLines([])
} catch (e) {
setSlotQuickError(e.message || 'Übung konnte nicht angelegt werden')
const msg = e.message || 'Übung konnte nicht angelegt werden'
setSlotQuickError(msg)
alert(msg)
} finally {
setSlotQuickSaving(false)
}
@ -766,20 +805,28 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
/>
) : null}
<ExerciseAiQuickCreateModal
open={slotQuickCreateOpen}
onClose={() => {
if (slotQuickSaving) return
setSlotQuickCreateOpen(false)
setSlotQuickCreateDraft(null)
setSlotQuickError('')
}}
title={draft?.slots?.[slotQuickCreateIndex]?.primary?.exerciseTitle || ''}
<ExerciseAiSuggestPreviewModal
draft={slotQuickCreateDraft}
onDraftChange={setSlotQuickCreateDraft}
busy={slotQuickSaving}
error={slotQuickError}
onSubmit={applySlotQuickCreate}
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}
/>
<ExerciseGapFillPrepModal

View File

@ -166,6 +166,24 @@ export function describeAiSkillRowForPreview(row, skillsCatalog) {
}
/** KI-Vorschau → bearbeitbarer Entwurf (Rich-Text-Felder). */
/** Rohes API-ai_suggestion oder bereits bearbeiteter Entwurf → Preview-Entwurf. */
export function ensureQuickCreateDraftFromAiSuggestion(
aiSuggestion,
{ title = '', focusAreaId = '', sketchPlain = '' } = {},
) {
if (!aiSuggestion || typeof aiSuggestion !== 'object') return null
if (aiSuggestion.instructionFields) return aiSuggestion
const preview = buildQuickCreateAiPreview(aiSuggestion, { sketchPlain })
if (
!preview.hasSummaryProposal &&
!preview.hasInstructionChoices &&
!preview.hasSkillChoices
) {
return null
}
return aiPreviewToQuickCreateDraft(preview, { title, focusAreaId, sketchPlain })
}
export function aiPreviewToQuickCreateDraft(preview, { title, focusAreaId, sketchPlain }) {
const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain)
const instructionFields = {