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
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

View File

@ -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

View File

@ -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,
})

View File

@ -222,10 +222,50 @@ export default function ProgressionOptimizeCompareModal({
) : null}
{dialogDiffs.length === 0 ? (
<p style={{ fontSize: '12px', color: 'var(--text2)' }}>
Keine Verbesserung gefunden dein Pfad ist für alle Slots bereits optimal bewertet
oder es fehlen passende Bibliotheks-Treffer (KI-Angebote im Bewertungs-Panel).
</p>
<>
<p style={{ fontSize: '12px', color: 'var(--text2)' }}>
Keine Bibliotheks-Verbesserung mit messbarem Gewinn Schachstellen siehe unten.
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' }}>