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
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:
parent
cec96ae473
commit
19bbcdaf50
|
|
@ -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“.`
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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({
|
||||
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}
|
||||
</h3>
|
||||
<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
|
||||
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.
|
||||
</p>
|
||||
|
||||
{noMeaningfulDiffs || proposedNotBetter ? (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '12px',
|
||||
padding: '10px 12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid color-mix(in srgb, var(--danger) 35%, var(--border))',
|
||||
background: 'color-mix(in srgb, var(--danger) 8%, var(--surface2))',
|
||||
fontSize: '12px',
|
||||
lineHeight: 1.45,
|
||||
}}
|
||||
>
|
||||
{noMeaningfulDiffs ? (
|
||||
<strong>Keine inhaltlichen Slot-Änderungen</strong>
|
||||
) : (
|
||||
<strong>Vorschlag nicht besser als dein Pfad</strong>
|
||||
)}
|
||||
{noMeaningfulDiffs ? (
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>
|
||||
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
|
||||
style={{
|
||||
marginBottom: '12px',
|
||||
padding: '10px 12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid color-mix(in srgb, var(--accent) 30%, var(--border))',
|
||||
background: 'color-mix(in srgb, var(--accent) 6%, var(--surface2))',
|
||||
fontSize: '12px',
|
||||
lineHeight: 1.45,
|
||||
}}
|
||||
>
|
||||
<strong>Warum nicht einfach alles übernehmen?</strong>
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>
|
||||
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
|
||||
Ganzpfad — nicht darauf, dass deine Auswahl besser ist. Nimm deshalb standardmäßig
|
||||
nur Lückenfüllungen an.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -140,7 +210,7 @@ export default function ProgressionOptimizeCompareModal({
|
|||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
<strong>Aktuell</strong>
|
||||
<strong>Dein Pfad (bewertet)</strong>
|
||||
<div style={{ marginTop: '6px' }}>{qaLabel(baselineQa)}</div>
|
||||
{baselineQa?.topic_coverage ? (
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{baselineQa.topic_coverage}</p>
|
||||
|
|
@ -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',
|
||||
}}
|
||||
>
|
||||
<strong>Vorschlag (Match + Optimierung)</strong>
|
||||
<div style={{ marginTop: '6px' }}>{qaLabel(proposedQa)}</div>
|
||||
{proposedPct != null && baselinePct != null && proposedPct !== baselinePct ? (
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>
|
||||
Δ {proposedPct - baselinePct > 0 ? '+' : ''}
|
||||
{proposedPct - baselinePct} Prozentpunkte
|
||||
</p>
|
||||
) : null}
|
||||
{proposedQa?.topic_coverage ? (
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{proposedQa.topic_coverage}</p>
|
||||
) : null}
|
||||
<strong>Match-Ganzpfad (nur Info)</strong>
|
||||
<div style={{ marginTop: '6px', color: 'var(--text2)' }}>
|
||||
{pipelineQa ? qaLabel(pipelineQa) : '—'}
|
||||
</div>
|
||||
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
|
||||
Rematch-Prozess — kein Versprechen für deine Checkbox-Auswahl.
|
||||
{pipelinePct != null && baselinePct != null && pipelinePct < baselinePct
|
||||
? ` (${pipelinePct} % < ${baselinePct} % bei voller Übernahme).`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tierCount > 0 || rematchCount > 0 || refineCount > 0 || hintCount > 0 ? (
|
||||
<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` : ''}
|
||||
{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.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{trivialDiffs.length > 0 ? (
|
||||
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '0 0 14px', lineHeight: 1.45 }}>
|
||||
{trivialDiffs.length} reine ID-Tausche (gleicher Titel) — nicht übernehmbar, daher nicht gelistet.
|
||||
{gapOnly.length > 0 ? (
|
||||
<p style={{ fontSize: '11px', color: 'var(--text2)', margin: '0 0 14px', lineHeight: 1.45 }}>
|
||||
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>
|
||||
) : null}
|
||||
|
||||
{slotDiffs.length === 0 ? (
|
||||
{dialogDiffs.length === 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '8px', flexWrap: 'wrap' }}>
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: '11px' }} onClick={() => toggleAll(true)}>
|
||||
Alle wählen
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: '11px' }} onClick={() => toggleAll(false)}>
|
||||
Keine
|
||||
</button>
|
||||
</div>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{slotDiffs.map((diff) => {
|
||||
const midx = Number(diff.roadmap_major_step_index)
|
||||
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',
|
||||
}}
|
||||
{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' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '11px' }}
|
||||
onClick={() => toggleGroup(recommended, true)}
|
||||
>
|
||||
<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',
|
||||
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: 'var(--accent-dark)' }}>
|
||||
Neu: {diff.proposed_title || '— leer —'}
|
||||
{diff.proposed_exercise_id != null ? ` (#${diff.proposed_exercise_id})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
Alle Lücken wählen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '11px' }}
|
||||
onClick={() => toggleGroup(recommended, false)}
|
||||
>
|
||||
Keine
|
||||
</button>
|
||||
</div>
|
||||
<ul
|
||||
style={{
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
margin: '0 0 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
{recommended.map((diff) => (
|
||||
<DiffRow
|
||||
key={`fill-${diff.roadmap_major_step_index}`}
|
||||
diff={diff}
|
||||
checked={selected.has(Number(diff.roadmap_major_step_index))}
|
||||
onToggle={toggle}
|
||||
applying={applying}
|
||||
/>
|
||||
))}
|
||||
</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}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={applying || selected.size === 0 || slotDiffs.length === 0 || proposedNotBetter}
|
||||
disabled={applying || selected.size === 0 || dialogDiffs.length === 0}
|
||||
onClick={() => onApplySelected([...selected])}
|
||||
>
|
||||
{applying ? 'Übernehmen …' : `Auswahl übernehmen (${selected.size})`}
|
||||
|
|
|
|||
|
|
@ -985,6 +985,51 @@ function actionableCompareSlotDiffs(diffs) {
|
|||
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) {
|
||||
const merged = (offers || []).map((o) => ({ ...o }))
|
||||
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 baselineQa = baselineRes?.path_qa || null
|
||||
const pipelineQa = proposedRes?.path_qa || null
|
||||
const slotDiffs = annotateCompareSlotDiffs(
|
||||
buildProgressionSlotDiffs(baselineSteps, proposedSteps),
|
||||
const slotDiffs = annotateCompareDiffKinds(
|
||||
annotateCompareSlotDiffs(
|
||||
buildProgressionSlotDiffs(baselineSteps, proposedSteps),
|
||||
),
|
||||
)
|
||||
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(
|
||||
proposedSteps,
|
||||
proposedRes?.gap_fill_offers || [],
|
||||
|
|
@ -1029,7 +1080,10 @@ export function buildProgressionComparePayload(baselineRes, proposedRes) {
|
|||
gap_fill_offers: gapFillOffers,
|
||||
slot_diffs: slotDiffs,
|
||||
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_diffs_source: 'steps',
|
||||
path_qa: proposedQa,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user