Add AI Suggestion Handling for Roadmap Gaps and Enhance Progression Graph Components
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 45s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m17s

- Implemented functions to resolve neighboring steps based on major indices and build AI context for unfilled roadmap stages.
- Enhanced `try_suggest_ai_stage_step` to generate AI proposals for empty roadmap stages, improving user experience in gap filling.
- Updated `build_gap_fill_offer` to utilize major step neighbors for better context in offers related to unfilled slots.
- Added tests to ensure correct functionality of AI suggestion handling in the context of roadmap gaps.
- Incremented application version to reflect these updates.
This commit is contained in:
Lars 2026-06-10 15:56:30 +02:00
parent ee22b22970
commit e0ddfa6ce5
5 changed files with 490 additions and 48 deletions

View File

@ -18,6 +18,144 @@ from planning_exercise_semantics import PlanningSemanticBrief, brief_to_summary_
_logger = logging.getLogger("shinkan.planning_exercise_path_ai_fill")
def _resolve_neighbor_steps_by_major_index(
steps: Sequence[Mapping[str, Any]],
major_idx: int,
) -> Tuple[Optional[Mapping[str, Any]], Optional[Mapping[str, Any]]]:
"""Nachbarn im Pfad anhand roadmap_major_step_index (nicht Array-Position)."""
step_before: Optional[Mapping[str, Any]] = None
step_after: Optional[Mapping[str, Any]] = None
for step in steps:
raw = step.get("roadmap_major_step_index")
if raw is None:
continue
try:
mi = int(raw)
except (TypeError, ValueError):
continue
if mi < major_idx:
step_before = step
elif mi > major_idx and step_after is None:
step_after = step
return step_before, step_after
def _build_stage_ai_context(
*,
goal_query: str,
brief: PlanningSemanticBrief,
spec: Mapping[str, Any],
step_before: Optional[Mapping[str, Any]] = None,
step_after: Optional[Mapping[str, Any]] = None,
) -> ExerciseFormAiPromptContext:
"""KI-Kontext für unbesetzte Roadmap-Stufe (keine Brücke zwischen falschen Array-Indizes)."""
gap = dict(spec.get("gap") or {})
phase = spec.get("phase") or gap.get("expected_phase") or "vertiefung"
topic = (brief.primary_topic or "Technik").strip()
learning_goal = (
gap.get("learning_goal")
or spec.get("title_hint")
or spec.get("sketch")
or ""
).strip()
title = (spec.get("title_hint") or f"{topic}{phase}").strip()[:280]
goal_parts = [
f"Planungsziel: {goal_query}",
f"Roadmap-Stufe ({phase}): {learning_goal}",
"Erstelle eine Übung, die dieses Stufen-Lernziel erfüllt — keine generische Brücken-Übung.",
]
if step_before:
goal_parts.append(
f"Vorherige Stufe im Pfad: „{(step_before.get('title') or '').strip()}"
)
if step_after:
goal_parts.append(
f"Nächste Stufe im Pfad: „{(step_after.get('title') or '').strip()}"
)
sketch = (spec.get("sketch") or "").strip()
if sketch and sketch != learning_goal:
goal_parts.extend(["", f"Kontext: {sketch}"])
goal = "\n".join(goal_parts)
focus_hint = topic if brief.topic_type == "technique" else None
if brief.must_phrases:
focus_hint = ", ".join(brief.must_phrases[:2])
return ExerciseFormAiPromptContext(
title=title[:280],
goal=goal[:8000],
execution=None,
focus_hint=focus_hint,
)
def try_suggest_ai_stage_step(
cur,
*,
goal_query: str,
brief: PlanningSemanticBrief,
spec: Mapping[str, Any],
steps: Sequence[Mapping[str, Any]],
) -> Optional[Dict[str, Any]]:
"""KI-Vorschlag für leere Roadmap-Stufe."""
major_idx = spec.get("roadmap_major_step_index")
if major_idx is None:
return None
try:
mi = int(major_idx)
except (TypeError, ValueError):
return None
step_before, step_after = _resolve_neighbor_steps_by_major_index(steps, mi)
gap = dict(spec.get("gap") or {})
if not gap.get("expected_phase"):
gap["expected_phase"] = spec.get("phase") or "vertiefung"
gap["roadmap_major_step_index"] = mi
if not gap.get("learning_goal"):
gap["learning_goal"] = spec.get("title_hint") or spec.get("sketch")
ctx = _build_stage_ai_context(
goal_query=goal_query,
brief=brief,
spec=spec,
step_before=step_before,
step_after=step_after,
)
try:
ai_payload = run_exercise_form_ai_suggestion(cur, ctx=ctx)
except Exception:
_logger.exception("roadmap_unfilled AI suggest failed")
return None
if not ai_payload:
return None
summary_text = ""
summary_obj = ai_payload.get("summary")
if isinstance(summary_obj, dict):
summary_text = str(summary_obj.get("text") or "").strip()
elif isinstance(summary_obj, str):
summary_text = summary_obj.strip()
proposal_key = f"ai-{uuid.uuid4().hex[:10]}"
title = (ctx.title or spec.get("title_hint") or "KI-Vorschlag").strip()
return {
"exercise_id": None,
"proposal_key": proposal_key,
"variant_id": None,
"title": title,
"summary": summary_text or None,
"score": None,
"semantic_score": None,
"reasons": ["KI-Neuanlage für Roadmap-Stufe ohne Bibliothekstreffer"],
"variants": [],
"is_bridge": False,
"is_ai_proposal": True,
"ai_suggestion": dict(ai_payload),
"roadmap_major_step_index": mi,
"roadmap_phase": gap.get("expected_phase"),
"roadmap_learning_goal": gap.get("learning_goal"),
}
def _build_gap_ai_context(
*,
goal_query: str,
@ -291,13 +429,20 @@ def build_gap_fill_goal_text(
stage_goal = snap.get("stage_learning_goal") or spec.get("title_hint")
if stage_goal:
parts.append(f"Lernziel dieser Roadmap-Stufe: {stage_goal}")
parts.extend(
[
f"Entwicklungsphase dieser Übung: {snap.get('stage_phase') or phase}",
f"Erwarteter Entwicklungsbogen: {arc}",
f"Einordnung: didaktische Zwischenstufe zwischen „{from_title}“ und „{to_title}“.",
]
)
parts.append(f"Entwicklungsphase dieser Übung: {snap.get('stage_phase') or phase}")
parts.append(f"Erwarteter Entwicklungsbogen: {arc}")
if spec.get("source") == "roadmap_unfilled":
parts.append(
"Einordnung: Übung für diese Roadmap-Stufe — das Stufen-Lernziel steht im Vordergrund."
)
if step_a:
parts.append(f"Vorherige Stufe: „{from_title}")
if step_b:
parts.append(f"Nächste Stufe: „{to_title}")
else:
parts.append(
f"Einordnung: didaktische Zwischenstufe zwischen „{from_title}“ und „{to_title}“."
)
if snap.get("stage_load_profile"):
parts.append(f"Belastungsschwerpunkte: {', '.join(snap['stage_load_profile'])}")
if snap.get("stage_success_criteria"):
@ -346,10 +491,20 @@ def build_gap_fill_offer(
proposal: Optional[Mapping[str, Any]] = None,
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
) -> Dict[str, Any]:
source = spec.get("source")
idx = int(spec.get("insert_after_index") or 0)
major_idx = spec.get("roadmap_major_step_index")
if source == "roadmap_unfilled" and major_idx is not None:
try:
mi = int(major_idx)
except (TypeError, ValueError):
mi = idx
step_a, step_b = _resolve_neighbor_steps_by_major_index(steps, mi)
idx = mi
else:
step_a = steps[idx] if idx < len(steps) else None
step_b = steps[idx + 1] if idx + 1 < len(steps) else None
offer_id = f"{spec.get('source')}-{idx}-{uuid.uuid4().hex[:8]}"
step_a = steps[idx] if idx < len(steps) else None
step_b = steps[idx + 1] if idx + 1 < len(steps) else None
goal_for_ai = ""
if brief and goal_query:
goal_for_ai = build_gap_fill_goal_text(
@ -411,6 +566,38 @@ def apply_gap_fill_after_qa(
offers: List[Dict[str, Any]] = []
for spec in specs:
source = spec.get("source")
if source == "roadmap_unfilled":
proposal: Optional[Dict[str, Any]] = None
if include_ai_calls and len(proposals) < max_ai_proposals:
proposal = try_suggest_ai_stage_step(
cur,
goal_query=goal_query,
brief=brief,
spec=spec,
steps=out,
)
offer = build_gap_fill_offer(
spec=spec,
steps=out,
goal_query=goal_query,
brief=brief,
proposal=proposal,
roadmap_snapshot=roadmap_snapshot,
)
offers.append(offer)
if proposal and auto_insert_proposals:
proposals.append(
{
"roadmap_major_step_index": spec.get("roadmap_major_step_index"),
"proposal_key": proposal.get("proposal_key"),
"proposal_title": proposal.get("title"),
"offer_id": offer.get("offer_id"),
}
)
continue
idx = int(spec.get("insert_after_index") or 0)
if idx < 0 or idx >= len(out) - 1:
continue
@ -432,7 +619,7 @@ def apply_gap_fill_after_qa(
if not gap.get("expected_phase"):
gap["expected_phase"] = spec.get("phase") or "vertiefung"
proposal: Optional[Dict[str, Any]] = None
proposal = None
if include_ai_calls and len(proposals) < max_ai_proposals:
proposal = try_suggest_ai_bridge_step(
cur,
@ -508,4 +695,5 @@ __all__ = [
"collect_gap_fill_specs",
"insert_ai_proposals_for_gaps",
"try_suggest_ai_bridge_step",
"try_suggest_ai_stage_step",
]

View File

@ -136,6 +136,39 @@ def test_build_gap_fill_goal_text_includes_expected_skills():
assert "Timing" in text
def test_build_gap_fill_offer_roadmap_unfilled_uses_major_step_neighbors():
"""Leere Stufe 2 zwischen Stufe 1 und 3 — Nachbarn per roadmap_major_step_index."""
brief = build_semantic_brief("Kumite Beinarbeit")
steps = [
{
"title": "Explosive Angriffe",
"exercise_id": 10,
"roadmap_major_step_index": 0,
},
{
"title": "Kumite-Anwendung",
"exercise_id": 30,
"roadmap_major_step_index": 2,
},
]
offer = build_gap_fill_offer(
spec={
"source": "roadmap_unfilled",
"roadmap_major_step_index": 1,
"phase": "grundlage",
"title_hint": "Grundlegende Kumite-Steppbewegungen",
"gap": {"learning_goal": "Grundlegende Kumite-Steppbewegungen", "expected_phase": "grundlage"},
},
steps=steps,
goal_query="Kumite Beinarbeit",
brief=brief,
)
assert offer["roadmap_major_step_index"] == 1
assert "Explosive Angriffe" in offer["from_title"]
assert "Kumite-Anwendung" in offer["to_title"]
assert "Stufen-Lernziel" in offer["goal_for_ai"] or "Roadmap-Stufe" in offer["goal_for_ai"]
def test_build_gap_fill_offer_exposes_context_preview():
brief = build_semantic_brief("Kumite Beinarbeit")
offer = build_gap_fill_offer(

View File

@ -11,17 +11,20 @@ import ProgressionFindingsPanel from './ProgressionFindingsPanel'
import {
aiPreviewToQuickCreateDraft,
buildQuickCreateAiPreview,
buildQuickCreateExercisePayloadFromDraft,
} from '../utils/exerciseAiQuickCreate'
import {
buildPathGapPlanningContextForAi,
gapOfferContextDisplayLines,
initialStageLearningGoalFromOffer,
} from '../utils/planningContextForExerciseAi'
import ExerciseAiQuickCreateModal from './exercises/ExerciseAiQuickCreateModal'
import {
addSlotToDraft,
applyEvaluateResponseToDraft,
applyGapOfferToDraft,
applyMatchStepsToSlots,
collectGapOffersFromApiResponse,
applyMatchResponseToDraft,
buildPlanningArtifactFromDraft,
hydrateProgressionGraphDraft,
insertSlotInDraft,
librarySlotExercise,
@ -30,10 +33,12 @@ import {
patchSlotInDraft,
removeSlotFromDraft,
saveProgressionGraphDraft,
setSlotPrimaryLibrary,
SLOT_MAX,
slotsAsPathStepRows,
slotsToEvaluateSteps,
syncProgressionRoadmapFromSlots,
syncSlotPhasesFromRoadmap,
} from '../utils/progressionGraphDraft'
function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) {
@ -83,6 +88,12 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
const [gapPrepError, setGapPrepError] = useState('')
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 loadGraph = useCallback(async () => {
if (!graphId) return
@ -93,11 +104,13 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
api.getExerciseProgressionGraph(Number(graphId)),
api.listExerciseProgressionEdges(Number(graphId)),
])
const edgeList = Array.isArray(edges) ? edges : []
setCurrentEdges(edgeList)
setGraphMeta(graph)
setDraft(
hydrateProgressionGraphDraft({
artifact: graph?.planning_roadmap,
edges: Array.isArray(edges) ? edges : [],
edges: edgeList,
graphName: graph?.name,
}),
)
@ -248,13 +261,6 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
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) {
@ -280,8 +286,10 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
})
const roadmap = res?.progression_roadmap
if (!roadmap) throw new Error('Keine Roadmap in der Antwort')
const preservedArtifact = buildPlanningArtifactFromDraft(draft) || {}
const hydrated = hydrateProgressionGraphDraft({
artifact: {
...preservedArtifact,
goal_query: q,
progression_roadmap: roadmap,
start_situation: draft.startSituation,
@ -289,10 +297,11 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
roadmap_notes: draft.roadmapNotes,
max_steps: (roadmap?.roadmap?.major_steps || []).length || draft.maxSteps,
},
edges: [],
edges: currentEdges,
graphName: draft.graphName,
})
setDraft({ ...hydrated, goalQuery: q, dirty: true })
const withPhases = syncSlotPhasesFromRoadmap(hydrated, roadmap)
setDraft({ ...withPhases, goalQuery: q, dirty: true })
setSemanticBrief(res?.semantic_brief_summary || null)
} catch (e) {
setActionErr(e.message || 'Roadmap-Generierung fehlgeschlagen')
@ -331,16 +340,19 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
})
const next = applyMatchStepsToSlots(
const { draft: matched, remainingOffers } = applyMatchResponseToDraft(
{
...synced,
progressionRoadmap: res?.progression_roadmap || synced.progressionRoadmap,
pathSkillExpectations: res?.path_skill_expectations || synced.pathSkillExpectations,
},
res?.steps,
res,
)
setDraft(next)
applyApiResponse(res)
setDraft(matched)
setSemanticBrief(res?.semantic_brief_summary || null)
setTargetSummary(res?.target_profile_summary || null)
setPathQa(res?.path_qa || null)
setGapFillOffers(remainingOffers)
} catch (e) {
setActionErr(e.message || 'Übungs-Match fehlgeschlagen')
} finally {
@ -374,8 +386,11 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
})
applyApiResponse(res)
setDraft((prev) => (prev ? { ...prev, lastFindings: res?.path_qa || null } : prev))
setSemanticBrief(res?.semantic_brief_summary || null)
setPathQa(res?.path_qa || null)
const { draft: evaluated, remainingOffers } = applyEvaluateResponseToDraft(synced, res)
setDraft({ ...evaluated, lastFindings: res?.path_qa || null })
setGapFillOffers(remainingOffers)
} catch (e) {
setActionErr(e.message || 'Bewertung fehlgeschlagen')
} finally {
@ -492,10 +507,12 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
ai_suggestion: aiDraft,
has_ai_payload: true,
}
setDraft((prev) => {
const next = applyGapOfferToDraft(prev, enrichedOffer, { slotIndex })
return { ...next, dirty: true }
})
setDraft((prev) => applyGapOfferToDraft(prev, enrichedOffer, { slotIndex }))
if (slotIndex != null && Number.isFinite(slotIndex)) {
setSlotQuickCreateIndex(slotIndex)
setSlotQuickCreateDraft(aiDraft)
setSlotQuickCreateOpen(true)
}
setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
setGapPrepOpen(false)
setActiveOffer(null)
@ -507,6 +524,50 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
}
}
const openSlotQuickCreate = (slotIndex) => {
const slot = draft?.slots?.[slotIndex]
if (!slot) return
const primary = slot.primary
setSlotQuickCreateIndex(slotIndex)
setSlotQuickError('')
if (primary?.kind === 'proposal' && primary.aiSuggestion) {
setSlotQuickCreateDraft(primary.aiSuggestion)
setSlotQuickCreateOpen(true)
return
}
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,
)
}
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))
setSlotQuickCreateOpen(false)
setSlotQuickCreateDraft(null)
setSlotQuickCreateIndex(null)
} catch (e) {
setSlotQuickError(e.message || 'Übung konnte nicht angelegt werden')
} finally {
setSlotQuickSaving(false)
}
}
const submitGapFillPrep = async () => {
const title = (gapPrepTitle || '').trim()
if (title.length < 3) {
@ -675,6 +736,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
onMoveDown={(i) => handleMoveSlot(i, 1)}
onRemoveSlot={handleRemoveSlot}
onInsertAfter={handleInsertAfter}
onCreateFromProposal={openSlotQuickCreate}
/>
))}
</div>
@ -704,6 +766,22 @@ 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 || ''}
draft={slotQuickCreateDraft}
onDraftChange={setSlotQuickCreateDraft}
busy={slotQuickSaving}
error={slotQuickError}
onSubmit={applySlotQuickCreate}
/>
<ExerciseGapFillPrepModal
open={gapPrepOpen}
offer={activeOffer}

View File

@ -25,6 +25,7 @@ export default function ProgressionSlotCard({
onMoveDown,
onRemoveSlot,
onInsertAfter,
onCreateFromProposal,
disabled = false,
}) {
const { primary, siblings = [], phase, learning_goal: learningGoal } = slot
@ -133,6 +134,12 @@ export default function ProgressionSlotCard({
<span style={{ color: 'var(--text3)', fontWeight: 400 }}>{` · ${primary.variantName}`}</span>
) : null}
</div>
{primary.kind === 'proposal' && primary.aiSuggestion?.summary?.text ? (
<p style={{ margin: '6px 0 0', fontSize: '12px', color: 'var(--text2)', lineHeight: 1.4 }}>
{primary.aiSuggestion.summary.text.slice(0, 220)}
{primary.aiSuggestion.summary.text.length > 220 ? '…' : ''}
</p>
) : null}
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', marginTop: '8px' }}>
<button
type="button"
@ -143,6 +150,17 @@ export default function ProgressionSlotCard({
>
{primary.kind === 'empty' ? 'Übung wählen' : 'Übung ändern'}
</button>
{primary.kind === 'proposal' && typeof onCreateFromProposal === 'function' ? (
<button
type="button"
className="btn btn-primary"
style={{ fontSize: '12px', padding: '4px 10px' }}
disabled={disabled}
onClick={() => onCreateFromProposal(slotIndex)}
>
{primary.aiSuggestion ? 'Als Übung anlegen' : 'Mit KI anlegen'}
</button>
) : null}
{primary.kind !== 'empty' ? (
<button
type="button"

View File

@ -161,21 +161,76 @@ export function offerCanExpandSlots(draft, offer) {
return (draft.slots?.length || 0) < SLOT_MAX
}
const GAP_OFFER_SOURCE_PRIORITY = {
roadmap_unfilled: 0,
unfilled_gap: 1,
llm_suggested: 2,
off_topic: 3,
}
export function collectGapOffersFromApiResponse(res) {
const top = Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : []
if (top.length) return top
const qa = res?.path_qa || {}
const merged = [
...(Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : []),
...(Array.isArray(qa?.gap_fill_offers) ? qa.gap_fill_offers : []),
]
const seen = new Set()
return merged.filter((offer) => {
const key = offer?.offer_id || `${offer?.source}-${offer?.title_hint}`
if (!key || seen.has(key)) return false
seen.add(key)
return Array.isArray(qa?.gap_fill_offers) ? qa.gap_fill_offers : []
}
/** Maximal ein Angebot pro Slot — Roadmap-Lücken vor Brücken/QS. */
export function dedupeGapOffersBySlot(offers, draft) {
const bySlot = new Map()
for (const offer of offers || []) {
const idx = resolveOfferSlotIndex(draft, offer)
if (idx == null || !Number.isFinite(idx) || idx < 0) continue
const existing = bySlot.get(idx)
const prio = GAP_OFFER_SOURCE_PRIORITY[offer?.source] ?? 9
const existingPrio = existing ? (GAP_OFFER_SOURCE_PRIORITY[existing?.source] ?? 9) : 99
if (!existing || prio < existingPrio) {
bySlot.set(idx, offer)
}
}
return Array.from(bySlot.entries())
.sort(([a], [b]) => a - b)
.map(([, offer]) => offer)
}
/** Angebote nur für Slots ohne belegte Primary (Bibliothek oder KI-Entwurf). */
export function filterGapOffersForUnfilledSlots(draft, offers) {
return (offers || []).filter((offer) => {
const idx = resolveOfferSlotIndex(draft, offer)
if (idx == null || idx < 0 || idx >= (draft?.slots?.length || 0)) return true
const p = draft.slots[idx]?.primary
if (p?.kind === 'library' && p.exerciseId != null) return false
if (p?.kind === 'proposal') return false
return true
})
}
export function syncSlotPhasesFromRoadmap(draft, progressionRoadmap) {
if (!progressionRoadmap) return draft
const majors = mapMajorStepsFromApi(progressionRoadmap)
if (!majors.length) return draft
const slots = (draft.slots || []).map((slot, i) => {
const m = majors[i]
if (!m) return slot
return {
...slot,
phase: m.phase || slot.phase,
learning_goal: m.learning_goal || slot.learning_goal,
load_profile: m.load_profile?.length ? m.load_profile : slot.load_profile,
success_criteria: m.success_criteria?.length ? m.success_criteria : slot.success_criteria,
anti_patterns: m.anti_patterns?.length ? m.anti_patterns : slot.anti_patterns,
exercise_type: m.exercise_type || slot.exercise_type,
}
})
return syncProgressionRoadmapFromSlots({
...draft,
slots,
progressionRoadmap,
majorSteps: majors,
maxSteps: Math.max(slots.length, majors.length),
})
}
export function slotsAsPathStepRows(draft) {
return (draft.slots || []).map((slot) => ({
exerciseId: slot.primary?.exerciseId ?? null,
@ -644,19 +699,23 @@ export function slotsToEvaluateSteps(draft) {
export function applyMatchStepsToSlots(draft, apiSteps) {
const steps = Array.isArray(apiSteps) ? apiSteps : []
const nextSlots = (draft.slots || []).map((slot) => ({ ...slot, primary: { ...slot.primary }, siblings: [...(slot.siblings || [])] }))
const nextSlots = (draft.slots || []).map((slot) => ({
...slot,
primary: { ...slot.primary },
siblings: [...(slot.siblings || [])],
}))
for (const step of steps) {
const idx =
step.roadmap_major_step_index != null
? Number(step.roadmap_major_step_index)
: steps.indexOf(step)
if (!Number.isFinite(idx) || idx < 0 || idx >= nextSlots.length) continue
if (step.roadmap_major_step_index == null || !Number.isFinite(Number(step.roadmap_major_step_index))) {
continue
}
const idx = Number(step.roadmap_major_step_index)
if (idx < 0 || idx >= nextSlots.length) continue
const isProposal = Boolean(step.is_ai_proposal) || step.exercise_id == null
if (isProposal) {
nextSlots[idx].primary = proposalSlotExercise({
title: step.title,
title: step.title || nextSlots[idx].learning_goal,
proposalKey: step.proposal_key,
aiSuggestion: step.ai_suggestion,
})
@ -672,6 +731,72 @@ export function applyMatchStepsToSlots(draft, apiSteps) {
return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true })
}
/** Lücken-Angebote in leere Slots legen; Panel nur für verbleibende Lücken. */
export function applyGapOffersFromResponse(draft, res) {
let next = draft
if (res?.progression_roadmap) {
next = syncSlotPhasesFromRoadmap(next, res.progression_roadmap)
}
const offers = dedupeGapOffersBySlot(collectGapOffersFromApiResponse(res), next)
const placedIds = new Set()
for (const offer of offers) {
const idx = resolveOfferSlotIndex(next, offer)
if (idx == null || idx < 0 || idx >= (next.slots?.length || 0)) continue
const primary = next.slots[idx]?.primary
if (primary?.kind === 'library' && primary.exerciseId != null) continue
if (primary?.kind === 'proposal') continue
next = applyGapOfferToSlot(next, idx, offer)
if (offer?.offer_id) placedIds.add(offer.offer_id)
}
const remainingOffers = filterGapOffersForUnfilledSlots(
next,
dedupeGapOffersBySlot(
collectGapOffersFromApiResponse(res).filter((o) => !placedIds.has(o?.offer_id)),
next,
),
)
return { draft: { ...next, dirty: true }, remainingOffers }
}
/** Match-Antwort: Schritte + Lücken-Angebote direkt in Slots (wie früher im Pfad-Wizard sichtbar). */
export function applyMatchResponseToDraft(draft, res) {
let next = applyMatchStepsToSlots(draft, res?.steps)
if (res?.progression_roadmap) {
next = {
...syncSlotPhasesFromRoadmap(next, res.progression_roadmap),
pathSkillExpectations: res?.path_skill_expectations || next.pathSkillExpectations,
}
}
const { draft: withOffers, remainingOffers } = applyGapOffersFromResponse(next, res)
return { draft: withOffers, remainingOffers }
}
/** Evaluate-Antwort: KI-Angebote in leere Slots (ohne Schritte neu zu matchen). */
export function applyEvaluateResponseToDraft(draft, res) {
return applyGapOffersFromResponse(draft, res)
}
export function setSlotPrimaryLibrary(draft, slotIndex, exercise) {
if (!exercise?.id) return draft
const slots = (draft.slots || []).map((s, i) =>
i === slotIndex
? {
...s,
primary: librarySlotExercise({
exerciseId: exercise.id,
exerciseTitle: exercise.title || `Übung #${exercise.id}`,
}),
}
: s,
)
return syncProgressionRoadmapFromSlots({ ...draft, slots, dirty: true })
}
export function applyGapOfferToSlot(draft, slotIndex, offer, aiSuggestion = null) {
const nextSlots = (draft.slots || []).map((s) => ({ ...s, primary: { ...s.primary }, siblings: [...(s.siblings || [])] }))
if (slotIndex < 0 || slotIndex >= nextSlots.length) return draft