Enhance Gap Fill and Rematch Logic in Progression Path
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 47s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 39s
Test Suite / playwright-tests (push) Successful in 1m22s

- Introduced `_step_neighbors_at_index` to safely retrieve neighboring steps without causing IndexErrors, improving robustness in gap fill specifications.
- Updated `collect_gap_fill_specs` to utilize the new neighbor retrieval function, ensuring safe access to adjacent steps during gap fill processing.
- Enhanced rematch logic in `_run_roadmap_rematch_loop` to incorporate `max_rematch_rounds`, allowing for controlled iterations during roadmap rematching.
- Improved handling of unfilled roadmap slots in `collect_rematch_slot_indices`, ensuring accurate identification of gaps in the progression path.
- Added tests to validate the new gap fill handling and rematch logic, ensuring reliability in path suggestion features.
This commit is contained in:
Lars 2026-06-11 21:20:47 +02:00
parent de939481ba
commit df93da9a03
6 changed files with 217 additions and 53 deletions

View File

@ -337,6 +337,18 @@ def _spec_dedupe_key(spec: Mapping[str, Any]) -> Tuple[Any, ...]:
) )
def _step_neighbors_at_index(
steps: Sequence[Mapping[str, Any]],
idx: int,
) -> Tuple[Optional[Mapping[str, Any]], Optional[Mapping[str, Any]]]:
"""Vorheriger/nächster Pfadschritt ohne IndexError (Rand-Slots, leere Stufen)."""
if idx < 0 or idx >= len(steps):
return None, None
step_a = steps[idx - 1] if idx > 0 else None
step_b = steps[idx + 1] if idx + 1 < len(steps) else None
return step_a, step_b
def collect_gap_fill_specs( def collect_gap_fill_specs(
*, *,
steps: Sequence[Mapping[str, Any]], steps: Sequence[Mapping[str, Any]],
@ -364,8 +376,10 @@ def collect_gap_fill_specs(
int(gap["from_exercise_id"]), int(gap["from_exercise_id"]),
int(gap["to_exercise_id"]), int(gap["to_exercise_id"]),
) )
if idx is None: if idx is None or idx + 1 >= len(steps):
continue continue
step_a = steps[idx]
step_b = steps[idx + 1]
phase = gap.get("expected_phase") or "vertiefung" phase = gap.get("expected_phase") or "vertiefung"
add( add(
{ {
@ -377,12 +391,12 @@ def collect_gap_fill_specs(
"sketch": _default_sketch( "sketch": _default_sketch(
goal_query=goal_query, goal_query=goal_query,
brief=brief, brief=brief,
step_a=steps[idx], step_a=step_a,
step_b=steps[idx + 1], step_b=step_b,
phase=str(phase), phase=str(phase),
rationale="Bibliothek enthält keine passende Brücke.", rationale="Bibliothek enthält keine passende Brücke.",
), ),
"rationale": "Lücke zwischen benachbarten Schritten — keine passende Bibliotheks-Übung.", "rationale": "Lücke zwischen benachbaren Schritten — keine passende Bibliotheks-Übung.",
} }
) )
@ -408,6 +422,7 @@ def collect_gap_fill_specs(
idx = int(ot.get("step_index") or 0) idx = int(ot.get("step_index") or 0)
if idx < 0 or idx >= len(steps): if idx < 0 or idx >= len(steps):
continue continue
step_a, step_b = _step_neighbors_at_index(steps, idx)
phase = ot.get("expected_phase") or "vertiefung" phase = ot.get("expected_phase") or "vertiefung"
insert_after = max(idx - 1, -1) insert_after = max(idx - 1, -1)
add( add(
@ -426,8 +441,8 @@ def collect_gap_fill_specs(
"sketch": _default_sketch( "sketch": _default_sketch(
goal_query=goal_query, goal_query=goal_query,
brief=brief, brief=brief,
step_a=steps[idx - 1], step_a=step_a,
step_b=steps[idx + 1], step_b=step_b,
phase=str(phase), phase=str(phase),
rationale=f"Ersetzt themenfremden Schritt „{ot.get('title')}“.", rationale=f"Ersetzt themenfremden Schritt „{ot.get('title')}“.",
), ),

View File

@ -108,6 +108,7 @@ class ProgressionPathSuggestRequest(BaseModel):
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 auto_rematch_after_qa: bool = True
max_rematch_rounds: int = Field(default=2, ge=0, le=3)
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
@ -438,7 +439,17 @@ def _load_supplemental_exercise_rows(
vis_params: Sequence[Any], vis_params: Sequence[Any],
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Supplemental-Übungen mit Graph-Sichtbarkeit, Fallback Library-vis_sql.""" """Supplemental-Übungen mit Graph-Sichtbarkeit, Fallback Library-vis_sql."""
ids = list(dict.fromkeys(int(x) for x in (exercise_ids or []) if int(x) > 0)) ids: List[int] = []
for raw in exercise_ids or []:
if raw is None:
continue
try:
eid = int(raw)
except (TypeError, ValueError):
continue
if eid > 0:
ids.append(eid)
ids = list(dict.fromkeys(ids))
if not ids: if not ids:
return [] return []
if progression_graph_id and int(progression_graph_id) > 0: if progression_graph_id and int(progression_graph_id) > 0:
@ -1222,7 +1233,19 @@ def _normalize_roadmap_steps_coverage(
return out return out
def _maybe_rematch_roadmap_after_strip( def _merge_rematch_unfilled(
roadmap_unfilled: List[Tuple[int, StageSpecArtifact]],
rematch_new_unfilled: List[Tuple[int, StageSpecArtifact]],
) -> List[Tuple[int, StageSpecArtifact]]:
if not rematch_new_unfilled:
return roadmap_unfilled
remapped = {sp.major_step_index for _, sp in rematch_new_unfilled}
kept = [item for item in roadmap_unfilled if item[1].major_step_index not in remapped]
kept.extend(rematch_new_unfilled)
return kept
def _run_roadmap_rematch_loop(
cur, cur,
*, *,
tenant: TenantContext, tenant: TenantContext,
@ -1237,6 +1260,7 @@ def _maybe_rematch_roadmap_after_strip(
stripped_off_topic: List[Dict[str, Any]], stripped_off_topic: List[Dict[str, Any]],
off_topic_before_strip: List[Dict[str, Any]], off_topic_before_strip: List[Dict[str, Any]],
roadmap_unfilled: List[Tuple[int, StageSpecArtifact]], roadmap_unfilled: List[Tuple[int, StageSpecArtifact]],
gaps: List[Dict[str, Any]],
) -> Tuple[ ) -> Tuple[
List[Dict[str, Any]], List[Dict[str, Any]],
List[Dict[str, Any]], List[Dict[str, Any]],
@ -1245,54 +1269,92 @@ def _maybe_rematch_roadmap_after_strip(
int, int,
List[Tuple[int, StageSpecArtifact]], List[Tuple[int, StageSpecArtifact]],
]: ]:
"""Phase A/B: Rematch-Schleife aus Strip, unfilled Slots und optimization_hints."""
rematch_log: List[Dict[str, Any]] = [] rematch_log: List[Dict[str, Any]] = []
rematch_rounds = 0 rematch_rounds = 0
if not body.auto_rematch_after_qa or not roadmap_ctx.stage_specs: max_rounds = int(body.max_rematch_rounds or 0)
return steps, rematch_log, stripped_off_topic, [], rematch_rounds, roadmap_unfilled if not body.auto_rematch_after_qa or max_rounds <= 0 or not roadmap_ctx.stage_specs:
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
slot_indices, rematch_reasons = collect_rematch_slot_indices( current_stripped = list(stripped_off_topic or [])
stripped_off_topic=stripped_off_topic, use_initial_off_topic = not current_stripped
off_topic_steps=off_topic_before_strip if not stripped_off_topic else [], off_topic_steps: List[Dict[str, Any]] = []
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( for round_idx in range(max_rounds):
cur, mini_qa = run_multistage_path_qa(
tenant=tenant, off_topic_steps=off_topic_steps if round_idx > 0 else [],
body=body, stripped_off_topic=current_stripped if round_idx == 0 else [],
goal_query=goal_query, gaps=gaps if round_idx == 0 else [],
max_steps=max_steps, llm_qa=None,
semantic_brief=semantic_brief, llm_applied=False,
path_target_profile=path_target_profile, roadmap_unfilled=roadmap_unfilled,
path_intent=path_intent, )
roadmap_ctx=roadmap_ctx, optimization_hints = list(mini_qa.get("optimization_hints") or [])
steps=steps,
slot_indices=slot_indices, slot_indices, rematch_reasons = collect_rematch_slot_indices(
rematch_reasons=rematch_reasons, stripped_off_topic=current_stripped if round_idx == 0 else [],
match_slot_fn=_match_roadmap_slot, off_topic_steps=off_topic_before_strip if round_idx == 0 and use_initial_off_topic else [],
) optimization_hints=optimization_hints,
rematch_rounds = 1 stage_specs=roadmap_ctx.stage_specs,
stripped_off_topic = prune_stripped_after_rematch(stripped_off_topic, rematch_log) roadmap_unfilled=roadmap_unfilled,
if rematch_new_unfilled: )
remapped = {sp.major_step_index for _, sp in rematch_new_unfilled} if not slot_indices:
roadmap_unfilled = [ break
item for item in roadmap_unfilled if item[1].major_step_index not in remapped
] steps, round_log, rematch_new_unfilled = rematch_roadmap_slots(
roadmap_unfilled.extend(rematch_new_unfilled) 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
for entry in round_log:
tagged = dict(entry)
tagged["round"] = rematch_rounds
rematch_log.append(tagged)
current_stripped = prune_stripped_after_rematch(current_stripped, round_log)
roadmap_unfilled = _merge_rematch_unfilled(roadmap_unfilled, rematch_new_unfilled)
use_initial_off_topic = False
off_topic_steps = detect_off_topic_steps(
cur,
steps,
brief=semantic_brief,
goal_query=goal_query,
)
if round_idx + 1 >= max_rounds:
break
if not off_topic_steps and not roadmap_unfilled:
break
if not off_topic_steps:
off_topic_steps = detect_off_topic_steps(
cur,
steps,
brief=semantic_brief,
goal_query=goal_query,
)
off_topic_steps = detect_off_topic_steps(
cur,
steps,
brief=semantic_brief,
goal_query=goal_query,
)
return ( return (
steps, steps,
rematch_log, rematch_log,
stripped_off_topic, current_stripped,
off_topic_steps, off_topic_steps,
rematch_rounds, rematch_rounds,
roadmap_unfilled, roadmap_unfilled,
@ -2000,7 +2062,7 @@ def suggest_progression_path(
rematch_off_topic, rematch_off_topic,
rematch_rounds, rematch_rounds,
roadmap_unfilled, roadmap_unfilled,
) = _maybe_rematch_roadmap_after_strip( ) = _run_roadmap_rematch_loop(
cur, cur,
tenant=tenant, tenant=tenant,
body=body, body=body,
@ -2014,6 +2076,7 @@ def suggest_progression_path(
stripped_off_topic=stripped_off_topic, stripped_off_topic=stripped_off_topic,
off_topic_before_strip=off_topic_before_strip, off_topic_before_strip=off_topic_before_strip,
roadmap_unfilled=roadmap_unfilled, roadmap_unfilled=roadmap_unfilled,
gaps=gaps,
) )
if rematch_off_topic: if rematch_off_topic:
off_topic_steps = rematch_off_topic off_topic_steps = rematch_off_topic

View File

@ -1,5 +1,5 @@
""" """
Auto-Rematch nach Pfad-QS betroffene Roadmap-Slots erneut matchen (Phase A). Auto-Rematch nach Pfad-QS betroffene Roadmap-Slots erneut matchen (Phase A/B).
""" """
from __future__ import annotations from __future__ import annotations
@ -14,6 +14,7 @@ def collect_rematch_slot_indices(
off_topic_steps: Sequence[Mapping[str, Any]], off_topic_steps: Sequence[Mapping[str, Any]],
optimization_hints: Sequence[Mapping[str, Any]], optimization_hints: Sequence[Mapping[str, Any]],
stage_specs: Sequence[StageSpecArtifact], stage_specs: Sequence[StageSpecArtifact],
roadmap_unfilled: Optional[Sequence[Any]] = None,
) -> Tuple[Set[int], Dict[int, str]]: ) -> Tuple[Set[int], Dict[int, str]]:
"""Major-Step-Indizes für rematch_slot + Begründung pro Slot.""" """Major-Step-Indizes für rematch_slot + Begründung pro Slot."""
spec_by_pos = list(stage_specs) spec_by_pos = list(stage_specs)
@ -64,6 +65,18 @@ def collect_rematch_slot_indices(
if midx is not None: if midx is not None:
_register(midx, str(hint.get("reason") or hint.get("issue") or "rematch_slot")) _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 return indices, reasons

View File

@ -214,3 +214,55 @@ def test_build_gap_fill_offer_exposes_context_preview():
) )
assert offer["context_preview"]["start_situation"] == "Steppbewegung" assert offer["context_preview"]["start_situation"] == "Steppbewegung"
assert "variable Rhythmen" in offer["goal_for_ai"] assert "variable Rhythmen" in offer["goal_for_ai"]
def test_collect_gap_fill_specs_off_topic_last_step_no_crash():
"""Rand-Slot: off_topic am letzten Schritt darf keinen IndexError auslösen (500)."""
brief = build_semantic_brief("Mawashi Geri Kumite")
steps = [
{"exercise_id": 1, "title": "Stand", "roadmap_major_step_index": 0},
{"exercise_id": 2, "title": "Yoko Geri", "roadmap_major_step_index": 1},
]
specs = collect_gap_fill_specs(
steps=steps,
unfilled_gaps=[],
off_topic_steps=[
{
"step_index": 1,
"roadmap_major_step_index": 1,
"title": "Yoko Geri",
"expected_phase": "anwendung",
}
],
llm_specs=[],
brief=brief,
goal_query="Mawashi Geri Kumite",
)
assert len(specs) == 1
assert specs[0]["source"] == "off_topic"
assert "Stand" in specs[0]["sketch"]
def test_collect_gap_fill_specs_off_topic_first_step_uses_safe_neighbors():
brief = build_semantic_brief("Mawashi Geri")
steps = [
{"exercise_id": 1, "title": "Yoko Geri", "roadmap_major_step_index": 0},
{"exercise_id": 2, "title": "Mawashi", "roadmap_major_step_index": 1},
]
specs = collect_gap_fill_specs(
steps=steps,
unfilled_gaps=[],
off_topic_steps=[
{
"step_index": 0,
"roadmap_major_step_index": 0,
"title": "Yoko Geri",
}
],
llm_specs=[],
brief=brief,
goal_query="Mawashi Geri",
)
assert len(specs) == 1
assert "Mawashi" in specs[0]["sketch"]
assert "vorherigem Schritt" in specs[0]["sketch"]

View File

@ -68,6 +68,19 @@ def test_collect_rematch_slot_indices_from_optimization_hints():
assert indices == {0} assert indices == {0}
def test_collect_rematch_slot_indices_from_roadmap_unfilled():
specs = _stage_specs()
indices, reasons = collect_rematch_slot_indices(
stripped_off_topic=[],
off_topic_steps=[],
optimization_hints=[],
stage_specs=specs,
roadmap_unfilled=[(1, specs[1])],
)
assert indices == {1}
assert "Roadmap-Stufe" in reasons[1]
def test_rematch_roadmap_slots_replaces_only_target_slot(): def test_rematch_roadmap_slots_replaces_only_target_slot():
specs = _stage_specs() specs = _stage_specs()
ctx = ProgressionRoadmapContext( ctx = ProgressionRoadmapContext(

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.225" APP_VERSION = "0.8.226"
BUILD_DATE = "2026-06-07" BUILD_DATE = "2026-05-22"
DB_SCHEMA_VERSION = "20260607090" DB_SCHEMA_VERSION = "20260607090"
MODULE_VERSIONS = { MODULE_VERSIONS = {
@ -38,7 +38,7 @@ MODULE_VERSIONS = {
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0", "methods": "0.1.0",
"exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume "exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume
"planning_exercise_suggest": "0.23.0", # planning_intent_context, finalize stage_specs, Prompt 089 "planning_exercise_suggest": "0.23.1", # Phase B: Rematch-Schleife mit optimization_hints + roadmap_unfilled
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint "training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
"training_programs": "0.1.0", "training_programs": "0.1.0",
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung "planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
@ -53,6 +53,14 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.226",
"date": "2026-05-22",
"changes": [
"Progressionsgraph Phase B: Rematch-Schleife (max_rematch_rounds) mit optimization_hints und roadmap_unfilled.",
"Fix: Graph-Bewertung/Match 500 bei off-topic am Rand-Slot (collect_gap_fill_specs IndexError).",
],
},
{ {
"version": "0.8.225", "version": "0.8.225",
"date": "2026-06-07", "date": "2026-06-07",