Implement Progression Comparison Logic and Refactor Fetching Methods
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m13s

- Introduced `buildProgressionComparePayload` to create a structured comparison response from baseline and proposed evaluation results, enhancing clarity in slot differences.
- Refactored `fetchMatchCompare` to `fetchFullMatch` for improved clarity and functionality in fetching progression paths.
- Updated `runMatchCompareFlow` to streamline the evaluation process, integrating baseline and match results for a comprehensive comparison.
- Enhanced utility functions for managing slot differences and gap fill offers, improving overall data handling in the progression graph editor.
- Adjusted frontend components to reflect these changes, ensuring a more intuitive user experience in managing progression paths.
This commit is contained in:
Lars 2026-06-13 08:46:10 +02:00
parent 53f1c7161f
commit cec96ae473
2 changed files with 124 additions and 14 deletions

View File

@ -28,6 +28,7 @@ import {
applyEvaluateResponseToDraft, applyEvaluateResponseToDraft,
applyGapOfferToDraft, applyGapOfferToDraft,
applySelectedCompareSteps, applySelectedCompareSteps,
buildProgressionComparePayload,
compareSlotDiffs, compareSlotDiffs,
collectGapOffersFromApiResponse, collectGapOffersFromApiResponse,
dedupeGapOffersBySlot, dedupeGapOffersBySlot,
@ -489,26 +490,26 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
} }
} }
const fetchMatchCompare = async (synced) => { const fetchFullMatch = async (synced) =>
const res = await api.suggestProgressionPath({ api.suggestProgressionPath({
...buildMatchRequestBase(synced), ...buildMatchRequestBase(synced),
evaluate_steps: slotsToEvaluateSteps(synced), preserve_slot_assignments: false,
compare_with_assignments: true,
include_llm_intent: false, include_llm_intent: false,
include_llm_path_qa: false, include_llm_path_qa: false,
}) })
if (!res?.comparison_mode) {
throw new Error('Kein Vergleich in der Antwort')
}
return res
}
const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => { const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => {
const res = await fetchMatchCompare(synced) setMatchNotice('Schritt 1/2: Aktuellen Pfad bewerten…')
setGapFillOffers(mergeGapOffersForDraft(synced, res)) const baselineRes = await fetchPathEvaluate(synced)
presentMatchCompare(res, { source }) setPathQa(baselineRes?.path_qa || null)
setPathQa(res?.baseline_path_qa || null)
return res setMatchNotice('Schritt 2/2: Match für alle Slots (Bibliothek + Lücken)…')
const matchRes = await fetchFullMatch(synced)
const compareRes = buildProgressionComparePayload(baselineRes, matchRes)
setGapFillOffers(mergeGapOffersForDraft(synced, baselineRes, matchRes))
presentMatchCompare(compareRes, { source })
return compareRes
} }
const presentMatchCompare = (res, { source = 'manual' } = {}) => { const presentMatchCompare = (res, { source = 'manual' } = {}) => {

View File

@ -928,6 +928,115 @@ export function draftHasLibrarySlotAssignments(draft) {
return slotsToSlotAssignments(draft).length >= 1 return slotsToSlotAssignments(draft).length >= 1
} }
function normalizeCompareSlotTitle(title) {
return (title || '').trim().toLowerCase()
}
function stepsByMajorIndex(steps) {
const out = new Map()
for (const step of steps || []) {
if (step?.roadmap_major_step_index == null || !Number.isFinite(Number(step.roadmap_major_step_index))) {
continue
}
out.set(Number(step.roadmap_major_step_index), step)
}
return out
}
function buildProgressionSlotDiffs(baselineSteps, proposedSteps) {
const baseBy = stepsByMajorIndex(baselineSteps)
const propBy = stepsByMajorIndex(proposedSteps)
const indices = new Set([...baseBy.keys(), ...propBy.keys()])
const diffs = []
for (const midx of [...indices].sort((a, b) => a - b)) {
const base = baseBy.get(midx) || {}
const prop = propBy.get(midx) || {}
const baseId = base.exercise_id
const propId = prop.exercise_id
if (baseId != null && propId != null && Number(baseId) === Number(propId)) continue
const baseTitle = (base.title || '').trim() || null
const propTitle = (prop.title || '').trim() || null
diffs.push({
roadmap_major_step_index: midx,
baseline_exercise_id: baseId != null ? Number(baseId) : null,
baseline_title: baseTitle,
proposed_exercise_id: propId != null ? Number(propId) : null,
proposed_title: propTitle,
baseline_slot_status: base.slot_status,
proposed_slot_status: prop.slot_status,
changed: baseId !== propId || baseTitle !== propTitle,
})
}
return diffs
}
function annotateCompareSlotDiffs(diffs) {
return (diffs || []).map((raw) => {
const bt = normalizeCompareSlotTitle(raw.baseline_title)
const pt = normalizeCompareSlotTitle(raw.proposed_title)
return {
...raw,
trivial_id_swap: Boolean(bt && pt && bt === pt),
}
})
}
function actionableCompareSlotDiffs(diffs) {
return (diffs || []).filter((d) => !d.trivial_id_swap)
}
function mergeGapFillOffersFromSteps(steps, offers) {
const merged = (offers || []).map((o) => ({ ...o }))
const seen = new Set(merged.map((o) => o.offer_id).filter(Boolean))
for (const step of steps || []) {
const go = step?.gap_offer
if (!go || typeof go !== 'object') continue
if (go.offer_id && seen.has(go.offer_id)) continue
if (go.offer_id) seen.add(go.offer_id)
merged.push({ ...go })
}
return merged
}
/**
* Vergleich aus zwei kaskadierten Antworten (Evaluate Match) spiegelt Backend-Compare.
*/
export function buildProgressionComparePayload(baselineRes, proposedRes) {
const baselineSteps = Array.isArray(baselineRes?.steps) ? baselineRes.steps : []
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 actionableDiffs = actionableCompareSlotDiffs(slotDiffs)
const gapFillOffers = mergeGapFillOffersFromSteps(
proposedSteps,
proposedRes?.gap_fill_offers || [],
)
const proposedQa =
actionableDiffs.length === 0 && baselineQa ? baselineQa : pipelineQa
return {
...proposedRes,
comparison_mode: true,
baseline_steps: baselineSteps,
baseline_path_qa: baselineQa,
proposed_steps: proposedSteps,
proposed_steps_pipeline: proposedSteps,
proposed_path_qa: proposedQa,
proposed_path_qa_pipeline: pipelineQa,
gap_fill_offers: gapFillOffers,
slot_diffs: slotDiffs,
slot_diffs_actionable: actionableDiffs,
slot_diff_count: actionableDiffs.length,
slot_diff_count_including_trivial: slotDiffs.length,
slot_diffs_source: 'steps',
path_qa: proposedQa,
steps: proposedSteps,
}
}
/** Alle Slot-Diffs inkl. reiner ID-Tausche (gleicher Titel). */ /** Alle Slot-Diffs inkl. reiner ID-Tausche (gleicher Titel). */
export function compareSlotDiffs(comparison, { actionableOnly = false } = {}) { export function compareSlotDiffs(comparison, { actionableOnly = false } = {}) {
if (actionableOnly && Array.isArray(comparison?.slot_diffs_actionable)) { if (actionableOnly && Array.isArray(comparison?.slot_diffs_actionable)) {