Enhance Exercise Retrieval and Path Handling Logic
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 48s
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 1m23s

- Introduced new functions for handling exercise visibility and retrieval based on progression graph context, including `fetch_exercise_rows_by_ids_for_graph`.
- Updated `_load_supplemental_exercise_rows` to incorporate graph visibility rules, improving the accuracy of exercise retrieval.
- Enhanced `_run_path_step_retrieval` to utilize preloaded supplemental exercise rows, optimizing performance and clarity in path step processing.
- Added `exercise_title_equivalent_to_stage_goal` function to improve title matching against learning goals, enhancing exercise relevance.
- Updated tests to validate new retrieval logic and title equivalence functionality, ensuring robustness in exercise selection processes.
This commit is contained in:
Lars 2026-06-11 12:33:02 +02:00
parent ad051c015f
commit ca2adbd55e
8 changed files with 500 additions and 81 deletions

View File

@ -6,7 +6,7 @@ planning_progression_roadmap.py und PLANNING_PROGRESSION_ROADMAP_SPEC.md.
"""
from __future__ import annotations
from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Tuple
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple
from fastapi import HTTPException
from pydantic import BaseModel, Field
@ -298,7 +298,7 @@ def _supplemental_exercise_ids_from_body(
cur,
body: ProgressionPathSuggestRequest,
) -> List[int]:
"""Kandidatenpool erweitern — ohne automatisches Slot-Pinning."""
"""Kandidatenpool erweitern (Graph-Kanten, Boost, Slot-Zuordnungen)."""
ids: List[int] = []
for raw in body.evaluate_steps or []:
if raw.exercise_id is not None:
@ -308,6 +308,14 @@ def _supplemental_exercise_ids_from_body(
continue
if eid > 0:
ids.append(eid)
for raw in body.slot_assignments 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)
@ -319,6 +327,64 @@ def _supplemental_exercise_ids_from_body(
return list(dict.fromkeys(ids))
def _graph_visibility_context(
cur,
progression_graph_id: Optional[int],
) -> Tuple[str, Optional[int]]:
if not progression_graph_id or int(progression_graph_id) < 1:
return "private", None
cur.execute(
"SELECT visibility, club_id FROM exercise_progression_graphs WHERE id = %s",
(int(progression_graph_id),),
)
row = cur.fetchone()
if not row:
return "private", None
g_club = row.get("club_id")
return (
str(row.get("visibility") or "private"),
int(g_club) if g_club is not None else None,
)
def _load_supplemental_exercise_rows(
cur,
*,
tenant: TenantContext,
progression_graph_id: Optional[int],
exercise_ids: Optional[Sequence[int]],
vis_sql: str,
vis_params: Sequence[Any],
) -> List[Dict[str, Any]]:
"""Supplemental-Übungen mit Graph-Sichtbarkeit, Fallback Library-vis_sql."""
ids = list(dict.fromkeys(int(x) for x in (exercise_ids or []) if int(x) > 0))
if not ids:
return []
if progression_graph_id and int(progression_graph_id) > 0:
from planning_exercise_retrieval import fetch_exercise_rows_by_ids_for_graph
gvis, gclub = _graph_visibility_context(cur, progression_graph_id)
graph_rows = fetch_exercise_rows_by_ids_for_graph(
cur,
ids,
graph_visibility=gvis,
graph_club_id=gclub,
profile_id=tenant.profile_id,
role=tenant.global_role,
exercise_allowed_fn=_exercise_allowed_in_progression_graph,
)
if graph_rows:
return graph_rows
from planning_exercise_retrieval import fetch_exercise_rows_by_ids
return fetch_exercise_rows_by_ids(
cur,
ids,
vis_sql=vis_sql,
vis_params=vis_params,
)
def _planning_visibility_sql(
cur,
tenant: TenantContext,
@ -508,6 +574,7 @@ def _run_path_step_retrieval(
path_primary_topic: Optional[str] = None,
path_technique_excludes: Optional[List[str]] = None,
supplemental_exercise_ids: Optional[List[int]] = None,
priority_exercise_ids: Optional[List[int]] = None,
) -> Tuple[List[Dict[str, Any]], PlanningTargetProfile, Dict[str, Any], str]:
step_query = step_query_override or step_retrieval_query(
semantic_brief, goal_query, step_index, max_steps
@ -617,6 +684,14 @@ def _run_path_step_retrieval(
progression_graph_id,
)
supplemental_rows = _load_supplemental_exercise_rows(
cur,
tenant=tenant,
progression_graph_id=progression_graph_id,
exercise_ids=supplemental_exercise_ids,
vis_sql=vis_sql,
vis_params=vis_params,
)
hits, _skills_by_ex, _full_lib = run_multistage_planning_retrieval(
cur,
vis_sql=vis_sql,
@ -627,9 +702,19 @@ def _run_path_step_retrieval(
intent=intent,
intent_weights=weights,
pack=pack,
supplemental_exercise_ids=supplemental_exercise_ids,
supplemental_rows_preloaded=supplemental_rows,
)
hits = _enrich_planning_hits_with_variant_meta(cur, hits[:32])
from planning_exercise_retrieval import trim_hits_preserving_priority_ids
priority_ids = list(
dict.fromkeys(
int(x)
for x in (priority_exercise_ids or supplemental_exercise_ids or [])
if int(x) > 0
)
)
hits = trim_hits_preserving_priority_ids(hits, priority_ids, limit=48)
hits = _enrich_planning_hits_with_variant_meta(cur, hits)
return hits, target_profile, query_intent_summary, intent
@ -727,37 +812,111 @@ def _annotate_roadmap_step(
if stage_spec.success_criteria:
step["success_criteria"] = list(stage_spec.success_criteria)
step["stage_success_criteria"] = list(stage_spec.success_criteria)
step["roadmap_match_source"] = "stage_spec"
if not step.get("roadmap_match_source"):
step["roadmap_match_source"] = "stage_spec"
if step.get("exercise_id") is not None:
step["slot_status"] = step.get("slot_status") or (
"preserved" if step.get("roadmap_match_source") == "slot_reconciled" else "matched"
)
else:
step["slot_status"] = step.get("slot_status") or "unfilled"
if skill_expectations:
step["skill_expectations"] = skill_expectations
return step
def _match_roadmap_slot(
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(
cur,
*,
body: ProgressionPathSuggestRequest,
goal_query: str,
max_steps: int,
semantic_brief: PlanningSemanticBrief,
path_target_profile: PlanningTargetProfile,
path_intent: str,
roadmap_ctx: ProgressionRoadmapContext,
stage_spec: StageSpecArtifact,
step_index: int,
stage_count: int,
planned_ids: List[int],
anchor_id: Optional[int],
anchor_variant_id: Optional[int],
used: Set[int],
) -> Tuple[Optional[Dict[str, Any]], Optional[StageSpecArtifact]]:
"""Einzelnen Roadmap-Slot matchen (Initial-Build und Auto-Rematch)."""
major_by_index: Dict[int, MajorStep] = {}
if roadmap_ctx.roadmap:
major_by_index = {m.index: m for m in roadmap_ctx.roadmap.major_steps}
major = major_by_index.get(stage_spec.major_step_index)
major: Optional[MajorStep],
) -> Dict[str, Any]:
"""Gemeinsamer Kontext für Reconcile + Match eines Roadmap-Slots."""
ga_dump = (
roadmap_ctx.goal_analysis.model_dump() if roadmap_ctx.goal_analysis else None
)
@ -770,34 +929,6 @@ def _match_roadmap_slot(
structured=roadmap_ctx.resolved_structured,
goal_analysis=roadmap_ctx.goal_analysis,
)
brief_summary = (
roadmap_ctx.semantic_brief
if roadmap_ctx.semantic_brief
else brief_to_summary_dict(semantic_brief)
)
stage_spec_dict = stage_spec.model_dump()
if major:
stage_spec_dict["phase"] = major.phase
stage_inp = expectation_input_from_progression_stage(
goal_query=goal_query,
goal_analysis=ga_dump,
resolved_structured=rs_dump,
stage_spec=stage_spec_dict,
semantic_brief_summary=brief_summary,
major_step=major.model_dump() if major else None,
)
stage_exp = build_planning_skill_expectations(cur, stage_inp, semantic_brief=semantic_brief)
step_target = apply_expectations_to_target(path_target_profile, stage_exp)
skill_exp_api = stage_exp.to_api_dict() if stage_exp.items else None
step_query = stage_spec_retrieval_query(
semantic_brief=semantic_brief,
goal_query=goal_query,
stage_spec=stage_spec,
major_step=major,
)
step_kind = resolve_step_exercise_kind_filter(stage_spec, body.exercise_kind_any)
stage_goal = (stage_spec.learning_goal or "").strip()
stage_start = (stage_spec.start_state or "").strip()
stage_target = (stage_spec.target_state or "").strip()
@ -855,6 +986,105 @@ def _match_roadmap_slot(
path_target_state=path_target or None,
contextualized_learning_goal=contextual_goal or None,
)
return {
"stage_goal": stage_goal,
"stage_anti": stage_anti,
"path_primary": path_primary,
"path_tech_excludes": path_tech_excludes,
"stage_match_brief": stage_match_brief,
"path_context_note": path_context_note,
"path_anti": path_anti,
"path_start": path_start,
"path_target": path_target,
"ga_dump": ga_dump,
"rs_dump": rs_dump,
}
def _match_roadmap_slot(
cur,
*,
tenant: TenantContext,
body: ProgressionPathSuggestRequest,
goal_query: str,
max_steps: int,
semantic_brief: PlanningSemanticBrief,
path_target_profile: PlanningTargetProfile,
path_intent: str,
roadmap_ctx: ProgressionRoadmapContext,
stage_spec: StageSpecArtifact,
step_index: int,
stage_count: int,
planned_ids: List[int],
anchor_id: Optional[int],
anchor_variant_id: Optional[int],
used: Set[int],
slot_priority_exercise_id: Optional[int] = None,
) -> Tuple[Optional[Dict[str, Any]], Optional[StageSpecArtifact]]:
"""Einzelnen Roadmap-Slot matchen (Initial-Build und Auto-Rematch)."""
major_by_index: Dict[int, MajorStep] = {}
if roadmap_ctx.roadmap:
major_by_index = {m.index: m for m in roadmap_ctx.roadmap.major_steps}
major = major_by_index.get(stage_spec.major_step_index)
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,
)
stage_goal = ctx["stage_goal"]
stage_anti = ctx["stage_anti"]
path_primary = ctx["path_primary"]
path_tech_excludes = ctx["path_tech_excludes"]
stage_match_brief = ctx["stage_match_brief"]
path_context_note = ctx["path_context_note"]
ga_dump = ctx["ga_dump"]
rs_dump = ctx["rs_dump"]
brief_summary = (
roadmap_ctx.semantic_brief
if roadmap_ctx.semantic_brief
else brief_to_summary_dict(semantic_brief)
)
stage_spec_dict = stage_spec.model_dump()
if major:
stage_spec_dict["phase"] = major.phase
stage_inp = expectation_input_from_progression_stage(
goal_query=goal_query,
goal_analysis=ga_dump,
resolved_structured=rs_dump,
stage_spec=stage_spec_dict,
semantic_brief_summary=brief_summary,
major_step=major.model_dump() if major else None,
)
stage_exp = build_planning_skill_expectations(cur, stage_inp, semantic_brief=semantic_brief)
step_target = apply_expectations_to_target(path_target_profile, stage_exp)
skill_exp_api = stage_exp.to_api_dict() if stage_exp.items else None
step_query = stage_spec_retrieval_query(
semantic_brief=semantic_brief,
goal_query=goal_query,
stage_spec=stage_spec,
major_step=major,
)
step_kind = resolve_step_exercise_kind_filter(stage_spec, body.exercise_kind_any)
supplemental_ids = _supplemental_exercise_ids_from_body(cur, body)
priority_ids = list(
dict.fromkeys(
x
for x in [slot_priority_exercise_id, *(body.retrieval_boost_exercise_ids or [])]
if x is not None and int(x) > 0
)
)
hits, _, _, _ = _run_path_step_retrieval(
cur,
@ -882,7 +1112,8 @@ 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(cur, body),
supplemental_exercise_ids=supplemental_ids,
priority_exercise_ids=priority_ids,
)
hit = _pick_best_path_hit(
@ -907,6 +1138,7 @@ def _match_roadmap_slot(
skill_expectations=skill_exp_api,
anti_patterns_override=stage_anti,
)
step["slot_status"] = "matched"
return step, None
@ -949,7 +1181,8 @@ def _normalize_roadmap_steps_coverage(
"roadmap_major_step_index": midx,
"roadmap_phase": major.phase if major else None,
"roadmap_learning_goal": goal or None,
"roadmap_match_source": "stage_spec",
"roadmap_match_source": "unfilled",
"slot_status": "unfilled",
"reasons": [],
}
)
@ -1063,34 +1296,54 @@ 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)
if body.preserve_slot_assignments
else {}
)
assignments = _slot_assignments_by_major_index(body.slot_assignments)
majors_by_index: Dict[int, MajorStep] = {}
if roadmap_ctx.roadmap:
majors_by_index = {m.index: m for m in roadmap_ctx.roadmap.major_steps}
for step_index, stage_spec in enumerate(stage_specs):
major_idx = stage_spec.major_step_index
major = majors_by_index.get(major_idx)
slot_priority_id: Optional[int] = None
if major_idx in assignments:
pinned = _path_step_from_slot_assignment(
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=majors_by_index.get(major_idx),
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 pinned:
steps.append(pinned)
eid = int(pinned["exercise_id"])
if reconciled:
steps.append(reconciled)
eid = int(reconciled["exercise_id"])
used.add(eid)
planned_ids.append(eid)
anchor_id = eid
anchor_variant_id = pinned.get("variant_id")
anchor_variant_id = reconciled.get("variant_id")
continue
try:
slot_priority_id = int(assignments[major_idx].exercise_id)
except (TypeError, ValueError):
slot_priority_id = None
step, unfilled_spec = _match_roadmap_slot(
cur,
@ -1109,6 +1362,7 @@ def _build_steps_roadmap_first(
anchor_id=anchor_id,
anchor_variant_id=anchor_variant_id,
used=used,
slot_priority_exercise_id=slot_priority_id,
)
if not step:
unfilled.append((step_index, unfilled_spec or stage_spec))

View File

@ -435,7 +435,14 @@ def detect_off_topic_steps(
for idx, step in enumerate(steps):
if step.get("is_ai_proposal") or step.get("exercise_id") is None:
continue
stage_goal_early = (step.get("roadmap_learning_goal") or "").strip()
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(
bundle["title"],
bundle["summary"],

View File

@ -58,6 +58,21 @@ def _normalize_exercise_kind_filter(exercise_kind_any: Optional[List[str]]) -> L
return out
_EXERCISE_ROW_SELECT = """
SELECT e.id, e.title, e.summary, e.method_archetype,
e.visibility, e.club_id, e.created_by,
(
SELECT fa.name FROM exercise_focus_areas efa
JOIN focus_areas fa ON fa.id = efa.focus_area_id
WHERE efa.exercise_id = e.id
ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
LIMIT 1
) AS primary_focus_name,
0.0::float AS ft_rank
FROM exercises e
"""
def fetch_exercise_rows_by_ids(
cur,
exercise_ids: Sequence[int],
@ -71,16 +86,7 @@ def fetch_exercise_rows_by_ids(
return []
ph = ",".join(["%s"] * len(ids))
sql = f"""
SELECT e.id, e.title, e.summary, e.method_archetype,
(
SELECT fa.name FROM exercise_focus_areas efa
JOIN focus_areas fa ON fa.id = efa.focus_area_id
WHERE efa.exercise_id = e.id
ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
LIMIT 1
) AS primary_focus_name,
0.0::float AS ft_rank
FROM exercises e
{_EXERCISE_ROW_SELECT.strip()}
WHERE e.id IN ({ph})
AND ({vis_sql})
AND COALESCE(e.status, '') <> %s
@ -90,6 +96,67 @@ def fetch_exercise_rows_by_ids(
return [dict(r) for r in cur.fetchall()]
def fetch_exercise_rows_by_ids_for_graph(
cur,
exercise_ids: Sequence[int],
*,
graph_visibility: str,
graph_club_id: Optional[int],
profile_id: int,
role: str,
exercise_allowed_fn,
) -> List[Dict[str, Any]]:
"""
Lädt Übungen nach ID mit Graph-Sichtbarkeitsregeln (nicht Library-vis_sql).
Ermöglicht Re-Match für im Graph verankerte private Übungen auf Club-Graphen
(eigene private) bzw. alle graph-konformen Übungen.
"""
ids = sorted({int(x) for x in exercise_ids if int(x) > 0})
if not ids:
return []
ph = ",".join(["%s"] * len(ids))
sql = f"""
{_EXERCISE_ROW_SELECT.strip()}
WHERE e.id IN ({ph})
AND COALESCE(e.status, '') <> %s
"""
cur.execute(sql, [*ids, "archived"])
out: List[Dict[str, Any]] = []
for row in cur.fetchall() or []:
if exercise_allowed_fn(
row,
graph_visibility=graph_visibility,
graph_club_id=graph_club_id,
profile_id=profile_id,
role=role,
):
out.append(dict(row))
return out
def trim_hits_preserving_priority_ids(
hits: Sequence[Mapping[str, Any]],
priority_ids: Optional[Sequence[int]],
*,
limit: int = 48,
) -> List[Dict[str, Any]]:
"""Behält priorisierte Graph-/Slot-Übungen im Kandidatenpool (vor pick_best_path_hit)."""
priority_set = {int(x) for x in (priority_ids or []) if int(x) > 0}
if not priority_set:
return list(hits)[:limit]
by_id: Dict[int, Dict[str, Any]] = {}
for hit in hits:
try:
by_id[int(hit["id"])] = dict(hit)
except (TypeError, ValueError, KeyError):
continue
priority_hits = [by_id[eid] for eid in sorted(priority_set) if eid in by_id]
rest = [dict(h) for h in hits if int(h.get("id") or 0) not in priority_set]
merged = priority_hits + rest
return merged[: max(limit, len(priority_hits))]
def merge_supplemental_exercise_rows(
rows: Sequence[Dict[str, Any]],
supplemental: Sequence[Dict[str, Any]],
@ -485,6 +552,7 @@ def run_multistage_planning_retrieval(
intent_weights: Mapping[str, float],
pack: Mapping[str, Any],
supplemental_exercise_ids: Optional[Sequence[int]] = None,
supplemental_rows_preloaded: Optional[Sequence[Dict[str, Any]]] = None,
) -> Tuple[List[Dict[str, Any]], Dict[int, Set[int]], bool]:
"""Orchestriert S1b-0 → S1b-1 (Voll-Library-Ranking)."""
rows = fetch_all_visible_exercise_rows(
@ -494,7 +562,9 @@ def run_multistage_planning_retrieval(
query=pack.get("retrieval_query") or query,
exercise_kind_any=exercise_kind_any,
)
if supplemental_exercise_ids:
if supplemental_rows_preloaded:
rows = merge_supplemental_exercise_rows(rows, supplemental_rows_preloaded)
elif supplemental_exercise_ids:
extra = fetch_exercise_rows_by_ids(
cur,
supplemental_exercise_ids,

View File

@ -153,6 +153,48 @@ def _normalize_phrase(text: str) -> str:
return re.sub(r"\s+", " ", (text or "").strip().lower())
_STAGE_TITLE_STOP = frozenset(
{"für", "fur", "und", "der", "die", "das", "mit", "im", "in", "am", "an", "zur", "zum", "den", "dem", "des"}
)
def _stage_title_tokens(text: str) -> List[str]:
return [
tok
for tok in _normalize_phrase(text).split()
if tok not in _STAGE_TITLE_STOP and len(tok) > 1
]
def exercise_title_equivalent_to_stage_goal(title: str, learning_goal: str) -> bool:
"""
Titel entspricht dem Stufen-Lernziel (wortgleich oder nahezu identisch).
Deckt Graph-Slots ab, bei denen die Übung gezielt zum Lernziel angelegt wurde,
ohne dass die Pfad-Haupttechnik im Übungstext vorkommt.
"""
t = _normalize_phrase(title)
lg = _normalize_phrase(learning_goal)
if len(t) < 3 or len(lg) < 3:
return False
if t == lg:
return True
shorter, longer = (t, lg) if len(t) <= len(lg) else (lg, t)
if shorter in longer and len(shorter) >= 8 and len(shorter) / max(len(longer), 1) >= 0.72:
return True
t_tok = _stage_title_tokens(title)
lg_tok = _stage_title_tokens(learning_goal)
if len(t_tok) >= 2 and t_tok == lg_tok:
return True
if len(t_tok) >= 2 and len(lg_tok) >= 2:
t_set = set(t_tok)
lg_set = set(lg_tok)
overlap = len(t_set & lg_set)
if overlap >= 2 and overlap / max(len(t_set), len(lg_set)) >= 0.85:
return True
return False
def _normalize_query(text: str) -> str:
return re.sub(r"\s+", " ", (text or "").strip())
@ -1059,6 +1101,9 @@ def exercise_passes_stage_fit(
if constraints.exclude_phrases and _blob_matches_stage_excludes(blob, constraints.exclude_phrases):
return False
if exercise_title_equivalent_to_stage_goal(title, learning_goal or lg):
return True
primary_path = (path_primary_topic or "").strip()
if not primary_path and lg:
hit = _find_technique_in_text(_normalize_phrase(lg))
@ -1327,6 +1372,7 @@ __all__ = [
"build_stage_match_brief",
"enrich_brief_with_path_constraints",
"exercise_passes_stage_fit",
"exercise_title_equivalent_to_stage_goal",
"resolve_path_primary_topic",
"resolve_path_anti_patterns",
"exercise_passes_stage_learning_goal_gate",

View File

@ -18,7 +18,7 @@ class _FakeCur:
return []
def test_supplemental_boost_uses_retrieval_boost_not_slot_pins():
def test_supplemental_boost_includes_slot_assignments_and_retrieval_boost():
body = ProgressionPathSuggestRequest(
query="Mawashi Geri Progression",
slot_assignments=[
@ -27,7 +27,7 @@ def test_supplemental_boost_uses_retrieval_boost_not_slot_pins():
retrieval_boost_exercise_ids=[42, 7],
)
ids = _supplemental_exercise_ids_from_body(_FakeCur(), body)
assert 99 not in ids
assert 99 in ids
assert 42 in ids
assert 7 in ids

View File

@ -340,6 +340,32 @@ def test_technique_scope_rejects_kumite_when_only_stage_goal_mentions_mawashi():
)
def test_title_equivalent_to_stage_goal():
from planning_exercise_semantics import exercise_title_equivalent_to_stage_goal
assert exercise_title_equivalent_to_stage_goal(
"Hüftmobilität für Mae Geri",
"Hüftmobilität für Mae Geri",
)
assert exercise_title_equivalent_to_stage_goal(
"Hüftmobilität Mae Geri",
"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():
stage_goal = "Koordination Absprung ohne Kick"
assert exercise_passes_stage_fit(
learning_goal=stage_goal,
title=stage_goal,
summary="",
goal="",
path_primary_topic="mawashi geri",
path_technique_excludes=["kumite"],
)
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"

View File

@ -42,6 +42,7 @@ import {
slotsAsPathStepRows,
slotsToEvaluateSteps,
draftRetrievalBoostExerciseIds,
slotsToSlotAssignments,
syncProgressionRoadmapFromSlots,
syncSlotPhasesFromRoadmap,
} from '../utils/progressionGraphDraft'
@ -415,6 +416,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,7 +712,22 @@ export function draftSiblingEdgePairs(draft) {
return pairs
}
/** Bereits zugeordnete Bibliotheks-Übungen — nur Retriever-Boost, kein Pinning. */
/** Slot-Zuordnungen für Backend-Reconciliation (validiert, nicht blind gepinnt). */
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,
}))
}
/** Alle Graph-Übungs-IDs für Retriever-Boost (Slots + Geschwister). */
export function draftRetrievalBoostExerciseIds(draft) {
const ids = new Set()
for (const slot of draft.slots || []) {
@ -784,7 +799,12 @@ export function applyMatchStepsToSlots(draft, apiSteps) {
const isProposal = Boolean(step.is_ai_proposal) || step.exercise_id == null
const hasAiPayload = Boolean(step.ai_suggestion) || Boolean(step.proposal_key)
if (isProposal && !hasAiPayload) {
nextSlots[idx].primary = emptySlotExercise()
const wasLibrary =
nextSlots[idx].primary?.kind === 'library' && nextSlots[idx].primary.exerciseId != null
const mustClear = step.slot_status === 'unfilled' || step.slot_status === 'stripped'
if (!wasLibrary || mustClear) {
nextSlots[idx].primary = emptySlotExercise()
}
} else if (isProposal) {
nextSlots[idx].primary = proposalSlotExercise({
title: step.title || nextSlots[idx].learning_goal,
@ -800,12 +820,6 @@ export function applyMatchStepsToSlots(draft, apiSteps) {
}
}
for (let i = 0; i < nextSlots.length; i += 1) {
if (!touchedMajors.has(i)) {
nextSlots[i].primary = emptySlotExercise()
}
}
return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true })
}