Progression optimiert Phase A #55

Merged
Lars merged 33 commits from develop into main 2026-06-11 21:26:54 +02:00
6 changed files with 133 additions and 38 deletions
Showing only changes of commit ad051c015f - Show all commits

View File

@ -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,18 +269,53 @@ 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) except (TypeError, ValueError):
except (TypeError, ValueError): 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(

View File

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

View File

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

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(): 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)

View File

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

View File

@ -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,11 +802,7 @@ 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 = emptySlotExercise()
nextSlots[i].primary?.kind === 'library' && nextSlots[i].primary.exerciseId != null
if (!keep) {
nextSlots[i].primary = emptySlotExercise()
}
} }
} }