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")
if body.compare_with_assignments:
assignments = _slot_assignments_by_major_index(body.slot_assignments)
if len(assignments) < 1:
eval_source = list(body.evaluate_steps or body.slot_assignments or [])
if len(eval_source) < 1:
raise HTTPException(
status_code=400,
detail="compare_with_assignments erfordert mindestens ein slot_assignment",
detail="compare_with_assignments erfordert evaluate_steps",
)
baseline_body = body.model_copy(
update={
"evaluate_only": True,
"evaluate_steps": list(
body.evaluate_steps or body.slot_assignments or []
),
"evaluate_steps": eval_source,
"compare_with_assignments": False,
"preserve_slot_assignments": False,
# Gleiche QS-Pipeline wie „Graph bewerten“ (kein Match/Rematch-Schönung)
@ -2472,7 +2470,7 @@ def suggest_progression_path(
proposed_body = body.model_copy(
update={
"compare_with_assignments": False,
"preserve_slot_assignments": True,
"preserve_slot_assignments": False,
"evaluate_only": False,
}
)

View File

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

View File

@ -27,11 +27,11 @@ import {
addSlotToDraft,
applyEvaluateResponseToDraft,
applyGapOfferToDraft,
applyMatchResponseToDraft,
applySelectedCompareSteps,
compareResponseHasActionableSlotChanges,
compareResponseHadRematchWithoutActionableDiffs,
compareSlotDiffs,
collectGapOffersFromApiResponse,
dedupeGapOffersBySlot,
filterGapOffersForUnfilledSlots,
pathQaQualityPercent,
applyResolvedStructuredToDraft,
buildPlanningArtifactFromDraft,
@ -120,6 +120,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
const [activePlanningContextLines, setActivePlanningContextLines] = useState([])
const [compareOpen, setCompareOpen] = useState(false)
const [comparePayload, setComparePayload] = useState(null)
const [compareSource, setCompareSource] = useState('manual')
const [comparing, setComparing] = useState(false)
const [compareApplying, setCompareApplying] = useState(false)
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({
...buildMatchRequestBase(synced),
evaluate_steps: slotsToEvaluateSteps(synced),
preserve_slot_assignments: true,
compare_with_assignments: true,
})
if (!res?.comparison_mode) {
@ -500,44 +500,43 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
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)
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 proposedQa = res?.proposed_path_qa || res?.path_qa || null
const actionableCount =
const diffCount =
res?.slot_diff_count ?? compareSlotDiffs(res, { actionableOnly: true }).length
const openCompareDialog = (diffCount, noticePrefix) => {
setComparePayload(res)
setProposedPathQa(res?.proposed_path_qa_pipeline || null)
setCompareOpen(true)
const bPct = pathQaQualityPercent(baselineQa)
const pPct = pathQaQualityPercent(proposedQa)
let notice = diffCount > 0
? `${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)
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 Slot-Vorschläge — Dialog zur Kontrolle geöffnet.'
if (bPct != null && pPct != null && pPct !== bPct) {
notice += ` Pfad-QS Vorschlag fair bewertet: ${bPct} % → ${pPct} %.`
}
setMatchNotice(notice)
}
if (source === 'match') {
if (compareResponseHasActionableSlotChanges(res)) {
openCompareDialog(actionableCount, 'KI schlägt ')
return { opened: true, res }
}
setProposedPathQa(null)
setComparePayload(null)
return { opened: false, res }
}
openCompareDialog(
actionableCount,
compareResponseHasActionableSlotChanges(res) ? 'KI schlägt ' : '',
)
return { opened: true, res }
const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => {
const res = await fetchMatchCompare(synced)
setGapFillOffers(gapOffersFromMatchResponse(synced, res))
presentMatchCompare(res, { source })
const evalRes = await fetchPathEvaluate(synced)
setPathQa(evalRes?.path_qa || res?.baseline_path_qa || null)
return res
}
const runMatch = async () => {
@ -555,94 +554,8 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
setMatchNotice('')
try {
const synced = syncProgressionRoadmapFromSlots(draft)
const hasAssignments = draftHasLibrarySlotAssignments(synced)
setProposedPathQa(null)
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)
}
await runMatchCompareFlow(synced, { source: 'match' })
} catch (e) {
setActionErr(e.message || 'Übungs-Match fehlgeschlagen')
} finally {
@ -656,8 +569,8 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
return
}
if (!draftHasLibrarySlotAssignments(draft)) {
alert('Mindestens ein Slot mit Bibliotheks-Übung nötig für einen Vergleich.')
if (validMajorSteps.length < 2) {
alert('Mindestens zwei Slots mit Lernziel (je 3+ Zeichen) nötig.')
return
}
setComparing(true)
@ -665,10 +578,8 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
setMatchNotice('')
try {
const synced = syncProgressionRoadmapFromSlots(draft)
const res = await fetchOptimizeCompare(synced)
presentOptimizeCompare(res, { source: 'manual' })
const evalRes = await fetchPathEvaluate(synced)
setPathQa(evalRes?.path_qa || res?.baseline_path_qa || null)
setProposedPathQa(null)
await runMatchCompareFlow(synced, { source: 'manual' })
} catch (e) {
setActionErr(e.message || 'Optimierungs-Vergleich fehlgeschlagen')
} finally {
@ -1217,7 +1128,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
onGenerateGapAi={openGapFillPrep}
onRematchSlots={runMatch}
onOptimizeCompare={runOptimizeCompare}
canOptimizeCompare={draftHasLibrarySlotAssignments(draft)}
canOptimizeCompare={validMajorSteps.length >= 2}
optimizeCompareBusy={comparing}
rematchBusy={matching}
generatingOfferId={generatingOfferId}
@ -1261,6 +1172,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
<ProgressionOptimizeCompareModal
open={compareOpen}
comparison={comparePayload}
mode={compareSource}
onClose={() => {
if (compareApplying) return
setCompareOpen(false)

View File

@ -14,6 +14,7 @@ function qaLabel(pathQa) {
export default function ProgressionOptimizeCompareModal({
open,
comparison,
mode = 'manual',
onClose,
onApplySelected,
applying = false,
@ -63,12 +64,16 @@ export default function ProgressionOptimizeCompareModal({
setSelected(on ? new Set(allKeys) : new Set())
}
const title =
mode === 'match' ? 'Übungs-Match — Vorschläge prüfen' : 'Optimierung vergleichen'
return (
<div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="optimize-compare-title"
style={{ zIndex: 2200 }}
onClick={(e) => {
if (e.target === e.currentTarget && !applying) onClose()
}}
@ -79,12 +84,11 @@ export default function ProgressionOptimizeCompareModal({
onClick={(e) => e.stopPropagation()}
>
<h3 id="optimize-compare-title" style={{ marginTop: 0 }}>
Optimierung vergleichen
{title}
</h3>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
Vergleicht deinen Pfad mit dem Match-Vorschlag beide Seiten mit derselben Bewertungslogik
wie Graph bewerten. Prozentwerte beziehen sich auf den übernehmbaren End-Stand, nicht auf
das Auto-Rematch-Protokoll.
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.
</p>
{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) {
return slotsToSlotAssignments(draft).length >= 1
}