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
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:
parent
b464047c3a
commit
ad051c015f
|
|
@ -120,6 +120,8 @@ class ProgressionPathSuggestRequest(BaseModel):
|
||||||
evaluate_only: bool = False
|
evaluate_only: bool = False
|
||||||
evaluate_steps: Optional[List[EvaluateStepPayload]] = None
|
evaluate_steps: Optional[List[EvaluateStepPayload]] = None
|
||||||
slot_assignments: 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
|
roadmap_override: Optional[RoadmapOverridePayload] = None
|
||||||
start_situation: Optional[str] = Field(default=None, max_length=2000)
|
start_situation: Optional[str] = Field(default=None, max_length=2000)
|
||||||
target_state: Optional[str] = Field(default=None, max_length=2000)
|
target_state: Optional[str] = Field(default=None, max_length=2000)
|
||||||
|
|
@ -267,11 +269,38 @@ def _build_path_target_profile(
|
||||||
return target, query_intent_summary, intent
|
return target, query_intent_summary, intent
|
||||||
|
|
||||||
|
|
||||||
def _supplemental_exercise_ids_from_body(body: ProgressionPathSuggestRequest) -> List[int]:
|
def _graph_edge_exercise_ids(cur, graph_id: Optional[int]) -> List[int]:
|
||||||
"""Verankerte Graph-Slots immer im Retriever-Kandidatenpool halten."""
|
"""Ü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] = []
|
ids: List[int] = []
|
||||||
for coll in (body.slot_assignments, body.evaluate_steps):
|
for raw in body.evaluate_steps or []:
|
||||||
for raw in coll or []:
|
|
||||||
if raw.exercise_id is not None:
|
if raw.exercise_id is not None:
|
||||||
try:
|
try:
|
||||||
eid = int(raw.exercise_id)
|
eid = int(raw.exercise_id)
|
||||||
|
|
@ -279,6 +308,14 @@ def _supplemental_exercise_ids_from_body(body: ProgressionPathSuggestRequest) ->
|
||||||
continue
|
continue
|
||||||
if eid > 0:
|
if eid > 0:
|
||||||
ids.append(eid)
|
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))
|
return list(dict.fromkeys(ids))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -845,7 +882,7 @@ def _match_roadmap_slot(
|
||||||
path_context_note=path_context_note,
|
path_context_note=path_context_note,
|
||||||
path_primary_topic=path_primary or None,
|
path_primary_topic=path_primary or None,
|
||||||
path_technique_excludes=path_tech_excludes 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(
|
hit = _pick_best_path_hit(
|
||||||
|
|
@ -1026,7 +1063,11 @@ def _build_steps_roadmap_first(
|
||||||
anchor_variant_id: Optional[int] = None
|
anchor_variant_id: Optional[int] = None
|
||||||
unfilled: List[Tuple[int, StageSpecArtifact]] = []
|
unfilled: List[Tuple[int, StageSpecArtifact]] = []
|
||||||
stage_count = len(stage_specs)
|
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] = {}
|
majors_by_index: Dict[int, MajorStep] = {}
|
||||||
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}
|
||||||
|
|
@ -1579,7 +1620,7 @@ def suggest_progression_path(
|
||||||
semantic_brief=semantic_brief,
|
semantic_brief=semantic_brief,
|
||||||
path_target_profile=path_target_profile,
|
path_target_profile=path_target_profile,
|
||||||
path_intent=path_intent,
|
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)
|
hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief)
|
||||||
|
|
@ -1640,7 +1681,7 @@ def suggest_progression_path(
|
||||||
planned_ids=planned_ids,
|
planned_ids=planned_ids,
|
||||||
path_target_profile=path_target_profile,
|
path_target_profile=path_target_profile,
|
||||||
path_intent=path_intent,
|
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(
|
steps, bridge_inserts, unfilled_gaps = insert_bridge_exercises(
|
||||||
cur,
|
cur,
|
||||||
|
|
@ -1683,7 +1724,11 @@ 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)
|
||||||
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:
|
if stripped_off_topic:
|
||||||
off_topic_steps = []
|
off_topic_steps = []
|
||||||
gaps = detect_path_gaps(
|
gaps = detect_path_gaps(
|
||||||
|
|
|
||||||
|
|
@ -1286,8 +1286,6 @@ def pick_best_path_hit(
|
||||||
return chosen
|
return chosen
|
||||||
|
|
||||||
if roadmap_stage_match:
|
if roadmap_stage_match:
|
||||||
if (path_primary_topic or "").strip():
|
|
||||||
return None
|
|
||||||
chosen = _scan(strict=False)
|
chosen = _scan(strict=False)
|
||||||
return chosen
|
return chosen
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,37 @@
|
||||||
"""Tests Planungs-KI Phase C3/E/F — Pfad-Vorschläge."""
|
"""Tests Planungs-KI Phase C3/E/F — Pfad-Vorschläge."""
|
||||||
from planning_exercise_path_builder import (
|
from planning_exercise_path_builder import (
|
||||||
|
EvaluateStepPayload,
|
||||||
|
ProgressionPathSuggestRequest,
|
||||||
_annotate_roadmap_step,
|
_annotate_roadmap_step,
|
||||||
_hit_to_path_step,
|
_hit_to_path_step,
|
||||||
_pick_best_path_hit,
|
_pick_best_path_hit,
|
||||||
|
_supplemental_exercise_ids_from_body,
|
||||||
)
|
)
|
||||||
from planning_progression_roadmap import MajorStep, StageSpecArtifact
|
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():
|
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}]
|
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
|
assert _pick_best_path_hit(hits, {1})["id"] == 2
|
||||||
|
|
|
||||||
|
|
@ -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():
|
def test_pick_best_roadmap_rejects_kumite_with_path_primary_topic():
|
||||||
q = "gesprungener Mawashi Geri Sprungphase"
|
q = "gesprungener Mawashi Geri Sprungphase"
|
||||||
brief = enrich_brief_with_path_constraints(build_semantic_brief(q), q)
|
brief = enrich_brief_with_path_constraints(build_semantic_brief(q), q)
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ import {
|
||||||
SLOT_MAX,
|
SLOT_MAX,
|
||||||
slotsAsPathStepRows,
|
slotsAsPathStepRows,
|
||||||
slotsToEvaluateSteps,
|
slotsToEvaluateSteps,
|
||||||
slotsToSlotAssignments,
|
draftRetrievalBoostExerciseIds,
|
||||||
syncProgressionRoadmapFromSlots,
|
syncProgressionRoadmapFromSlots,
|
||||||
syncSlotPhasesFromRoadmap,
|
syncSlotPhasesFromRoadmap,
|
||||||
} from '../utils/progressionGraphDraft'
|
} from '../utils/progressionGraphDraft'
|
||||||
|
|
@ -415,7 +415,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
include_llm_roadmap: false,
|
include_llm_roadmap: false,
|
||||||
roadmap_first: true,
|
roadmap_first: true,
|
||||||
roadmap_override: override,
|
roadmap_override: override,
|
||||||
slot_assignments: slotsToSlotAssignments(synced),
|
retrieval_boost_exercise_ids: draftRetrievalBoostExerciseIds(synced),
|
||||||
progression_graph_id: Number(graphId),
|
progression_graph_id: Number(graphId),
|
||||||
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
|
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -712,19 +712,17 @@ export function draftSiblingEdgePairs(draft) {
|
||||||
return pairs
|
return pairs
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bereits zugeordnete Bibliotheks-Übungen für Re-Match (Pins). */
|
/** Bereits zugeordnete Bibliotheks-Übungen — nur Retriever-Boost, kein Pinning. */
|
||||||
export function slotsToSlotAssignments(draft) {
|
export function draftRetrievalBoostExerciseIds(draft) {
|
||||||
return (draft.slots || [])
|
const ids = new Set()
|
||||||
.filter((slot) => slot.primary?.kind === 'library' && slot.primary.exerciseId != null)
|
for (const slot of draft.slots || []) {
|
||||||
.map((slot) => ({
|
const p = slot.primary
|
||||||
exercise_id: slot.primary.exerciseId,
|
if (p?.kind === 'library' && p.exerciseId != null) ids.add(p.exerciseId)
|
||||||
variant_id: slot.primary.variantId || null,
|
for (const sib of slot.siblings || []) {
|
||||||
title: slot.primary.exerciseTitle || null,
|
if (sib.kind === 'library' && sib.exerciseId != null) ids.add(sib.exerciseId)
|
||||||
is_ai_proposal: false,
|
}
|
||||||
roadmap_major_step_index: slot.majorStepIndex,
|
}
|
||||||
roadmap_phase: slot.phase || null,
|
return [...ids]
|
||||||
roadmap_learning_goal: slot.learning_goal || null,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function slotsToEvaluateSteps(draft) {
|
export function slotsToEvaluateSteps(draft) {
|
||||||
|
|
@ -804,13 +802,9 @@ export function applyMatchStepsToSlots(draft, apiSteps) {
|
||||||
|
|
||||||
for (let i = 0; i < nextSlots.length; i += 1) {
|
for (let i = 0; i < nextSlots.length; i += 1) {
|
||||||
if (!touchedMajors.has(i)) {
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true })
|
return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user