progression V2 #57

Merged
Lars merged 20 commits from develop into main 2026-06-13 16:34:09 +02:00
2 changed files with 124 additions and 14 deletions
Showing only changes of commit cec96ae473 - Show all commits

View File

@ -28,6 +28,7 @@ import {
applyEvaluateResponseToDraft,
applyGapOfferToDraft,
applySelectedCompareSteps,
buildProgressionComparePayload,
compareSlotDiffs,
collectGapOffersFromApiResponse,
dedupeGapOffersBySlot,
@ -489,26 +490,26 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
}
}
const fetchMatchCompare = async (synced) => {
const res = await api.suggestProgressionPath({
const fetchFullMatch = async (synced) =>
api.suggestProgressionPath({
...buildMatchRequestBase(synced),
evaluate_steps: slotsToEvaluateSteps(synced),
compare_with_assignments: true,
preserve_slot_assignments: false,
include_llm_intent: 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 res = await fetchMatchCompare(synced)
setGapFillOffers(mergeGapOffersForDraft(synced, res))
presentMatchCompare(res, { source })
setPathQa(res?.baseline_path_qa || null)
return res
setMatchNotice('Schritt 1/2: Aktuellen Pfad bewerten…')
const baselineRes = await fetchPathEvaluate(synced)
setPathQa(baselineRes?.path_qa || null)
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' } = {}) => {

View File

@ -928,6 +928,115 @@ export function draftHasLibrarySlotAssignments(draft) {
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). */
export function compareSlotDiffs(comparison, { actionableOnly = false } = {}) {
if (actionableOnly && Array.isArray(comparison?.slot_diffs_actionable)) {