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
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:
parent
f3710ac0a1
commit
b8f65e04c5
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user