From 5ed06002d90c2eaf43da77238ef01cda3829b3be Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 12 Jun 2026 13:22:04 +0200 Subject: [PATCH] Implement Comparison Logic for Progression Path Suggestions - Added `compare_with_assignments` flag to `ProgressionPathSuggestRequest` to enable comparison of proposed paths with existing slot assignments. - Introduced `_assignment_preservation_active` function to determine if existing assignments should be preserved during path suggestions. - Enhanced `suggest_progression_path` to handle comparison logic, including validation for minimum slot assignments required for comparison. - Implemented `_build_progression_compare_response` to structure the response for comparison results, including slot differences and quality scores. - Updated frontend components to support new comparison features, including handling of slot assignments and optimization comparisons. - Bumped version to reflect the new features and improvements. --- backend/planning_exercise_path_builder.py | 160 ++++++++++++- .../test_planning_assignment_preservation.py | 31 +++ .../ExerciseProgressionPathBuilder.jsx | 34 +++ .../components/ProgressionFindingsPanel.jsx | 25 +- .../src/components/ProgressionGraphEditor.jsx | 220 ++++++++++++++++-- .../ProgressionOptimizeCompareModal.jsx | 202 ++++++++++++++++ frontend/src/utils/progressionGraphDraft.js | 49 ++++ 7 files changed, 695 insertions(+), 26 deletions(-) create mode 100644 backend/tests/test_planning_assignment_preservation.py create mode 100644 frontend/src/components/ProgressionOptimizeCompareModal.jsx diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index f1bf496..b034dea 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -141,6 +141,7 @@ class ProgressionPathSuggestRequest(BaseModel): roadmap_notes: Optional[str] = Field(default=None, max_length=2000) progression_graph_id: Optional[int] = Field(default=None, ge=1) exercise_kind_any: Optional[List[str]] = None + compare_with_assignments: bool = False planning_catalog_context: Optional[ProgressionPlanningCatalogContext] = None @@ -676,6 +677,11 @@ def _slot_assignments_by_major_index( 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( cur, *, @@ -1848,11 +1854,33 @@ def _build_steps_roadmap_first( if roadmap_ctx.roadmap: 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): major_idx = stage_spec.major_step_index major = majors_by_index.get(major_idx) 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: try: 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( cur, *, @@ -2133,6 +2243,32 @@ def suggest_progression_path( if not _has_planning_role(role): 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) if len(goal_query) < 3: raise HTTPException(status_code=400, detail="Ziel-Anfrage: mindestens 3 Zeichen") @@ -2431,6 +2567,7 @@ def suggest_progression_path( reorder_notes: List[str] = [] roadmap_qa_mode: Optional[str] = None + preserve_assignments = _assignment_preservation_active(body) if body.include_path_qa: if roadmap_first: roadmap_qa_mode = "roadmap_first_lite" @@ -2466,7 +2603,9 @@ def suggest_progression_path( elif gaps and roadmap_first: 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( cur, goal_query=goal_query, @@ -2497,11 +2636,14 @@ def suggest_progression_path( goal_query=goal_query, ) off_topic_before_strip = list(off_topic_steps) - steps, stripped_off_topic = strip_off_topic_steps_from_path( - steps, - off_topic_steps, - min_remaining=0 if roadmap_first else 2, - ) + if preserve_assignments: + stripped_off_topic = [] + else: + 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: off_topic_steps = [] gaps = detect_path_gaps( @@ -2511,7 +2653,7 @@ def suggest_progression_path( 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, rematch_log, @@ -2545,7 +2687,7 @@ def suggest_progression_path( 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( cur, steps, @@ -2656,6 +2798,8 @@ def suggest_progression_path( path_qa["refine_applied"] = True path_qa["refine_log"] = 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) match_summary = { diff --git a/backend/tests/test_planning_assignment_preservation.py b/backend/tests/test_planning_assignment_preservation.py new file mode 100644 index 0000000..9b93741 --- /dev/null +++ b/backend/tests/test_planning_assignment_preservation.py @@ -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) diff --git a/frontend/src/components/ExerciseProgressionPathBuilder.jsx b/frontend/src/components/ExerciseProgressionPathBuilder.jsx index f06c61d..7b2efc1 100644 --- a/frontend/src/components/ExerciseProgressionPathBuilder.jsx +++ b/frontend/src/components/ExerciseProgressionPathBuilder.jsx @@ -16,6 +16,9 @@ import { pathQaShowsStrongResult, setCatalogSelectItems, splitPathQaHints, + draftHasLibrarySlotAssignments, + slotsToSlotAssignments, + draftRetrievalBoostExerciseIds, } from '../utils/progressionGraphDraft' import { aiPreviewToQuickCreateDraft, @@ -1245,6 +1248,22 @@ export default function ExerciseProgressionPathBuilder({ setError('') try { 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({ query: q, max_steps: validSteps.length, @@ -1257,6 +1276,21 @@ export default function ExerciseProgressionPathBuilder({ include_llm_roadmap: false, roadmap_first: true, 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), ...roadmapStructuredPayload(startSituation, targetState, roadmapNotes), ...catalogApiPayload, diff --git a/frontend/src/components/ProgressionFindingsPanel.jsx b/frontend/src/components/ProgressionFindingsPanel.jsx index 7483353..91ea61c 100644 --- a/frontend/src/components/ProgressionFindingsPanel.jsx +++ b/frontend/src/components/ProgressionFindingsPanel.jsx @@ -162,6 +162,9 @@ export default function ProgressionFindingsPanel({ onInsertGapSlot, onGenerateGapAi, onRematchSlots = null, + onOptimizeCompare = null, + canOptimizeCompare = false, + optimizeCompareBusy = false, rematchBusy = false, generatingOfferId = null, aiBusy = false, @@ -174,6 +177,9 @@ export default function ProgressionFindingsPanel({ const rematchLog = Array.isArray(pathQa?.rematch_log) ? pathQa.rematch_log : [] const refineLog = Array.isArray(pathQa?.refine_log) ? pathQa.refine_log : [] const showRematchAction = hasRematchSlotHints(pathQa) && typeof onRematchSlots === 'function' + const showOptimizeCompare = + typeof onOptimizeCompare === 'function' + && (canOptimizeCompare || pathQa?.assignments_preserved || showRematchAction) const qualityPct = pathQaQualityPercent(pathQa) const strongResult = pathQaShowsStrongResult(pathQa) @@ -214,6 +220,12 @@ export default function ProgressionFindingsPanel({ Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'} {qualityPct != null ? ` (${qualityPct} %)` : ''} + {pathQa.assignments_preserved ? ( +

+ Bestehende Slot-Zuordnungen beibehalten — QS wie „Graph bewerten“, ohne Auto-Rematch. + {showOptimizeCompare ? ' „Übungen matchen“ oder „Optimierung vergleichen“ prüft Alternativen.' : ''} +

+ ) : null} {strongResult ? (

Starker Pfad — KI-Empfehlungen unten können Feinschliff oder optionale Vertiefung sein. @@ -350,7 +362,18 @@ export default function ProgressionFindingsPanel({ ) })} - {showRematchAction ? ( + {showOptimizeCompare ? ( + + ) : null} + {showRematchAction && !showOptimizeCompare ? ( + {draftHasLibrarySlotAssignments(draft) ? ( + + ) : null} @@ -1015,6 +1186,9 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa onInsertGapSlot={handleInsertGapSlot} onGenerateGapAi={openGapFillPrep} onRematchSlots={runMatch} + onOptimizeCompare={runOptimizeCompare} + canOptimizeCompare={draftHasLibrarySlotAssignments(draft)} + optimizeCompareBusy={comparing} rematchBusy={matching} generatingOfferId={generatingOfferId} aiBusy={gapAiBusy} @@ -1054,6 +1228,18 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa zIndex={2100} /> + { + if (compareApplying) return + setCompareOpen(false) + setComparePayload(null) + }} + onApplySelected={applyOptimizeCompare} + applying={compareApplying} + /> + 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 ( +

{ + if (e.target === e.currentTarget && !applying) onClose() + }} + > +
e.stopPropagation()} + > +

+ Optimierung vergleichen +

+

+ Links dein aktueller Pfad, rechts der Vorschlag nach vollem Match inkl. Auto-Optimierung. + Wähle die Slots, die du übernehmen möchtest. +

+ +
+
+ Aktuell +
{qaLabel(baselineQa)}
+ {baselineQa?.topic_coverage ? ( +

{baselineQa.topic_coverage}

+ ) : null} +
+
+ Vorschlag (Match + Optimierung) +
{qaLabel(proposedQa)}
+ {proposedPct != null && baselinePct != null && proposedPct !== baselinePct ? ( +

+ Δ {proposedPct - baselinePct > 0 ? '+' : ''} + {proposedPct - baselinePct} Prozentpunkte +

+ ) : null} + {proposedQa?.topic_coverage ? ( +

{proposedQa.topic_coverage}

+ ) : null} +
+
+ + {slotDiffs.length === 0 ? ( +

+ Keine abweichenden Slot-Zuordnungen — der optimierte Lauf liefert denselben Pfad. +

+ ) : ( + <> +
+ + +
+
    + {slotDiffs.map((diff) => { + const midx = Number(diff.roadmap_major_step_index) + const checked = selected.has(midx) + return ( +
  • + +
  • + ) + })} +
+ + )} + +
+ + +
+
+
+ ) +} diff --git a/frontend/src/utils/progressionGraphDraft.js b/frontend/src/utils/progressionGraphDraft.js index 21d88e8..8eb117e 100644 --- a/frontend/src/utils/progressionGraphDraft.js +++ b/frontend/src/utils/progressionGraphDraft.js @@ -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). */ export function draftRetrievalBoostExerciseIds(draft) { const ids = new Set() @@ -1046,6 +1063,38 @@ export function applyMatchStepsToSlots(draft, apiSteps) { 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. */ export function applyGapOffersFromResponse(draft, res, { replaceOffTopicSlots = false } = {}) { let next = draft