""" Auto-Rematch nach Pfad-QS — betroffene Roadmap-Slots erneut matchen (Phase A/B). """ from __future__ import annotations 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]], off_topic_steps: Sequence[Mapping[str, Any]], optimization_hints: Sequence[Mapping[str, Any]], stage_specs: Sequence[StageSpecArtifact], roadmap_unfilled: Optional[Sequence[Any]] = None, ) -> 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")) for item in roadmap_unfilled or []: if isinstance(item, (list, tuple)) and len(item) >= 2: idx, spec = item[0], item[1] midx = getattr(spec, "major_step_index", idx) _register(int(midx), "Keine passende Übung für Roadmap-Stufe") elif isinstance(item, dict): midx = _resolve_major(item) if midx is not None: issue = str(item.get("issue") or "roadmap_unfilled") r = (item.get("reasons") or [issue])[0] if item.get("reasons") else issue _register(midx, str(r)) 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, ) -> 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, rejected_by_major: Optional[Mapping[int, Set[int]]] = None, slot_assignment_history: Optional[Mapping[int, Set[int]]] = None, ) -> 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 } if old and old.get("exercise_id") is not None: used.add(int(old["exercise_id"])) for rejected_id in rejected_by_major.get(int(major_idx), set()) if rejected_by_major else set(): if rejected_id > 0: used.add(int(rejected_id)) 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, 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") if new_step: try: new_eid = int(new_step.get("exercise_id") or 0) except (TypeError, ValueError): new_eid = 0 rejected = ( rejected_by_major.get(int(major_idx), set()) if rejected_by_major else set() ) if new_eid > 0 and new_eid in rejected: new_step = None 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 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: major = next( (m for m in roadmap_ctx.roadmap.major_steps if int(m.index) == int(major_idx)), None, ) steps_by_major[int(major_idx)] = { "exercise_id": None, "variant_id": None, "title": goal or f"Slot {major_idx + 1}", "is_ai_proposal": False, "roadmap_major_step_index": int(major_idx), "roadmap_phase": major.phase if major else None, "roadmap_learning_goal": goal or None, "roadmap_match_source": "unfilled", "slot_status": "unfilled", "reasons": ["Keine passende Übung für Roadmap-Stufe"], } if unfilled_spec is not None: new_unfilled.append((step_index, unfilled_spec)) elif stage_spec is not None: new_unfilled.append((step_index, stage_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 def prune_stripped_after_rematch( stripped_off_topic: Sequence[Mapping[str, Any]], rematch_log: Sequence[Mapping[str, Any]], ) -> List[Dict[str, Any]]: """Entfernt aus stripped_off_topic Slots, die per Rematch ersetzt wurden.""" replaced: Set[int] = set() for entry in rematch_log or []: if not isinstance(entry, dict): continue if str(entry.get("action") or "") != "replaced": continue midx = entry.get("roadmap_major_step_index") if midx is not None: replaced.add(int(midx)) if not replaced: return list(stripped_off_topic or []) out: List[Dict[str, Any]] = [] for item in stripped_off_topic or []: if not isinstance(item, dict): continue midx = item.get("roadmap_major_step_index") if midx is not None and int(midx) in replaced: continue out.append(dict(item)) return out __all__ = [ "collect_rematch_slot_indices", "filter_rematch_slot_indices", "prune_stripped_after_rematch", "rematch_roadmap_slots", ]