progression V2 #57

Merged
Lars merged 20 commits from develop into main 2026-06-13 16:34:09 +02:00
3 changed files with 327 additions and 152 deletions
Showing only changes of commit 19bbcdaf50 - Show all commits

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(--danger) 35%, var(--border))', border: '1px solid color-mix(in srgb, var(--accent) 30%, var(--border))',
background: 'color-mix(in srgb, var(--danger) 8%, var(--surface2))', background: 'color-mix(in srgb, var(--accent) 6%, var(--surface2))',
fontSize: '12px', fontSize: '12px',
lineHeight: 1.45, lineHeight: 1.45,
}} }}
> >
{noMeaningfulDiffs ? ( <strong>Warum nicht einfach alles übernehmen?</strong>
<strong>Keine inhaltlichen Slot-Änderungen</strong>
) : (
<strong>Vorschlag nicht besser als dein Pfad</strong>
)}
{noMeaningfulDiffs ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}> <p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>
Rematch hat höchstens dieselben Übungen unter anderen IDs getroffen kein Grund zur Der Match-Lauf optimiert den <em>gesamten</em> Pfad neu (inkl. Rematch). Das kann
Übernahme. Bitte abbrechen. 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.
</p> </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> </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.
{pipelinePct != null && baselinePct != null && pipelinePct < baselinePct
? ` (${pipelinePct} % < ${baselinePct} % bei voller Übernahme).`
: ''}
</p> </p>
) : null}
{proposedQa?.topic_coverage ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{proposedQa.topic_coverage}</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>
) : ( ) : (
<> <>
{recommended.length > 0 ? (
<>
<h4 style={{ margin: '0 0 8px', fontSize: '0.9rem' }}>
Lücken füllen (empfohlen)
</h4>
<div style={{ display: 'flex', gap: '8px', marginBottom: '8px', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '8px', marginBottom: '8px', flexWrap: 'wrap' }}>
<button type="button" className="btn btn-secondary" style={{ fontSize: '11px' }} onClick={() => toggleAll(true)}> <button
Alle wählen type="button"
className="btn btn-secondary"
style={{ fontSize: '11px' }}
onClick={() => toggleGroup(recommended, true)}
>
Alle Lücken wählen
</button> </button>
<button type="button" className="btn btn-secondary" style={{ fontSize: '11px' }} onClick={() => toggleAll(false)}> <button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px' }}
onClick={() => toggleGroup(recommended, false)}
>
Keine Keine
</button> </button>
</div> </div>
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '8px' }}> <ul
{slotDiffs.map((diff) => {
const midx = Number(diff.roadmap_major_step_index)
const checked = selected.has(midx)
return (
<li
key={`diff-${midx}`}
style={{ style={{
padding: '10px 12px', listStyle: 'none',
borderRadius: '8px', padding: 0,
border: '1px solid var(--border)', margin: '0 0 16px',
background: checked ? 'var(--surface2)' : 'var(--surface)', display: 'flex',
fontSize: '12px', flexDirection: 'column',
}}
>
<label style={{ display: 'flex', gap: '8px', alignItems: 'flex-start', cursor: 'pointer' }}>
<input
type="checkbox"
checked={checked}
onChange={() => toggle(midx)}
disabled={applying}
style={{ marginTop: '3px' }}
/>
<span style={{ flex: 1 }}>
<strong>Slot {midx + 1}</strong>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '8px', gap: '8px',
marginTop: '6px',
}} }}
> >
<span style={{ color: 'var(--text2)' }}> {recommended.map((diff) => (
Bisher: {diff.baseline_title || '— leer —'} <DiffRow
{diff.baseline_exercise_id != null ? ` (#${diff.baseline_exercise_id})` : ''} key={`fill-${diff.roadmap_major_step_index}`}
</span> diff={diff}
<span style={{ color: 'var(--accent-dark)' }}> checked={selected.has(Number(diff.roadmap_major_step_index))}
Neu: {diff.proposed_title || '— leer —'} onToggle={toggle}
{diff.proposed_exercise_id != null ? ` (#${diff.proposed_exercise_id})` : ''} applying={applying}
</span> />
</div> ))}
</span>
</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(
annotateCompareSlotDiffs(
buildProgressionSlotDiffs(baselineSteps, proposedSteps), 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,