Enhance Roadmap Slot Matching and Off-Topic Detection
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
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
- 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.
This commit is contained in:
parent
a152218c45
commit
1d94c2ebf1
|
|
@ -14,6 +14,7 @@ from pydantic import BaseModel, Field
|
||||||
from tenant_context import TenantContext, library_content_visibility_sql
|
from tenant_context import TenantContext, library_content_visibility_sql
|
||||||
from planning_exercise_profiles import PlanningTargetProfile
|
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 collect_rematch_slot_indices, rematch_roadmap_slots
|
||||||
from planning_stage_context import build_contextualized_stage_goal, resolve_path_start_target
|
from planning_stage_context import build_contextualized_stage_goal, resolve_path_start_target
|
||||||
from planning_exercise_path_qa import (
|
from planning_exercise_path_qa import (
|
||||||
apply_llm_path_reorder,
|
apply_llm_path_reorder,
|
||||||
|
|
@ -97,6 +98,7 @@ class ProgressionPathSuggestRequest(BaseModel):
|
||||||
max_steps: int = Field(default=5, ge=2, le=10)
|
max_steps: int = Field(default=5, ge=2, le=10)
|
||||||
include_llm_intent: bool = True
|
include_llm_intent: bool = True
|
||||||
include_path_qa: bool = True
|
include_path_qa: bool = True
|
||||||
|
auto_rematch_after_qa: bool = True
|
||||||
include_llm_path_qa: bool = True
|
include_llm_path_qa: bool = True
|
||||||
include_path_reorder: bool = True
|
include_path_reorder: bool = True
|
||||||
include_ai_gap_fill: bool = True
|
include_ai_gap_fill: bool = True
|
||||||
|
|
@ -534,7 +536,7 @@ def _annotate_roadmap_step(
|
||||||
return step
|
return step
|
||||||
|
|
||||||
|
|
||||||
def _build_steps_roadmap_first(
|
def _match_roadmap_slot(
|
||||||
cur,
|
cur,
|
||||||
*,
|
*,
|
||||||
tenant: TenantContext,
|
tenant: TenantContext,
|
||||||
|
|
@ -545,28 +547,19 @@ def _build_steps_roadmap_first(
|
||||||
path_target_profile: PlanningTargetProfile,
|
path_target_profile: PlanningTargetProfile,
|
||||||
path_intent: str,
|
path_intent: str,
|
||||||
roadmap_ctx: ProgressionRoadmapContext,
|
roadmap_ctx: ProgressionRoadmapContext,
|
||||||
) -> Tuple[List[Dict[str, Any]], List[Tuple[int, StageSpecArtifact]]]:
|
stage_spec: StageSpecArtifact,
|
||||||
"""Retrieval pro stage_spec statt iterativem Pfad-Bau (Phase F3)."""
|
step_index: int,
|
||||||
stage_specs = list(roadmap_ctx.stage_specs or [])[:max_steps]
|
stage_count: int,
|
||||||
if not stage_specs and roadmap_ctx.roadmap:
|
planned_ids: List[int],
|
||||||
stage_specs = [
|
anchor_id: Optional[int],
|
||||||
StageSpecArtifact(
|
anchor_variant_id: Optional[int],
|
||||||
major_step_index=m.index,
|
used: Set[int],
|
||||||
learning_goal=m.learning_goal,
|
) -> Tuple[Optional[Dict[str, Any]], Optional[StageSpecArtifact]]:
|
||||||
)
|
"""Einzelnen Roadmap-Slot matchen (Initial-Build und Auto-Rematch)."""
|
||||||
for m in roadmap_ctx.roadmap.major_steps[:max_steps]
|
|
||||||
]
|
|
||||||
|
|
||||||
major_by_index: Dict[int, MajorStep] = {}
|
major_by_index: Dict[int, MajorStep] = {}
|
||||||
if roadmap_ctx.roadmap:
|
if roadmap_ctx.roadmap:
|
||||||
major_by_index = {m.index: m for m in roadmap_ctx.roadmap.major_steps}
|
major_by_index = {m.index: m for m in roadmap_ctx.roadmap.major_steps}
|
||||||
|
major = major_by_index.get(stage_spec.major_step_index)
|
||||||
used: Set[int] = set()
|
|
||||||
steps: List[Dict[str, Any]] = []
|
|
||||||
planned_ids: List[int] = []
|
|
||||||
anchor_id: Optional[int] = None
|
|
||||||
anchor_variant_id: Optional[int] = None
|
|
||||||
unfilled: List[Tuple[int, StageSpecArtifact]] = []
|
|
||||||
|
|
||||||
ga_dump = (
|
ga_dump = (
|
||||||
roadmap_ctx.goal_analysis.model_dump() if roadmap_ctx.goal_analysis else None
|
roadmap_ctx.goal_analysis.model_dump() if roadmap_ctx.goal_analysis else None
|
||||||
|
|
@ -580,15 +573,12 @@ def _build_steps_roadmap_first(
|
||||||
structured=roadmap_ctx.resolved_structured,
|
structured=roadmap_ctx.resolved_structured,
|
||||||
goal_analysis=roadmap_ctx.goal_analysis,
|
goal_analysis=roadmap_ctx.goal_analysis,
|
||||||
)
|
)
|
||||||
stage_count = len(stage_specs)
|
|
||||||
brief_summary = (
|
brief_summary = (
|
||||||
roadmap_ctx.semantic_brief
|
roadmap_ctx.semantic_brief
|
||||||
if roadmap_ctx.semantic_brief
|
if roadmap_ctx.semantic_brief
|
||||||
else brief_to_summary_dict(semantic_brief)
|
else brief_to_summary_dict(semantic_brief)
|
||||||
)
|
)
|
||||||
|
|
||||||
for step_index, stage_spec in enumerate(stage_specs):
|
|
||||||
major = major_by_index.get(stage_spec.major_step_index)
|
|
||||||
stage_spec_dict = stage_spec.model_dump()
|
stage_spec_dict = stage_spec.model_dump()
|
||||||
if major:
|
if major:
|
||||||
stage_spec_dict["phase"] = major.phase
|
stage_spec_dict["phase"] = major.phase
|
||||||
|
|
@ -702,8 +692,7 @@ def _build_steps_roadmap_first(
|
||||||
)
|
)
|
||||||
|
|
||||||
if not hit:
|
if not hit:
|
||||||
unfilled.append((step_index, stage_spec))
|
return None, stage_spec
|
||||||
continue
|
|
||||||
|
|
||||||
step = _annotate_roadmap_step(
|
step = _annotate_roadmap_step(
|
||||||
_hit_to_path_step(hit),
|
_hit_to_path_step(hit),
|
||||||
|
|
@ -712,6 +701,63 @@ def _build_steps_roadmap_first(
|
||||||
skill_expectations=skill_exp_api,
|
skill_expectations=skill_exp_api,
|
||||||
anti_patterns_override=stage_anti,
|
anti_patterns_override=stage_anti,
|
||||||
)
|
)
|
||||||
|
return step, None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_steps_roadmap_first(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
tenant: TenantContext,
|
||||||
|
body: ProgressionPathSuggestRequest,
|
||||||
|
goal_query: str,
|
||||||
|
max_steps: int,
|
||||||
|
semantic_brief: PlanningSemanticBrief,
|
||||||
|
path_target_profile: PlanningTargetProfile,
|
||||||
|
path_intent: str,
|
||||||
|
roadmap_ctx: ProgressionRoadmapContext,
|
||||||
|
) -> Tuple[List[Dict[str, Any]], List[Tuple[int, StageSpecArtifact]]]:
|
||||||
|
"""Retrieval pro stage_spec statt iterativem Pfad-Bau (Phase F3)."""
|
||||||
|
stage_specs = list(roadmap_ctx.stage_specs or [])[:max_steps]
|
||||||
|
if not stage_specs and roadmap_ctx.roadmap:
|
||||||
|
stage_specs = [
|
||||||
|
StageSpecArtifact(
|
||||||
|
major_step_index=m.index,
|
||||||
|
learning_goal=m.learning_goal,
|
||||||
|
)
|
||||||
|
for m in roadmap_ctx.roadmap.major_steps[:max_steps]
|
||||||
|
]
|
||||||
|
|
||||||
|
used: Set[int] = set()
|
||||||
|
steps: List[Dict[str, Any]] = []
|
||||||
|
planned_ids: List[int] = []
|
||||||
|
anchor_id: Optional[int] = None
|
||||||
|
anchor_variant_id: Optional[int] = None
|
||||||
|
unfilled: List[Tuple[int, StageSpecArtifact]] = []
|
||||||
|
stage_count = len(stage_specs)
|
||||||
|
|
||||||
|
for step_index, stage_spec in enumerate(stage_specs):
|
||||||
|
step, unfilled_spec = _match_roadmap_slot(
|
||||||
|
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=stage_count,
|
||||||
|
planned_ids=planned_ids,
|
||||||
|
anchor_id=anchor_id,
|
||||||
|
anchor_variant_id=anchor_variant_id,
|
||||||
|
used=used,
|
||||||
|
)
|
||||||
|
if not step:
|
||||||
|
unfilled.append((step_index, unfilled_spec or stage_spec))
|
||||||
|
continue
|
||||||
|
|
||||||
steps.append(step)
|
steps.append(step)
|
||||||
eid = int(step["exercise_id"])
|
eid = int(step["exercise_id"])
|
||||||
used.add(eid)
|
used.add(eid)
|
||||||
|
|
@ -1245,6 +1291,8 @@ def suggest_progression_path(
|
||||||
gap_fill_offers: List[Dict[str, Any]] = []
|
gap_fill_offers: List[Dict[str, Any]] = []
|
||||||
off_topic_steps: List[Dict[str, Any]] = []
|
off_topic_steps: List[Dict[str, Any]] = []
|
||||||
stripped_off_topic: List[Dict[str, Any]] = []
|
stripped_off_topic: List[Dict[str, Any]] = []
|
||||||
|
rematch_log: List[Dict[str, Any]] = []
|
||||||
|
rematch_rounds = 0
|
||||||
llm_qa: Optional[Dict[str, Any]] = None
|
llm_qa: Optional[Dict[str, Any]] = None
|
||||||
llm_qa_applied = False
|
llm_qa_applied = False
|
||||||
reorder_applied = False
|
reorder_applied = False
|
||||||
|
|
@ -1315,6 +1363,7 @@ def suggest_progression_path(
|
||||||
brief=semantic_brief,
|
brief=semantic_brief,
|
||||||
goal_query=goal_query,
|
goal_query=goal_query,
|
||||||
)
|
)
|
||||||
|
off_topic_before_strip = list(off_topic_steps)
|
||||||
steps, stripped_off_topic = strip_off_topic_steps_from_path(steps, off_topic_steps)
|
steps, stripped_off_topic = strip_off_topic_steps_from_path(steps, off_topic_steps)
|
||||||
if stripped_off_topic:
|
if stripped_off_topic:
|
||||||
off_topic_steps = []
|
off_topic_steps = []
|
||||||
|
|
@ -1325,6 +1374,56 @@ def suggest_progression_path(
|
||||||
roadmap_first=roadmap_first,
|
roadmap_first=roadmap_first,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
roadmap_first
|
||||||
|
and body.auto_rematch_after_qa
|
||||||
|
and roadmap_ctx is not None
|
||||||
|
and roadmap_ctx.stage_specs
|
||||||
|
):
|
||||||
|
slot_indices, rematch_reasons = collect_rematch_slot_indices(
|
||||||
|
stripped_off_topic=stripped_off_topic,
|
||||||
|
off_topic_steps=off_topic_before_strip if not stripped_off_topic else [],
|
||||||
|
optimization_hints=[],
|
||||||
|
stage_specs=roadmap_ctx.stage_specs,
|
||||||
|
)
|
||||||
|
if slot_indices:
|
||||||
|
steps, rematch_log, rematch_new_unfilled = rematch_roadmap_slots(
|
||||||
|
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,
|
||||||
|
steps=steps,
|
||||||
|
slot_indices=slot_indices,
|
||||||
|
rematch_reasons=rematch_reasons,
|
||||||
|
match_slot_fn=_match_roadmap_slot,
|
||||||
|
)
|
||||||
|
rematch_rounds = 1
|
||||||
|
if rematch_new_unfilled:
|
||||||
|
remapped = {sp.major_step_index for _, sp in rematch_new_unfilled}
|
||||||
|
roadmap_unfilled = [
|
||||||
|
item
|
||||||
|
for item in roadmap_unfilled
|
||||||
|
if item[1].major_step_index not in remapped
|
||||||
|
]
|
||||||
|
roadmap_unfilled.extend(rematch_new_unfilled)
|
||||||
|
off_topic_steps = detect_off_topic_steps(
|
||||||
|
cur,
|
||||||
|
steps,
|
||||||
|
brief=semantic_brief,
|
||||||
|
goal_query=goal_query,
|
||||||
|
)
|
||||||
|
gaps = detect_path_gaps(
|
||||||
|
cur,
|
||||||
|
steps,
|
||||||
|
brief=semantic_brief,
|
||||||
|
roadmap_first=roadmap_first,
|
||||||
|
)
|
||||||
|
|
||||||
llm_gap_specs = parse_llm_suggested_new_exercises(
|
llm_gap_specs = parse_llm_suggested_new_exercises(
|
||||||
llm_qa,
|
llm_qa,
|
||||||
brief=semantic_brief,
|
brief=semantic_brief,
|
||||||
|
|
@ -1396,6 +1495,10 @@ def suggest_progression_path(
|
||||||
roadmap_qa_mode=roadmap_qa_mode,
|
roadmap_qa_mode=roadmap_qa_mode,
|
||||||
multistage_qa=multistage_qa,
|
multistage_qa=multistage_qa,
|
||||||
)
|
)
|
||||||
|
if rematch_log:
|
||||||
|
path_qa["rematch_applied"] = True
|
||||||
|
path_qa["rematch_log"] = rematch_log
|
||||||
|
path_qa["rematch_rounds"] = rematch_rounds
|
||||||
|
|
||||||
target_profile_summary = path_target_profile.to_summary_dict(cur)
|
target_profile_summary = path_target_profile.to_summary_dict(cur)
|
||||||
retrieval_parts = ["profile_v1", "full_library", "path_builder", "semantics"]
|
retrieval_parts = ["profile_v1", "full_library", "path_builder", "semantics"]
|
||||||
|
|
@ -1419,6 +1522,8 @@ def suggest_progression_path(
|
||||||
retrieval_parts.append("roadmap_edited")
|
retrieval_parts.append("roadmap_edited")
|
||||||
if roadmap_unfilled:
|
if roadmap_unfilled:
|
||||||
retrieval_parts.append("roadmap_unfilled")
|
retrieval_parts.append("roadmap_unfilled")
|
||||||
|
if rematch_log:
|
||||||
|
retrieval_parts.append("path_rematch")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"goal_query": goal_query,
|
"goal_query": goal_query,
|
||||||
|
|
|
||||||
|
|
@ -398,6 +398,16 @@ def apply_llm_path_reorder(
|
||||||
_OFF_TOPIC_SEMANTIC_MAX = 0.10
|
_OFF_TOPIC_SEMANTIC_MAX = 0.10
|
||||||
|
|
||||||
|
|
||||||
|
def _with_roadmap_major_index(
|
||||||
|
step: Mapping[str, Any],
|
||||||
|
entry: Dict[str, Any],
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
midx = step.get("roadmap_major_step_index")
|
||||||
|
if midx is not None:
|
||||||
|
entry["roadmap_major_step_index"] = int(midx)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
def detect_off_topic_steps(
|
def detect_off_topic_steps(
|
||||||
cur,
|
cur,
|
||||||
steps: Sequence[Mapping[str, Any]],
|
steps: Sequence[Mapping[str, Any]],
|
||||||
|
|
@ -425,6 +435,8 @@ def detect_off_topic_steps(
|
||||||
step_anti = list(step.get("roadmap_anti_patterns") or []) + path_anti
|
step_anti = list(step.get("roadmap_anti_patterns") or []) + path_anti
|
||||||
if step_anti and _blob_matches_stage_excludes(blob, step_anti):
|
if step_anti and _blob_matches_stage_excludes(blob, step_anti):
|
||||||
off_topic.append(
|
off_topic.append(
|
||||||
|
_with_roadmap_major_index(
|
||||||
|
step,
|
||||||
{
|
{
|
||||||
"step_index": idx,
|
"step_index": idx,
|
||||||
"exercise_id": int(step["exercise_id"]),
|
"exercise_id": int(step["exercise_id"]),
|
||||||
|
|
@ -433,7 +445,8 @@ def detect_off_topic_steps(
|
||||||
"expected_phase": (step.get("roadmap_phase") or "").strip().lower() or None,
|
"expected_phase": (step.get("roadmap_phase") or "").strip().lower() or None,
|
||||||
"issue": "path_exclude",
|
"issue": "path_exclude",
|
||||||
"reasons": ["Widerspricht Pfad-Ausschlüssen (z. B. Kumite)"],
|
"reasons": ["Widerspricht Pfad-Ausschlüssen (z. B. Kumite)"],
|
||||||
}
|
},
|
||||||
|
)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
primary = (brief.primary_topic or "").strip()
|
primary = (brief.primary_topic or "").strip()
|
||||||
|
|
@ -450,6 +463,8 @@ def detect_off_topic_steps(
|
||||||
relaxed=False,
|
relaxed=False,
|
||||||
):
|
):
|
||||||
off_topic.append(
|
off_topic.append(
|
||||||
|
_with_roadmap_major_index(
|
||||||
|
step,
|
||||||
{
|
{
|
||||||
"step_index": idx,
|
"step_index": idx,
|
||||||
"exercise_id": int(step["exercise_id"]),
|
"exercise_id": int(step["exercise_id"]),
|
||||||
|
|
@ -458,7 +473,8 @@ def detect_off_topic_steps(
|
||||||
"expected_phase": (step.get("roadmap_phase") or "").strip().lower() or None,
|
"expected_phase": (step.get("roadmap_phase") or "").strip().lower() or None,
|
||||||
"issue": "technique_scope",
|
"issue": "technique_scope",
|
||||||
"reasons": [f"Passt nicht zur Haupttechnik „{primary}“"],
|
"reasons": [f"Passt nicht zur Haupttechnik „{primary}“"],
|
||||||
}
|
},
|
||||||
|
)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
stage_goal = (step.get("roadmap_learning_goal") or "").strip()
|
stage_goal = (step.get("roadmap_learning_goal") or "").strip()
|
||||||
|
|
@ -488,6 +504,8 @@ def detect_off_topic_steps(
|
||||||
anti_patterns=stage_anti or None,
|
anti_patterns=stage_anti or None,
|
||||||
):
|
):
|
||||||
off_topic.append(
|
off_topic.append(
|
||||||
|
_with_roadmap_major_index(
|
||||||
|
step,
|
||||||
{
|
{
|
||||||
"step_index": idx,
|
"step_index": idx,
|
||||||
"exercise_id": int(step["exercise_id"]),
|
"exercise_id": int(step["exercise_id"]),
|
||||||
|
|
@ -497,7 +515,8 @@ def detect_off_topic_steps(
|
||||||
"issue": "stage_mismatch",
|
"issue": "stage_mismatch",
|
||||||
"roadmap_learning_goal": stage_goal,
|
"roadmap_learning_goal": stage_goal,
|
||||||
"reasons": sem_reasons[:3],
|
"reasons": sem_reasons[:3],
|
||||||
}
|
},
|
||||||
|
)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
if exercise_passes_path_semantic_gate(
|
if exercise_passes_path_semantic_gate(
|
||||||
|
|
@ -512,6 +531,8 @@ def detect_off_topic_steps(
|
||||||
if sem > _OFF_TOPIC_SEMANTIC_MAX:
|
if sem > _OFF_TOPIC_SEMANTIC_MAX:
|
||||||
continue
|
continue
|
||||||
off_topic.append(
|
off_topic.append(
|
||||||
|
_with_roadmap_major_index(
|
||||||
|
step,
|
||||||
{
|
{
|
||||||
"step_index": idx,
|
"step_index": idx,
|
||||||
"exercise_id": int(step["exercise_id"]),
|
"exercise_id": int(step["exercise_id"]),
|
||||||
|
|
@ -520,7 +541,8 @@ def detect_off_topic_steps(
|
||||||
"expected_phase": phase,
|
"expected_phase": phase,
|
||||||
"issue": "off_topic",
|
"issue": "off_topic",
|
||||||
"reasons": sem_reasons[:3],
|
"reasons": sem_reasons[:3],
|
||||||
}
|
},
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return off_topic
|
return off_topic
|
||||||
|
|
||||||
|
|
|
||||||
202
backend/planning_path_rematch.py
Normal file
202
backend/planning_path_rematch.py
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
"""
|
||||||
|
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",
|
||||||
|
]
|
||||||
133
backend/tests/test_planning_path_rematch.py
Normal file
133
backend/tests/test_planning_path_rematch.py
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
"""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_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 not in used
|
||||||
|
assert 10 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
|
||||||
Loading…
Reference in New Issue
Block a user