Refactor Roadmap Step Annotation and Slot Assignment Logic
All checks were successful
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m12s
All checks were successful
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m12s
- Updated `_annotate_roadmap_step` to change the condition for setting `slot_status` based on `roadmap_match_source`, improving clarity in slot assignment handling. - Removed the `_try_reconcile_slot_assignment` function to streamline the slot assignment process, as its logic is now integrated into the main flow. - Enhanced `_match_roadmap_slot` to conditionally preserve slot assignments based on exercise ID, ensuring better handling of existing assignments. - Improved the handling of semantic scores in `rank_visible_library_hits` to prioritize the best semantic fit, enhancing exercise retrieval accuracy. - Added tests to validate the new logic for title equivalence and semantic scoring, ensuring robustness in exercise selection processes.
This commit is contained in:
parent
ca2adbd55e
commit
b2fbf6b4af
|
|
@ -816,7 +816,7 @@ def _annotate_roadmap_step(
|
||||||
step["roadmap_match_source"] = "stage_spec"
|
step["roadmap_match_source"] = "stage_spec"
|
||||||
if step.get("exercise_id") is not None:
|
if step.get("exercise_id") is not None:
|
||||||
step["slot_status"] = step.get("slot_status") or (
|
step["slot_status"] = step.get("slot_status") or (
|
||||||
"preserved" if step.get("roadmap_match_source") == "slot_reconciled" else "matched"
|
"preserved" if step.get("roadmap_match_source") == "slot_best_match" else "matched"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
step["slot_status"] = step.get("slot_status") or "unfilled"
|
step["slot_status"] = step.get("slot_status") or "unfilled"
|
||||||
|
|
@ -825,84 +825,6 @@ def _annotate_roadmap_step(
|
||||||
return step
|
return step
|
||||||
|
|
||||||
|
|
||||||
def _try_reconcile_slot_assignment(
|
|
||||||
cur,
|
|
||||||
*,
|
|
||||||
assignment: EvaluateStepPayload,
|
|
||||||
stage_spec: StageSpecArtifact,
|
|
||||||
major_step: Optional[MajorStep],
|
|
||||||
tenant: TenantContext,
|
|
||||||
progression_graph_id: Optional[int],
|
|
||||||
stage_match_brief: Optional[PlanningSemanticBrief],
|
|
||||||
stage_goal: str,
|
|
||||||
stage_anti: Optional[List[str]],
|
|
||||||
path_primary: str,
|
|
||||||
path_tech_excludes: Optional[List[str]],
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Bestehende Slot-Zuordnung behalten, wenn sie noch zum Stufen-Lernziel passt.
|
|
||||||
|
|
||||||
Validiert gegen dieselben Gates wie Match/QA (relaxed), inkl. Titel-Äquivalenz.
|
|
||||||
"""
|
|
||||||
from planning_exercise_semantics import (
|
|
||||||
exercise_passes_stage_fit,
|
|
||||||
exercise_title_equivalent_to_stage_goal,
|
|
||||||
)
|
|
||||||
|
|
||||||
step = _path_step_from_slot_assignment(
|
|
||||||
cur,
|
|
||||||
assignment=assignment,
|
|
||||||
stage_spec=stage_spec,
|
|
||||||
major_step=major_step,
|
|
||||||
tenant=tenant,
|
|
||||||
progression_graph_id=progression_graph_id,
|
|
||||||
)
|
|
||||||
if not step:
|
|
||||||
return None
|
|
||||||
|
|
||||||
title = str(step.get("title") or "").strip()
|
|
||||||
summary = str(step.get("summary") or "").strip()
|
|
||||||
goal = ""
|
|
||||||
cur.execute("SELECT goal FROM exercises WHERE id = %s", (int(step["exercise_id"]),))
|
|
||||||
grow = cur.fetchone()
|
|
||||||
if grow:
|
|
||||||
goal = str(grow.get("goal") or "").strip()
|
|
||||||
|
|
||||||
lg = (stage_goal or stage_spec.learning_goal or "").strip()
|
|
||||||
if exercise_title_equivalent_to_stage_goal(title, lg):
|
|
||||||
step["roadmap_match_source"] = "slot_reconciled"
|
|
||||||
step["slot_status"] = "preserved"
|
|
||||||
step["reasons"] = ["Bestehende Zuordnung (Titel = Lernziel)"] + list(step.get("reasons") or [])[:2]
|
|
||||||
return _annotate_roadmap_step(
|
|
||||||
step,
|
|
||||||
stage_spec=stage_spec,
|
|
||||||
major_step=major_step,
|
|
||||||
anti_patterns_override=stage_anti,
|
|
||||||
)
|
|
||||||
|
|
||||||
if exercise_passes_stage_fit(
|
|
||||||
learning_goal=lg,
|
|
||||||
title=title,
|
|
||||||
summary=summary,
|
|
||||||
goal=goal,
|
|
||||||
stage_brief=stage_match_brief,
|
|
||||||
anti_patterns=stage_anti,
|
|
||||||
path_primary_topic=path_primary or None,
|
|
||||||
path_technique_excludes=path_tech_excludes,
|
|
||||||
relaxed=True,
|
|
||||||
):
|
|
||||||
step["roadmap_match_source"] = "slot_reconciled"
|
|
||||||
step["slot_status"] = "preserved"
|
|
||||||
step["reasons"] = ["Bestehende Zuordnung (Stufen-Fit)"] + list(step.get("reasons") or [])[:2]
|
|
||||||
return _annotate_roadmap_step(
|
|
||||||
step,
|
|
||||||
stage_spec=stage_spec,
|
|
||||||
major_step=major_step,
|
|
||||||
anti_patterns_override=stage_anti,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _stage_validation_context_for_spec(
|
def _stage_validation_context_for_spec(
|
||||||
cur,
|
cur,
|
||||||
*,
|
*,
|
||||||
|
|
@ -1138,7 +1060,16 @@ def _match_roadmap_slot(
|
||||||
skill_expectations=skill_exp_api,
|
skill_expectations=skill_exp_api,
|
||||||
anti_patterns_override=stage_anti,
|
anti_patterns_override=stage_anti,
|
||||||
)
|
)
|
||||||
|
if (
|
||||||
|
slot_priority_exercise_id is not None
|
||||||
|
and int(step["exercise_id"]) == int(slot_priority_exercise_id)
|
||||||
|
):
|
||||||
|
step["slot_status"] = "preserved"
|
||||||
|
step["roadmap_match_source"] = "slot_best_match"
|
||||||
|
step["reasons"] = ["Bester Treffer (bestehende Zuordnung)"] + list(step.get("reasons") or [])[:2]
|
||||||
|
else:
|
||||||
step["slot_status"] = "matched"
|
step["slot_status"] = "matched"
|
||||||
|
step["roadmap_match_source"] = "stage_spec"
|
||||||
return step, None
|
return step, None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1307,39 +1238,6 @@ def _build_steps_roadmap_first(
|
||||||
slot_priority_id: Optional[int] = None
|
slot_priority_id: Optional[int] = None
|
||||||
|
|
||||||
if major_idx in assignments:
|
if major_idx in assignments:
|
||||||
ctx = _stage_validation_context_for_spec(
|
|
||||||
cur,
|
|
||||||
body=body,
|
|
||||||
goal_query=goal_query,
|
|
||||||
semantic_brief=semantic_brief,
|
|
||||||
path_target_profile=path_target_profile,
|
|
||||||
roadmap_ctx=roadmap_ctx,
|
|
||||||
stage_spec=stage_spec,
|
|
||||||
step_index=step_index,
|
|
||||||
stage_count=stage_count,
|
|
||||||
major=major,
|
|
||||||
)
|
|
||||||
reconciled = _try_reconcile_slot_assignment(
|
|
||||||
cur,
|
|
||||||
assignment=assignments[major_idx],
|
|
||||||
stage_spec=stage_spec,
|
|
||||||
major_step=major,
|
|
||||||
tenant=tenant,
|
|
||||||
progression_graph_id=body.progression_graph_id,
|
|
||||||
stage_match_brief=ctx["stage_match_brief"],
|
|
||||||
stage_goal=ctx["stage_goal"],
|
|
||||||
stage_anti=ctx["stage_anti"],
|
|
||||||
path_primary=ctx["path_primary"],
|
|
||||||
path_tech_excludes=ctx["path_tech_excludes"],
|
|
||||||
)
|
|
||||||
if reconciled:
|
|
||||||
steps.append(reconciled)
|
|
||||||
eid = int(reconciled["exercise_id"])
|
|
||||||
used.add(eid)
|
|
||||||
planned_ids.append(eid)
|
|
||||||
anchor_id = eid
|
|
||||||
anchor_variant_id = reconciled.get("variant_id")
|
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
slot_priority_id = int(assignments[major_idx].exercise_id)
|
slot_priority_id = int(assignments[major_idx].exercise_id)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
|
|
|
||||||
|
|
@ -435,14 +435,7 @@ def detect_off_topic_steps(
|
||||||
for idx, step in enumerate(steps):
|
for idx, step in enumerate(steps):
|
||||||
if step.get("is_ai_proposal") or step.get("exercise_id") is None:
|
if step.get("is_ai_proposal") or step.get("exercise_id") is None:
|
||||||
continue
|
continue
|
||||||
stage_goal_early = (step.get("roadmap_learning_goal") or "").strip()
|
|
||||||
bundle = _load_exercise_text_bundle(cur, int(step["exercise_id"]))
|
bundle = _load_exercise_text_bundle(cur, int(step["exercise_id"]))
|
||||||
from planning_exercise_semantics import exercise_title_equivalent_to_stage_goal
|
|
||||||
|
|
||||||
if stage_goal_early and exercise_title_equivalent_to_stage_goal(
|
|
||||||
bundle["title"], stage_goal_early
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
blob = _blob_from_fields(
|
blob = _blob_from_fields(
|
||||||
bundle["title"],
|
bundle["title"],
|
||||||
bundle["summary"],
|
bundle["summary"],
|
||||||
|
|
|
||||||
|
|
@ -431,8 +431,30 @@ def rank_visible_library_hits(
|
||||||
step_phase=step_phase,
|
step_phase=step_phase,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
rank_stage_sem = stage_semantic_score
|
||||||
|
stage_lg = (stage_learning_goal or "").strip()
|
||||||
|
if roadmap_stage_match and stage_lg:
|
||||||
|
raw_brief = build_stage_match_brief(
|
||||||
|
learning_goal=stage_lg,
|
||||||
|
anti_patterns=pack.get("stage_anti_patterns"),
|
||||||
|
phase=step_phase,
|
||||||
|
)
|
||||||
|
raw_sem, raw_reasons = score_exercise_stage_fit(
|
||||||
|
title=title_s,
|
||||||
|
summary=summary_s,
|
||||||
|
goal=goal_s,
|
||||||
|
variant_names=variants_by_ex.get(eid, []),
|
||||||
|
stage_brief=raw_brief,
|
||||||
|
step_phase=step_phase,
|
||||||
|
)
|
||||||
|
rank_stage_sem = max(stage_semantic_score, raw_sem)
|
||||||
|
if raw_sem > stage_semantic_score and raw_reasons:
|
||||||
|
for rr in raw_reasons:
|
||||||
|
if rr not in stage_semantic_reasons:
|
||||||
|
stage_semantic_reasons.append(rr)
|
||||||
|
|
||||||
effective_semantic = (
|
effective_semantic = (
|
||||||
stage_semantic_score
|
rank_stage_sem
|
||||||
if roadmap_stage_match and stage_match_brief
|
if roadmap_stage_match and stage_match_brief
|
||||||
else semantic_score
|
else semantic_score
|
||||||
)
|
)
|
||||||
|
|
@ -461,7 +483,7 @@ def rank_visible_library_hits(
|
||||||
summary=summary_s,
|
summary=summary_s,
|
||||||
goal=goal_s,
|
goal=goal_s,
|
||||||
stage_brief=stage_match_brief,
|
stage_brief=stage_match_brief,
|
||||||
stage_semantic_score=stage_semantic_score,
|
stage_semantic_score=rank_stage_sem,
|
||||||
anti_patterns=pack.get("stage_anti_patterns"),
|
anti_patterns=pack.get("stage_anti_patterns"),
|
||||||
step_phase=step_phase,
|
step_phase=step_phase,
|
||||||
path_primary_topic=pack.get("path_primary_topic"),
|
path_primary_topic=pack.get("path_primary_topic"),
|
||||||
|
|
@ -528,6 +550,7 @@ def rank_visible_library_hits(
|
||||||
"reasons": reasons,
|
"reasons": reasons,
|
||||||
"semantic_score": round(semantic_score, 4),
|
"semantic_score": round(semantic_score, 4),
|
||||||
"stage_semantic_score": round(stage_semantic_score, 4),
|
"stage_semantic_score": round(stage_semantic_score, 4),
|
||||||
|
"stage_rank_semantic": round(rank_stage_sem, 4),
|
||||||
"goal": goal_s,
|
"goal": goal_s,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -954,6 +954,7 @@ def enrich_brief_with_path_constraints(
|
||||||
|
|
||||||
_MIN_STAGE_FIT_SEMANTIC = 0.30
|
_MIN_STAGE_FIT_SEMANTIC = 0.30
|
||||||
_MIN_STAGE_FIT_RELAXED = 0.20
|
_MIN_STAGE_FIT_RELAXED = 0.20
|
||||||
|
_MIN_TITLE_EQUIV_SEMANTIC = 0.15
|
||||||
|
|
||||||
|
|
||||||
def build_stage_match_brief(
|
def build_stage_match_brief(
|
||||||
|
|
@ -1101,8 +1102,7 @@ def exercise_passes_stage_fit(
|
||||||
if constraints.exclude_phrases and _blob_matches_stage_excludes(blob, constraints.exclude_phrases):
|
if constraints.exclude_phrases and _blob_matches_stage_excludes(blob, constraints.exclude_phrases):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if exercise_title_equivalent_to_stage_goal(title, learning_goal or lg):
|
title_equiv = exercise_title_equivalent_to_stage_goal(title, learning_goal or lg)
|
||||||
return True
|
|
||||||
|
|
||||||
primary_path = (path_primary_topic or "").strip()
|
primary_path = (path_primary_topic or "").strip()
|
||||||
if not primary_path and lg:
|
if not primary_path and lg:
|
||||||
|
|
@ -1114,7 +1114,7 @@ def exercise_passes_stage_fit(
|
||||||
for item in technique_sibling_excludes(primary_path):
|
for item in technique_sibling_excludes(primary_path):
|
||||||
if item not in tech_excludes:
|
if item not in tech_excludes:
|
||||||
tech_excludes.append(item)
|
tech_excludes.append(item)
|
||||||
if primary_path and not exercise_passes_technique_path_scope(
|
if primary_path and not title_equiv and not exercise_passes_technique_path_scope(
|
||||||
primary_topic=primary_path,
|
primary_topic=primary_path,
|
||||||
title=title,
|
title=title,
|
||||||
summary=summary,
|
summary=summary,
|
||||||
|
|
@ -1139,7 +1139,12 @@ def exercise_passes_stage_fit(
|
||||||
step_phase=step_phase,
|
step_phase=step_phase,
|
||||||
)
|
)
|
||||||
|
|
||||||
threshold = _MIN_STAGE_FIT_RELAXED if relaxed else min_stage_semantic
|
if relaxed:
|
||||||
|
threshold = _MIN_STAGE_FIT_RELAXED
|
||||||
|
elif title_equiv:
|
||||||
|
threshold = _MIN_TITLE_EQUIV_SEMANTIC
|
||||||
|
else:
|
||||||
|
threshold = min_stage_semantic
|
||||||
return float(stage_sem or 0.0) >= threshold
|
return float(stage_sem or 0.0) >= threshold
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1291,7 +1296,11 @@ def pick_best_path_hit(
|
||||||
summary = str(hit.get("summary") or "")
|
summary = str(hit.get("summary") or "")
|
||||||
goal_text = str(hit.get("goal") or hit.get("exercise_goal") or "")
|
goal_text = str(hit.get("goal") or hit.get("exercise_goal") or "")
|
||||||
sem = float(hit.get("semantic_score") or 0.0)
|
sem = float(hit.get("semantic_score") or 0.0)
|
||||||
stage_sem = float(hit.get("stage_semantic_score") or sem)
|
stage_sem = float(
|
||||||
|
hit.get("stage_rank_semantic")
|
||||||
|
or hit.get("stage_semantic_score")
|
||||||
|
or sem
|
||||||
|
)
|
||||||
|
|
||||||
if roadmap_stage_match and stage_goal:
|
if roadmap_stage_match and stage_goal:
|
||||||
if not exercise_passes_stage_fit(
|
if not exercise_passes_stage_fit(
|
||||||
|
|
|
||||||
|
|
@ -354,18 +354,57 @@ def test_title_equivalent_to_stage_goal():
|
||||||
assert not exercise_title_equivalent_to_stage_goal("Kumite", "Hüftmobilität für Mae Geri")
|
assert not exercise_title_equivalent_to_stage_goal("Kumite", "Hüftmobilität für Mae Geri")
|
||||||
|
|
||||||
|
|
||||||
def test_stage_fit_passes_for_title_equivalent_despite_missing_path_technique():
|
def test_stage_fit_passes_for_title_equivalent_with_sufficient_semantic_score():
|
||||||
stage_goal = "Koordination Absprung ohne Kick"
|
stage_goal = "Koordination Absprung ohne Kick"
|
||||||
assert exercise_passes_stage_fit(
|
assert exercise_passes_stage_fit(
|
||||||
learning_goal=stage_goal,
|
learning_goal=stage_goal,
|
||||||
title=stage_goal,
|
title=stage_goal,
|
||||||
summary="",
|
summary="Absprung und Landung koordinieren",
|
||||||
goal="",
|
goal="",
|
||||||
path_primary_topic="mawashi geri",
|
path_primary_topic="mawashi geri",
|
||||||
path_technique_excludes=["kumite"],
|
path_technique_excludes=["kumite"],
|
||||||
|
stage_semantic_score=0.42,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_best_prefers_semantic_fit_over_coincidental_title():
|
||||||
|
stage_goal = "Hüftmobilität für Mawashi Geri"
|
||||||
|
stage_brief = build_stage_match_brief(learning_goal=stage_goal)
|
||||||
|
hits = [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Hüftmobilität für Mawashi Geri",
|
||||||
|
"summary": "allgemeine Aufwärmung",
|
||||||
|
"goal": "",
|
||||||
|
"score": 0.9,
|
||||||
|
"semantic_score": 0.12,
|
||||||
|
"stage_semantic_score": 0.12,
|
||||||
|
"stage_rank_semantic": 0.35,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "Mawashi Hüftmobilität und Adduktoren",
|
||||||
|
"summary": "Dehnung Hüfte für Rundtritt",
|
||||||
|
"goal": "Mawashi Geri Hüftbeweglichkeit",
|
||||||
|
"score": 0.72,
|
||||||
|
"semantic_score": 0.58,
|
||||||
|
"stage_semantic_score": 0.58,
|
||||||
|
"stage_rank_semantic": 0.62,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
chosen = pick_best_path_hit(
|
||||||
|
hits,
|
||||||
|
set(),
|
||||||
|
stage_learning_goal=stage_goal,
|
||||||
|
roadmap_stage_match=True,
|
||||||
|
stage_match_brief=stage_brief,
|
||||||
|
path_primary_topic="mawashi geri",
|
||||||
|
path_technique_excludes=technique_sibling_excludes("mawashi geri"),
|
||||||
|
)
|
||||||
|
assert chosen is not None
|
||||||
|
assert int(chosen["id"]) == 2
|
||||||
|
|
||||||
|
|
||||||
def test_pick_roadmap_relaxed_with_path_primary_when_strict_fails():
|
def test_pick_roadmap_relaxed_with_path_primary_when_strict_fails():
|
||||||
"""Bestehende Graph-Übungen: relaxed Gate auch bei gesetztem path_primary_topic."""
|
"""Bestehende Graph-Übungen: relaxed Gate auch bei gesetztem path_primary_topic."""
|
||||||
stage_goal = "Hüftmobilität für Mawashi Geri"
|
stage_goal = "Hüftmobilität für Mawashi Geri"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user