From 5b73d1a1f545706d8e37607b80cd94524b84bda5 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 23 May 2026 12:38:38 +0200 Subject: [PATCH] Enhance Planning Exercise Path Builder and Retrieval Logic - Updated the path selection logic to incorporate semantic gating, ensuring only relevant exercises are considered based on semantic scores. - Introduced new functions for building path target profiles and resolving semantic skill weights, enhancing the contextual understanding of exercise suggestions. - Improved the retrieval process by applying dynamic retrieval weights based on semantic strength, refining the accuracy of exercise recommendations. - Incremented version to 0.8.188 and updated changelog to document these enhancements in planning AI functionality. --- backend/planning_exercise_path_builder.py | 151 ++++++++++--- backend/planning_exercise_retrieval.py | 19 +- backend/planning_exercise_semantics.py | 207 +++++++++++++++--- .../tests/test_planning_exercise_path_qa.py | 10 +- .../tests/test_planning_exercise_semantics.py | 7 +- backend/version.py | 12 +- 6 files changed, 333 insertions(+), 73 deletions(-) diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 2b47239..3c87169 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -23,9 +23,12 @@ from planning_exercise_path_ai_fill import insert_ai_proposals_for_gaps from planning_exercise_retrieval import run_multistage_planning_retrieval from planning_exercise_semantics import ( PlanningSemanticBrief, - apply_dynamic_retrieval_weights, + apply_path_retrieval_weights, brief_to_summary_dict, build_semantic_brief, + enrich_target_with_semantic_expectations, + exercise_passes_path_semantic_gate, + resolve_semantic_skill_weights, step_phase_for_index, step_retrieval_query, try_enrich_semantic_brief_with_llm, @@ -33,9 +36,7 @@ from planning_exercise_semantics import ( from planning_exercise_target_pipeline import build_planning_target_with_query_pipeline from planning_exercise_progression import apply_progression_context_to_pack from planning_exercise_suggest import ( - INTENT_SUGGEST_NEXT, _enrich_planning_hits_with_variant_meta, - _intent_weights, _load_skill_ids_for_exercise, _normalize_query, resolve_planning_exercise_intent, @@ -58,6 +59,8 @@ class ProgressionPathSuggestRequest(BaseModel): def _pick_best_path_hit( hits: List[Dict[str, Any]], used_exercise_ids: Set[int], + *, + semantic_brief: Optional[PlanningSemanticBrief] = None, ) -> Optional[Dict[str, Any]]: best: Optional[Dict[str, Any]] = None best_key: Tuple[float, float] = (-1.0, -1.0) @@ -66,6 +69,12 @@ def _pick_best_path_hit( 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: @@ -74,6 +83,52 @@ def _pick_best_path_hit( return best +def _build_path_target_profile( + cur, + *, + goal_query: str, + semantic_brief: PlanningSemanticBrief, + include_llm_intent: bool, +) -> Tuple[PlanningTargetProfile, Dict[str, Any], str]: + """Einmaliges Erwartungsprofil für den gesamten Pfad (Query + Semantik + Skills).""" + empty_unit = { + "id": None, + "framework_slot_id": None, + "origin_framework_slot_id": None, + } + pipeline_context = { + "unit_title": None, + "group_name": None, + "section_title": None, + "section_guidance_notes": goal_query, + "section_exercise_count": 0, + "planned_count": 0, + "anchor_title": None, + "anchor_exercise_id": None, + "last_section_exercise_title": None, + "progression_graph_id": None, + "unit_skill_profile": None, + "section_skill_profile": None, + "has_planning_reference": False, + "expectation_mode": "query_only", + } + target, intent, _scenario, query_intent_summary = build_planning_target_with_query_pipeline( + cur, + unit=empty_unit, + planned_exercise_ids=[], + section_planned_exercise_ids=[], + anchor_exercise_id=None, + query=goal_query, + heuristic_intent=resolve_planning_exercise_intent(goal_query, "free_search"), + include_llm_intent=include_llm_intent, + context_summary=pipeline_context, + has_planning_reference=False, + ) + skill_weights = resolve_semantic_skill_weights(cur, semantic_brief) + target = enrich_target_with_semantic_expectations(target, skill_weights=skill_weights) + return target, query_intent_summary, intent + + def _hit_to_path_step(hit: Dict[str, Any], *, is_bridge: bool = False) -> Dict[str, Any]: raw_vid = hit.get("suggested_variant_id") variant_id: Optional[int] = None @@ -118,10 +173,16 @@ def _run_path_step_retrieval( bridge_mode: bool = False, step_a: Optional[Dict[str, Any]] = None, step_b: Optional[Dict[str, Any]] = None, + path_target_profile: Optional[PlanningTargetProfile] = None, + path_intent: Optional[str] = None, ) -> Tuple[List[Dict[str, Any]], PlanningTargetProfile, Dict[str, Any], str]: step_query = step_retrieval_query(semantic_brief, goal_query, step_index, max_steps) if bridge_mode and step_a and step_b: - step_query = f"{semantic_brief.retrieval_query or goal_query} brücke zwischen schritten" + phase = step_phase_for_index(semantic_brief, step_index, max_steps) + parts = [semantic_brief.primary_topic or semantic_brief.retrieval_query or goal_query] + if phase: + parts.append(phase) + step_query = _normalize_query(" ".join(p for p in parts if p) + " brücke") pack: Dict[str, Any] = { "unit_id": None, @@ -158,7 +219,7 @@ def _run_path_step_retrieval( if step_index == 0 and not bridge_mode: heuristic_intent = resolve_planning_exercise_intent(goal_query, "free_search") else: - heuristic_intent = INTENT_SUGGEST_NEXT + heuristic_intent = path_intent or resolve_planning_exercise_intent(goal_query, "free_search") has_plan_ref = bool(pack.get("has_planning_reference")) pipeline_context = { @@ -178,25 +239,25 @@ def _run_path_step_retrieval( "expectation_mode": "query_only" if step_index == 0 and not planned_ids else "planning_hybrid", } - target_profile, intent, _scenario, query_intent_summary = build_planning_target_with_query_pipeline( - cur, - unit=pack["unit"], - planned_exercise_ids=pack["planned_exercise_ids"], - section_planned_exercise_ids=[], - anchor_exercise_id=pack.get("anchor_exercise_id"), - query=goal_query if step_index == 0 and not bridge_mode else step_query, - heuristic_intent=heuristic_intent, - include_llm_intent=include_llm_intent and step_index == 0 and not bridge_mode, - context_summary=pipeline_context, - has_planning_reference=has_plan_ref, - ) + if path_target_profile is not None: + target_profile = path_target_profile + intent = path_intent or resolve_planning_exercise_intent(goal_query, "free_search") + query_intent_summary = {} + else: + target_profile, intent, _scenario, query_intent_summary = build_planning_target_with_query_pipeline( + cur, + unit=pack["unit"], + planned_exercise_ids=pack["planned_exercise_ids"], + section_planned_exercise_ids=[], + anchor_exercise_id=pack.get("anchor_exercise_id"), + query=goal_query if step_index == 0 and not bridge_mode else step_query, + heuristic_intent=heuristic_intent, + include_llm_intent=include_llm_intent and step_index == 0 and not bridge_mode, + context_summary=pipeline_context, + has_planning_reference=has_plan_ref, + ) - weights = apply_dynamic_retrieval_weights( - _intent_weights(intent), - semantic_brief, - scenario="free_search" if step_index == 0 and not bridge_mode else "progression", - has_planning_reference=has_plan_ref, - ) + weights = apply_path_retrieval_weights(semantic_brief) profile_id = tenant.profile_id role = tenant.global_role @@ -233,6 +294,8 @@ def _make_bridge_search_fn( exercise_kind_any: Optional[List[str]], semantic_brief: PlanningSemanticBrief, planned_ids: List[int], + path_target_profile: PlanningTargetProfile, + path_intent: str, ) -> Callable[..., List[Dict[str, Any]]]: def _bridge_search( step_a: Dict[str, Any], @@ -255,8 +318,19 @@ def _make_bridge_search_fn( bridge_mode=True, step_a=step_a, step_b=step_b, + path_target_profile=path_target_profile, + path_intent=path_intent, ) - return hits + gated = [ + h + for h in hits + if exercise_passes_path_semantic_gate( + semantic_score=float(h.get("semantic_score") or 0.0), + title=str(h.get("title") or ""), + brief=semantic_brief, + ) + ] + return gated or hits[:8] return _bridge_search @@ -283,16 +357,21 @@ def suggest_progression_path( cur, goal_query, semantic_brief ) + path_target_profile, first_intent_summary, path_intent = _build_path_target_profile( + cur, + goal_query=goal_query, + semantic_brief=semantic_brief, + include_llm_intent=body.include_llm_intent, + ) + used: Set[int] = set() steps: List[Dict[str, Any]] = [] planned_ids: List[int] = [] anchor_id: Optional[int] = None anchor_variant_id: Optional[int] = None - target_profile: Optional[PlanningTargetProfile] = None - first_intent_summary: Dict[str, Any] = {} for step_index in range(max_steps): - hits, target_profile, query_intent_summary, _intent = _run_path_step_retrieval( + hits, _tp, _qis, _intent = _run_path_step_retrieval( cur, tenant=tenant, goal_query=goal_query, @@ -305,11 +384,11 @@ def suggest_progression_path( include_llm_intent=body.include_llm_intent, exercise_kind_any=body.exercise_kind_any, semantic_brief=semantic_brief, + path_target_profile=path_target_profile, + path_intent=path_intent, ) - if step_index == 0: - first_intent_summary = query_intent_summary - hit = _pick_best_path_hit(hits, used) + hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief) if not hit: break @@ -349,6 +428,8 @@ def suggest_progression_path( exercise_kind_any=body.exercise_kind_any, semantic_brief=semantic_brief, planned_ids=planned_ids, + path_target_profile=path_target_profile, + path_intent=path_intent, ) steps, bridge_inserts, unfilled_gaps = insert_bridge_exercises( cur, @@ -378,7 +459,13 @@ def suggest_progression_path( ) if body.include_path_reorder and llm_qa_applied and llm_qa: - steps, reorder_applied, reorder_notes = apply_llm_path_reorder(steps, llm_qa) + q_score = llm_qa.get("quality_score") + try: + q_val = float(q_score) if q_score is not None else None + except (TypeError, ValueError): + q_val = None + if llm_qa.get("overall_ok") or (q_val is not None and q_val >= 0.45): + steps, reorder_applied, reorder_notes = apply_llm_path_reorder(steps, llm_qa) path_qa = build_path_qa_summary( gaps=gaps, @@ -390,7 +477,7 @@ def suggest_progression_path( reorder_notes=reorder_notes, ) - target_profile_summary = target_profile.to_summary_dict(cur) if target_profile else None + target_profile_summary = path_target_profile.to_summary_dict(cur) retrieval_parts = ["profile_v1", "full_library", "path_builder", "semantics"] if body.include_path_qa: retrieval_parts.append("path_qa") diff --git a/backend/planning_exercise_retrieval.py b/backend/planning_exercise_retrieval.py index 2194b02..76fa007 100644 --- a/backend/planning_exercise_retrieval.py +++ b/backend/planning_exercise_retrieval.py @@ -14,7 +14,11 @@ from planning_exercise_profiles import ( load_exercise_match_profiles_bulk, score_exercise_against_target, ) -from planning_exercise_semantics import PlanningSemanticBrief, score_exercise_semantic_relevance +from planning_exercise_semantics import ( + PlanningSemanticBrief, + exercise_passes_path_semantic_gate, + score_exercise_semantic_relevance, +) _MAX_LIBRARY_ROWS = 8000 _PROFILE_LOAD_BATCH = 400 @@ -195,6 +199,7 @@ def rank_visible_library_hits( if isinstance(semantic_brief_raw, PlanningSemanticBrief): semantic_brief = semantic_brief_raw step_phase = pack.get("path_step_phase") + path_mode = pack.get("context_mode") == "progression_path" last_planned_skills: Set[int] = set() planned_ids = pack.get("planned_exercise_ids") or [] @@ -274,6 +279,18 @@ def rank_visible_library_hits( step_phase=step_phase, ) + if ( + path_mode + and semantic_brief + and semantic_brief.semantic_strength >= 0.55 + and not exercise_passes_path_semantic_gate( + semantic_score=semantic_score, + title=str(row.get("title") or ""), + brief=semantic_brief, + ) + ): + continue + score = ( weights.get("semantic", 0.0) * semantic_score + weights["fulltext"] * ft_norm diff --git a/backend/planning_exercise_semantics.py b/backend/planning_exercise_semantics.py index 46c2ef1..e669bc2 100644 --- a/backend/planning_exercise_semantics.py +++ b/backend/planning_exercise_semantics.py @@ -39,6 +39,17 @@ _OTHER_TECHNIQUE_PATTERNS: Tuple[Tuple[str, Tuple[str, ...]], ...] = ( ("gedan barai", ("age uke", "soto uke")), ) +_TECHNIQUE_EXPECTED_SKILLS: Dict[str, Tuple[str, ...]] = { + "mae geri": ("Geri Waza", "Koordination", "Gleichgewicht", "Kime"), + "mawashi geri": ("Geri Waza", "Koordination", "Gleichgewicht"), + "yoko geri": ("Geri Waza", "Koordination", "Gleichgewicht"), + "ushiro geri": ("Geri Waza", "Koordination", "Gleichgewicht"), + "sakuto geri": ("Geri Waza", "Koordination", "Gleichgewicht"), + "mikazuki geri": ("Geri Waza", "Koordination", "Gleichgewicht"), +} + +_DEFAULT_TECHNIQUE_SKILLS: Tuple[str, ...] = ("Geri Waza", "Koordination", "Gleichgewicht") + _ARC_PHASES: Tuple[Tuple[str, Tuple[str, ...]], ...] = ( ("einstieg", ("einstieg", "erlernen", "lernen", "anfänger", "anfaenger", "beginn", "grund")), ("grundlage", ("grundlage", "fundament", "basis", "basic")), @@ -223,19 +234,17 @@ def build_semantic_brief(query: Optional[str]) -> PlanningSemanticBrief: if arc: strength = max(strength, 0.55 if technique else 0.45) - extra_phrases = _keyword_phrases_from_query(q) - for ph in extra_phrases: - if ph not in must and not any(ph in m or m in ph for m in must): - if len(ph) >= 5: - must.append(ph) + # Keine generischen Stichwörter in must_phrases — sonst verwässert das Scoring. + retrieval_parts = list(must) + if primary: + retrieval_parts.append(primary) + if arc: + retrieval_parts.extend(arc[:2]) + retrieval = " ".join(dict.fromkeys(retrieval_parts))[:500] if retrieval_parts else q if len(q) >= 24 and not technique: strength = max(strength, 0.4) - retrieval = " ".join(must[:4]) if must else q - if arc and primary: - retrieval = f"{primary} {' '.join(arc[:2])}" - return PlanningSemanticBrief( primary_topic=primary, topic_type=topic_type, @@ -279,7 +288,8 @@ def merge_semantic_brief_llm( pass if data.get("must_phrases"): - data["retrieval_query"] = " ".join(data["must_phrases"][:4])[:500] + core = semantic_core_phrases(PlanningSemanticBrief.model_validate(data)) + data["retrieval_query"] = " ".join(core[:4])[:500] if core else data.get("retrieval_query", "") out = PlanningSemanticBrief.model_validate(data) if out.primary_topic and out.topic_type == "general": out = out.model_copy(update={"topic_type": "technique"}) @@ -351,16 +361,17 @@ def step_retrieval_query( ) -> str: phase = step_phase_for_index(brief, step_index, max_steps) parts: List[str] = [] - if brief.retrieval_query: - parts.append(brief.retrieval_query) - elif goal_query: - parts.append(goal_query) - if brief.primary_topic and brief.primary_topic not in " ".join(parts).lower(): + if brief.primary_topic: parts.append(brief.primary_topic) + elif brief.retrieval_query: + parts.append(brief.retrieval_query.split()[0] if brief.retrieval_query else "") if phase: - hint = _PHASE_QUERY_HINTS.get(phase, phase) - parts.append(hint) - return _normalize_query(" ".join(parts)) or _normalize_query(goal_query) + parts.append(phase) + if not parts and brief.retrieval_query: + parts.append(brief.retrieval_query) + elif not parts and goal_query: + parts.append(goal_query) + return _normalize_query(" ".join(p for p in parts if p)) or _normalize_query(goal_query) def apply_dynamic_retrieval_weights( @@ -440,30 +451,37 @@ def score_exercise_semantic_relevance( reasons: List[str] = [] must = list(brief.must_phrases or []) exclude = list(brief.exclude_phrases or []) + core = semantic_core_phrases(brief) + core_hits = sum(1 for ph in core if _phrase_in_blob(ph, blob)) must_hits = sum(1 for ph in must if _phrase_in_blob(ph, blob)) exclude_hits = sum(1 for ph in exclude if _phrase_in_blob(ph, blob)) score = 0.0 - if must: - must_ratio = must_hits / len(must) - score += 0.55 * must_ratio - if must_hits == len(must): - reasons.append("Alle Kernbegriffe der Anfrage im Übungstext") - elif must_hits > 0: - reasons.append("Teilweise passende Kernbegriffe") - elif brief.primary_topic and _phrase_in_blob(brief.primary_topic, blob): - score += 0.45 - reasons.append(f"Thema „{brief.primary_topic}“ im Übungstext") + if core: + core_ratio = core_hits / len(core) + score += 0.62 * core_ratio + if core_hits == len(core): + reasons.append("Kern-Thema der Anfrage im Übungstext") + elif core_hits > 0: + reasons.append("Teilweise passend zum Kern-Thema") elif brief.primary_topic and _phrase_in_blob(brief.primary_topic, blob): - score += 0.5 + score += 0.55 reasons.append(f"Thema „{brief.primary_topic}“ im Übungstext") - if exclude_hits > 0: - penalty = min(0.55, 0.18 * exclude_hits) - if must_hits == 0 or exclude_hits >= must_hits: - score -= penalty - reasons.append("Enthält ausgeschlossene Nebenthemen") + if must and core != must: + extra_ratio = must_hits / len(must) + score += 0.12 * extra_ratio + + primary_ok = bool(core_hits) or ( + brief.primary_topic and _phrase_in_blob(brief.primary_topic, blob) + ) + if exclude_hits > 0 and not primary_ok: + penalty = min(0.65, 0.22 * exclude_hits) + score -= penalty + reasons.append("Enthält ausgeschlossene Nebenthemen") + elif exclude_hits > 0 and primary_ok: + score -= min(0.12, 0.06 * exclude_hits) if step_phase and step_phase in _PHASE_QUERY_HINTS: phase_markers = next((markers for phase, markers in _ARC_PHASES if phase == step_phase), ()) @@ -479,13 +497,134 @@ def score_exercise_semantic_relevance( return max(0.0, min(1.0, round(score, 4))), reasons[:4] +def semantic_core_phrases(brief: PlanningSemanticBrief) -> List[str]: + """Harte Kernphrasen fürs Matching.""" + if brief.primary_topic: + return [_normalize_phrase(brief.primary_topic)] + core = [_normalize_phrase(p) for p in (brief.must_phrases or [])[:2] if p] + return [p for p in core if p] + + +def resolve_semantic_skill_weights(cur, brief: PlanningSemanticBrief) -> Dict[int, float]: + """Deterministisches Fähigkeitserwartungsprofil aus Technik-Thema.""" + topic = _normalize_phrase(brief.primary_topic or "") + if topic in _TECHNIQUE_EXPECTED_SKILLS: + names = list(_TECHNIQUE_EXPECTED_SKILLS[topic]) + elif brief.topic_type == "technique" or "geri" in topic: + names = list(_DEFAULT_TECHNIQUE_SKILLS) + else: + return {} + + weights: Dict[int, float] = {} + for name in names[:6]: + cur.execute( + """ + SELECT id, name FROM skills + WHERE (status IS NULL OR status = 'active') + AND LOWER(name) LIKE %s + ORDER BY CASE WHEN LOWER(name) = %s THEN 0 WHEN LOWER(name) LIKE %s THEN 1 ELSE 2 END, + LENGTH(name) ASC + LIMIT 1 + """, + (f"%{name.lower()}%", name.lower(), f"{name.lower()}%"), + ) + row = cur.fetchone() + if row: + sid = int(row["id"]) + weights[sid] = max(weights.get(sid, 0.0), 1.0) + return weights + + +def enrich_target_with_semantic_expectations( + target, + *, + skill_weights: Dict[int, float], +): + from planning_exercise_profiles import PlanningTargetProfile, _merge_weight_maps, _normalize_weight_map + + if not skill_weights: + return target + merged = _normalize_weight_map(_merge_weight_maps(dict(target.skill_weights), skill_weights, scale=1.0)) + sources = list(target.sources) + if "semantic_expectation" not in sources: + sources.append("semantic_expectation") + return PlanningTargetProfile( + focus_area_ids=dict(target.focus_area_ids), + style_direction_ids=dict(target.style_direction_ids), + training_type_ids=dict(target.training_type_ids), + target_group_ids=dict(target.target_group_ids), + skill_weights=merged, + skill_gap_weights=dict(target.skill_gap_weights), + skill_plan_weights=dict(target.skill_plan_weights), + sources=sources, + ) + + +def apply_path_retrieval_weights(brief: PlanningSemanticBrief) -> Dict[str, float]: + """Pfad-Builder: Semantik + Profil dominieren.""" + sem = float(brief.semantic_strength or 0.0) + if sem >= 0.65: + return { + "semantic": 0.50, + "fulltext": 0.16, + "profile": 0.26, + "progression": 0.04, + "skill": 0.04, + "plan": 0.0, + "repeat_unit": -0.40, + "repeat_group": -0.15, + } + if sem >= 0.35: + return { + "semantic": 0.38, + "fulltext": 0.18, + "profile": 0.28, + "progression": 0.06, + "skill": 0.06, + "plan": 0.04, + "repeat_unit": -0.35, + "repeat_group": -0.15, + } + return { + "semantic": 0.22, + "fulltext": 0.22, + "profile": 0.28, + "progression": 0.10, + "skill": 0.10, + "plan": 0.08, + "repeat_unit": -0.30, + "repeat_group": -0.15, + } + + +def exercise_passes_path_semantic_gate( + *, + semantic_score: float, + title: str, + brief: PlanningSemanticBrief, +) -> bool: + if brief.semantic_strength < 0.55: + return True + if semantic_score >= 0.20: + return True + topic = brief.primary_topic or "" + if topic and _phrase_in_blob(topic, (title or "").lower()): + return True + return False + + __all__ = [ "PlanningSemanticBrief", "apply_dynamic_retrieval_weights", + "apply_path_retrieval_weights", "brief_to_summary_dict", "build_semantic_brief", + "enrich_target_with_semantic_expectations", + "exercise_passes_path_semantic_gate", "merge_semantic_brief_llm", + "resolve_semantic_skill_weights", "score_exercise_semantic_relevance", + "semantic_core_phrases", "step_phase_for_index", "step_retrieval_query", "try_enrich_semantic_brief_with_llm", diff --git a/backend/tests/test_planning_exercise_path_qa.py b/backend/tests/test_planning_exercise_path_qa.py index 5cf9824..7a84695 100644 --- a/backend/tests/test_planning_exercise_path_qa.py +++ b/backend/tests/test_planning_exercise_path_qa.py @@ -1,17 +1,25 @@ """Tests Planungs-KI Phase E — Pfad-QA.""" from planning_exercise_path_builder import _pick_best_path_hit +from planning_exercise_semantics import build_semantic_brief from planning_exercise_path_qa import apply_llm_path_reorder def test_pick_best_path_hit_prefers_semantic_score(): + brief = build_semantic_brief("Mae Geri Perfektion") hits = [ {"id": 1, "title": "Mawashi", "score": 0.9, "semantic_score": 0.1}, {"id": 2, "title": "Mae Geri", "score": 0.75, "semantic_score": 0.85}, ] - chosen = _pick_best_path_hit(hits, set()) + chosen = _pick_best_path_hit(hits, set(), semantic_brief=brief) 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_pick_best_path_hit_skips_used(): hits = [{"id": 1, "title": "A", "score": 0.5, "semantic_score": 0.5}] assert _pick_best_path_hit(hits, {1}) is None diff --git a/backend/tests/test_planning_exercise_semantics.py b/backend/tests/test_planning_exercise_semantics.py index 6337204..e3f5ada 100644 --- a/backend/tests/test_planning_exercise_semantics.py +++ b/backend/tests/test_planning_exercise_semantics.py @@ -12,10 +12,10 @@ def test_build_semantic_brief_mae_geri(): "Von Erlernen bis zur Perfektion, des Fußtritts Mae Geri" ) assert brief.primary_topic == "mae geri" - assert "mae geri" in brief.must_phrases + assert brief.must_phrases == ["mae geri"] assert "mawashi geri" in brief.exclude_phrases + assert "perfektion" not in brief.must_phrases assert brief.semantic_strength >= 0.8 - assert "einstieg" in brief.development_arc or "perfektion" in brief.development_arc def test_semantic_score_prefers_mae_over_mawashi(): @@ -64,4 +64,5 @@ def test_step_retrieval_query_carries_topic_and_phase(): q0 = step_retrieval_query(brief, brief.retrieval_query, 0, 5) q4 = step_retrieval_query(brief, brief.retrieval_query, 4, 5) assert "mae geri" in q0.lower() - assert q0 != q4 + assert "mae geri" in q4.lower() + assert "einstieg grundübung" not in q0.lower() diff --git a/backend/version.py b/backend/version.py index 2a2a977..e21383d 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.187" +APP_VERSION = "0.8.188" 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.0", # Phase E2: Pfad-Neuordnung + KI-Lückenfüller + "planning_exercise_suggest": "0.15.1", # Pfad: fixes Semantik-Gate + Skill-Erwartungsprofil "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.188", + "date": "2026-05-23", + "changes": [ + "Fix Pfad-Builder: Mae-Geri-Thema — kein Skill-Profil ab Schritt 2, verwässerte must_phrases.", + "Pfad-Retrieval: hartes Semantik-Gate, Geri-Waza/Koordination/Gleichgewicht-Erwartungsprofil, QS-Reorder nur bei ok.", + ], + }, { "version": "0.8.187", "date": "2026-05-23",