Enhance Roadmap Step Handling and Off-Topic Logic
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 52s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 41s
Test Suite / playwright-tests (push) Successful in 1m51s

- Improved off-topic step handling by incorporating roadmap major step indices for better indexing and detection.
- Refactored `collect_gap_fill_specs` to streamline the insertion logic for off-topic steps, ensuring correct placement based on major step indices.
- Introduced `_normalize_roadmap_steps_coverage` function to standardize roadmap steps coverage, enhancing the handling of missing slots.
- Added `prune_stripped_after_rematch` function to clean up stripped off-topic steps after rematching, improving the overall rematching process.
- Updated tests to validate new rematching and off-topic handling features, ensuring robustness against edge cases.
- Incremented application version to reflect these updates.
This commit is contained in:
Lars 2026-06-11 10:40:25 +02:00
parent 1d94c2ebf1
commit 713a344d17
7 changed files with 269 additions and 53 deletions

View File

@ -363,15 +363,35 @@ def collect_gap_fill_specs(
) )
for ot in off_topic_steps: for ot in off_topic_steps:
major_idx = ot.get("roadmap_major_step_index")
idx: Optional[int] = None
if major_idx is not None:
try:
mi = int(major_idx)
except (TypeError, ValueError):
mi = None
if mi is not None:
idx = next(
(
i
for i, s in enumerate(steps)
if s.get("roadmap_major_step_index") is not None
and int(s["roadmap_major_step_index"]) == mi
),
None,
)
if idx is None:
idx = int(ot.get("step_index") or 0) idx = int(ot.get("step_index") or 0)
if idx <= 0 or idx >= len(steps) - 1: if idx < 0 or idx >= len(steps):
continue continue
phase = ot.get("expected_phase") or "vertiefung" phase = ot.get("expected_phase") or "vertiefung"
insert_after = max(idx - 1, -1)
add( add(
{ {
"source": "off_topic", "source": "off_topic",
"insert_after_index": idx - 1, "insert_after_index": insert_after,
"replace_step_index": idx, "replace_step_index": idx,
"roadmap_major_step_index": major_idx,
"gap": { "gap": {
"expected_phase": phase, "expected_phase": phase,
"off_topic_title": ot.get("title"), "off_topic_title": ot.get("title"),

View File

@ -14,7 +14,11 @@ 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_path_rematch import (
collect_rematch_slot_indices,
prune_stripped_after_rematch,
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,
@ -704,6 +708,129 @@ def _match_roadmap_slot(
return step, None return step, None
def _normalize_roadmap_steps_coverage(
steps: List[Dict[str, Any]],
*,
roadmap_ctx: ProgressionRoadmapContext,
max_steps: int,
) -> List[Dict[str, Any]]:
"""Ein Eintrag pro Roadmap-Major-Step — fehlende Slots als leere Platzhalter."""
stage_specs = list(roadmap_ctx.stage_specs or [])[:max_steps]
if not stage_specs:
return steps
major_by_index: Dict[int, MajorStep] = {}
if roadmap_ctx.roadmap:
major_by_index = {m.index: m for m in roadmap_ctx.roadmap.major_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:
by_major[int(midx)] = step
out: 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 by_major:
out.append(by_major[midx])
continue
major = major_by_index.get(midx)
goal = (spec.learning_goal or "").strip()
out.append(
{
"exercise_id": None,
"variant_id": None,
"title": goal or f"Slot {midx + 1}",
"is_ai_proposal": False,
"roadmap_major_step_index": midx,
"roadmap_phase": major.phase if major else None,
"roadmap_learning_goal": goal or None,
"roadmap_match_source": "stage_spec",
"reasons": [],
}
)
return out
def _maybe_rematch_roadmap_after_strip(
cur,
*,
tenant: TenantContext,
body: ProgressionPathSuggestRequest,
goal_query: str,
max_steps: int,
semantic_brief: PlanningSemanticBrief,
path_target_profile: PlanningTargetProfile,
path_intent: str,
roadmap_ctx: ProgressionRoadmapContext,
steps: List[Dict[str, Any]],
stripped_off_topic: List[Dict[str, Any]],
off_topic_before_strip: List[Dict[str, Any]],
roadmap_unfilled: List[Tuple[int, StageSpecArtifact]],
) -> Tuple[
List[Dict[str, Any]],
List[Dict[str, Any]],
List[Dict[str, Any]],
List[Dict[str, Any]],
int,
List[Tuple[int, StageSpecArtifact]],
]:
rematch_log: List[Dict[str, Any]] = []
rematch_rounds = 0
if not body.auto_rematch_after_qa or not roadmap_ctx.stage_specs:
return steps, rematch_log, stripped_off_topic, [], rematch_rounds, roadmap_unfilled
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 not slot_indices:
return steps, rematch_log, stripped_off_topic, [], rematch_rounds, roadmap_unfilled
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
stripped_off_topic = prune_stripped_after_rematch(stripped_off_topic, rematch_log)
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,
)
return (
steps,
rematch_log,
stripped_off_topic,
off_topic_steps,
rematch_rounds,
roadmap_unfilled,
)
def _build_steps_roadmap_first( def _build_steps_roadmap_first(
cur, cur,
*, *,
@ -906,7 +1033,6 @@ def _run_evaluate_only_path_qa(
brief=semantic_brief, brief=semantic_brief,
goal_query=goal_query, goal_query=goal_query,
) )
steps, stripped_off_topic = strip_off_topic_steps_from_path(steps, off_topic_steps)
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,
@ -918,7 +1044,7 @@ def _run_evaluate_only_path_qa(
gap_specs = collect_gap_fill_specs( gap_specs = collect_gap_fill_specs(
steps=steps, steps=steps,
unfilled_gaps=fresh_large_gaps or unfilled_gaps, unfilled_gaps=fresh_large_gaps or unfilled_gaps,
off_topic_steps=off_topic_steps if not stripped_off_topic else [], off_topic_steps=off_topic_steps,
llm_specs=llm_gap_specs, llm_specs=llm_gap_specs,
brief=semantic_brief, brief=semantic_brief,
goal_query=goal_query, goal_query=goal_query,
@ -1374,20 +1500,15 @@ def suggest_progression_path(
roadmap_first=roadmap_first, roadmap_first=roadmap_first,
) )
if ( if roadmap_first and roadmap_ctx is not None:
roadmap_first (
and body.auto_rematch_after_qa steps,
and roadmap_ctx is not None rematch_log,
and roadmap_ctx.stage_specs stripped_off_topic,
): rematch_off_topic,
slot_indices, rematch_reasons = collect_rematch_slot_indices( rematch_rounds,
stripped_off_topic=stripped_off_topic, roadmap_unfilled,
off_topic_steps=off_topic_before_strip if not stripped_off_topic else [], ) = _maybe_rematch_roadmap_after_strip(
optimization_hints=[],
stage_specs=roadmap_ctx.stage_specs,
)
if slot_indices:
steps, rematch_log, rematch_new_unfilled = rematch_roadmap_slots(
cur, cur,
tenant=tenant, tenant=tenant,
body=body, body=body,
@ -1398,25 +1519,12 @@ def suggest_progression_path(
path_intent=path_intent, path_intent=path_intent,
roadmap_ctx=roadmap_ctx, roadmap_ctx=roadmap_ctx,
steps=steps, steps=steps,
slot_indices=slot_indices, stripped_off_topic=stripped_off_topic,
rematch_reasons=rematch_reasons, off_topic_before_strip=off_topic_before_strip,
match_slot_fn=_match_roadmap_slot, roadmap_unfilled=roadmap_unfilled,
)
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,
) )
if rematch_off_topic:
off_topic_steps = rematch_off_topic
gaps = detect_path_gaps( gaps = detect_path_gaps(
cur, cur,
steps, steps,
@ -1500,6 +1608,13 @@ def suggest_progression_path(
path_qa["rematch_log"] = rematch_log path_qa["rematch_log"] = rematch_log
path_qa["rematch_rounds"] = rematch_rounds path_qa["rematch_rounds"] = rematch_rounds
if roadmap_first and roadmap_ctx is not None:
steps = _normalize_roadmap_steps_coverage(
steps,
roadmap_ctx=roadmap_ctx,
max_steps=max_steps,
)
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"]
if roadmap_first: if roadmap_first:

View File

@ -416,7 +416,14 @@ def detect_off_topic_steps(
goal_query: Optional[str] = None, goal_query: Optional[str] = None,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Schritte ohne Bezug zum Pfad-Thema (z. B. reine Kraftübungen bei Mae Geri).""" """Schritte ohne Bezug zum Pfad-Thema (z. B. reine Kraftübungen bei Mae Geri)."""
if brief.semantic_strength < 0.55 or len(steps) < 2: if len(steps) < 2:
return []
roadmap_stage_steps = any(
(step.get("roadmap_match_source") == "stage_spec")
or (step.get("roadmap_learning_goal") or "").strip()
for step in steps
)
if brief.semantic_strength < 0.55 and not roadmap_stage_steps:
return [] return []
path_anti = resolve_path_anti_patterns(goal_query or "", semantic_brief=brief) path_anti = resolve_path_anti_patterns(goal_query or "", semantic_brief=brief)

View File

@ -137,6 +137,8 @@ def rematch_roadmap_slots(
for m, s in steps_by_major.items() for m, s in steps_by_major.items()
if s.get("exercise_id") is not None if s.get("exercise_id") is not None
} }
if old and old.get("exercise_id") is not None:
used.add(int(old["exercise_id"]))
planned_ids, anchor_id, anchor_variant_id = _context_before_major( planned_ids, anchor_id, anchor_variant_id = _context_before_major(
steps_by_major, int(major_idx) steps_by_major, int(major_idx)
) )
@ -196,7 +198,35 @@ def rematch_roadmap_slots(
return ordered, rematch_log, new_unfilled 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__ = [ __all__ = [
"collect_rematch_slot_indices", "collect_rematch_slot_indices",
"prune_stripped_after_rematch",
"rematch_roadmap_slots", "rematch_roadmap_slots",
] ]

View File

@ -95,8 +95,9 @@ def test_rematch_roadmap_slots_replaces_only_target_slot():
def _fake_match(cur, *, stage_spec, used, **kwargs): def _fake_match(cur, *, stage_spec, used, **kwargs):
assert stage_spec.major_step_index == 1 assert stage_spec.major_step_index == 1
assert 20 not in used assert 20 in used
assert 10 in used assert 10 in used
assert 30 in used
return ( return (
{ {
"exercise_id": 21, "exercise_id": 21,
@ -131,3 +132,41 @@ def test_rematch_roadmap_slots_replaces_only_target_slot():
assert log[0]["replaced_exercise_id"] == 20 assert log[0]["replaced_exercise_id"] == 20
assert log[0]["new_exercise_id"] == 21 assert log[0]["new_exercise_id"] == 21
assert not unfilled 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]

View File

@ -1022,10 +1022,7 @@ export default function ExerciseProgressionPathBuilder({
const applyPathMatchResponse = (res, q) => { const applyPathMatchResponse = (res, q) => {
const qa = res?.path_qa || null const qa = res?.path_qa || null
const rawRows = (Array.isArray(res?.steps) ? res.steps : []).map(mapApiStepToRow) const rawRows = (Array.isArray(res?.steps) ? res.steps : []).map(mapApiStepToRow)
const rows = const rows = applyOffTopicFlags(rawRows, qa)
Array.isArray(qa?.stripped_off_topic_steps) && qa.stripped_off_topic_steps.length > 0
? rawRows
: applyOffTopicFlags(rawRows, qa)
if (rows.length < 2) { if (rows.length < 2) {
throw new Error('Zu wenig Schritte im Vorschlag.') throw new Error('Zu wenig Schritte im Vorschlag.')
} }

View File

@ -750,12 +750,14 @@ export function applyMatchStepsToSlots(draft, apiSteps) {
siblings: [...(slot.siblings || [])], siblings: [...(slot.siblings || [])],
})) }))
const touchedMajors = new Set()
for (const step of steps) { for (const step of steps) {
if (step.roadmap_major_step_index == null || !Number.isFinite(Number(step.roadmap_major_step_index))) { if (step.roadmap_major_step_index == null || !Number.isFinite(Number(step.roadmap_major_step_index))) {
continue continue
} }
const idx = Number(step.roadmap_major_step_index) const idx = Number(step.roadmap_major_step_index)
if (idx < 0 || idx >= nextSlots.length) continue if (idx < 0 || idx >= nextSlots.length) continue
touchedMajors.add(idx)
const isProposal = Boolean(step.is_ai_proposal) || step.exercise_id == null const isProposal = Boolean(step.is_ai_proposal) || step.exercise_id == null
if (isProposal) { if (isProposal) {
@ -773,6 +775,12 @@ export function applyMatchStepsToSlots(draft, apiSteps) {
} }
} }
for (let i = 0; i < nextSlots.length; i += 1) {
if (!touchedMajors.has(i)) {
nextSlots[i].primary = emptySlotExercise()
}
}
return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true }) return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true })
} }