Enhance Path Evaluation and Slot Management Features
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m13s

- 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.
This commit is contained in:
Lars 2026-06-13 12:17:58 +02:00
parent 3468b2066e
commit e9bf5bd1a5
4 changed files with 207 additions and 39 deletions

View File

@ -6,6 +6,7 @@ planning_progression_roadmap.py und PLANNING_PROGRESSION_ROADMAP_SPEC.md.
""" """
from __future__ import annotations from __future__ import annotations
import re
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple
from fastapi import HTTPException 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) baseline_quality_score: Optional[float] = Field(default=None, ge=0.0, le=1.0)
include_incremental_diff_scoring: bool = False include_incremental_diff_scoring: bool = False
unified_slot_review: bool = False unified_slot_review: bool = False
baseline_path_qa_snapshot: Optional[Dict[str, Any]] = None
def _resolve_planning_catalog_context( def _resolve_planning_catalog_context(
@ -1165,6 +1167,7 @@ def _match_roadmap_slot(
anchor_variant_id: Optional[int], anchor_variant_id: Optional[int],
used: Set[int], used: Set[int],
slot_priority_exercise_id: Optional[int] = None, slot_priority_exercise_id: Optional[int] = None,
skip_post_match_gate: bool = False,
) -> Tuple[Optional[Dict[str, Any]], Optional[StageSpecArtifact]]: ) -> Tuple[Optional[Dict[str, Any]], Optional[StageSpecArtifact]]:
"""Einzelnen Roadmap-Slot matchen (Initial-Build und Auto-Rematch).""" """Einzelnen Roadmap-Slot matchen (Initial-Build und Auto-Rematch)."""
major_by_index: Dict[int, MajorStep] = {} major_by_index: Dict[int, MajorStep] = {}
@ -1331,11 +1334,15 @@ def _match_roadmap_slot(
else: else:
step["slot_status"] = "matched" step["slot_status"] = "matched"
step["roadmap_match_source"] = "stage_spec" step["roadmap_match_source"] = "stage_spec"
if step.get("roadmap_match_source") != "slot_best_match" and not _roadmap_step_passes_post_match_gate( if (
cur, not skip_post_match_gate
step, and step.get("roadmap_match_source") != "slot_best_match"
goal_query=goal_query, and not _roadmap_step_passes_post_match_gate(
semantic_brief=semantic_brief, cur,
step,
goal_query=goal_query,
semantic_brief=semantic_brief,
)
): ):
return None, stage_spec return None, stage_spec
return step, None return step, None
@ -2478,6 +2485,21 @@ def _resolve_hint_major_index(
return pos if pos >= 0 else None 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( def _problematic_slots_from_path_qa(
baseline_qa: Optional[Mapping[str, Any]], baseline_qa: Optional[Mapping[str, Any]],
baseline_steps: Sequence[Mapping[str, Any]], baseline_steps: Sequence[Mapping[str, Any]],
@ -2504,13 +2526,32 @@ def _problematic_slots_from_path_qa(
if not isinstance(hint, dict): if not isinstance(hint, dict):
continue continue
action = str(hint.get("action") or "").strip().lower() action = str(hint.get("action") or "").strip().lower()
if action in ("review_roadmap", "refine_stage_spec"): if action == "review_roadmap":
continue continue
midx = _resolve_hint_major_index(hint, stage_specs) 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: if midx is None:
continue continue
_add( _add(
midx, int(midx),
str( str(
hint.get("reason") hint.get("reason")
or hint.get("issue") 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 []: for raw in (baseline_qa or {}).get("issues") or []:
text = str(raw or "").strip() text = str(raw or "").strip()
if not text: if not text:
@ -2535,7 +2588,8 @@ def _problematic_slots_from_path_qa(
continue continue
title = str(step.get("title") or "").strip() title = str(step.get("title") or "").strip()
if ( 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 f"stufe {slot_no}" in text.lower()
or (title and title.lower() in text.lower()) or (title and title.lower() in text.lower())
): ):
@ -2896,6 +2950,7 @@ def _roadmap_slot_library_candidates(
used: Set[int], used: Set[int],
exclude_exercise_id: Optional[int] = None, exclude_exercise_id: Optional[int] = None,
max_candidates: int = 5, max_candidates: int = 5,
skip_post_match_gate: bool = False,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Mehrere Bibliotheks-Kandidaten je Slot (beste zuerst, aktuelle optional ausgeschlossen).""" """Mehrere Bibliotheks-Kandidaten je Slot (beste zuerst, aktuelle optional ausgeschlossen)."""
pick_used = set(used) pick_used = set(used)
@ -2925,6 +2980,7 @@ def _roadmap_slot_library_candidates(
anchor_variant_id=anchor_variant_id, anchor_variant_id=anchor_variant_id,
used=pick_used, used=pick_used,
slot_priority_exercise_id=None, slot_priority_exercise_id=None,
skip_post_match_gate=skip_post_match_gate,
) )
if not step or step.get("exercise_id") is None: if not step or step.get("exercise_id") is None:
break break
@ -2993,34 +3049,55 @@ def _run_unified_slot_improvement_review(
detail="unified_slot_review erfordert Roadmap (roadmap_override / roadmap_first)", 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) baseline_steps = _evaluate_steps_from_payload(cur, body.baseline_evaluate_steps)
qa_pack = _run_evaluate_only_path_qa( snapshot = (
cur, dict(body.baseline_path_qa_snapshot)
body=eval_body, if isinstance(body.baseline_path_qa_snapshot, dict)
goal_query=goal_query, else None
semantic_brief=semantic_brief,
steps=list(baseline_steps),
roadmap_ctx=roadmap_ctx,
) )
baseline_steps = list(qa_pack.get("steps") or baseline_steps) if snapshot:
baseline_qa = qa_pack.get("path_qa") if isinstance(qa_pack.get("path_qa"), dict) else {} baseline_qa = snapshot
if baseline_qa.get("quality_score") is None: if baseline_qa.get("quality_score") is None:
baseline_qa = dict(baseline_qa) baseline_qa["quality_score"] = compute_deterministic_path_quality_score(
baseline_qa["quality_score"] = compute_deterministic_path_quality_score( gaps=baseline_qa.get("large_gaps") or [],
gaps=baseline_qa.get("large_gaps") or [], off_topic_steps=baseline_qa.get("off_topic_steps") or [],
off_topic_steps=baseline_qa.get("off_topic_steps") or [], steps=baseline_steps,
steps=baseline_steps, multistage_qa=baseline_qa,
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[Dict[str, Any]] = []
gap_fill_offers = list(qa_pack.get("gap_fill_offers") or []) 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 []) off_topic_map = _off_topic_reasons_by_slot(baseline_qa.get("off_topic_steps") or [])
problem_slots = _problematic_slots_from_path_qa( problem_slots = _problematic_slots_from_path_qa(
baseline_qa, baseline_qa,
@ -3076,6 +3153,7 @@ def _run_unified_slot_improvement_review(
except (TypeError, ValueError): except (TypeError, ValueError):
exclude_id = None exclude_id = None
relax_match_gate = bool(off_topic or slot_problem)
candidates = _roadmap_slot_library_candidates( candidates = _roadmap_slot_library_candidates(
cur, cur,
tenant=tenant, tenant=tenant,
@ -3093,8 +3171,9 @@ def _run_unified_slot_improvement_review(
anchor_id=anchor_id, anchor_id=anchor_id,
anchor_variant_id=anchor_variant_id, anchor_variant_id=anchor_variant_id,
used=used_other, used=used_other,
exclude_exercise_id=exclude_id if not off_topic else int(current_id) if current_id else None, exclude_exercise_id=int(current_id) if current_id is not None else exclude_id,
max_candidates=3, max_candidates=5 if relax_match_gate else 3,
skip_post_match_gate=relax_match_gate,
) )
accepted_for_slot = False accepted_for_slot = False

View File

@ -1,5 +1,6 @@
"""Schachstellen-Erkennung für unified Slot-Review.""" """Schachstellen-Erkennung für unified Slot-Review."""
from planning_exercise_path_builder import ( from planning_exercise_path_builder import (
_parse_slot_refs_from_text,
_problematic_slots_from_path_qa, _problematic_slots_from_path_qa,
_slot_suggestion_accepted, _slot_suggestion_accepted,
) )
@ -51,3 +52,46 @@ def test_slot_suggestion_accepted_for_problem_slot():
major_idx=1, major_idx=1,
slot_problem=True, 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

View File

@ -507,6 +507,11 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
evaluate_only: false, evaluate_only: false,
unified_slot_review: true, unified_slot_review: true,
baseline_evaluate_steps: slotsToEvaluateSteps(synced), 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, include_llm_intent: false,
auto_rematch_after_qa: false, auto_rematch_after_qa: false,
}) })

View File

@ -222,10 +222,50 @@ export default function ProgressionOptimizeCompareModal({
) : null} ) : null}
{dialogDiffs.length === 0 ? ( {dialogDiffs.length === 0 ? (
<p style={{ fontSize: '12px', color: 'var(--text2)' }}> <>
Keine Verbesserung gefunden dein Pfad ist für alle Slots bereits optimal bewertet <p style={{ fontSize: '12px', color: 'var(--text2)' }}>
oder es fehlen passende Bibliotheks-Treffer (KI-Angebote im Bewertungs-Panel). Keine Bibliotheks-Verbesserung mit messbarem Gewinn Schachstellen siehe unten.
</p> KI-Angebote im Bewertungs-Panel oder Brücke / KI-Angebot nutzen.
</p>
{comparison?.problem_slots && Object.keys(comparison.problem_slots).length > 0 ? (
<ul
style={{
listStyle: 'none',
padding: 0,
margin: '12px 0 0',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
{Object.entries(comparison.problem_slots).map(([midxRaw, reasons]) => {
const midx = Number(midxRaw)
const reasonList = Array.isArray(reasons) ? reasons : [reasons]
return (
<li
key={`problem-${midxRaw}`}
style={{
padding: '10px 12px',
borderRadius: '8px',
border: '1px solid var(--danger)',
background: 'var(--surface2)',
fontSize: '12px',
}}
>
<strong style={{ color: 'var(--danger)' }}>
Schachstelle Slot {midx + 1}
</strong>
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
{reasonList.filter(Boolean).map((text, i) => (
<li key={`${midxRaw}-r-${i}`}>{text}</li>
))}
</ul>
</li>
)
})}
</ul>
) : null}
</>
) : ( ) : (
<> <>
<div style={{ display: 'flex', gap: '8px', marginBottom: '8px', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '8px', marginBottom: '8px', flexWrap: 'wrap' }}>