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
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:
parent
f36a747efa
commit
a49987408b
|
|
@ -110,7 +110,7 @@ class ProgressionPathSuggestRequest(BaseModel):
|
|||
include_path_qa: bool = True
|
||||
auto_rematch_after_qa: 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_path_reorder: bool = True
|
||||
include_ai_gap_fill: bool = True
|
||||
|
|
@ -1297,6 +1297,23 @@ def _run_roadmap_rematch_loop(
|
|||
current_stripped = list(stripped_off_topic or [])
|
||||
use_initial_off_topic = not current_stripped
|
||||
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):
|
||||
mini_qa = run_multistage_path_qa(
|
||||
|
|
@ -1357,12 +1374,20 @@ def _run_roadmap_rematch_loop(
|
|||
slot_indices=slot_indices,
|
||||
rematch_reasons=rematch_reasons,
|
||||
match_slot_fn=_match_roadmap_slot,
|
||||
rejected_by_major=rejected_by_major,
|
||||
)
|
||||
rematch_rounds += 1
|
||||
for entry in round_log:
|
||||
tagged = dict(entry)
|
||||
tagged["round"] = rematch_rounds
|
||||
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)
|
||||
roadmap_unfilled = _merge_rematch_unfilled(roadmap_unfilled, rematch_new_unfilled)
|
||||
|
|
@ -1374,6 +1399,7 @@ def _run_roadmap_rematch_loop(
|
|||
brief=semantic_brief,
|
||||
goal_query=goal_query,
|
||||
)
|
||||
_track_rejected(off_topic_steps)
|
||||
if round_idx + 1 >= max_rounds:
|
||||
break
|
||||
if not off_topic_steps and not roadmap_unfilled:
|
||||
|
|
|
|||
|
|
@ -21,12 +21,15 @@ from planning_exercise_semantics import (
|
|||
_blob_from_fields,
|
||||
_blob_matches_stage_excludes,
|
||||
brief_to_summary_dict,
|
||||
build_stage_match_brief,
|
||||
exercise_passes_path_semantic_gate,
|
||||
exercise_passes_stage_learning_goal_gate,
|
||||
exercise_passes_technique_path_scope,
|
||||
merge_stage_exclude_phrases,
|
||||
resolve_path_anti_patterns,
|
||||
resolve_path_primary_topic,
|
||||
score_exercise_semantic_relevance,
|
||||
score_exercise_stage_fit,
|
||||
semantic_brief_for_stage,
|
||||
step_phase_for_index,
|
||||
technique_sibling_excludes,
|
||||
|
|
@ -442,8 +445,13 @@ def detect_off_topic_steps(
|
|||
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):
|
||||
step_anti_raw = list(step.get("roadmap_anti_patterns") or [])
|
||||
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(
|
||||
_with_roadmap_major_index(
|
||||
step,
|
||||
|
|
@ -459,7 +467,6 @@ def detect_off_topic_steps(
|
|||
)
|
||||
)
|
||||
continue
|
||||
stage_goal_pre = (step.get("roadmap_learning_goal") or "").strip()
|
||||
primary = (
|
||||
resolve_path_primary_topic(
|
||||
goal_query or "",
|
||||
|
|
@ -512,6 +519,26 @@ def detect_off_topic_steps(
|
|||
step_phase=phase,
|
||||
)
|
||||
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(
|
||||
learning_goal=stage_goal,
|
||||
title=bundle["title"],
|
||||
|
|
@ -520,6 +547,15 @@ def detect_off_topic_steps(
|
|||
semantic_score=sem,
|
||||
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(
|
||||
_with_roadmap_major_index(
|
||||
step,
|
||||
|
|
@ -527,11 +563,11 @@ def detect_off_topic_steps(
|
|||
"step_index": idx,
|
||||
"exercise_id": int(step["exercise_id"]),
|
||||
"title": step.get("title") or bundle["title"],
|
||||
"semantic_score": round(sem, 4),
|
||||
"semantic_score": round(stage_sem, 4),
|
||||
"expected_phase": phase,
|
||||
"issue": "stage_mismatch",
|
||||
"roadmap_learning_goal": stage_goal,
|
||||
"reasons": sem_reasons[:3],
|
||||
"reasons": reasons[:3],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -813,6 +813,70 @@ def _expand_stage_exclude_phrase(phrase: str) -> List[str]:
|
|||
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]:
|
||||
"""Wörter aus Stufen-Lernziel für Text-Match (ohne Füllwörter, ohne Negationssegmente)."""
|
||||
text = _normalize_phrase(learning_goal)
|
||||
|
|
@ -850,9 +914,11 @@ def parse_stage_goal_constraints(
|
|||
exclude.extend(_expand_stage_exclude_phrase(chunk))
|
||||
|
||||
for raw in anti_patterns or []:
|
||||
if is_trainer_stage_anti_marker(str(raw or "")):
|
||||
continue
|
||||
s = _normalize_phrase(str(raw or ""))
|
||||
if s:
|
||||
exclude.extend(_expand_stage_exclude_phrase(s))
|
||||
if s and s not in exclude:
|
||||
exclude.append(s)
|
||||
|
||||
positive = _significant_stage_tokens(lg, strip_negated=True)
|
||||
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 "")):
|
||||
if expanded and expanded not in merged_anti:
|
||||
merged_anti.append(expanded)
|
||||
constraints = parse_stage_goal_constraints(lg, merged_anti)
|
||||
constraints = parse_stage_goal_constraints(lg)
|
||||
must: List[str] = []
|
||||
norm_lg = _normalize_phrase(lg)
|
||||
if primary_path and primary_path not in must:
|
||||
|
|
@ -1031,11 +1097,13 @@ def build_stage_match_brief(
|
|||
if ph:
|
||||
arc.append(ph)
|
||||
|
||||
exclude_phrases = merge_stage_exclude_phrases(lg, merged_anti)
|
||||
|
||||
return PlanningSemanticBrief(
|
||||
primary_topic="",
|
||||
topic_type="focus",
|
||||
must_phrases=must[:12],
|
||||
exclude_phrases=list(constraints.exclude_phrases)[:12],
|
||||
exclude_phrases=exclude_phrases[:12],
|
||||
development_arc=arc[:4],
|
||||
retrieval_query=" ".join(p for p in retrieval_parts if p)[:500],
|
||||
semantic_strength=0.78,
|
||||
|
|
@ -1062,19 +1130,36 @@ def score_exercise_stage_fit(
|
|||
step_phase=step_phase,
|
||||
)
|
||||
blob = _blob_from_fields(title, summary, goal, variant_names or [])
|
||||
focus_tokens = [
|
||||
t
|
||||
for t in (stage_brief.must_phrases or [])
|
||||
if t and " " not in t and len(t) >= 4
|
||||
][:6]
|
||||
if focus_tokens:
|
||||
hits = sum(1 for t in focus_tokens if _phrase_in_blob(t, blob))
|
||||
ratio = hits / len(focus_tokens)
|
||||
bonus = 0.28 * ratio
|
||||
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
|
||||
for t in (stage_brief.must_phrases or [])
|
||||
if t and len(_normalize_phrase(t)) >= 5
|
||||
][:6]
|
||||
if focus_phrases:
|
||||
hits = sum(1 for p in focus_phrases if _phrase_in_blob(p, blob))
|
||||
ratio = hits / len(focus_phrases)
|
||||
bonus = 0.32 * ratio
|
||||
if bonus > 0:
|
||||
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]
|
||||
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]
|
||||
|
||||
|
||||
|
|
@ -1099,11 +1184,13 @@ def exercise_passes_stage_fit(
|
|||
return True
|
||||
|
||||
blob = _blob_from_fields(title, summary, goal, [])
|
||||
constraints = parse_stage_goal_constraints(lg, anti_patterns)
|
||||
if constraints.exclude_phrases and _blob_matches_stage_excludes(blob, constraints.exclude_phrases):
|
||||
exclude_phrases = merge_stage_exclude_phrases(lg, anti_patterns)
|
||||
if exclude_phrases and _blob_matches_stage_excludes(blob, exclude_phrases):
|
||||
return False
|
||||
|
||||
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()
|
||||
if not primary_path and lg:
|
||||
|
|
@ -1293,10 +1380,8 @@ def _pick_roadmap_rank_fallback(
|
|||
summary = str(hit.get("summary") or "")
|
||||
goal_text = str(hit.get("goal") or hit.get("exercise_goal") or "")
|
||||
blob = _blob_from_fields(title, summary, goal_text, [])
|
||||
constraints = parse_stage_goal_constraints(stage_goal, stage_anti_patterns)
|
||||
if constraints.exclude_phrases and _blob_matches_stage_excludes(
|
||||
blob, constraints.exclude_phrases
|
||||
):
|
||||
exclude_phrases = merge_stage_exclude_phrases(stage_goal, stage_anti_patterns)
|
||||
if exclude_phrases and _blob_matches_stage_excludes(blob, exclude_phrases):
|
||||
continue
|
||||
title_equiv = exercise_title_equivalent_to_stage_goal(title, stage_goal)
|
||||
primary = (path_primary_topic or "").strip()
|
||||
|
|
@ -1465,8 +1550,11 @@ __all__ = [
|
|||
"resolve_path_primary_topic",
|
||||
"resolve_path_anti_patterns",
|
||||
"exercise_passes_stage_learning_goal_gate",
|
||||
"is_trainer_stage_anti_marker",
|
||||
"merge_semantic_brief_llm",
|
||||
"merge_stage_exclude_phrases",
|
||||
"parse_stage_goal_constraints",
|
||||
"stage_focus_phrases_from_learning_goal",
|
||||
"pick_best_path_hit",
|
||||
"exercise_passes_technique_path_scope",
|
||||
"score_exercise_stage_fit",
|
||||
|
|
|
|||
|
|
@ -2,17 +2,17 @@
|
|||
Phase C: Stufen-Spec verfeinern nach stage_mismatch, dann Rematch.
|
||||
|
||||
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 typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
||||
|
||||
from planning_exercise_semantics import (
|
||||
PlanningSemanticBrief,
|
||||
build_stage_match_brief,
|
||||
is_trainer_stage_anti_marker,
|
||||
merge_stage_exclude_phrases,
|
||||
parse_stage_goal_constraints,
|
||||
resolve_path_anti_patterns,
|
||||
stage_focus_phrases_from_learning_goal,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
def _rejected_exercise_marker(title: str) -> str:
|
||||
return f"keine Übung wie „{title[:120]}“"
|
||||
|
||||
|
||||
def refine_stage_spec_artifact(
|
||||
spec: StageSpecArtifact,
|
||||
*,
|
||||
finding: Mapping[str, Any],
|
||||
goal_query: str,
|
||||
semantic_brief: Optional[PlanningSemanticBrief] = None,
|
||||
goal_query: str = "",
|
||||
semantic_brief: Optional[Any] = None,
|
||||
path_anti_patterns: Optional[Sequence[str]] = None,
|
||||
) -> Tuple[StageSpecArtifact, List[str]]:
|
||||
"""
|
||||
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 = (
|
||||
str(finding.get("roadmap_learning_goal") or spec.learning_goal or "").strip()
|
||||
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 [])
|
||||
changes: List[str] = []
|
||||
|
||||
rejected_title = str(finding.get("title") or "").strip()
|
||||
if rejected_title:
|
||||
marker = f"keine Übung wie „{rejected_title[:120]}“"
|
||||
marker = _rejected_exercise_marker(rejected_title)
|
||||
if marker not in anti:
|
||||
anti.append(marker)
|
||||
changes.append(f"Ausschluss abgelehnter Übung: {rejected_title[:80]}")
|
||||
|
||||
path_anti = list(path_anti_patterns or [])
|
||||
if not path_anti and semantic_brief is not None:
|
||||
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 []:
|
||||
goal_excludes = parse_stage_goal_constraints(learning_goal).exclude_phrases
|
||||
for phrase in goal_excludes or []:
|
||||
if phrase and phrase not in anti:
|
||||
anti.append(phrase)
|
||||
changes.append(f"Ausschluss aus Lernziel: {phrase[:60]}")
|
||||
|
||||
stage_brief = build_stage_match_brief(
|
||||
learning_goal=learning_goal,
|
||||
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]}"
|
||||
for phrase in stage_focus_phrases_from_learning_goal(learning_goal):
|
||||
crit = f"Bezug zu Stufen-Lernziel: {phrase[:100]}"
|
||||
if crit not in success:
|
||||
success.append(crit)
|
||||
changes.append(f"Erfolgskriterium: {p[:60]}")
|
||||
changes.append(f"Erfolgskriterium: {phrase[:60]}")
|
||||
|
||||
for raw in finding.get("reasons") or []:
|
||||
r = str(raw or "").strip()
|
||||
if len(r) < 8:
|
||||
continue
|
||||
if r == "Kern-Thema der Anfrage im Übungstext":
|
||||
continue
|
||||
crit = f"QS-Hinweis: {r[:120]}"
|
||||
if crit not in success:
|
||||
success.append(crit)
|
||||
|
|
@ -157,7 +149,7 @@ def refine_stage_spec_artifact(
|
|||
load_profile=list(spec.load_profile or []),
|
||||
exercise_type=spec.exercise_type,
|
||||
success_criteria=success[:8],
|
||||
anti_patterns=anti[:14],
|
||||
anti_patterns=merge_stage_exclude_phrases(learning_goal, anti)[:14],
|
||||
)
|
||||
return refined, changes
|
||||
|
||||
|
|
@ -168,13 +160,14 @@ def apply_stage_spec_refinements(
|
|||
optimization_hints: Sequence[Mapping[str, Any]],
|
||||
off_topic_steps: Sequence[Mapping[str, Any]],
|
||||
goal_query: str,
|
||||
semantic_brief: Optional[PlanningSemanticBrief] = None,
|
||||
semantic_brief: Optional[Any] = None,
|
||||
) -> Tuple[List[StageSpecArtifact], List[Dict[str, Any]]]:
|
||||
"""
|
||||
Wendet refine_stage_spec auf betroffene Slots an (mutiert stage_specs in ctx).
|
||||
|
||||
Returns: (stage_specs, refine_log)
|
||||
"""
|
||||
del goal_query, semantic_brief
|
||||
stage_specs = list(roadmap_ctx.stage_specs or [])
|
||||
if not stage_specs:
|
||||
return stage_specs, []
|
||||
|
|
@ -187,10 +180,8 @@ def apply_stage_spec_refinements(
|
|||
if not targets:
|
||||
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}
|
||||
refine_log: List[Dict[str, Any]] = []
|
||||
refined_majors: Set[int] = set()
|
||||
|
||||
for midx in sorted(targets):
|
||||
spec = spec_by_major.get(int(midx))
|
||||
|
|
@ -199,20 +190,18 @@ def apply_stage_spec_refinements(
|
|||
refined_spec, changes = refine_stage_spec_artifact(
|
||||
spec,
|
||||
finding=targets[midx],
|
||||
goal_query=goal_query,
|
||||
semantic_brief=semantic_brief,
|
||||
path_anti_patterns=path_anti,
|
||||
)
|
||||
if not changes:
|
||||
continue
|
||||
spec_by_major[int(midx)] = refined_spec
|
||||
refined_majors.add(int(midx))
|
||||
rejected_id = targets[midx].get("exercise_id")
|
||||
refine_log.append(
|
||||
{
|
||||
"roadmap_major_step_index": int(midx),
|
||||
"action": "refined",
|
||||
"issue": "stage_mismatch",
|
||||
"rejected_title": targets[midx].get("title"),
|
||||
"rejected_exercise_id": int(rejected_id) if rejected_id else None,
|
||||
"changes": changes[:6],
|
||||
"reason": (changes[0] if changes else "refine_stage_spec")[:400],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ def rematch_roadmap_slots(
|
|||
slot_indices: Set[int],
|
||||
rematch_reasons: Mapping[int, str],
|
||||
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]]]:
|
||||
"""
|
||||
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:
|
||||
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(
|
||||
steps_by_major, int(major_idx)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
"""Tests Phase C — refine_stage_spec nach stage_mismatch."""
|
||||
from planning_exercise_semantics import build_semantic_brief
|
||||
from planning_path_refine_stage import (
|
||||
apply_stage_spec_refinements,
|
||||
collect_refine_stage_targets,
|
||||
|
|
@ -49,17 +48,14 @@ def test_refine_stage_spec_adds_rejected_title_and_criteria():
|
|||
"roadmap_learning_goal": spec.learning_goal,
|
||||
"reasons": ["Semantik zu schwach für Stufen-Lernziel"],
|
||||
}
|
||||
brief = build_semantic_brief("Mawashi Geri Kumite")
|
||||
refined, changes = refine_stage_spec_artifact(
|
||||
spec,
|
||||
finding=finding,
|
||||
goal_query="Mawashi Geri ohne Kumite",
|
||||
semantic_brief=brief,
|
||||
)
|
||||
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.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():
|
||||
|
|
@ -81,7 +77,6 @@ def test_apply_stage_spec_refinements_mutates_context():
|
|||
}
|
||||
],
|
||||
goal_query="Mawashi Geri",
|
||||
semantic_brief=build_semantic_brief("Mawashi Geri"),
|
||||
)
|
||||
assert len(log) == 1
|
||||
assert log[0]["action"] == "refined"
|
||||
|
|
|
|||
54
backend/tests/test_planning_stage_anti_patterns.py
Normal file
54
backend/tests/test_planning_stage_anti_patterns.py
Normal 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
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.227"
|
||||
APP_VERSION = "0.8.228"
|
||||
BUILD_DATE = "2026-05-22"
|
||||
DB_SCHEMA_VERSION = "20260607090"
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ MODULE_VERSIONS = {
|
|||
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||
"methods": "0.1.0",
|
||||
"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_programs": "0.1.0",
|
||||
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
||||
|
|
|
|||
|
|
@ -113,7 +113,10 @@ export function formatRefineLogEntry(entry) {
|
|||
}
|
||||
|
||||
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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user