From ca2adbd55ec645621c9067157c6ac7e3541cb596 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 11 Jun 2026 12:33:02 +0200 Subject: [PATCH] Enhance Exercise Retrieval and Path Handling Logic - 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. --- backend/planning_exercise_path_builder.py | 374 +++++++++++++++--- backend/planning_exercise_path_qa.py | 7 + backend/planning_exercise_retrieval.py | 92 ++++- backend/planning_exercise_semantics.py | 46 +++ .../test_planning_exercise_path_builder.py | 4 +- .../test_planning_roadmap_stage_match.py | 26 ++ .../src/components/ProgressionGraphEditor.jsx | 2 + frontend/src/utils/progressionGraphDraft.js | 30 +- 8 files changed, 500 insertions(+), 81 deletions(-) diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 700272a..1783275 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -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)) diff --git a/backend/planning_exercise_path_qa.py b/backend/planning_exercise_path_qa.py index d3e50a7..4cb3c85 100644 --- a/backend/planning_exercise_path_qa.py +++ b/backend/planning_exercise_path_qa.py @@ -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"], diff --git a/backend/planning_exercise_retrieval.py b/backend/planning_exercise_retrieval.py index da71d8a..deb0d13 100644 --- a/backend/planning_exercise_retrieval.py +++ b/backend/planning_exercise_retrieval.py @@ -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, diff --git a/backend/planning_exercise_semantics.py b/backend/planning_exercise_semantics.py index 1a378cd..ecb9ac9 100644 --- a/backend/planning_exercise_semantics.py +++ b/backend/planning_exercise_semantics.py @@ -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", diff --git a/backend/tests/test_planning_exercise_path_builder.py b/backend/tests/test_planning_exercise_path_builder.py index 5c021b6..ef20664 100644 --- a/backend/tests/test_planning_exercise_path_builder.py +++ b/backend/tests/test_planning_exercise_path_builder.py @@ -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 diff --git a/backend/tests/test_planning_roadmap_stage_match.py b/backend/tests/test_planning_roadmap_stage_match.py index 0c9fdc9..e620c95 100644 --- a/backend/tests/test_planning_roadmap_stage_match.py +++ b/backend/tests/test_planning_roadmap_stage_match.py @@ -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" diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index 042acfa..ecb7fd4 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -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), diff --git a/frontend/src/utils/progressionGraphDraft.js b/frontend/src/utils/progressionGraphDraft.js index 96e7632..c3960e6 100644 --- a/frontend/src/utils/progressionGraphDraft.js +++ b/frontend/src/utils/progressionGraphDraft.js @@ -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 }) }