Add Slot Assignments and Enhance Path Handling Logic
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m17s

- Introduced `slot_assignments` to `ProgressionPathSuggestRequest` for improved handling of existing slot assignments in path building.
- Implemented `_slot_assignments_by_major_index` and `_path_step_from_slot_assignment` functions to facilitate the integration of slot assignments into the path generation process.
- Updated `_build_steps_roadmap_first` to utilize slot assignments, enhancing the accuracy of path steps based on existing exercise slots.
- Enhanced `detect_path_gaps` to skip empty slots, preventing unnecessary errors during gap detection.
- Added tests to validate the new slot assignment handling and ensure robustness in path generation logic.
This commit is contained in:
Lars 2026-06-11 12:02:04 +02:00
parent 480890d0c6
commit 7203c871fc
5 changed files with 128 additions and 1 deletions

View File

@ -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,

View File

@ -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(

View File

@ -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]})

View File

@ -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),
})

View File

@ -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()
}
}
}