From df93da9a03167097f6e87af4bc4fc29749ef4c90 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Jun 2026 21:20:47 +0200 Subject: [PATCH] Enhance Gap Fill and Rematch Logic in Progression Path - 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. --- backend/planning_exercise_path_ai_fill.py | 27 +++- backend/planning_exercise_path_builder.py | 149 +++++++++++++----- backend/planning_path_rematch.py | 15 +- .../test_planning_exercise_path_ai_fill.py | 52 ++++++ backend/tests/test_planning_path_rematch.py | 13 ++ backend/version.py | 14 +- 6 files changed, 217 insertions(+), 53 deletions(-) diff --git a/backend/planning_exercise_path_ai_fill.py b/backend/planning_exercise_path_ai_fill.py index e798161..81373bf 100644 --- a/backend/planning_exercise_path_ai_fill.py +++ b/backend/planning_exercise_path_ai_fill.py @@ -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( *, steps: Sequence[Mapping[str, Any]], @@ -364,8 +376,10 @@ def collect_gap_fill_specs( int(gap["from_exercise_id"]), int(gap["to_exercise_id"]), ) - if idx is None: + if idx is None or idx + 1 >= len(steps): continue + step_a = steps[idx] + step_b = steps[idx + 1] phase = gap.get("expected_phase") or "vertiefung" add( { @@ -377,12 +391,12 @@ def collect_gap_fill_specs( "sketch": _default_sketch( goal_query=goal_query, brief=brief, - step_a=steps[idx], - step_b=steps[idx + 1], + step_a=step_a, + step_b=step_b, phase=str(phase), 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) if idx < 0 or idx >= len(steps): continue + step_a, step_b = _step_neighbors_at_index(steps, idx) phase = ot.get("expected_phase") or "vertiefung" insert_after = max(idx - 1, -1) add( @@ -426,8 +441,8 @@ def collect_gap_fill_specs( "sketch": _default_sketch( goal_query=goal_query, brief=brief, - step_a=steps[idx - 1], - step_b=steps[idx + 1], + step_a=step_a, + step_b=step_b, phase=str(phase), rationale=f"Ersetzt themenfremden Schritt „{ot.get('title')}“.", ), diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 4f2c912..58c08fe 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -108,6 +108,7 @@ class ProgressionPathSuggestRequest(BaseModel): include_llm_intent: bool = True include_path_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_path_reorder: bool = True include_ai_gap_fill: bool = True @@ -438,7 +439,17 @@ def _load_supplemental_exercise_rows( vis_params: Sequence[Any], ) -> List[Dict[str, Any]]: """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: return [] if progression_graph_id and int(progression_graph_id) > 0: @@ -1222,7 +1233,19 @@ def _normalize_roadmap_steps_coverage( 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, *, tenant: TenantContext, @@ -1237,6 +1260,7 @@ def _maybe_rematch_roadmap_after_strip( stripped_off_topic: List[Dict[str, Any]], off_topic_before_strip: List[Dict[str, Any]], roadmap_unfilled: List[Tuple[int, StageSpecArtifact]], + gaps: List[Dict[str, Any]], ) -> Tuple[ List[Dict[str, Any]], List[Dict[str, Any]], @@ -1245,54 +1269,92 @@ def _maybe_rematch_roadmap_after_strip( int, List[Tuple[int, StageSpecArtifact]], ]: + """Phase A/B: Rematch-Schleife aus Strip, unfilled Slots und optimization_hints.""" 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 + max_rounds = int(body.max_rematch_rounds or 0) + 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( - 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 + current_stripped = list(stripped_off_topic or []) + use_initial_off_topic = not current_stripped + off_topic_steps: List[Dict[str, Any]] = [] - 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) + for round_idx in range(max_rounds): + mini_qa = run_multistage_path_qa( + off_topic_steps=off_topic_steps if round_idx > 0 else [], + stripped_off_topic=current_stripped if round_idx == 0 else [], + gaps=gaps if round_idx == 0 else [], + llm_qa=None, + llm_applied=False, + roadmap_unfilled=roadmap_unfilled, + ) + optimization_hints = list(mini_qa.get("optimization_hints") or []) + + slot_indices, rematch_reasons = collect_rematch_slot_indices( + stripped_off_topic=current_stripped if round_idx == 0 else [], + off_topic_steps=off_topic_before_strip if round_idx == 0 and use_initial_off_topic else [], + optimization_hints=optimization_hints, + stage_specs=roadmap_ctx.stage_specs, + roadmap_unfilled=roadmap_unfilled, + ) + if not slot_indices: + break + + steps, round_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 + 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 ( steps, rematch_log, - stripped_off_topic, + current_stripped, off_topic_steps, rematch_rounds, roadmap_unfilled, @@ -2000,7 +2062,7 @@ def suggest_progression_path( rematch_off_topic, rematch_rounds, roadmap_unfilled, - ) = _maybe_rematch_roadmap_after_strip( + ) = _run_roadmap_rematch_loop( cur, tenant=tenant, body=body, @@ -2014,6 +2076,7 @@ def suggest_progression_path( stripped_off_topic=stripped_off_topic, off_topic_before_strip=off_topic_before_strip, roadmap_unfilled=roadmap_unfilled, + gaps=gaps, ) if rematch_off_topic: off_topic_steps = rematch_off_topic diff --git a/backend/planning_path_rematch.py b/backend/planning_path_rematch.py index d86f5b9..4bc19dd 100644 --- a/backend/planning_path_rematch.py +++ b/backend/planning_path_rematch.py @@ -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 @@ -14,6 +14,7 @@ def collect_rematch_slot_indices( 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) @@ -64,6 +65,18 @@ def collect_rematch_slot_indices( 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 diff --git a/backend/tests/test_planning_exercise_path_ai_fill.py b/backend/tests/test_planning_exercise_path_ai_fill.py index 8748b04..f39b3bf 100644 --- a/backend/tests/test_planning_exercise_path_ai_fill.py +++ b/backend/tests/test_planning_exercise_path_ai_fill.py @@ -214,3 +214,55 @@ def test_build_gap_fill_offer_exposes_context_preview(): ) assert offer["context_preview"]["start_situation"] == "Steppbewegung" 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"] diff --git a/backend/tests/test_planning_path_rematch.py b/backend/tests/test_planning_path_rematch.py index 46e4b02..78cc99f 100644 --- a/backend/tests/test_planning_path_rematch.py +++ b/backend/tests/test_planning_path_rematch.py @@ -68,6 +68,19 @@ def test_collect_rematch_slot_indices_from_optimization_hints(): 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(): specs = _stage_specs() ctx = ProgressionRoadmapContext( diff --git a/backend/version.py b/backend/version.py index 8ec0719..b09870e 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,7 +1,7 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.225" -BUILD_DATE = "2026-06-07" +APP_VERSION = "0.8.226" +BUILD_DATE = "2026-05-22" DB_SCHEMA_VERSION = "20260607090" MODULE_VERSIONS = { @@ -38,7 +38,7 @@ MODULE_VERSIONS = { "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "methods": "0.1.0", "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_programs": "0.1.0", "planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung @@ -53,6 +53,14 @@ MODULE_VERSIONS = { } 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", "date": "2026-06-07",