Refactor Planning Exercise Path Logic and Enhance Semantic Gating
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 41s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m13s
Test Suite / pytest-backend (pull_request) Successful in 38s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 13s
Test Suite / k6 /health Baseline (pull_request) Successful in 33s
Test Suite / playwright-tests (pull_request) Successful in 1m13s

- Replaced the manual path selection logic with a new `pick_best_path_hit` function to streamline the process of selecting the best exercise based on semantic scores and gating criteria.
- Updated the semantic gating logic to apply a soft penalty for off-topic exercises, improving the flexibility of exercise selection.
- Enhanced the handling of title, summary, and goal parameters in semantic checks to ensure more accurate relevance assessments.
- Incremented version to 0.8.189 and updated changelog to reflect these improvements in planning AI functionality.
This commit is contained in:
Lars 2026-05-23 12:50:55 +02:00
parent 5b73d1a1f5
commit 8d1dd59c3c
5 changed files with 143 additions and 32 deletions

View File

@ -28,6 +28,7 @@ from planning_exercise_semantics import (
build_semantic_brief, build_semantic_brief,
enrich_target_with_semantic_expectations, enrich_target_with_semantic_expectations,
exercise_passes_path_semantic_gate, exercise_passes_path_semantic_gate,
pick_best_path_hit,
resolve_semantic_skill_weights, resolve_semantic_skill_weights,
step_phase_for_index, step_phase_for_index,
step_retrieval_query, step_retrieval_query,
@ -62,25 +63,7 @@ def _pick_best_path_hit(
*, *,
semantic_brief: Optional[PlanningSemanticBrief] = None, semantic_brief: Optional[PlanningSemanticBrief] = None,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
best: Optional[Dict[str, Any]] = None return pick_best_path_hit(hits, used_exercise_ids, semantic_brief=semantic_brief)
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
def _build_path_target_profile( def _build_path_target_profile(
@ -327,10 +310,12 @@ def _make_bridge_search_fn(
if exercise_passes_path_semantic_gate( if exercise_passes_path_semantic_gate(
semantic_score=float(h.get("semantic_score") or 0.0), semantic_score=float(h.get("semantic_score") or 0.0),
title=str(h.get("title") or ""), title=str(h.get("title") or ""),
summary=str(h.get("summary") or ""),
brief=semantic_brief, brief=semantic_brief,
strict=False,
) )
] ]
return gated or hits[:8] return gated or hits[:12]
return _bridge_search return _bridge_search

View File

@ -286,10 +286,15 @@ def rank_visible_library_hits(
and not exercise_passes_path_semantic_gate( and not exercise_passes_path_semantic_gate(
semantic_score=semantic_score, semantic_score=semantic_score,
title=str(row.get("title") or ""), title=str(row.get("title") or ""),
summary=str(row.get("summary") or ""),
goal=goals_by_ex.get(eid, ""),
brief=semantic_brief, brief=semantic_brief,
strict=True,
) )
): ):
continue score_penalty = 0.42
else:
score_penalty = 0.0
score = ( score = (
weights.get("semantic", 0.0) * semantic_score weights.get("semantic", 0.0) * semantic_score
@ -300,6 +305,7 @@ def rank_visible_library_hits(
+ weights["profile"] * profile_score + weights["profile"] * profile_score
+ weights["repeat_unit"] * repeat_unit + weights["repeat_unit"] * repeat_unit
+ weights["repeat_group"] * repeat_group + weights["repeat_group"] * repeat_group
- score_penalty
) )
reasons: List[str] = [] reasons: List[str] = []

View File

@ -421,15 +421,22 @@ def _blob_from_fields(
return " ".join(p for p in parts if p).lower() 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: def _phrase_in_blob(phrase: str, blob: str) -> bool:
ph = _normalize_phrase(phrase) ph = _normalize_phrase(phrase)
if not ph or not blob: if not ph or not blob:
return False 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 return True
if " " not in ph: if " " not in ph:
return bool(re.search(rf"\b{re.escape(ph)}\b", blob)) return bool(re.search(rf"\b{re.escape(ph)}\b", low))
return ph in blob return ph in low
def score_exercise_semantic_relevance( def score_exercise_semantic_relevance(
@ -602,17 +609,95 @@ def exercise_passes_path_semantic_gate(
semantic_score: float, semantic_score: float,
title: str, title: str,
brief: PlanningSemanticBrief, brief: PlanningSemanticBrief,
summary: str = "",
goal: str = "",
strict: bool = True,
) -> bool: ) -> bool:
if brief.semantic_strength < 0.55: if brief.semantic_strength < 0.55:
return True 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 return True
topic = brief.primary_topic or "" 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 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 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__ = [ __all__ = [
"PlanningSemanticBrief", "PlanningSemanticBrief",
"apply_dynamic_retrieval_weights", "apply_dynamic_retrieval_weights",
@ -622,6 +707,7 @@ __all__ = [
"enrich_target_with_semantic_expectations", "enrich_target_with_semantic_expectations",
"exercise_passes_path_semantic_gate", "exercise_passes_path_semantic_gate",
"merge_semantic_brief_llm", "merge_semantic_brief_llm",
"pick_best_path_hit",
"resolve_semantic_skill_weights", "resolve_semantic_skill_weights",
"score_exercise_semantic_relevance", "score_exercise_semantic_relevance",
"semantic_core_phrases", "semantic_core_phrases",

View File

@ -14,10 +14,36 @@ def test_pick_best_path_hit_prefers_semantic_score():
assert chosen["id"] == 2 assert chosen["id"] == 2
def test_pick_best_path_hit_skips_off_topic_when_gate(): def test_phrase_compact_match_maegeri():
brief = build_semantic_brief("Mae Geri") from planning_exercise_semantics import _phrase_in_blob
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 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(): def test_pick_best_path_hit_skips_used():

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.188" APP_VERSION = "0.8.189"
BUILD_DATE = "2026-05-23" BUILD_DATE = "2026-05-23"
DB_SCHEMA_VERSION = "20260531074" DB_SCHEMA_VERSION = "20260531074"
@ -29,7 +29,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.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay "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_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
@ -44,6 +44,14 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ 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", "version": "0.8.188",
"date": "2026-05-23", "date": "2026-05-23",