progression V2 #57

Merged
Lars merged 20 commits from develop into main 2026-06-13 16:34:09 +02:00
4 changed files with 228 additions and 41 deletions
Showing only changes of commit 5bca5ef9eb - Show all commits

View File

@ -2253,9 +2253,15 @@ def suggest_progression_path(
baseline_body = body.model_copy(
update={
"evaluate_only": True,
"evaluate_steps": list(body.slot_assignments or []),
"evaluate_steps": list(
body.evaluate_steps or body.slot_assignments or []
),
"compare_with_assignments": False,
"preserve_slot_assignments": True,
"preserve_slot_assignments": False,
# Gleiche QS-Pipeline wie „Graph bewerten“ (kein Match/Rematch-Schönung)
"include_llm_intent": False,
"auto_rematch_after_qa": False,
"include_roadmap_preview": False,
}
)
baseline = suggest_progression_path(cur, tenant=tenant, body=baseline_body)

View File

@ -26,6 +26,124 @@ function severityStyle(pathQa) {
}
}
function PathQaPipelineDetails({ pathQa, draft, title, compact = false }) {
const { fixHints: optimizationHints } = useMemo(
() => splitPathQaHints(pathQa),
[pathQa],
)
const rematchLog = Array.isArray(pathQa?.rematch_log) ? pathQa.rematch_log : []
const refineLog = Array.isArray(pathQa?.refine_log) ? pathQa.refine_log : []
const qaTiers = Array.isArray(pathQa?.qa_tiers) ? pathQa.qa_tiers : []
const qualityPct = pathQaQualityPercent(pathQa)
const hasContent =
qaTiers.length > 0
|| (pathQa?.rematch_applied && rematchLog.length > 0)
|| (pathQa?.refine_applied && refineLog.length > 0)
|| optimizationHints.length > 0
if (!pathQa || !hasContent) return null
return (
<div
style={{
marginTop: compact ? '10px' : '12px',
padding: compact ? '8px 10px' : '10px 12px',
borderRadius: '8px',
border: '1px solid color-mix(in srgb, var(--accent) 30%, var(--border))',
background: 'color-mix(in srgb, var(--accent) 6%, var(--surface2))',
}}
>
<strong style={{ fontSize: compact ? '11px' : '12px' }}>
{title}
{qualityPct != null ? ` · ${pathQa.overall_ok ? 'OK' : 'Hinweise'} (${qualityPct} %)` : ''}
</strong>
{qaTiers.length > 0 ? (
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)', fontSize: '11px' }}>
{qaTiers.map((tier) => (
<li key={tier.id || tier.label}>
{tier.label || tier.id}
{tier.finding_count != null ? ` (${tier.finding_count})` : ''}
{tier.applied === false ? ' · LLM aus' : ''}
</li>
))}
</ul>
) : null}
{pathQa.rematch_applied && rematchLog.length > 0 ? (
<>
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)', fontSize: '11px' }}>
Auto-Rematch
{pathQa.rematch_rounds != null ? ` (${pathQa.rematch_rounds} Runde(n))` : ''}
</p>
<ul style={{ margin: 0, paddingLeft: '16px', color: 'var(--text2)', fontSize: '11px' }}>
{rematchLog.map((entry, i) => (
<li key={`rematch-${i}-${entry.roadmap_major_step_index}-${entry.action}`}>
{formatRematchLogEntry(entry)}
</li>
))}
</ul>
</>
) : null}
{pathQa.refine_applied && refineLog.length > 0 ? (
<>
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)', fontSize: '11px' }}>
Stufen-Spec verfeinert ({refineLog.length})
</p>
<ul style={{ margin: 0, paddingLeft: '16px', color: 'var(--text2)', fontSize: '11px' }}>
{refineLog.map((entry, i) => (
<li key={`refine-${i}-${entry.roadmap_major_step_index}`}>
{formatRefineLogEntry(entry)}
</li>
))}
</ul>
</>
) : null}
{optimizationHints.length > 0 ? (
<>
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)', fontSize: '11px' }}>
Handlungsbedarf ({optimizationHints.length})
</p>
<ul
style={{
margin: 0,
padding: 0,
listStyle: 'none',
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}
>
{optimizationHints.slice(0, compact ? 4 : 8).map((hint, i) => {
const slotIdx = resolveHintSlotIndex(hint, draft)
return (
<li
key={`hint-${i}-${hint.action}-${hint.issue}-${slotIdx ?? 'x'}`}
style={{
padding: '6px 8px',
borderRadius: '6px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
fontSize: '11px',
color: 'var(--text2)',
}}
>
<span className="exercise-tag" style={{ marginBottom: '4px', display: 'inline-block' }}>
{optimizationHintActionLabel(hint.action)}
{slotIdx != null ? ` · Slot ${slotIdx + 1}` : ''}
</span>
{hint.title ? (
<div style={{ fontWeight: 600, color: 'var(--text1)' }}>{hint.title}</div>
) : null}
{hint.reason ? <p style={{ margin: '4px 0 0' }}>{hint.reason}</p> : null}
</li>
)
})}
</ul>
</>
) : null}
</div>
)
}
function GapOfferCard({
offer,
slotCount,
@ -163,6 +281,7 @@ export default function ProgressionFindingsPanel({
onGenerateGapAi,
onRematchSlots = null,
onOptimizeCompare = null,
optimizationPreviewQa = null,
canOptimizeCompare = false,
optimizeCompareBusy = false,
rematchBusy = false,
@ -187,7 +306,8 @@ export default function ProgressionFindingsPanel({
<div className="card" style={{ position: 'sticky', top: '12px' }}>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Graph-Bewertung</h3>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
Prüft den Slot-Stand und listet KI-Angebote für leere Stufen und Lücken.
Bewertet den aktuellen Slot-Stand (3-Stufen-QS, ohne Auto-Rematch) dieselbe Logik nach Match,
solange keine Zuordnung geändert wurde.
</p>
<button
@ -222,8 +342,8 @@ export default function ProgressionFindingsPanel({
</strong>
{pathQa.assignments_preserved ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)', fontSize: '11px' }}>
Bestehende Slot-Zuordnungen beibehalten QS wie Graph bewerten, ohne Auto-Rematch.
{showOptimizeCompare ? ' „Übungen matchen“ oder „Optimierung vergleichen“ prüft Alternativen.' : ''}
Bewertung des aktuellen Pfads keine automatische Umzuordnung.
{showOptimizeCompare ? ' Match prüft zusätzlich Optimierungsvorschläge im Vergleich.' : ''}
</p>
) : null}
{strongResult ? (
@ -386,6 +506,14 @@ export default function ProgressionFindingsPanel({
) : null}
</>
) : null}
{optimizationPreviewQa ? (
<PathQaPipelineDetails
pathQa={optimizationPreviewQa}
draft={draft}
title="3-Stufen-Optimierung (Vorschlag nach Match)"
compact
/>
) : null}
</div>
) : (
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>

View File

@ -122,6 +122,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
const [comparePayload, setComparePayload] = useState(null)
const [comparing, setComparing] = useState(false)
const [compareApplying, setCompareApplying] = useState(false)
const [proposedPathQa, setProposedPathQa] = useState(null)
const loadGraph = useCallback(async () => {
if (!graphId) return
@ -435,6 +436,35 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
}
}
const buildEvaluateRequest = (synced) => {
const override = majorStepsToOverridePayload(synced.slots)
return {
query: (synced.goalQuery || '').trim(),
max_steps: synced.slots.length || draft?.maxSteps || 5,
include_path_qa: true,
include_llm_path_qa: true,
include_ai_gap_fill: true,
include_path_reorder: false,
include_llm_intent: false,
evaluate_only: true,
evaluate_steps: slotsToEvaluateSteps(synced),
roadmap_override: override,
slot_assignments: slotsToSlotAssignments(synced),
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(synced.startSituation, synced.targetState, synced.roadmapNotes),
...catalogApiPayload,
}
}
const fetchPathEvaluate = async (synced) => api.suggestProgressionPath(buildEvaluateRequest(synced))
const applyEvaluateResult = (synced, res) => {
setSemanticBrief(res?.semantic_brief_summary || null)
setPathQa(res?.path_qa || null)
const { draft: evaluated, remainingOffers } = applyEvaluateResponseToDraft(synced, res)
return { draft: { ...evaluated, lastFindings: res?.path_qa || null }, remainingOffers }
}
const buildMatchRequestBase = (synced) => {
const override = majorStepsToOverridePayload(synced.slots)
return {
@ -460,6 +490,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
const fetchOptimizeCompare = async (synced) => {
const res = await api.suggestProgressionPath({
...buildMatchRequestBase(synced),
evaluate_steps: slotsToEvaluateSteps(synced),
preserve_slot_assignments: false,
compare_with_assignments: true,
})
@ -475,6 +506,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
const baselineQa = res?.baseline_path_qa || null
const proposedQa = res?.proposed_path_qa || res?.path_qa || null
setPathQa(baselineQa)
setProposedPathQa(proposedQa)
const openCompareDialog = (diffCount, noticePrefix) => {
setComparePayload(res)
@ -521,11 +553,16 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
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) return
if (opened) {
const evalRes = await fetchPathEvaluate(synced)
setPathQa(evalRes?.path_qa || compareRes?.baseline_path_qa || null)
return
}
const { draft: matched, remainingOffers } = applyMatchResponseToDraft(
{
@ -536,19 +573,27 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
},
compareRes,
)
setDraft(matched)
setPathQa(compareRes?.proposed_path_qa || compareRes?.path_qa || null)
setGapFillOffers(remainingOffers)
const syncedMatched = syncProgressionRoadmapFromSlots(matched)
const evalRes = await fetchPathEvaluate(syncedMatched)
const { draft: evaluated, remainingOffers: evalOffers } = applyEvaluateResult(
syncedMatched,
evalRes,
)
setDraft(evaluated)
setGapFillOffers(evalOffers.length ? evalOffers : remainingOffers)
const evalPct = pathQaQualityPercent(evalRes?.path_qa)
const ms = compareRes?.match_summary
if (ms) {
setMatchNotice(
`Match: ${ms.library_matches ?? 0}/${ms.slot_count ?? '?'} Slots aus Bibliothek, ${ms.gap_fill_offer_count ?? 0} KI-Angebote. Bestehende Zuordnungen unverändert.`,
)
let notice = `Match: ${ms.library_matches ?? 0}/${ms.slot_count ?? '?'} Slots aus Bibliothek, ${ms.gap_fill_offer_count ?? 0} KI-Angebote.`
if (evalPct != null) {
notice += ` Pfad-QS (Bewertung): ${evalPct} %.`
}
setMatchNotice(notice)
}
try {
await saveProgressionGraphDraft(api, graphId, {
...matched,
lastFindings: compareRes?.proposed_path_qa || compareRes?.path_qa || null,
...evaluated,
lastFindings: evalRes?.path_qa || null,
})
setDraft((prev) => (prev ? { ...prev, dirty: false } : prev))
} catch (saveErr) {
@ -625,6 +670,8 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
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)
} catch (e) {
setActionErr(e.message || 'Optimierungs-Vergleich fehlgeschlagen')
} finally {
@ -642,15 +689,18 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
comparePayload.proposed_steps || comparePayload.steps,
selectedMajorIndices,
)
const proposedQa = comparePayload.proposed_path_qa || comparePayload.path_qa
setDraft({ ...nextDraft, lastFindings: proposedQa || null })
setPathQa(proposedQa || null)
const syncedNext = syncProgressionRoadmapFromSlots(nextDraft)
const evalRes = await fetchPathEvaluate(syncedNext)
const { draft: evaluated, remainingOffers } = applyEvaluateResult(syncedNext, evalRes)
setDraft({ ...evaluated, dirty: true })
setGapFillOffers(remainingOffers)
setProposedPathQa(null)
setCompareOpen(false)
setComparePayload(null)
setMatchNotice('Ausgewählte Optimierungen übernommen.')
setMatchNotice('Ausgewählte Optimierungen übernommen — Pfad-QS neu bewertet.')
await saveProgressionGraphDraft(api, graphId, {
...nextDraft,
lastFindings: proposedQa || null,
...evaluated,
lastFindings: evalRes?.path_qa || null,
})
setDraft((prev) => (prev ? { ...prev, dirty: false } : prev))
} catch (e) {
@ -668,29 +718,12 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
}
setEvaluating(true)
setActionErr('')
setProposedPathQa(null)
try {
const synced = syncProgressionRoadmapFromSlots(draft)
const override =
validMajorSteps.length >= 2 ? majorStepsToOverridePayload(synced.slots) : undefined
const res = await api.suggestProgressionPath({
query: q,
max_steps: synced.slots.length || draft.maxSteps || 5,
include_path_qa: true,
include_llm_path_qa: true,
include_ai_gap_fill: true,
include_path_reorder: false,
include_llm_intent: false,
evaluate_only: true,
evaluate_steps: slotsToEvaluateSteps(synced),
roadmap_override: override,
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
...catalogApiPayload,
})
setSemanticBrief(res?.semantic_brief_summary || null)
setPathQa(res?.path_qa || null)
const { draft: evaluated, remainingOffers } = applyEvaluateResponseToDraft(synced, res)
setDraft({ ...evaluated, lastFindings: res?.path_qa || null })
const res = await fetchPathEvaluate(synced)
const { draft: evaluated, remainingOffers } = applyEvaluateResult(synced, res)
setDraft(evaluated)
setGapFillOffers(remainingOffers)
} catch (e) {
setActionErr(e.message || 'Bewertung fehlgeschlagen')
@ -1187,6 +1220,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
onGenerateGapAi={openGapFillPrep}
onRematchSlots={runMatch}
onOptimizeCompare={runOptimizeCompare}
optimizationPreviewQa={proposedPathQa}
canOptimizeCompare={draftHasLibrarySlotAssignments(draft)}
optimizeCompareBusy={comparing}
rematchBusy={matching}
@ -1235,6 +1269,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
if (compareApplying) return
setCompareOpen(false)
setComparePayload(null)
setProposedPathQa(null)
}}
onApplySelected={applyOptimizeCompare}
applying={compareApplying}

View File

@ -37,6 +37,11 @@ export default function ProgressionOptimizeCompareModal({
const proposedQa = comparison.proposed_path_qa || comparison.path_qa
const baselinePct = pathQaQualityPercent(baselineQa)
const proposedPct = pathQaQualityPercent(proposedQa)
const rematchRounds = proposedQa?.rematch_rounds
const rematchCount = Array.isArray(proposedQa?.rematch_log) ? proposedQa.rematch_log.length : 0
const refineCount = Array.isArray(proposedQa?.refine_log) ? proposedQa.refine_log.length : 0
const hintCount = Number(proposedQa?.optimization_hint_count || 0)
const tierCount = Array.isArray(proposedQa?.qa_tiers) ? proposedQa.qa_tiers.length : 0
const toggle = (midx) => {
setSelected((prev) => {
@ -120,6 +125,19 @@ export default function ProgressionOptimizeCompareModal({
</div>
</div>
{tierCount > 0 || rematchCount > 0 || refineCount > 0 || hintCount > 0 ? (
<p style={{ fontSize: '11px', color: 'var(--text2)', margin: '0 0 14px', lineHeight: 1.45 }}>
3-Stufen-Optimierung im Vorschlag
{tierCount > 0 ? ` · ${tierCount} QS-Stufen` : ''}
{rematchCount > 0
? ` · Auto-Rematch ${rematchRounds != null ? `(${rematchRounds} Runde(n))` : ''}: ${rematchCount} Anpassung(en)`
: ''}
{refineCount > 0 ? ` · ${refineCount} Stufen-Spec verfeinert` : ''}
{hintCount > 0 ? ` · ${hintCount} Handlungshinweis(e)` : ''}
. Details im Panel Graph-Bewertung.
</p>
) : null}
{slotDiffs.length === 0 ? (
<p style={{ fontSize: '12px', color: 'var(--text2)' }}>
Keine abweichenden Slot-Zuordnungen der optimierte Lauf liefert denselben Pfad.