Implement Peer Learning Goals and Stage Fit Enhancements
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 14s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m33s

- Introduced `_peer_stage_learning_goals` to retrieve learning goals from peer stages, enhancing the ability to filter exercises based on cross-slot collisions.
- Added `_filter_learning_goal_candidate_ids` to refine candidate selection by incorporating peer learning goals and stage fit criteria, improving exercise relevance in suggestions.
- Enhanced `pick_best_path_hit` and `_match_roadmap_slot` to utilize peer learning goals for better exercise selection and to prevent conflicts with titles from other stages.
- Updated `stage_refinement_criteria_from_learning_goal` to provide clearer criteria for stage refinement based on learning goals.
- Bumped version to 0.8.229 to reflect the new features and improvements.
This commit is contained in:
Lars 2026-06-12 07:40:26 +02:00
parent a49987408b
commit 8a4be795f4
5 changed files with 241 additions and 43 deletions

View File

@ -53,6 +53,8 @@ from planning_exercise_semantics import (
resolve_path_anti_patterns,
resolve_path_primary_topic,
exercise_passes_path_semantic_gate,
exercise_passes_stage_fit,
exercise_title_matches_peer_stage_goal,
pick_best_path_hit,
resolve_semantic_skill_weights,
step_phase_for_index,
@ -201,6 +203,78 @@ def _roadmap_structured_from_body(body: ProgressionPathSuggestRequest) -> Option
)
def _peer_stage_learning_goals(
roadmap_ctx: ProgressionRoadmapContext,
*,
current_major_index: int,
) -> List[str]:
goals: List[str] = []
for spec in roadmap_ctx.stage_specs or []:
if int(spec.major_step_index) == int(current_major_index):
continue
lg = (spec.learning_goal or "").strip()
if lg and lg not in goals:
goals.append(lg)
return goals
def _filter_learning_goal_candidate_ids(
cur,
*,
tenant: TenantContext,
progression_graph_id: Optional[int],
candidate_ids: Sequence[int],
stage_goal: str,
stage_match_brief: PlanningSemanticBrief,
stage_anti: Optional[List[str]],
path_primary: str,
path_tech_excludes: Optional[List[str]],
peer_learning_goals: Sequence[str],
) -> List[int]:
"""Learning-Goal-Kandidaten nur, wenn sie Stufen-Gate und Peer-Check bestehen."""
if not candidate_ids:
return []
vis_sql, vis_params = _planning_visibility_sql(cur, tenant, progression_graph_id)
rows = _load_supplemental_exercise_rows(
cur,
tenant=tenant,
progression_graph_id=progression_graph_id,
exercise_ids=list(candidate_ids),
vis_sql=vis_sql,
vis_params=vis_params,
)
out: List[int] = []
for row in rows:
try:
eid = int(row.get("id") or 0)
except (TypeError, ValueError):
continue
if eid <= 0:
continue
title = str(row.get("title") or "")
if peer_learning_goals and exercise_title_matches_peer_stage_goal(
title,
current_learning_goal=stage_goal,
peer_learning_goals=peer_learning_goals,
):
continue
summary = str(row.get("summary") or "")
goal_text = str(row.get("goal") or row.get("exercise_goal") or "")
if exercise_passes_stage_fit(
learning_goal=stage_goal,
title=title,
summary=summary,
goal=goal_text,
stage_brief=stage_match_brief,
anti_patterns=stage_anti,
path_primary_topic=path_primary or None,
path_technique_excludes=path_tech_excludes,
relaxed=True,
):
out.append(eid)
return out
def _pick_best_path_hit(
hits: List[Dict[str, Any]],
used_exercise_ids: Set[int],
@ -212,6 +286,7 @@ def _pick_best_path_hit(
stage_match_brief: Optional[PlanningSemanticBrief] = None,
path_primary_topic: Optional[str] = None,
path_technique_excludes: Optional[List[str]] = None,
peer_learning_goals: Optional[List[str]] = None,
) -> Optional[Dict[str, Any]]:
return pick_best_path_hit(
hits,
@ -223,6 +298,7 @@ def _pick_best_path_hit(
stage_match_brief=stage_match_brief,
path_primary_topic=path_primary_topic,
path_technique_excludes=path_technique_excludes,
peer_learning_goals=peer_learning_goals,
)
@ -366,13 +442,12 @@ def _fetch_learning_goal_library_candidate_ids(
learning_goal: str,
limit: int = 24,
) -> List[int]:
"""Sichtbare Übungen, deren Titel/Volltext zum Stufen-Lernziel passt."""
"""Sichtbare Übungen mit exakt passendem Titel oder Volltext-Treffer (kein breites LIKE)."""
lg = (learning_goal or "").strip()
if len(lg) < 3:
return []
vis_sql, vis_params = _planning_visibility_sql(cur, tenant, progression_graph_id)
tsq = _safe_tsquery_fragment(lg)
like_pat = f"%{lg[:100].lower()}%"
try:
cur.execute(
f"""
@ -382,7 +457,6 @@ def _fetch_learning_goal_library_candidate_ids(
AND COALESCE(e.status, '') <> %s
AND (
lower(trim(e.title)) = lower(trim(%s))
OR lower(e.title) LIKE %s
OR (%s <> '' AND e.search_vector @@ plainto_tsquery('german', %s))
)
ORDER BY
@ -395,7 +469,6 @@ def _fetch_learning_goal_library_candidate_ids(
*vis_params,
"archived",
lg,
like_pat,
tsq,
tsq,
lg,
@ -411,14 +484,11 @@ def _fetch_learning_goal_library_candidate_ids(
FROM exercises e
WHERE ({vis_sql})
AND COALESCE(e.status, '') <> %s
AND (
lower(trim(e.title)) = lower(trim(%s))
OR lower(e.title) LIKE %s
)
ORDER BY CASE WHEN lower(trim(e.title)) = lower(trim(%s)) THEN 0 ELSE 1 END, e.id ASC
AND lower(trim(e.title)) = lower(trim(%s))
ORDER BY e.id ASC
LIMIT %s
""",
[*vis_params, "archived", lg, like_pat, lg, int(limit)],
[*vis_params, "archived", lg, int(limit)],
)
out: List[int] = []
for row in cur.fetchall() or []:
@ -1092,14 +1162,30 @@ def _match_roadmap_slot(
major_step=major,
)
step_kind = resolve_step_exercise_kind_filter(stage_spec, body.exercise_kind_any)
peer_goals = _peer_stage_learning_goals(
roadmap_ctx,
current_major_index=int(stage_spec.major_step_index),
)
supplemental_ids = _supplemental_exercise_ids_from_body(cur, body)
lg_candidates = _fetch_learning_goal_library_candidate_ids(
lg_candidates_raw = _fetch_learning_goal_library_candidate_ids(
cur,
tenant=tenant,
progression_graph_id=body.progression_graph_id,
learning_goal=stage_goal,
)
lg_candidates = _filter_learning_goal_candidate_ids(
cur,
tenant=tenant,
progression_graph_id=body.progression_graph_id,
candidate_ids=lg_candidates_raw,
stage_goal=stage_goal,
stage_match_brief=stage_match_brief,
stage_anti=stage_anti,
path_primary=path_primary,
path_tech_excludes=path_tech_excludes,
peer_learning_goals=peer_goals,
)
supplemental_ids = list(
dict.fromkeys(
int(x)
@ -1163,6 +1249,7 @@ def _match_roadmap_slot(
stage_match_brief=stage_match_brief,
path_primary_topic=path_primary or None,
path_technique_excludes=path_tech_excludes or None,
peer_learning_goals=peer_goals,
)
if not hit:

View File

@ -864,19 +864,48 @@ def stage_focus_phrases_from_learning_goal(learning_goal: str) -> List[str]:
return []
tokens = _significant_stage_tokens(lg, strip_negated=True)
phrases: List[str] = []
for tok in tokens:
if len(tok) >= 5 and tok not in phrases:
phrases.append(tok)
norm_lg = _normalize_phrase(lg)
if len(norm_lg) >= 8:
phrases.append(norm_lg[:120])
for i in range(len(tokens) - 1):
pair = f"{tokens[i]} {tokens[i + 1]}"
if len(pair) >= 8 and pair not in phrases:
phrases.append(pair)
norm_lg = _normalize_phrase(lg)
if len(norm_lg) >= 8 and norm_lg not in phrases:
phrases.insert(0, norm_lg[:120])
for tok in tokens:
if len(tok) >= 6 and tok not in phrases:
phrases.append(tok)
return phrases[:8]
def stage_refinement_criteria_from_learning_goal(learning_goal: str) -> List[str]:
"""Erfolgskriterien für Phase C — nur aussagekräftige Mehrwort-Phrasen."""
out: List[str] = []
for phrase in stage_focus_phrases_from_learning_goal(learning_goal):
p = str(phrase or "").strip()
if not p:
continue
if " " in p or len(p) >= 12:
out.append(p[:120])
return out[:4]
def exercise_title_matches_peer_stage_goal(
title: str,
*,
current_learning_goal: str,
peer_learning_goals: Sequence[str],
) -> bool:
"""Titel passt zum Lernziel einer anderen Roadmap-Stufe (Cross-Slot-Kollision)."""
current = (current_learning_goal or "").strip()
for peer in peer_learning_goals or []:
plg = (peer or "").strip()
if len(plg) < 3 or plg == current:
continue
if exercise_title_equivalent_to_stage_goal(title, plg):
return True
return False
def _significant_stage_tokens(learning_goal: str, *, strip_negated: bool = True) -> List[str]:
"""Wörter aus Stufen-Lernziel für Text-Match (ohne Füllwörter, ohne Negationssegmente)."""
text = _normalize_phrase(learning_goal)
@ -1356,17 +1385,23 @@ def _pick_roadmap_rank_fallback(
stage_anti_patterns: Optional[Sequence[str]] = None,
path_primary_topic: Optional[str] = None,
path_technique_excludes: Optional[Sequence[str]] = None,
stage_match_brief: Optional[PlanningSemanticBrief] = None,
peer_learning_goals: Optional[Sequence[str]] = None,
) -> Optional[Dict[str, Any]]:
"""
Roadmap-Notfall: bester Treffer nach Stufen-Ranking, wenn striktes Gate leer läuft.
Filtert weiterhin Ausschlüsse und Technik-Scope (Kumite etc.), aber ohne
Mindest-Semantik-Schwelle so finden auch wortnahe Bibliotheks-Übungen den Slot.
Weiterhin mit relaxed stage_fit kein blindes Ranking ohne Stufen-Passung.
"""
stage_goal = (stage_learning_goal or "").strip()
if not stage_goal or not hits:
return None
stage_brief = stage_match_brief or build_stage_match_brief(
learning_goal=stage_goal,
anti_patterns=stage_anti_patterns,
)
best: Optional[Dict[str, Any]] = None
best_key: Tuple[float, float] = (-1.0, -1.0)
for hit in hits:
@ -1379,33 +1414,31 @@ def _pick_roadmap_rank_fallback(
title = str(hit.get("title") or "")
summary = str(hit.get("summary") or "")
goal_text = str(hit.get("goal") or hit.get("exercise_goal") or "")
blob = _blob_from_fields(title, summary, goal_text, [])
exclude_phrases = merge_stage_exclude_phrases(stage_goal, stage_anti_patterns)
if exclude_phrases and _blob_matches_stage_excludes(blob, exclude_phrases):
if peer_learning_goals and exercise_title_matches_peer_stage_goal(
title,
current_learning_goal=stage_goal,
peer_learning_goals=peer_learning_goals,
):
continue
title_equiv = exercise_title_equivalent_to_stage_goal(title, stage_goal)
primary = (path_primary_topic or "").strip()
if primary and not title_equiv:
tech_excludes = list(path_technique_excludes or [])
for item in technique_sibling_excludes(primary):
if item not in tech_excludes:
tech_excludes.append(item)
if not exercise_passes_technique_path_scope(
primary_topic=primary,
title=title,
summary=summary,
goal=goal_text,
learning_goal=stage_goal,
sibling_excludes=tech_excludes,
relaxed=True,
):
continue
rank_sem = float(
hit.get("stage_rank_semantic")
or hit.get("stage_semantic_score")
or hit.get("semantic_score")
or 0.0
)
if not exercise_passes_stage_fit(
learning_goal=stage_goal,
title=title,
summary=summary,
goal=goal_text,
stage_brief=stage_brief,
stage_semantic_score=rank_sem,
anti_patterns=stage_anti_patterns,
path_primary_topic=path_primary_topic,
path_technique_excludes=path_technique_excludes,
relaxed=True,
):
continue
score = float(hit.get("score") or 0.0)
key = (rank_sem, score)
if key > best_key:
@ -1427,6 +1460,7 @@ def pick_best_path_hit(
stage_match_brief: Optional[PlanningSemanticBrief] = None,
path_primary_topic: Optional[str] = None,
path_technique_excludes: Optional[Sequence[str]] = None,
peer_learning_goals: Optional[Sequence[str]] = None,
) -> Optional[Dict[str, Any]]:
"""Gestufte Auswahl: strikt → relaxed → optional Notfall-Fallback."""
if not hits:
@ -1451,6 +1485,12 @@ def pick_best_path_hit(
title = str(hit.get("title") or "")
summary = str(hit.get("summary") or "")
goal_text = str(hit.get("goal") or hit.get("exercise_goal") or "")
if peer_learning_goals and exercise_title_matches_peer_stage_goal(
title,
current_learning_goal=stage_goal,
peer_learning_goals=peer_learning_goals,
):
continue
sem = float(hit.get("semantic_score") or 0.0)
stage_sem = float(
hit.get("stage_rank_semantic")
@ -1506,6 +1546,8 @@ def pick_best_path_hit(
stage_anti_patterns=stage_anti_patterns,
path_primary_topic=path_primary_topic,
path_technique_excludes=path_technique_excludes,
stage_match_brief=stage_brief,
peer_learning_goals=peer_learning_goals,
)
chosen = _scan(strict=False)
@ -1546,6 +1588,7 @@ __all__ = [
"build_stage_match_brief",
"enrich_brief_with_path_constraints",
"exercise_passes_stage_fit",
"exercise_title_matches_peer_stage_goal",
"exercise_title_equivalent_to_stage_goal",
"resolve_path_primary_topic",
"resolve_path_anti_patterns",
@ -1555,6 +1598,7 @@ __all__ = [
"merge_stage_exclude_phrases",
"parse_stage_goal_constraints",
"stage_focus_phrases_from_learning_goal",
"stage_refinement_criteria_from_learning_goal",
"pick_best_path_hit",
"exercise_passes_technique_path_scope",
"score_exercise_stage_fit",

View File

@ -12,7 +12,7 @@ from planning_exercise_semantics import (
is_trainer_stage_anti_marker,
merge_stage_exclude_phrases,
parse_stage_goal_constraints,
stage_focus_phrases_from_learning_goal,
stage_refinement_criteria_from_learning_goal,
)
from planning_progression_roadmap import ProgressionRoadmapContext, StageSpecArtifact
@ -118,7 +118,7 @@ def refine_stage_spec_artifact(
anti.append(phrase)
changes.append(f"Ausschluss aus Lernziel: {phrase[:60]}")
for phrase in stage_focus_phrases_from_learning_goal(learning_goal):
for phrase in stage_refinement_criteria_from_learning_goal(learning_goal):
crit = f"Bezug zu Stufen-Lernziel: {phrase[:100]}"
if crit not in success:
success.append(crit)

View File

@ -52,3 +52,70 @@ def test_stage_focus_scoring_rewards_learning_goal_tokens():
stage_brief=brief,
)
assert score >= 0.25
def test_rank_fallback_requires_relaxed_stage_fit():
goal_a = "Grundlegende Körperhaltung und erste Mae Geri Bewegung"
goal_b = "Präzise Trefferfläche und variable Distanzen"
hits = [
{
"id": 1,
"title": "Gleichgewichtstritt Mae-Geri",
"summary": "Balance",
"goal": "Mae Geri",
"stage_rank_semantic": 0.04,
"score": 0.5,
},
{
"id": 2,
"title": "Erlernen des Mae Geri aus zusammengesetzten Einzelbewegungen",
"summary": "Teile verbinden",
"goal": "Zusammensetzung",
"stage_rank_semantic": 0.03,
"score": 0.48,
},
]
from planning_exercise_semantics import pick_best_path_hit
brief_a = build_stage_match_brief(learning_goal=goal_a)
chosen = pick_best_path_hit(
hits,
set(),
stage_learning_goal=goal_a,
roadmap_stage_match=True,
stage_match_brief=brief_a,
peer_learning_goals=[goal_b],
)
assert chosen is None
def test_peer_stage_title_blocked_for_wrong_slot():
goal_a = "Grundlegende Körperhaltung und erste Mae Geri Bewegung"
goal_b = "Gleichgewichtstritt Mae-Geri"
from planning_exercise_semantics import exercise_title_matches_peer_stage_goal, pick_best_path_hit
assert exercise_title_matches_peer_stage_goal(
"Gleichgewichtstritt Mae-Geri",
current_learning_goal=goal_a,
peer_learning_goals=[goal_b],
)
hits = [
{
"id": 10,
"title": "Gleichgewichtstritt Mae-Geri",
"summary": "Balance auf einem Bein",
"goal": "Mae Geri aus Gleichgewicht",
"stage_rank_semantic": 0.35,
"score": 0.6,
}
]
brief_a = build_stage_match_brief(learning_goal=goal_a)
chosen = pick_best_path_hit(
hits,
set(),
stage_learning_goal=goal_a,
roadmap_stage_match=True,
stage_match_brief=brief_a,
peer_learning_goals=[goal_b],
)
assert chosen is None

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.228"
APP_VERSION = "0.8.229"
BUILD_DATE = "2026-05-22"
DB_SCHEMA_VERSION = "20260607090"
@ -38,7 +38,7 @@ MODULE_VERSIONS = {
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0",
"exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume
"planning_exercise_suggest": "0.23.3", # Stufen-Match: saubere Anti-Patterns, Fit-Scoring, Rematch-Akkumulation
"planning_exercise_suggest": "0.23.4", # Stufen-Match: Fallback mit Gate, Peer-Slot-Schutz, LG-Kandidaten-Filter
"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