Enhance Progression Path Suggestion with Retrieval Boost and Slot Assignment 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 34s
Test Suite / playwright-tests (push) Successful in 1m14s

- 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.
This commit is contained in:
Lars 2026-06-11 12:20:41 +02:00
parent b464047c3a
commit ad051c015f
6 changed files with 133 additions and 38 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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