From e9bf5bd1a5987629aa7d857224d94d3c1905c074 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 13 Jun 2026 12:17:58 +0200 Subject: [PATCH] Enhance Path Evaluation and Slot Management Features - Introduced `_parse_slot_refs_from_text` to extract and convert slot references from text, improving the handling of user input in path evaluations. - Updated `_problematic_slots_from_path_qa` to utilize the new parsing function, enhancing the identification of problematic slots based on various hints and issues. - Enhanced `ProgressionGraphEditor` and `ProgressionOptimizeCompareModal` to better display identified problem slots and their associated reasons, improving user feedback during evaluations. - Added tests for new parsing functionality and its integration with existing slot management processes, ensuring robustness in slot reference handling. --- backend/planning_exercise_path_builder.py | 149 ++++++++++++++---- .../tests/test_planning_problematic_slots.py | 44 ++++++ .../src/components/ProgressionGraphEditor.jsx | 5 + .../ProgressionOptimizeCompareModal.jsx | 48 +++++- 4 files changed, 207 insertions(+), 39 deletions(-) diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 649848b..87db549 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -6,6 +6,7 @@ planning_progression_roadmap.py und PLANNING_PROGRESSION_ROADMAP_SPEC.md. """ from __future__ import annotations +import re from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple from fastapi import HTTPException @@ -149,6 +150,7 @@ class ProgressionPathSuggestRequest(BaseModel): baseline_quality_score: Optional[float] = Field(default=None, ge=0.0, le=1.0) include_incremental_diff_scoring: bool = False unified_slot_review: bool = False + baseline_path_qa_snapshot: Optional[Dict[str, Any]] = None def _resolve_planning_catalog_context( @@ -1165,6 +1167,7 @@ def _match_roadmap_slot( anchor_variant_id: Optional[int], used: Set[int], slot_priority_exercise_id: Optional[int] = None, + skip_post_match_gate: bool = False, ) -> Tuple[Optional[Dict[str, Any]], Optional[StageSpecArtifact]]: """Einzelnen Roadmap-Slot matchen (Initial-Build und Auto-Rematch).""" major_by_index: Dict[int, MajorStep] = {} @@ -1331,11 +1334,15 @@ def _match_roadmap_slot( else: step["slot_status"] = "matched" step["roadmap_match_source"] = "stage_spec" - if step.get("roadmap_match_source") != "slot_best_match" and not _roadmap_step_passes_post_match_gate( - cur, - step, - goal_query=goal_query, - semantic_brief=semantic_brief, + if ( + not skip_post_match_gate + and step.get("roadmap_match_source") != "slot_best_match" + and not _roadmap_step_passes_post_match_gate( + cur, + step, + goal_query=goal_query, + semantic_brief=semantic_brief, + ) ): return None, stage_spec return step, None @@ -2478,6 +2485,21 @@ def _resolve_hint_major_index( return pos if pos >= 0 else None +def _parse_slot_refs_from_text(text: str) -> Set[int]: + """„Schritt 8“ / „Slot 8“ / „Stufe 8“ → 0-basierter major_step_index (7).""" + found: Set[int] = set() + if not text: + return found + for match in re.finditer(r"(?:schritt|slot|stufe)\s*(\d+)", text.lower()): + try: + n = int(match.group(1)) + except (TypeError, ValueError): + continue + if n >= 1: + found.add(n - 1) + return found + + def _problematic_slots_from_path_qa( baseline_qa: Optional[Mapping[str, Any]], baseline_steps: Sequence[Mapping[str, Any]], @@ -2504,13 +2526,32 @@ def _problematic_slots_from_path_qa( if not isinstance(hint, dict): continue action = str(hint.get("action") or "").strip().lower() - if action in ("review_roadmap", "refine_stage_spec"): + if action == "review_roadmap": continue midx = _resolve_hint_major_index(hint, stage_specs) + if midx is None: + title = str(hint.get("title") or "") + for ref in _parse_slot_refs_from_text( + " ".join( + str(hint.get(k) or "") + for k in ("reason", "issue", "title", "roadmap_learning_goal") + ) + ): + midx = ref + break + if title: + for step in baseline_steps or []: + if not isinstance(step, dict): + continue + st = str(step.get("title") or "").strip() + smidx = step.get("roadmap_major_step_index") + if st and title.lower() in st.lower() and smidx is not None: + midx = int(smidx) + break if midx is None: continue _add( - midx, + int(midx), str( hint.get("reason") or hint.get("issue") @@ -2519,6 +2560,18 @@ def _problematic_slots_from_path_qa( ), ) + llm_text_parts: List[str] = [] + for key in ("topic_coverage",): + raw = (baseline_qa or {}).get(key) + if raw: + llm_text_parts.append(str(raw)) + for key in ("issues", "recommendations", "sequence_notes"): + for raw in (baseline_qa or {}).get(key) or []: + llm_text_parts.append(str(raw or "")) + combined = "\n".join(llm_text_parts) + for midx in _parse_slot_refs_from_text(combined): + _add(midx, "In Pfad-Bewertung als Schachstelle genannt") + for raw in (baseline_qa or {}).get("issues") or []: text = str(raw or "").strip() if not text: @@ -2535,7 +2588,8 @@ def _problematic_slots_from_path_qa( continue title = str(step.get("title") or "").strip() if ( - f"slot {slot_no}" in text.lower() + f"schritt {slot_no}" in text.lower() + or f"slot {slot_no}" in text.lower() or f"stufe {slot_no}" in text.lower() or (title and title.lower() in text.lower()) ): @@ -2896,6 +2950,7 @@ def _roadmap_slot_library_candidates( used: Set[int], exclude_exercise_id: Optional[int] = None, max_candidates: int = 5, + skip_post_match_gate: bool = False, ) -> List[Dict[str, Any]]: """Mehrere Bibliotheks-Kandidaten je Slot (beste zuerst, aktuelle optional ausgeschlossen).""" pick_used = set(used) @@ -2925,6 +2980,7 @@ def _roadmap_slot_library_candidates( anchor_variant_id=anchor_variant_id, used=pick_used, slot_priority_exercise_id=None, + skip_post_match_gate=skip_post_match_gate, ) if not step or step.get("exercise_id") is None: break @@ -2993,34 +3049,55 @@ def _run_unified_slot_improvement_review( detail="unified_slot_review erfordert Roadmap (roadmap_override / roadmap_first)", ) - eval_body = body.model_copy( - update={ - "include_llm_path_qa": body.include_llm_path_qa, - "include_ai_gap_fill": body.include_ai_gap_fill, - "auto_rematch_after_qa": False, - } - ) baseline_steps = _evaluate_steps_from_payload(cur, body.baseline_evaluate_steps) - qa_pack = _run_evaluate_only_path_qa( - cur, - body=eval_body, - goal_query=goal_query, - semantic_brief=semantic_brief, - steps=list(baseline_steps), - roadmap_ctx=roadmap_ctx, + snapshot = ( + dict(body.baseline_path_qa_snapshot) + if isinstance(body.baseline_path_qa_snapshot, dict) + else None ) - baseline_steps = list(qa_pack.get("steps") or baseline_steps) - baseline_qa = qa_pack.get("path_qa") if isinstance(qa_pack.get("path_qa"), dict) else {} - if baseline_qa.get("quality_score") is None: - baseline_qa = dict(baseline_qa) - baseline_qa["quality_score"] = compute_deterministic_path_quality_score( - gaps=baseline_qa.get("large_gaps") or [], - off_topic_steps=baseline_qa.get("off_topic_steps") or [], - steps=baseline_steps, - multistage_qa=baseline_qa, + if snapshot: + baseline_qa = snapshot + if baseline_qa.get("quality_score") is None: + baseline_qa["quality_score"] = compute_deterministic_path_quality_score( + gaps=baseline_qa.get("large_gaps") or [], + off_topic_steps=baseline_qa.get("off_topic_steps") or [], + steps=baseline_steps, + multistage_qa=baseline_qa, + ) + baseline_score = ( + float(body.baseline_quality_score) + if body.baseline_quality_score is not None + else _path_qa_quality_score(baseline_qa) ) - baseline_score = _path_qa_quality_score(baseline_qa) - gap_fill_offers = list(qa_pack.get("gap_fill_offers") or []) + gap_fill_offers: List[Dict[str, Any]] = [] + else: + eval_body = body.model_copy( + update={ + "include_llm_path_qa": body.include_llm_path_qa, + "include_ai_gap_fill": body.include_ai_gap_fill, + "auto_rematch_after_qa": False, + } + ) + qa_pack = _run_evaluate_only_path_qa( + cur, + body=eval_body, + goal_query=goal_query, + semantic_brief=semantic_brief, + steps=list(baseline_steps), + roadmap_ctx=roadmap_ctx, + ) + baseline_steps = list(qa_pack.get("steps") or baseline_steps) + baseline_qa = qa_pack.get("path_qa") if isinstance(qa_pack.get("path_qa"), dict) else {} + if baseline_qa.get("quality_score") is None: + baseline_qa = dict(baseline_qa) + baseline_qa["quality_score"] = compute_deterministic_path_quality_score( + gaps=baseline_qa.get("large_gaps") or [], + off_topic_steps=baseline_qa.get("off_topic_steps") or [], + steps=baseline_steps, + multistage_qa=baseline_qa, + ) + baseline_score = _path_qa_quality_score(baseline_qa) + gap_fill_offers = list(qa_pack.get("gap_fill_offers") or []) off_topic_map = _off_topic_reasons_by_slot(baseline_qa.get("off_topic_steps") or []) problem_slots = _problematic_slots_from_path_qa( baseline_qa, @@ -3076,6 +3153,7 @@ def _run_unified_slot_improvement_review( except (TypeError, ValueError): exclude_id = None + relax_match_gate = bool(off_topic or slot_problem) candidates = _roadmap_slot_library_candidates( cur, tenant=tenant, @@ -3093,8 +3171,9 @@ def _run_unified_slot_improvement_review( anchor_id=anchor_id, anchor_variant_id=anchor_variant_id, used=used_other, - exclude_exercise_id=exclude_id if not off_topic else int(current_id) if current_id else None, - max_candidates=3, + exclude_exercise_id=int(current_id) if current_id is not None else exclude_id, + max_candidates=5 if relax_match_gate else 3, + skip_post_match_gate=relax_match_gate, ) accepted_for_slot = False diff --git a/backend/tests/test_planning_problematic_slots.py b/backend/tests/test_planning_problematic_slots.py index 00039f7..4052139 100644 --- a/backend/tests/test_planning_problematic_slots.py +++ b/backend/tests/test_planning_problematic_slots.py @@ -1,5 +1,6 @@ """Schachstellen-Erkennung für unified Slot-Review.""" from planning_exercise_path_builder import ( + _parse_slot_refs_from_text, _problematic_slots_from_path_qa, _slot_suggestion_accepted, ) @@ -51,3 +52,46 @@ def test_slot_suggestion_accepted_for_problem_slot(): major_idx=1, slot_problem=True, ) + + +def test_parse_slot_refs_schritt_is_one_based(): + assert _parse_slot_refs_from_text("Schritt 8 (Ukemi Vorwärts) entfernen") == {7} + assert _parse_slot_refs_from_text("slot 3 und Stufe 5") == {2, 4} + + +def test_problematic_slots_from_refine_stage_spec_hint(): + qa = { + "optimization_hints": [ + { + "action": "refine_stage_spec", + "step_index": 7, + "issue": "stage_mismatch", + "reason": "Stufen-Fit zu schwach (0.00) für „Integration von Täuschung“", + } + ], + "off_topic_steps": [], + } + steps = [ + {"roadmap_major_step_index": i, "exercise_id": i + 1, "title": f"Übung {i + 1}"} + for i in range(8) + ] + steps[7]["title"] = "Ukemi Vorwärts" + specs = [_spec(i) for i in range(8)] + problems = _problematic_slots_from_path_qa(qa, steps, specs) + assert 7 in problems + + +def test_problematic_slots_from_llm_schritt_text(): + qa = { + "optimization_hints": [], + "off_topic_steps": [], + "issues": [ + "Schritt 8 (Ukemi Vorwärts) hat keinen Bezug zur Kumite-Beinarbeit", + ], + } + steps = [ + {"roadmap_major_step_index": 7, "exercise_id": 99, "title": "Ukemi Vorwärts"}, + ] + specs = [_spec(7)] + problems = _problematic_slots_from_path_qa(qa, steps, specs) + assert 7 in problems diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index a60b80a..80a4187 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -507,6 +507,11 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa evaluate_only: false, unified_slot_review: true, baseline_evaluate_steps: slotsToEvaluateSteps(synced), + baseline_path_qa_snapshot: baselineRes?.path_qa || null, + baseline_quality_score: + baselineRes?.path_qa?.quality_score != null + ? Number(baselineRes.path_qa.quality_score) + : null, include_llm_intent: false, auto_rematch_after_qa: false, }) diff --git a/frontend/src/components/ProgressionOptimizeCompareModal.jsx b/frontend/src/components/ProgressionOptimizeCompareModal.jsx index 2e28f81..8e4147d 100644 --- a/frontend/src/components/ProgressionOptimizeCompareModal.jsx +++ b/frontend/src/components/ProgressionOptimizeCompareModal.jsx @@ -222,10 +222,50 @@ export default function ProgressionOptimizeCompareModal({ ) : null} {dialogDiffs.length === 0 ? ( -

- Keine Verbesserung gefunden — dein Pfad ist für alle Slots bereits optimal bewertet - oder es fehlen passende Bibliotheks-Treffer (KI-Angebote im Bewertungs-Panel). -

+ <> +

+ Keine Bibliotheks-Verbesserung mit messbarem Gewinn — Schachstellen siehe unten. + KI-Angebote im Bewertungs-Panel oder „Brücke / KI-Angebot“ nutzen. +

+ {comparison?.problem_slots && Object.keys(comparison.problem_slots).length > 0 ? ( + + ) : null} + ) : ( <>