Enhance Rematch Logic and Slot Filtering in Planning Path
All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 45s
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 1m13s

- Introduced `filter_rematch_slot_indices` to exclude preserved slots from rematching, improving the accuracy of slot assignments.
- Added `_slot_priority_for_rematch` to prioritize existing slot assignments during rematching, enhancing the robustness of the rematch process.
- Updated `_run_roadmap_rematch_loop` to utilize the new filtering and prioritization logic, ensuring better handling of rematch scenarios.
- Enhanced tests in `test_planning_path_rematch.py` to validate the new filtering behavior and ensure correct exercise restoration when not rejected.
- Bumped version to reflect the new features and improvements.
This commit is contained in:
Lars 2026-06-12 12:33:00 +02:00
parent f3710ac0a1
commit b8f65e04c5
4 changed files with 223 additions and 6 deletions

View File

@ -27,6 +27,7 @@ from planning_exercise_profiles import PlanningTargetProfile
from planning_path_qa_pipeline import run_multistage_path_qa from planning_path_qa_pipeline import run_multistage_path_qa
from planning_path_rematch import ( from planning_path_rematch import (
collect_rematch_slot_indices, collect_rematch_slot_indices,
filter_rematch_slot_indices,
prune_stripped_after_rematch, prune_stripped_after_rematch,
rematch_roadmap_slots, rematch_roadmap_slots,
) )
@ -1709,6 +1710,12 @@ def _run_roadmap_rematch_loop(
slot_indices.add(int(midx)) slot_indices.add(int(midx))
if int(midx) not in rematch_reasons: if int(midx) not in rematch_reasons:
rematch_reasons[int(midx)] = "refine_stage_spec" 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: if not slot_indices:
break break

View File

@ -8,6 +8,40 @@ from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
from planning_progression_roadmap import ProgressionRoadmapContext, StageSpecArtifact 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( def collect_rematch_slot_indices(
*, *,
stripped_off_topic: Sequence[Mapping[str, Any]], stripped_off_topic: Sequence[Mapping[str, Any]],
@ -80,6 +114,43 @@ def collect_rematch_slot_indices(
return indices, reasons 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( def _context_before_major(
steps_by_major: Mapping[int, Mapping[str, Any]], steps_by_major: Mapping[int, Mapping[str, Any]],
target_major: int, target_major: int,
@ -178,6 +249,12 @@ def rematch_roadmap_slots(
anchor_id=anchor_id, anchor_id=anchor_id,
anchor_variant_id=anchor_variant_id, anchor_variant_id=anchor_variant_id,
used=used, 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") 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) new_eid = int(new_step.get("exercise_id") or 0)
except (TypeError, ValueError): except (TypeError, ValueError):
new_eid = 0 new_eid = 0
hist = ( rejected = (
slot_assignment_history.get(int(major_idx), set()) rejected_by_major.get(int(major_idx), set()) if rejected_by_major else set()
if slot_assignment_history
else set()
) )
if new_eid > 0 and new_eid in hist: if new_eid > 0 and new_eid in rejected:
new_step = None new_step = None
if new_step: if new_step:
steps_by_major[int(major_idx)] = new_step steps_by_major[int(major_idx)] = new_step
@ -207,6 +282,26 @@ def rematch_roadmap_slots(
} }
) )
else: 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() goal = (stage_spec.learning_goal or "").strip()
major = None major = None
if roadmap_ctx.roadmap: if roadmap_ctx.roadmap:
@ -278,6 +373,7 @@ def prune_stripped_after_rematch(
__all__ = [ __all__ = [
"collect_rematch_slot_indices", "collect_rematch_slot_indices",
"filter_rematch_slot_indices",
"prune_stripped_after_rematch", "prune_stripped_after_rematch",
"rematch_roadmap_slots", "rematch_roadmap_slots",
] ]

View File

@ -214,6 +214,7 @@ def test_rematch_unfilled_leaves_placeholder_step():
slot_indices={1}, slot_indices={1},
rematch_reasons={1: "stage_mismatch"}, rematch_reasons={1: "stage_mismatch"},
match_slot_fn=_no_match, match_slot_fn=_no_match,
rejected_by_major={1: {99}},
) )
assert len(ordered) == 2 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}] unfilled_steps = [{"exercise_id": None, "roadmap_major_step_index": 5}]
kept2 = _prune_filled_from_roadmap_unfilled(unfilled_steps, [(4, spec)]) kept2 = _prune_filled_from_roadmap_unfilled(unfilled_steps, [(4, spec)])
assert len(kept2) == 1 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}

View File

@ -1024,9 +1024,22 @@ export function applyMatchStepsToSlots(draft, apiSteps) {
if (!step) { if (!step) {
return base 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 { return {
...base, ...base,
primary: mapStepToPrimary(step, slot), primary: mappedPrimary,
} }
}) })