diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 6d3fcc7..f1bf496 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -27,6 +27,7 @@ from planning_exercise_profiles import PlanningTargetProfile from planning_path_qa_pipeline import run_multistage_path_qa from planning_path_rematch import ( collect_rematch_slot_indices, + filter_rematch_slot_indices, prune_stripped_after_rematch, rematch_roadmap_slots, ) @@ -1709,6 +1710,12 @@ def _run_roadmap_rematch_loop( slot_indices.add(int(midx)) if int(midx) not in rematch_reasons: rematch_reasons[int(midx)] = "refine_stage_spec" + slot_indices = filter_rematch_slot_indices( + steps, + slot_indices, + stripped_off_topic=current_stripped if round_idx == 0 else [], + off_topic_steps=off_topic_before_strip if round_idx == 0 and use_initial_off_topic else [], + ) if not slot_indices: break diff --git a/backend/planning_path_rematch.py b/backend/planning_path_rematch.py index 200c3c7..4f47dfd 100644 --- a/backend/planning_path_rematch.py +++ b/backend/planning_path_rematch.py @@ -8,6 +8,40 @@ from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple from planning_progression_roadmap import ProgressionRoadmapContext, StageSpecArtifact +def _slot_priority_for_rematch( + body, + *, + major_idx: int, + old: Optional[Mapping[str, Any]], + rejected_by_major: Optional[Mapping[int, Set[int]]], +) -> Optional[int]: + """Bestehende Slot-Zuordnung beim Rematch bevorzugen — außer explizit abgelehnt.""" + priority_id: Optional[int] = None + if body is not None: + for raw in getattr(body, "slot_assignments", None) or []: + midx = getattr(raw, "roadmap_major_step_index", None) + if midx is None or int(midx) != int(major_idx): + continue + eid = getattr(raw, "exercise_id", None) + if eid is not None: + try: + priority_id = int(eid) + except (TypeError, ValueError): + priority_id = None + break + if priority_id is None and old and old.get("exercise_id") is not None: + try: + priority_id = int(old["exercise_id"]) + except (TypeError, ValueError): + priority_id = None + if priority_id is None or priority_id < 1: + return None + rejected = rejected_by_major.get(int(major_idx), set()) if rejected_by_major else set() + if priority_id in rejected: + return None + return priority_id + + def collect_rematch_slot_indices( *, stripped_off_topic: Sequence[Mapping[str, Any]], @@ -80,6 +114,43 @@ def collect_rematch_slot_indices( return indices, reasons +def filter_rematch_slot_indices( + steps: Sequence[Mapping[str, Any]], + slot_indices: Set[int], + *, + stripped_off_topic: Sequence[Mapping[str, Any]], + off_topic_steps: Sequence[Mapping[str, Any]], +) -> Set[int]: + """Trainer-Zuordnungen (slot_best_match) nicht rematchen, außer Slot ist explizit beanstandet.""" + flagged: Set[int] = set() + for item in list(stripped_off_topic or []) + list(off_topic_steps or []): + if not isinstance(item, dict): + continue + midx = item.get("roadmap_major_step_index") + if midx is not None: + try: + flagged.add(int(midx)) + except (TypeError, ValueError): + pass + + preserved: Set[int] = set() + for raw in steps or []: + if not isinstance(raw, dict): + continue + midx = raw.get("roadmap_major_step_index") + if midx is None: + continue + try: + major_idx = int(midx) + except (TypeError, ValueError): + continue + if raw.get("roadmap_match_source") == "slot_best_match" or raw.get("slot_status") == "preserved": + if major_idx not in flagged: + preserved.add(major_idx) + + return {idx for idx in slot_indices if idx not in preserved} + + def _context_before_major( steps_by_major: Mapping[int, Mapping[str, Any]], target_major: int, @@ -178,6 +249,12 @@ def rematch_roadmap_slots( anchor_id=anchor_id, anchor_variant_id=anchor_variant_id, used=used, + slot_priority_exercise_id=_slot_priority_for_rematch( + body, + major_idx=major_idx, + old=old, + rejected_by_major=rejected_by_major, + ), ) reason = str(rematch_reasons.get(int(major_idx)) or "rematch_slot") @@ -186,12 +263,10 @@ def rematch_roadmap_slots( new_eid = int(new_step.get("exercise_id") or 0) except (TypeError, ValueError): new_eid = 0 - hist = ( - slot_assignment_history.get(int(major_idx), set()) - if slot_assignment_history - else set() + rejected = ( + rejected_by_major.get(int(major_idx), set()) if rejected_by_major else set() ) - if new_eid > 0 and new_eid in hist: + if new_eid > 0 and new_eid in rejected: new_step = None if new_step: steps_by_major[int(major_idx)] = new_step @@ -207,6 +282,26 @@ def rematch_roadmap_slots( } ) else: + if old and old.get("exercise_id") is not None: + try: + old_eid = int(old["exercise_id"]) + except (TypeError, ValueError): + old_eid = 0 + rejected = ( + rejected_by_major.get(int(major_idx), set()) if rejected_by_major else set() + ) + if old_eid > 0 and old_eid not in rejected: + steps_by_major[int(major_idx)] = dict(old) + rematch_log.append( + { + "roadmap_major_step_index": int(major_idx), + "action": "restored", + "reason": reason, + "restored_exercise_id": old_eid, + "restored_title": old.get("title"), + } + ) + continue goal = (stage_spec.learning_goal or "").strip() major = None if roadmap_ctx.roadmap: @@ -278,6 +373,7 @@ def prune_stripped_after_rematch( __all__ = [ "collect_rematch_slot_indices", + "filter_rematch_slot_indices", "prune_stripped_after_rematch", "rematch_roadmap_slots", ] diff --git a/backend/tests/test_planning_path_rematch.py b/backend/tests/test_planning_path_rematch.py index 9337c9e..2ee19f5 100644 --- a/backend/tests/test_planning_path_rematch.py +++ b/backend/tests/test_planning_path_rematch.py @@ -214,6 +214,7 @@ def test_rematch_unfilled_leaves_placeholder_step(): slot_indices={1}, rematch_reasons={1: "stage_mismatch"}, match_slot_fn=_no_match, + rejected_by_major={1: {99}}, ) assert len(ordered) == 2 @@ -235,3 +236,103 @@ def test_prune_filled_from_roadmap_unfilled(): unfilled_steps = [{"exercise_id": None, "roadmap_major_step_index": 5}] kept2 = _prune_filled_from_roadmap_unfilled(unfilled_steps, [(4, spec)]) assert len(kept2) == 1 + + +def test_rematch_keeps_same_exercise_when_not_rejected(): + """Regression: slot_assignment_history blockierte gültige Wiederzuordnung → leere Slots.""" + 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": 42, "title": "Gut", "roadmap_major_step_index": 1}, + ] + + def _same_match(cur, *, stage_spec, slot_priority_exercise_id=None, **kwargs): + assert slot_priority_exercise_id == 42 + return ( + {"exercise_id": 42, "title": "Gut", "roadmap_major_step_index": stage_spec.major_step_index}, + None, + ) + + 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: "refine_stage_spec"}, + match_slot_fn=_same_match, + rejected_by_major={}, + ) + + assert ordered[1]["exercise_id"] == 42 + assert log[0]["action"] == "replaced" + assert not unfilled + + +def test_rematch_restores_when_match_fails_and_not_rejected(): + 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": 42, "title": "Gut", "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: "refine_stage_spec"}, + match_slot_fn=_no_match, + rejected_by_major={}, + ) + + assert ordered[1]["exercise_id"] == 42 + assert log[0]["action"] == "restored" + assert not unfilled + + +def test_filter_rematch_skips_preserved_slots(): + from planning_path_rematch import filter_rematch_slot_indices + + steps = [ + { + "exercise_id": 10, + "roadmap_major_step_index": 0, + "roadmap_match_source": "slot_best_match", + "slot_status": "preserved", + }, + {"exercise_id": 20, "roadmap_major_step_index": 1}, + ] + filtered = filter_rematch_slot_indices( + steps, + {0, 1}, + stripped_off_topic=[], + off_topic_steps=[], + ) + assert filtered == {1} diff --git a/frontend/src/utils/progressionGraphDraft.js b/frontend/src/utils/progressionGraphDraft.js index 5f1fe1e..21d88e8 100644 --- a/frontend/src/utils/progressionGraphDraft.js +++ b/frontend/src/utils/progressionGraphDraft.js @@ -1024,9 +1024,22 @@ export function applyMatchStepsToSlots(draft, apiSteps) { if (!step) { return base } + const mappedPrimary = mapStepToPrimary(step, slot) + const apiUnfilled = + step.exercise_id == null && + (step.slot_status === 'unfilled' || + step.roadmap_match_source === 'unfilled' || + mappedPrimary.kind === 'empty') + if ( + apiUnfilled && + slot.primary?.kind === 'library' && + slot.primary.exerciseId != null + ) { + return base + } return { ...base, - primary: mapStepToPrimary(step, slot), + primary: mappedPrimary, } })