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.
339 lines
9.6 KiB
Python
339 lines
9.6 KiB
Python
"""Tests Auto-Rematch nach Pfad-QS (Phase A)."""
|
|
from planning_path_rematch import collect_rematch_slot_indices, rematch_roadmap_slots
|
|
from planning_progression_roadmap import ProgressionRoadmapContext, StageSpecArtifact
|
|
|
|
|
|
def _stage_specs():
|
|
return [
|
|
StageSpecArtifact(major_step_index=0, learning_goal="Grundlage"),
|
|
StageSpecArtifact(major_step_index=1, learning_goal="Vertiefung"),
|
|
StageSpecArtifact(major_step_index=2, learning_goal="Anwendung"),
|
|
]
|
|
|
|
|
|
def test_collect_rematch_slot_indices_from_stripped_with_major_index():
|
|
specs = _stage_specs()
|
|
stripped = [
|
|
{
|
|
"step_index": 1,
|
|
"roadmap_major_step_index": 1,
|
|
"issue": "technique_scope",
|
|
"reasons": ["Passt nicht zur Haupttechnik"],
|
|
}
|
|
]
|
|
indices, reasons = collect_rematch_slot_indices(
|
|
stripped_off_topic=stripped,
|
|
off_topic_steps=[],
|
|
optimization_hints=[],
|
|
stage_specs=specs,
|
|
)
|
|
assert indices == {1}
|
|
assert "Haupttechnik" in reasons[1]
|
|
|
|
|
|
def test_collect_rematch_slot_indices_resolves_step_index_to_major():
|
|
specs = _stage_specs()
|
|
off_topic = [
|
|
{
|
|
"step_index": 2,
|
|
"issue": "stage_mismatch",
|
|
"reasons": ["Ziel passt nicht"],
|
|
}
|
|
]
|
|
indices, reasons = collect_rematch_slot_indices(
|
|
stripped_off_topic=[],
|
|
off_topic_steps=off_topic,
|
|
optimization_hints=[],
|
|
stage_specs=specs,
|
|
)
|
|
assert indices == {2}
|
|
assert reasons[2] == "Ziel passt nicht"
|
|
|
|
|
|
def test_collect_rematch_slot_indices_from_optimization_hints():
|
|
specs = _stage_specs()
|
|
hints = [
|
|
{
|
|
"action": "rematch_slot",
|
|
"roadmap_major_step_index": 0,
|
|
"reason": "QS-Tier-1",
|
|
}
|
|
]
|
|
indices, _ = collect_rematch_slot_indices(
|
|
stripped_off_topic=[],
|
|
off_topic_steps=[],
|
|
optimization_hints=hints,
|
|
stage_specs=specs,
|
|
)
|
|
assert indices == {0}
|
|
|
|
|
|
def test_collect_rematch_slot_indices_from_roadmap_unfilled():
|
|
specs = _stage_specs()
|
|
indices, reasons = collect_rematch_slot_indices(
|
|
stripped_off_topic=[],
|
|
off_topic_steps=[],
|
|
optimization_hints=[],
|
|
stage_specs=specs,
|
|
roadmap_unfilled=[(1, specs[1])],
|
|
)
|
|
assert indices == {1}
|
|
assert "Roadmap-Stufe" in reasons[1]
|
|
|
|
|
|
def test_rematch_roadmap_slots_replaces_only_target_slot():
|
|
specs = _stage_specs()
|
|
ctx = ProgressionRoadmapContext(
|
|
goal_query="Mawashi Geri",
|
|
max_steps=3,
|
|
stage_specs=specs,
|
|
)
|
|
steps = [
|
|
{
|
|
"exercise_id": 10,
|
|
"title": "Slot 0 OK",
|
|
"roadmap_major_step_index": 0,
|
|
},
|
|
{
|
|
"exercise_id": 20,
|
|
"title": "Mae Geri falsch",
|
|
"roadmap_major_step_index": 1,
|
|
},
|
|
{
|
|
"exercise_id": 30,
|
|
"title": "Slot 2 OK",
|
|
"roadmap_major_step_index": 2,
|
|
},
|
|
]
|
|
|
|
def _fake_match(cur, *, stage_spec, used, **kwargs):
|
|
assert stage_spec.major_step_index == 1
|
|
assert 20 in used
|
|
assert 10 in used
|
|
assert 30 in used
|
|
return (
|
|
{
|
|
"exercise_id": 21,
|
|
"title": "Sprungkraft Mawashi",
|
|
"roadmap_major_step_index": 1,
|
|
},
|
|
None,
|
|
)
|
|
|
|
ordered, log, unfilled = rematch_roadmap_slots(
|
|
None,
|
|
tenant=None,
|
|
body=None,
|
|
goal_query="Mawashi Geri",
|
|
max_steps=3,
|
|
semantic_brief=None,
|
|
path_target_profile=None,
|
|
path_intent="",
|
|
roadmap_ctx=ctx,
|
|
steps=steps,
|
|
slot_indices={1},
|
|
rematch_reasons={1: "technique_scope"},
|
|
match_slot_fn=_fake_match,
|
|
)
|
|
|
|
assert len(ordered) == 3
|
|
assert ordered[0]["exercise_id"] == 10
|
|
assert ordered[1]["exercise_id"] == 21
|
|
assert ordered[2]["exercise_id"] == 30
|
|
assert len(log) == 1
|
|
assert log[0]["action"] == "replaced"
|
|
assert log[0]["replaced_exercise_id"] == 20
|
|
assert log[0]["new_exercise_id"] == 21
|
|
assert not unfilled
|
|
|
|
|
|
def test_rematch_excludes_replaced_exercise_from_used():
|
|
specs = _stage_specs()
|
|
ctx = ProgressionRoadmapContext(
|
|
goal_query="Mawashi Geri",
|
|
max_steps=3,
|
|
stage_specs=specs,
|
|
)
|
|
steps = [
|
|
{"exercise_id": 10, "title": "OK", "roadmap_major_step_index": 0},
|
|
{"exercise_id": 99, "title": "Mae Geri", "roadmap_major_step_index": 1},
|
|
]
|
|
seen_used = []
|
|
|
|
def _fake_match(cur, *, used, stage_spec, **kwargs):
|
|
seen_used.append(set(used))
|
|
return (
|
|
{"exercise_id": 42, "title": "Neu", "roadmap_major_step_index": stage_spec.major_step_index},
|
|
None,
|
|
)
|
|
|
|
rematch_roadmap_slots(
|
|
None,
|
|
tenant=None,
|
|
body=None,
|
|
goal_query="Mawashi",
|
|
max_steps=3,
|
|
semantic_brief=None,
|
|
path_target_profile=None,
|
|
path_intent="",
|
|
roadmap_ctx=ctx,
|
|
steps=steps,
|
|
slot_indices={1},
|
|
rematch_reasons={1: "technique_scope"},
|
|
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,
|
|
rejected_by_major={1: {99}},
|
|
)
|
|
|
|
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
|
|
|
|
|
|
def test_prune_filled_from_roadmap_unfilled():
|
|
from planning_exercise_path_builder import _prune_filled_from_roadmap_unfilled
|
|
|
|
spec = StageSpecArtifact(major_step_index=5, learning_goal="Zielgenauigkeit")
|
|
steps = [{"exercise_id": 99, "roadmap_major_step_index": 5}]
|
|
kept = _prune_filled_from_roadmap_unfilled(steps, [(4, spec)])
|
|
assert kept == []
|
|
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}
|