Enhance Stage Specification Refinement and Rematch Logic
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m22s

- Updated `max_rematch_rounds` in `ProgressionPathSuggestRequest` to allow for a maximum of 3 rounds, improving flexibility in rematch processes.
- Introduced `_track_rejected` function to track rejected exercises by major step index, enhancing the rematch logic to account for previously rejected exercises.
- Enhanced `_run_roadmap_rematch_loop` to utilize the new rejection tracking, ensuring better handling of off-topic steps during rematching.
- Improved `detect_off_topic_steps` to incorporate refined scoring and reasoning for stage fit, enhancing the accuracy of off-topic detection.
- Updated `refine_stage_spec_artifact` to merge stage exclusion phrases more effectively, improving the clarity of anti-pattern handling.
- Bumped version to 0.8.228 to reflect the new features and improvements.
This commit is contained in:
Lars 2026-06-11 22:11:31 +02:00
parent f36a747efa
commit a49987408b
9 changed files with 269 additions and 74 deletions

View File

@ -110,7 +110,7 @@ class ProgressionPathSuggestRequest(BaseModel):
include_path_qa: bool = True include_path_qa: bool = True
auto_rematch_after_qa: bool = True auto_rematch_after_qa: bool = True
auto_refine_stage_spec: bool = True auto_refine_stage_spec: bool = True
max_rematch_rounds: int = Field(default=2, ge=0, le=3) max_rematch_rounds: int = Field(default=3, ge=0, le=4)
include_llm_path_qa: bool = True include_llm_path_qa: bool = True
include_path_reorder: bool = True include_path_reorder: bool = True
include_ai_gap_fill: bool = True include_ai_gap_fill: bool = True
@ -1297,6 +1297,23 @@ def _run_roadmap_rematch_loop(
current_stripped = list(stripped_off_topic or []) current_stripped = list(stripped_off_topic or [])
use_initial_off_topic = not current_stripped use_initial_off_topic = not current_stripped
off_topic_steps: List[Dict[str, Any]] = [] off_topic_steps: List[Dict[str, Any]] = []
rejected_by_major: Dict[int, Set[int]] = {}
def _track_rejected(items: Sequence[Mapping[str, Any]]) -> None:
for item in items or []:
if not isinstance(item, dict):
continue
eid = item.get("exercise_id")
midx = item.get("roadmap_major_step_index")
if eid is None or midx is None:
continue
try:
rejected_by_major.setdefault(int(midx), set()).add(int(eid))
except (TypeError, ValueError):
continue
_track_rejected(off_topic_before_strip)
_track_rejected(current_stripped)
for round_idx in range(max_rounds): for round_idx in range(max_rounds):
mini_qa = run_multistage_path_qa( mini_qa = run_multistage_path_qa(
@ -1357,12 +1374,20 @@ def _run_roadmap_rematch_loop(
slot_indices=slot_indices, slot_indices=slot_indices,
rematch_reasons=rematch_reasons, rematch_reasons=rematch_reasons,
match_slot_fn=_match_roadmap_slot, match_slot_fn=_match_roadmap_slot,
rejected_by_major=rejected_by_major,
) )
rematch_rounds += 1 rematch_rounds += 1
for entry in round_log: for entry in round_log:
tagged = dict(entry) tagged = dict(entry)
tagged["round"] = rematch_rounds tagged["round"] = rematch_rounds
rematch_log.append(tagged) rematch_log.append(tagged)
rid = entry.get("replaced_exercise_id")
midx = entry.get("roadmap_major_step_index")
if rid is not None and midx is not None:
try:
rejected_by_major.setdefault(int(midx), set()).add(int(rid))
except (TypeError, ValueError):
pass
current_stripped = prune_stripped_after_rematch(current_stripped, round_log) current_stripped = prune_stripped_after_rematch(current_stripped, round_log)
roadmap_unfilled = _merge_rematch_unfilled(roadmap_unfilled, rematch_new_unfilled) roadmap_unfilled = _merge_rematch_unfilled(roadmap_unfilled, rematch_new_unfilled)
@ -1374,6 +1399,7 @@ def _run_roadmap_rematch_loop(
brief=semantic_brief, brief=semantic_brief,
goal_query=goal_query, goal_query=goal_query,
) )
_track_rejected(off_topic_steps)
if round_idx + 1 >= max_rounds: if round_idx + 1 >= max_rounds:
break break
if not off_topic_steps and not roadmap_unfilled: if not off_topic_steps and not roadmap_unfilled:

View File

@ -21,12 +21,15 @@ from planning_exercise_semantics import (
_blob_from_fields, _blob_from_fields,
_blob_matches_stage_excludes, _blob_matches_stage_excludes,
brief_to_summary_dict, brief_to_summary_dict,
build_stage_match_brief,
exercise_passes_path_semantic_gate, exercise_passes_path_semantic_gate,
exercise_passes_stage_learning_goal_gate, exercise_passes_stage_learning_goal_gate,
exercise_passes_technique_path_scope, exercise_passes_technique_path_scope,
merge_stage_exclude_phrases,
resolve_path_anti_patterns, resolve_path_anti_patterns,
resolve_path_primary_topic, resolve_path_primary_topic,
score_exercise_semantic_relevance, score_exercise_semantic_relevance,
score_exercise_stage_fit,
semantic_brief_for_stage, semantic_brief_for_stage,
step_phase_for_index, step_phase_for_index,
technique_sibling_excludes, technique_sibling_excludes,
@ -442,8 +445,13 @@ def detect_off_topic_steps(
bundle["goal"], bundle["goal"],
bundle["variant_names"], bundle["variant_names"],
) )
step_anti = list(step.get("roadmap_anti_patterns") or []) + path_anti step_anti_raw = list(step.get("roadmap_anti_patterns") or [])
if step_anti and _blob_matches_stage_excludes(blob, step_anti): stage_goal_pre = (step.get("roadmap_learning_goal") or "").strip()
exclude_phrases = merge_stage_exclude_phrases(
stage_goal_pre,
[*step_anti_raw, *path_anti],
)
if exclude_phrases and _blob_matches_stage_excludes(blob, exclude_phrases):
off_topic.append( off_topic.append(
_with_roadmap_major_index( _with_roadmap_major_index(
step, step,
@ -459,7 +467,6 @@ def detect_off_topic_steps(
) )
) )
continue continue
stage_goal_pre = (step.get("roadmap_learning_goal") or "").strip()
primary = ( primary = (
resolve_path_primary_topic( resolve_path_primary_topic(
goal_query or "", goal_query or "",
@ -512,6 +519,26 @@ def detect_off_topic_steps(
step_phase=phase, step_phase=phase,
) )
stage_anti = list(step.get("roadmap_anti_patterns") or []) stage_anti = list(step.get("roadmap_anti_patterns") or [])
stage_match_brief = (
build_stage_match_brief(
learning_goal=stage_goal,
anti_patterns=stage_anti or None,
phase=phase or None,
)
if stage_goal
else None
)
stage_sem = 0.0
stage_reasons: List[str] = []
if stage_match_brief:
stage_sem, stage_reasons = score_exercise_stage_fit(
title=bundle["title"],
summary=bundle["summary"],
goal=bundle["goal"],
variant_names=bundle["variant_names"],
stage_brief=stage_match_brief,
step_phase=phase,
)
if stage_goal and not exercise_passes_stage_learning_goal_gate( if stage_goal and not exercise_passes_stage_learning_goal_gate(
learning_goal=stage_goal, learning_goal=stage_goal,
title=bundle["title"], title=bundle["title"],
@ -520,6 +547,15 @@ def detect_off_topic_steps(
semantic_score=sem, semantic_score=sem,
anti_patterns=stage_anti or None, anti_patterns=stage_anti or None,
): ):
reasons = [
r
for r in stage_reasons
if r and r != "Kern-Thema der Anfrage im Übungstext"
]
if not reasons:
reasons = [
f"Stufen-Fit zu schwach ({stage_sem:.2f}) für „{stage_goal[:80]}"
]
off_topic.append( off_topic.append(
_with_roadmap_major_index( _with_roadmap_major_index(
step, step,
@ -527,11 +563,11 @@ def detect_off_topic_steps(
"step_index": idx, "step_index": idx,
"exercise_id": int(step["exercise_id"]), "exercise_id": int(step["exercise_id"]),
"title": step.get("title") or bundle["title"], "title": step.get("title") or bundle["title"],
"semantic_score": round(sem, 4), "semantic_score": round(stage_sem, 4),
"expected_phase": phase, "expected_phase": phase,
"issue": "stage_mismatch", "issue": "stage_mismatch",
"roadmap_learning_goal": stage_goal, "roadmap_learning_goal": stage_goal,
"reasons": sem_reasons[:3], "reasons": reasons[:3],
}, },
) )
) )

View File

@ -813,6 +813,70 @@ def _expand_stage_exclude_phrase(phrase: str) -> List[str]:
return out[:12] return out[:12]
def is_trainer_stage_anti_marker(raw: str) -> bool:
"""Trainer-/QS-Marker — nicht als Negationsphrase parsen."""
norm = _normalize_phrase(str(raw or ""))
if not norm:
return False
stripped = re.sub(r"[„“\"'«»]", "", norm)
stripped = re.sub(r"\s+", " ", stripped).strip()
if stripped.startswith("keine übung wie") or stripped.startswith("keine uebung wie"):
return True
return stripped.startswith("qs-hinweis")
def merge_stage_exclude_phrases(
learning_goal: str,
anti_patterns: Optional[Sequence[str]] = None,
) -> List[str]:
"""
Ausschlussphrasen für Stufen-Gates Negationen nur aus dem Lernziel expandieren,
explizite anti_patterns unverändert (ohne Trainer-Marker erneut zu parsen).
"""
lg = (learning_goal or "").strip()
exclude: List[str] = []
if len(lg) >= 3:
for item in parse_stage_goal_constraints(lg).exclude_phrases:
if item and item not in exclude:
exclude.append(item)
markers: List[str] = []
for raw in anti_patterns or []:
s = str(raw or "").strip()
if not s:
continue
if is_trainer_stage_anti_marker(s):
if s not in markers:
markers.append(s[:200])
continue
norm = _normalize_phrase(s)
if norm and norm not in exclude:
exclude.append(norm)
for marker in markers:
if marker not in exclude:
exclude.append(marker)
return exclude[:16]
def stage_focus_phrases_from_learning_goal(learning_goal: str) -> List[str]:
"""Mehrwort-Schwerpunkte aus Stufen-Lernziel für Fit-Scoring."""
lg = (learning_goal or "").strip()
if len(lg) < 3:
return []
tokens = _significant_stage_tokens(lg, strip_negated=True)
phrases: List[str] = []
for tok in tokens:
if len(tok) >= 5 and tok not in phrases:
phrases.append(tok)
for i in range(len(tokens) - 1):
pair = f"{tokens[i]} {tokens[i + 1]}"
if len(pair) >= 8 and pair not in phrases:
phrases.append(pair)
norm_lg = _normalize_phrase(lg)
if len(norm_lg) >= 8 and norm_lg not in phrases:
phrases.insert(0, norm_lg[:120])
return phrases[:8]
def _significant_stage_tokens(learning_goal: str, *, strip_negated: bool = True) -> List[str]: def _significant_stage_tokens(learning_goal: str, *, strip_negated: bool = True) -> List[str]:
"""Wörter aus Stufen-Lernziel für Text-Match (ohne Füllwörter, ohne Negationssegmente).""" """Wörter aus Stufen-Lernziel für Text-Match (ohne Füllwörter, ohne Negationssegmente)."""
text = _normalize_phrase(learning_goal) text = _normalize_phrase(learning_goal)
@ -850,9 +914,11 @@ def parse_stage_goal_constraints(
exclude.extend(_expand_stage_exclude_phrase(chunk)) exclude.extend(_expand_stage_exclude_phrase(chunk))
for raw in anti_patterns or []: for raw in anti_patterns or []:
if is_trainer_stage_anti_marker(str(raw or "")):
continue
s = _normalize_phrase(str(raw or "")) s = _normalize_phrase(str(raw or ""))
if s: if s and s not in exclude:
exclude.extend(_expand_stage_exclude_phrase(s)) exclude.append(s)
positive = _significant_stage_tokens(lg, strip_negated=True) positive = _significant_stage_tokens(lg, strip_negated=True)
focus_hits = [t for t in positive if t in _STAGE_FOCUS_TOKENS] focus_hits = [t for t in positive if t in _STAGE_FOCUS_TOKENS]
@ -997,7 +1063,7 @@ def build_stage_match_brief(
for expanded in _expand_stage_exclude_phrase(str(raw or "")): for expanded in _expand_stage_exclude_phrase(str(raw or "")):
if expanded and expanded not in merged_anti: if expanded and expanded not in merged_anti:
merged_anti.append(expanded) merged_anti.append(expanded)
constraints = parse_stage_goal_constraints(lg, merged_anti) constraints = parse_stage_goal_constraints(lg)
must: List[str] = [] must: List[str] = []
norm_lg = _normalize_phrase(lg) norm_lg = _normalize_phrase(lg)
if primary_path and primary_path not in must: if primary_path and primary_path not in must:
@ -1031,11 +1097,13 @@ def build_stage_match_brief(
if ph: if ph:
arc.append(ph) arc.append(ph)
exclude_phrases = merge_stage_exclude_phrases(lg, merged_anti)
return PlanningSemanticBrief( return PlanningSemanticBrief(
primary_topic="", primary_topic="",
topic_type="focus", topic_type="focus",
must_phrases=must[:12], must_phrases=must[:12],
exclude_phrases=list(constraints.exclude_phrases)[:12], exclude_phrases=exclude_phrases[:12],
development_arc=arc[:4], development_arc=arc[:4],
retrieval_query=" ".join(p for p in retrieval_parts if p)[:500], retrieval_query=" ".join(p for p in retrieval_parts if p)[:500],
semantic_strength=0.78, semantic_strength=0.78,
@ -1062,19 +1130,36 @@ def score_exercise_stage_fit(
step_phase=step_phase, step_phase=step_phase,
) )
blob = _blob_from_fields(title, summary, goal, variant_names or []) blob = _blob_from_fields(title, summary, goal, variant_names or [])
focus_tokens = [ lg_hint = ""
for part in (stage_brief.retrieval_query or "").split("|"):
part = part.strip()
if part.lower().startswith("lernziel:"):
lg_hint = part.split(":", 1)[-1].strip()
break
if not lg_hint:
for mp in stage_brief.must_phrases or []:
if mp and len(_normalize_phrase(mp)) >= 8:
lg_hint = mp
break
focus_phrases = stage_focus_phrases_from_learning_goal(lg_hint) if lg_hint else []
if not focus_phrases:
focus_phrases = [
t t
for t in (stage_brief.must_phrases or []) for t in (stage_brief.must_phrases or [])
if t and " " not in t and len(t) >= 4 if t and len(_normalize_phrase(t)) >= 5
][:6] ][:6]
if focus_tokens: if focus_phrases:
hits = sum(1 for t in focus_tokens if _phrase_in_blob(t, blob)) hits = sum(1 for p in focus_phrases if _phrase_in_blob(p, blob))
ratio = hits / len(focus_tokens) ratio = hits / len(focus_phrases)
bonus = 0.28 * ratio bonus = 0.32 * ratio
if bonus > 0: if bonus > 0:
score = min(1.0, score + bonus) score = min(1.0, score + bonus)
if hits >= max(1, len(focus_tokens) // 2): if hits >= max(1, len(focus_phrases) // 2):
reasons = ["Stufen-Schwerpunkte im Übungstext", *reasons] reasons = ["Stufen-Schwerpunkte im Übungstext", *reasons]
learning_goal_for_equiv = lg_hint or (stage_brief.must_phrases[0] if stage_brief.must_phrases else "")
if learning_goal_for_equiv and exercise_title_equivalent_to_stage_goal(title, learning_goal_for_equiv):
score = max(score, 0.42)
reasons = ["Titel entspricht Stufen-Lernziel", *reasons]
return max(0.0, min(1.0, round(score, 4))), reasons[:4] return max(0.0, min(1.0, round(score, 4))), reasons[:4]
@ -1099,11 +1184,13 @@ def exercise_passes_stage_fit(
return True return True
blob = _blob_from_fields(title, summary, goal, []) blob = _blob_from_fields(title, summary, goal, [])
constraints = parse_stage_goal_constraints(lg, anti_patterns) exclude_phrases = merge_stage_exclude_phrases(lg, anti_patterns)
if constraints.exclude_phrases and _blob_matches_stage_excludes(blob, constraints.exclude_phrases): if exclude_phrases and _blob_matches_stage_excludes(blob, exclude_phrases):
return False return False
title_equiv = exercise_title_equivalent_to_stage_goal(title, learning_goal or lg) title_equiv = exercise_title_equivalent_to_stage_goal(title, learning_goal or lg)
if title_equiv:
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:
@ -1293,10 +1380,8 @@ def _pick_roadmap_rank_fallback(
summary = str(hit.get("summary") or "") summary = str(hit.get("summary") or "")
goal_text = str(hit.get("goal") or hit.get("exercise_goal") or "") goal_text = str(hit.get("goal") or hit.get("exercise_goal") or "")
blob = _blob_from_fields(title, summary, goal_text, []) blob = _blob_from_fields(title, summary, goal_text, [])
constraints = parse_stage_goal_constraints(stage_goal, stage_anti_patterns) exclude_phrases = merge_stage_exclude_phrases(stage_goal, stage_anti_patterns)
if constraints.exclude_phrases and _blob_matches_stage_excludes( if exclude_phrases and _blob_matches_stage_excludes(blob, exclude_phrases):
blob, constraints.exclude_phrases
):
continue continue
title_equiv = exercise_title_equivalent_to_stage_goal(title, stage_goal) title_equiv = exercise_title_equivalent_to_stage_goal(title, stage_goal)
primary = (path_primary_topic or "").strip() primary = (path_primary_topic or "").strip()
@ -1465,8 +1550,11 @@ __all__ = [
"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",
"is_trainer_stage_anti_marker",
"merge_semantic_brief_llm", "merge_semantic_brief_llm",
"merge_stage_exclude_phrases",
"parse_stage_goal_constraints", "parse_stage_goal_constraints",
"stage_focus_phrases_from_learning_goal",
"pick_best_path_hit", "pick_best_path_hit",
"exercise_passes_technique_path_scope", "exercise_passes_technique_path_scope",
"score_exercise_stage_fit", "score_exercise_stage_fit",

View File

@ -2,17 +2,17 @@
Phase C: Stufen-Spec verfeinern nach stage_mismatch, dann Rematch. Phase C: Stufen-Spec verfeinern nach stage_mismatch, dann Rematch.
Deterministisch keine LLM-Ratelosigkeit. Schärft anti_patterns / success_criteria Deterministisch keine LLM-Ratelosigkeit. Schärft anti_patterns / success_criteria
aus QS-Finding, schließt abgelehnte Übung aus, übernimmt Pfad-Ausschlüsse. aus QS-Finding, schließt abgelehnte Übung aus.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
from planning_exercise_semantics import ( from planning_exercise_semantics import (
PlanningSemanticBrief, is_trainer_stage_anti_marker,
build_stage_match_brief, merge_stage_exclude_phrases,
parse_stage_goal_constraints, parse_stage_goal_constraints,
resolve_path_anti_patterns, stage_focus_phrases_from_learning_goal,
) )
from planning_progression_roadmap import ProgressionRoadmapContext, StageSpecArtifact from planning_progression_roadmap import ProgressionRoadmapContext, StageSpecArtifact
@ -79,65 +79,57 @@ def _append_unique_strings(dest: List[str], items: Sequence[str], *, limit: int
return out return out
def _rejected_exercise_marker(title: str) -> str:
return f"keine Übung wie „{title[:120]}"
def refine_stage_spec_artifact( def refine_stage_spec_artifact(
spec: StageSpecArtifact, spec: StageSpecArtifact,
*, *,
finding: Mapping[str, Any], finding: Mapping[str, Any],
goal_query: str, goal_query: str = "",
semantic_brief: Optional[PlanningSemanticBrief] = None, semantic_brief: Optional[Any] = None,
path_anti_patterns: Optional[Sequence[str]] = None, path_anti_patterns: Optional[Sequence[str]] = None,
) -> Tuple[StageSpecArtifact, List[str]]: ) -> Tuple[StageSpecArtifact, List[str]]:
""" """
Schärft eine StageSpec aus QS-Finding. Returns (neue Spec, Änderungsliste). Schärft eine StageSpec aus QS-Finding. Returns (neue Spec, Änderungsliste).
Pfad-Ausschlüsse werden beim Match separat gemerged nicht in stage_spec duplizieren.
""" """
del goal_query, semantic_brief, path_anti_patterns
learning_goal = ( learning_goal = (
str(finding.get("roadmap_learning_goal") or spec.learning_goal or "").strip() str(finding.get("roadmap_learning_goal") or spec.learning_goal or "").strip()
or spec.learning_goal or spec.learning_goal
) )
anti = list(spec.anti_patterns or []) anti = [a for a in list(spec.anti_patterns or []) if not is_trainer_stage_anti_marker(a)]
success = list(spec.success_criteria or []) success = list(spec.success_criteria or [])
changes: List[str] = [] changes: List[str] = []
rejected_title = str(finding.get("title") or "").strip() rejected_title = str(finding.get("title") or "").strip()
if rejected_title: if rejected_title:
marker = f"keine Übung wie „{rejected_title[:120]}" marker = _rejected_exercise_marker(rejected_title)
if marker not in anti: if marker not in anti:
anti.append(marker) anti.append(marker)
changes.append(f"Ausschluss abgelehnter Übung: {rejected_title[:80]}") changes.append(f"Ausschluss abgelehnter Übung: {rejected_title[:80]}")
path_anti = list(path_anti_patterns or []) goal_excludes = parse_stage_goal_constraints(learning_goal).exclude_phrases
if not path_anti and semantic_brief is not None: for phrase in goal_excludes or []:
path_anti = resolve_path_anti_patterns(goal_query, semantic_brief=semantic_brief)
merged_anti = _append_unique_strings(anti, path_anti)
if len(merged_anti) > len(anti):
changes.append("Pfad-Ausschlüsse in Stufen-anti_patterns übernommen")
anti = merged_anti
constraints = parse_stage_goal_constraints(learning_goal, anti)
for phrase in constraints.exclude_phrases or []:
if phrase and phrase not in anti: if phrase and phrase not in anti:
anti.append(phrase) anti.append(phrase)
changes.append(f"Ausschluss aus Lernziel: {phrase[:60]}") changes.append(f"Ausschluss aus Lernziel: {phrase[:60]}")
stage_brief = build_stage_match_brief( for phrase in stage_focus_phrases_from_learning_goal(learning_goal):
learning_goal=learning_goal, crit = f"Bezug zu Stufen-Lernziel: {phrase[:100]}"
anti_patterns=anti,
success_criteria=list(spec.success_criteria or []),
load_profile=list(spec.load_profile or []),
)
for phrase in (stage_brief.must_phrases or [])[:4]:
p = str(phrase or "").strip()
if len(p) < 4:
continue
crit = f"Bezug zu Stufen-Lernziel: {p[:100]}"
if crit not in success: if crit not in success:
success.append(crit) success.append(crit)
changes.append(f"Erfolgskriterium: {p[:60]}") changes.append(f"Erfolgskriterium: {phrase[:60]}")
for raw in finding.get("reasons") or []: for raw in finding.get("reasons") or []:
r = str(raw or "").strip() r = str(raw or "").strip()
if len(r) < 8: if len(r) < 8:
continue continue
if r == "Kern-Thema der Anfrage im Übungstext":
continue
crit = f"QS-Hinweis: {r[:120]}" crit = f"QS-Hinweis: {r[:120]}"
if crit not in success: if crit not in success:
success.append(crit) success.append(crit)
@ -157,7 +149,7 @@ def refine_stage_spec_artifact(
load_profile=list(spec.load_profile or []), load_profile=list(spec.load_profile or []),
exercise_type=spec.exercise_type, exercise_type=spec.exercise_type,
success_criteria=success[:8], success_criteria=success[:8],
anti_patterns=anti[:14], anti_patterns=merge_stage_exclude_phrases(learning_goal, anti)[:14],
) )
return refined, changes return refined, changes
@ -168,13 +160,14 @@ def apply_stage_spec_refinements(
optimization_hints: Sequence[Mapping[str, Any]], optimization_hints: Sequence[Mapping[str, Any]],
off_topic_steps: Sequence[Mapping[str, Any]], off_topic_steps: Sequence[Mapping[str, Any]],
goal_query: str, goal_query: str,
semantic_brief: Optional[PlanningSemanticBrief] = None, semantic_brief: Optional[Any] = None,
) -> Tuple[List[StageSpecArtifact], List[Dict[str, Any]]]: ) -> Tuple[List[StageSpecArtifact], List[Dict[str, Any]]]:
""" """
Wendet refine_stage_spec auf betroffene Slots an (mutiert stage_specs in ctx). Wendet refine_stage_spec auf betroffene Slots an (mutiert stage_specs in ctx).
Returns: (stage_specs, refine_log) Returns: (stage_specs, refine_log)
""" """
del goal_query, semantic_brief
stage_specs = list(roadmap_ctx.stage_specs or []) stage_specs = list(roadmap_ctx.stage_specs or [])
if not stage_specs: if not stage_specs:
return stage_specs, [] return stage_specs, []
@ -187,10 +180,8 @@ def apply_stage_spec_refinements(
if not targets: if not targets:
return stage_specs, [] return stage_specs, []
path_anti = resolve_path_anti_patterns(goal_query, semantic_brief=semantic_brief)
spec_by_major = {int(s.major_step_index): s for s in stage_specs} spec_by_major = {int(s.major_step_index): s for s in stage_specs}
refine_log: List[Dict[str, Any]] = [] refine_log: List[Dict[str, Any]] = []
refined_majors: Set[int] = set()
for midx in sorted(targets): for midx in sorted(targets):
spec = spec_by_major.get(int(midx)) spec = spec_by_major.get(int(midx))
@ -199,20 +190,18 @@ def apply_stage_spec_refinements(
refined_spec, changes = refine_stage_spec_artifact( refined_spec, changes = refine_stage_spec_artifact(
spec, spec,
finding=targets[midx], finding=targets[midx],
goal_query=goal_query,
semantic_brief=semantic_brief,
path_anti_patterns=path_anti,
) )
if not changes: if not changes:
continue continue
spec_by_major[int(midx)] = refined_spec spec_by_major[int(midx)] = refined_spec
refined_majors.add(int(midx)) rejected_id = targets[midx].get("exercise_id")
refine_log.append( refine_log.append(
{ {
"roadmap_major_step_index": int(midx), "roadmap_major_step_index": int(midx),
"action": "refined", "action": "refined",
"issue": "stage_mismatch", "issue": "stage_mismatch",
"rejected_title": targets[midx].get("title"), "rejected_title": targets[midx].get("title"),
"rejected_exercise_id": int(rejected_id) if rejected_id else None,
"changes": changes[:6], "changes": changes[:6],
"reason": (changes[0] if changes else "refine_stage_spec")[:400], "reason": (changes[0] if changes else "refine_stage_spec")[:400],
} }

View File

@ -115,6 +115,7 @@ def rematch_roadmap_slots(
slot_indices: Set[int], slot_indices: Set[int],
rematch_reasons: Mapping[int, str], rematch_reasons: Mapping[int, str],
match_slot_fn, match_slot_fn,
rejected_by_major: Optional[Mapping[int, Set[int]]] = None,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Tuple[int, StageSpecArtifact]]]: ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Tuple[int, StageSpecArtifact]]]:
""" """
Ersetzt nur betroffene Slots; andere Schritte und used-Set bleiben konsistent. Ersetzt nur betroffene Slots; andere Schritte und used-Set bleiben konsistent.
@ -152,6 +153,9 @@ def rematch_roadmap_slots(
} }
if old and old.get("exercise_id") is not None: if old and old.get("exercise_id") is not None:
used.add(int(old["exercise_id"])) used.add(int(old["exercise_id"]))
for rejected_id in rejected_by_major.get(int(major_idx), set()) if rejected_by_major else set():
if rejected_id > 0:
used.add(int(rejected_id))
planned_ids, anchor_id, anchor_variant_id = _context_before_major( planned_ids, anchor_id, anchor_variant_id = _context_before_major(
steps_by_major, int(major_idx) steps_by_major, int(major_idx)
) )

View File

@ -1,5 +1,4 @@
"""Tests Phase C — refine_stage_spec nach stage_mismatch.""" """Tests Phase C — refine_stage_spec nach stage_mismatch."""
from planning_exercise_semantics import build_semantic_brief
from planning_path_refine_stage import ( from planning_path_refine_stage import (
apply_stage_spec_refinements, apply_stage_spec_refinements,
collect_refine_stage_targets, collect_refine_stage_targets,
@ -49,17 +48,14 @@ def test_refine_stage_spec_adds_rejected_title_and_criteria():
"roadmap_learning_goal": spec.learning_goal, "roadmap_learning_goal": spec.learning_goal,
"reasons": ["Semantik zu schwach für Stufen-Lernziel"], "reasons": ["Semantik zu schwach für Stufen-Lernziel"],
} }
brief = build_semantic_brief("Mawashi Geri Kumite")
refined, changes = refine_stage_spec_artifact( refined, changes = refine_stage_spec_artifact(
spec, spec,
finding=finding, finding=finding,
goal_query="Mawashi Geri ohne Kumite",
semantic_brief=brief,
) )
assert changes assert changes
assert any("Mawashi Trittpräzision" in a for a in refined.anti_patterns) assert any("Mawashi" in a and "Tritt" in a for a in refined.anti_patterns)
assert refined.success_criteria assert refined.success_criteria
assert refined.anti_patterns != spec.anti_patterns or refined.success_criteria != spec.success_criteria assert not any("anderetechnikals" in a.replace(" ", "") for a in refined.anti_patterns)
def test_apply_stage_spec_refinements_mutates_context(): def test_apply_stage_spec_refinements_mutates_context():
@ -81,7 +77,6 @@ def test_apply_stage_spec_refinements_mutates_context():
} }
], ],
goal_query="Mawashi Geri", goal_query="Mawashi Geri",
semantic_brief=build_semantic_brief("Mawashi Geri"),
) )
assert len(log) == 1 assert len(log) == 1
assert log[0]["action"] == "refined" assert log[0]["action"] == "refined"

View File

@ -0,0 +1,54 @@
"""Tests für Stufen-Ausschlüsse und Anti-Pattern-Sanitizer."""
from planning_exercise_semantics import (
exercise_passes_stage_fit,
is_trainer_stage_anti_marker,
merge_stage_exclude_phrases,
parse_stage_goal_constraints,
score_exercise_stage_fit,
build_stage_match_brief,
)
def test_trainer_anti_marker_not_reparsed_as_negation():
marker = 'keine Übung wie „One Leg Squat“'
assert is_trainer_stage_anti_marker(marker)
excludes = merge_stage_exclude_phrases(
"Gleichgewichtstritt Mae-Geri",
[marker, "kumite"],
)
assert "onelegsquat" not in "".join(excludes).replace(" ", "")
assert "kumite" in excludes
def test_parse_stage_goal_constraints_skips_trainer_markers_in_anti():
marker = 'keine Übung wie „Erlernen des Mae Geri aus zusammengesetzten Einzelbewegungen“'
result = parse_stage_goal_constraints(
"Koordination ohne Kumite",
[marker],
)
joined = " ".join(result.exclude_phrases)
assert "keine uebung wie" not in joined
assert "kumite" in joined
def test_title_equivalent_passes_stage_fit_despite_low_semantic():
goal = "Gleichgewichtstritt Mae-Geri"
assert exercise_passes_stage_fit(
learning_goal=goal,
title="Gleichgewichtstritt Mae-Geri",
summary="Balance und Treffpunkt variieren.",
goal="Mae Geri aus Stand.",
stage_semantic_score=0.05,
)
def test_stage_focus_scoring_rewards_learning_goal_tokens():
goal = "Erlernen des Mae Geri aus zusammengesetzten Einzelbewegungen"
brief = build_stage_match_brief(learning_goal=goal)
score, reasons = score_exercise_stage_fit(
title="Mae Geri aus Einzelteilen",
summary="Zusammensetzung aus Schritt und Armschwingung.",
goal="Einzelbewegungen verbinden.",
stage_brief=brief,
)
assert score >= 0.25

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.227" APP_VERSION = "0.8.228"
BUILD_DATE = "2026-05-22" BUILD_DATE = "2026-05-22"
DB_SCHEMA_VERSION = "20260607090" DB_SCHEMA_VERSION = "20260607090"
@ -38,7 +38,7 @@ MODULE_VERSIONS = {
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0", "methods": "0.1.0",
"exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume "exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume
"planning_exercise_suggest": "0.23.2", # Phase C: refine_stage_spec bei stage_mismatch vor Rematch "planning_exercise_suggest": "0.23.3", # Stufen-Match: saubere Anti-Patterns, Fit-Scoring, Rematch-Akkumulation
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint "training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
"training_programs": "0.1.0", "training_programs": "0.1.0",
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung "planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung

View File

@ -113,7 +113,10 @@ export function formatRefineLogEntry(entry) {
} }
export function hasRematchSlotHints(pathQa) { export function hasRematchSlotHints(pathQa) {
return (pathQa?.optimization_hints || []).some((h) => h?.action === 'rematch_slot') return (pathQa?.optimization_hints || []).some((h) => {
const action = h?.action
return action === 'rematch_slot' || action === 'refine_stage_spec'
})
} }
function createEmptySlot(index) { function createEmptySlot(index) {