From ad051c015f267485c6d3ae8fb18aaad2796e56e9 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Jun 2026 12:20:41 +0200 Subject: [PATCH] Enhance Progression Path Suggestion with Retrieval Boost and Slot Assignment Logic - Added `preserve_slot_assignments` and `retrieval_boost_exercise_ids` to `ProgressionPathSuggestRequest` for improved handling of exercise suggestions. - Refactored `_supplemental_exercise_ids_from_body` to incorporate retrieval boost exercise IDs, ensuring they are prioritized over slot assignments. - Updated `_build_steps_roadmap_first` to conditionally preserve slot assignments based on the new flag. - Enhanced tests to validate the new retrieval boost logic and its integration with existing slot assignment handling. --- backend/planning_exercise_path_builder.py | 77 +++++++++++++++---- backend/planning_exercise_semantics.py | 2 - .../test_planning_exercise_path_builder.py | 25 ++++++ .../test_planning_roadmap_stage_match.py | 33 ++++++++ .../src/components/ProgressionGraphEditor.jsx | 4 +- frontend/src/utils/progressionGraphDraft.js | 30 +++----- 6 files changed, 133 insertions(+), 38 deletions(-) diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 8b6157d..700272a 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -120,6 +120,8 @@ class ProgressionPathSuggestRequest(BaseModel): evaluate_only: bool = False evaluate_steps: Optional[List[EvaluateStepPayload]] = None slot_assignments: Optional[List[EvaluateStepPayload]] = None + preserve_slot_assignments: bool = False + retrieval_boost_exercise_ids: Optional[List[int]] = 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) @@ -267,18 +269,53 @@ def _build_path_target_profile( return target, query_intent_summary, intent -def _supplemental_exercise_ids_from_body(body: ProgressionPathSuggestRequest) -> List[int]: - """Verankerte Graph-Slots immer im Retriever-Kandidatenpool halten.""" +def _graph_edge_exercise_ids(cur, graph_id: Optional[int]) -> List[int]: + """Übungs-IDs aus gespeicherten Graph-Kanten (für Re-Match-Boost).""" + if not graph_id or int(graph_id) < 1: + return [] + cur.execute( + """ + SELECT from_exercise_id AS eid FROM exercise_progression_edges + WHERE graph_id = %s AND from_exercise_id IS NOT NULL + UNION + SELECT to_exercise_id AS eid FROM exercise_progression_edges + WHERE graph_id = %s AND to_exercise_id IS NOT NULL + """, + (int(graph_id), int(graph_id)), + ) + out: List[int] = [] + for row in cur.fetchall() or []: + try: + eid = int(row.get("eid") or 0) + except (TypeError, ValueError): + continue + if eid > 0: + out.append(eid) + return out + + +def _supplemental_exercise_ids_from_body( + cur, + body: ProgressionPathSuggestRequest, +) -> List[int]: + """Kandidatenpool erweitern — ohne automatisches Slot-Pinning.""" ids: List[int] = [] - for coll in (body.slot_assignments, body.evaluate_steps): - for raw in coll or []: - if raw.exercise_id is not None: - try: - eid = int(raw.exercise_id) - except (TypeError, ValueError): - continue - if eid > 0: - ids.append(eid) + for raw in body.evaluate_steps or []: + if raw.exercise_id is not None: + try: + eid = int(raw.exercise_id) + except (TypeError, ValueError): + continue + if eid > 0: + ids.append(eid) + for eid in body.retrieval_boost_exercise_ids or []: + try: + val = int(eid) + except (TypeError, ValueError): + continue + if val > 0: + ids.append(val) + ids.extend(_graph_edge_exercise_ids(cur, body.progression_graph_id)) return list(dict.fromkeys(ids)) @@ -845,7 +882,7 @@ def _match_roadmap_slot( path_context_note=path_context_note, path_primary_topic=path_primary or None, path_technique_excludes=path_tech_excludes or None, - supplemental_exercise_ids=_supplemental_exercise_ids_from_body(body), + supplemental_exercise_ids=_supplemental_exercise_ids_from_body(cur, body), ) hit = _pick_best_path_hit( @@ -1026,7 +1063,11 @@ 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) + assignments = ( + _slot_assignments_by_major_index(body.slot_assignments) + if body.preserve_slot_assignments + else {} + ) majors_by_index: Dict[int, MajorStep] = {} if roadmap_ctx.roadmap: majors_by_index = {m.index: m for m in roadmap_ctx.roadmap.major_steps} @@ -1579,7 +1620,7 @@ def suggest_progression_path( semantic_brief=semantic_brief, path_target_profile=path_target_profile, path_intent=path_intent, - supplemental_exercise_ids=_supplemental_exercise_ids_from_body(body), + supplemental_exercise_ids=_supplemental_exercise_ids_from_body(cur, body), ) hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief) @@ -1640,7 +1681,7 @@ def suggest_progression_path( planned_ids=planned_ids, path_target_profile=path_target_profile, path_intent=path_intent, - supplemental_exercise_ids=_supplemental_exercise_ids_from_body(body), + supplemental_exercise_ids=_supplemental_exercise_ids_from_body(cur, body), ) steps, bridge_inserts, unfilled_gaps = insert_bridge_exercises( cur, @@ -1683,7 +1724,11 @@ def suggest_progression_path( goal_query=goal_query, ) off_topic_before_strip = list(off_topic_steps) - steps, stripped_off_topic = strip_off_topic_steps_from_path(steps, off_topic_steps) + steps, stripped_off_topic = strip_off_topic_steps_from_path( + steps, + off_topic_steps, + min_remaining=0 if roadmap_first else 2, + ) if stripped_off_topic: off_topic_steps = [] gaps = detect_path_gaps( diff --git a/backend/planning_exercise_semantics.py b/backend/planning_exercise_semantics.py index 701e41c..1a378cd 100644 --- a/backend/planning_exercise_semantics.py +++ b/backend/planning_exercise_semantics.py @@ -1286,8 +1286,6 @@ def pick_best_path_hit( return chosen if roadmap_stage_match: - if (path_primary_topic or "").strip(): - return None chosen = _scan(strict=False) return chosen diff --git a/backend/tests/test_planning_exercise_path_builder.py b/backend/tests/test_planning_exercise_path_builder.py index 84547f6..5c021b6 100644 --- a/backend/tests/test_planning_exercise_path_builder.py +++ b/backend/tests/test_planning_exercise_path_builder.py @@ -1,12 +1,37 @@ """Tests Planungs-KI Phase C3/E/F — Pfad-Vorschläge.""" from planning_exercise_path_builder import ( + EvaluateStepPayload, + ProgressionPathSuggestRequest, _annotate_roadmap_step, _hit_to_path_step, _pick_best_path_hit, + _supplemental_exercise_ids_from_body, ) from planning_progression_roadmap import MajorStep, StageSpecArtifact +class _FakeCur: + def execute(self, *_args, **_kwargs): + return None + + def fetchall(self): + return [] + + +def test_supplemental_boost_uses_retrieval_boost_not_slot_pins(): + body = ProgressionPathSuggestRequest( + query="Mawashi Geri Progression", + slot_assignments=[ + EvaluateStepPayload(exercise_id=99, roadmap_major_step_index=0), + ], + retrieval_boost_exercise_ids=[42, 7], + ) + ids = _supplemental_exercise_ids_from_body(_FakeCur(), body) + assert 99 not in ids + assert 42 in ids + assert 7 in ids + + def test_pick_next_path_hit_skips_used(): hits = [{"id": 1, "title": "A", "semantic_score": 0.2}, {"id": 2, "title": "B", "semantic_score": 0.2}, {"id": 3, "title": "C", "semantic_score": 0.2}] assert _pick_best_path_hit(hits, {1})["id"] == 2 diff --git a/backend/tests/test_planning_roadmap_stage_match.py b/backend/tests/test_planning_roadmap_stage_match.py index 9519c60..0c9fdc9 100644 --- a/backend/tests/test_planning_roadmap_stage_match.py +++ b/backend/tests/test_planning_roadmap_stage_match.py @@ -340,6 +340,39 @@ def test_technique_scope_rejects_kumite_when_only_stage_goal_mentions_mawashi(): ) +def test_pick_roadmap_relaxed_with_path_primary_when_strict_fails(): + """Bestehende Graph-Übungen: relaxed Gate auch bei gesetztem path_primary_topic.""" + stage_goal = "Hüftmobilität für Mawashi Geri" + primary = "mawashi geri" + stage_brief = build_stage_match_brief( + learning_goal=stage_goal, + path_primary_topic=primary, + path_technique_excludes=technique_sibling_excludes(primary), + ) + hits = [ + { + "id": 42, + "title": "Mawashi Geri Hüftmobilität — Vereinsübung", + "summary": "Dehnung und Hüfte für Rundtritt", + "goal": "Mobilität Mawashi Geri", + "score": 0.55, + "semantic_score": 0.25, + "stage_semantic_score": 0.25, + }, + ] + chosen = pick_best_path_hit( + hits, + set(), + stage_learning_goal=stage_goal, + roadmap_stage_match=True, + stage_match_brief=stage_brief, + path_primary_topic=primary, + path_technique_excludes=technique_sibling_excludes(primary), + ) + assert chosen is not None + assert int(chosen["id"]) == 42 + + def test_pick_best_roadmap_rejects_kumite_with_path_primary_topic(): q = "gesprungener Mawashi Geri Sprungphase" brief = enrich_brief_with_path_constraints(build_semantic_brief(q), q) diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index 8687fd5..042acfa 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -41,7 +41,7 @@ import { SLOT_MAX, slotsAsPathStepRows, slotsToEvaluateSteps, - slotsToSlotAssignments, + draftRetrievalBoostExerciseIds, syncProgressionRoadmapFromSlots, syncSlotPhasesFromRoadmap, } from '../utils/progressionGraphDraft' @@ -415,7 +415,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa 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), }) diff --git a/frontend/src/utils/progressionGraphDraft.js b/frontend/src/utils/progressionGraphDraft.js index 3cd9ab4..96e7632 100644 --- a/frontend/src/utils/progressionGraphDraft.js +++ b/frontend/src/utils/progressionGraphDraft.js @@ -712,19 +712,17 @@ 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, - })) +/** Bereits zugeordnete Bibliotheks-Übungen — nur Retriever-Boost, kein Pinning. */ +export function draftRetrievalBoostExerciseIds(draft) { + const ids = new Set() + for (const slot of draft.slots || []) { + const p = slot.primary + if (p?.kind === 'library' && p.exerciseId != null) ids.add(p.exerciseId) + for (const sib of slot.siblings || []) { + if (sib.kind === 'library' && sib.exerciseId != null) ids.add(sib.exerciseId) + } + } + return [...ids] } export function slotsToEvaluateSteps(draft) { @@ -804,11 +802,7 @@ export function applyMatchStepsToSlots(draft, apiSteps) { for (let i = 0; i < nextSlots.length; i += 1) { if (!touchedMajors.has(i)) { - const keep = - nextSlots[i].primary?.kind === 'library' && nextSlots[i].primary.exerciseId != null - if (!keep) { - nextSlots[i].primary = emptySlotExercise() - } + nextSlots[i].primary = emptySlotExercise() } }