Enhance Planning Exercise Path Builder and Retrieval Logic
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 1m23s
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 1m23s
- 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.
This commit is contained in:
parent
c2c736dafc
commit
5b73d1a1f5
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user