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,
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“.`

View File

@ -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})`}

View File

@ -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,