Enhance Progression Findings Panel with Rematch and Optimization Hints
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m13s

- Added support for displaying optimization hints and rematch logs in the Progression Findings Panel, improving user feedback on potential enhancements.
- Introduced new utility functions for formatting rematch log entries and resolving hint slot indices, enhancing clarity in displayed information.
- Updated the Progression Graph Editor to handle rematch actions and display relevant match summaries, ensuring comprehensive insights during progression analysis.
- Enhanced the utility functions to support the new features, ensuring robust handling of optimization actions and rematch logic.
This commit is contained in:
Lars 2026-06-11 21:39:16 +02:00
parent df93da9a03
commit de9fdf3ac0
3 changed files with 135 additions and 2 deletions

View File

@ -6,6 +6,10 @@ import {
offerCanExpandSlots,
offerNeedsNewSlot,
offerSourceLabel,
optimizationHintActionLabel,
formatRematchLogEntry,
hasRematchSlotHints,
resolveHintSlotIndex,
resolveOfferSlotIndex,
} from '../utils/progressionGraphDraft'
@ -153,10 +157,16 @@ export default function ProgressionFindingsPanel({
onApplyGapOffer,
onInsertGapSlot,
onGenerateGapAi,
onRematchSlots = null,
rematchBusy = false,
generatingOfferId = null,
aiBusy = false,
evaluateDisabled = false,
}) {
const optimizationHints = Array.isArray(pathQa?.optimization_hints) ? pathQa.optimization_hints : []
const rematchLog = Array.isArray(pathQa?.rematch_log) ? pathQa.rematch_log : []
const showRematchAction = hasRematchSlotHints(pathQa) && typeof onRematchSlots === 'function'
return (
<div className="card" style={{ position: 'sticky', top: '12px' }}>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Graph-Bewertung</h3>
@ -221,6 +231,75 @@ export default function ProgressionFindingsPanel({
{pathQa.off_topic_count} Schritt(e) ohne Bezug zum Pfad-Thema.
</p>
) : null}
{pathQa.rematch_applied && rematchLog.length > 0 ? (
<>
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)' }}>
Auto-Rematch
{pathQa.rematch_rounds != null ? ` (${pathQa.rematch_rounds} Runde(n))` : ''}
</p>
<ul style={{ margin: 0, paddingLeft: '16px', color: 'var(--text2)' }}>
{rematchLog.map((entry, i) => (
<li key={`rematch-${i}-${entry.roadmap_major_step_index}-${entry.action}`}>
{formatRematchLogEntry(entry)}
</li>
))}
</ul>
</>
) : null}
{optimizationHints.length > 0 ? (
<>
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)' }}>
Optimierungspotenziale ({optimizationHints.length})
</p>
<ul
style={{
margin: 0,
padding: 0,
listStyle: 'none',
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}
>
{optimizationHints.slice(0, 8).map((hint, i) => {
const slotIdx = resolveHintSlotIndex(hint, draft)
return (
<li
key={`hint-${i}-${hint.action}-${hint.issue}-${slotIdx ?? 'x'}`}
style={{
padding: '8px 10px',
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>
{showRematchAction ? (
<button
type="button"
className="btn btn-secondary btn-full"
style={{ marginTop: '8px', fontSize: '12px' }}
disabled={rematchBusy || evaluateDisabled}
onClick={onRematchSlots}
>
{rematchBusy ? 'Match läuft…' : 'Betroffene Slots neu matchen'}
</button>
) : null}
</>
) : null}
</div>
) : (
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>

View File

@ -435,11 +435,19 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
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) {
setMatchNotice(
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).`,
)
}
setMatchNotice(parts.join(' '))
}
try {
await saveProgressionGraphDraft(api, graphId, {
...matched,
@ -954,6 +962,8 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
onApplyGapOffer={handleApplyGapOffer}
onInsertGapSlot={handleInsertGapSlot}
onGenerateGapAi={openGapFillPrep}
onRematchSlots={runMatch}
rematchBusy={matching}
generatingOfferId={generatingOfferId}
aiBusy={gapAiBusy}
evaluateDisabled={busy || !draft.goalQuery?.trim()}

View File

@ -62,6 +62,50 @@ export function offerSourceLabel(source) {
return OFFER_SOURCE_LABELS[source] || source || 'Angebot'
}
const OPTIMIZATION_ACTION_LABELS = {
rematch_slot: 'Slot neu matchen',
bridge_or_gap_fill: 'Brücke / KI-Angebot',
refine_stage_spec: 'Stufen-Spec verfeinern',
review_roadmap: 'Roadmap prüfen',
review: 'Prüfen',
}
export function optimizationHintActionLabel(action) {
return OPTIMIZATION_ACTION_LABELS[action] || action || 'Hinweis'
}
/** Slot-Index aus optimization_hint (roadmap_major_step_index oder step_index). */
export function resolveHintSlotIndex(hint, draft = null) {
if (!hint || typeof hint !== 'object') return null
const raw = hint.roadmap_major_step_index ?? hint.step_index
if (raw == null || !Number.isFinite(Number(raw))) return null
const idx = Number(raw)
const slotCount = draft?.slots?.length
if (slotCount != null && (idx < 0 || idx >= slotCount)) return null
return idx
}
export function formatRematchLogEntry(entry) {
if (!entry || typeof entry !== 'object') return ''
const slot = Number.isFinite(Number(entry.roadmap_major_step_index))
? `Slot ${Number(entry.roadmap_major_step_index) + 1}`
: 'Slot'
const round = entry.round != null ? ` (Runde ${entry.round})` : ''
if (entry.action === 'replaced') {
const from = entry.replaced_title || (entry.replaced_exercise_id ? `#${entry.replaced_exercise_id}` : '—')
const to = entry.new_title || (entry.new_exercise_id ? `#${entry.new_exercise_id}` : '—')
return `${slot}${round}: „${from}“ → „${to}`
}
if (entry.action === 'rematch_unfilled') {
return `${slot}${round}: kein passender Ersatz (${entry.reason || 'unfilled'})`
}
return `${slot}${round}: ${entry.reason || entry.action || 'Rematch'}`
}
export function hasRematchSlotHints(pathQa) {
return (pathQa?.optimization_hints || []).some((h) => h?.action === 'rematch_slot')
}
function createEmptySlot(index) {
const phase = ROADMAP_PHASES[Math.min(index, ROADMAP_PHASES.length - 1)]
return {