Implement Comparison Logic for Progression Path Suggestions
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 45s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m18s
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 45s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m18s
- 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.
This commit is contained in:
parent
b8f65e04c5
commit
5ed06002d9
|
|
@ -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,6 +2636,9 @@ def suggest_progression_path(
|
|||
goal_query=goal_query,
|
||||
)
|
||||
off_topic_before_strip = list(off_topic_steps)
|
||||
if preserve_assignments:
|
||||
stripped_off_topic = []
|
||||
else:
|
||||
steps, stripped_off_topic = strip_off_topic_steps_from_path(
|
||||
steps,
|
||||
off_topic_steps,
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
31
backend/tests/test_planning_assignment_preservation.py
Normal file
31
backend/tests/test_planning_assignment_preservation.py
Normal 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)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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} %)` : ''}
|
||||
</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 ? (
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--accent-dark)', fontWeight: 600 }}>
|
||||
Starker Pfad — KI-Empfehlungen unten können Feinschliff oder optionale Vertiefung sein.
|
||||
|
|
@ -350,7 +362,18 @@ export default function ProgressionFindingsPanel({
|
|||
)
|
||||
})}
|
||||
</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
|
||||
type="button"
|
||||
className="btn btn-secondary btn-full"
|
||||
|
|
|
|||
|
|
@ -22,11 +22,17 @@ import {
|
|||
initialStageLearningGoalFromOffer,
|
||||
} from '../utils/planningContextForExerciseAi'
|
||||
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
|
||||
import ProgressionOptimizeCompareModal from './ProgressionOptimizeCompareModal'
|
||||
import {
|
||||
addSlotToDraft,
|
||||
applyEvaluateResponseToDraft,
|
||||
applyGapOfferToDraft,
|
||||
applyMatchResponseToDraft,
|
||||
applySelectedCompareSteps,
|
||||
compareResponseHasCuratedSlotChanges,
|
||||
compareResponseHasSlotChanges,
|
||||
curatedSlotDiffs,
|
||||
pathQaQualityPercent,
|
||||
applyResolvedStructuredToDraft,
|
||||
buildPlanningArtifactFromDraft,
|
||||
hydrateProgressionGraphDraft,
|
||||
|
|
@ -43,6 +49,7 @@ import {
|
|||
slotsAsPathStepRows,
|
||||
slotsToEvaluateSteps,
|
||||
draftRetrievalBoostExerciseIds,
|
||||
draftHasLibrarySlotAssignments,
|
||||
slotsToSlotAssignments,
|
||||
syncProgressionRoadmapFromSlots,
|
||||
syncSlotPhasesFromRoadmap,
|
||||
|
|
@ -111,6 +118,10 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
const [slotQuickSaving, setSlotQuickSaving] = useState(false)
|
||||
const [slotQuickError, setSlotQuickError] = 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 () => {
|
||||
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 q = (draft?.goalQuery || '').trim()
|
||||
if (q.length < 3) {
|
||||
|
|
@ -439,24 +520,46 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
setMatchNotice('')
|
||||
try {
|
||||
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({
|
||||
query: q,
|
||||
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(draft.startSituation, draft.targetState, draft.roadmapNotes),
|
||||
...catalogApiPayload,
|
||||
...buildMatchRequestBase(synced),
|
||||
preserve_slot_assignments: false,
|
||||
})
|
||||
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 q = (draft?.goalQuery || '').trim()
|
||||
if (q.length < 3) {
|
||||
|
|
@ -951,9 +1106,25 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
className="btn btn-secondary"
|
||||
disabled={busy || matching}
|
||||
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'}
|
||||
</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}>
|
||||
{busy ? 'Speichern…' : 'Graph speichern'}
|
||||
</button>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
||||
<ProgressionOptimizeCompareModal
|
||||
open={compareOpen}
|
||||
comparison={comparePayload}
|
||||
onClose={() => {
|
||||
if (compareApplying) return
|
||||
setCompareOpen(false)
|
||||
setComparePayload(null)
|
||||
}}
|
||||
onApplySelected={applyOptimizeCompare}
|
||||
applying={compareApplying}
|
||||
/>
|
||||
|
||||
<ExerciseGapFillPrepModal
|
||||
open={gapPrepOpen}
|
||||
offer={activeOffer}
|
||||
|
|
|
|||
202
frontend/src/components/ProgressionOptimizeCompareModal.jsx
Normal file
202
frontend/src/components/ProgressionOptimizeCompareModal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user