diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index 5f0467a..0fbdcd2 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -28,36 +28,37 @@ import { applyEvaluateResponseToDraft, applyGapOfferToDraft, applySelectedCompareSteps, - buildProgressionComparePayload, - compareSlotDiffs, - collectGapOffersFromApiResponse, - dedupeGapOffersBySlot, - filterGapOffersForUnfilledSlots, - mergeGapOffersForDraft, - pathQaQualityPercent, applyResolvedStructuredToDraft, buildPlanningArtifactFromDraft, + buildProgressionComparePayload, + collectGapOffersFromApiResponse, + compareSlotDiffs, + dedupeGapOffersBySlot, + draftHasLibrarySlotAssignments, + draftRetrievalBoostExerciseIds, + EMPTY_PLANNING_CATALOG_CONTEXT, + filterGapOffersForUnfilledSlots, hydrateProgressionGraphDraft, - SLOT_MIN, insertSlotInDraft, librarySlotExercise, majorStepsToOverridePayload, + mergeGapOffersForDraft, moveSlotInDraft, patchSlotInDraft, + pathQaQualityPercent, + planningCatalogContextToApi, + recommendedCompareDiffs, removeSlotFromDraft, saveProgressionGraphDraft, + setCatalogSelectItems, setSlotPrimaryLibrary, SLOT_MAX, + SLOT_MIN, slotsAsPathStepRows, slotsToEvaluateSteps, - draftRetrievalBoostExerciseIds, - draftHasLibrarySlotAssignments, slotsToSlotAssignments, syncProgressionRoadmapFromSlots, syncSlotPhasesFromRoadmap, - EMPTY_PLANNING_CATALOG_CONTEXT, - planningCatalogContextToApi, - setCatalogSelectItems, } from '../utils/progressionGraphDraft' function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) { @@ -523,13 +524,22 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa const baselineQa = res?.baseline_path_qa || null const proposedQa = res?.proposed_path_qa || res?.path_qa || null const diffCount = - res?.slot_diff_count ?? compareSlotDiffs(res, { actionableOnly: true }).length + res?.slot_diff_count_recommended + ?? recommendedCompareDiffs(res).length + ?? res?.slot_diff_count + ?? compareSlotDiffs(res, { actionableOnly: true }).length + const replaceCount = (res?.slot_diffs || []).filter( + (d) => d?.diff_kind === 'replace', + ).length const bPct = pathQaQualityPercent(baselineQa) const pPct = pathQaQualityPercent(proposedQa) let notice = diffCount > 0 - ? `Match: ${diffCount} Slot-Vorschlag/Vorschläge — bitte im Dialog prüfen und auswählen.` - : 'Match: Keine abweichenden Bibliotheks-Slots — Dialog zur Kontrolle geöffnet.' + ? `Match: ${diffCount} Lückenfüllung(en) im Dialog — nur diese sind vorausgewählt.` + : 'Match: Keine Bibliotheks-Lückenfüllungen — Dialog zur Kontrolle geöffnet.' + if (replaceCount > 0) { + notice += ` ${replaceCount} optionale Ersetzung(en) bestehender Slots — standardmäßig abgewählt.` + } const gapCount = collectGapOffersFromApiResponse(res).length if (gapCount > 0) { notice += ` ${gapCount} KI-Angebot(e) für leere Slots im Panel „Graph-Bewertung“.` diff --git a/frontend/src/components/ProgressionOptimizeCompareModal.jsx b/frontend/src/components/ProgressionOptimizeCompareModal.jsx index 7c0a08a..a00faa1 100644 --- a/frontend/src/components/ProgressionOptimizeCompareModal.jsx +++ b/frontend/src/components/ProgressionOptimizeCompareModal.jsx @@ -2,7 +2,14 @@ * Gegenüberstellung: bestehender Pfad vs. optimierter Match-Vorschlag. */ import React, { useMemo, useState } from 'react' -import { compareSlotDiffs, pathQaQualityPercent } from '../utils/progressionGraphDraft' +import { + compareDiffsForDialog, + defaultSelectedCompareDiffs, + gapOnlyCompareDiffs, + optionalReplaceCompareDiffs, + pathQaQualityPercent, + recommendedCompareDiffs, +} from '../utils/progressionGraphDraft' function qaLabel(pathQa) { const pct = pathQaQualityPercent(pathQa) @@ -11,6 +18,66 @@ function qaLabel(pathQa) { return ok ? 'OK' : 'Hinweise' } +function DiffRow({ diff, checked, onToggle, applying, tone = 'neutral' }) { + const midx = Number(diff.roadmap_major_step_index) + const border = + tone === 'warn' + ? '1px solid color-mix(in srgb, var(--danger) 35%, var(--border))' + : '1px solid var(--border)' + const bg = checked + ? tone === 'warn' + ? 'color-mix(in srgb, var(--danger) 6%, var(--surface2))' + : 'var(--surface2)' + : 'var(--surface)' + + return ( +
  • + +
  • + ) +} + export default function ProgressionOptimizeCompareModal({ open, comparison, @@ -19,37 +86,43 @@ export default function ProgressionOptimizeCompareModal({ onApplySelected, applying = false, }) { - const slotDiffs = compareSlotDiffs(comparison, { actionableOnly: true }) - const trivialDiffs = (comparison?.slot_diffs || []).filter((d) => d?.trivial_id_swap) - const [selected, setSelected] = useState(() => new Set()) - - const allKeys = useMemo( - () => slotDiffs.map((d) => Number(d.roadmap_major_step_index)), - [slotDiffs], + const recommended = useMemo( + () => recommendedCompareDiffs(comparison), + [comparison], ) + const optionalReplace = useMemo( + () => optionalReplaceCompareDiffs(comparison), + [comparison], + ) + const gapOnly = useMemo(() => gapOnlyCompareDiffs(comparison), [comparison]) + const dialogDiffs = useMemo(() => compareDiffsForDialog(comparison), [comparison]) + const defaultSelected = useMemo( + () => defaultSelectedCompareDiffs(comparison), + [comparison], + ) + + const [selected, setSelected] = useState(() => new Set()) React.useEffect(() => { if (!open) return - setSelected(new Set(allKeys)) - }, [open, allKeys]) + setSelected(new Set(defaultSelected)) + }, [open, defaultSelected]) if (!open || !comparison) return null const baselineQa = comparison.baseline_path_qa - const proposedQa = comparison.proposed_path_qa || comparison.path_qa + const pipelineQa = comparison.proposed_path_qa_pipeline const baselinePct = pathQaQualityPercent(baselineQa) - const proposedPct = pathQaQualityPercent(proposedQa) - const pipelinePct = pathQaQualityPercent(comparison?.proposed_path_qa_pipeline) - const rematchRounds = comparison?.proposed_path_qa_pipeline?.rematch_rounds - ?? proposedQa?.rematch_rounds - const pipelineQa = comparison?.proposed_path_qa_pipeline + const pipelinePct = pathQaQualityPercent(pipelineQa) + const rematchRounds = pipelineQa?.rematch_rounds const rematchCount = Array.isArray(pipelineQa?.rematch_log) ? pipelineQa.rematch_log.length : 0 const refineCount = Array.isArray(pipelineQa?.refine_log) ? pipelineQa.refine_log.length : 0 const hintCount = Number(pipelineQa?.optimization_hint_count || 0) const tierCount = Array.isArray(pipelineQa?.qa_tiers) ? pipelineQa.qa_tiers.length : 0 - const noMeaningfulDiffs = slotDiffs.length === 0 - const proposedNotBetter = - proposedPct != null && baselinePct != null && proposedPct <= baselinePct + + const selectedReplaceCount = optionalReplace.filter((d) => + selected.has(Number(d.roadmap_major_step_index)), + ).length const toggle = (midx) => { setSelected((prev) => { @@ -60,8 +133,16 @@ export default function ProgressionOptimizeCompareModal({ }) } - const toggleAll = (on) => { - setSelected(on ? new Set(allKeys) : new Set()) + const toggleGroup = (diffs, on) => { + setSelected((prev) => { + const next = new Set(prev) + for (const d of diffs) { + const midx = Number(d.roadmap_major_step_index) + if (on) next.add(midx) + else next.delete(midx) + } + return next + }) } const title = @@ -87,41 +168,30 @@ export default function ProgressionOptimizeCompareModal({ {title}

    - KI matcht alle Lernziel-Slots neu (auch bereits belegte). Du wählst im Dialog, welche - Vorschläge übernommen werden — der Graph ändert sich erst nach „Auswahl übernehmen“. + Übernimm nur, was deinen Pfad verbessert. Leere Slots mit Bibliotheks-Treffer sind + vorausgewählt; Ersetzungen bestehender Übungen sind optional und oft schlechter. + KI-Entwürfe ohne Bibliotheks-ID gehören ins Panel „KI-Angebote“, nicht hierher.

    - {noMeaningfulDiffs || proposedNotBetter ? ( -
    - {noMeaningfulDiffs ? ( - Keine inhaltlichen Slot-Änderungen - ) : ( - Vorschlag nicht besser als dein Pfad - )} - {noMeaningfulDiffs ? ( -

    - Rematch hat höchstens dieselben Übungen unter anderen IDs getroffen — kein Grund zur - Übernahme. Bitte abbrechen. -

    - ) : ( -

    - Fair bewertet liefert der Vorschlag keinen höheren Pfad-QS-Wert. Die frühere niedrigere - Pipeline-Zahl{pipelinePct != null ? ` (${pipelinePct} %)` : ''} stammte aus dem - Rematch-Lauf, nicht aus dem sichtbaren End-Pfad. -

    - )} -
    - ) : null} +
    + Warum nicht einfach alles übernehmen? +

    + Der Match-Lauf optimiert den gesamten Pfad neu (inkl. Rematch). Das kann + bereits gute Slots verschlechtern. Die Prozentzahl rechts bezieht sich auf diesen + Ganzpfad — nicht darauf, dass deine Auswahl besser ist. Nimm deshalb standardmäßig + nur Lückenfüllungen an. +

    +
    - Aktuell + Dein Pfad (bewertet)
    {qaLabel(baselineQa)}
    {baselineQa?.topic_coverage ? (

    {baselineQa.topic_coverage}

    @@ -150,117 +220,158 @@ export default function ProgressionOptimizeCompareModal({ style={{ padding: '10px 12px', borderRadius: '8px', - border: '1px solid color-mix(in srgb, var(--accent) 35%, var(--border))', - background: 'color-mix(in srgb, var(--accent) 8%, var(--surface2))', + border: '1px solid color-mix(in srgb, var(--text3) 35%, var(--border))', + background: 'var(--surface2)', fontSize: '12px', }} > - Vorschlag (Match + Optimierung) -
    {qaLabel(proposedQa)}
    - {proposedPct != null && baselinePct != null && proposedPct !== baselinePct ? ( -

    - Δ {proposedPct - baselinePct > 0 ? '+' : ''} - {proposedPct - baselinePct} Prozentpunkte -

    - ) : null} - {proposedQa?.topic_coverage ? ( -

    {proposedQa.topic_coverage}

    - ) : null} + Match-Ganzpfad (nur Info) +
    + {pipelineQa ? qaLabel(pipelineQa) : '—'} +
    +

    + Rematch-Prozess — kein Versprechen für deine Checkbox-Auswahl. + {pipelinePct != null && baselinePct != null && pipelinePct < baselinePct + ? ` (${pipelinePct} % < ${baselinePct} % bei voller Übernahme).` + : ''} +

    {tierCount > 0 || rematchCount > 0 || refineCount > 0 || hintCount > 0 ? (

    - 3-Stufen-Optimierung im Vorschlag + Rematch-Protokoll {tierCount > 0 ? ` · ${tierCount} QS-Stufen` : ''} {rematchCount > 0 - ? ` · Auto-Rematch ${rematchRounds != null ? `(${rematchRounds} Runde(n))` : ''}: ${rematchCount} Anpassung(en)` + ? ` · ${rematchRounds != null ? `${rematchRounds} Runde(n)` : ''}: ${rematchCount} Anpassung(en)` : ''} {refineCount > 0 ? ` · ${refineCount} Stufen-Spec verfeinert` : ''} {hintCount > 0 ? ` · ${hintCount} Handlungshinweis(e)` : ''} - . Nur Prozessinfo — nicht für die Prozentzahl links/rechts.

    ) : null} - {trivialDiffs.length > 0 ? ( -

    - {trivialDiffs.length} reine ID-Tausche (gleicher Titel) — nicht übernehmbar, daher nicht gelistet. + {gapOnly.length > 0 ? ( +

    + Slot{gapOnly.length > 1 ? 's' : ''}{' '} + {gapOnly.map((d) => Number(d.roadmap_major_step_index) + 1).join(', ')}: kein + Bibliotheks-Treffer — bitte „KI-Angebote“ im Panel nutzen (eigenständig pro Slot).

    ) : null} - {slotDiffs.length === 0 ? ( + {dialogDiffs.length === 0 ? (

    - Keine inhaltlichen Abweichungen — der End-Stand entspricht deinem Pfad. + Keine übernehmbaren Bibliotheks-Änderungen. Leere Slots ggf. über KI-Angebote im Panel + befüllen — nichts am Pfad ändern ist oft die richtige Wahl.

    ) : ( <> -
    - - -
    - + Alle Lücken wählen + + + + + + ) : null} + + {optionalReplace.length > 0 ? ( + <> +

    + Bestehende Slots ersetzen (optional — oft Verschlechterung) +

    +

    + Standard: abgewählt. Nur aktivieren, wenn du die konkrete Übung bewusst tauschen + willst. +

    + + + ) : null} )} -
    + {selectedReplaceCount > 0 ? ( +

    + {selectedReplaceCount} Ersetzung(en) gewählt — kann Pfad-QS senken. Lückenfüllungen + sind unkritischer. +

    + ) : null} + +