Enhance Path Exclusion Logic and Semantic Brief Enrichment
Some checks failed
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Has been cancelled

- Introduced `resolve_path_anti_patterns` to improve handling of path exclusions based on explicit negations and semantic briefs.
- Updated `enrich_brief_with_path_constraints` to incorporate path-specific exclusions into semantic briefs, enhancing exercise relevance.
- Modified roadmap step annotation to allow for anti-pattern overrides, improving flexibility in exercise selection.
- Enhanced tests to validate new path exclusion features and ensure correct functionality against learning goals.
- Incremented application version to reflect these updates.
This commit is contained in:
Lars 2026-06-11 08:43:59 +02:00
parent 07e147bc76
commit 3c12363b8f
6 changed files with 270 additions and 18 deletions

View File

@ -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 = []

View File

@ -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]] = []

View File

@ -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",

View File

@ -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,

View File

@ -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

View File

@ -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",