diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 618a467..2cfcea6 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -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: - return True - return float(proposed_slot_score) > float(baseline_slot_score) + 0.001 + 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 + + +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 diff --git a/backend/planning_exercise_path_qa.py b/backend/planning_exercise_path_qa.py index 48770a1..6b8032a 100644 --- a/backend/planning_exercise_path_qa.py +++ b/backend/planning_exercise_path_qa.py @@ -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) + + assignment_qa = build_assignment_qa_snapshot( + steps=steps, + 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["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, - steps=steps, - multistage_qa=multistage_qa, - ) 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") - ) - 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))) + """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, + ) + 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", diff --git a/backend/tests/test_planning_deterministic_quality_score.py b/backend/tests/test_planning_deterministic_quality_score.py index f5a0975..ed7e0dd 100644 --- a/backend/tests/test_planning_deterministic_quality_score.py +++ b/backend/tests/test_planning_deterministic_quality_score.py @@ -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 diff --git a/backend/tests/test_planning_path_qa_split.py b/backend/tests/test_planning_path_qa_split.py new file mode 100644 index 0000000..77bb0f9 --- /dev/null +++ b/backend/tests/test_planning_path_qa_split.py @@ -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"]) diff --git a/backend/tests/test_planning_problematic_slots.py b/backend/tests/test_planning_problematic_slots.py index 8276d70..bae6fd1 100644 --- a/backend/tests/test_planning_problematic_slots.py +++ b/backend/tests/test_planning_problematic_slots.py @@ -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 diff --git a/frontend/src/components/ProgressionFindingsPanel.jsx b/frontend/src/components/ProgressionFindingsPanel.jsx index 0b4135e..f79a0fb 100644 --- a/frontend/src/components/ProgressionFindingsPanel.jsx +++ b/frontend/src/components/ProgressionFindingsPanel.jsx @@ -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 ( +
+ + {title}: {subsection.overall_ok ? 'OK' : 'Hinweise'} + {pct != null ? ` (${pct} %)` : ''} + + {Array.isArray(subsection.issues) && subsection.issues.length > 0 ? ( + + ) : null} + {children} +
+ ) +} + 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 (
@@ -364,9 +409,14 @@ export default function ProgressionFindingsPanel({ }} > - Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'} + Pfad-QS gesamt: {pathQa.overall_ok ? 'OK' : 'Hinweise'} {qualityPct != null ? ` (${qualityPct} %)` : ''} + {hasSplitQa ? ( +

+ Gesamt = schwächere Dimension (Roadmap vs. Übungsbesetzung). +

+ ) : null} {pathQa.assignments_preserved ? (

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.

) : null} - {pathQa.topic_coverage ? ( + {hasSplitQa ? ( + <> + + {roadmapQa?.topic_coverage ? ( +

{roadmapQa.topic_coverage}

+ ) : null} +
+ + {assignmentQa?.empty_slot_count > 0 ? ( +

+ {assignmentQa.empty_slot_count} leere Slot(s) — „Übungen matchen“ oder manuell befüllen. +

+ ) : null} +
+ + ) : null} + {!hasSplitQa && pathQa.topic_coverage ? (

{pathQa.topic_coverage}

) : null} {highlightTexts.length > 0 ? ( @@ -415,7 +481,7 @@ export default function ProgressionFindingsPanel({ ) : null} - {Array.isArray(pathQa.issues) && pathQa.issues.length > 0 ? ( + {Array.isArray(pathQa.issues) && pathQa.issues.length > 0 && !hasSplitQa ? (
{rejectedCount > 0 ? ( diff --git a/frontend/src/utils/progressionGraphDraft.js b/frontend/src/utils/progressionGraphDraft.js index eec0907..40f0dee 100644 --- a/frontend/src/utils/progressionGraphDraft.js +++ b/frontend/src/utils/progressionGraphDraft.js @@ -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)) }