Enhance Gap Fill Offer Handling and Progression Path Logic
All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m24s

- Updated `suggest_progression_path` to ensure unique gap fill offers are collected and added based on their IDs, improving the relevance of suggestions.
- Refined the logic for setting `slot_status` and handling `gap_offer` and `proposal_key` in steps, enhancing clarity in progression path management.
- Improved the `collectGapOffersFromApiResponse` function to consolidate gap offers from various sources, ensuring comprehensive offer retrieval.
- Enhanced the handling of unfilled slots in `applyMatchStepsToSlots`, ensuring proper assignment of proposals and gap offers.
- Added tests to validate the new logic for gap fill offers and slot assignments, ensuring robustness in path suggestion features.
This commit is contained in:
Lars 2026-06-11 13:13:46 +02:00
parent 6d130a7e09
commit de939481ba
2 changed files with 53 additions and 17 deletions

View File

@ -2107,7 +2107,7 @@ def suggest_progression_path(
max_steps=max_steps,
)
if body.include_ai_gap_fill:
seen_offer_ids = {o.get("offer_id") for o in gap_fill_offers}
seen_offer_ids = {o.get("offer_id") for o in gap_fill_offers if o.get("offer_id")}
for step in steps:
if step.get("exercise_id") is not None:
continue
@ -2115,6 +2115,12 @@ def suggest_progression_path(
major_idx = int(step["roadmap_major_step_index"])
except (TypeError, ValueError, KeyError):
continue
if step.get("gap_offer") and step.get("proposal_key"):
oid = step["gap_offer"].get("offer_id")
if oid and oid not in seen_offer_ids:
gap_fill_offers.append(dict(step["gap_offer"]))
seen_offer_ids.add(oid)
continue
stage_spec = next(
(
s
@ -2123,15 +2129,19 @@ def suggest_progression_path(
),
None,
)
if stage_spec is None:
continue
learning_goal = (
(stage_spec.learning_goal if stage_spec else None)
or step.get("roadmap_learning_goal")
or step.get("title")
or ""
).strip()
spec = {
"source": "roadmap_unfilled",
"insert_after_index": max(major_idx - 1, -1),
"roadmap_major_step_index": major_idx,
"phase": (step.get("roadmap_phase") or "vertiefung").strip().lower(),
"title_hint": (stage_spec.learning_goal or step.get("title") or f"Slot {major_idx + 1}")[:120],
"sketch": (stage_spec.learning_goal or "").strip(),
"title_hint": (learning_goal or f"Slot {major_idx + 1}")[:120],
"sketch": learning_goal,
"rationale": f"Slot {major_idx + 1} — keine passende Bibliotheks-Übung; KI-Entwurf für diese Stufe.",
}
offer = build_gap_fill_offer(
@ -2148,7 +2158,10 @@ def suggest_progression_path(
semantic_brief=semantic_brief,
),
)
if offer.get("offer_id") not in seen_offer_ids:
step["gap_offer"] = offer
step["proposal_key"] = offer.get("offer_id")
step["slot_status"] = "unfilled"
if offer.get("offer_id") and offer.get("offer_id") not in seen_offer_ids:
gap_fill_offers.append(offer)
seen_offer_ids.add(offer.get("offer_id"))

View File

@ -214,10 +214,21 @@ const GAP_OFFER_SOURCE_PRIORITY = {
}
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 || {}
return Array.isArray(qa?.gap_fill_offers) ? qa.gap_fill_offers : []
const out = []
const seen = new Set()
const add = (offer) => {
if (!offer || typeof offer !== 'object') return
const id = offer.offer_id || `${offer.source}-${offer.roadmap_major_step_index}`
if (seen.has(id)) return
seen.add(id)
out.push(offer)
}
for (const offer of res?.gap_fill_offers || []) add(offer)
for (const offer of res?.path_qa?.gap_fill_offers || []) add(offer)
for (const step of res?.steps || []) {
if (step?.gap_offer) add(step.gap_offer)
}
return out
}
/** Maximal ein Angebot pro Slot — Roadmap-Lücken vor Brücken/QS. */
@ -805,13 +816,25 @@ export function applyMatchStepsToSlots(draft, apiSteps) {
const isProposal = Boolean(step.is_ai_proposal) || step.exercise_id == null
const hasAiPayload = Boolean(step.ai_suggestion) || Boolean(step.proposal_key)
if (isProposal && !hasAiPayload) {
const wasLibrary =
nextSlots[idx].primary?.kind === 'library' && nextSlots[idx].primary.exerciseId != null
const mustClear = step.slot_status === 'unfilled' || step.slot_status === 'stripped'
if (!wasLibrary || mustClear) {
nextSlots[idx].primary = emptySlotExercise()
}
const isUnfilledSlot =
step.slot_status === 'unfilled' ||
step.slot_status === 'stripped' ||
step.roadmap_match_source === 'unfilled' ||
Boolean(step.gap_offer)
if (isProposal && !hasAiPayload && isUnfilledSlot) {
const offer = step.gap_offer || {}
nextSlots[idx].primary = proposalSlotExercise({
title:
offer.title_hint ||
step.roadmap_learning_goal ||
step.title ||
nextSlots[idx].learning_goal ||
`Slot ${idx + 1}`,
proposalKey: offer.offer_id || step.proposal_key || `roadmap-unfilled-${idx}`,
aiSuggestion: offer.ai_suggestion || null,
})
} else if (isProposal && !hasAiPayload) {
nextSlots[idx].primary = emptySlotExercise()
} else if (isProposal) {
nextSlots[idx].primary = proposalSlotExercise({
title: step.title || nextSlots[idx].learning_goal,