From 3f130aa8ad18e2c80253dc2663d6091e56a13fab Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 13 Jun 2026 08:17:59 +0200 Subject: [PATCH] Refactor Progression Path Evaluation and Comparison Logic - 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. --- backend/planning_exercise_path_builder.py | 12 +- .../components/ProgressionFindingsPanel.jsx | 4 +- .../src/components/ProgressionGraphEditor.jsx | 172 +++++------------- .../ProgressionOptimizeCompareModal.jsx | 12 +- frontend/src/utils/progressionGraphDraft.js | 2 +- 5 files changed, 58 insertions(+), 144 deletions(-) diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 0448e2e..44b8638 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -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, } ) diff --git a/frontend/src/components/ProgressionFindingsPanel.jsx b/frontend/src/components/ProgressionFindingsPanel.jsx index b4919cb..162b04e 100644 --- a/frontend/src/components/ProgressionFindingsPanel.jsx +++ b/frontend/src/components/ProgressionFindingsPanel.jsx @@ -350,8 +350,8 @@ export default function ProgressionFindingsPanel({ {pathQa.assignments_preserved ? (

- 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.

) : null} {strongResult ? ( diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index f6376d1..f5c9bff 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -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 { if (compareApplying) return setCompareOpen(false) diff --git a/frontend/src/components/ProgressionOptimizeCompareModal.jsx b/frontend/src/components/ProgressionOptimizeCompareModal.jsx index 5b68fc1..7c0a08a 100644 --- a/frontend/src/components/ProgressionOptimizeCompareModal.jsx +++ b/frontend/src/components/ProgressionOptimizeCompareModal.jsx @@ -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 (
{ if (e.target === e.currentTarget && !applying) onClose() }} @@ -79,12 +84,11 @@ export default function ProgressionOptimizeCompareModal({ onClick={(e) => e.stopPropagation()} >

- Optimierung vergleichen + {title}

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

{noMeaningfulDiffs || proposedNotBetter ? ( diff --git a/frontend/src/utils/progressionGraphDraft.js b/frontend/src/utils/progressionGraphDraft.js index d9d04eb..e7e46da 100644 --- a/frontend/src/utils/progressionGraphDraft.js +++ b/frontend/src/utils/progressionGraphDraft.js @@ -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 }