progression V2 #57

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

View File

@ -141,6 +141,7 @@ class ProgressionPathSuggestRequest(BaseModel):
roadmap_notes: Optional[str] = Field(default=None, max_length=2000) roadmap_notes: Optional[str] = Field(default=None, max_length=2000)
progression_graph_id: Optional[int] = Field(default=None, ge=1) progression_graph_id: Optional[int] = Field(default=None, ge=1)
exercise_kind_any: Optional[List[str]] = None exercise_kind_any: Optional[List[str]] = None
compare_with_assignments: bool = False
planning_catalog_context: Optional[ProgressionPlanningCatalogContext] = None planning_catalog_context: Optional[ProgressionPlanningCatalogContext] = None
@ -676,6 +677,11 @@ def _slot_assignments_by_major_index(
return out return out
def _assignment_preservation_active(body: ProgressionPathSuggestRequest) -> bool:
"""Trainer-Pfad schützen — nur bei explizitem Flag (Frontend entscheidet pro Aktion)."""
return bool(body.preserve_slot_assignments)
def _path_step_from_slot_assignment( def _path_step_from_slot_assignment(
cur, cur,
*, *,
@ -1848,11 +1854,33 @@ def _build_steps_roadmap_first(
if roadmap_ctx.roadmap: if roadmap_ctx.roadmap:
majors_by_index = {m.index: m for m in roadmap_ctx.roadmap.major_steps} majors_by_index = {m.index: m for m in roadmap_ctx.roadmap.major_steps}
preserve_assignments = _assignment_preservation_active(body)
for step_index, stage_spec in enumerate(stage_specs): for step_index, stage_spec in enumerate(stage_specs):
major_idx = stage_spec.major_step_index major_idx = stage_spec.major_step_index
major = majors_by_index.get(major_idx) major = majors_by_index.get(major_idx)
slot_priority_id: Optional[int] = None slot_priority_id: Optional[int] = None
if preserve_assignments and major_idx in assignments:
direct = _path_step_from_slot_assignment(
cur,
assignment=assignments[major_idx],
stage_spec=stage_spec,
major_step=major,
tenant=tenant,
progression_graph_id=body.progression_graph_id,
)
if direct:
direct["slot_status"] = "preserved"
direct["roadmap_match_source"] = "slot_best_match"
steps.append(direct)
eid = int(direct["exercise_id"])
used.add(eid)
planned_ids.append(eid)
anchor_id = eid
anchor_variant_id = direct.get("variant_id")
continue
if major_idx in assignments: if major_idx in assignments:
try: try:
slot_priority_id = int(assignments[major_idx].exercise_id) slot_priority_id = int(assignments[major_idx].exercise_id)
@ -2123,6 +2151,88 @@ def _run_evaluate_only_path_qa(
} }
def _path_qa_quality_score(path_qa: Optional[Mapping[str, Any]]) -> Optional[float]:
if not path_qa:
return None
raw = path_qa.get("quality_score")
try:
return float(raw) if raw is not None else None
except (TypeError, ValueError):
return None
def _steps_by_major_index(steps: Sequence[Mapping[str, Any]]) -> Dict[int, Dict[str, Any]]:
out: Dict[int, Dict[str, Any]] = {}
for raw in steps or []:
if not isinstance(raw, dict):
continue
midx = raw.get("roadmap_major_step_index")
if midx is None:
continue
try:
out[int(midx)] = dict(raw)
except (TypeError, ValueError):
continue
return out
def _build_progression_slot_diffs(
baseline_steps: Sequence[Mapping[str, Any]],
proposed_steps: Sequence[Mapping[str, Any]],
) -> List[Dict[str, Any]]:
"""Gegenüberstellung je Roadmap-Slot — nur geänderte oder neu leere Slots."""
base_by = _steps_by_major_index(baseline_steps)
prop_by = _steps_by_major_index(proposed_steps)
diffs: List[Dict[str, Any]] = []
for midx in sorted(set(base_by.keys()) | set(prop_by.keys())):
base = base_by.get(midx, {})
prop = prop_by.get(midx, {})
base_id = base.get("exercise_id")
prop_id = prop.get("exercise_id")
base_title = (base.get("title") or "").strip() or None
prop_title = (prop.get("title") or "").strip() or None
if base_id is not None and prop_id is not None and int(base_id) == int(prop_id):
continue
diffs.append(
{
"roadmap_major_step_index": midx,
"baseline_exercise_id": int(base_id) if base_id is not None else None,
"baseline_title": base_title,
"proposed_exercise_id": int(prop_id) if prop_id is not None else None,
"proposed_title": prop_title,
"baseline_slot_status": base.get("slot_status"),
"proposed_slot_status": prop.get("slot_status"),
"changed": base_id != prop_id or base_title != prop_title,
}
)
return diffs
def _build_progression_compare_response(
baseline: Mapping[str, Any],
proposed: Mapping[str, Any],
) -> Dict[str, Any]:
baseline_steps = list(baseline.get("steps") or [])
proposed_steps = list(proposed.get("steps") or [])
baseline_qa = baseline.get("path_qa") if isinstance(baseline.get("path_qa"), dict) else {}
proposed_qa = proposed.get("path_qa") if isinstance(proposed.get("path_qa"), dict) else {}
slot_diffs = _build_progression_slot_diffs(baseline_steps, proposed_steps)
return {
**dict(proposed),
"comparison_mode": True,
"baseline_steps": baseline_steps,
"baseline_path_qa": baseline_qa,
"proposed_steps": proposed_steps,
"proposed_path_qa": proposed_qa,
"slot_diffs": slot_diffs,
"slot_diff_count": len(slot_diffs),
"baseline_quality_score": _path_qa_quality_score(baseline_qa),
"proposed_quality_score": _path_qa_quality_score(proposed_qa),
"path_qa": proposed_qa,
"steps": proposed_steps,
}
def suggest_progression_path( def suggest_progression_path(
cur, cur,
*, *,
@ -2133,6 +2243,32 @@ def suggest_progression_path(
if not _has_planning_role(role): if not _has_planning_role(role):
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Pfad-Vorschläge abrufen") 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:
raise HTTPException(
status_code=400,
detail="compare_with_assignments erfordert mindestens ein slot_assignment",
)
baseline_body = body.model_copy(
update={
"evaluate_only": True,
"evaluate_steps": list(body.slot_assignments or []),
"compare_with_assignments": False,
"preserve_slot_assignments": True,
}
)
baseline = suggest_progression_path(cur, tenant=tenant, body=baseline_body)
proposed_body = body.model_copy(
update={
"compare_with_assignments": False,
"preserve_slot_assignments": False,
"evaluate_only": False,
}
)
proposed = suggest_progression_path(cur, tenant=tenant, body=proposed_body)
return _build_progression_compare_response(baseline, proposed)
goal_query = _normalize_query(body.query) goal_query = _normalize_query(body.query)
if len(goal_query) < 3: if len(goal_query) < 3:
raise HTTPException(status_code=400, detail="Ziel-Anfrage: mindestens 3 Zeichen") raise HTTPException(status_code=400, detail="Ziel-Anfrage: mindestens 3 Zeichen")
@ -2431,6 +2567,7 @@ def suggest_progression_path(
reorder_notes: List[str] = [] reorder_notes: List[str] = []
roadmap_qa_mode: Optional[str] = None roadmap_qa_mode: Optional[str] = None
preserve_assignments = _assignment_preservation_active(body)
if body.include_path_qa: if body.include_path_qa:
if roadmap_first: if roadmap_first:
roadmap_qa_mode = "roadmap_first_lite" roadmap_qa_mode = "roadmap_first_lite"
@ -2466,7 +2603,9 @@ def suggest_progression_path(
elif gaps and roadmap_first: elif gaps and roadmap_first:
unfilled_gaps = list(gaps) unfilled_gaps = list(gaps)
if body.include_llm_path_qa and not roadmap_first: if body.include_llm_path_qa and (
not roadmap_first or preserve_assignments
):
llm_qa, llm_qa_applied = try_llm_qa_progression_path( llm_qa, llm_qa_applied = try_llm_qa_progression_path(
cur, cur,
goal_query=goal_query, goal_query=goal_query,
@ -2497,11 +2636,14 @@ def suggest_progression_path(
goal_query=goal_query, goal_query=goal_query,
) )
off_topic_before_strip = list(off_topic_steps) off_topic_before_strip = list(off_topic_steps)
steps, stripped_off_topic = strip_off_topic_steps_from_path( if preserve_assignments:
steps, stripped_off_topic = []
off_topic_steps, else:
min_remaining=0 if roadmap_first else 2, steps, stripped_off_topic = strip_off_topic_steps_from_path(
) steps,
off_topic_steps,
min_remaining=0 if roadmap_first else 2,
)
if stripped_off_topic: if stripped_off_topic:
off_topic_steps = [] off_topic_steps = []
gaps = detect_path_gaps( gaps = detect_path_gaps(
@ -2511,7 +2653,7 @@ def suggest_progression_path(
roadmap_first=roadmap_first, roadmap_first=roadmap_first,
) )
if roadmap_first and roadmap_ctx is not None: if roadmap_first and roadmap_ctx is not None and not preserve_assignments:
( (
steps, steps,
rematch_log, rematch_log,
@ -2545,7 +2687,7 @@ def suggest_progression_path(
roadmap_first=roadmap_first, roadmap_first=roadmap_first,
) )
if body.include_llm_path_qa and roadmap_first: if body.include_llm_path_qa and roadmap_first and not preserve_assignments:
gaps = detect_path_gaps( gaps = detect_path_gaps(
cur, cur,
steps, steps,
@ -2656,6 +2798,8 @@ def suggest_progression_path(
path_qa["refine_applied"] = True path_qa["refine_applied"] = True
path_qa["refine_log"] = refine_log path_qa["refine_log"] = refine_log
path_qa["refine_count"] = len(refine_log) path_qa["refine_count"] = len(refine_log)
if preserve_assignments:
path_qa["assignments_preserved"] = True
filled_library_steps = sum(1 for s in steps if s.get("exercise_id") is not None) filled_library_steps = sum(1 for s in steps if s.get("exercise_id") is not None)
match_summary = { match_summary = {

View File

@ -0,0 +1,31 @@
"""Tests Trainer-Pfad-Schutz (preserve_slot_assignments)."""
from planning_exercise_path_builder import (
EvaluateStepPayload,
ProgressionPathSuggestRequest,
_assignment_preservation_active,
)
def test_assignment_preservation_explicit_flag():
body = ProgressionPathSuggestRequest(
query="Kumite Beinarbeit Progression",
preserve_slot_assignments=True,
)
assert _assignment_preservation_active(body)
def test_assignment_preservation_not_auto_from_slot_assignments():
"""Nur explizites preserve_slot_assignments — sonst wäre compare/match blockiert."""
body = ProgressionPathSuggestRequest(
query="Kumite Beinarbeit Progression",
slot_assignments=[
EvaluateStepPayload(exercise_id=10, roadmap_major_step_index=0),
EvaluateStepPayload(exercise_id=11, roadmap_major_step_index=1),
],
)
assert not _assignment_preservation_active(body)
def test_assignment_preservation_inactive_without_assignments():
body = ProgressionPathSuggestRequest(query="Kumite Beinarbeit Progression")
assert not _assignment_preservation_active(body)

View File

@ -16,6 +16,9 @@ import {
pathQaShowsStrongResult, pathQaShowsStrongResult,
setCatalogSelectItems, setCatalogSelectItems,
splitPathQaHints, splitPathQaHints,
draftHasLibrarySlotAssignments,
slotsToSlotAssignments,
draftRetrievalBoostExerciseIds,
} from '../utils/progressionGraphDraft' } from '../utils/progressionGraphDraft'
import { import {
aiPreviewToQuickCreateDraft, aiPreviewToQuickCreateDraft,
@ -1245,6 +1248,22 @@ export default function ExerciseProgressionPathBuilder({
setError('') setError('')
try { try {
const override = majorStepsToOverridePayload(validSteps) const override = majorStepsToOverridePayload(validSteps)
const preserveAssignments = draftHasLibrarySlotAssignments({
slots: validSteps.map((s, i) => ({
majorStepIndex: i,
phase: s.phase,
learning_goal: s.learning_goal,
primary:
pathSteps[i]?.exerciseId != null
? {
kind: 'library',
exerciseId: pathSteps[i].exerciseId,
exerciseTitle: pathSteps[i].exerciseTitle,
variantId: pathSteps[i].variantId,
}
: { kind: 'empty' },
})),
})
const res = await api.suggestProgressionPath({ const res = await api.suggestProgressionPath({
query: q, query: q,
max_steps: validSteps.length, max_steps: validSteps.length,
@ -1257,6 +1276,21 @@ export default function ExerciseProgressionPathBuilder({
include_llm_roadmap: false, include_llm_roadmap: false,
roadmap_first: true, roadmap_first: true,
roadmap_override: override, roadmap_override: override,
preserve_slot_assignments: preserveAssignments,
slot_assignments: pathSteps
.map((row, i) => {
if (row.exerciseId == null) return null
return {
exercise_id: row.exerciseId,
variant_id: row.variantId || null,
title: row.exerciseTitle || null,
is_ai_proposal: false,
roadmap_major_step_index: i,
roadmap_phase: validSteps[i]?.phase || null,
roadmap_learning_goal: validSteps[i]?.learning_goal || null,
}
})
.filter(Boolean),
progression_graph_id: Number(graphId), progression_graph_id: Number(graphId),
...roadmapStructuredPayload(startSituation, targetState, roadmapNotes), ...roadmapStructuredPayload(startSituation, targetState, roadmapNotes),
...catalogApiPayload, ...catalogApiPayload,

View File

@ -162,6 +162,9 @@ export default function ProgressionFindingsPanel({
onInsertGapSlot, onInsertGapSlot,
onGenerateGapAi, onGenerateGapAi,
onRematchSlots = null, onRematchSlots = null,
onOptimizeCompare = null,
canOptimizeCompare = false,
optimizeCompareBusy = false,
rematchBusy = false, rematchBusy = false,
generatingOfferId = null, generatingOfferId = null,
aiBusy = false, aiBusy = false,
@ -174,6 +177,9 @@ export default function ProgressionFindingsPanel({
const rematchLog = Array.isArray(pathQa?.rematch_log) ? pathQa.rematch_log : [] const rematchLog = Array.isArray(pathQa?.rematch_log) ? pathQa.rematch_log : []
const refineLog = Array.isArray(pathQa?.refine_log) ? pathQa.refine_log : [] const refineLog = Array.isArray(pathQa?.refine_log) ? pathQa.refine_log : []
const showRematchAction = hasRematchSlotHints(pathQa) && typeof onRematchSlots === 'function' const showRematchAction = hasRematchSlotHints(pathQa) && typeof onRematchSlots === 'function'
const showOptimizeCompare =
typeof onOptimizeCompare === 'function'
&& (canOptimizeCompare || pathQa?.assignments_preserved || showRematchAction)
const qualityPct = pathQaQualityPercent(pathQa) const qualityPct = pathQaQualityPercent(pathQa)
const strongResult = pathQaShowsStrongResult(pathQa) const strongResult = pathQaShowsStrongResult(pathQa)
@ -214,6 +220,12 @@ export default function ProgressionFindingsPanel({
Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'} Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'}
{qualityPct != null ? ` (${qualityPct} %)` : ''} {qualityPct != null ? ` (${qualityPct} %)` : ''}
</strong> </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.' : ''}
</p>
) : null}
{strongResult ? ( {strongResult ? (
<p style={{ margin: '6px 0 0', color: 'var(--accent-dark)', fontWeight: 600 }}> <p style={{ margin: '6px 0 0', color: 'var(--accent-dark)', fontWeight: 600 }}>
Starker Pfad KI-Empfehlungen unten können Feinschliff oder optionale Vertiefung sein. Starker Pfad KI-Empfehlungen unten können Feinschliff oder optionale Vertiefung sein.
@ -350,7 +362,18 @@ export default function ProgressionFindingsPanel({
) )
})} })}
</ul> </ul>
{showRematchAction ? ( {showOptimizeCompare ? (
<button
type="button"
className="btn btn-primary btn-full"
style={{ marginTop: '8px', fontSize: '12px' }}
disabled={optimizeCompareBusy || evaluateDisabled}
onClick={onOptimizeCompare}
>
{optimizeCompareBusy ? 'Vergleich läuft…' : 'Optimierung vergleichen'}
</button>
) : null}
{showRematchAction && !showOptimizeCompare ? (
<button <button
type="button" type="button"
className="btn btn-secondary btn-full" className="btn btn-secondary btn-full"

View File

@ -22,11 +22,17 @@ import {
initialStageLearningGoalFromOffer, initialStageLearningGoalFromOffer,
} from '../utils/planningContextForExerciseAi' } from '../utils/planningContextForExerciseAi'
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal' import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
import ProgressionOptimizeCompareModal from './ProgressionOptimizeCompareModal'
import { import {
addSlotToDraft, addSlotToDraft,
applyEvaluateResponseToDraft, applyEvaluateResponseToDraft,
applyGapOfferToDraft, applyGapOfferToDraft,
applyMatchResponseToDraft, applyMatchResponseToDraft,
applySelectedCompareSteps,
compareResponseHasCuratedSlotChanges,
compareResponseHasSlotChanges,
curatedSlotDiffs,
pathQaQualityPercent,
applyResolvedStructuredToDraft, applyResolvedStructuredToDraft,
buildPlanningArtifactFromDraft, buildPlanningArtifactFromDraft,
hydrateProgressionGraphDraft, hydrateProgressionGraphDraft,
@ -43,6 +49,7 @@ import {
slotsAsPathStepRows, slotsAsPathStepRows,
slotsToEvaluateSteps, slotsToEvaluateSteps,
draftRetrievalBoostExerciseIds, draftRetrievalBoostExerciseIds,
draftHasLibrarySlotAssignments,
slotsToSlotAssignments, slotsToSlotAssignments,
syncProgressionRoadmapFromSlots, syncProgressionRoadmapFromSlots,
syncSlotPhasesFromRoadmap, syncSlotPhasesFromRoadmap,
@ -111,6 +118,10 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
const [slotQuickSaving, setSlotQuickSaving] = useState(false) const [slotQuickSaving, setSlotQuickSaving] = useState(false)
const [slotQuickError, setSlotQuickError] = useState('') const [slotQuickError, setSlotQuickError] = useState('')
const [activePlanningContextLines, setActivePlanningContextLines] = useState([]) const [activePlanningContextLines, setActivePlanningContextLines] = useState([])
const [compareOpen, setCompareOpen] = useState(false)
const [comparePayload, setComparePayload] = useState(null)
const [comparing, setComparing] = useState(false)
const [compareApplying, setCompareApplying] = useState(false)
const loadGraph = useCallback(async () => { const loadGraph = useCallback(async () => {
if (!graphId) return if (!graphId) return
@ -424,6 +435,76 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
} }
} }
const buildMatchRequestBase = (synced) => {
const override = majorStepsToOverridePayload(synced.slots)
return {
query: (synced.goalQuery || '').trim(),
max_steps: synced.slots.length,
include_llm_intent: true,
include_path_qa: true,
include_llm_path_qa: true,
include_path_reorder: false,
include_ai_gap_fill: true,
include_roadmap_preview: true,
include_llm_roadmap: false,
roadmap_first: true,
roadmap_override: override,
slot_assignments: slotsToSlotAssignments(synced),
retrieval_boost_exercise_ids: draftRetrievalBoostExerciseIds(synced),
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(synced.startSituation, synced.targetState, synced.roadmapNotes),
...catalogApiPayload,
}
}
const fetchOptimizeCompare = async (synced) => {
const res = await api.suggestProgressionPath({
...buildMatchRequestBase(synced),
preserve_slot_assignments: false,
compare_with_assignments: true,
})
if (!res?.comparison_mode) {
throw new Error('Kein Vergleich in der Antwort')
}
return res
}
const presentOptimizeCompare = (res, { source = 'manual' } = {}) => {
setSemanticBrief(res?.semantic_brief_summary || null)
setTargetSummary(res?.target_profile_summary || null)
const baselineQa = res?.baseline_path_qa || null
const proposedQa = res?.proposed_path_qa || res?.path_qa || null
setPathQa(baselineQa)
const openCompareDialog = (diffCount, noticePrefix) => {
setComparePayload(res)
setCompareOpen(true)
const bPct = pathQaQualityPercent(baselineQa)
const pPct = pathQaQualityPercent(proposedQa)
let notice = `${noticePrefix}${diffCount} Slot-Anpassung(en) — Vergleichsdialog geöffnet.`
if (bPct != null && pPct != null && pPct !== bPct) {
notice += ` QS ${bPct} % → ${pPct} %.`
}
setMatchNotice(notice)
}
if (source === 'match') {
if (compareResponseHasCuratedSlotChanges(res)) {
openCompareDialog(curatedSlotDiffs(res).length, 'KI schlägt ')
return { opened: true, res }
}
return { opened: false, res }
}
setComparePayload(res)
setCompareOpen(true)
if (compareResponseHasSlotChanges(res)) {
const diffCount = res.slot_diff_count ?? res.slot_diffs?.length ?? 0
openCompareDialog(diffCount, 'KI schlägt ')
}
return { opened: true, res }
}
const runMatch = async () => { const runMatch = async () => {
const q = (draft?.goalQuery || '').trim() const q = (draft?.goalQuery || '').trim()
if (q.length < 3) { if (q.length < 3) {
@ -439,24 +520,46 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
setMatchNotice('') setMatchNotice('')
try { try {
const synced = syncProgressionRoadmapFromSlots(draft) const synced = syncProgressionRoadmapFromSlots(draft)
const override = majorStepsToOverridePayload(synced.slots) const hasAssignments = draftHasLibrarySlotAssignments(synced)
if (hasAssignments) {
const res = await fetchOptimizeCompare(synced)
const { opened, res: compareRes } = presentOptimizeCompare(res, { source: 'match' })
if (opened) return
const { draft: matched, remainingOffers } = applyMatchResponseToDraft(
{
...synced,
progressionRoadmap: compareRes?.progression_roadmap || synced.progressionRoadmap,
pathSkillExpectations:
compareRes?.path_skill_expectations || synced.pathSkillExpectations,
},
compareRes,
)
setDraft(matched)
setPathQa(compareRes?.proposed_path_qa || compareRes?.path_qa || null)
setGapFillOffers(remainingOffers)
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.`,
)
}
try {
await saveProgressionGraphDraft(api, graphId, {
...matched,
lastFindings: compareRes?.proposed_path_qa || compareRes?.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({ const res = await api.suggestProgressionPath({
query: q, ...buildMatchRequestBase(synced),
max_steps: synced.slots.length, preserve_slot_assignments: false,
include_llm_intent: true,
include_path_qa: true,
include_llm_path_qa: true,
include_path_reorder: false,
include_ai_gap_fill: true,
include_roadmap_preview: true,
include_llm_roadmap: false,
roadmap_first: true,
roadmap_override: override,
slot_assignments: slotsToSlotAssignments(synced),
retrieval_boost_exercise_ids: draftRetrievalBoostExerciseIds(synced),
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
...catalogApiPayload,
}) })
const { draft: matched, remainingOffers } = applyMatchResponseToDraft( const { draft: matched, remainingOffers } = applyMatchResponseToDraft(
{ {
@ -505,6 +608,58 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
} }
} }
const runOptimizeCompare = async () => {
const q = (draft?.goalQuery || '').trim()
if (q.length < 3) {
alert('Ziel-Anfrage: mindestens 3 Zeichen.')
return
}
if (!draftHasLibrarySlotAssignments(draft)) {
alert('Mindestens ein Slot mit Bibliotheks-Übung nötig für einen Vergleich.')
return
}
setComparing(true)
setActionErr('')
setMatchNotice('')
try {
const synced = syncProgressionRoadmapFromSlots(draft)
const res = await fetchOptimizeCompare(synced)
presentOptimizeCompare(res, { source: 'manual' })
} catch (e) {
setActionErr(e.message || 'Optimierungs-Vergleich fehlgeschlagen')
} finally {
setComparing(false)
}
}
const applyOptimizeCompare = async (selectedMajorIndices) => {
if (!comparePayload || !draft) return
setCompareApplying(true)
try {
const synced = syncProgressionRoadmapFromSlots(draft)
const nextDraft = applySelectedCompareSteps(
synced,
comparePayload.proposed_steps || comparePayload.steps,
selectedMajorIndices,
)
const proposedQa = comparePayload.proposed_path_qa || comparePayload.path_qa
setDraft({ ...nextDraft, lastFindings: proposedQa || null })
setPathQa(proposedQa || null)
setCompareOpen(false)
setComparePayload(null)
setMatchNotice('Ausgewählte Optimierungen übernommen.')
await saveProgressionGraphDraft(api, graphId, {
...nextDraft,
lastFindings: proposedQa || null,
})
setDraft((prev) => (prev ? { ...prev, dirty: false } : prev))
} catch (e) {
setActionErr(e.message || 'Übernahme fehlgeschlagen')
} finally {
setCompareApplying(false)
}
}
const runEvaluate = async () => { const runEvaluate = async () => {
const q = (draft?.goalQuery || '').trim() const q = (draft?.goalQuery || '').trim()
if (q.length < 3) { if (q.length < 3) {
@ -951,9 +1106,25 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
className="btn btn-secondary" className="btn btn-secondary"
disabled={busy || matching} disabled={busy || matching}
onClick={runMatch} onClick={runMatch}
title={
draftHasLibrarySlotAssignments(draft)
? 'Voller Match mit Auto-Optimierung — bei Abweichungen öffnet sich der Vergleichsdialog'
: 'Bibliotheks-Übungen für leere Slots finden'
}
> >
{matching ? 'Match…' : 'Übungen matchen'} {matching ? 'Match…' : 'Übungen matchen'}
</button> </button>
{draftHasLibrarySlotAssignments(draft) ? (
<button
type="button"
className="btn btn-secondary"
disabled={busy || comparing || matching}
onClick={runOptimizeCompare}
title="Aktuellen Pfad vs. voller Match mit Auto-Optimierung — du wählst pro Slot"
>
{comparing ? 'Vergleich…' : 'Optimierung vergleichen'}
</button>
) : null}
<button type="button" className="btn btn-primary" disabled={busy} onClick={handleSave}> <button type="button" className="btn btn-primary" disabled={busy} onClick={handleSave}>
{busy ? 'Speichern…' : 'Graph speichern'} {busy ? 'Speichern…' : 'Graph speichern'}
</button> </button>
@ -1015,6 +1186,9 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
onInsertGapSlot={handleInsertGapSlot} onInsertGapSlot={handleInsertGapSlot}
onGenerateGapAi={openGapFillPrep} onGenerateGapAi={openGapFillPrep}
onRematchSlots={runMatch} onRematchSlots={runMatch}
onOptimizeCompare={runOptimizeCompare}
canOptimizeCompare={draftHasLibrarySlotAssignments(draft)}
optimizeCompareBusy={comparing}
rematchBusy={matching} rematchBusy={matching}
generatingOfferId={generatingOfferId} generatingOfferId={generatingOfferId}
aiBusy={gapAiBusy} aiBusy={gapAiBusy}
@ -1054,6 +1228,18 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
zIndex={2100} zIndex={2100}
/> />
<ProgressionOptimizeCompareModal
open={compareOpen}
comparison={comparePayload}
onClose={() => {
if (compareApplying) return
setCompareOpen(false)
setComparePayload(null)
}}
onApplySelected={applyOptimizeCompare}
applying={compareApplying}
/>
<ExerciseGapFillPrepModal <ExerciseGapFillPrepModal
open={gapPrepOpen} open={gapPrepOpen}
offer={activeOffer} offer={activeOffer}

View File

@ -0,0 +1,202 @@
/**
* Gegenüberstellung: bestehender Pfad vs. optimierter Match-Vorschlag.
*/
import React, { useMemo, useState } from 'react'
import { pathQaQualityPercent } from '../utils/progressionGraphDraft'
function qaLabel(pathQa) {
const pct = pathQaQualityPercent(pathQa)
const ok = pathQa?.overall_ok
if (pct != null) return `${ok ? 'OK' : 'Hinweise'} (${pct} %)`
return ok ? 'OK' : 'Hinweise'
}
export default function ProgressionOptimizeCompareModal({
open,
comparison,
onClose,
onApplySelected,
applying = false,
}) {
const slotDiffs = Array.isArray(comparison?.slot_diffs) ? comparison.slot_diffs : []
const [selected, setSelected] = useState(() => new Set())
const allKeys = useMemo(
() => slotDiffs.map((d) => Number(d.roadmap_major_step_index)),
[slotDiffs],
)
React.useEffect(() => {
if (!open) return
setSelected(new Set(allKeys))
}, [open, allKeys])
if (!open || !comparison) return null
const baselineQa = comparison.baseline_path_qa
const proposedQa = comparison.proposed_path_qa || comparison.path_qa
const baselinePct = pathQaQualityPercent(baselineQa)
const proposedPct = pathQaQualityPercent(proposedQa)
const toggle = (midx) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(midx)) next.delete(midx)
else next.add(midx)
return next
})
}
const toggleAll = (on) => {
setSelected(on ? new Set(allKeys) : new Set())
}
return (
<div
className="modal-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="optimize-compare-title"
onClick={(e) => {
if (e.target === e.currentTarget && !applying) onClose()
}}
>
<div
className="card modal-content"
style={{ maxWidth: '720px', width: '100%', maxHeight: '90vh', overflow: 'auto' }}
onClick={(e) => e.stopPropagation()}
>
<h3 id="optimize-compare-title" style={{ marginTop: 0 }}>
Optimierung vergleichen
</h3>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
Links dein aktueller Pfad, rechts der Vorschlag nach vollem Match inkl. Auto-Optimierung.
Wähle die Slots, die du übernehmen möchtest.
</p>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '12px',
marginBottom: '14px',
}}
>
<div
style={{
padding: '10px 12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
fontSize: '12px',
}}
>
<strong>Aktuell</strong>
<div style={{ marginTop: '6px' }}>{qaLabel(baselineQa)}</div>
{baselineQa?.topic_coverage ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{baselineQa.topic_coverage}</p>
) : null}
</div>
<div
style={{
padding: '10px 12px',
borderRadius: '8px',
border: '1px solid color-mix(in srgb, var(--accent) 35%, var(--border))',
background: 'color-mix(in srgb, var(--accent) 8%, var(--surface2))',
fontSize: '12px',
}}
>
<strong>Vorschlag (Match + Optimierung)</strong>
<div style={{ marginTop: '6px' }}>{qaLabel(proposedQa)}</div>
{proposedPct != null && baselinePct != null && proposedPct !== baselinePct ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>
Δ {proposedPct - baselinePct > 0 ? '+' : ''}
{proposedPct - baselinePct} Prozentpunkte
</p>
) : null}
{proposedQa?.topic_coverage ? (
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{proposedQa.topic_coverage}</p>
) : null}
</div>
</div>
{slotDiffs.length === 0 ? (
<p style={{ fontSize: '12px', color: 'var(--text2)' }}>
Keine abweichenden Slot-Zuordnungen der optimierte Lauf liefert denselben Pfad.
</p>
) : (
<>
<div style={{ display: 'flex', gap: '8px', marginBottom: '8px', flexWrap: 'wrap' }}>
<button type="button" className="btn btn-secondary" style={{ fontSize: '11px' }} onClick={() => toggleAll(true)}>
Alle wählen
</button>
<button type="button" className="btn btn-secondary" style={{ fontSize: '11px' }} onClick={() => toggleAll(false)}>
Keine
</button>
</div>
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '8px' }}>
{slotDiffs.map((diff) => {
const midx = Number(diff.roadmap_major_step_index)
const checked = selected.has(midx)
return (
<li
key={`diff-${midx}`}
style={{
padding: '10px 12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: checked ? 'var(--surface2)' : 'var(--surface)',
fontSize: '12px',
}}
>
<label style={{ display: 'flex', gap: '8px', alignItems: 'flex-start', cursor: 'pointer' }}>
<input
type="checkbox"
checked={checked}
onChange={() => toggle(midx)}
disabled={applying}
style={{ marginTop: '3px' }}
/>
<span style={{ flex: 1 }}>
<strong>Slot {midx + 1}</strong>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '8px',
marginTop: '6px',
}}
>
<span style={{ color: 'var(--text2)' }}>
Bisher: {diff.baseline_title || '— leer —'}
</span>
<span style={{ color: 'var(--accent-dark)' }}>
Neu: {diff.proposed_title || '— leer —'}
</span>
</div>
</span>
</label>
</li>
)
})}
</ul>
</>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '16px', justifyContent: 'flex-end' }}>
<button type="button" className="btn btn-secondary" disabled={applying} onClick={onClose}>
Abbrechen
</button>
<button
type="button"
className="btn btn-primary"
disabled={applying || selected.size === 0 || slotDiffs.length === 0}
onClick={() => onApplySelected([...selected])}
>
{applying ? 'Übernehmen …' : `Auswahl übernehmen (${selected.size})`}
</button>
</div>
</div>
</div>
)
}

View File

@ -906,6 +906,23 @@ export function slotsToSlotAssignments(draft) {
})) }))
} }
/** Mindestens ein Bibliotheks-Slot belegt → kuratierter Stand, Match prüft Abweichungen. */
export function draftHasLibrarySlotAssignments(draft) {
return slotsToSlotAssignments(draft).length >= 1
}
/** Diff-Einträge nur für Slots, die vorher schon eine Bibliotheks-Übung hatten. */
export function curatedSlotDiffs(comparison) {
const diffs = comparison?.slot_diffs
if (!Array.isArray(diffs)) return []
return diffs.filter((d) => d?.baseline_exercise_id != null)
}
/** Vergleich würde eine bestehende Zuordnung ändern (Dialog bei Match). */
export function compareResponseHasCuratedSlotChanges(res) {
return curatedSlotDiffs(res).length > 0
}
/** Alle Graph-Übungs-IDs für Retriever-Boost (Slots + Geschwister + gespeichertes Artefakt). */ /** Alle Graph-Übungs-IDs für Retriever-Boost (Slots + Geschwister + gespeichertes Artefakt). */
export function draftRetrievalBoostExerciseIds(draft) { export function draftRetrievalBoostExerciseIds(draft) {
const ids = new Set() const ids = new Set()
@ -1046,6 +1063,38 @@ export function applyMatchStepsToSlots(draft, apiSteps) {
return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true }) return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true })
} }
/** Vergleichs-Antwort: mindestens ein Slot mit anderer Übung als im Ist-Stand. */
export function compareResponseHasSlotChanges(res) {
const count = res?.slot_diff_count ?? res?.slot_diffs?.length ?? 0
return Number(count) > 0
}
/** Nur ausgewählte Slots aus Optimierungs-Vorschlag übernehmen. */
export function applySelectedCompareSteps(draft, proposedSteps, selectedMajorIndices) {
const selected = new Set(
(selectedMajorIndices || [])
.map((x) => Number(x))
.filter((x) => Number.isFinite(x)),
)
if (!selected.size) return draft
const stepByMajor = new Map()
for (const step of proposedSteps || []) {
if (step?.roadmap_major_step_index == null) continue
stepByMajor.set(Number(step.roadmap_major_step_index), step)
}
const nextSlots = (draft.slots || []).map((slot) => {
const midx = Number(slot.majorStepIndex)
if (!selected.has(midx)) {
return { ...slot, primary: { ...slot.primary }, siblings: [...(slot.siblings || [])] }
}
const step = stepByMajor.get(midx)
if (!step) return slot
const patched = applyMatchStepsToSlots({ ...draft, slots: [slot] }, [step])
return patched.slots[0]
})
return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true })
}
/** Lücken-Angebote in leere Slots legen; Panel nur für verbleibende Lücken. */ /** Lücken-Angebote in leere Slots legen; Panel nur für verbleibende Lücken. */
export function applyGapOffersFromResponse(draft, res, { replaceOffTopicSlots = false } = {}) { export function applyGapOffersFromResponse(draft, res, { replaceOffTopicSlots = false } = {}) {
let next = draft let next = draft