From de939481ba60ec5dbe6d4e518b7a46ae920bda8a Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Jun 2026 13:13:46 +0200 Subject: [PATCH] Enhance Gap Fill Offer Handling and Progression Path Logic - 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. --- backend/planning_exercise_path_builder.py | 25 +++++++++--- frontend/src/utils/progressionGraphDraft.js | 45 ++++++++++++++++----- 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 8dd6327..4f2c912 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -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")) diff --git a/frontend/src/utils/progressionGraphDraft.js b/frontend/src/utils/progressionGraphDraft.js index 9a98e79..4c57960 100644 --- a/frontend/src/utils/progressionGraphDraft.js +++ b/frontend/src/utils/progressionGraphDraft.js @@ -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,