progression V2 #57
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user