From 18547613ead964b7cf21bd6a2250d3bd6c77aaf4 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 10 Jun 2026 16:39:17 +0200 Subject: [PATCH] Implement Stage Learning Goal Features in Planning Exercise - 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. --- backend/planning_exercise_path_builder.py | 52 ++++---- backend/planning_exercise_path_qa.py | 36 ++++- backend/planning_exercise_retrieval.py | 24 +++- backend/planning_exercise_semantics.py | 124 +++++++++++++++++- .../test_planning_roadmap_stage_match.py | 96 ++++++++++++++ backend/version.py | 11 +- .../PLANNING_PROGRESSION_GRAPH_KI.md | 35 +++-- .../ExerciseProgressionPathBuilder.jsx | 5 + 8 files changed, 339 insertions(+), 44 deletions(-) create mode 100644 backend/tests/test_planning_roadmap_stage_match.py diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index 364448a..7dc0b4c 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -38,6 +38,7 @@ from planning_exercise_semantics import ( exercise_passes_path_semantic_gate, pick_best_path_hit, resolve_semantic_skill_weights, + semantic_brief_for_stage, step_phase_for_index, step_retrieval_query, try_enrich_semantic_brief_with_llm, @@ -183,8 +184,16 @@ def _pick_best_path_hit( used_exercise_ids: Set[int], *, semantic_brief: Optional[PlanningSemanticBrief] = None, + stage_learning_goal: Optional[str] = None, + roadmap_stage_match: bool = False, ) -> 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( @@ -282,6 +291,7 @@ def _run_path_step_retrieval( step_query_override: Optional[str] = None, step_phase_override: Optional[str] = None, step_target_profile_override: Optional[PlanningTargetProfile] = None, + stage_learning_goal: Optional[str] = None, ) -> Tuple[List[Dict[str, Any]], PlanningTargetProfile, Dict[str, Any], str]: step_query = step_query_override or step_retrieval_query( semantic_brief, goal_query, step_index, max_steps @@ -317,6 +327,8 @@ def _run_path_step_retrieval( "retrieval_query": step_query, "path_step_phase": step_phase_override 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( cur, @@ -556,6 +568,12 @@ def _build_steps_roadmap_first( major_step=major, ) 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( cur, @@ -569,36 +587,22 @@ def _build_steps_roadmap_first( progression_graph_id=body.progression_graph_id, include_llm_intent=body.include_llm_intent and step_index == 0, exercise_kind_any=step_kind, - semantic_brief=semantic_brief, + semantic_brief=stage_brief, path_target_profile=path_target_profile, path_intent=path_intent, step_query_override=step_query, step_phase_override=major.phase if major else None, step_target_profile_override=step_target, + stage_learning_goal=stage_goal or None, ) - hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief) - if not hit and step_query != goal_query: - hits, _, _, _ = _run_path_step_retrieval( - cur, - tenant=tenant, - goal_query=goal_query, - 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) + hit = _pick_best_path_hit( + hits, + used, + semantic_brief=stage_brief, + stage_learning_goal=stage_goal or None, + roadmap_stage_match=True, + ) if not hit: unfilled.append((step_index, stage_spec)) diff --git a/backend/planning_exercise_path_qa.py b/backend/planning_exercise_path_qa.py index 2f41247..2fdf291 100644 --- a/backend/planning_exercise_path_qa.py +++ b/backend/planning_exercise_path_qa.py @@ -20,7 +20,9 @@ from planning_exercise_semantics import ( PlanningSemanticBrief, brief_to_summary_dict, exercise_passes_path_semantic_gate, + exercise_passes_stage_learning_goal_gate, score_exercise_semantic_relevance, + semantic_brief_for_stage, 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: continue 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( title=bundle["title"], summary=bundle["summary"], goal=bundle["goal"], variant_names=bundle["variant_names"], - brief=brief, + brief=step_brief, 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( semantic_score=sem, title=bundle["title"], summary=bundle["summary"], goal=bundle["goal"], - brief=brief, + brief=step_brief, strict=True, ): continue diff --git a/backend/planning_exercise_retrieval.py b/backend/planning_exercise_retrieval.py index dcb928e..42af3e8 100644 --- a/backend/planning_exercise_retrieval.py +++ b/backend/planning_exercise_retrieval.py @@ -17,6 +17,7 @@ from planning_exercise_profiles import ( from planning_exercise_semantics import ( PlanningSemanticBrief, exercise_passes_path_semantic_gate, + exercise_passes_stage_learning_goal_gate, score_exercise_semantic_relevance, ) @@ -200,6 +201,8 @@ def rank_visible_library_hits( semantic_brief = semantic_brief_raw step_phase = pack.get("path_step_phase") 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() planned_ids = pack.get("planned_exercise_ids") or [] @@ -279,6 +282,8 @@ def rank_visible_library_hits( step_phase=step_phase, ) + score_penalty = 0.0 + stage_match_reason: Optional[str] = None if ( path_mode and semantic_brief @@ -293,8 +298,21 @@ def rank_visible_library_hits( ) ): score_penalty = 0.42 - else: - score_penalty = 0.0 + 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: + score_penalty += 0.35 score = ( weights.get("semantic", 0.0) * semantic_score @@ -309,6 +327,8 @@ def rank_visible_library_hits( ) reasons: List[str] = [] + if stage_match_reason: + reasons.append(stage_match_reason) if semantic_score >= 0.35 and semantic_reasons: for sr in semantic_reasons: if sr not in reasons: diff --git a/backend/planning_exercise_semantics.py b/backend/planning_exercise_semantics.py index 7571233..e91a6a0 100644 --- a/backend/planning_exercise_semantics.py +++ b/backend/planning_exercise_semantics.py @@ -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( *, semantic_score: float, @@ -641,11 +738,15 @@ def pick_best_path_hit( used_exercise_ids: Set[int], *, semantic_brief: Optional[PlanningSemanticBrief] = None, + stage_learning_goal: Optional[str] = None, + roadmap_stage_match: bool = False, ) -> Optional[Dict[str, Any]]: - """Gestufte Auswahl: strikt → relaxed → bester Semantik-Score.""" + """Gestufte Auswahl: strikt → relaxed → optional Notfall-Fallback.""" if not hits: return None + stage_goal = (stage_learning_goal or "").strip() + def _scan(*, strict: bool) -> Optional[Dict[str, Any]]: best: Optional[Dict[str, Any]] = None best_key: Tuple[float, float] = (-1.0, -1.0) @@ -654,15 +755,25 @@ def pick_best_path_hit( if eid in used_exercise_ids: continue 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( semantic_score=sem, - title=str(hit.get("title") or ""), - summary=str(hit.get("summary") or ""), + title=title, + summary=summary, goal="", brief=semantic_brief, strict=strict, ): 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) key = (sem, score) if key > best_key: @@ -677,7 +788,10 @@ def pick_best_path_hit( if 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_key: Tuple[float, float] = (-1.0, -1.0) for hit in hits: @@ -706,8 +820,10 @@ __all__ = [ "build_semantic_brief", "enrich_target_with_semantic_expectations", "exercise_passes_path_semantic_gate", + "exercise_passes_stage_learning_goal_gate", "merge_semantic_brief_llm", "pick_best_path_hit", + "semantic_brief_for_stage", "resolve_semantic_skill_weights", "score_exercise_semantic_relevance", "semantic_core_phrases", diff --git a/backend/tests/test_planning_roadmap_stage_match.py b/backend/tests/test_planning_roadmap_stage_match.py new file mode 100644 index 0000000..6fc529a --- /dev/null +++ b/backend/tests/test_planning_roadmap_stage_match.py @@ -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 diff --git a/backend/version.py b/backend/version.py index d3ab790..00dacd4 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.217" +APP_VERSION = "0.8.218" BUILD_DATE = "2026-06-07" DB_SCHEMA_VERSION = "20260607088" @@ -53,6 +53,15 @@ MODULE_VERSIONS = { } 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", "date": "2026-06-07", diff --git a/docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md b/docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md index 246493a..bacadd1 100644 --- a/docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md +++ b/docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md @@ -1,6 +1,6 @@ # 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`) > **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:** @@ -174,7 +188,7 @@ Code: `planning_exercise_progression.py` → `apply_progression_context_to_pack` --- -## 6. Persistenz +## 7. Persistenz ### 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` @@ -231,7 +245,7 @@ Integration: --- -## 8. KI-Lücken (Gap-Fill) +## 9. KI-Lücken (Gap-Fill) Flow: 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 | |-------|--------|--------|---------| @@ -255,12 +269,13 @@ Kontext-Helfer: `frontend/src/utils/planningContextForExerciseAi.js` | F7 | `planning_skill_expectations` (Retrieval + UI + Gap) | ✅ | 0.8.215–216 | | F8 | Editierbare `stage_specs` in UI | ✅ | 0.8.216 | | 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 | 🔲 | — | | **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) 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 | |-------|-----------| @@ -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 | |-------|----------------| @@ -307,7 +322,7 @@ Kern: Wizard ① Ziel & Start/Ziel → ② Roadmap → ③ Match → ④ Lücken --- -## 13. Changelog (Dokument) +## 14. Changelog (Dokument) | Datum | Änderung | |-------|----------| diff --git a/frontend/src/components/ExerciseProgressionPathBuilder.jsx b/frontend/src/components/ExerciseProgressionPathBuilder.jsx index a80812d..598a620 100644 --- a/frontend/src/components/ExerciseProgressionPathBuilder.jsx +++ b/frontend/src/components/ExerciseProgressionPathBuilder.jsx @@ -1895,6 +1895,11 @@ export default function ExerciseProgressionPathBuilder({ : ''} {step.roadmapPhase ? ` (${step.roadmapPhase})` : ''} {step.isOffTopic ? ' (themenfremd)' : ''} + {step.semanticScore != null && + Number(step.semanticScore) < 0.22 && + step.roadmapLearningGoal ? ( + (schwaches Stufen-Match) + ) : null} {step.isFromGraph ? ' (im Graph)' : ''} {step.isAiProposal ? ' (KI-Neu)' : step.isBridge ? ' (Brücke)' : ''}