diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py
index f910df4..364448a 100644
--- a/backend/planning_exercise_path_builder.py
+++ b/backend/planning_exercise_path_builder.py
@@ -668,6 +668,47 @@ def _evaluate_steps_from_payload(
return steps
+def _build_evaluate_empty_slot_gap_specs(
+ steps: List[Dict[str, Any]],
+ *,
+ goal_query: str,
+) -> List[Dict[str, Any]]:
+ """Gap-Angebote für leere Roadmap-Slots im evaluate_only-Modus."""
+ specs: List[Dict[str, Any]] = []
+ for step in steps:
+ if step.get("exercise_id") is not None:
+ continue
+ major_idx = step.get("roadmap_major_step_index")
+ if major_idx is None:
+ continue
+ try:
+ roadmap_idx = int(major_idx)
+ except (TypeError, ValueError):
+ continue
+ phase = (step.get("roadmap_phase") or "vertiefung").strip().lower()
+ learning_goal = (step.get("roadmap_learning_goal") or step.get("title") or "").strip()
+ title_hint = learning_goal[:120] if learning_goal else f"Slot {roadmap_idx + 1}"
+ specs.append(
+ {
+ "source": "roadmap_unfilled",
+ "insert_after_index": max(roadmap_idx - 1, -1),
+ "gap": {
+ "expected_phase": phase,
+ "roadmap_major_step_index": roadmap_idx,
+ "learning_goal": learning_goal,
+ },
+ "phase": phase,
+ "title_hint": title_hint,
+ "sketch": learning_goal or title_hint,
+ "rationale": (
+ f"Slot {roadmap_idx + 1} ohne Übung — KI-Entwurf für diese Roadmap-Stufe."
+ ),
+ "roadmap_major_step_index": roadmap_idx,
+ }
+ )
+ return specs[:8]
+
+
def _run_evaluate_only_path_qa(
cur,
*,
@@ -728,6 +769,27 @@ def _run_evaluate_only_path_qa(
brief=semantic_brief,
goal_query=goal_query,
)
+ empty_slot_specs = _build_evaluate_empty_slot_gap_specs(
+ steps,
+ goal_query=goal_query,
+ )
+ seen_spec_keys = {
+ (
+ s.get("source"),
+ s.get("roadmap_major_step_index"),
+ s.get("insert_after_index"),
+ )
+ for s in gap_specs
+ }
+ for spec in empty_slot_specs:
+ key = (
+ spec.get("source"),
+ spec.get("roadmap_major_step_index"),
+ spec.get("insert_after_index"),
+ )
+ if key not in seen_spec_keys:
+ gap_specs.append(spec)
+ seen_spec_keys.add(key)
path_roadmap_snapshot = None
if roadmap_ctx:
path_roadmap_snapshot = build_progression_gap_snapshot(
diff --git a/backend/progression_graph_planning_artifact.py b/backend/progression_graph_planning_artifact.py
index 42988ad..0a75480 100644
--- a/backend/progression_graph_planning_artifact.py
+++ b/backend/progression_graph_planning_artifact.py
@@ -2,7 +2,7 @@
from __future__ import annotations
import json
-from typing import Any, Dict, Optional
+from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, field_validator
diff --git a/backend/tests/test_progression_graph_planning_artifact.py b/backend/tests/test_progression_graph_planning_artifact.py
index 523e6ea..1ae1932 100644
--- a/backend/tests/test_progression_graph_planning_artifact.py
+++ b/backend/tests/test_progression_graph_planning_artifact.py
@@ -34,3 +34,26 @@ def test_normalize_planning_roadmap_with_progression_roadmap():
def test_normalize_rejects_invalid_type():
with pytest.raises(ValueError, match="JSON-Objekt"):
normalize_planning_roadmap_payload("not-json")
+
+
+def test_normalize_slot_contents():
+ out = normalize_planning_roadmap_payload(
+ {
+ "goal_query": "Gerade-Tritt",
+ "max_steps": 3,
+ "slot_contents": [
+ {
+ "major_step_index": 0,
+ "primary": {"kind": "library", "exercise_id": 12, "title": "Grundstellung"},
+ "siblings": [],
+ },
+ {
+ "major_step_index": 1,
+ "primary": {"kind": "proposal", "title": "KI-Entwurf", "proposal_key": "p1"},
+ "siblings": [],
+ },
+ ],
+ }
+ )
+ assert len(out["slot_contents"]) == 2
+ assert out["slot_contents"][1]["primary"]["kind"] == "proposal"
diff --git a/frontend/src/components/ProgressionFindingsPanel.jsx b/frontend/src/components/ProgressionFindingsPanel.jsx
index af70fa5..ebfc1cb 100644
--- a/frontend/src/components/ProgressionFindingsPanel.jsx
+++ b/frontend/src/components/ProgressionFindingsPanel.jsx
@@ -1,7 +1,13 @@
/**
* Zentrales Findings-Panel für Progressionsgraph-QA (Phase B.2).
*/
-import React from 'react'
+import React, { useMemo, useState } from 'react'
+import {
+ offerCanExpandSlots,
+ offerNeedsNewSlot,
+ offerSourceLabel,
+ resolveOfferSlotIndex,
+} from '../utils/progressionGraphDraft'
function severityStyle(pathQa) {
if (!pathQa) return {}
@@ -12,20 +18,150 @@ function severityStyle(pathQa) {
}
}
+function GapOfferCard({
+ offer,
+ slotCount,
+ draft,
+ onApplyDraft,
+ onInsertSlot,
+ onGenerateAi,
+ generatingOfferId,
+ aiBusy,
+}) {
+ const defaultSlot = resolveOfferSlotIndex(draft, offer)
+ const [slotPick, setSlotPick] = useState(
+ defaultSlot != null && Number.isFinite(defaultSlot) ? String(defaultSlot) : '',
+ )
+ const needsInsert = offerNeedsNewSlot(offer)
+ const canInsert = offerCanExpandSlots(draft, offer)
+
+ const slotOptions = useMemo(() => {
+ const rows = []
+ for (let i = 0; i < slotCount; i += 1) {
+ rows.push({ value: String(i), label: `Slot ${i + 1}` })
+ }
+ return rows
+ }, [slotCount])
+
+ const applyToSlot = () => {
+ const idx = slotPick !== '' ? Number(slotPick) : defaultSlot
+ if (!Number.isFinite(idx)) {
+ alert('Bitte einen Slot wählen.')
+ return
+ }
+ onApplyDraft(offer, idx)
+ }
+
+ return (
+
+
+ {offerSourceLabel(offer.source)}
+ {offer.phase ? ` · ${offer.phase}` : ''}
+ {offer.has_ai_payload ? ' · KI-Entwurf bereit' : ''}
+
+ {offer.title_hint || offer.proposal_title || 'Übungsvorschlag'}
+ {offer.rationale ? (
+ {offer.rationale}
+ ) : null}
+ {offer.from_title && offer.to_title ? (
+
+ Zwischen „{offer.from_title}“ und „{offer.to_title}“
+
+ ) : null}
+
+
+
+ Ziel-Slot
+ setSlotPick(e.target.value)}
+ >
+ — wählen —
+ {slotOptions.map((o) => (
+
+ {o.label}
+
+ ))}
+
+
+
+
+
+ {offer.has_ai_payload ? (
+ applyToSlot()}
+ >
+ Entwurf in Slot
+
+ ) : (
+ onGenerateAi(offer, slotPick !== '' ? Number(slotPick) : defaultSlot)}
+ >
+ {generatingOfferId === offer.offer_id ? 'KI erstellt…' : 'KI anlegen'}
+
+ )}
+ applyToSlot()}
+ >
+ Platzhalter in Slot
+
+ {needsInsert ? (
+ onInsertSlot(offer)}
+ >
+ Neuen Slot einfügen
+
+ ) : null}
+
+
+ )
+}
+
export default function ProgressionFindingsPanel({
pathQa = null,
gapFillOffers = [],
+ draft = null,
+ slotCount = 0,
loading = false,
error = '',
onEvaluate,
onApplyGapOffer,
+ onInsertGapSlot,
+ onGenerateGapAi,
+ generatingOfferId = null,
+ aiBusy = false,
evaluateDisabled = false,
}) {
return (
Graph-Bewertung
- Prüft den aktuellen Slot-Stand (inkl. KI-Entwürfe) ohne erneutes Übungs-Matching.
+ Prüft den Slot-Stand und listet KI-Angebote für leere Stufen und Lücken.
) : null}
- {pathQa.roadmap_qa_mode === 'roadmap_first_lite' ? (
-
- QS an Roadmap gekoppelt (keine Brücken zwischen Major Steps).
-
- ) : null}
) : (
- Noch keine Bewertung. Slots befüllen und „Graph bewerten“ ausführen.
+ Noch keine Bewertung. Roadmap anlegen, dann „Graph bewerten“ oder „Übungen matchen“.
)}
- {Array.isArray(gapFillOffers) && gapFillOffers.length > 0 ? (
-
-
Lücken-Angebote
+
+
+ KI-Angebote {gapFillOffers.length > 0 ? `(${gapFillOffers.length})` : ''}
+
+ {gapFillOffers.length === 0 ? (
+
+ Keine offenen Angebote. Nach Match oder Bewertung erscheinen Vorschläge für leere Slots und Lücken.
+
+ ) : (
-
- ) : null}
+ )}
+
)
}
diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx
index 4c1bedc..1c71a24 100644
--- a/frontend/src/components/ProgressionGraphEditor.jsx
+++ b/frontend/src/components/ProgressionGraphEditor.jsx
@@ -5,22 +5,37 @@ 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 {
- applyGapOfferToSlot,
+ aiPreviewToQuickCreateDraft,
+ buildQuickCreateAiPreview,
+} from '../utils/exerciseAiQuickCreate'
+import {
+ buildPathGapPlanningContextForAi,
+ gapOfferContextDisplayLines,
+ initialStageLearningGoalFromOffer,
+} from '../utils/planningContextForExerciseAi'
+import {
+ addSlotToDraft,
+ applyGapOfferToDraft,
applyMatchStepsToSlots,
- buildPlanningArtifactFromDraft,
+ collectGapOffersFromApiResponse,
hydrateProgressionGraphDraft,
+ insertSlotInDraft,
librarySlotExercise,
majorStepsToOverridePayload,
- reindexMajorSteps,
+ moveSlotInDraft,
+ patchSlotInDraft,
+ removeSlotFromDraft,
saveProgressionGraphDraft,
+ SLOT_MAX,
+ slotsAsPathStepRows,
slotsToEvaluateSteps,
+ syncProgressionRoadmapFromSlots,
} from '../utils/progressionGraphDraft'
-const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion']
-
function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) {
const body = {}
const start = (startSituation || '').trim()
@@ -32,6 +47,16 @@ function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) {
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 }) {
const [graphMeta, setGraphMeta] = useState(null)
const [draft, setDraft] = useState(null)
@@ -44,6 +69,20 @@ export default function ProgressionGraphEditor({ graphId }) {
const [evaluating, setEvaluating] = useState(false)
const [matching, setMatching] = useState(false)
const [roadmapLoading, setRoadmapLoading] = useState(false)
+ const [semanticBrief, setSemanticBrief] = useState(null)
+ const [targetSummary, setTargetSummary] = useState(null)
+ const [focusAreas, setFocusAreas] = 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 loadGraph = useCallback(async () => {
if (!graphId) return
@@ -76,6 +115,21 @@ export default function ProgressionGraphEditor({ graphId }) {
loadGraph()
}, [loadGraph])
+ useEffect(() => {
+ let cancelled = false
+ api
+ .listFocusAreas({ status: 'active' })
+ .then((fa) => {
+ if (!cancelled) setFocusAreas(Array.isArray(fa) ? fa : [])
+ })
+ .catch(() => {
+ if (!cancelled) setFocusAreas([])
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
const patchDraft = useCallback((patchFn) => {
setDraft((prev) => {
if (!prev) return prev
@@ -84,6 +138,21 @@ export default function ProgressionGraphEditor({ graphId }) {
})
}, [])
+ 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
@@ -99,27 +168,68 @@ export default function ProgressionGraphEditor({ graphId }) {
if (!siblings.some((x) => x.exerciseId === entry.exerciseId)) siblings.push(entry)
return { ...s, siblings }
})
- return { ...d, slots }
+ return syncProgressionRoadmapFromSlots({ ...d, slots })
})
setPickContext(null)
}
const handlePatchLearningGoal = (slotIndex, value) => {
- patchDraft((d) => {
- const slots = d.slots.map((s, i) => (i === slotIndex ? { ...s, learning_goal: value } : s))
- const majorSteps = reindexMajorSteps(
- (d.majorSteps || []).map((m, i) => (i === slotIndex ? { ...m, learning_goal: value } : m)),
- )
- return { ...d, slots, majorSteps }
- })
+ 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,
+ i === slotIndex
+ ? {
+ ...s,
+ primary: {
+ kind: 'empty',
+ exerciseId: null,
+ variantId: null,
+ exerciseTitle: '',
+ variantName: null,
+ proposalKey: null,
+ aiSuggestion: null,
+ },
+ siblings: [],
+ }
+ : s,
)
- return { ...d, slots }
+ return syncProgressionRoadmapFromSlots({ ...d, slots })
})
}
@@ -133,48 +243,18 @@ export default function ProgressionGraphEditor({ graphId }) {
})
}
- const handleAddSlot = () => {
- patchDraft((d) => {
- const idx = d.slots.length
- const phase = ROADMAP_PHASES[Math.min(idx, ROADMAP_PHASES.length - 1)]
- const slot = {
- majorStepIndex: idx,
- phase,
- learning_goal: '',
- consolidates: [],
- rationale: '',
- load_profile: [],
- success_criteria: [],
- anti_patterns: [],
- exercise_type: '',
- primary: { kind: 'empty', exerciseId: null, variantId: null, exerciseTitle: '', variantName: null, proposalKey: null, aiSuggestion: null },
- siblings: [],
- }
- const major = {
- index: idx,
- phase,
- learning_goal: '',
- consolidates: [],
- rationale: '',
- load_profile: [],
- success_criteria: [],
- anti_patterns: [],
- exercise_type: '',
- }
- return {
- ...d,
- slots: [...d.slots, slot],
- majorSteps: [...(d.majorSteps || []), major],
- maxSteps: Math.max(d.maxSteps, idx + 1),
- }
- })
- }
-
const validMajorSteps = useMemo(() => {
if (!draft?.slots) return []
return draft.slots.filter((s) => (s.learning_goal || '').trim().length >= 3)
}, [draft?.slots])
+ const applyApiResponse = (res) => {
+ setSemanticBrief(res?.semantic_brief_summary || null)
+ setTargetSummary(res?.target_profile_summary || null)
+ setPathQa(res?.path_qa || null)
+ setGapFillOffers(collectGapOffersFromApiResponse(res))
+ }
+
const runRoadmapGenerate = async () => {
const q = (draft?.goalQuery || '').trim()
if (q.length < 3) {
@@ -200,26 +280,20 @@ export default function ProgressionGraphEditor({ graphId }) {
})
const roadmap = res?.progression_roadmap
if (!roadmap) throw new Error('Keine Roadmap in der Antwort')
- const majors = (roadmap?.roadmap?.major_steps || []).map((s, i) => ({
- index: i,
- phase: s.phase || ROADMAP_PHASES[Math.min(i, ROADMAP_PHASES.length - 1)],
- learning_goal: (s.learning_goal || '').trim(),
- consolidates: Array.isArray(s.consolidates) ? s.consolidates : [],
- rationale: s.rationale || '',
- load_profile: [],
- success_criteria: [],
- anti_patterns: [],
- exercise_type: '',
- }))
const hydrated = hydrateProgressionGraphDraft({
artifact: {
- ...buildPlanningArtifactFromDraft({ ...draft, progressionRoadmap: roadmap }),
+ goal_query: q,
progression_roadmap: roadmap,
+ start_situation: draft.startSituation,
+ target_state: draft.targetState,
+ roadmap_notes: draft.roadmapNotes,
+ max_steps: (roadmap?.roadmap?.major_steps || []).length || draft.maxSteps,
},
edges: [],
graphName: draft.graphName,
})
setDraft({ ...hydrated, goalQuery: q, dirty: true })
+ setSemanticBrief(res?.semantic_brief_summary || null)
} catch (e) {
setActionErr(e.message || 'Roadmap-Generierung fehlgeschlagen')
} finally {
@@ -240,20 +314,11 @@ export default function ProgressionGraphEditor({ graphId }) {
setMatching(true)
setActionErr('')
try {
- const override = majorStepsToOverridePayload(draft.slots.map((s) => ({
- index: s.majorStepIndex,
- phase: s.phase,
- learning_goal: s.learning_goal,
- consolidates: s.consolidates,
- rationale: s.rationale,
- load_profile: s.load_profile,
- success_criteria: s.success_criteria,
- anti_patterns: s.anti_patterns,
- exercise_type: s.exercise_type,
- })))
+ const synced = syncProgressionRoadmapFromSlots(draft)
+ const override = majorStepsToOverridePayload(synced.slots)
const res = await api.suggestProgressionPath({
query: q,
- max_steps: validMajorSteps.length,
+ max_steps: synced.slots.length,
include_llm_intent: true,
include_path_qa: true,
include_llm_path_qa: true,
@@ -268,15 +333,14 @@ export default function ProgressionGraphEditor({ graphId }) {
})
const next = applyMatchStepsToSlots(
{
- ...draft,
- progressionRoadmap: res?.progression_roadmap || draft.progressionRoadmap,
- pathSkillExpectations: res?.path_skill_expectations || draft.pathSkillExpectations,
+ ...synced,
+ progressionRoadmap: res?.progression_roadmap || synced.progressionRoadmap,
+ pathSkillExpectations: res?.path_skill_expectations || synced.pathSkillExpectations,
},
res?.steps,
)
setDraft(next)
- setPathQa(res?.path_qa || null)
- setGapFillOffers(Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : [])
+ applyApiResponse(res)
} catch (e) {
setActionErr(e.message || 'Übungs-Match fehlgeschlagen')
} finally {
@@ -293,38 +357,24 @@ export default function ProgressionGraphEditor({ graphId }) {
setEvaluating(true)
setActionErr('')
try {
+ const synced = syncProgressionRoadmapFromSlots(draft)
const override =
- validMajorSteps.length >= 2
- ? majorStepsToOverridePayload(
- draft.slots.map((s) => ({
- index: s.majorStepIndex,
- phase: s.phase,
- learning_goal: s.learning_goal,
- consolidates: s.consolidates,
- rationale: s.rationale,
- load_profile: s.load_profile,
- success_criteria: s.success_criteria,
- anti_patterns: s.anti_patterns,
- exercise_type: s.exercise_type,
- })),
- )
- : undefined
+ validMajorSteps.length >= 2 ? majorStepsToOverridePayload(synced.slots) : undefined
const res = await api.suggestProgressionPath({
query: q,
- max_steps: draft.slots.length || draft.maxSteps || 5,
+ 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(draft),
+ evaluate_steps: slotsToEvaluateSteps(synced),
roadmap_override: override,
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
})
- setPathQa(res?.path_qa || null)
- setGapFillOffers(Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : [])
+ applyApiResponse(res)
setDraft((prev) => (prev ? { ...prev, lastFindings: res?.path_qa || null } : prev))
} catch (e) {
setActionErr(e.message || 'Bewertung fehlgeschlagen')
@@ -348,14 +398,136 @@ export default function ProgressionGraphEditor({ graphId }) {
}
}
- const handleApplyGapOffer = (offer) => {
- const idx =
- offer?.roadmap_major_step_index != null ? Number(offer.roadmap_major_step_index) : null
- if (idx == null || !Number.isFinite(idx)) {
- alert('Angebot ohne Slot-Zuordnung — bitte manuell zuweisen.')
+ 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) => applyGapOfferToSlot(prev, idx, offer))
+ 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 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) : '')
+ 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('')
+ try {
+ const planningContext = buildPathGapPlanningContextForAi({
+ offer,
+ ...gapContextParams,
+ stageLearningGoalOverride: stageGoal,
+ gapTrainerSupplements: supplements,
+ })
+ 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,
+ }
+ setDraft((prev) => {
+ const next = applyGapOfferToDraft(prev, enrichedOffer, { slotIndex })
+ return { ...next, dirty: true }
+ })
+ setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
+ setGapPrepOpen(false)
+ setActiveOffer(null)
+ } catch (e) {
+ setGapPrepError(e.message || 'KI-Anlage fehlgeschlagen')
+ } finally {
+ setGapAiBusy(false)
+ setGeneratingOfferId(null)
+ }
+ }
+
+ 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) {
@@ -385,7 +557,7 @@ export default function ProgressionGraphEditor({ graphId }) {
{graphMeta?.name || draft.graphName || `Graph #${graphId}`}
- Slot-Editor · Roadmap = Struktur · ein Primärpfad + Schwestern
+ Slots verschieben, ergänzen · KI-Angebote zuordnen · dynamisch erweitern (max. {SLOT_MAX})
@@ -403,7 +575,7 @@ export default function ProgressionGraphEditor({ graphId }) {
className="progression-graph-editor-grid"
style={{
display: 'grid',
- gridTemplateColumns: 'minmax(0, 1fr) minmax(260px, 320px)',
+ gridTemplateColumns: 'minmax(0, 1fr) minmax(280px, 340px)',
gap: '14px',
alignItems: 'start',
}}
@@ -472,22 +644,34 @@ export default function ProgressionGraphEditor({ graphId }) {
Slots ({draft.slots.length})
- = 10} onClick={handleAddSlot}>
- Slot hinzufügen
+ = SLOT_MAX}
+ onClick={handleAddSlot}
+ >
+ Slot am Ende
{draft.slots.map((slot, idx) => (
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}
/>
))}
@@ -495,10 +679,16 @@ export default function ProgressionGraphEditor({ graphId }) {
@@ -511,6 +701,29 @@ export default function ProgressionGraphEditor({ graphId }) {
/>
) : null}
+ {
+ 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}
+ />
+