shinkan-jinkendo/backend/planning_path_rematch.py
Lars 1d94c2ebf1
All checks were successful
Deploy Development / deploy (push) Successful in 44s
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 36s
Test Suite / playwright-tests (push) Successful in 1m23s
Enhance Roadmap Slot Matching and Off-Topic Detection
- Introduced `auto_rematch_after_qa` parameter in `ProgressionPathSuggestRequest` to enable automatic rematching after quality assurance checks.
- Refactored roadmap slot matching logic to improve clarity and functionality, renaming `_build_steps_roadmap_first` to `_match_roadmap_slot`.
- Added `_with_roadmap_major_index` utility to streamline off-topic step detection by incorporating roadmap major step indices.
- Enhanced off-topic detection logic to utilize the new utility for improved clarity in identifying mismatches and exclusions.
- Incremented application version to reflect these updates.
2026-06-11 10:30:48 +02:00

203 lines
6.9 KiB
Python

"""
Auto-Rematch nach Pfad-QS — betroffene Roadmap-Slots erneut matchen (Phase A).
"""
from __future__ import annotations
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
from planning_progression_roadmap import ProgressionRoadmapContext, StageSpecArtifact
def collect_rematch_slot_indices(
*,
stripped_off_topic: Sequence[Mapping[str, Any]],
off_topic_steps: Sequence[Mapping[str, Any]],
optimization_hints: Sequence[Mapping[str, Any]],
stage_specs: Sequence[StageSpecArtifact],
) -> Tuple[Set[int], Dict[int, str]]:
"""Major-Step-Indizes für rematch_slot + Begründung pro Slot."""
spec_by_pos = list(stage_specs)
indices: Set[int] = set()
reasons: Dict[int, str] = {}
def _register(midx: int, reason: str) -> None:
indices.add(int(midx))
if midx not in reasons and reason:
reasons[int(midx)] = reason[:400]
def _resolve_major(item: Mapping[str, Any]) -> Optional[int]:
raw = item.get("roadmap_major_step_index")
if raw is not None:
return int(raw)
si = item.get("step_index")
if si is not None:
pos = int(si)
if 0 <= pos < len(spec_by_pos):
return int(spec_by_pos[pos].major_step_index)
return None
for item in stripped_off_topic or []:
if not isinstance(item, dict):
continue
midx = _resolve_major(item)
if midx is not None:
issue = str(item.get("issue") or "stripped_off_topic")
r = (item.get("reasons") or [issue])[0] if item.get("reasons") else issue
_register(midx, str(r))
for item in off_topic_steps or []:
if not isinstance(item, dict):
continue
midx = _resolve_major(item)
if midx is None:
continue
issue = str(item.get("issue") or "off_topic")
r = (item.get("reasons") or [issue])[0] if item.get("reasons") else issue
_register(midx, str(r))
for hint in optimization_hints or []:
if not isinstance(hint, dict):
continue
if str(hint.get("action") or "") != "rematch_slot":
continue
midx = _resolve_major(hint)
if midx is not None:
_register(midx, str(hint.get("reason") or hint.get("issue") or "rematch_slot"))
return indices, reasons
def _context_before_major(
steps_by_major: Mapping[int, Mapping[str, Any]],
target_major: int,
) -> Tuple[List[int], Optional[int], Optional[int]]:
planned: List[int] = []
anchor: Optional[int] = None
anchor_vid: Optional[int] = None
for midx in sorted(steps_by_major):
if midx >= target_major:
break
step = steps_by_major[midx]
eid = step.get("exercise_id")
if eid is not None:
planned.append(int(eid))
anchor = int(eid)
vid = step.get("variant_id")
anchor_vid = int(vid) if vid is not None else None
return planned, anchor, anchor_vid
def rematch_roadmap_slots(
cur,
*,
tenant,
body,
goal_query: str,
max_steps: int,
semantic_brief,
path_target_profile,
path_intent: str,
roadmap_ctx: ProgressionRoadmapContext,
steps: Sequence[Mapping[str, Any]],
slot_indices: Set[int],
rematch_reasons: Mapping[int, str],
match_slot_fn,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Tuple[int, StageSpecArtifact]]]:
"""
Ersetzt nur betroffene Slots; andere Schritte und used-Set bleiben konsistent.
match_slot_fn: _match_roadmap_slot aus path_builder (Injection gegen Zirkularität).
"""
stage_specs = list(roadmap_ctx.stage_specs or [])[:max_steps]
if not stage_specs or not slot_indices:
return list(steps), [], []
spec_by_major = {int(s.major_step_index): s for s in stage_specs}
steps_by_major: Dict[int, Dict[str, Any]] = {}
for raw in steps:
step = dict(raw)
midx = step.get("roadmap_major_step_index")
if midx is not None:
steps_by_major[int(midx)] = step
rematch_log: List[Dict[str, Any]] = []
new_unfilled: List[Tuple[int, StageSpecArtifact]] = []
for major_idx in sorted(slot_indices):
stage_spec = spec_by_major.get(int(major_idx))
if stage_spec is None:
continue
step_index = next(
(i for i, sp in enumerate(stage_specs) if int(sp.major_step_index) == int(major_idx)),
major_idx,
)
old = steps_by_major.pop(int(major_idx), None)
used = {
int(s["exercise_id"])
for m, s in steps_by_major.items()
if s.get("exercise_id") is not None
}
planned_ids, anchor_id, anchor_variant_id = _context_before_major(
steps_by_major, int(major_idx)
)
new_step, unfilled_spec = match_slot_fn(
cur,
tenant=tenant,
body=body,
goal_query=goal_query,
max_steps=max_steps,
semantic_brief=semantic_brief,
path_target_profile=path_target_profile,
path_intent=path_intent,
roadmap_ctx=roadmap_ctx,
stage_spec=stage_spec,
step_index=step_index,
stage_count=len(stage_specs),
planned_ids=planned_ids,
anchor_id=anchor_id,
anchor_variant_id=anchor_variant_id,
used=used,
)
reason = str(rematch_reasons.get(int(major_idx)) or "rematch_slot")
if new_step:
steps_by_major[int(major_idx)] = new_step
rematch_log.append(
{
"roadmap_major_step_index": int(major_idx),
"action": "replaced",
"reason": reason,
"replaced_exercise_id": old.get("exercise_id") if old else None,
"replaced_title": old.get("title") if old else None,
"new_exercise_id": new_step.get("exercise_id"),
"new_title": new_step.get("title"),
}
)
else:
if unfilled_spec is not None:
new_unfilled.append((step_index, unfilled_spec))
rematch_log.append(
{
"roadmap_major_step_index": int(major_idx),
"action": "rematch_unfilled",
"reason": reason,
"replaced_exercise_id": old.get("exercise_id") if old else None,
"replaced_title": old.get("title") if old else None,
}
)
ordered: List[Dict[str, Any]] = []
for spec in sorted(stage_specs, key=lambda s: s.major_step_index):
midx = int(spec.major_step_index)
if midx in steps_by_major:
ordered.append(steps_by_major[midx])
return ordered, rematch_log, new_unfilled
__all__ = [
"collect_rematch_slot_indices",
"rematch_roadmap_slots",
]