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

- 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:
Lars 2026-06-10 16:39:17 +02:00
parent 48d51c07c5
commit 18547613ea
8 changed files with 339 additions and 44 deletions

View File

@ -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))

View File

@ -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

View File

@ -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:

View File

@ -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",

View 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

View File

@ -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",

View File

@ -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.215216 | | F7 | `planning_skill_expectations` (Retrieval + UI + Gap) | ✅ | 0.8.215216 |
| 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 |
|-------|----------| |-------|----------|

View File

@ -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>