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

- Added new functions for computing assignment quality scores and counting step assignment statistics, improving the evaluation of steps in path quality assessments.
- Updated existing methods to incorporate the new scoring logic, enhancing the robustness of path evaluations.
- Introduced UI components in the frontend to display detailed quality assessment results, including handling of split dimensions in path evaluations.
- Enhanced tests to cover new functionalities and ensure accuracy in quality scoring and slot management processes.
This commit is contained in:
Lars 2026-06-13 17:13:35 +02:00
parent 7265cd5a01
commit 313d613b7c
8 changed files with 427 additions and 55 deletions

View File

@ -2208,6 +2208,7 @@ def _run_evaluate_only_path_qa(
reorder_notes=[],
roadmap_qa_mode=roadmap_qa_mode,
multistage_qa=multistage_qa,
steps=steps,
)
return {
"path_qa": path_qa,
@ -2500,6 +2501,7 @@ def _quick_evaluate_steps_qa(
llm_applied=False,
roadmap_qa_mode="roadmap_first_lite" if roadmap_first else None,
multistage_qa=multistage_qa,
steps=steps_list,
)
if path_qa.get("quality_score") is None:
path_qa["quality_score"] = compute_deterministic_path_quality_score(
@ -3072,6 +3074,7 @@ def _suggestion_as_slot_diff(entry: Mapping[str, Any]) -> Dict[str, Any]:
_SLOT_FIT_POOR_THRESHOLD = 0.30
_SLOT_FIT_GOOD_THRESHOLD = 0.50
def _off_topic_semantic_scores_by_slot(
@ -3152,9 +3155,18 @@ def _slot_auto_select_library(
return False
if proposed_slot_score is None:
return False
if baseline_slot_score is None:
effective_baseline = float(baseline_slot_score) if baseline_slot_score is not None else 0.0
if float(proposed_slot_score) <= effective_baseline + 0.001:
return False
# Leerer Slot: Bibliothek nur vorauswählen, wenn Stufen-Fit klar ausreicht.
if baseline_exercise_id is None:
return float(proposed_slot_score) >= _SLOT_FIT_GOOD_THRESHOLD
return True
return float(proposed_slot_score) > float(baseline_slot_score) + 0.001
def _slot_auto_select_ai(*, library_auto_select: bool, has_ai: bool) -> bool:
"""KI-Vorschlag vorauswählen, wenn angeboten und Bibliothek nicht klar besser."""
return bool(has_ai and not library_auto_select)
def _build_unified_slot_review_entry(
@ -3391,10 +3403,14 @@ def _build_unified_slot_review_entry(
)
gap_fill_offers.append(slot_offer)
if slot_offer:
ai_auto = _slot_auto_select_ai(
library_auto_select=bool(library_alt and library_alt.get("auto_select")),
has_ai=True,
)
ai_alt = {
"title_hint": slot_offer.get("title_hint") or f"Slot {major_idx + 1}",
"gap_offer": slot_offer,
"auto_select": False,
"auto_select": ai_auto,
}
return {
@ -4312,6 +4328,7 @@ def suggest_progression_path(
reorder_notes=reorder_notes,
roadmap_qa_mode=roadmap_qa_mode,
multistage_qa=multistage_qa,
steps=steps,
)
if rematch_log:
path_qa["rematch_applied"] = True

View File

@ -688,6 +688,160 @@ def find_step_pair_index(
return None
def count_step_assignment_stats(steps: Optional[Sequence[Mapping[str, Any]]]) -> Dict[str, int]:
stats = {"total": 0, "empty": 0, "library_filled": 0, "ai_proposal": 0}
for raw in steps or []:
if not isinstance(raw, dict):
continue
stats["total"] += 1
if raw.get("exercise_id") is not None and not raw.get("is_ai_proposal"):
stats["library_filled"] += 1
elif raw.get("is_ai_proposal"):
stats["ai_proposal"] += 1
else:
stats["empty"] += 1
return stats
def compute_assignment_quality_score(
*,
steps: Optional[Sequence[Mapping[str, Any]]] = None,
off_topic_steps: Optional[Sequence[Mapping[str, Any]]] = None,
gaps: Optional[Sequence[Mapping[str, Any]]] = None,
) -> float:
"""QS der Übungsbesetzung — leere Slots stark abwerten."""
stats = count_step_assignment_stats(steps)
total = stats["total"]
if total <= 0:
return 0.45
empty = stats["empty"]
library = stats["library_filled"]
ai = stats["ai_proposal"]
fill_credit = (library + 0.55 * ai) / total
score = 0.1 + 0.84 * fill_credit
if empty > 0:
score -= 0.22 * (empty / total)
score -= 0.08 * len(off_topic_steps or [])
score -= 0.03 * len(gaps or [])
return max(0.08, min(0.98, round(score, 4)))
def compute_roadmap_quality_score(
*,
llm_qa: Optional[Mapping[str, Any]] = None,
llm_applied: bool = False,
gaps: Optional[Sequence[Mapping[str, Any]]] = None,
multistage_qa: Optional[Mapping[str, Any]] = None,
) -> float:
"""QS der Roadmap-/Stufenlogik — unabhängig von Slot-Befüllung."""
if llm_applied and llm_qa and llm_qa.get("quality_score") is not None:
try:
return max(0.08, min(0.98, round(float(llm_qa["quality_score"]), 4)))
except (TypeError, ValueError):
pass
score = 0.9
score -= 0.05 * len(gaps or [])
hint_count = int((multistage_qa or {}).get("optimization_hint_count") or 0)
score -= min(0.12, 0.015 * hint_count)
return max(0.35, min(0.98, round(score, 4)))
def build_assignment_qa_snapshot(
*,
steps: Optional[Sequence[Mapping[str, Any]]] = None,
off_topic_steps: Optional[Sequence[Mapping[str, Any]]] = None,
gaps: Optional[Sequence[Mapping[str, Any]]] = None,
) -> Dict[str, Any]:
off_topic = list(off_topic_steps or [])
stats = count_step_assignment_stats(steps)
score = compute_assignment_quality_score(
steps=steps,
off_topic_steps=off_topic,
gaps=gaps,
)
issues: List[str] = []
if stats["empty"] > 0:
issues.append(
f"{stats['empty']} von {stats['total']} Slot(s) ohne Übung — bitte Bibliothek oder KI-Vorschlag zuweisen",
)
if stats["ai_proposal"] > 0 and stats["library_filled"] == 0 and stats["empty"] > 0:
issues.append(
f"{stats['ai_proposal']} KI-Entwurf(e), aber noch {stats['empty']} leere Slot(s)",
)
for item in off_topic[:5]:
title = (item.get("title") or "Schritt").strip()
issues.append(f"{title}“ passt nicht zum Stufen-Ziel")
overall_ok = stats["empty"] == 0 and len(off_topic) == 0
return {
"overall_ok": overall_ok,
"quality_score": score,
"slot_count": stats["total"],
"empty_slot_count": stats["empty"],
"library_filled_count": stats["library_filled"],
"ai_proposal_count": stats["ai_proposal"],
"issues": issues,
}
def build_roadmap_qa_snapshot(
*,
llm_qa: Optional[Mapping[str, Any]] = None,
llm_applied: bool = False,
gaps: Optional[Sequence[Mapping[str, Any]]] = None,
multistage_qa: Optional[Mapping[str, Any]] = None,
roadmap_qa_mode: Optional[str] = None,
) -> Dict[str, Any]:
score = compute_roadmap_quality_score(
llm_qa=llm_qa,
llm_applied=llm_applied,
gaps=gaps,
multistage_qa=multistage_qa,
)
issues: List[str] = []
if not llm_applied:
for gap in gaps or []:
issues.append(
f"Übergang „{gap.get('from_title')}“ → „{gap.get('to_title')}“ schwach (Score {gap.get('gap_score')})",
)
if llm_applied and llm_qa:
issues.extend(str(x).strip() for x in (llm_qa.get("issues") or []) if str(x).strip())
overall_ok = bool(llm_qa.get("overall_ok", True)) if llm_applied and llm_qa else len(gaps or []) == 0
snapshot: Dict[str, Any] = {
"overall_ok": overall_ok,
"quality_score": score,
"issues": issues[:8],
"llm_applied": bool(llm_applied),
"roadmap_qa_mode": roadmap_qa_mode,
}
if llm_applied and llm_qa:
snapshot["topic_coverage"] = llm_qa.get("topic_coverage")
snapshot["recommendations"] = list(llm_qa.get("recommendations") or [])
snapshot["sequence_notes"] = list(llm_qa.get("sequence_notes") or [])
return snapshot
def merge_path_quality_scores(
roadmap_qa: Mapping[str, Any],
assignment_qa: Mapping[str, Any],
) -> float:
"""Gesamt-QS: schwächere Dimension begrenzt — leere Slots senken den Pfad deutlich."""
try:
roadmap_score = float(roadmap_qa.get("quality_score"))
except (TypeError, ValueError):
roadmap_score = None
try:
assignment_score = float(assignment_qa.get("quality_score"))
except (TypeError, ValueError):
assignment_score = None
if roadmap_score is not None and assignment_score is not None:
return round(min(roadmap_score, assignment_score), 4)
if assignment_score is not None:
return assignment_score
if roadmap_score is not None:
return roadmap_score
return 0.5
def build_path_qa_summary(
*,
gaps: Sequence[Mapping[str, Any]],
@ -702,6 +856,7 @@ def build_path_qa_summary(
reorder_notes: Optional[Sequence[str]] = None,
roadmap_qa_mode: Optional[str] = None,
multistage_qa: Optional[Mapping[str, Any]] = None,
steps: Optional[Sequence[Mapping[str, Any]]] = None,
) -> Dict[str, Any]:
offers = list(gap_fill_offers or [])
off_topic = list(off_topic_steps or [])
@ -726,31 +881,32 @@ def build_path_qa_summary(
summary["qa_tiers"] = list(multistage_qa.get("qa_tiers") or [])
summary["optimization_hints"] = list(multistage_qa.get("optimization_hints") or [])
summary["optimization_hint_count"] = int(multistage_qa.get("optimization_hint_count") or 0)
if llm_qa:
summary["overall_ok"] = bool(llm_qa.get("overall_ok", True))
summary["quality_score"] = llm_qa.get("quality_score")
summary["issues"] = list(llm_qa.get("issues") or [])
summary["sequence_notes"] = list(llm_qa.get("sequence_notes") or [])
summary["topic_coverage"] = llm_qa.get("topic_coverage")
summary["recommendations"] = list(llm_qa.get("recommendations") or [])
summary["suggested_new_exercises"] = list(llm_qa.get("suggested_new_exercises") or [])
else:
summary["overall_ok"] = len(gaps) == 0 and len(off_topic) == 0
summary["issues"] = [
f"Lücke zwischen „{g.get('from_title')}“ und „{g.get('to_title')}“ (Score {g.get('gap_score')})"
for g in gaps
] if gaps else []
if off_topic:
summary["issues"] = list(summary["issues"]) + [
f"Schritt „{o.get('title')}“ passt nicht zum Pfad-Thema"
for o in off_topic
]
summary["quality_score"] = compute_deterministic_path_quality_score(
gaps=gaps,
off_topic_steps=off_topic,
assignment_qa = build_assignment_qa_snapshot(
steps=steps,
multistage_qa=multistage_qa,
off_topic_steps=off_topic,
gaps=gaps,
)
roadmap_qa = build_roadmap_qa_snapshot(
llm_qa=llm_qa,
llm_applied=llm_applied,
gaps=gaps,
multistage_qa=multistage_qa,
roadmap_qa_mode=roadmap_qa_mode,
)
summary["assignment_qa"] = assignment_qa
summary["roadmap_qa"] = roadmap_qa
summary["quality_score"] = merge_path_quality_scores(roadmap_qa, assignment_qa)
summary["overall_ok"] = bool(
assignment_qa.get("overall_ok")
and roadmap_qa.get("overall_ok", True),
)
summary["topic_coverage"] = roadmap_qa.get("topic_coverage")
summary["recommendations"] = list(roadmap_qa.get("recommendations") or [])
summary["sequence_notes"] = list(roadmap_qa.get("sequence_notes") or [])
summary["issues"] = list(assignment_qa.get("issues") or []) + list(roadmap_qa.get("issues") or [])[:6]
if llm_qa:
summary["suggested_new_exercises"] = list(llm_qa.get("suggested_new_exercises") or [])
return summary
@ -761,31 +917,34 @@ def compute_deterministic_path_quality_score(
steps: Optional[Sequence[Mapping[str, Any]]] = None,
multistage_qa: Optional[Mapping[str, Any]] = None,
) -> float:
"""Heuristische Pfad-QS ohne LLM — Basis für Slot-Vergleiche."""
score = 0.92
score -= 0.08 * len(off_topic_steps or [])
score -= 0.05 * len(gaps or [])
if steps:
empty = sum(
1
for s in steps
if isinstance(s, dict)
and s.get("exercise_id") is None
and not s.get("is_ai_proposal")
"""Heuristische Pfad-QS ohne LLM — Roadmap + Besetzung kombiniert."""
roadmap_qa = build_roadmap_qa_snapshot(
llm_qa=None,
llm_applied=False,
gaps=gaps,
multistage_qa=multistage_qa,
)
score -= 0.06 * empty
hint_count = int((multistage_qa or {}).get("optimization_hint_count") or 0)
score -= min(0.14, 0.02 * hint_count)
return max(0.35, min(0.98, round(score, 4)))
assignment_qa = build_assignment_qa_snapshot(
steps=steps,
off_topic_steps=off_topic_steps,
gaps=gaps,
)
return merge_path_quality_scores(roadmap_qa, assignment_qa)
__all__ = [
"apply_llm_path_reorder",
"build_assignment_qa_snapshot",
"build_path_qa_summary",
"build_roadmap_qa_snapshot",
"compute_assignment_quality_score",
"compute_deterministic_path_quality_score",
"compute_roadmap_quality_score",
"count_step_assignment_stats",
"detect_off_topic_steps",
"detect_path_gaps",
"is_roadmap_planned_neighbor_pair",
"merge_path_quality_scores",
"strip_off_topic_steps_from_path",
"find_step_pair_index",
"insert_bridge_exercises",

View File

@ -3,19 +3,28 @@ from planning_exercise_path_qa import compute_deterministic_path_quality_score
def test_deterministic_quality_score_penalizes_off_topic():
base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[])
steps = [{"roadmap_major_step_index": 0, "exercise_id": 1}]
base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[], steps=steps)
with_off = compute_deterministic_path_quality_score(
gaps=[],
off_topic_steps=[{"roadmap_major_step_index": 1}],
steps=steps,
)
assert with_off < base
def test_deterministic_quality_score_penalizes_empty_slots():
base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[], steps=[])
filled = [{"roadmap_major_step_index": 0, "exercise_id": 1}]
base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[], steps=filled)
with_empty = compute_deterministic_path_quality_score(
gaps=[],
off_topic_steps=[],
steps=[{"exercise_id": None}, {"exercise_id": 1}],
steps=[{"roadmap_major_step_index": 0, "exercise_id": None}, {"roadmap_major_step_index": 1, "exercise_id": 2}],
)
all_empty = compute_deterministic_path_quality_score(
gaps=[],
off_topic_steps=[],
steps=[{"roadmap_major_step_index": 0, "exercise_id": None}] * 4,
)
assert with_empty < base
assert all_empty <= 0.15

View File

@ -0,0 +1,62 @@
"""Getrennte Roadmap- vs. Besetzungs-QS."""
from planning_exercise_path_qa import (
build_assignment_qa_snapshot,
build_path_qa_summary,
compute_assignment_quality_score,
merge_path_quality_scores,
)
def _empty_steps(n: int):
return [{"roadmap_major_step_index": i, "exercise_id": None} for i in range(n)]
def test_assignment_quality_all_empty_slots_is_low():
steps = _empty_steps(5)
score = compute_assignment_quality_score(steps=steps, off_topic_steps=[], gaps=[])
assert score <= 0.15
def test_assignment_quality_all_filled_is_high():
steps = [{"roadmap_major_step_index": i, "exercise_id": i + 1} for i in range(5)]
score = compute_assignment_quality_score(steps=steps, off_topic_steps=[], gaps=[])
assert score >= 0.9
def test_build_path_qa_summary_caps_llm_score_when_slots_empty():
steps = _empty_steps(4)
summary = build_path_qa_summary(
gaps=[],
bridge_inserts=[],
ai_proposals=[],
off_topic_steps=[],
stripped_off_topic=[],
llm_qa={
"overall_ok": True,
"quality_score": 0.88,
"topic_coverage": "Roadmap deckt Ziel gut ab",
"issues": [],
"recommendations": ["Feinschliff Stufe 3"],
},
llm_applied=True,
steps=steps,
)
assert summary["roadmap_qa"]["quality_score"] == 0.88
assert summary["assignment_qa"]["empty_slot_count"] == 4
assert summary["assignment_qa"]["quality_score"] <= 0.15
assert summary["quality_score"] <= 0.15
assert summary["overall_ok"] is False
def test_merge_path_quality_uses_minimum():
assert merge_path_quality_scores(
{"quality_score": 0.88},
{"quality_score": 0.12},
) == 0.12
def test_assignment_snapshot_reports_empty_slots():
snap = build_assignment_qa_snapshot(steps=_empty_steps(3), off_topic_steps=[], gaps=[])
assert snap["empty_slot_count"] == 3
assert snap["overall_ok"] is False
assert any("ohne Übung" in issue for issue in snap["issues"])

View File

@ -2,6 +2,7 @@
from planning_exercise_path_builder import (
_parse_slot_refs_from_text,
_problematic_slots_from_path_qa,
_slot_auto_select_ai,
_slot_auto_select_library,
_slot_suggestion_accepted,
)
@ -113,6 +114,27 @@ def test_slot_auto_select_requires_higher_score():
)
def test_slot_auto_select_empty_slot_requires_good_fit():
assert not _slot_auto_select_library(
baseline_slot_score=None,
proposed_slot_score=0.35,
baseline_exercise_id=None,
proposed_exercise_id=2,
)
assert _slot_auto_select_library(
baseline_slot_score=None,
proposed_slot_score=0.55,
baseline_exercise_id=None,
proposed_exercise_id=2,
)
def test_slot_auto_select_ai_when_library_not_selected():
assert _slot_auto_select_ai(library_auto_select=False, has_ai=True)
assert not _slot_auto_select_ai(library_auto_select=True, has_ai=True)
assert not _slot_auto_select_ai(library_auto_select=False, has_ai=False)
def test_off_topic_slot_gap_spec_for_filled_slot():
from planning_exercise_path_builder import _build_off_topic_slot_gap_spec

View File

@ -11,6 +11,8 @@ import {
formatRefineLogEntry,
hasRematchSlotHints,
pathQaQualityPercent,
pathQaHasSplitDimensions,
pathQaSubsectionPercent,
pathQaShowsStrongResult,
resolveHintSlotIndex,
resolveOfferSlotIndex,
@ -26,6 +28,46 @@ function severityStyle(pathQa) {
}
}
function subsectionSeverityStyle(subsection) {
if (!subsection) return {}
return {
background: subsection.overall_ok
? 'color-mix(in srgb, var(--accent) 6%, var(--surface))'
: 'color-mix(in srgb, var(--danger) 10%, var(--surface))',
border: `1px solid ${subsection.overall_ok ? 'var(--border)' : 'color-mix(in srgb, var(--danger) 35%, var(--border))'}`,
}
}
function PathQaDimensionBlock({ title, subsection, children = null }) {
if (!subsection) return null
const pct = pathQaSubsectionPercent(subsection)
return (
<div
style={{
marginTop: '10px',
padding: '8px 10px',
borderRadius: '8px',
fontSize: '11px',
lineHeight: 1.45,
...subsectionSeverityStyle(subsection),
}}
>
<strong>
{title}: {subsection.overall_ok ? 'OK' : 'Hinweise'}
{pct != null ? ` (${pct} %)` : ''}
</strong>
{Array.isArray(subsection.issues) && subsection.issues.length > 0 ? (
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
{subsection.issues.slice(0, 5).map((issue) => (
<li key={`${title}-${issue}`}>{issue}</li>
))}
</ul>
) : null}
{children}
</div>
)
}
function PathQaPipelineDetails({ pathQa, fairQa = null, draft, title, compact = false }) {
const { fixHints: optimizationHints } = useMemo(
() => splitPathQaHints(pathQa),
@ -310,6 +352,9 @@ export default function ProgressionFindingsPanel({
&& (canOptimizeCompare || pathQa?.assignments_preserved || showRematchAction)
const qualityPct = pathQaQualityPercent(pathQa)
const strongResult = pathQaShowsStrongResult(pathQa)
const hasSplitQa = pathQaHasSplitDimensions(pathQa)
const roadmapQa = pathQa?.roadmap_qa || null
const assignmentQa = pathQa?.assignment_qa || null
return (
<div className="card" style={{ position: 'sticky', top: '12px' }}>
@ -364,9 +409,14 @@ export default function ProgressionFindingsPanel({
}}
>
<strong>
Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'}
Pfad-QS gesamt: {pathQa.overall_ok ? 'OK' : 'Hinweise'}
{qualityPct != null ? ` (${qualityPct} %)` : ''}
</strong>
{hasSplitQa ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)', fontSize: '11px' }}>
Gesamt = schwächere Dimension (Roadmap vs. Übungsbesetzung).
</p>
) : null}
{pathQa.assignments_preserved ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)', fontSize: '11px' }}>
Bewertung des aktuellen Pfads. Übungen matchen öffnet einen Dialog mit Vorschlägen für
@ -378,7 +428,23 @@ export default function ProgressionFindingsPanel({
Starker Pfad KI-Empfehlungen unten können Feinschliff oder optionale Vertiefung sein.
</p>
) : null}
{pathQa.topic_coverage ? (
{hasSplitQa ? (
<>
<PathQaDimensionBlock title="Roadmap & Stufen" subsection={roadmapQa}>
{roadmapQa?.topic_coverage ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{roadmapQa.topic_coverage}</p>
) : null}
</PathQaDimensionBlock>
<PathQaDimensionBlock title="Übungsbesetzung" subsection={assignmentQa}>
{assignmentQa?.empty_slot_count > 0 ? (
<p style={{ margin: '6px 0 0', color: 'var(--danger)' }}>
{assignmentQa.empty_slot_count} leere Slot(s) Übungen matchen oder manuell befüllen.
</p>
) : null}
</PathQaDimensionBlock>
</>
) : null}
{!hasSplitQa && pathQa.topic_coverage ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{pathQa.topic_coverage}</p>
) : null}
{highlightTexts.length > 0 ? (
@ -415,7 +481,7 @@ export default function ProgressionFindingsPanel({
</ul>
</>
) : null}
{Array.isArray(pathQa.issues) && pathQa.issues.length > 0 ? (
{Array.isArray(pathQa.issues) && pathQa.issues.length > 0 && !hasSplitQa ? (
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
{pathQa.issues.map((issue) => (
<li key={issue}>{issue}</li>

View File

@ -6,7 +6,9 @@ import FormModalOverlay from './FormModalOverlay'
import {
compareSlotReviews,
defaultSelectedCompareDiffs,
pathQaHasSplitDimensions,
pathQaQualityPercent,
pathQaSubsectionPercent,
qualityDeltaPercent,
rejectedCompareDiffs,
slotFitScorePercent,
@ -189,6 +191,9 @@ function SlotReviewRow({ review, selected, onToggle, applying }) {
<strong>KI-Vorschlag nutzen</strong>
<span style={{ display: 'block', fontSize: '11px', color: 'var(--text3)', marginTop: '2px' }}>
{ai.title_hint || 'Neue Übung per KI entwerfen'}
{ai.auto_select
? ' — empfohlen, Bibliothek passt nicht ausreichend zum Stufen-Ziel'
: ''}
</span>
</span>
</label>
@ -223,6 +228,9 @@ export default function ProgressionOptimizeCompareModal({
const baselineQa = comparison.baseline_path_qa
const baselinePct = pathQaQualityPercent(baselineQa)
const hasSplitQa = pathQaHasSplitDimensions(baselineQa)
const roadmapPct = pathQaSubsectionPercent(baselineQa?.roadmap_qa)
const assignmentPct = pathQaSubsectionPercent(baselineQa?.assignment_qa)
const rejectedCount = rejected.length
const reviewError = comparison.review_error || null
@ -258,8 +266,9 @@ export default function ProgressionOptimizeCompareModal({
{title}
</h3>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
Je Slot: aktuelle Bewertung, beste Bibliotheks-Alternative und optional KI. Haken nur
vorausgewählt, wenn die Alternative einen höheren Stufen-Fit hat.
Je Slot: aktuelle Bewertung, beste Bibliotheks-Alternative und optional KI. Vorauswahl:
Bibliothek nur bei klar besserem Stufen-Fit; bei leeren oder schwach passenden Slots eher
KI-Vorschlag.
</p>
{reviewError ? (
@ -289,9 +298,19 @@ export default function ProgressionOptimizeCompareModal({
>
<strong>Dein Pfad</strong>
<div style={{ marginTop: '6px' }}>{qaLabel(baselineQa)}</div>
{baselineQa?.topic_coverage ? (
{hasSplitQa ? (
<div style={{ marginTop: '6px', fontSize: '11px', color: 'var(--text2)', lineHeight: 1.45 }}>
Roadmap {roadmapPct != null ? `${roadmapPct} %` : '—'}
{' · '}
Besetzung {assignmentPct != null ? `${assignmentPct} %` : '—'}
</div>
) : null}
{!hasSplitQa && baselineQa?.topic_coverage ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{baselineQa.topic_coverage}</p>
) : null}
{hasSplitQa && baselineQa?.roadmap_qa?.topic_coverage ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{baselineQa.roadmap_qa.topic_coverage}</p>
) : null}
</div>
{rejectedCount > 0 ? (

View File

@ -173,8 +173,19 @@ export function pathQaQualityPercent(pathQa) {
return Math.round(Number(pathQa.quality_score) * 100)
}
export function pathQaSubsectionPercent(subsection) {
if (subsection?.quality_score == null || !Number.isFinite(Number(subsection.quality_score))) return null
return Math.round(Number(subsection.quality_score) * 100)
}
export function pathQaHasSplitDimensions(pathQa) {
return Boolean(pathQa?.roadmap_qa || pathQa?.assignment_qa)
}
export function pathQaShowsStrongResult(pathQa) {
const pct = pathQaQualityPercent(pathQa)
const assignmentOk = pathQa?.assignment_qa ? pathQa.assignment_qa.overall_ok !== false : true
if (!assignmentOk) return false
if (pathQa?.overall_ok && pct != null && pct >= 85) return true
return Boolean(pathQa?.overall_ok && pct != null && pct >= 80 && !(pathQa?.issues || []).length)
}
@ -1073,9 +1084,16 @@ export function compareDiffsForDialog(comparison) {
export function defaultSelectedCompareDiffs(comparison) {
const reviews = compareSlotReviews(comparison)
if (reviews.length > 0) {
return reviews
.filter((review) => review?.library_alternative?.auto_select)
.map((review) => slotReviewSelectionKey(review.roadmap_major_step_index, 'library'))
const keys = []
for (const review of reviews) {
const midx = review.roadmap_major_step_index
if (review?.ai_alternative?.auto_select) {
keys.push(slotReviewSelectionKey(midx, 'ai'))
} else if (review?.library_alternative?.auto_select) {
keys.push(slotReviewSelectionKey(midx, 'library'))
}
}
return keys
}
return compareDiffsForDialog(comparison).map((d) => Number(d.roadmap_major_step_index))
}