diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 473c228..44a1228 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -36,7 +36,9 @@ from planning_exercise_semantics import ( brief_to_summary_dict, build_semantic_brief, build_stage_match_brief, + enrich_brief_with_path_constraints, enrich_target_with_semantic_expectations, + resolve_path_anti_patterns, exercise_passes_path_semantic_gate, pick_best_path_hit, resolve_semantic_skill_weights, @@ -487,6 +489,7 @@ def _annotate_roadmap_step( stage_spec: StageSpecArtifact, major_step: Optional[MajorStep], skill_expectations: Optional[Dict[str, Any]] = None, + anti_patterns_override: Optional[List[str]] = None, ) -> Dict[str, Any]: reasons = list(step.get("reasons") or []) learning_goal = (stage_spec.learning_goal or "").strip() @@ -508,8 +511,9 @@ def _annotate_roadmap_step( step["roadmap_major_step_index"] = stage_spec.major_step_index step["roadmap_phase"] = major_step.phase if major_step else None step["roadmap_learning_goal"] = learning_goal or None - if stage_spec.anti_patterns: - step["roadmap_anti_patterns"] = list(stage_spec.anti_patterns) + anti = list(anti_patterns_override or stage_spec.anti_patterns or []) + if anti: + step["roadmap_anti_patterns"] = anti step["roadmap_match_source"] = "stage_spec" if skill_expectations: step["skill_expectations"] = skill_expectations @@ -589,7 +593,6 @@ def _build_steps_roadmap_first( ) step_kind = resolve_step_exercise_kind_filter(stage_spec, body.exercise_kind_any) stage_goal = (stage_spec.learning_goal or "").strip() - stage_anti = list(stage_spec.anti_patterns or []) path_context_note = None if rs_dump: ctx_parts = [ @@ -598,6 +601,12 @@ def _build_steps_roadmap_first( str(rs_dump.get("roadmap_notes") or "").strip()[:120], ] path_context_note = " ".join(p for p in ctx_parts if p)[:240] or None + path_anti = resolve_path_anti_patterns( + goal_query, + semantic_brief=semantic_brief, + extra_context=path_context_note, + ) + stage_anti = list(dict.fromkeys([*(stage_spec.anti_patterns or []), *path_anti])) stage_match_brief = build_stage_match_brief( learning_goal=stage_goal, anti_patterns=stage_anti, @@ -605,6 +614,7 @@ def _build_steps_roadmap_first( load_profile=list(stage_spec.load_profile or []), phase=major.phase if major else None, path_context_note=path_context_note, + path_anti_patterns=path_anti, ) hits, _, _, _ = _run_path_step_retrieval( @@ -652,6 +662,7 @@ def _build_steps_roadmap_first( stage_spec=stage_spec, major_step=major, skill_expectations=skill_exp_api, + anti_patterns_override=stage_anti, ) steps.append(step) eid = int(step["exercise_id"]) @@ -795,7 +806,13 @@ def _run_evaluate_only_path_qa( bridge_inserts=bridge_inserts, ) - off_topic_steps = detect_off_topic_steps(cur, steps, brief=semantic_brief) + off_topic_steps = detect_off_topic_steps( + cur, + steps, + brief=semantic_brief, + goal_query=goal_query, + ) + steps, stripped_off_topic = strip_off_topic_steps_from_path(steps, off_topic_steps) llm_gap_specs = parse_llm_suggested_new_exercises( llm_qa, brief=semantic_brief, @@ -807,7 +824,7 @@ def _run_evaluate_only_path_qa( gap_specs = collect_gap_fill_specs( steps=steps, unfilled_gaps=fresh_large_gaps or unfilled_gaps, - off_topic_steps=off_topic_steps, + off_topic_steps=off_topic_steps if not stripped_off_topic else [], llm_specs=llm_gap_specs, brief=semantic_brief, goal_query=goal_query, @@ -902,6 +919,20 @@ def suggest_progression_path( semantic_brief, semantic_llm_applied = try_enrich_semantic_brief_with_llm( cur, goal_query, semantic_brief ) + extra_path_ctx = " ".join( + p + for p in ( + (body.start_situation or "").strip(), + (body.target_state or "").strip(), + (body.roadmap_notes or "").strip(), + ) + if p + ) + semantic_brief = enrich_brief_with_path_constraints( + semantic_brief, + goal_query, + extra_context=extra_path_ctx or None, + ) roadmap_first = bool(body.roadmap_first) roadmap_only = bool(body.roadmap_only) @@ -1222,7 +1253,12 @@ def suggest_progression_path( if llm_qa.get("overall_ok") or (q_val is not None and q_val >= 0.45): steps, reorder_applied, reorder_notes = apply_llm_path_reorder(steps, llm_qa) - off_topic_steps = detect_off_topic_steps(cur, steps, brief=semantic_brief) + off_topic_steps = detect_off_topic_steps( + cur, + steps, + brief=semantic_brief, + goal_query=goal_query, + ) steps, stripped_off_topic = strip_off_topic_steps_from_path(steps, off_topic_steps) if stripped_off_topic: off_topic_steps = [] diff --git a/backend/planning_exercise_path_qa.py b/backend/planning_exercise_path_qa.py index d8880bc..de7a896 100644 --- a/backend/planning_exercise_path_qa.py +++ b/backend/planning_exercise_path_qa.py @@ -18,9 +18,12 @@ from openrouter_chat import ( from planning_exercise_semantics import ( PlanningSemanticBrief, + _blob_from_fields, + _blob_matches_stage_excludes, brief_to_summary_dict, exercise_passes_path_semantic_gate, exercise_passes_stage_learning_goal_gate, + resolve_path_anti_patterns, score_exercise_semantic_relevance, semantic_brief_for_stage, step_phase_for_index, @@ -398,17 +401,39 @@ def detect_off_topic_steps( steps: Sequence[Mapping[str, Any]], *, brief: PlanningSemanticBrief, + goal_query: Optional[str] = None, ) -> List[Dict[str, Any]]: """Schritte ohne Bezug zum Pfad-Thema (z. B. reine Kraftübungen bei Mae Geri).""" if brief.semantic_strength < 0.55 or len(steps) < 2: return [] + path_anti = resolve_path_anti_patterns(goal_query or "", semantic_brief=brief) off_topic: List[Dict[str, Any]] = [] total = len(steps) for idx, step in enumerate(steps): if step.get("is_ai_proposal") or step.get("exercise_id") is None: continue bundle = _load_exercise_text_bundle(cur, int(step["exercise_id"])) + blob = _blob_from_fields( + bundle["title"], + bundle["summary"], + bundle["goal"], + bundle["variant_names"], + ) + step_anti = list(step.get("roadmap_anti_patterns") or []) + path_anti + if step_anti and _blob_matches_stage_excludes(blob, step_anti): + off_topic.append( + { + "step_index": idx, + "exercise_id": int(step["exercise_id"]), + "title": step.get("title") or bundle["title"], + "semantic_score": 0.0, + "expected_phase": (step.get("roadmap_phase") or "").strip().lower() or None, + "issue": "path_exclude", + "reasons": ["Widerspricht Pfad-Ausschlüssen (z. B. Kumite)"], + } + ) + continue stage_goal = (step.get("roadmap_learning_goal") or "").strip() phase = (step.get("roadmap_phase") or "").strip().lower() or step_phase_for_index( brief, idx, total @@ -529,9 +554,10 @@ def strip_off_topic_steps_from_path( return steps, [] by_index = {int(o["step_index"]): dict(o) for o in off_topic_steps if o.get("step_index") is not None} - indices = sorted(by_index.keys(), reverse=True) - if len(steps) - len(indices) < min_remaining: + max_remove = max(0, len(steps) - min_remaining) + if max_remove <= 0: return steps, [] + indices = sorted(by_index.keys(), reverse=True)[:max_remove] out = list(steps) removed: List[Dict[str, Any]] = [] diff --git a/backend/planning_exercise_semantics.py b/backend/planning_exercise_semantics.py index 45a1adc..8c29c06 100644 --- a/backend/planning_exercise_semantics.py +++ b/backend/planning_exercise_semantics.py @@ -246,6 +246,11 @@ def build_semantic_brief(query: Optional[str]) -> PlanningSemanticBrief: if len(q) >= 24 and not technique: strength = max(strength, 0.4) + path_constraints = parse_stage_goal_constraints(q) + for item in path_constraints.exclude_phrases: + if item not in exclude: + exclude.append(item) + return PlanningSemanticBrief( primary_topic=primary, topic_type=topic_type, @@ -775,6 +780,63 @@ def _blob_matches_stage_excludes(blob: str, exclude_phrases: Sequence[str]) -> b return False +def resolve_path_anti_patterns( + goal_query: str, + *, + semantic_brief: Optional[PlanningSemanticBrief] = None, + extra_context: Optional[str] = None, +) -> List[str]: + """ + Pfadweite Ausschlüsse — nur aus expliziten Quellen, kein Themen-Raten. + + Quellen (in dieser Reihenfolge): + 1. Negationen in Anfrage/Kontext (ohne/kein/nicht …) via parse_stage_goal_constraints + 2. exclude_phrases im Semantic Brief (inkl. LLM/Technik-Regeln) + 3. stage_specs.anti_patterns (Roadmap-Stufe, vom Trainer oder LLM) + + Keine stillen Ausschlüsse aus dem Hauptthema (z. B. „Mawashi“ → kein Kumite). + """ + parts = [str(goal_query or "").strip(), str(extra_context or "").strip()] + combined = " ".join(p for p in parts if p) + if not combined and not semantic_brief: + return [] + + constraints = parse_stage_goal_constraints(combined) if combined else StageGoalConstraints() + out: List[str] = [] + for item in constraints.exclude_phrases: + if item and item not in out: + out.append(item) + + if semantic_brief: + for raw in semantic_brief.exclude_phrases or []: + for expanded in _expand_stage_exclude_phrase(str(raw or "")): + if expanded and expanded not in out: + out.append(expanded) + + return out[:24] + + +def enrich_brief_with_path_constraints( + brief: PlanningSemanticBrief, + goal_query: str, + *, + extra_context: Optional[str] = None, +) -> PlanningSemanticBrief: + """Negationen/Ausschlüsse aus der Gesamtanfrage in den Semantic Brief übernehmen.""" + anti = resolve_path_anti_patterns( + goal_query, + semantic_brief=brief, + extra_context=extra_context, + ) + if not anti: + return brief + exclude = list(brief.exclude_phrases or []) + for item in anti: + if item not in exclude: + exclude.append(item) + return brief.model_copy(update={"exclude_phrases": exclude[:16]}) + + _MIN_STAGE_FIT_SEMANTIC = 0.30 _MIN_STAGE_FIT_RELAXED = 0.20 @@ -787,6 +849,7 @@ def build_stage_match_brief( load_profile: Optional[Sequence[str]] = None, phase: Optional[str] = None, path_context_note: Optional[str] = None, + path_anti_patterns: Optional[Sequence[str]] = None, ) -> PlanningSemanticBrief: """ Stufen-zentrierter Semantik-Brief — unabhängig vom Gesamt-Pfad-Thema. @@ -797,7 +860,12 @@ def build_stage_match_brief( if len(lg) < 3: return PlanningSemanticBrief(semantic_strength=0.0) - constraints = parse_stage_goal_constraints(lg, anti_patterns) + merged_anti: List[str] = [] + for raw in list(anti_patterns or []) + list(path_anti_patterns or []): + s = str(raw or "").strip() + if s and s not in merged_anti: + merged_anti.append(s) + constraints = parse_stage_goal_constraints(lg, merged_anti) must: List[str] = [] norm_lg = _normalize_phrase(lg) for token in constraints.positive_tokens: @@ -1134,7 +1202,9 @@ __all__ = [ "StageGoalConstraints", "apply_stage_match_retrieval_weights", "build_stage_match_brief", + "enrich_brief_with_path_constraints", "exercise_passes_stage_fit", + "resolve_path_anti_patterns", "exercise_passes_stage_learning_goal_gate", "merge_semantic_brief_llm", "parse_stage_goal_constraints", diff --git a/backend/planning_progression_roadmap.py b/backend/planning_progression_roadmap.py index e1e0a6c..725296a 100644 --- a/backend/planning_progression_roadmap.py +++ b/backend/planning_progression_roadmap.py @@ -847,12 +847,24 @@ def build_stage_specs( major_steps: Sequence[MajorStep], *, goal_analysis: GoalAnalysisArtifact, + goal_query: str = "", + semantic_brief: Optional[PlanningSemanticBrief] = None, ) -> List[StageSpecArtifact]: """Phase C — Stufenspezifikation je Major Step (heuristisch).""" + from planning_exercise_semantics import resolve_path_anti_patterns + topic = goal_analysis.primary_topic or "Technik" + path_anti = resolve_path_anti_patterns(goal_query, semantic_brief=semantic_brief) specs: List[StageSpecArtifact] = [] for step in major_steps: phase = (step.phase or "vertiefung").lower() + anti = [ + "reine Kraftübung ohne Technikbezug", + f"andere Technik als {topic}" if topic else "themenfremde Übung", + ] + for item in path_anti: + if item not in anti: + anti.append(item) specs.append( StageSpecArtifact( major_step_index=step.index, @@ -863,10 +875,7 @@ def build_stage_specs( f"Bezug zu {topic}", f"Phase {phase} erkennbar im Übungsziel", ], - anti_patterns=[ - "reine Kraftübung ohne Technikbezug", - f"andere Technik als {topic}" if topic else "themenfremde Übung", - ], + anti_patterns=anti[:14], ) ) return specs @@ -927,14 +936,24 @@ def roadmap_context_from_override( ) ) if not all(s.exercise_type for s in stage_specs): - rebuilt = build_stage_specs(majors, goal_analysis=goal_analysis) + rebuilt = build_stage_specs( + majors, + goal_analysis=goal_analysis, + goal_query=goal_query.strip(), + semantic_brief=semantic_brief, + ) for i, spec in enumerate(stage_specs): if not spec.exercise_type: spec.exercise_type = rebuilt[i].exercise_type if not spec.load_profile: spec.load_profile = list(rebuilt[i].load_profile) else: - stage_specs = build_stage_specs(majors, goal_analysis=goal_analysis) + stage_specs = build_stage_specs( + majors, + goal_analysis=goal_analysis, + goal_query=goal_query.strip(), + semantic_brief=semantic_brief, + ) return ProgressionRoadmapContext( goal_query=goal_query.strip(), @@ -1103,7 +1122,12 @@ def run_progression_roadmap_pipeline( ) ctx.roadmap = roadmap - stage_specs = build_stage_specs(roadmap.major_steps, goal_analysis=goal_analysis) + stage_specs = build_stage_specs( + roadmap.major_steps, + goal_analysis=goal_analysis, + goal_query=goal_query, + semantic_brief=brief, + ) if include_llm_roadmap and cur is not None: llm_specs, spec_ok = try_llm_stage_specs( cur, diff --git a/backend/tests/test_planning_roadmap_stage_match.py b/backend/tests/test_planning_roadmap_stage_match.py index ce8a572..9077640 100644 --- a/backend/tests/test_planning_roadmap_stage_match.py +++ b/backend/tests/test_planning_roadmap_stage_match.py @@ -1,12 +1,16 @@ """Tests Roadmap-Stufen-Match — Gate gegen themenfremde Übungen.""" from planning_exercise_semantics import ( + build_semantic_brief, build_stage_match_brief, + enrich_brief_with_path_constraints, exercise_passes_stage_learning_goal_gate, + exercise_passes_stage_fit, pick_best_path_hit, + resolve_path_anti_patterns, score_exercise_stage_fit, semantic_brief_for_stage, - build_semantic_brief, ) +from planning_exercise_path_qa import strip_off_topic_steps_from_path def test_stage_gate_accepts_learning_goal_in_title(): @@ -174,6 +178,83 @@ def test_pick_best_rejects_mawashi_tritt_precision_for_coordination_slot(): assert int(chosen["id"]) == 100 +def test_path_anti_patterns_from_keine_kumite_anwendung(): + q = "gesprungener Mawashi Geri Sprungphase, keine Kumite-Anwendung gewünscht" + brief = enrich_brief_with_path_constraints(build_semantic_brief(q), q) + anti = resolve_path_anti_patterns(q, semantic_brief=brief) + assert any("kumite" in a for a in anti) + + +def test_stage_fit_rejects_kumite_when_path_excludes_kumite(): + q = "gesprungener Mawashi Geri, keine Kumite-Anwendung" + brief = enrich_brief_with_path_constraints(build_semantic_brief(q), q) + path_anti = resolve_path_anti_patterns(q, semantic_brief=brief) + stage_goal = "Sprungkraft und Koordination für gesprungenen Mawashi Geri" + stage_brief = build_stage_match_brief( + learning_goal=stage_goal, + anti_patterns=path_anti, + path_anti_patterns=path_anti, + ) + assert not exercise_passes_stage_fit( + learning_goal=stage_goal, + title="Kumite Distanztraining Mawashi", + summary="Partner-Kumite mit Trittanwendung", + goal="Anwendung im freien Kampf", + stage_brief=stage_brief, + anti_patterns=path_anti, + ) + + +def test_pick_best_skips_kumite_for_mawashi_athletic_path(): + q = "gesprungener Mawashi Geri Sprungkraft, keine Kumite-Anwendung" + brief = enrich_brief_with_path_constraints(build_semantic_brief(q), q) + path_anti = resolve_path_anti_patterns(q, semantic_brief=brief) + stage_goal = "Athletisches Sprungtraining für Mawashi Geri" + stage_brief = build_stage_match_brief( + learning_goal=stage_goal, + anti_patterns=path_anti, + path_anti_patterns=path_anti, + ) + hits = [ + { + "id": 1, + "title": "Kumite Mawashi Anwendung", + "summary": "Partner Kumite", + "goal": "Kampfanwendung", + "score": 0.95, + "semantic_score": 0.55, + "stage_semantic_score": 0.55, + }, + { + "id": 2, + "title": "Sprungkraft Plyometrie", + "summary": "Absprung und Landung", + "goal": "Sprungkraft für Tritttechnik", + "score": 0.62, + "semantic_score": 0.38, + "stage_semantic_score": 0.38, + }, + ] + chosen = pick_best_path_hit( + hits, + set(), + stage_learning_goal=stage_goal, + stage_anti_patterns=path_anti, + roadmap_stage_match=True, + stage_match_brief=stage_brief, + ) + assert chosen is not None + assert int(chosen["id"]) == 2 + + +def test_strip_off_topic_removes_partial_when_most_steps_bad(): + steps = [{"exercise_id": i, "title": f"E{i}"} for i in range(1, 8)] + off_topic = [{"step_index": i, "issue": "path_exclude"} for i in range(5)] + out, removed = strip_off_topic_steps_from_path(steps, off_topic, min_remaining=2) + assert len(out) == 2 + assert len(removed) == 5 + + def test_parse_stage_goal_constraints_extracts_ohne_tritttechnik(): from planning_exercise_semantics import parse_stage_goal_constraints diff --git a/backend/version.py b/backend/version.py index 9469e76..8fc2b84 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.220" +APP_VERSION = "0.8.222" BUILD_DATE = "2026-06-07" DB_SCHEMA_VERSION = "20260607088" @@ -53,6 +53,21 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.222", + "date": "2026-06-07", + "changes": [ + "Pfad-Ausschlüsse: athletic→Kumite-Heuristik entfernt — nur explizite Negationen und anti_patterns.", + ], + }, + { + "version": "0.8.221", + "date": "2026-06-07", + "changes": [ + "Pfad-Ausschlüsse (keine Kumite etc.) aus Anfrage in Brief, stage_specs und Matching-Gates.", + "QS entfernt path_exclude-Schritte; partielles Strippen wenn die meisten Slots falsch sind.", + ], + }, { "version": "0.8.220", "date": "2026-06-07",