diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 384475a..5c2c700 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -1272,9 +1272,35 @@ def _match_roadmap_slot( else: step["slot_status"] = "matched" step["roadmap_match_source"] = "stage_spec" + if step.get("roadmap_match_source") != "slot_best_match" and not _roadmap_step_passes_post_match_gate( + cur, + step, + goal_query=goal_query, + semantic_brief=semantic_brief, + ): + return None, stage_spec return step, None +def _roadmap_step_passes_post_match_gate( + cur, + step: Dict[str, Any], + *, + goal_query: str, + semantic_brief: PlanningSemanticBrief, +) -> bool: + """Abgleich mit Pfad-QA — kein Rematch-Treffer, der sofort wieder stage_mismatch wäre.""" + if step.get("exercise_id") is None: + return False + issues = detect_off_topic_steps( + cur, + [step], + brief=semantic_brief, + goal_query=goal_query, + ) + return not issues + + def _normalize_roadmap_steps_coverage( steps: List[Dict[str, Any]], *, @@ -1394,6 +1420,92 @@ def _purge_stage_mismatch_roadmap_slots( return out, new_unfilled +def _enrich_roadmap_unfilled_gap_offers( + cur, + *, + steps: List[Dict[str, Any]], + gap_fill_offers: List[Dict[str, Any]], + body: ProgressionPathSuggestRequest, + roadmap_ctx: ProgressionRoadmapContext, + goal_query: str, + semantic_brief: PlanningSemanticBrief, +) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """KI-Lücken-Angebote für alle leeren Roadmap-Slots (nach Rematch/Normalize).""" + if not body.include_ai_gap_fill: + return steps, gap_fill_offers + + seen_offer_ids = {o.get("offer_id") for o in gap_fill_offers if o.get("offer_id")} + out_steps: List[Dict[str, Any]] = [] + offers = list(gap_fill_offers) + + for raw in steps: + step = dict(raw) + if step.get("exercise_id") is not None: + out_steps.append(step) + continue + try: + major_idx = int(step["roadmap_major_step_index"]) + except (TypeError, ValueError, KeyError): + out_steps.append(step) + 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: + offers.append(dict(step["gap_offer"])) + seen_offer_ids.add(oid) + out_steps.append(step) + continue + stage_spec = next( + ( + s + for s in (roadmap_ctx.stage_specs or []) + if int(s.major_step_index) == major_idx + ), + None, + ) + 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": (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( + spec=spec, + steps=steps, + goal_query=goal_query, + brief=semantic_brief, + proposal=None, + roadmap_snapshot=_roadmap_gap_snapshot_for_spec( + cur, + roadmap_ctx, + spec, + goal_query=goal_query, + semantic_brief=semantic_brief, + ), + ) + 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: + offers.append(offer) + seen_offer_ids.add(offer.get("offer_id")) + out_steps.append(step) + + return out_steps, offers + + def _merge_rematch_unfilled( roadmap_unfilled: List[Tuple[int, StageSpecArtifact]], rematch_new_unfilled: List[Tuple[int, StageSpecArtifact]], @@ -2269,7 +2381,7 @@ def suggest_progression_path( elif gaps and roadmap_first: unfilled_gaps = list(gaps) - if body.include_llm_path_qa: + if body.include_llm_path_qa and not roadmap_first: llm_qa, llm_qa_applied = try_llm_qa_progression_path( cur, goal_query=goal_query, @@ -2348,6 +2460,22 @@ def suggest_progression_path( roadmap_first=roadmap_first, ) + if body.include_llm_path_qa and roadmap_first: + gaps = detect_path_gaps( + cur, + steps, + brief=semantic_brief, + roadmap_first=roadmap_first, + ) + llm_qa, llm_qa_applied = try_llm_qa_progression_path( + cur, + goal_query=goal_query, + brief=semantic_brief, + steps=steps, + gaps=gaps, + bridge_inserts=bridge_inserts, + ) + llm_gap_specs = parse_llm_suggested_new_exercises( llm_qa, brief=semantic_brief, @@ -2397,6 +2525,22 @@ def suggest_progression_path( if offer.get("offer_id") not in seen_offer_ids: gap_fill_offers.append(offer) + if roadmap_first and roadmap_ctx is not None: + steps = _normalize_roadmap_steps_coverage( + steps, + roadmap_ctx=roadmap_ctx, + max_steps=max_steps, + ) + steps, gap_fill_offers = _enrich_roadmap_unfilled_gap_offers( + cur, + steps=steps, + gap_fill_offers=gap_fill_offers, + body=body, + roadmap_ctx=roadmap_ctx, + goal_query=goal_query, + semantic_brief=semantic_brief, + ) + multistage_qa = run_multistage_path_qa( off_topic_steps=off_topic_steps, stripped_off_topic=stripped_off_topic, @@ -2428,71 +2572,6 @@ def suggest_progression_path( path_qa["refine_log"] = refine_log path_qa["refine_count"] = len(refine_log) - if roadmap_first and roadmap_ctx is not None: - steps = _normalize_roadmap_steps_coverage( - steps, - roadmap_ctx=roadmap_ctx, - max_steps=max_steps, - ) - if body.include_ai_gap_fill: - 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 - try: - 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 - for s in (roadmap_ctx.stage_specs or []) - if int(s.major_step_index) == major_idx - ), - None, - ) - 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": (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( - spec=spec, - steps=steps, - goal_query=goal_query, - brief=semantic_brief, - proposal=None, - roadmap_snapshot=_roadmap_gap_snapshot_for_spec( - cur, - roadmap_ctx, - spec, - goal_query=goal_query, - semantic_brief=semantic_brief, - ), - ) - 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")) - filled_library_steps = sum(1 for s in steps if s.get("exercise_id") is not None) match_summary = { "roadmap_first": roadmap_first, diff --git a/backend/planning_path_rematch.py b/backend/planning_path_rematch.py index dc042ee..200c3c7 100644 --- a/backend/planning_path_rematch.py +++ b/backend/planning_path_rematch.py @@ -207,8 +207,29 @@ def rematch_roadmap_slots( } ) else: + goal = (stage_spec.learning_goal or "").strip() + major = None + if roadmap_ctx.roadmap: + major = next( + (m for m in roadmap_ctx.roadmap.major_steps if int(m.index) == int(major_idx)), + None, + ) + steps_by_major[int(major_idx)] = { + "exercise_id": None, + "variant_id": None, + "title": goal or f"Slot {major_idx + 1}", + "is_ai_proposal": False, + "roadmap_major_step_index": int(major_idx), + "roadmap_phase": major.phase if major else None, + "roadmap_learning_goal": goal or None, + "roadmap_match_source": "unfilled", + "slot_status": "unfilled", + "reasons": ["Keine passende Übung für Roadmap-Stufe"], + } if unfilled_spec is not None: new_unfilled.append((step_index, unfilled_spec)) + elif stage_spec is not None: + new_unfilled.append((step_index, stage_spec)) rematch_log.append( { "roadmap_major_step_index": int(major_idx), diff --git a/backend/tests/test_planning_path_rematch.py b/backend/tests/test_planning_path_rematch.py index 78cc99f..aaeddb5 100644 --- a/backend/tests/test_planning_path_rematch.py +++ b/backend/tests/test_planning_path_rematch.py @@ -183,3 +183,43 @@ def test_rematch_excludes_replaced_exercise_from_used(): match_slot_fn=_fake_match, ) assert 99 in seen_used[0] + + +def test_rematch_unfilled_leaves_placeholder_step(): + specs = _stage_specs() + ctx = ProgressionRoadmapContext( + goal_query="Mae Geri", + max_steps=3, + stage_specs=specs, + ) + steps = [ + {"exercise_id": 10, "title": "OK", "roadmap_major_step_index": 0}, + {"exercise_id": 99, "title": "Falsch", "roadmap_major_step_index": 1}, + ] + + def _no_match(cur, *, stage_spec, **kwargs): + return None, stage_spec + + ordered, log, unfilled = rematch_roadmap_slots( + None, + tenant=None, + body=None, + goal_query="Mae Geri", + max_steps=3, + semantic_brief=None, + path_target_profile=None, + path_intent="", + roadmap_ctx=ctx, + steps=steps, + slot_indices={1}, + rematch_reasons={1: "stage_mismatch"}, + match_slot_fn=_no_match, + ) + + assert len(ordered) == 2 + slot1 = ordered[1] + assert slot1["exercise_id"] is None + assert slot1["slot_status"] == "unfilled" + assert slot1["roadmap_match_source"] == "unfilled" + assert log[0]["action"] == "rematch_unfilled" + assert len(unfilled) == 1 diff --git a/backend/version.py b/backend/version.py index 17b1603..ce251cc 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.230" +APP_VERSION = "0.8.231" BUILD_DATE = "2026-05-22" DB_SCHEMA_VERSION = "20260607090" @@ -38,7 +38,7 @@ MODULE_VERSIONS = { "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "methods": "0.1.0", "exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume - "planning_exercise_suggest": "0.23.5", # Roadmap-Match strikt; stage_mismatch → unfilled + KI-Gap + "planning_exercise_suggest": "0.23.6", # Gap-Angebote nach Rematch; LLM-QA auf finalem Pfad; Post-Match-Gate "training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint "training_programs": "0.1.0", "planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung diff --git a/frontend/src/utils/progressionGraphDraft.js b/frontend/src/utils/progressionGraphDraft.js index 1a6489e..908cba8 100644 --- a/frontend/src/utils/progressionGraphDraft.js +++ b/frontend/src/utils/progressionGraphDraft.js @@ -856,21 +856,16 @@ 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 touchedMajors = new Set() + const stepByMajor = new Map() for (const step of steps) { 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 - touchedMajors.add(idx) + stepByMajor.set(Number(step.roadmap_major_step_index), step) + } + const mapStepToPrimary = (step, slot) => { + const midx = Number(slot.majorStepIndex) const isProposal = Boolean(step.is_ai_proposal) || step.exercise_id == null const hasAiPayload = Boolean(step.ai_suggestion) || Boolean(step.proposal_key) const isUnfilledSlot = @@ -880,33 +875,50 @@ export function applyMatchStepsToSlots(draft, apiSteps) { Boolean(step.gap_offer) if (isProposal && !hasAiPayload && isUnfilledSlot) { const offer = step.gap_offer || {} - nextSlots[idx].primary = proposalSlotExercise({ + return 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}`, + slot.learning_goal || + `Slot ${midx + 1}`, + proposalKey: offer.offer_id || step.proposal_key || `roadmap-unfilled-${midx}`, 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, + } + if (isProposal && !hasAiPayload) { + return emptySlotExercise() + } + if (isProposal) { + return proposalSlotExercise({ + title: step.title || slot.learning_goal, proposalKey: step.proposal_key, aiSuggestion: step.ai_suggestion, }) - } else { - nextSlots[idx].primary = librarySlotExercise({ - exerciseId: step.exercise_id, - exerciseTitle: step.title || `Übung #${step.exercise_id}`, - variantId: step.variant_id, - }) } + return librarySlotExercise({ + exerciseId: step.exercise_id, + exerciseTitle: step.title || `Übung #${step.exercise_id}`, + variantId: step.variant_id, + }) } + const nextSlots = (draft.slots || []).map((slot) => { + const base = { + ...slot, + primary: { ...slot.primary }, + siblings: [...(slot.siblings || [])], + } + const step = stepByMajor.get(Number(slot.majorStepIndex)) + if (!step) { + return base + } + return { + ...base, + primary: mapStepToPrimary(step, slot), + } + }) + return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true }) }