Implement Stage Learning Goal Features in Planning Exercise
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 43s
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
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 43s
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
- Added `semantic_brief_for_stage` function to enhance semantic briefs with stage learning goals for improved roadmap matching. - Introduced `exercise_passes_stage_learning_goal_gate` to validate exercises against stage learning goals, enhancing relevance checks. - Updated path retrieval and scoring logic to incorporate stage learning goals, allowing for more nuanced exercise selection. - Enhanced UI to indicate weak matches with stage learning goals, improving user feedback on exercise relevance. - Incremented application version to reflect these updates.
This commit is contained in:
parent
48d51c07c5
commit
18547613ea
|
|
@ -38,6 +38,7 @@ from planning_exercise_semantics import (
|
||||||
exercise_passes_path_semantic_gate,
|
exercise_passes_path_semantic_gate,
|
||||||
pick_best_path_hit,
|
pick_best_path_hit,
|
||||||
resolve_semantic_skill_weights,
|
resolve_semantic_skill_weights,
|
||||||
|
semantic_brief_for_stage,
|
||||||
step_phase_for_index,
|
step_phase_for_index,
|
||||||
step_retrieval_query,
|
step_retrieval_query,
|
||||||
try_enrich_semantic_brief_with_llm,
|
try_enrich_semantic_brief_with_llm,
|
||||||
|
|
@ -183,8 +184,16 @@ def _pick_best_path_hit(
|
||||||
used_exercise_ids: Set[int],
|
used_exercise_ids: Set[int],
|
||||||
*,
|
*,
|
||||||
semantic_brief: Optional[PlanningSemanticBrief] = None,
|
semantic_brief: Optional[PlanningSemanticBrief] = None,
|
||||||
|
stage_learning_goal: Optional[str] = None,
|
||||||
|
roadmap_stage_match: bool = False,
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
return pick_best_path_hit(hits, used_exercise_ids, semantic_brief=semantic_brief)
|
return pick_best_path_hit(
|
||||||
|
hits,
|
||||||
|
used_exercise_ids,
|
||||||
|
semantic_brief=semantic_brief,
|
||||||
|
stage_learning_goal=stage_learning_goal,
|
||||||
|
roadmap_stage_match=roadmap_stage_match,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_path_target_profile(
|
def _build_path_target_profile(
|
||||||
|
|
@ -282,6 +291,7 @@ def _run_path_step_retrieval(
|
||||||
step_query_override: Optional[str] = None,
|
step_query_override: Optional[str] = None,
|
||||||
step_phase_override: Optional[str] = None,
|
step_phase_override: Optional[str] = None,
|
||||||
step_target_profile_override: Optional[PlanningTargetProfile] = None,
|
step_target_profile_override: Optional[PlanningTargetProfile] = None,
|
||||||
|
stage_learning_goal: Optional[str] = None,
|
||||||
) -> Tuple[List[Dict[str, Any]], PlanningTargetProfile, Dict[str, Any], str]:
|
) -> Tuple[List[Dict[str, Any]], PlanningTargetProfile, Dict[str, Any], str]:
|
||||||
step_query = step_query_override or step_retrieval_query(
|
step_query = step_query_override or step_retrieval_query(
|
||||||
semantic_brief, goal_query, step_index, max_steps
|
semantic_brief, goal_query, step_index, max_steps
|
||||||
|
|
@ -317,6 +327,8 @@ def _run_path_step_retrieval(
|
||||||
"retrieval_query": step_query,
|
"retrieval_query": step_query,
|
||||||
"path_step_phase": step_phase_override
|
"path_step_phase": step_phase_override
|
||||||
or step_phase_for_index(semantic_brief, step_index, max_steps),
|
or step_phase_for_index(semantic_brief, step_index, max_steps),
|
||||||
|
"stage_learning_goal": (stage_learning_goal or "").strip() or None,
|
||||||
|
"roadmap_stage_match": bool((stage_learning_goal or "").strip()),
|
||||||
}
|
}
|
||||||
pack = apply_progression_context_to_pack(
|
pack = apply_progression_context_to_pack(
|
||||||
cur,
|
cur,
|
||||||
|
|
@ -556,6 +568,12 @@ def _build_steps_roadmap_first(
|
||||||
major_step=major,
|
major_step=major,
|
||||||
)
|
)
|
||||||
step_kind = resolve_step_exercise_kind_filter(stage_spec, body.exercise_kind_any)
|
step_kind = resolve_step_exercise_kind_filter(stage_spec, body.exercise_kind_any)
|
||||||
|
stage_goal = (stage_spec.learning_goal or "").strip()
|
||||||
|
stage_brief = semantic_brief_for_stage(
|
||||||
|
semantic_brief,
|
||||||
|
learning_goal=stage_goal,
|
||||||
|
phase=major.phase if major else None,
|
||||||
|
)
|
||||||
|
|
||||||
hits, _, _, _ = _run_path_step_retrieval(
|
hits, _, _, _ = _run_path_step_retrieval(
|
||||||
cur,
|
cur,
|
||||||
|
|
@ -569,36 +587,22 @@ def _build_steps_roadmap_first(
|
||||||
progression_graph_id=body.progression_graph_id,
|
progression_graph_id=body.progression_graph_id,
|
||||||
include_llm_intent=body.include_llm_intent and step_index == 0,
|
include_llm_intent=body.include_llm_intent and step_index == 0,
|
||||||
exercise_kind_any=step_kind,
|
exercise_kind_any=step_kind,
|
||||||
semantic_brief=semantic_brief,
|
semantic_brief=stage_brief,
|
||||||
path_target_profile=path_target_profile,
|
path_target_profile=path_target_profile,
|
||||||
path_intent=path_intent,
|
path_intent=path_intent,
|
||||||
step_query_override=step_query,
|
step_query_override=step_query,
|
||||||
step_phase_override=major.phase if major else None,
|
step_phase_override=major.phase if major else None,
|
||||||
step_target_profile_override=step_target,
|
step_target_profile_override=step_target,
|
||||||
|
stage_learning_goal=stage_goal or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief)
|
hit = _pick_best_path_hit(
|
||||||
if not hit and step_query != goal_query:
|
hits,
|
||||||
hits, _, _, _ = _run_path_step_retrieval(
|
used,
|
||||||
cur,
|
semantic_brief=stage_brief,
|
||||||
tenant=tenant,
|
stage_learning_goal=stage_goal or None,
|
||||||
goal_query=goal_query,
|
roadmap_stage_match=True,
|
||||||
step_index=step_index,
|
|
||||||
max_steps=max_steps,
|
|
||||||
planned_ids=planned_ids,
|
|
||||||
anchor_id=anchor_id,
|
|
||||||
anchor_variant_id=anchor_variant_id,
|
|
||||||
progression_graph_id=body.progression_graph_id,
|
|
||||||
include_llm_intent=False,
|
|
||||||
exercise_kind_any=step_kind,
|
|
||||||
semantic_brief=semantic_brief,
|
|
||||||
path_target_profile=path_target_profile,
|
|
||||||
path_intent=path_intent,
|
|
||||||
step_query_override=goal_query,
|
|
||||||
step_phase_override=major.phase if major else None,
|
|
||||||
step_target_profile_override=step_target,
|
|
||||||
)
|
)
|
||||||
hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief)
|
|
||||||
|
|
||||||
if not hit:
|
if not hit:
|
||||||
unfilled.append((step_index, stage_spec))
|
unfilled.append((step_index, stage_spec))
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,9 @@ from planning_exercise_semantics import (
|
||||||
PlanningSemanticBrief,
|
PlanningSemanticBrief,
|
||||||
brief_to_summary_dict,
|
brief_to_summary_dict,
|
||||||
exercise_passes_path_semantic_gate,
|
exercise_passes_path_semantic_gate,
|
||||||
|
exercise_passes_stage_learning_goal_gate,
|
||||||
score_exercise_semantic_relevance,
|
score_exercise_semantic_relevance,
|
||||||
|
semantic_brief_for_stage,
|
||||||
step_phase_for_index,
|
step_phase_for_index,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -407,21 +409,49 @@ def detect_off_topic_steps(
|
||||||
if step.get("is_ai_proposal") or step.get("exercise_id") is None:
|
if step.get("is_ai_proposal") or step.get("exercise_id") is None:
|
||||||
continue
|
continue
|
||||||
bundle = _load_exercise_text_bundle(cur, int(step["exercise_id"]))
|
bundle = _load_exercise_text_bundle(cur, int(step["exercise_id"]))
|
||||||
phase = step_phase_for_index(brief, idx, total)
|
stage_goal = (step.get("roadmap_learning_goal") or "").strip()
|
||||||
|
phase = (step.get("roadmap_phase") or "").strip().lower() or step_phase_for_index(
|
||||||
|
brief, idx, total
|
||||||
|
)
|
||||||
|
step_brief = (
|
||||||
|
semantic_brief_for_stage(brief, learning_goal=stage_goal, phase=phase or None)
|
||||||
|
if stage_goal
|
||||||
|
else brief
|
||||||
|
)
|
||||||
sem, sem_reasons = score_exercise_semantic_relevance(
|
sem, sem_reasons = score_exercise_semantic_relevance(
|
||||||
title=bundle["title"],
|
title=bundle["title"],
|
||||||
summary=bundle["summary"],
|
summary=bundle["summary"],
|
||||||
goal=bundle["goal"],
|
goal=bundle["goal"],
|
||||||
variant_names=bundle["variant_names"],
|
variant_names=bundle["variant_names"],
|
||||||
brief=brief,
|
brief=step_brief,
|
||||||
step_phase=phase,
|
step_phase=phase,
|
||||||
)
|
)
|
||||||
|
if stage_goal and not exercise_passes_stage_learning_goal_gate(
|
||||||
|
learning_goal=stage_goal,
|
||||||
|
title=bundle["title"],
|
||||||
|
summary=bundle["summary"],
|
||||||
|
goal=bundle["goal"],
|
||||||
|
semantic_score=sem,
|
||||||
|
):
|
||||||
|
off_topic.append(
|
||||||
|
{
|
||||||
|
"step_index": idx,
|
||||||
|
"exercise_id": int(step["exercise_id"]),
|
||||||
|
"title": step.get("title") or bundle["title"],
|
||||||
|
"semantic_score": round(sem, 4),
|
||||||
|
"expected_phase": phase,
|
||||||
|
"issue": "stage_mismatch",
|
||||||
|
"roadmap_learning_goal": stage_goal,
|
||||||
|
"reasons": sem_reasons[:3],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
if exercise_passes_path_semantic_gate(
|
if exercise_passes_path_semantic_gate(
|
||||||
semantic_score=sem,
|
semantic_score=sem,
|
||||||
title=bundle["title"],
|
title=bundle["title"],
|
||||||
summary=bundle["summary"],
|
summary=bundle["summary"],
|
||||||
goal=bundle["goal"],
|
goal=bundle["goal"],
|
||||||
brief=brief,
|
brief=step_brief,
|
||||||
strict=True,
|
strict=True,
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from planning_exercise_profiles import (
|
||||||
from planning_exercise_semantics import (
|
from planning_exercise_semantics import (
|
||||||
PlanningSemanticBrief,
|
PlanningSemanticBrief,
|
||||||
exercise_passes_path_semantic_gate,
|
exercise_passes_path_semantic_gate,
|
||||||
|
exercise_passes_stage_learning_goal_gate,
|
||||||
score_exercise_semantic_relevance,
|
score_exercise_semantic_relevance,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -200,6 +201,8 @@ def rank_visible_library_hits(
|
||||||
semantic_brief = semantic_brief_raw
|
semantic_brief = semantic_brief_raw
|
||||||
step_phase = pack.get("path_step_phase")
|
step_phase = pack.get("path_step_phase")
|
||||||
path_mode = pack.get("context_mode") == "progression_path"
|
path_mode = pack.get("context_mode") == "progression_path"
|
||||||
|
stage_learning_goal = (pack.get("stage_learning_goal") or "").strip()
|
||||||
|
roadmap_stage_match = bool(pack.get("roadmap_stage_match"))
|
||||||
|
|
||||||
last_planned_skills: Set[int] = set()
|
last_planned_skills: Set[int] = set()
|
||||||
planned_ids = pack.get("planned_exercise_ids") or []
|
planned_ids = pack.get("planned_exercise_ids") or []
|
||||||
|
|
@ -279,6 +282,8 @@ def rank_visible_library_hits(
|
||||||
step_phase=step_phase,
|
step_phase=step_phase,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
score_penalty = 0.0
|
||||||
|
stage_match_reason: Optional[str] = None
|
||||||
if (
|
if (
|
||||||
path_mode
|
path_mode
|
||||||
and semantic_brief
|
and semantic_brief
|
||||||
|
|
@ -293,8 +298,21 @@ def rank_visible_library_hits(
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
score_penalty = 0.42
|
score_penalty = 0.42
|
||||||
|
if roadmap_stage_match and stage_learning_goal:
|
||||||
|
title_s = str(row.get("title") or "")
|
||||||
|
summary_s = str(row.get("summary") or "")
|
||||||
|
goal_s = goals_by_ex.get(eid, "")
|
||||||
|
if exercise_passes_stage_learning_goal_gate(
|
||||||
|
learning_goal=stage_learning_goal,
|
||||||
|
title=title_s,
|
||||||
|
summary=summary_s,
|
||||||
|
goal=goal_s,
|
||||||
|
semantic_score=semantic_score,
|
||||||
|
):
|
||||||
|
score_penalty = max(0.0, score_penalty - 0.08)
|
||||||
|
stage_match_reason = "Passt zum Stufen-Lernziel"
|
||||||
else:
|
else:
|
||||||
score_penalty = 0.0
|
score_penalty += 0.35
|
||||||
|
|
||||||
score = (
|
score = (
|
||||||
weights.get("semantic", 0.0) * semantic_score
|
weights.get("semantic", 0.0) * semantic_score
|
||||||
|
|
@ -309,6 +327,8 @@ def rank_visible_library_hits(
|
||||||
)
|
)
|
||||||
|
|
||||||
reasons: List[str] = []
|
reasons: List[str] = []
|
||||||
|
if stage_match_reason:
|
||||||
|
reasons.append(stage_match_reason)
|
||||||
if semantic_score >= 0.35 and semantic_reasons:
|
if semantic_score >= 0.35 and semantic_reasons:
|
||||||
for sr in semantic_reasons:
|
for sr in semantic_reasons:
|
||||||
if sr not in reasons:
|
if sr not in reasons:
|
||||||
|
|
|
||||||
|
|
@ -604,6 +604,103 @@ def apply_path_retrieval_weights(brief: PlanningSemanticBrief) -> Dict[str, floa
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_STAGE_GOAL_STOPWORDS = _QUERY_STOPWORDS | frozenset(
|
||||||
|
{
|
||||||
|
"stufe",
|
||||||
|
"phase",
|
||||||
|
"lernziel",
|
||||||
|
"grundlage",
|
||||||
|
"vertiefung",
|
||||||
|
"anwendung",
|
||||||
|
"perfektion",
|
||||||
|
"einstieg",
|
||||||
|
"sicher",
|
||||||
|
"sauber",
|
||||||
|
"korrekt",
|
||||||
|
"technik",
|
||||||
|
"training",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _significant_stage_tokens(learning_goal: str) -> List[str]:
|
||||||
|
"""Wörter aus Stufen-Lernziel für Text-Match (ohne Füllwörter)."""
|
||||||
|
raw = re.findall(r"[a-zäöüß]{4,}", _normalize_phrase(learning_goal), flags=re.IGNORECASE)
|
||||||
|
out: List[str] = []
|
||||||
|
for w in raw:
|
||||||
|
low = w.lower().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue")
|
||||||
|
if low in _STAGE_GOAL_STOPWORDS:
|
||||||
|
continue
|
||||||
|
if low not in out:
|
||||||
|
out.append(low)
|
||||||
|
return out[:10]
|
||||||
|
|
||||||
|
|
||||||
|
def semantic_brief_for_stage(
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
*,
|
||||||
|
learning_goal: str,
|
||||||
|
phase: Optional[str] = None,
|
||||||
|
) -> PlanningSemanticBrief:
|
||||||
|
"""Brief um Stufen-Lernziel erweitern — für Roadmap-Match pro Major Step."""
|
||||||
|
lg = _normalize_phrase(learning_goal)
|
||||||
|
if not lg:
|
||||||
|
return brief
|
||||||
|
must = list(brief.must_phrases or [])
|
||||||
|
if lg not in must:
|
||||||
|
must.insert(0, lg[:120])
|
||||||
|
arc = list(brief.development_arc or [])
|
||||||
|
ph = (phase or "").strip().lower()
|
||||||
|
if ph and ph not in arc:
|
||||||
|
arc = [ph, *arc]
|
||||||
|
strength = max(float(brief.semantic_strength or 0.0), 0.58)
|
||||||
|
return brief.model_copy(
|
||||||
|
update={
|
||||||
|
"must_phrases": must[:12],
|
||||||
|
"development_arc": arc[:8],
|
||||||
|
"semantic_strength": min(1.0, strength),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def exercise_passes_stage_learning_goal_gate(
|
||||||
|
*,
|
||||||
|
learning_goal: str,
|
||||||
|
title: str,
|
||||||
|
summary: str = "",
|
||||||
|
goal: str = "",
|
||||||
|
semantic_score: float = 0.0,
|
||||||
|
min_semantic: float = 0.20,
|
||||||
|
relaxed: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""Roadmap-Stufe: Übung muss zum Stufen-Lernziel passen, nicht nur zum Gesamtthema."""
|
||||||
|
lg = (learning_goal or "").strip()
|
||||||
|
if len(lg) < 3:
|
||||||
|
return True
|
||||||
|
|
||||||
|
blob = _blob_from_fields(title, summary, goal, [])
|
||||||
|
norm_lg = _normalize_phrase(lg)
|
||||||
|
if _phrase_in_blob(norm_lg, blob):
|
||||||
|
return True
|
||||||
|
|
||||||
|
tokens = _significant_stage_tokens(lg)
|
||||||
|
if not tokens:
|
||||||
|
threshold = 0.12 if relaxed else min_semantic
|
||||||
|
return semantic_score >= threshold
|
||||||
|
|
||||||
|
hits = sum(1 for t in tokens if _phrase_in_blob(t, blob))
|
||||||
|
if len(tokens) <= 2:
|
||||||
|
required = 1
|
||||||
|
else:
|
||||||
|
required = max(2, (len(tokens) + 1) // 2)
|
||||||
|
|
||||||
|
if hits >= required:
|
||||||
|
return True
|
||||||
|
|
||||||
|
threshold = 0.14 if relaxed else min_semantic
|
||||||
|
return semantic_score >= threshold
|
||||||
|
|
||||||
|
|
||||||
def exercise_passes_path_semantic_gate(
|
def exercise_passes_path_semantic_gate(
|
||||||
*,
|
*,
|
||||||
semantic_score: float,
|
semantic_score: float,
|
||||||
|
|
@ -641,11 +738,15 @@ def pick_best_path_hit(
|
||||||
used_exercise_ids: Set[int],
|
used_exercise_ids: Set[int],
|
||||||
*,
|
*,
|
||||||
semantic_brief: Optional[PlanningSemanticBrief] = None,
|
semantic_brief: Optional[PlanningSemanticBrief] = None,
|
||||||
|
stage_learning_goal: Optional[str] = None,
|
||||||
|
roadmap_stage_match: bool = False,
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
"""Gestufte Auswahl: strikt → relaxed → bester Semantik-Score."""
|
"""Gestufte Auswahl: strikt → relaxed → optional Notfall-Fallback."""
|
||||||
if not hits:
|
if not hits:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
stage_goal = (stage_learning_goal or "").strip()
|
||||||
|
|
||||||
def _scan(*, strict: bool) -> Optional[Dict[str, Any]]:
|
def _scan(*, strict: bool) -> Optional[Dict[str, Any]]:
|
||||||
best: Optional[Dict[str, Any]] = None
|
best: Optional[Dict[str, Any]] = None
|
||||||
best_key: Tuple[float, float] = (-1.0, -1.0)
|
best_key: Tuple[float, float] = (-1.0, -1.0)
|
||||||
|
|
@ -654,15 +755,25 @@ def pick_best_path_hit(
|
||||||
if eid in used_exercise_ids:
|
if eid in used_exercise_ids:
|
||||||
continue
|
continue
|
||||||
sem = float(hit.get("semantic_score") or 0.0)
|
sem = float(hit.get("semantic_score") or 0.0)
|
||||||
|
title = str(hit.get("title") or "")
|
||||||
|
summary = str(hit.get("summary") or "")
|
||||||
if semantic_brief and not exercise_passes_path_semantic_gate(
|
if semantic_brief and not exercise_passes_path_semantic_gate(
|
||||||
semantic_score=sem,
|
semantic_score=sem,
|
||||||
title=str(hit.get("title") or ""),
|
title=title,
|
||||||
summary=str(hit.get("summary") or ""),
|
summary=summary,
|
||||||
goal="",
|
goal="",
|
||||||
brief=semantic_brief,
|
brief=semantic_brief,
|
||||||
strict=strict,
|
strict=strict,
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
|
if stage_goal and not exercise_passes_stage_learning_goal_gate(
|
||||||
|
learning_goal=stage_goal,
|
||||||
|
title=title,
|
||||||
|
summary=summary,
|
||||||
|
semantic_score=sem,
|
||||||
|
relaxed=not strict,
|
||||||
|
):
|
||||||
|
continue
|
||||||
score = float(hit.get("score") or 0.0)
|
score = float(hit.get("score") or 0.0)
|
||||||
key = (sem, score)
|
key = (sem, score)
|
||||||
if key > best_key:
|
if key > best_key:
|
||||||
|
|
@ -677,7 +788,10 @@ def pick_best_path_hit(
|
||||||
if chosen:
|
if chosen:
|
||||||
return chosen
|
return chosen
|
||||||
|
|
||||||
# Notfall: bester verbleibender Treffer mit Semantik > 0 (Thema trotzdem priorisieren)
|
if roadmap_stage_match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Notfall (nur retrieval-first / Brücken): bester verbleibender Treffer
|
||||||
fallback: Optional[Dict[str, Any]] = None
|
fallback: Optional[Dict[str, Any]] = None
|
||||||
fallback_key: Tuple[float, float] = (-1.0, -1.0)
|
fallback_key: Tuple[float, float] = (-1.0, -1.0)
|
||||||
for hit in hits:
|
for hit in hits:
|
||||||
|
|
@ -706,8 +820,10 @@ __all__ = [
|
||||||
"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",
|
||||||
|
"exercise_passes_stage_learning_goal_gate",
|
||||||
"merge_semantic_brief_llm",
|
"merge_semantic_brief_llm",
|
||||||
"pick_best_path_hit",
|
"pick_best_path_hit",
|
||||||
|
"semantic_brief_for_stage",
|
||||||
"resolve_semantic_skill_weights",
|
"resolve_semantic_skill_weights",
|
||||||
"score_exercise_semantic_relevance",
|
"score_exercise_semantic_relevance",
|
||||||
"semantic_core_phrases",
|
"semantic_core_phrases",
|
||||||
|
|
|
||||||
96
backend/tests/test_planning_roadmap_stage_match.py
Normal file
96
backend/tests/test_planning_roadmap_stage_match.py
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
"""Tests Roadmap-Stufen-Match — Gate gegen themenfremde Übungen."""
|
||||||
|
from planning_exercise_semantics import (
|
||||||
|
exercise_passes_stage_learning_goal_gate,
|
||||||
|
pick_best_path_hit,
|
||||||
|
semantic_brief_for_stage,
|
||||||
|
build_semantic_brief,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stage_gate_accepts_learning_goal_in_title():
|
||||||
|
assert exercise_passes_stage_learning_goal_gate(
|
||||||
|
learning_goal="variable Rhythmen und Hüftmobilität für Mae Geri",
|
||||||
|
title="Mae Geri — variable Rhythmen",
|
||||||
|
summary="",
|
||||||
|
semantic_score=0.1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stage_gate_rejects_unrelated_kumite():
|
||||||
|
assert not exercise_passes_stage_learning_goal_gate(
|
||||||
|
learning_goal="variable Rhythmen und Hüftmobilität für Mae Geri",
|
||||||
|
title="Kumite Grundstellungen",
|
||||||
|
summary="Partnerarbeit Distanz",
|
||||||
|
semantic_score=0.05,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_semantic_brief_for_stage_adds_learning_goal():
|
||||||
|
brief = build_semantic_brief("Mae Geri Perfektion")
|
||||||
|
stage = semantic_brief_for_stage(
|
||||||
|
brief,
|
||||||
|
learning_goal="Hüftmobilität und Kammerhaltung",
|
||||||
|
phase="grundlage",
|
||||||
|
)
|
||||||
|
assert "hüftmobilität und kammerhaltung" in stage.must_phrases[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_best_path_hit_roadmap_stage_no_weak_fallback():
|
||||||
|
brief = build_semantic_brief("Mae Geri Perfektion")
|
||||||
|
stage_brief = semantic_brief_for_stage(
|
||||||
|
brief,
|
||||||
|
learning_goal="Hüftmobilität für Mae Geri",
|
||||||
|
phase="grundlage",
|
||||||
|
)
|
||||||
|
hits = [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Kumite Stellungen",
|
||||||
|
"summary": "Partner Distanz",
|
||||||
|
"score": 0.92,
|
||||||
|
"semantic_score": 0.08,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "Kraft-Ausdauer Zirkel",
|
||||||
|
"summary": "allgemeine Fitness",
|
||||||
|
"score": 0.88,
|
||||||
|
"semantic_score": 0.02,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
chosen = pick_best_path_hit(
|
||||||
|
hits,
|
||||||
|
set(),
|
||||||
|
semantic_brief=stage_brief,
|
||||||
|
stage_learning_goal="Hüftmobilität für Mae Geri",
|
||||||
|
roadmap_stage_match=True,
|
||||||
|
)
|
||||||
|
assert chosen is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_best_path_hit_roadmap_stage_picks_relevant():
|
||||||
|
brief = build_semantic_brief("Mae Geri Perfektion")
|
||||||
|
stage_brief = semantic_brief_for_stage(
|
||||||
|
brief,
|
||||||
|
learning_goal="Hüftmobilität für Mae Geri",
|
||||||
|
phase="grundlage",
|
||||||
|
)
|
||||||
|
hits = [
|
||||||
|
{"id": 1, "title": "Kumite", "score": 0.9, "semantic_score": 0.1},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "Mae Geri Hüftmobilität",
|
||||||
|
"summary": "Kammerhaltung und Hüfte",
|
||||||
|
"score": 0.7,
|
||||||
|
"semantic_score": 0.55,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
chosen = pick_best_path_hit(
|
||||||
|
hits,
|
||||||
|
set(),
|
||||||
|
semantic_brief=stage_brief,
|
||||||
|
stage_learning_goal="Hüftmobilität für Mae Geri",
|
||||||
|
roadmap_stage_match=True,
|
||||||
|
)
|
||||||
|
assert chosen is not None
|
||||||
|
assert int(chosen["id"]) == 2
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.217"
|
APP_VERSION = "0.8.218"
|
||||||
BUILD_DATE = "2026-06-07"
|
BUILD_DATE = "2026-06-07"
|
||||||
DB_SCHEMA_VERSION = "20260607088"
|
DB_SCHEMA_VERSION = "20260607088"
|
||||||
|
|
||||||
|
|
@ -53,6 +53,15 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "0.8.218",
|
||||||
|
"date": "2026-06-07",
|
||||||
|
"changes": [
|
||||||
|
"Roadmap-Match: Stufen-Lernziel-Gate (semantic_brief_for_stage, stage_learning_goal).",
|
||||||
|
"Kein Fallback auf globale goal_query bei roadmap_first — Lücke statt falscher Übung.",
|
||||||
|
"Retrieval-Strafe/Bonus für Stufen-Passung; QS erkennt stage_mismatch.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.8.217",
|
"version": "0.8.217",
|
||||||
"date": "2026-06-07",
|
"date": "2026-06-07",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Progressionsgraph — KI-Planung (Ist-Stand)
|
# Progressionsgraph — KI-Planung (Ist-Stand)
|
||||||
|
|
||||||
**Stand:** 2026-05-22 (Dokumentation) · **App-Version:** **0.8.217** · **DB:** Migration **088**
|
**Stand:** 2026-05-22 (Dokumentation) · **App-Version:** **0.8.218** · **DB:** Migration **088**
|
||||||
**Maßgeblich für Code:** `backend/version.py` (`APP_VERSION`, `DB_SCHEMA_VERSION`)
|
**Maßgeblich für Code:** `backend/version.py` (`APP_VERSION`, `DB_SCHEMA_VERSION`)
|
||||||
|
|
||||||
> **Diese Datei ist die zentrale Referenz** für die KI-gestützte Planung im Progressionsgraph.
|
> **Diese Datei ist die zentrale Referenz** für die KI-gestützte Planung im Progressionsgraph.
|
||||||
|
|
@ -157,7 +157,21 @@ flowchart TB
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Rolle des bestehenden Graphs
|
## 5. Roadmap-Match — Stufen-Qualität (0.8.218)
|
||||||
|
|
||||||
|
Pro Major Step gilt:
|
||||||
|
|
||||||
|
1. **Stufen-Brief** — `semantic_brief_for_stage()` ergänzt `must_phrases` um das Stufen-Lernziel.
|
||||||
|
2. **Stufen-Gate** — `exercise_passes_stage_learning_goal_gate()` prüft Lernziel-Text in Titel/Summary/Ziel oder Mindest-`semantic_score`.
|
||||||
|
3. **Kein Fallback** — Bei `roadmap_first` wird **nicht** auf die globale `goal_query` zurückgefallen; passt keine Übung → **Lücke** (`roadmap_unfilled`) statt themenfremder Übung.
|
||||||
|
4. **Retrieval** — Bonus/Strafe im Hybrid-Score je nach Stufen-Passung.
|
||||||
|
5. **QS** — `detect_off_topic_steps` erkennt `stage_mismatch` anhand `roadmap_learning_goal`.
|
||||||
|
|
||||||
|
Tests: `test_planning_roadmap_stage_match.py`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Rolle des bestehenden Graphs
|
||||||
|
|
||||||
**Wichtig — häufiges Missverständnis:**
|
**Wichtig — häufiges Missverständnis:**
|
||||||
|
|
||||||
|
|
@ -174,7 +188,7 @@ Code: `planning_exercise_progression.py` → `apply_progression_context_to_pack`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Persistenz
|
## 7. Persistenz
|
||||||
|
|
||||||
### 6.1 Kanten (`exercise_progression_edges`)
|
### 6.1 Kanten (`exercise_progression_edges`)
|
||||||
|
|
||||||
|
|
@ -211,7 +225,7 @@ Validierung: `progression_graph_planning_artifact.py` · Tests: `test_progressio
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Fähigkeiten-Scoring-Anbindung
|
## 8. Fähigkeiten-Scoring-Anbindung
|
||||||
|
|
||||||
Modul: `planning_skill_expectations.py`
|
Modul: `planning_skill_expectations.py`
|
||||||
|
|
||||||
|
|
@ -231,7 +245,7 @@ Integration:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. KI-Lücken (Gap-Fill)
|
## 9. KI-Lücken (Gap-Fill)
|
||||||
|
|
||||||
Flow:
|
Flow:
|
||||||
1. `roadmap_unfilled` / QA-Lücken → `gap_fill_offers`
|
1. `roadmap_unfilled` / QA-Lücken → `gap_fill_offers`
|
||||||
|
|
@ -243,7 +257,7 @@ Kontext-Helfer: `frontend/src/utils/planningContextForExerciseAi.js`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Implementierungsstände (Phasen)
|
## 10. Implementierungsstände (Phasen)
|
||||||
|
|
||||||
| Phase | Inhalt | Status | Version |
|
| Phase | Inhalt | Status | Version |
|
||||||
|-------|--------|--------|---------|
|
|-------|--------|--------|---------|
|
||||||
|
|
@ -255,12 +269,13 @@ Kontext-Helfer: `frontend/src/utils/planningContextForExerciseAi.js`
|
||||||
| F7 | `planning_skill_expectations` (Retrieval + UI + Gap) | ✅ | 0.8.215–216 |
|
| F7 | `planning_skill_expectations` (Retrieval + UI + Gap) | ✅ | 0.8.215–216 |
|
||||||
| F8 | Editierbare `stage_specs` in UI | ✅ | 0.8.216 |
|
| F8 | Editierbare `stage_specs` in UI | ✅ | 0.8.216 |
|
||||||
| F9 | `planning_roadmap` Persistenz (Migration **088**) | ✅ | 0.8.217 |
|
| F9 | `planning_roadmap` Persistenz (Migration **088**) | ✅ | 0.8.217 |
|
||||||
|
| F10 | Stufen-Lernziel-Gate beim Match (kein goal_query-Fallback) | ✅ | 0.8.218 |
|
||||||
| **G** | Trainingsplanung eigene Pipeline + Graph-Referenz | 🔲 | — |
|
| **G** | Trainingsplanung eigene Pipeline + Graph-Referenz | 🔲 | — |
|
||||||
| **UX** | Wizard/Stepper statt Scroll-Monolith | 🔲 | separater Chat |
|
| **UX** | Wizard/Stepper statt Scroll-Monolith | 🔲 | separater Chat |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Offenes Backlog (priorisiert)
|
## 11. Offenes Backlog (priorisiert)
|
||||||
|
|
||||||
1. **UI-Überarbeitung** — Wizard mit 4 Schritten, progressive disclosure (Briefing unten)
|
1. **UI-Überarbeitung** — Wizard mit 4 Schritten, progressive disclosure (Briefing unten)
|
||||||
2. **Graph-Erweiterungsmodus** — Start ab gewähltem Knoten / letzter Sequenz
|
2. **Graph-Erweiterungsmodus** — Start ab gewähltem Knoten / letzter Sequenz
|
||||||
|
|
@ -277,7 +292,7 @@ Kern: Wizard ① Ziel & Start/Ziel → ② Roadmap → ③ Match → ④ Lücken
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Tests
|
## 12. Tests
|
||||||
|
|
||||||
| Datei | Abdeckung |
|
| Datei | Abdeckung |
|
||||||
|-------|-----------|
|
|-------|-----------|
|
||||||
|
|
@ -291,7 +306,7 @@ Kern: Wizard ① Ziel & Start/Ziel → ② Roadmap → ③ Match → ④ Lücken
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. Dokumenten-Index (Drift vermeiden)
|
## 13. Dokumenten-Index (Drift vermeiden)
|
||||||
|
|
||||||
| Frage | Primäre Quelle |
|
| Frage | Primäre Quelle |
|
||||||
|-------|----------------|
|
|-------|----------------|
|
||||||
|
|
@ -307,7 +322,7 @@ Kern: Wizard ① Ziel & Start/Ziel → ② Roadmap → ③ Match → ④ Lücken
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. Changelog (Dokument)
|
## 14. Changelog (Dokument)
|
||||||
|
|
||||||
| Datum | Änderung |
|
| Datum | Änderung |
|
||||||
|-------|----------|
|
|-------|----------|
|
||||||
|
|
|
||||||
|
|
@ -1895,6 +1895,11 @@ export default function ExerciseProgressionPathBuilder({
|
||||||
: ''}
|
: ''}
|
||||||
{step.roadmapPhase ? ` (${step.roadmapPhase})` : ''}
|
{step.roadmapPhase ? ` (${step.roadmapPhase})` : ''}
|
||||||
{step.isOffTopic ? ' (themenfremd)' : ''}
|
{step.isOffTopic ? ' (themenfremd)' : ''}
|
||||||
|
{step.semanticScore != null &&
|
||||||
|
Number(step.semanticScore) < 0.22 &&
|
||||||
|
step.roadmapLearningGoal ? (
|
||||||
|
<span style={{ color: 'var(--danger)' }}> (schwaches Stufen-Match)</span>
|
||||||
|
) : null}
|
||||||
{step.isFromGraph ? ' (im Graph)' : ''}
|
{step.isFromGraph ? ' (im Graph)' : ''}
|
||||||
{step.isAiProposal ? ' (KI-Neu)' : step.isBridge ? ' (Brücke)' : ''}
|
{step.isAiProposal ? ' (KI-Neu)' : step.isBridge ? ' (Brücke)' : ''}
|
||||||
</label>
|
</label>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user