diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 31d04f9..9316f41 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -115,6 +115,7 @@ class ProgressionPathSuggestRequest(BaseModel): start_target_only: bool = False evaluate_only: bool = False evaluate_steps: Optional[List[EvaluateStepPayload]] = None + slot_assignments: Optional[List[EvaluateStepPayload]] = None roadmap_override: Optional[RoadmapOverridePayload] = None start_situation: Optional[str] = Field(default=None, max_length=2000) target_state: Optional[str] = Field(default=None, max_length=2000) @@ -262,6 +263,52 @@ def _build_path_target_profile( return target, query_intent_summary, intent +def _slot_assignments_by_major_index( + assignments: Optional[List[EvaluateStepPayload]], +) -> Dict[int, EvaluateStepPayload]: + out: Dict[int, EvaluateStepPayload] = {} + for raw in assignments or []: + if raw.exercise_id is None or raw.roadmap_major_step_index is None: + continue + out[int(raw.roadmap_major_step_index)] = raw + return out + + +def _path_step_from_slot_assignment( + cur, + *, + assignment: EvaluateStepPayload, + stage_spec: StageSpecArtifact, + major_step: Optional[MajorStep], +) -> Optional[Dict[str, Any]]: + """Bestehende Slot-Zuordnung aus dem Graph-Editor (nach KI-Anlage) übernehmen.""" + eid = int(assignment.exercise_id) + cur.execute( + "SELECT id, title, summary FROM exercises WHERE id = %s", + (eid,), + ) + row = cur.fetchone() + if not row: + return None + title = (assignment.title or row.get("title") or "").strip() or str(row.get("title") or "") + step = { + "exercise_id": eid, + "variant_id": assignment.variant_id, + "title": title, + "summary": row.get("summary"), + "score": None, + "semantic_score": None, + "reasons": ["Bestehende Slot-Zuordnung (Graph-Editor)"], + "variants": [], + "slot_assignment": True, + } + return _annotate_roadmap_step( + step, + stage_spec=stage_spec, + major_step=major_step, + ) + + def _hit_to_path_step(hit: Dict[str, Any], *, is_bridge: bool = False) -> Dict[str, Any]: raw_vid = hit.get("suggested_variant_id") variant_id: Optional[int] = None @@ -873,8 +920,29 @@ def _build_steps_roadmap_first( anchor_variant_id: Optional[int] = None unfilled: List[Tuple[int, StageSpecArtifact]] = [] stage_count = len(stage_specs) + assignments = _slot_assignments_by_major_index(body.slot_assignments) + majors_by_index: Dict[int, MajorStep] = {} + if roadmap_ctx.roadmap: + majors_by_index = {m.index: m for m in roadmap_ctx.roadmap.major_steps} for step_index, stage_spec in enumerate(stage_specs): + major_idx = stage_spec.major_step_index + if major_idx in assignments: + pinned = _path_step_from_slot_assignment( + cur, + assignment=assignments[major_idx], + stage_spec=stage_spec, + major_step=majors_by_index.get(major_idx), + ) + if pinned: + steps.append(pinned) + eid = int(pinned["exercise_id"]) + used.add(eid) + planned_ids.append(eid) + anchor_id = eid + anchor_variant_id = pinned.get("variant_id") + continue + step, unfilled_spec = _match_roadmap_slot( cur, tenant=tenant, diff --git a/backend/planning_exercise_path_qa.py b/backend/planning_exercise_path_qa.py index f0cb034..d3e50a7 100644 --- a/backend/planning_exercise_path_qa.py +++ b/backend/planning_exercise_path_qa.py @@ -182,6 +182,8 @@ def detect_path_gaps( for i in range(total_segments): step_a = steps[i] step_b = steps[i + 1] + if step_a.get("exercise_id") is None or step_b.get("exercise_id") is None: + continue if roadmap_first and is_roadmap_planned_neighbor_pair(step_a, step_b): continue gap = measure_step_transition_gap( diff --git a/backend/tests/test_planning_exercise_path_qa.py b/backend/tests/test_planning_exercise_path_qa.py index 929e187..f6a8d8a 100644 --- a/backend/tests/test_planning_exercise_path_qa.py +++ b/backend/tests/test_planning_exercise_path_qa.py @@ -106,6 +106,42 @@ def test_detect_path_gaps_skips_roadmap_neighbors(): assert gaps == [] +def test_detect_path_gaps_skips_empty_slots(): + """Graph-Bewertung: leere Slots dürfen keinen 500er durch Übergangs-Lücken auslösen.""" + brief = build_semantic_brief("Mawashi Geri Kumite") + steps = [ + { + "exercise_id": 10, + "title": "Stand", + "roadmap_major_step_index": 0, + }, + { + "exercise_id": None, + "title": "(leer: Slot 2)", + "is_ai_proposal": True, + "roadmap_major_step_index": 1, + }, + { + "exercise_id": 11, + "title": "Anwendung", + "roadmap_major_step_index": 2, + }, + ] + + class _FakeCur: + def execute(self, *args, **kwargs): + return None + + def fetchall(self): + return [] + + def fetchone(self): + return {"title": "X", "summary": "", "goal": ""} + + gaps = detect_path_gaps(_FakeCur(), steps, brief=brief, roadmap_first=True) + assert isinstance(gaps, list) + + def test_apply_llm_path_reorder_invalid_ignored(): steps = [{"exercise_id": 1}, {"exercise_id": 2}] reordered, applied, _ = apply_llm_path_reorder(steps, {"ordered_step_indices": [0, 0]}) diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index 7d5a20f..8687fd5 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -41,6 +41,7 @@ import { SLOT_MAX, slotsAsPathStepRows, slotsToEvaluateSteps, + slotsToSlotAssignments, syncProgressionRoadmapFromSlots, syncSlotPhasesFromRoadmap, } from '../utils/progressionGraphDraft' @@ -414,6 +415,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa include_llm_roadmap: false, roadmap_first: true, roadmap_override: override, + slot_assignments: slotsToSlotAssignments(synced), progression_graph_id: Number(graphId), ...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes), }) diff --git a/frontend/src/utils/progressionGraphDraft.js b/frontend/src/utils/progressionGraphDraft.js index 3a2707a..3cd9ab4 100644 --- a/frontend/src/utils/progressionGraphDraft.js +++ b/frontend/src/utils/progressionGraphDraft.js @@ -712,6 +712,21 @@ export function draftSiblingEdgePairs(draft) { return pairs } +/** Bereits zugeordnete Bibliotheks-Übungen für Re-Match (Pins). */ +export function slotsToSlotAssignments(draft) { + return (draft.slots || []) + .filter((slot) => slot.primary?.kind === 'library' && slot.primary.exerciseId != null) + .map((slot) => ({ + exercise_id: slot.primary.exerciseId, + variant_id: slot.primary.variantId || null, + title: slot.primary.exerciseTitle || null, + is_ai_proposal: false, + roadmap_major_step_index: slot.majorStepIndex, + roadmap_phase: slot.phase || null, + roadmap_learning_goal: slot.learning_goal || null, + })) +} + export function slotsToEvaluateSteps(draft) { return (draft.slots || []).map((slot) => { const p = slot.primary @@ -789,7 +804,11 @@ export function applyMatchStepsToSlots(draft, apiSteps) { for (let i = 0; i < nextSlots.length; i += 1) { if (!touchedMajors.has(i)) { - nextSlots[i].primary = emptySlotExercise() + const keep = + nextSlots[i].primary?.kind === 'library' && nextSlots[i].primary.exerciseId != null + if (!keep) { + nextSlots[i].primary = emptySlotExercise() + } } }