diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 3c87169..bbb6cb6 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -28,6 +28,7 @@ from planning_exercise_semantics import ( build_semantic_brief, enrich_target_with_semantic_expectations, exercise_passes_path_semantic_gate, + pick_best_path_hit, resolve_semantic_skill_weights, step_phase_for_index, step_retrieval_query, @@ -62,25 +63,7 @@ def _pick_best_path_hit( *, semantic_brief: Optional[PlanningSemanticBrief] = None, ) -> Optional[Dict[str, Any]]: - best: Optional[Dict[str, Any]] = None - best_key: Tuple[float, float] = (-1.0, -1.0) - for hit in hits: - eid = int(hit["id"]) - if eid in used_exercise_ids: - continue - sem = float(hit.get("semantic_score") or 0.0) - if semantic_brief and not exercise_passes_path_semantic_gate( - semantic_score=sem, - title=str(hit.get("title") or ""), - brief=semantic_brief, - ): - continue - score = float(hit.get("score") or 0.0) - key = (sem, score) - if key > best_key: - best_key = key - best = hit - return best + return pick_best_path_hit(hits, used_exercise_ids, semantic_brief=semantic_brief) def _build_path_target_profile( @@ -327,10 +310,12 @@ def _make_bridge_search_fn( if exercise_passes_path_semantic_gate( semantic_score=float(h.get("semantic_score") or 0.0), title=str(h.get("title") or ""), + summary=str(h.get("summary") or ""), brief=semantic_brief, + strict=False, ) ] - return gated or hits[:8] + return gated or hits[:12] return _bridge_search diff --git a/backend/planning_exercise_retrieval.py b/backend/planning_exercise_retrieval.py index 76fa007..dcb928e 100644 --- a/backend/planning_exercise_retrieval.py +++ b/backend/planning_exercise_retrieval.py @@ -286,10 +286,15 @@ def rank_visible_library_hits( and not exercise_passes_path_semantic_gate( semantic_score=semantic_score, title=str(row.get("title") or ""), + summary=str(row.get("summary") or ""), + goal=goals_by_ex.get(eid, ""), brief=semantic_brief, + strict=True, ) ): - continue + score_penalty = 0.42 + else: + score_penalty = 0.0 score = ( weights.get("semantic", 0.0) * semantic_score @@ -300,6 +305,7 @@ def rank_visible_library_hits( + weights["profile"] * profile_score + weights["repeat_unit"] * repeat_unit + weights["repeat_group"] * repeat_group + - score_penalty ) reasons: List[str] = [] diff --git a/backend/planning_exercise_semantics.py b/backend/planning_exercise_semantics.py index e669bc2..7571233 100644 --- a/backend/planning_exercise_semantics.py +++ b/backend/planning_exercise_semantics.py @@ -421,15 +421,22 @@ def _blob_from_fields( return " ".join(p for p in parts if p).lower() +def _compact_alpha(text: str) -> str: + return re.sub(r"[^a-z0-9äöüß]+", "", (text or "").lower()) + + def _phrase_in_blob(phrase: str, blob: str) -> bool: ph = _normalize_phrase(phrase) if not ph or not blob: return False - if ph in blob: + low = blob.lower() + if ph in low: + return True + if _compact_alpha(ph) and _compact_alpha(ph) in _compact_alpha(low): return True if " " not in ph: - return bool(re.search(rf"\b{re.escape(ph)}\b", blob)) - return ph in blob + return bool(re.search(rf"\b{re.escape(ph)}\b", low)) + return ph in low def score_exercise_semantic_relevance( @@ -602,17 +609,95 @@ def exercise_passes_path_semantic_gate( semantic_score: float, title: str, brief: PlanningSemanticBrief, + summary: str = "", + goal: str = "", + strict: bool = True, ) -> bool: if brief.semantic_strength < 0.55: return True - if semantic_score >= 0.20: + + blob = _blob_from_fields(title, summary, goal, []) + min_score = 0.18 if strict else 0.06 + if semantic_score >= min_score: return True + topic = brief.primary_topic or "" - if topic and _phrase_in_blob(topic, (title or "").lower()): + if topic and _phrase_in_blob(topic, blob): return True + + if not strict: + # Mae Geri oft im Fließtext, nicht im Titel + if semantic_score >= 0.04 and topic and _phrase_in_blob(topic, blob): + return True + parts = topic.split() + if len(parts) >= 2 and all(_phrase_in_blob(p, blob) for p in parts): + return True + return False +def pick_best_path_hit( + hits: List[Dict[str, Any]], + used_exercise_ids: Set[int], + *, + semantic_brief: Optional[PlanningSemanticBrief] = None, +) -> Optional[Dict[str, Any]]: + """Gestufte Auswahl: strikt → relaxed → bester Semantik-Score.""" + if not hits: + return None + + def _scan(*, strict: bool) -> Optional[Dict[str, Any]]: + best: Optional[Dict[str, Any]] = None + best_key: Tuple[float, float] = (-1.0, -1.0) + for hit in hits: + eid = int(hit["id"]) + if eid in used_exercise_ids: + continue + sem = float(hit.get("semantic_score") or 0.0) + if semantic_brief and not exercise_passes_path_semantic_gate( + semantic_score=sem, + title=str(hit.get("title") or ""), + summary=str(hit.get("summary") or ""), + goal="", + brief=semantic_brief, + strict=strict, + ): + continue + score = float(hit.get("score") or 0.0) + key = (sem, score) + if key > best_key: + best_key = key + best = hit + return best + + chosen = _scan(strict=True) + if chosen: + return chosen + chosen = _scan(strict=False) + if chosen: + return chosen + + # Notfall: bester verbleibender Treffer mit Semantik > 0 (Thema trotzdem priorisieren) + fallback: Optional[Dict[str, Any]] = None + fallback_key: Tuple[float, float] = (-1.0, -1.0) + for hit in hits: + eid = int(hit["id"]) + if eid in used_exercise_ids: + continue + sem = float(hit.get("semantic_score") or 0.0) + score = float(hit.get("score") or 0.0) + if sem <= 0 and semantic_brief and semantic_brief.primary_topic: + topic = semantic_brief.primary_topic + blob = (str(hit.get("title") or "") + " " + str(hit.get("summary") or "")).lower() + if not _phrase_in_blob(topic, blob): + continue + key = (sem, score) + if key > fallback_key: + fallback_key = key + fallback = hit + return fallback + + __all__ = [ "PlanningSemanticBrief", "apply_dynamic_retrieval_weights", @@ -622,6 +707,7 @@ __all__ = [ "enrich_target_with_semantic_expectations", "exercise_passes_path_semantic_gate", "merge_semantic_brief_llm", + "pick_best_path_hit", "resolve_semantic_skill_weights", "score_exercise_semantic_relevance", "semantic_core_phrases", diff --git a/backend/tests/test_planning_exercise_path_qa.py b/backend/tests/test_planning_exercise_path_qa.py index 7a84695..f781cbf 100644 --- a/backend/tests/test_planning_exercise_path_qa.py +++ b/backend/tests/test_planning_exercise_path_qa.py @@ -14,10 +14,36 @@ def test_pick_best_path_hit_prefers_semantic_score(): assert chosen["id"] == 2 -def test_pick_best_path_hit_skips_off_topic_when_gate(): - brief = build_semantic_brief("Mae Geri") - hits = [{"id": 1, "title": "Kumite Grundstellung", "score": 0.9, "semantic_score": 0.05}] - assert _pick_best_path_hit(hits, set(), semantic_brief=brief) is None +def test_phrase_compact_match_maegeri(): + from planning_exercise_semantics import _phrase_in_blob + + assert _phrase_in_blob("mae geri", "Erlernen des Mae-Geri aus Einzelbewegungen") + assert _phrase_in_blob("mae geri", "Maegeri Kihon") + + +def test_pick_best_path_hit_fallback_title_only_in_summary(): + from planning_exercise_semantics import pick_best_path_hit + + brief = build_semantic_brief("Mae Geri Perfektion") + hits = [ + { + "id": 1, + "title": "Kumite Stellungen", + "summary": "", + "score": 0.9, + "semantic_score": 0.02, + }, + { + "id": 2, + "title": "Einzelbewegungen", + "summary": "Schrittweise Erlernen des Mae Geri", + "score": 0.5, + "semantic_score": 0.08, + }, + ] + chosen = pick_best_path_hit(hits, set(), semantic_brief=brief) + assert chosen is not None + assert int(chosen["id"]) == 2 def test_pick_best_path_hit_skips_used(): diff --git a/backend/version.py b/backend/version.py index e21383d..3a5b275 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.188" +APP_VERSION = "0.8.189" BUILD_DATE = "2026-05-23" DB_SCHEMA_VERSION = "20260531074" @@ -29,7 +29,7 @@ MODULE_VERSIONS = { "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "methods": "0.1.0", "exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay - "planning_exercise_suggest": "0.15.1", # Pfad: fixes Semantik-Gate + Skill-Erwartungsprofil + "planning_exercise_suggest": "0.15.2", # Pfad-Gate: soft penalty + gestufter Fallback "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 @@ -44,6 +44,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.189", + "date": "2026-05-23", + "changes": [ + "Fix Pfad-Builder: Semantik-Gate zu strikt — soft penalty statt Hard-Filter, gestufter Fallback.", + "Mae-Geri-Matching: Titel/Summary/Goal, Mae-Geri/Maegeri-Schreibweisen.", + ], + }, { "version": "0.8.188", "date": "2026-05-23",