Refactor Progression Path Evaluation and Comparison Logic
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m22s

- Updated `suggest_progression_path` to utilize `evaluate_steps` for improved validation, ensuring at least one evaluation step is provided.
- Modified frontend components to enhance user experience in the comparison process, including clearer messaging and improved dialog handling.
- Adjusted `ProgressionGraphEditor` to streamline the comparison flow and integrate new evaluation parameters.
- Enhanced `ProgressionOptimizeCompareModal` to reflect changes in comparison logic, allowing for better user interaction with proposed path suggestions.
- Bumped version to reflect the new features and improvements.
This commit is contained in:
Lars 2026-06-13 08:17:59 +02:00
parent 69ce3f6975
commit 3f130aa8ad
5 changed files with 58 additions and 144 deletions

View File

@ -2448,18 +2448,16 @@ def suggest_progression_path(
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Pfad-Vorschläge abrufen") raise HTTPException(status_code=403, detail="Nur Trainer dürfen Pfad-Vorschläge abrufen")
if body.compare_with_assignments: if body.compare_with_assignments:
assignments = _slot_assignments_by_major_index(body.slot_assignments) eval_source = list(body.evaluate_steps or body.slot_assignments or [])
if len(assignments) < 1: if len(eval_source) < 1:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="compare_with_assignments erfordert mindestens ein slot_assignment", detail="compare_with_assignments erfordert evaluate_steps",
) )
baseline_body = body.model_copy( baseline_body = body.model_copy(
update={ update={
"evaluate_only": True, "evaluate_only": True,
"evaluate_steps": list( "evaluate_steps": eval_source,
body.evaluate_steps or body.slot_assignments or []
),
"compare_with_assignments": False, "compare_with_assignments": False,
"preserve_slot_assignments": False, "preserve_slot_assignments": False,
# Gleiche QS-Pipeline wie „Graph bewerten“ (kein Match/Rematch-Schönung) # Gleiche QS-Pipeline wie „Graph bewerten“ (kein Match/Rematch-Schönung)
@ -2472,7 +2470,7 @@ def suggest_progression_path(
proposed_body = body.model_copy( proposed_body = body.model_copy(
update={ update={
"compare_with_assignments": False, "compare_with_assignments": False,
"preserve_slot_assignments": True, "preserve_slot_assignments": False,
"evaluate_only": False, "evaluate_only": False,
} }
) )

View File

@ -350,8 +350,8 @@ export default function ProgressionFindingsPanel({
</strong> </strong>
{pathQa.assignments_preserved ? ( {pathQa.assignments_preserved ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)', fontSize: '11px' }}> <p style={{ margin: '6px 0 0', color: 'var(--text2)', fontSize: '11px' }}>
Bewertung des aktuellen Pfads keine automatische Umzuordnung. Bewertung des aktuellen Pfads. Übungen matchen öffnet einen Dialog mit Vorschlägen für
{showOptimizeCompare ? ' Match prüft zusätzlich Optimierungsvorschläge im Vergleich.' : ''} alle Slots Übernahme nur nach deiner Auswahl.
</p> </p>
) : null} ) : null}
{strongResult ? ( {strongResult ? (

View File

@ -27,11 +27,11 @@ import {
addSlotToDraft, addSlotToDraft,
applyEvaluateResponseToDraft, applyEvaluateResponseToDraft,
applyGapOfferToDraft, applyGapOfferToDraft,
applyMatchResponseToDraft,
applySelectedCompareSteps, applySelectedCompareSteps,
compareResponseHasActionableSlotChanges,
compareResponseHadRematchWithoutActionableDiffs,
compareSlotDiffs, compareSlotDiffs,
collectGapOffersFromApiResponse,
dedupeGapOffersBySlot,
filterGapOffersForUnfilledSlots,
pathQaQualityPercent, pathQaQualityPercent,
applyResolvedStructuredToDraft, applyResolvedStructuredToDraft,
buildPlanningArtifactFromDraft, buildPlanningArtifactFromDraft,
@ -120,6 +120,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
const [activePlanningContextLines, setActivePlanningContextLines] = useState([]) const [activePlanningContextLines, setActivePlanningContextLines] = useState([])
const [compareOpen, setCompareOpen] = useState(false) const [compareOpen, setCompareOpen] = useState(false)
const [comparePayload, setComparePayload] = useState(null) const [comparePayload, setComparePayload] = useState(null)
const [compareSource, setCompareSource] = useState('manual')
const [comparing, setComparing] = useState(false) const [comparing, setComparing] = useState(false)
const [compareApplying, setCompareApplying] = useState(false) const [compareApplying, setCompareApplying] = useState(false)
const [proposedPathQa, setProposedPathQa] = useState(null) const [proposedPathQa, setProposedPathQa] = useState(null)
@ -487,11 +488,10 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
} }
} }
const fetchOptimizeCompare = async (synced) => { const fetchMatchCompare = async (synced) => {
const res = await api.suggestProgressionPath({ const res = await api.suggestProgressionPath({
...buildMatchRequestBase(synced), ...buildMatchRequestBase(synced),
evaluate_steps: slotsToEvaluateSteps(synced), evaluate_steps: slotsToEvaluateSteps(synced),
preserve_slot_assignments: true,
compare_with_assignments: true, compare_with_assignments: true,
}) })
if (!res?.comparison_mode) { if (!res?.comparison_mode) {
@ -500,44 +500,43 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
return res return res
} }
const presentOptimizeCompare = (res, { source = 'manual' } = {}) => { const gapOffersFromMatchResponse = (synced, res) =>
filterGapOffersForUnfilledSlots(
synced,
dedupeGapOffersBySlot(collectGapOffersFromApiResponse(res), synced),
)
const presentMatchCompare = (res, { source = 'manual' } = {}) => {
setSemanticBrief(res?.semantic_brief_summary || null) setSemanticBrief(res?.semantic_brief_summary || null)
setTargetSummary(res?.target_profile_summary || null) setTargetSummary(res?.target_profile_summary || null)
setComparePayload(res)
setCompareSource(source)
setProposedPathQa(res?.proposed_path_qa_pipeline || null)
setCompareOpen(true)
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 actionableCount = const diffCount =
res?.slot_diff_count ?? compareSlotDiffs(res, { actionableOnly: true }).length res?.slot_diff_count ?? compareSlotDiffs(res, { actionableOnly: true }).length
const bPct = pathQaQualityPercent(baselineQa)
const openCompareDialog = (diffCount, noticePrefix) => { const pPct = pathQaQualityPercent(proposedQa)
setComparePayload(res) let notice =
setProposedPathQa(res?.proposed_path_qa_pipeline || null) diffCount > 0
setCompareOpen(true) ? `Match: ${diffCount} Slot-Vorschlag/Vorschläge — bitte im Dialog prüfen und auswählen.`
const bPct = pathQaQualityPercent(baselineQa) : 'Match: Keine abweichenden Slot-Vorschläge — Dialog zur Kontrolle geöffnet.'
const pPct = pathQaQualityPercent(proposedQa) if (bPct != null && pPct != null && pPct !== bPct) {
let notice = diffCount > 0 notice += ` Pfad-QS Vorschlag fair bewertet: ${bPct} % → ${pPct} %.`
? `${noticePrefix}${diffCount} Slot-Anpassung(en) — Vergleichsdialog geöffnet.`
: 'Vergleichsdialog geöffnet — keine übernehmbaren Slot-Änderungen im End-Pfad.'
if (bPct != null && pPct != null && pPct !== bPct) {
notice += ` Vorschlag fair bewertet: ${bPct} % → ${pPct} %.`
}
setMatchNotice(notice)
} }
setMatchNotice(notice)
}
if (source === 'match') { const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => {
if (compareResponseHasActionableSlotChanges(res)) { const res = await fetchMatchCompare(synced)
openCompareDialog(actionableCount, 'KI schlägt ') setGapFillOffers(gapOffersFromMatchResponse(synced, res))
return { opened: true, res } presentMatchCompare(res, { source })
} const evalRes = await fetchPathEvaluate(synced)
setProposedPathQa(null) setPathQa(evalRes?.path_qa || res?.baseline_path_qa || null)
setComparePayload(null) return res
return { opened: false, res }
}
openCompareDialog(
actionableCount,
compareResponseHasActionableSlotChanges(res) ? 'KI schlägt ' : '',
)
return { opened: true, res }
} }
const runMatch = async () => { const runMatch = async () => {
@ -555,94 +554,8 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
setMatchNotice('') setMatchNotice('')
try { try {
const synced = syncProgressionRoadmapFromSlots(draft) const synced = syncProgressionRoadmapFromSlots(draft)
const hasAssignments = draftHasLibrarySlotAssignments(synced)
setProposedPathQa(null) setProposedPathQa(null)
await runMatchCompareFlow(synced, { source: 'match' })
if (hasAssignments) {
const res = await fetchOptimizeCompare(synced)
const { opened, res: compareRes } = presentOptimizeCompare(res, { source: 'match' })
if (opened) {
const evalRes = await fetchPathEvaluate(synced)
setPathQa(evalRes?.path_qa || compareRes?.baseline_path_qa || null)
return
}
const evalRes = await fetchPathEvaluate(synced)
const { draft: evaluated, remainingOffers } = applyEvaluateResult(synced, evalRes)
setDraft(evaluated)
setGapFillOffers(remainingOffers)
setProposedPathQa(null)
setComparePayload(null)
const evalPct = pathQaQualityPercent(evalRes?.path_qa)
if (compareResponseHadRematchWithoutActionableDiffs(compareRes)) {
setMatchNotice(
`Match: Auto-Rematch ohne übernehmbare End-Änderung — dein Pfad bleibt unverändert${
evalPct != null ? ` (Pfad-QS ${evalPct} %).` : '.'
}`,
)
} else {
setMatchNotice(
`Match: Kein inhaltlicher Optimierungsvorschlag — Pfad unverändert${
evalPct != null ? ` (${evalPct} %).` : '.'
}`,
)
}
try {
await saveProgressionGraphDraft(api, graphId, {
...evaluated,
lastFindings: evalRes?.path_qa || null,
})
setDraft((prev) => (prev ? { ...prev, dirty: false } : prev))
} catch (saveErr) {
console.warn('Match-Artefakt konnte nicht gespeichert werden', saveErr)
}
return
}
const res = await api.suggestProgressionPath({
...buildMatchRequestBase(synced),
preserve_slot_assignments: false,
})
const { draft: matched, remainingOffers } = applyMatchResponseToDraft(
{
...synced,
progressionRoadmap: res?.progression_roadmap || synced.progressionRoadmap,
pathSkillExpectations: res?.path_skill_expectations || synced.pathSkillExpectations,
},
res,
)
setDraft(matched)
setSemanticBrief(res?.semantic_brief_summary || null)
setTargetSummary(res?.target_profile_summary || null)
setPathQa(res?.path_qa || null)
setGapFillOffers(remainingOffers)
const ms = res?.match_summary
const rematchLog = res?.path_qa?.rematch_log
const rematchRounds = res?.path_qa?.rematch_rounds
if (ms) {
const parts = [
`Match: ${ms.library_matches ?? 0}/${ms.slot_count ?? '?'} Slots aus Bibliothek, ${ms.gap_fill_offer_count ?? 0} KI-Angebote.`,
]
if (rematchRounds > 0 && Array.isArray(rematchLog) && rematchLog.length > 0) {
parts.push(
`Auto-Rematch: ${rematchLog.length} Anpassung(en) in ${rematchRounds} Runde(n).`,
)
}
const refineLog = res?.path_qa?.refine_log
if (Array.isArray(refineLog) && refineLog.length > 0) {
parts.push(`Stufen-Spec: ${refineLog.length} Slot(s) verfeinert.`)
}
setMatchNotice(parts.join(' '))
}
try {
await saveProgressionGraphDraft(api, graphId, {
...matched,
lastFindings: res?.path_qa || null,
})
setDraft((prev) => (prev ? { ...prev, dirty: false } : prev))
} catch (saveErr) {
console.warn('Match-Artefakt konnte nicht gespeichert werden', saveErr)
}
} catch (e) { } catch (e) {
setActionErr(e.message || 'Übungs-Match fehlgeschlagen') setActionErr(e.message || 'Übungs-Match fehlgeschlagen')
} finally { } finally {
@ -656,8 +569,8 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
alert('Ziel-Anfrage: mindestens 3 Zeichen.') alert('Ziel-Anfrage: mindestens 3 Zeichen.')
return return
} }
if (!draftHasLibrarySlotAssignments(draft)) { if (validMajorSteps.length < 2) {
alert('Mindestens ein Slot mit Bibliotheks-Übung nötig für einen Vergleich.') alert('Mindestens zwei Slots mit Lernziel (je 3+ Zeichen) nötig.')
return return
} }
setComparing(true) setComparing(true)
@ -665,10 +578,8 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
setMatchNotice('') setMatchNotice('')
try { try {
const synced = syncProgressionRoadmapFromSlots(draft) const synced = syncProgressionRoadmapFromSlots(draft)
const res = await fetchOptimizeCompare(synced) setProposedPathQa(null)
presentOptimizeCompare(res, { source: 'manual' }) await runMatchCompareFlow(synced, { source: 'manual' })
const evalRes = await fetchPathEvaluate(synced)
setPathQa(evalRes?.path_qa || res?.baseline_path_qa || null)
} catch (e) { } catch (e) {
setActionErr(e.message || 'Optimierungs-Vergleich fehlgeschlagen') setActionErr(e.message || 'Optimierungs-Vergleich fehlgeschlagen')
} finally { } finally {
@ -1217,7 +1128,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
onGenerateGapAi={openGapFillPrep} onGenerateGapAi={openGapFillPrep}
onRematchSlots={runMatch} onRematchSlots={runMatch}
onOptimizeCompare={runOptimizeCompare} onOptimizeCompare={runOptimizeCompare}
canOptimizeCompare={draftHasLibrarySlotAssignments(draft)} canOptimizeCompare={validMajorSteps.length >= 2}
optimizeCompareBusy={comparing} optimizeCompareBusy={comparing}
rematchBusy={matching} rematchBusy={matching}
generatingOfferId={generatingOfferId} generatingOfferId={generatingOfferId}
@ -1261,6 +1172,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
<ProgressionOptimizeCompareModal <ProgressionOptimizeCompareModal
open={compareOpen} open={compareOpen}
comparison={comparePayload} comparison={comparePayload}
mode={compareSource}
onClose={() => { onClose={() => {
if (compareApplying) return if (compareApplying) return
setCompareOpen(false) setCompareOpen(false)

View File

@ -14,6 +14,7 @@ function qaLabel(pathQa) {
export default function ProgressionOptimizeCompareModal({ export default function ProgressionOptimizeCompareModal({
open, open,
comparison, comparison,
mode = 'manual',
onClose, onClose,
onApplySelected, onApplySelected,
applying = false, applying = false,
@ -63,12 +64,16 @@ export default function ProgressionOptimizeCompareModal({
setSelected(on ? new Set(allKeys) : new Set()) setSelected(on ? new Set(allKeys) : new Set())
} }
const title =
mode === 'match' ? 'Übungs-Match — Vorschläge prüfen' : 'Optimierung vergleichen'
return ( return (
<div <div
className="modal-overlay" className="modal-overlay"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="optimize-compare-title" aria-labelledby="optimize-compare-title"
style={{ zIndex: 2200 }}
onClick={(e) => { onClick={(e) => {
if (e.target === e.currentTarget && !applying) onClose() if (e.target === e.currentTarget && !applying) onClose()
}} }}
@ -79,12 +84,11 @@ export default function ProgressionOptimizeCompareModal({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<h3 id="optimize-compare-title" style={{ marginTop: 0 }}> <h3 id="optimize-compare-title" style={{ marginTop: 0 }}>
Optimierung vergleichen {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 }}>
Vergleicht deinen Pfad mit dem Match-Vorschlag beide Seiten mit derselben Bewertungslogik KI matcht alle Lernziel-Slots neu (auch bereits belegte). Du wählst im Dialog, welche
wie Graph bewerten. Prozentwerte beziehen sich auf den übernehmbaren End-Stand, nicht auf Vorschläge übernommen werden der Graph ändert sich erst nach Auswahl übernehmen.
das Auto-Rematch-Protokoll.
</p> </p>
{noMeaningfulDiffs || proposedNotBetter ? ( {noMeaningfulDiffs || proposedNotBetter ? (

View File

@ -906,7 +906,7 @@ export function slotsToSlotAssignments(draft) {
})) }))
} }
/** Mindestens ein Bibliotheks-Slot belegt → kuratierter Stand, Match prüft Abweichungen. */ /** Mindestens ein Bibliotheks-Slot belegt. */
export function draftHasLibrarySlotAssignments(draft) { export function draftHasLibrarySlotAssignments(draft) {
return slotsToSlotAssignments(draft).length >= 1 return slotsToSlotAssignments(draft).length >= 1
} }