diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 7722146..752f1b9 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -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: diff --git a/backend/planning_exercise_path_qa.py b/backend/planning_exercise_path_qa.py index d3e50a7..79f6b16 100644 --- a/backend/planning_exercise_path_qa.py +++ b/backend/planning_exercise_path_qa.py @@ -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], }, ) ) diff --git a/backend/planning_exercise_semantics.py b/backend/planning_exercise_semantics.py index 501c884..c140075 100644 --- a/backend/planning_exercise_semantics.py +++ b/backend/planning_exercise_semantics.py @@ -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", diff --git a/backend/planning_path_refine_stage.py b/backend/planning_path_refine_stage.py index 04ee7dd..0ee9987 100644 --- a/backend/planning_path_refine_stage.py +++ b/backend/planning_path_refine_stage.py @@ -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], } diff --git a/backend/planning_path_rematch.py b/backend/planning_path_rematch.py index 4bc19dd..9adcb5c 100644 --- a/backend/planning_path_rematch.py +++ b/backend/planning_path_rematch.py @@ -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) ) diff --git a/backend/tests/test_planning_path_refine_stage.py b/backend/tests/test_planning_path_refine_stage.py index 529e278..5cbdaca 100644 --- a/backend/tests/test_planning_path_refine_stage.py +++ b/backend/tests/test_planning_path_refine_stage.py @@ -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" diff --git a/backend/tests/test_planning_stage_anti_patterns.py b/backend/tests/test_planning_stage_anti_patterns.py new file mode 100644 index 0000000..5c97fb9 --- /dev/null +++ b/backend/tests/test_planning_stage_anti_patterns.py @@ -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 diff --git a/backend/version.py b/backend/version.py index 83b0284..90c12e1 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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 diff --git a/frontend/src/utils/progressionGraphDraft.js b/frontend/src/utils/progressionGraphDraft.js index 81f7983..1a6489e 100644 --- a/frontend/src/utils/progressionGraphDraft.js +++ b/frontend/src/utils/progressionGraphDraft.js @@ -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) {