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

View File

@ -166,6 +166,24 @@ export function describeAiSkillRowForPreview(row, skillsCatalog) {
} }
/** KI-Vorschau → bearbeitbarer Entwurf (Rich-Text-Felder). */ /** 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 }) { export function aiPreviewToQuickCreateDraft(preview, { title, focusAreaId, sketchPlain }) {
const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain) const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain)
const instructionFields = { const instructionFields = {