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,
|
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“.`
|
||||||
|
|
|
||||||
|
|
@ -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})`}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user