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)
|
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,6 +2636,9 @@ 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)
|
||||||
|
if preserve_assignments:
|
||||||
|
stripped_off_topic = []
|
||||||
|
else:
|
||||||
steps, stripped_off_topic = strip_off_topic_steps_from_path(
|
steps, stripped_off_topic = strip_off_topic_steps_from_path(
|
||||||
steps,
|
steps,
|
||||||
off_topic_steps,
|
off_topic_steps,
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
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,
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
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). */
|
/** 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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user