diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 7973571..d017fc0 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -46,6 +46,7 @@ from planning_exercise_semantics import ( enrich_brief_with_path_constraints, enrich_target_with_semantic_expectations, resolve_path_anti_patterns, + resolve_path_primary_topic, exercise_passes_path_semantic_gate, pick_best_path_hit, resolve_semantic_skill_weights, @@ -631,9 +632,17 @@ def _match_roadmap_slot( extra_context=path_context_note, ) stage_anti = list(dict.fromkeys([*(stage_spec.anti_patterns or []), *path_anti])) - path_primary = (semantic_brief.primary_topic or "").strip() + path_primary = ( + resolve_path_primary_topic( + goal_query, + semantic_brief, + stage_learning_goal=stage_goal, + extra_context=path_context_note, + ) + or "" + ).strip() path_tech_excludes = list(semantic_brief.exclude_phrases or []) - if semantic_brief.topic_type == "technique" and path_primary: + if path_primary: from planning_exercise_semantics import technique_sibling_excludes for item in technique_sibling_excludes(path_primary): diff --git a/backend/planning_exercise_path_qa.py b/backend/planning_exercise_path_qa.py index 82b5847..f0cb034 100644 --- a/backend/planning_exercise_path_qa.py +++ b/backend/planning_exercise_path_qa.py @@ -25,6 +25,7 @@ from planning_exercise_semantics import ( exercise_passes_stage_learning_goal_gate, exercise_passes_technique_path_scope, resolve_path_anti_patterns, + resolve_path_primary_topic, score_exercise_semantic_relevance, semantic_brief_for_stage, step_phase_for_index, @@ -456,10 +457,17 @@ def detect_off_topic_steps( ) ) continue - primary = (brief.primary_topic or "").strip() - if brief.topic_type == "technique" and primary: + stage_goal_pre = (step.get("roadmap_learning_goal") or "").strip() + primary = ( + resolve_path_primary_topic( + goal_query or "", + brief, + stage_learning_goal=stage_goal_pre or None, + ) + or "" + ).strip() + if primary: siblings = technique_sibling_excludes(primary) - stage_goal_pre = (step.get("roadmap_learning_goal") or "").strip() if not exercise_passes_technique_path_scope( primary_topic=primary, title=bundle["title"], diff --git a/backend/planning_exercise_semantics.py b/backend/planning_exercise_semantics.py index c7b0d77..9d630bb 100644 --- a/backend/planning_exercise_semantics.py +++ b/backend/planning_exercise_semantics.py @@ -180,6 +180,28 @@ def _find_technique_in_text(q_lower: str) -> Optional[Tuple[str, Tuple[str, ...] return None +def resolve_path_primary_topic( + goal_query: str, + semantic_brief: Optional[PlanningSemanticBrief] = None, + *, + stage_learning_goal: Optional[str] = None, + extra_context: Optional[str] = None, +) -> Optional[str]: + """ + Haupttechnik aus Anfrage, Kontext oder Stufen-Lernziel — nicht nur aus goal_query. + """ + if semantic_brief: + primary = (semantic_brief.primary_topic or "").strip() + if primary: + return primary + parts = [goal_query or "", extra_context or "", stage_learning_goal or ""] + combined = _normalize_phrase(" ".join(p for p in parts if p)) + if not combined: + return None + hit = _find_technique_in_text(combined.lower()) + return hit[0] if hit else None + + def technique_sibling_excludes(primary_topic: str) -> List[str]: """Andere Techniken derselben Familie (z. B. Mae/Yoko bei Mawashi) — aus Katalog.""" topic = _normalize_phrase(primary_topic) @@ -1038,13 +1060,22 @@ def exercise_passes_stage_fit( return False primary_path = (path_primary_topic or "").strip() + if not primary_path and lg: + hit = _find_technique_in_text(_normalize_phrase(lg)) + if hit: + primary_path = hit[0] + tech_excludes = list(path_technique_excludes or []) + if primary_path: + for item in technique_sibling_excludes(primary_path): + if item not in tech_excludes: + tech_excludes.append(item) if primary_path and not exercise_passes_technique_path_scope( primary_topic=primary_path, title=title, summary=summary, goal=goal, learning_goal=lg, - sibling_excludes=path_technique_excludes, + sibling_excludes=tech_excludes, relaxed=relaxed, ): return False @@ -1295,6 +1326,7 @@ __all__ = [ "build_stage_match_brief", "enrich_brief_with_path_constraints", "exercise_passes_stage_fit", + "resolve_path_primary_topic", "resolve_path_anti_patterns", "exercise_passes_stage_learning_goal_gate", "merge_semantic_brief_llm", diff --git a/backend/planning_progression_roadmap.py b/backend/planning_progression_roadmap.py index 5d95e7f..f772345 100644 --- a/backend/planning_progression_roadmap.py +++ b/backend/planning_progression_roadmap.py @@ -1028,6 +1028,7 @@ def roadmap_context_from_override( goal_query=goal_query.strip(), max_steps=effective_max, semantic_brief=brief_to_summary_dict(semantic_brief), + resolved_structured=structured, goal_analysis=goal_analysis, roadmap=RoadmapArtifact(major_steps=majors), stage_specs=stage_specs, diff --git a/backend/tests/test_planning_roadmap_stage_match.py b/backend/tests/test_planning_roadmap_stage_match.py index 56d768b..d7cc943 100644 --- a/backend/tests/test_planning_roadmap_stage_match.py +++ b/backend/tests/test_planning_roadmap_stage_match.py @@ -8,6 +8,7 @@ from planning_exercise_semantics import ( exercise_passes_technique_path_scope, pick_best_path_hit, resolve_path_anti_patterns, + resolve_path_primary_topic, score_exercise_stage_fit, semantic_brief_for_stage, technique_sibling_excludes, @@ -231,12 +232,13 @@ def test_pick_best_skips_kumite_for_mawashi_athletic_path(): "id": 2, "title": "Sprungkraft Plyometrie", "summary": "Absprung und Landung", - "goal": "Sprungkraft für Tritttechnik", + "goal": "Sprungkraft für Mawashi Geri Vorbereitung", "score": 0.62, "semantic_score": 0.38, "stage_semantic_score": 0.38, }, ] + primary = resolve_path_primary_topic(q, brief, stage_learning_goal=stage_goal) chosen = pick_best_path_hit( hits, set(), @@ -244,6 +246,56 @@ def test_pick_best_skips_kumite_for_mawashi_athletic_path(): stage_anti_patterns=path_anti, roadmap_stage_match=True, stage_match_brief=stage_brief, + path_primary_topic=primary, + path_technique_excludes=technique_sibling_excludes(primary or "mawashi geri"), + ) + assert chosen is not None + assert int(chosen["id"]) == 2 + + +def test_resolve_path_primary_topic_from_stage_learning_goal(): + brief = build_semantic_brief("Trainingsprogression gesprungener Tritt") + primary = resolve_path_primary_topic( + "Trainingsprogression gesprungener Tritt", + brief, + stage_learning_goal="Perfektionierung der statischen Mawashi Geri Technik", + ) + assert primary and "mawashi" in primary + + +def test_pick_rejects_kumite_when_primary_only_in_stage_goal(): + brief = build_semantic_brief("Trainingsprogression") + stage_goal = "Perfektionierung der statischen Mawashi Geri Technik" + stage_brief = build_stage_match_brief(learning_goal=stage_goal) + primary = resolve_path_primary_topic("Trainingsprogression", brief, stage_learning_goal=stage_goal) + hits = [ + { + "id": 4, + "title": "4 Kumite Reaktions Übungen", + "summary": "Partner", + "goal": "Kumite", + "score": 0.95, + "semantic_score": 0.4, + "stage_semantic_score": 0.35, + }, + { + "id": 2, + "title": "Mawashi Geri Standtechnik", + "summary": "Rundtritt", + "goal": "Mawashi Geri Basis", + "score": 0.7, + "semantic_score": 0.5, + "stage_semantic_score": 0.48, + }, + ] + chosen = pick_best_path_hit( + hits, + set(), + stage_learning_goal=stage_goal, + roadmap_stage_match=True, + stage_match_brief=stage_brief, + path_primary_topic=primary, + path_technique_excludes=technique_sibling_excludes(primary or "mawashi geri"), ) assert chosen is not None assert int(chosen["id"]) == 2 diff --git a/frontend/src/components/ExerciseProgressionPathBuilder.jsx b/frontend/src/components/ExerciseProgressionPathBuilder.jsx index 327a0a9..9c83c04 100644 --- a/frontend/src/components/ExerciseProgressionPathBuilder.jsx +++ b/frontend/src/components/ExerciseProgressionPathBuilder.jsx @@ -142,8 +142,10 @@ function normalizeTitleKey(text) { .replace(/\s+/g, ' ') } -function mergeGraphIntoPathSteps(pathRows, graphNodes) { - if (!Array.isArray(graphNodes) || !graphNodes.length || !pathRows.length) return pathRows +function mergeGraphIntoPathSteps(pathRows, graphNodes, { skipGraphMerge = false } = {}) { + if (skipGraphMerge || !Array.isArray(graphNodes) || !graphNodes.length || !pathRows.length) { + return pathRows + } return pathRows.map((row, i) => { const node = graphNodes[i] if (!node?.exercise_id) return row @@ -1019,14 +1021,14 @@ export default function ExerciseProgressionPathBuilder({ } } - const applyPathMatchResponse = (res, q) => { + const applyPathMatchResponse = (res, q, { skipGraphMerge = true } = {}) => { const qa = res?.path_qa || null const rawRows = (Array.isArray(res?.steps) ? res.steps : []).map(mapApiStepToRow) const rows = applyOffTopicFlags(rawRows, qa) + const mergedRows = mergeGraphIntoPathSteps(rows, graphChainNodes, { skipGraphMerge }) if (rows.length < 2) { throw new Error('Zu wenig Schritte im Vorschlag.') } - const mergedRows = mergeGraphIntoPathSteps(rows, graphChainNodes) const rawGaps = Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : Array.isArray(qa?.gap_fill_offers) diff --git a/frontend/src/utils/progressionGraphDraft.js b/frontend/src/utils/progressionGraphDraft.js index 119494b..62d8659 100644 --- a/frontend/src/utils/progressionGraphDraft.js +++ b/frontend/src/utils/progressionGraphDraft.js @@ -785,7 +785,7 @@ export function applyMatchStepsToSlots(draft, apiSteps) { } /** Lücken-Angebote in leere Slots legen; Panel nur für verbleibende Lücken. */ -export function applyGapOffersFromResponse(draft, res) { +export function applyGapOffersFromResponse(draft, res, { replaceOffTopicSlots = false } = {}) { let next = draft if (res?.progression_roadmap) { next = syncSlotPhasesFromRoadmap(next, res.progression_roadmap) @@ -798,8 +798,14 @@ export function applyGapOffersFromResponse(draft, res) { const idx = resolveOfferSlotIndex(next, offer) if (idx == null || idx < 0 || idx >= (next.slots?.length || 0)) continue const primary = next.slots[idx]?.primary - if (primary?.kind === 'library' && primary.exerciseId != null) continue - if (primary?.kind === 'proposal') continue + const isOffTopicReplace = + replaceOffTopicSlots && + offer?.source === 'off_topic' && + offer?.replace_step_index != null + if (!isOffTopicReplace) { + if (primary?.kind === 'library' && primary.exerciseId != null) continue + if (primary?.kind === 'proposal') continue + } next = applyGapOfferToSlot(next, idx, offer) if (offer?.offer_id) placedIds.add(offer.offer_id) @@ -817,7 +823,7 @@ export function applyGapOffersFromResponse(draft, res) { } /** Match-Antwort: Schritte + Lücken-Angebote direkt in Slots (wie früher im Pfad-Wizard sichtbar). */ -export function applyMatchResponseToDraft(draft, res) { +export function applyMatchResponseToDraft(draft, res, { replaceOffTopicSlots = true } = {}) { let next = applyMatchStepsToSlots(draft, res?.steps) if (res?.progression_roadmap) { next = { @@ -825,7 +831,9 @@ export function applyMatchResponseToDraft(draft, res) { pathSkillExpectations: res?.path_skill_expectations || next.pathSkillExpectations, } } - const { draft: withOffers, remainingOffers } = applyGapOffersFromResponse(next, res) + const { draft: withOffers, remainingOffers } = applyGapOffersFromResponse(next, res, { + replaceOffTopicSlots, + }) return { draft: withOffers, remainingOffers } }