Refactor Progression Comparison Logic and Enhance UI Components
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 43s
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 1m19s

- Introduced new utility functions for comparing slot differences, including `compareDiffKind`, `annotateCompareDiffKinds`, and various filtering functions to streamline the comparison process.
- Updated `ProgressionGraphEditor` to utilize the new comparison logic, improving the handling of slot differences and user notifications.
- Enhanced `ProgressionOptimizeCompareModal` to better manage proposed path suggestions, including clearer messaging and improved selection handling for optional replacements.
- Adjusted frontend components to reflect changes in comparison logic, ensuring a more intuitive user experience in managing progression paths.
This commit is contained in:
Lars 2026-06-13 09:02:15 +02:00
parent cec96ae473
commit 19bbcdaf50
3 changed files with 327 additions and 152 deletions

View File

@ -28,36 +28,37 @@ import {
applyEvaluateResponseToDraft, applyEvaluateResponseToDraft,
applyGapOfferToDraft, applyGapOfferToDraft,
applySelectedCompareSteps, applySelectedCompareSteps,
buildProgressionComparePayload,
compareSlotDiffs,
collectGapOffersFromApiResponse,
dedupeGapOffersBySlot,
filterGapOffersForUnfilledSlots,
mergeGapOffersForDraft,
pathQaQualityPercent,
applyResolvedStructuredToDraft, applyResolvedStructuredToDraft,
buildPlanningArtifactFromDraft, buildPlanningArtifactFromDraft,
buildProgressionComparePayload,
collectGapOffersFromApiResponse,
compareSlotDiffs,
dedupeGapOffersBySlot,
draftHasLibrarySlotAssignments,
draftRetrievalBoostExerciseIds,
EMPTY_PLANNING_CATALOG_CONTEXT,
filterGapOffersForUnfilledSlots,
hydrateProgressionGraphDraft, hydrateProgressionGraphDraft,
SLOT_MIN,
insertSlotInDraft, insertSlotInDraft,
librarySlotExercise, librarySlotExercise,
majorStepsToOverridePayload, majorStepsToOverridePayload,
mergeGapOffersForDraft,
moveSlotInDraft, moveSlotInDraft,
patchSlotInDraft, patchSlotInDraft,
pathQaQualityPercent,
planningCatalogContextToApi,
recommendedCompareDiffs,
removeSlotFromDraft, removeSlotFromDraft,
saveProgressionGraphDraft, saveProgressionGraphDraft,
setCatalogSelectItems,
setSlotPrimaryLibrary, setSlotPrimaryLibrary,
SLOT_MAX, SLOT_MAX,
SLOT_MIN,
slotsAsPathStepRows, slotsAsPathStepRows,
slotsToEvaluateSteps, slotsToEvaluateSteps,
draftRetrievalBoostExerciseIds,
draftHasLibrarySlotAssignments,
slotsToSlotAssignments, slotsToSlotAssignments,
syncProgressionRoadmapFromSlots, syncProgressionRoadmapFromSlots,
syncSlotPhasesFromRoadmap, syncSlotPhasesFromRoadmap,
EMPTY_PLANNING_CATALOG_CONTEXT,
planningCatalogContextToApi,
setCatalogSelectItems,
} from '../utils/progressionGraphDraft' } from '../utils/progressionGraphDraft'
function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) { 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 baselineQa = res?.baseline_path_qa || null
const proposedQa = res?.proposed_path_qa || res?.path_qa || null const proposedQa = res?.proposed_path_qa || res?.path_qa || null
const diffCount = 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 bPct = pathQaQualityPercent(baselineQa)
const pPct = pathQaQualityPercent(proposedQa) const pPct = pathQaQualityPercent(proposedQa)
let notice = let notice =
diffCount > 0 diffCount > 0
? `Match: ${diffCount} Slot-Vorschlag/Vorschläge — bitte im Dialog prüfen und auswählen.` ? `Match: ${diffCount} Lückenfüllung(en) im Dialog — nur diese sind vorausgewählt.`
: 'Match: Keine abweichenden Bibliotheks-Slots — Dialog zur Kontrolle geöffnet.' : '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 const gapCount = collectGapOffersFromApiResponse(res).length
if (gapCount > 0) { if (gapCount > 0) {
notice += ` ${gapCount} KI-Angebot(e) für leere Slots im Panel „Graph-Bewertung“.` notice += ` ${gapCount} KI-Angebot(e) für leere Slots im Panel „Graph-Bewertung“.`

View File

@ -2,7 +2,14 @@
* Gegenüberstellung: bestehender Pfad vs. optimierter Match-Vorschlag. * Gegenüberstellung: bestehender Pfad vs. optimierter Match-Vorschlag.
*/ */
import React, { useMemo, useState } from 'react' 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) { function qaLabel(pathQa) {
const pct = pathQaQualityPercent(pathQa) const pct = pathQaQualityPercent(pathQa)
@ -11,6 +18,66 @@ function qaLabel(pathQa) {
return ok ? 'OK' : 'Hinweise' 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 (
<li
style={{
padding: '10px 12px',
borderRadius: '8px',
border,
background: bg,
fontSize: '12px',
}}
>
<label style={{ display: 'flex', gap: '8px', alignItems: 'flex-start', cursor: 'pointer' }}>
<input
type="checkbox"
checked={checked}
onChange={() => onToggle(midx)}
disabled={applying}
style={{ marginTop: '3px' }}
/>
<span style={{ flex: 1 }}>
<strong>Slot {midx + 1}</strong>
{tone === 'warn' ? (
<span style={{ marginLeft: '6px', fontSize: '10px', color: 'var(--danger)' }}>
Ersetzt deine Zuordnung
</span>
) : null}
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '8px',
marginTop: '6px',
}}
>
<span style={{ color: 'var(--text2)' }}>
Bisher: {diff.baseline_title || '— leer —'}
{diff.baseline_exercise_id != null ? ` (#${diff.baseline_exercise_id})` : ''}
</span>
<span style={{ color: tone === 'warn' ? 'var(--danger)' : 'var(--accent-dark)' }}>
Neu: {diff.proposed_title || '— leer —'}
{diff.proposed_exercise_id != null ? ` (#${diff.proposed_exercise_id})` : ''}
</span>
</div>
</span>
</label>
</li>
)
}
export default function ProgressionOptimizeCompareModal({ export default function ProgressionOptimizeCompareModal({
open, open,
comparison, comparison,
@ -19,37 +86,43 @@ export default function ProgressionOptimizeCompareModal({
onApplySelected, onApplySelected,
applying = false, applying = false,
}) { }) {
const slotDiffs = compareSlotDiffs(comparison, { actionableOnly: true }) const recommended = useMemo(
const trivialDiffs = (comparison?.slot_diffs || []).filter((d) => d?.trivial_id_swap) () => recommendedCompareDiffs(comparison),
const [selected, setSelected] = useState(() => new Set()) [comparison],
const allKeys = useMemo(
() => slotDiffs.map((d) => Number(d.roadmap_major_step_index)),
[slotDiffs],
) )
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(() => { React.useEffect(() => {
if (!open) return if (!open) return
setSelected(new Set(allKeys)) setSelected(new Set(defaultSelected))
}, [open, allKeys]) }, [open, defaultSelected])
if (!open || !comparison) return null if (!open || !comparison) return null
const baselineQa = comparison.baseline_path_qa 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 baselinePct = pathQaQualityPercent(baselineQa)
const proposedPct = pathQaQualityPercent(proposedQa) const pipelinePct = pathQaQualityPercent(pipelineQa)
const pipelinePct = pathQaQualityPercent(comparison?.proposed_path_qa_pipeline) const rematchRounds = pipelineQa?.rematch_rounds
const rematchRounds = comparison?.proposed_path_qa_pipeline?.rematch_rounds
?? proposedQa?.rematch_rounds
const pipelineQa = comparison?.proposed_path_qa_pipeline
const rematchCount = Array.isArray(pipelineQa?.rematch_log) ? pipelineQa.rematch_log.length : 0 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 refineCount = Array.isArray(pipelineQa?.refine_log) ? pipelineQa.refine_log.length : 0
const hintCount = Number(pipelineQa?.optimization_hint_count || 0) const hintCount = Number(pipelineQa?.optimization_hint_count || 0)
const tierCount = Array.isArray(pipelineQa?.qa_tiers) ? pipelineQa.qa_tiers.length : 0 const tierCount = Array.isArray(pipelineQa?.qa_tiers) ? pipelineQa.qa_tiers.length : 0
const noMeaningfulDiffs = slotDiffs.length === 0
const proposedNotBetter = const selectedReplaceCount = optionalReplace.filter((d) =>
proposedPct != null && baselinePct != null && proposedPct <= baselinePct selected.has(Number(d.roadmap_major_step_index)),
).length
const toggle = (midx) => { const toggle = (midx) => {
setSelected((prev) => { setSelected((prev) => {
@ -60,8 +133,16 @@ export default function ProgressionOptimizeCompareModal({
}) })
} }
const toggleAll = (on) => { const toggleGroup = (diffs, on) => {
setSelected(on ? new Set(allKeys) : new Set()) 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 = const title =
@ -87,41 +168,30 @@ export default function ProgressionOptimizeCompareModal({
{title} {title}
</h3> </h3>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}> <p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
KI matcht alle Lernziel-Slots neu (auch bereits belegte). Du wählst im Dialog, welche Übernimm nur, was deinen Pfad verbessert. Leere Slots mit Bibliotheks-Treffer sind
Vorschläge übernommen werden der Graph ändert sich erst nach Auswahl übernehmen. vorausgewählt; Ersetzungen bestehender Übungen sind optional und oft schlechter.
KI-Entwürfe ohne Bibliotheks-ID gehören ins Panel KI-Angebote, nicht hierher.
</p> </p>
{noMeaningfulDiffs || proposedNotBetter ? ( <div
<div style={{
style={{ marginBottom: '12px',
marginBottom: '12px', padding: '10px 12px',
padding: '10px 12px', borderRadius: '8px',
borderRadius: '8px', border: '1px solid color-mix(in srgb, var(--accent) 30%, var(--border))',
border: '1px solid color-mix(in srgb, var(--danger) 35%, var(--border))', background: 'color-mix(in srgb, var(--accent) 6%, var(--surface2))',
background: 'color-mix(in srgb, var(--danger) 8%, var(--surface2))', fontSize: '12px',
fontSize: '12px', lineHeight: 1.45,
lineHeight: 1.45, }}
}} >
> <strong>Warum nicht einfach alles übernehmen?</strong>
{noMeaningfulDiffs ? ( <p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>
<strong>Keine inhaltlichen Slot-Änderungen</strong> Der Match-Lauf optimiert den <em>gesamten</em> Pfad neu (inkl. Rematch). Das kann
) : ( bereits gute Slots verschlechtern. Die Prozentzahl rechts bezieht sich auf diesen
<strong>Vorschlag nicht besser als dein Pfad</strong> Ganzpfad nicht darauf, dass deine Auswahl besser ist. Nimm deshalb standardmäßig
)} nur Lückenfüllungen an.
{noMeaningfulDiffs ? ( </p>
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}> </div>
Rematch hat höchstens dieselben Übungen unter anderen IDs getroffen kein Grund zur
Übernahme. Bitte abbrechen.
</p>
) : (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>
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.
</p>
)}
</div>
) : null}
<div <div
style={{ style={{
@ -140,7 +210,7 @@ export default function ProgressionOptimizeCompareModal({
fontSize: '12px', fontSize: '12px',
}} }}
> >
<strong>Aktuell</strong> <strong>Dein Pfad (bewertet)</strong>
<div style={{ marginTop: '6px' }}>{qaLabel(baselineQa)}</div> <div style={{ marginTop: '6px' }}>{qaLabel(baselineQa)}</div>
{baselineQa?.topic_coverage ? ( {baselineQa?.topic_coverage ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{baselineQa.topic_coverage}</p> <p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{baselineQa.topic_coverage}</p>
@ -150,117 +220,158 @@ export default function ProgressionOptimizeCompareModal({
style={{ style={{
padding: '10px 12px', padding: '10px 12px',
borderRadius: '8px', borderRadius: '8px',
border: '1px solid color-mix(in srgb, var(--accent) 35%, var(--border))', border: '1px solid color-mix(in srgb, var(--text3) 35%, var(--border))',
background: 'color-mix(in srgb, var(--accent) 8%, var(--surface2))', background: 'var(--surface2)',
fontSize: '12px', fontSize: '12px',
}} }}
> >
<strong>Vorschlag (Match + Optimierung)</strong> <strong>Match-Ganzpfad (nur Info)</strong>
<div style={{ marginTop: '6px' }}>{qaLabel(proposedQa)}</div> <div style={{ marginTop: '6px', color: 'var(--text2)' }}>
{proposedPct != null && baselinePct != null && proposedPct !== baselinePct ? ( {pipelineQa ? qaLabel(pipelineQa) : '—'}
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}> </div>
Δ {proposedPct - baselinePct > 0 ? '+' : ''} <p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
{proposedPct - baselinePct} Prozentpunkte Rematch-Prozess kein Versprechen für deine Checkbox-Auswahl.
</p> {pipelinePct != null && baselinePct != null && pipelinePct < baselinePct
) : null} ? ` (${pipelinePct} % < ${baselinePct} % bei voller Übernahme).`
{proposedQa?.topic_coverage ? ( : ''}
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{proposedQa.topic_coverage}</p> </p>
) : null}
</div> </div>
</div> </div>
{tierCount > 0 || rematchCount > 0 || refineCount > 0 || hintCount > 0 ? ( {tierCount > 0 || rematchCount > 0 || refineCount > 0 || hintCount > 0 ? (
<p style={{ fontSize: '11px', color: 'var(--text2)', margin: '0 0 14px', lineHeight: 1.45 }}> <p style={{ fontSize: '11px', color: 'var(--text2)', margin: '0 0 14px', lineHeight: 1.45 }}>
3-Stufen-Optimierung im Vorschlag Rematch-Protokoll
{tierCount > 0 ? ` · ${tierCount} QS-Stufen` : ''} {tierCount > 0 ? ` · ${tierCount} QS-Stufen` : ''}
{rematchCount > 0 {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` : ''} {refineCount > 0 ? ` · ${refineCount} Stufen-Spec verfeinert` : ''}
{hintCount > 0 ? ` · ${hintCount} Handlungshinweis(e)` : ''} {hintCount > 0 ? ` · ${hintCount} Handlungshinweis(e)` : ''}
. Nur Prozessinfo nicht für die Prozentzahl links/rechts.
</p> </p>
) : null} ) : null}
{trivialDiffs.length > 0 ? ( {gapOnly.length > 0 ? (
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '0 0 14px', lineHeight: 1.45 }}> <p style={{ fontSize: '11px', color: 'var(--text2)', margin: '0 0 14px', lineHeight: 1.45 }}>
{trivialDiffs.length} reine ID-Tausche (gleicher Titel) nicht übernehmbar, daher nicht gelistet. 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).
</p> </p>
) : null} ) : null}
{slotDiffs.length === 0 ? ( {dialogDiffs.length === 0 ? (
<p style={{ fontSize: '12px', color: 'var(--text2)' }}> <p style={{ fontSize: '12px', color: 'var(--text2)' }}>
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.
</p> </p>
) : ( ) : (
<> <>
<div style={{ display: 'flex', gap: '8px', marginBottom: '8px', flexWrap: 'wrap' }}> {recommended.length > 0 ? (
<button type="button" className="btn btn-secondary" style={{ fontSize: '11px' }} onClick={() => toggleAll(true)}> <>
Alle wählen <h4 style={{ margin: '0 0 8px', fontSize: '0.9rem' }}>
</button> Lücken füllen (empfohlen)
<button type="button" className="btn btn-secondary" style={{ fontSize: '11px' }} onClick={() => toggleAll(false)}> </h4>
Keine <div style={{ display: 'flex', gap: '8px', marginBottom: '8px', flexWrap: 'wrap' }}>
</button> <button
</div> type="button"
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '8px' }}> className="btn btn-secondary"
{slotDiffs.map((diff) => { style={{ fontSize: '11px' }}
const midx = Number(diff.roadmap_major_step_index) onClick={() => toggleGroup(recommended, true)}
const checked = selected.has(midx)
return (
<li
key={`diff-${midx}`}
style={{
padding: '10px 12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: checked ? 'var(--surface2)' : 'var(--surface)',
fontSize: '12px',
}}
> >
<label style={{ display: 'flex', gap: '8px', alignItems: 'flex-start', cursor: 'pointer' }}> Alle Lücken wählen
<input </button>
type="checkbox" <button
checked={checked} type="button"
onChange={() => toggle(midx)} className="btn btn-secondary"
disabled={applying} style={{ fontSize: '11px' }}
style={{ marginTop: '3px' }} onClick={() => toggleGroup(recommended, false)}
/> >
<span style={{ flex: 1 }}> Keine
<strong>Slot {midx + 1}</strong> </button>
<div </div>
style={{ <ul
display: 'grid', style={{
gridTemplateColumns: '1fr 1fr', listStyle: 'none',
gap: '8px', padding: 0,
marginTop: '6px', margin: '0 0 16px',
}} display: 'flex',
> flexDirection: 'column',
<span style={{ color: 'var(--text2)' }}> gap: '8px',
Bisher: {diff.baseline_title || '— leer —'} }}
{diff.baseline_exercise_id != null ? ` (#${diff.baseline_exercise_id})` : ''} >
</span> {recommended.map((diff) => (
<span style={{ color: 'var(--accent-dark)' }}> <DiffRow
Neu: {diff.proposed_title || '— leer —'} key={`fill-${diff.roadmap_major_step_index}`}
{diff.proposed_exercise_id != null ? ` (#${diff.proposed_exercise_id})` : ''} diff={diff}
</span> checked={selected.has(Number(diff.roadmap_major_step_index))}
</div> onToggle={toggle}
</span> applying={applying}
</label> />
</li> ))}
) </ul>
})} </>
</ul> ) : null}
{optionalReplace.length > 0 ? (
<>
<h4 style={{ margin: '0 0 8px', fontSize: '0.9rem', color: 'var(--danger)' }}>
Bestehende Slots ersetzen (optional oft Verschlechterung)
</h4>
<p style={{ fontSize: '11px', color: 'var(--text2)', margin: '0 0 8px' }}>
Standard: abgewählt. Nur aktivieren, wenn du die konkrete Übung bewusst tauschen
willst.
</p>
<ul
style={{
listStyle: 'none',
padding: 0,
margin: 0,
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
{optionalReplace.map((diff) => (
<DiffRow
key={`replace-${diff.roadmap_major_step_index}`}
diff={diff}
checked={selected.has(Number(diff.roadmap_major_step_index))}
onToggle={toggle}
applying={applying}
tone="warn"
/>
))}
</ul>
</>
) : null}
</> </>
)} )}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '16px', justifyContent: 'flex-end' }}> {selectedReplaceCount > 0 ? (
<p
className="form-error"
style={{ marginTop: '12px', fontSize: '11px' }}
>
{selectedReplaceCount} Ersetzung(en) gewählt kann Pfad-QS senken. Lückenfüllungen
sind unkritischer.
</p>
) : null}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
marginTop: '16px',
justifyContent: 'flex-end',
}}
>
<button type="button" className="btn btn-secondary" disabled={applying} onClick={onClose}> <button type="button" className="btn btn-secondary" disabled={applying} onClick={onClose}>
Abbrechen Abbrechen
</button> </button>
<button <button
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
disabled={applying || selected.size === 0 || slotDiffs.length === 0 || proposedNotBetter} disabled={applying || selected.size === 0 || dialogDiffs.length === 0}
onClick={() => onApplySelected([...selected])} onClick={() => onApplySelected([...selected])}
> >
{applying ? 'Übernehmen …' : `Auswahl übernehmen (${selected.size})`} {applying ? 'Übernehmen …' : `Auswahl übernehmen (${selected.size})`}

View File

@ -985,6 +985,51 @@ function actionableCompareSlotDiffs(diffs) {
return (diffs || []).filter((d) => !d.trivial_id_swap) return (diffs || []).filter((d) => !d.trivial_id_swap)
} }
/** fill = leerer Slot + Bibliotheks-Treffer; replace = bestehende Übung tauschen; gap_only = nur KI-Angebot. */
export function compareDiffKind(diff) {
if (!diff || diff.trivial_id_swap) return 'skip'
const hasBase = diff.baseline_exercise_id != null
const hasProp = diff.proposed_exercise_id != null
if (!hasBase && hasProp) return 'fill'
if (hasBase && hasProp) return 'replace'
if (!hasBase && !hasProp) return 'gap_only'
if (hasBase && !hasProp) return 'replace'
return 'skip'
}
export function annotateCompareDiffKinds(diffs) {
return (diffs || []).map((d) => ({
...d,
diff_kind: compareDiffKind(d),
}))
}
/** Nur übernehmbare Bibliotheks-Diffs (kein reines Titel-/Gap-Geplänkel). */
export function compareDiffsForDialog(comparison) {
const diffs = annotateCompareDiffKinds(
compareSlotDiffs(comparison, { actionableOnly: true }),
)
return diffs.filter((d) => d.diff_kind === 'fill' || d.diff_kind === 'replace')
}
export function recommendedCompareDiffs(comparison) {
return compareDiffsForDialog(comparison).filter((d) => d.diff_kind === 'fill')
}
export function optionalReplaceCompareDiffs(comparison) {
return compareDiffsForDialog(comparison).filter((d) => d.diff_kind === 'replace')
}
export function gapOnlyCompareDiffs(comparison) {
return annotateCompareDiffKinds(
compareSlotDiffs(comparison, { actionableOnly: true }),
).filter((d) => d.diff_kind === 'gap_only')
}
export function defaultSelectedCompareDiffs(comparison) {
return recommendedCompareDiffs(comparison).map((d) => Number(d.roadmap_major_step_index))
}
function mergeGapFillOffersFromSteps(steps, offers) { function mergeGapFillOffersFromSteps(steps, offers) {
const merged = (offers || []).map((o) => ({ ...o })) const merged = (offers || []).map((o) => ({ ...o }))
const seen = new Set(merged.map((o) => o.offer_id).filter(Boolean)) const seen = new Set(merged.map((o) => o.offer_id).filter(Boolean))
@ -1006,10 +1051,16 @@ export function buildProgressionComparePayload(baselineRes, proposedRes) {
const proposedSteps = Array.isArray(proposedRes?.steps) ? proposedRes.steps : [] const proposedSteps = Array.isArray(proposedRes?.steps) ? proposedRes.steps : []
const baselineQa = baselineRes?.path_qa || null const baselineQa = baselineRes?.path_qa || null
const pipelineQa = proposedRes?.path_qa || null const pipelineQa = proposedRes?.path_qa || null
const slotDiffs = annotateCompareSlotDiffs( const slotDiffs = annotateCompareDiffKinds(
buildProgressionSlotDiffs(baselineSteps, proposedSteps), annotateCompareSlotDiffs(
buildProgressionSlotDiffs(baselineSteps, proposedSteps),
),
) )
const actionableDiffs = actionableCompareSlotDiffs(slotDiffs) const actionableDiffs = actionableCompareSlotDiffs(slotDiffs)
const dialogDiffs = actionableDiffs.filter(
(d) => d.diff_kind === 'fill' || d.diff_kind === 'replace',
)
const recommendedDiffs = dialogDiffs.filter((d) => d.diff_kind === 'fill')
const gapFillOffers = mergeGapFillOffersFromSteps( const gapFillOffers = mergeGapFillOffersFromSteps(
proposedSteps, proposedSteps,
proposedRes?.gap_fill_offers || [], proposedRes?.gap_fill_offers || [],
@ -1029,7 +1080,10 @@ export function buildProgressionComparePayload(baselineRes, proposedRes) {
gap_fill_offers: gapFillOffers, gap_fill_offers: gapFillOffers,
slot_diffs: slotDiffs, slot_diffs: slotDiffs,
slot_diffs_actionable: actionableDiffs, slot_diffs_actionable: actionableDiffs,
slot_diff_count: actionableDiffs.length, slot_diffs_dialog: dialogDiffs,
slot_diffs_recommended: recommendedDiffs,
slot_diff_count: dialogDiffs.length,
slot_diff_count_recommended: recommendedDiffs.length,
slot_diff_count_including_trivial: slotDiffs.length, slot_diff_count_including_trivial: slotDiffs.length,
slot_diffs_source: 'steps', slot_diffs_source: 'steps',
path_qa: proposedQa, path_qa: proposedQa,