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
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:
parent
ad051c015f
commit
ca2adbd55e
|
|
@ -6,7 +6,7 @@ planning_progression_roadmap.py und PLANNING_PROGRESSION_ROADMAP_SPEC.md.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
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 fastapi import HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
@ -298,7 +298,7 @@ def _supplemental_exercise_ids_from_body(
|
||||||
cur,
|
cur,
|
||||||
body: ProgressionPathSuggestRequest,
|
body: ProgressionPathSuggestRequest,
|
||||||
) -> List[int]:
|
) -> List[int]:
|
||||||
"""Kandidatenpool erweitern — ohne automatisches Slot-Pinning."""
|
"""Kandidatenpool erweitern (Graph-Kanten, Boost, Slot-Zuordnungen)."""
|
||||||
ids: List[int] = []
|
ids: List[int] = []
|
||||||
for raw in body.evaluate_steps or []:
|
for raw in body.evaluate_steps or []:
|
||||||
if raw.exercise_id is not None:
|
if raw.exercise_id is not None:
|
||||||
|
|
@ -308,6 +308,14 @@ def _supplemental_exercise_ids_from_body(
|
||||||
continue
|
continue
|
||||||
if eid > 0:
|
if eid > 0:
|
||||||
ids.append(eid)
|
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 []:
|
for eid in body.retrieval_boost_exercise_ids or []:
|
||||||
try:
|
try:
|
||||||
val = int(eid)
|
val = int(eid)
|
||||||
|
|
@ -319,6 +327,64 @@ def _supplemental_exercise_ids_from_body(
|
||||||
return list(dict.fromkeys(ids))
|
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(
|
def _planning_visibility_sql(
|
||||||
cur,
|
cur,
|
||||||
tenant: TenantContext,
|
tenant: TenantContext,
|
||||||
|
|
@ -508,6 +574,7 @@ def _run_path_step_retrieval(
|
||||||
path_primary_topic: Optional[str] = None,
|
path_primary_topic: Optional[str] = None,
|
||||||
path_technique_excludes: Optional[List[str]] = None,
|
path_technique_excludes: Optional[List[str]] = None,
|
||||||
supplemental_exercise_ids: Optional[List[int]] = 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]:
|
) -> Tuple[List[Dict[str, Any]], PlanningTargetProfile, Dict[str, Any], str]:
|
||||||
step_query = step_query_override or step_retrieval_query(
|
step_query = step_query_override or step_retrieval_query(
|
||||||
semantic_brief, goal_query, step_index, max_steps
|
semantic_brief, goal_query, step_index, max_steps
|
||||||
|
|
@ -617,6 +684,14 @@ def _run_path_step_retrieval(
|
||||||
progression_graph_id,
|
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(
|
hits, _skills_by_ex, _full_lib = run_multistage_planning_retrieval(
|
||||||
cur,
|
cur,
|
||||||
vis_sql=vis_sql,
|
vis_sql=vis_sql,
|
||||||
|
|
@ -627,9 +702,19 @@ def _run_path_step_retrieval(
|
||||||
intent=intent,
|
intent=intent,
|
||||||
intent_weights=weights,
|
intent_weights=weights,
|
||||||
pack=pack,
|
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
|
return hits, target_profile, query_intent_summary, intent
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -727,37 +812,111 @@ def _annotate_roadmap_step(
|
||||||
if stage_spec.success_criteria:
|
if stage_spec.success_criteria:
|
||||||
step["success_criteria"] = list(stage_spec.success_criteria)
|
step["success_criteria"] = list(stage_spec.success_criteria)
|
||||||
step["stage_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:
|
if skill_expectations:
|
||||||
step["skill_expectations"] = skill_expectations
|
step["skill_expectations"] = skill_expectations
|
||||||
return step
|
return step
|
||||||
|
|
||||||
|
|
||||||
def _match_roadmap_slot(
|
def _try_reconcile_slot_assignment(
|
||||||
cur,
|
cur,
|
||||||
*,
|
*,
|
||||||
|
assignment: EvaluateStepPayload,
|
||||||
|
stage_spec: StageSpecArtifact,
|
||||||
|
major_step: Optional[MajorStep],
|
||||||
tenant: TenantContext,
|
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,
|
body: ProgressionPathSuggestRequest,
|
||||||
goal_query: str,
|
goal_query: str,
|
||||||
max_steps: int,
|
|
||||||
semantic_brief: PlanningSemanticBrief,
|
semantic_brief: PlanningSemanticBrief,
|
||||||
path_target_profile: PlanningTargetProfile,
|
path_target_profile: PlanningTargetProfile,
|
||||||
path_intent: str,
|
|
||||||
roadmap_ctx: ProgressionRoadmapContext,
|
roadmap_ctx: ProgressionRoadmapContext,
|
||||||
stage_spec: StageSpecArtifact,
|
stage_spec: StageSpecArtifact,
|
||||||
step_index: int,
|
step_index: int,
|
||||||
stage_count: int,
|
stage_count: int,
|
||||||
planned_ids: List[int],
|
major: Optional[MajorStep],
|
||||||
anchor_id: Optional[int],
|
) -> Dict[str, Any]:
|
||||||
anchor_variant_id: Optional[int],
|
"""Gemeinsamer Kontext für Reconcile + Match eines Roadmap-Slots."""
|
||||||
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)
|
|
||||||
|
|
||||||
ga_dump = (
|
ga_dump = (
|
||||||
roadmap_ctx.goal_analysis.model_dump() if roadmap_ctx.goal_analysis else None
|
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,
|
structured=roadmap_ctx.resolved_structured,
|
||||||
goal_analysis=roadmap_ctx.goal_analysis,
|
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_goal = (stage_spec.learning_goal or "").strip()
|
||||||
stage_start = (stage_spec.start_state or "").strip()
|
stage_start = (stage_spec.start_state or "").strip()
|
||||||
stage_target = (stage_spec.target_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,
|
path_target_state=path_target or None,
|
||||||
contextualized_learning_goal=contextual_goal 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(
|
hits, _, _, _ = _run_path_step_retrieval(
|
||||||
cur,
|
cur,
|
||||||
|
|
@ -882,7 +1112,8 @@ 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(cur, body),
|
supplemental_exercise_ids=supplemental_ids,
|
||||||
|
priority_exercise_ids=priority_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
hit = _pick_best_path_hit(
|
hit = _pick_best_path_hit(
|
||||||
|
|
@ -907,6 +1138,7 @@ def _match_roadmap_slot(
|
||||||
skill_expectations=skill_exp_api,
|
skill_expectations=skill_exp_api,
|
||||||
anti_patterns_override=stage_anti,
|
anti_patterns_override=stage_anti,
|
||||||
)
|
)
|
||||||
|
step["slot_status"] = "matched"
|
||||||
return step, None
|
return step, None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -949,7 +1181,8 @@ def _normalize_roadmap_steps_coverage(
|
||||||
"roadmap_major_step_index": midx,
|
"roadmap_major_step_index": midx,
|
||||||
"roadmap_phase": major.phase if major else None,
|
"roadmap_phase": major.phase if major else None,
|
||||||
"roadmap_learning_goal": goal or None,
|
"roadmap_learning_goal": goal or None,
|
||||||
"roadmap_match_source": "stage_spec",
|
"roadmap_match_source": "unfilled",
|
||||||
|
"slot_status": "unfilled",
|
||||||
"reasons": [],
|
"reasons": [],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -1063,34 +1296,54 @@ 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 = (
|
assignments = _slot_assignments_by_major_index(body.slot_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}
|
||||||
|
|
||||||
for step_index, stage_spec in enumerate(stage_specs):
|
for step_index, stage_spec in enumerate(stage_specs):
|
||||||
major_idx = stage_spec.major_step_index
|
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:
|
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,
|
cur,
|
||||||
assignment=assignments[major_idx],
|
assignment=assignments[major_idx],
|
||||||
stage_spec=stage_spec,
|
stage_spec=stage_spec,
|
||||||
major_step=majors_by_index.get(major_idx),
|
major_step=major,
|
||||||
tenant=tenant,
|
tenant=tenant,
|
||||||
progression_graph_id=body.progression_graph_id,
|
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:
|
if reconciled:
|
||||||
steps.append(pinned)
|
steps.append(reconciled)
|
||||||
eid = int(pinned["exercise_id"])
|
eid = int(reconciled["exercise_id"])
|
||||||
used.add(eid)
|
used.add(eid)
|
||||||
planned_ids.append(eid)
|
planned_ids.append(eid)
|
||||||
anchor_id = eid
|
anchor_id = eid
|
||||||
anchor_variant_id = pinned.get("variant_id")
|
anchor_variant_id = reconciled.get("variant_id")
|
||||||
continue
|
continue
|
||||||
|
try:
|
||||||
|
slot_priority_id = int(assignments[major_idx].exercise_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
slot_priority_id = None
|
||||||
|
|
||||||
step, unfilled_spec = _match_roadmap_slot(
|
step, unfilled_spec = _match_roadmap_slot(
|
||||||
cur,
|
cur,
|
||||||
|
|
@ -1109,6 +1362,7 @@ def _build_steps_roadmap_first(
|
||||||
anchor_id=anchor_id,
|
anchor_id=anchor_id,
|
||||||
anchor_variant_id=anchor_variant_id,
|
anchor_variant_id=anchor_variant_id,
|
||||||
used=used,
|
used=used,
|
||||||
|
slot_priority_exercise_id=slot_priority_id,
|
||||||
)
|
)
|
||||||
if not step:
|
if not step:
|
||||||
unfilled.append((step_index, unfilled_spec or stage_spec))
|
unfilled.append((step_index, unfilled_spec or stage_spec))
|
||||||
|
|
|
||||||
|
|
@ -435,7 +435,14 @@ 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"],
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,21 @@ def _normalize_exercise_kind_filter(exercise_kind_any: Optional[List[str]]) -> L
|
||||||
return out
|
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(
|
def fetch_exercise_rows_by_ids(
|
||||||
cur,
|
cur,
|
||||||
exercise_ids: Sequence[int],
|
exercise_ids: Sequence[int],
|
||||||
|
|
@ -71,16 +86,7 @@ def fetch_exercise_rows_by_ids(
|
||||||
return []
|
return []
|
||||||
ph = ",".join(["%s"] * len(ids))
|
ph = ",".join(["%s"] * len(ids))
|
||||||
sql = f"""
|
sql = f"""
|
||||||
SELECT e.id, e.title, e.summary, e.method_archetype,
|
{_EXERCISE_ROW_SELECT.strip()}
|
||||||
(
|
|
||||||
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
|
|
||||||
WHERE e.id IN ({ph})
|
WHERE e.id IN ({ph})
|
||||||
AND ({vis_sql})
|
AND ({vis_sql})
|
||||||
AND COALESCE(e.status, '') <> %s
|
AND COALESCE(e.status, '') <> %s
|
||||||
|
|
@ -90,6 +96,67 @@ def fetch_exercise_rows_by_ids(
|
||||||
return [dict(r) for r in cur.fetchall()]
|
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(
|
def merge_supplemental_exercise_rows(
|
||||||
rows: Sequence[Dict[str, Any]],
|
rows: Sequence[Dict[str, Any]],
|
||||||
supplemental: Sequence[Dict[str, Any]],
|
supplemental: Sequence[Dict[str, Any]],
|
||||||
|
|
@ -485,6 +552,7 @@ def run_multistage_planning_retrieval(
|
||||||
intent_weights: Mapping[str, float],
|
intent_weights: Mapping[str, float],
|
||||||
pack: Mapping[str, Any],
|
pack: Mapping[str, Any],
|
||||||
supplemental_exercise_ids: Optional[Sequence[int]] = None,
|
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]:
|
) -> Tuple[List[Dict[str, Any]], Dict[int, Set[int]], bool]:
|
||||||
"""Orchestriert S1b-0 → S1b-1 (Voll-Library-Ranking)."""
|
"""Orchestriert S1b-0 → S1b-1 (Voll-Library-Ranking)."""
|
||||||
rows = fetch_all_visible_exercise_rows(
|
rows = fetch_all_visible_exercise_rows(
|
||||||
|
|
@ -494,7 +562,9 @@ def run_multistage_planning_retrieval(
|
||||||
query=pack.get("retrieval_query") or query,
|
query=pack.get("retrieval_query") or query,
|
||||||
exercise_kind_any=exercise_kind_any,
|
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(
|
extra = fetch_exercise_rows_by_ids(
|
||||||
cur,
|
cur,
|
||||||
supplemental_exercise_ids,
|
supplemental_exercise_ids,
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,48 @@ def _normalize_phrase(text: str) -> str:
|
||||||
return re.sub(r"\s+", " ", (text or "").strip().lower())
|
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:
|
def _normalize_query(text: str) -> str:
|
||||||
return re.sub(r"\s+", " ", (text or "").strip())
|
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):
|
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):
|
||||||
|
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:
|
||||||
hit = _find_technique_in_text(_normalize_phrase(lg))
|
hit = _find_technique_in_text(_normalize_phrase(lg))
|
||||||
|
|
@ -1327,6 +1372,7 @@ __all__ = [
|
||||||
"build_stage_match_brief",
|
"build_stage_match_brief",
|
||||||
"enrich_brief_with_path_constraints",
|
"enrich_brief_with_path_constraints",
|
||||||
"exercise_passes_stage_fit",
|
"exercise_passes_stage_fit",
|
||||||
|
"exercise_title_equivalent_to_stage_goal",
|
||||||
"resolve_path_primary_topic",
|
"resolve_path_primary_topic",
|
||||||
"resolve_path_anti_patterns",
|
"resolve_path_anti_patterns",
|
||||||
"exercise_passes_stage_learning_goal_gate",
|
"exercise_passes_stage_learning_goal_gate",
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ class _FakeCur:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def test_supplemental_boost_uses_retrieval_boost_not_slot_pins():
|
def test_supplemental_boost_includes_slot_assignments_and_retrieval_boost():
|
||||||
body = ProgressionPathSuggestRequest(
|
body = ProgressionPathSuggestRequest(
|
||||||
query="Mawashi Geri Progression",
|
query="Mawashi Geri Progression",
|
||||||
slot_assignments=[
|
slot_assignments=[
|
||||||
|
|
@ -27,7 +27,7 @@ def test_supplemental_boost_uses_retrieval_boost_not_slot_pins():
|
||||||
retrieval_boost_exercise_ids=[42, 7],
|
retrieval_boost_exercise_ids=[42, 7],
|
||||||
)
|
)
|
||||||
ids = _supplemental_exercise_ids_from_body(_FakeCur(), body)
|
ids = _supplemental_exercise_ids_from_body(_FakeCur(), body)
|
||||||
assert 99 not in ids
|
assert 99 in ids
|
||||||
assert 42 in ids
|
assert 42 in ids
|
||||||
assert 7 in ids
|
assert 7 in ids
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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():
|
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"
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import {
|
||||||
slotsAsPathStepRows,
|
slotsAsPathStepRows,
|
||||||
slotsToEvaluateSteps,
|
slotsToEvaluateSteps,
|
||||||
draftRetrievalBoostExerciseIds,
|
draftRetrievalBoostExerciseIds,
|
||||||
|
slotsToSlotAssignments,
|
||||||
syncProgressionRoadmapFromSlots,
|
syncProgressionRoadmapFromSlots,
|
||||||
syncSlotPhasesFromRoadmap,
|
syncSlotPhasesFromRoadmap,
|
||||||
} from '../utils/progressionGraphDraft'
|
} from '../utils/progressionGraphDraft'
|
||||||
|
|
@ -415,6 +416,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),
|
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,7 +712,22 @@ export function draftSiblingEdgePairs(draft) {
|
||||||
return pairs
|
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) {
|
export function draftRetrievalBoostExerciseIds(draft) {
|
||||||
const ids = new Set()
|
const ids = new Set()
|
||||||
for (const slot of draft.slots || []) {
|
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 isProposal = Boolean(step.is_ai_proposal) || step.exercise_id == null
|
||||||
const hasAiPayload = Boolean(step.ai_suggestion) || Boolean(step.proposal_key)
|
const hasAiPayload = Boolean(step.ai_suggestion) || Boolean(step.proposal_key)
|
||||||
if (isProposal && !hasAiPayload) {
|
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) {
|
} else if (isProposal) {
|
||||||
nextSlots[idx].primary = proposalSlotExercise({
|
nextSlots[idx].primary = proposalSlotExercise({
|
||||||
title: step.title || nextSlots[idx].learning_goal,
|
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 })
|
return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user