diff --git a/.claude/docs/PROJECT_STATUS.md b/.claude/docs/PROJECT_STATUS.md
index 3ab8915..209c8fd 100644
--- a/.claude/docs/PROJECT_STATUS.md
+++ b/.claude/docs/PROJECT_STATUS.md
@@ -15,7 +15,7 @@
**Plattform-Rechtstexte (P-01, 0.8.95–0.8.96):** Admin-Editor mit **Abschnitts- und Vollvorschau** (Markdown); fortlaufende Abschnittsnummerierung in der Anzeige/PDF (Darstellung, nicht DB-persistent).
-**Parallel weiter relevant:** **Trainingsplan Phasen & Streams** (Migration **063**, Coach + Planung **0.8.137–0.8.140**; Handover **`docs/HANDOVER.md`** §3); **Trainingsrahmenprogramm** (036–037), **Progressionsgraph** (032–034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**.
+**Parallel weiter relevant:** **Trainingsplan Phasen & Streams** (Migration **063**, Coach + Planung **0.8.137–0.8.140**; Handover **`docs/HANDOVER.md`** §3); **Trainingsrahmenprogramm** (036–037), **Progressionsgraph** (032–034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**. **Planungs-KI Progressionsgraph** (Roadmap-first, Auto-Optimierung, Katalog-Kontext **0.8.233**): Ist-Doku **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`**, Handover **`docs/HANDOVER.md`** §2.8.
**Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) Abschnitt 12 · Medien-Norm: [`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`](technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md) (inkl. **Abschnitt 11 Inline-Medien**, umgesetzt) · **Fachlicher Nutzerüberblick:** [`../../docs/FACHLICHE_NUTZERFUNKTIONEN.md`](../../docs/FACHLICHE_NUTZERFUNKTIONEN.md)
diff --git a/.claude/docs/functional/DOMAIN_MODEL.md b/.claude/docs/functional/DOMAIN_MODEL.md
index 158d784..5f0a99a 100644
--- a/.claude/docs/functional/DOMAIN_MODEL.md
+++ b/.claude/docs/functional/DOMAIN_MODEL.md
@@ -465,6 +465,8 @@ skill_level_definitions (
**Fachliche Grenze aktuell:** Mehrere gleichwertige „Pakete“ paralleler Alternativen sind **modellierbar** (mehrere ausgehende Kanten), aber noch **nicht** über eine dedizierte „Alternativgruppe“ in der UI trivial pflegbar; siehe `technical/TRAINING_FRAMEWORK_SPEC.md` §4.
+**KI-Planung (Workbench, Stand 0.8.233):** Am Graph können Trainer neben Kanten ein **`planning_roadmap`**-Artefakt (Curriculum-Stufen) und **`planning_catalog_context`** (Primärfokus, Stilrichtung, Trainingsstil, Zielgruppe aus den Katalog-Dimensionen §1) pflegen. Die Roadmap-first-Pipeline matcht Übungen pro Stufe; Didaktik und Reihenfolge kommen aus Roadmap + QS, nicht aus Technik-Hardcoding. **Geplant (H1):** Katalog-Dimensionen zusätzlich als **Prompt-Snippets** in LLM-Aufrufen (Priorität Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung) — **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`**. Technische Details: **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`**. Für **Trainingsplanung** (Einheit, Abschnitt, Rahmen-Slot) gelten dieselben Katalog- und Retrieval-Bausteine mit anderen Scopes — Phase G, siehe Roadmap **`PLANNING_KI_ROADMAP.md`**.
+
### Trainingsrahmen‑Vorlage (Rahmenprogramm, CURR‑002 Stufe 2 / CURR‑009)
**Abgrenzung:** Eine **einzeilige** Trainingsplan‑Mikrovorlage (`training_plan_template`) strukturiert **eine** Einheit; das **Rahmenprogramm** ist eine **eigene Bibliotheksentität** mit **sortierten Session‑Slots**, **mindestens einem** formulierten **Entwicklungsziel** (Zielliste, **CURR‑011**) und einem **vollständigen Ablauf** pro Slot (**`training_unit_sections` + `training_unit_section_items`** wie bei geplanten Einheiten — **CURR‑010** inhaltlich, technisch seit **037** identisch zur Planungsstruktur). Der persistierte **Progressionsgraph** zwischen Übungen bleibt **optional** (**CURR‑013**).
diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py
index 6d3fcc7..618a467 100644
--- a/backend/planning_exercise_path_builder.py
+++ b/backend/planning_exercise_path_builder.py
@@ -6,6 +6,7 @@ planning_progression_roadmap.py und PLANNING_PROGRESSION_ROADMAP_SPEC.md.
"""
from __future__ import annotations
+import re
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple
from fastapi import HTTPException
@@ -27,14 +28,17 @@ from planning_exercise_profiles import PlanningTargetProfile
from planning_path_qa_pipeline import run_multistage_path_qa
from planning_path_rematch import (
collect_rematch_slot_indices,
+ filter_rematch_slot_indices,
prune_stripped_after_rematch,
rematch_roadmap_slots,
)
from planning_path_refine_stage import apply_stage_spec_refinements, collect_refine_stage_targets
from planning_stage_context import build_contextualized_stage_goal, resolve_path_start_target
from planning_exercise_path_qa import (
+ _load_exercise_text_bundle,
apply_llm_path_reorder,
build_path_qa_summary,
+ compute_deterministic_path_quality_score,
detect_off_topic_steps,
detect_path_gaps,
insert_bridge_exercises,
@@ -63,6 +67,7 @@ from planning_exercise_semantics import (
exercise_passes_stage_fit,
exercise_title_matches_peer_stage_goal,
pick_best_path_hit,
+ score_exercise_stage_fit,
resolve_semantic_skill_weights,
step_phase_for_index,
step_retrieval_query,
@@ -140,7 +145,14 @@ class ProgressionPathSuggestRequest(BaseModel):
roadmap_notes: Optional[str] = Field(default=None, max_length=2000)
progression_graph_id: Optional[int] = Field(default=None, ge=1)
exercise_kind_any: Optional[List[str]] = None
+ compare_with_assignments: bool = False
planning_catalog_context: Optional[ProgressionPlanningCatalogContext] = None
+ # Für Match-Vergleich: Baseline aus evaluate_only (Schritt 1) — inkrementelles QS-Scoring je Diff
+ baseline_evaluate_steps: Optional[List[EvaluateStepPayload]] = None
+ baseline_quality_score: Optional[float] = Field(default=None, ge=0.0, le=1.0)
+ include_incremental_diff_scoring: bool = False
+ unified_slot_review: bool = False
+ baseline_path_qa_snapshot: Optional[Dict[str, Any]] = None
def _resolve_planning_catalog_context(
@@ -675,6 +687,11 @@ def _slot_assignments_by_major_index(
return out
+def _assignment_preservation_active(body: ProgressionPathSuggestRequest) -> bool:
+ """Trainer-Pfad schützen — nur bei explizitem Flag (Frontend entscheidet pro Aktion)."""
+ return bool(body.preserve_slot_assignments)
+
+
def _path_step_from_slot_assignment(
cur,
*,
@@ -1152,6 +1169,7 @@ def _match_roadmap_slot(
anchor_variant_id: Optional[int],
used: Set[int],
slot_priority_exercise_id: Optional[int] = None,
+ skip_post_match_gate: bool = False,
) -> Tuple[Optional[Dict[str, Any]], Optional[StageSpecArtifact]]:
"""Einzelnen Roadmap-Slot matchen (Initial-Build und Auto-Rematch)."""
major_by_index: Dict[int, MajorStep] = {}
@@ -1318,11 +1336,15 @@ def _match_roadmap_slot(
else:
step["slot_status"] = "matched"
step["roadmap_match_source"] = "stage_spec"
- if step.get("roadmap_match_source") != "slot_best_match" and not _roadmap_step_passes_post_match_gate(
- cur,
- step,
- goal_query=goal_query,
- semantic_brief=semantic_brief,
+ if (
+ not skip_post_match_gate
+ and step.get("roadmap_match_source") != "slot_best_match"
+ and not _roadmap_step_passes_post_match_gate(
+ cur,
+ step,
+ goal_query=goal_query,
+ semantic_brief=semantic_brief,
+ )
):
return None, stage_spec
return step, None
@@ -1709,6 +1731,12 @@ def _run_roadmap_rematch_loop(
slot_indices.add(int(midx))
if int(midx) not in rematch_reasons:
rematch_reasons[int(midx)] = "refine_stage_spec"
+ slot_indices = filter_rematch_slot_indices(
+ steps,
+ slot_indices,
+ stripped_off_topic=current_stripped if round_idx == 0 else [],
+ off_topic_steps=off_topic_before_strip if round_idx == 0 and use_initial_off_topic else [],
+ )
if not slot_indices:
break
@@ -1841,11 +1869,33 @@ def _build_steps_roadmap_first(
if roadmap_ctx.roadmap:
majors_by_index = {m.index: m for m in roadmap_ctx.roadmap.major_steps}
+ preserve_assignments = _assignment_preservation_active(body)
+
for step_index, stage_spec in enumerate(stage_specs):
major_idx = stage_spec.major_step_index
major = majors_by_index.get(major_idx)
slot_priority_id: Optional[int] = None
+ if preserve_assignments and major_idx in assignments:
+ direct = _path_step_from_slot_assignment(
+ cur,
+ assignment=assignments[major_idx],
+ stage_spec=stage_spec,
+ major_step=major,
+ tenant=tenant,
+ progression_graph_id=body.progression_graph_id,
+ )
+ if direct:
+ direct["slot_status"] = "preserved"
+ direct["roadmap_match_source"] = "slot_best_match"
+ steps.append(direct)
+ eid = int(direct["exercise_id"])
+ used.add(eid)
+ planned_ids.append(eid)
+ anchor_id = eid
+ anchor_variant_id = direct.get("variant_id")
+ continue
+
if major_idx in assignments:
try:
slot_priority_id = int(assignments[major_idx].exercise_id)
@@ -1974,6 +2024,56 @@ def _build_evaluate_empty_slot_gap_specs(
return specs[:8]
+def _build_off_topic_slot_gap_spec(
+ step: Mapping[str, Any],
+ *,
+ goal_query: str = "",
+) -> Optional[Dict[str, Any]]:
+ """KI-Angebot für belegten, aber themenfremden Slot (Ersatz statt Leerstelle)."""
+ del goal_query
+ major_idx = step.get("roadmap_major_step_index")
+ if major_idx is None:
+ return None
+ try:
+ roadmap_idx = int(major_idx)
+ except (TypeError, ValueError):
+ return None
+ phase = (step.get("roadmap_phase") or "vertiefung").strip().lower()
+ learning_goal = (step.get("roadmap_learning_goal") or step.get("title") or "").strip()
+ rejected_title = (step.get("title") or "").strip()
+ title_hint = learning_goal[:120] if learning_goal else f"Slot {roadmap_idx + 1}"
+ rationale = (
+ f"Slot {roadmap_idx + 1}: Ersatz für „{rejected_title}“ — passende Übung per KI."
+ if rejected_title
+ else f"Slot {roadmap_idx + 1} — KI-Entwurf für diese Roadmap-Stufe."
+ )
+ return {
+ "source": "off_topic",
+ "insert_after_index": max(roadmap_idx - 1, -1),
+ "replace_step_index": roadmap_idx,
+ "gap": {
+ "expected_phase": phase,
+ "roadmap_major_step_index": roadmap_idx,
+ "learning_goal": learning_goal,
+ },
+ "phase": phase,
+ "title_hint": title_hint,
+ "sketch": learning_goal or title_hint,
+ "rationale": rationale[:400],
+ "roadmap_major_step_index": roadmap_idx,
+ }
+
+
+def _gap_offer_major_index(offer: Mapping[str, Any]) -> Optional[int]:
+ raw = offer.get("roadmap_major_step_index")
+ if raw is None:
+ return None
+ try:
+ return int(raw)
+ except (TypeError, ValueError):
+ return None
+
+
def _run_evaluate_only_path_qa(
cur,
*,
@@ -2116,6 +2216,1516 @@ def _run_evaluate_only_path_qa(
}
+def _path_qa_quality_score(path_qa: Optional[Mapping[str, Any]]) -> Optional[float]:
+ if not path_qa:
+ return None
+ raw = path_qa.get("quality_score")
+ try:
+ return float(raw) if raw is not None else None
+ except (TypeError, ValueError):
+ return None
+
+
+def _steps_by_major_index(steps: Sequence[Mapping[str, Any]]) -> Dict[int, Dict[str, Any]]:
+ out: Dict[int, Dict[str, Any]] = {}
+ for raw in steps or []:
+ if not isinstance(raw, dict):
+ continue
+ midx = raw.get("roadmap_major_step_index")
+ if midx is None:
+ continue
+ try:
+ out[int(midx)] = dict(raw)
+ except (TypeError, ValueError):
+ continue
+ return out
+
+
+def _steps_to_evaluate_payloads(steps: Sequence[Mapping[str, Any]]) -> List[EvaluateStepPayload]:
+ """Pfad-Schritte → evaluate_steps (für faire QS auf dem End-Stand)."""
+ payloads: List[EvaluateStepPayload] = []
+ for step in steps or []:
+ if not isinstance(step, dict):
+ continue
+ midx = step.get("roadmap_major_step_index")
+ if midx is None:
+ continue
+ eid = step.get("exercise_id")
+ is_proposal = bool(step.get("is_ai_proposal")) or eid is None
+ payloads.append(
+ EvaluateStepPayload(
+ exercise_id=int(eid) if eid is not None and not is_proposal else None,
+ variant_id=step.get("variant_id"),
+ title=step.get("title"),
+ is_ai_proposal=is_proposal,
+ ai_suggestion=step.get("ai_suggestion") if isinstance(step.get("ai_suggestion"), dict) else None,
+ proposal_key=step.get("proposal_key"),
+ roadmap_major_step_index=int(midx),
+ roadmap_phase=step.get("roadmap_phase"),
+ roadmap_learning_goal=step.get("roadmap_learning_goal"),
+ )
+ )
+ payloads.sort(key=lambda p: int(p.roadmap_major_step_index or 0))
+ return payloads
+
+
+def _normalize_slot_title(title: Optional[str]) -> str:
+ return (title or "").strip().casefold()
+
+
+def _annotate_slot_diffs(
+ diffs: Sequence[Mapping[str, Any]],
+) -> List[Dict[str, Any]]:
+ """Kennzeichnet reine ID-Tausche (gleicher Titel) — bleiben sichtbar, zählen aber nicht als inhaltlich."""
+ out: List[Dict[str, Any]] = []
+ for raw in diffs or []:
+ if not isinstance(raw, dict):
+ continue
+ entry = dict(raw)
+ bt = _normalize_slot_title(entry.get("baseline_title"))
+ pt = _normalize_slot_title(entry.get("proposed_title"))
+ entry["trivial_id_swap"] = bool(bt and pt and bt == pt)
+ out.append(entry)
+ return out
+
+
+def _actionable_slot_diffs(diffs: Sequence[Mapping[str, Any]]) -> List[Dict[str, Any]]:
+ return [d for d in diffs if not d.get("trivial_id_swap")]
+
+
+def _last_rematch_replacements_by_slot(
+ rematch_log: Sequence[Mapping[str, Any]],
+) -> Dict[int, Mapping[str, Any]]:
+ """Letzter erfolgreicher Replace je Slot (Multi-Runden-Rematch)."""
+ out: Dict[int, Mapping[str, Any]] = {}
+ for entry in rematch_log or []:
+ if not isinstance(entry, dict):
+ continue
+ if str(entry.get("action") or "") != "replaced":
+ continue
+ if entry.get("new_exercise_id") is None:
+ continue
+ midx = entry.get("roadmap_major_step_index")
+ if midx is None:
+ continue
+ out[int(midx)] = entry
+ return out
+
+
+def _baseline_slot_accepts_rematch_suggestion(base: Mapping[str, Any]) -> bool:
+ """Rematch-Protokoll nur für leere oder explizit ungültige Slots — nicht kuratierte Zuordnungen ersetzen."""
+ if not base:
+ return True
+ base_id = base.get("exercise_id")
+ status = str(base.get("slot_status") or "").strip().lower()
+ if base_id is None:
+ return True
+ if status in {"unfilled", "stripped", "gap", "off_topic"}:
+ return True
+ return False
+
+
+def _build_rematch_suggestion_diffs(
+ baseline_steps: Sequence[Mapping[str, Any]],
+ rematch_log: Sequence[Mapping[str, Any]],
+) -> List[Dict[str, Any]]:
+ """Vorschläge aus Rematch-Protokoll, wenn End-Pfad vs. Baseline identisch wirkt."""
+ base_by = _steps_by_major_index(baseline_steps)
+ replacements = _last_rematch_replacements_by_slot(rematch_log)
+ diffs: List[Dict[str, Any]] = []
+ for midx, entry in sorted(replacements.items()):
+ base = base_by.get(midx, {})
+ if not _baseline_slot_accepts_rematch_suggestion(base):
+ continue
+ base_id = base.get("exercise_id")
+ new_id = entry.get("new_exercise_id")
+ base_title = (base.get("title") or "").strip() or None
+ new_title = (entry.get("new_title") or "").strip() or None
+ same_id = False
+ if base_id is not None and new_id is not None:
+ try:
+ same_id = int(base_id) == int(new_id)
+ except (TypeError, ValueError):
+ same_id = False
+ if same_id:
+ bt = _normalize_slot_title(base_title)
+ pt = _normalize_slot_title(new_title)
+ if bt and pt and bt == pt:
+ continue
+ diffs.append(
+ {
+ "roadmap_major_step_index": midx,
+ "baseline_exercise_id": int(base_id) if base_id is not None else None,
+ "baseline_title": base_title,
+ "proposed_exercise_id": int(new_id) if new_id is not None else None,
+ "proposed_title": new_title,
+ "baseline_slot_status": base.get("slot_status"),
+ "proposed_slot_status": "matched",
+ "changed": True,
+ "from_rematch_log": True,
+ }
+ )
+ return diffs
+
+
+def _overlay_rematch_suggestions_on_steps(
+ proposed_steps: Sequence[Mapping[str, Any]],
+ suggestion_diffs: Sequence[Mapping[str, Any]],
+) -> List[Dict[str, Any]]:
+ """Ergänzt proposed_steps um Rematch-Kandidaten (für selektive Übernahme)."""
+ if not suggestion_diffs:
+ return list(proposed_steps or [])
+ prop_by = _steps_by_major_index(proposed_steps)
+ for diff in suggestion_diffs:
+ if not isinstance(diff, dict) or not diff.get("from_rematch_log"):
+ continue
+ midx = diff.get("roadmap_major_step_index")
+ new_id = diff.get("proposed_exercise_id")
+ if midx is None or new_id is None:
+ continue
+ existing = dict(prop_by.get(int(midx), {}))
+ existing.update(
+ {
+ "exercise_id": int(new_id),
+ "title": diff.get("proposed_title") or existing.get("title"),
+ "variant_id": existing.get("variant_id"),
+ "roadmap_major_step_index": int(midx),
+ "is_ai_proposal": False,
+ "slot_status": "matched",
+ "roadmap_match_source": "rematch_suggestion",
+ }
+ )
+ prop_by[int(midx)] = existing
+ ordered: List[Dict[str, Any]] = []
+ for midx in sorted(prop_by.keys()):
+ ordered.append(prop_by[midx])
+ return ordered
+
+
+def _build_progression_slot_diffs(
+ baseline_steps: Sequence[Mapping[str, Any]],
+ proposed_steps: Sequence[Mapping[str, Any]],
+) -> List[Dict[str, Any]]:
+ """Gegenüberstellung je Roadmap-Slot — nur geänderte oder neu leere Slots."""
+ base_by = _steps_by_major_index(baseline_steps)
+ prop_by = _steps_by_major_index(proposed_steps)
+ diffs: List[Dict[str, Any]] = []
+ for midx in sorted(set(base_by.keys()) | set(prop_by.keys())):
+ base = base_by.get(midx, {})
+ prop = prop_by.get(midx, {})
+ base_id = base.get("exercise_id")
+ prop_id = prop.get("exercise_id")
+ base_title = (base.get("title") or "").strip() or None
+ prop_title = (prop.get("title") or "").strip() or None
+ if base_id is not None and prop_id is not None and int(base_id) == int(prop_id):
+ continue
+ diffs.append(
+ {
+ "roadmap_major_step_index": midx,
+ "baseline_exercise_id": int(base_id) if base_id is not None else None,
+ "baseline_title": base_title,
+ "proposed_exercise_id": int(prop_id) if prop_id is not None else None,
+ "proposed_title": prop_title,
+ "baseline_slot_status": base.get("slot_status"),
+ "proposed_slot_status": prop.get("slot_status"),
+ "changed": base_id != prop_id or base_title != prop_title,
+ }
+ )
+ return diffs
+
+
+def _evaluate_steps_for_compare_qa(
+ cur,
+ *,
+ tenant: TenantContext,
+ body: ProgressionPathSuggestRequest,
+ steps: Sequence[Mapping[str, Any]],
+) -> Optional[Dict[str, Any]]:
+ """Evaluate-only auf konkretem Schritt-Stand (gleiche Pipeline wie Graph bewerten)."""
+ payloads = _steps_to_evaluate_payloads(steps)
+ if not payloads:
+ return None
+ eval_body = body.model_copy(
+ update={
+ "evaluate_only": True,
+ "evaluate_steps": payloads,
+ "compare_with_assignments": False,
+ "preserve_slot_assignments": False,
+ "include_llm_intent": False,
+ "auto_rematch_after_qa": False,
+ "include_roadmap_preview": False,
+ }
+ )
+ return suggest_progression_path(cur, tenant=tenant, body=eval_body)
+
+
+def _quick_evaluate_steps_qa(
+ cur,
+ *,
+ goal_query: str,
+ semantic_brief: PlanningSemanticBrief,
+ steps: Sequence[Mapping[str, Any]],
+ roadmap_ctx: Optional[ProgressionRoadmapContext],
+) -> Dict[str, Any]:
+ """Schnelle Pfad-QS ohne rekursiven API-Lauf — für Slot-Vergleiche."""
+ roadmap_first = roadmap_ctx is not None
+ steps_list = list(steps or [])
+ gaps = detect_path_gaps(
+ cur,
+ steps_list,
+ brief=semantic_brief,
+ roadmap_first=roadmap_first,
+ )
+ off_topic_steps = detect_off_topic_steps(
+ cur,
+ steps_list,
+ brief=semantic_brief,
+ goal_query=goal_query,
+ )
+ multistage_qa = run_multistage_path_qa(
+ off_topic_steps=off_topic_steps,
+ stripped_off_topic=[],
+ gaps=gaps,
+ llm_qa=None,
+ llm_applied=False,
+ )
+ path_qa = build_path_qa_summary(
+ gaps=gaps,
+ bridge_inserts=[],
+ ai_proposals=[],
+ gap_fill_offers=[],
+ off_topic_steps=off_topic_steps,
+ stripped_off_topic=[],
+ llm_qa=None,
+ llm_applied=False,
+ roadmap_qa_mode="roadmap_first_lite" if roadmap_first else None,
+ multistage_qa=multistage_qa,
+ )
+ if path_qa.get("quality_score") is None:
+ path_qa["quality_score"] = compute_deterministic_path_quality_score(
+ gaps=gaps,
+ off_topic_steps=off_topic_steps,
+ steps=steps_list,
+ multistage_qa=multistage_qa,
+ )
+ return path_qa
+
+
+def _off_topic_slot_indices(path_qa: Optional[Mapping[str, Any]]) -> Set[int]:
+ return set(_off_topic_reasons_by_slot((path_qa or {}).get("off_topic_steps") or []).keys())
+
+
+def _resolve_hint_major_index(
+ hint: Mapping[str, Any],
+ stage_specs: Sequence[StageSpecArtifact],
+) -> Optional[int]:
+ raw = hint.get("roadmap_major_step_index")
+ if raw is not None:
+ try:
+ return int(raw)
+ except (TypeError, ValueError):
+ return None
+ step_index = hint.get("step_index")
+ if step_index is None:
+ return None
+ try:
+ pos = int(step_index)
+ except (TypeError, ValueError):
+ return None
+ if 0 <= pos < len(stage_specs):
+ return int(stage_specs[pos].major_step_index)
+ return pos if pos >= 0 else None
+
+
+def _parse_slot_refs_from_text(text: str) -> Set[int]:
+ """„Schritt 8“ / „Slot 8“ / „Stufe 8“ → 0-basierter major_step_index (7)."""
+ found: Set[int] = set()
+ if not text:
+ return found
+ for match in re.finditer(r"(?:schritt|slot|stufe)\s*(\d+)", text.lower()):
+ try:
+ n = int(match.group(1))
+ except (TypeError, ValueError):
+ continue
+ if n >= 1:
+ found.add(n - 1)
+ return found
+
+
+def _problematic_slots_from_path_qa(
+ baseline_qa: Optional[Mapping[str, Any]],
+ baseline_steps: Sequence[Mapping[str, Any]],
+ stage_specs: Sequence[StageSpecArtifact],
+) -> Dict[int, List[str]]:
+ """Schachstellen aus derselben QS wie „Graph bewerten“ — Basis für Match-Vorschläge."""
+ problems: Dict[int, List[str]] = {}
+
+ def _add(midx: int, reason: str) -> None:
+ text = (reason or "").strip()
+ if not text:
+ return
+ bucket = problems.setdefault(int(midx), [])
+ if text not in bucket:
+ bucket.append(text[:400])
+
+ for midx, reasons in _off_topic_reasons_by_slot(
+ (baseline_qa or {}).get("off_topic_steps") or [],
+ ).items():
+ for reason in reasons:
+ _add(midx, reason)
+
+ for hint in (baseline_qa or {}).get("optimization_hints") or []:
+ if not isinstance(hint, dict):
+ continue
+ action = str(hint.get("action") or "").strip().lower()
+ if action == "review_roadmap":
+ continue
+ midx = _resolve_hint_major_index(hint, stage_specs)
+ if midx is None:
+ title = str(hint.get("title") or "")
+ for ref in _parse_slot_refs_from_text(
+ " ".join(
+ str(hint.get(k) or "")
+ for k in ("reason", "issue", "title", "roadmap_learning_goal")
+ )
+ ):
+ midx = ref
+ break
+ if title:
+ for step in baseline_steps or []:
+ if not isinstance(step, dict):
+ continue
+ st = str(step.get("title") or "").strip()
+ smidx = step.get("roadmap_major_step_index")
+ if st and title.lower() in st.lower() and smidx is not None:
+ midx = int(smidx)
+ break
+ if midx is None:
+ continue
+ _add(
+ int(midx),
+ str(
+ hint.get("reason")
+ or hint.get("issue")
+ or hint.get("title")
+ or action
+ ),
+ )
+
+ llm_text_parts: List[str] = []
+ for key in ("topic_coverage",):
+ raw = (baseline_qa or {}).get(key)
+ if raw:
+ llm_text_parts.append(str(raw))
+ for key in ("issues", "recommendations", "sequence_notes"):
+ for raw in (baseline_qa or {}).get(key) or []:
+ llm_text_parts.append(str(raw or ""))
+ combined = "\n".join(llm_text_parts)
+ for midx in _parse_slot_refs_from_text(combined):
+ _add(midx, "In Pfad-Bewertung als Schachstelle genannt")
+
+ for raw in (baseline_qa or {}).get("issues") or []:
+ text = str(raw or "").strip()
+ if not text:
+ continue
+ for step in baseline_steps or []:
+ if not isinstance(step, dict):
+ continue
+ midx = step.get("roadmap_major_step_index")
+ if midx is None:
+ continue
+ try:
+ slot_no = int(midx) + 1
+ except (TypeError, ValueError):
+ continue
+ title = str(step.get("title") or "").strip()
+ if (
+ f"schritt {slot_no}" in text.lower()
+ or f"slot {slot_no}" in text.lower()
+ or f"stufe {slot_no}" in text.lower()
+ or (title and title.lower() in text.lower())
+ ):
+ _add(int(midx), text)
+
+ for step in baseline_steps or []:
+ if not isinstance(step, dict):
+ continue
+ midx = step.get("roadmap_major_step_index")
+ if midx is None:
+ continue
+ try:
+ major_idx = int(midx)
+ except (TypeError, ValueError):
+ continue
+ if step.get("exercise_id") is None and not step.get("is_ai_proposal"):
+ _add(major_idx, "Leerer Slot ohne Bibliotheks-Übung")
+
+ return problems
+
+
+def _slot_suggestion_accepted(
+ *,
+ baseline_qa: Optional[Mapping[str, Any]],
+ projected_qa: Optional[Mapping[str, Any]],
+ baseline_score: Optional[float],
+ projected_score: Optional[float],
+ diff: Mapping[str, Any],
+ off_topic: bool,
+ major_idx: int,
+ slot_problem: bool = False,
+ stage_specs: Optional[Sequence[StageSpecArtifact]] = None,
+ baseline_steps: Optional[Sequence[Mapping[str, Any]]] = None,
+ projected_steps: Optional[Sequence[Mapping[str, Any]]] = None,
+) -> bool:
+ """Entscheidet, ob ein Slot-Vorschlag in die Liste kommt."""
+ base_id = diff.get("baseline_exercise_id")
+ prop_id = diff.get("proposed_exercise_id")
+ base_off = _off_topic_slot_indices(baseline_qa)
+ proj_off = _off_topic_slot_indices(projected_qa)
+ delta = _quality_delta(baseline_score, projected_score)
+
+ if prop_id is not None and base_id is not None and int(base_id) == int(prop_id):
+ return False
+
+ if slot_problem and prop_id is not None:
+ if major_idx in base_off and major_idx not in proj_off:
+ return True
+ if delta is not None and delta >= -0.001:
+ return True
+ if stage_specs is not None:
+ proj_problems = _problematic_slots_from_path_qa(
+ projected_qa,
+ projected_steps or baseline_steps or [],
+ stage_specs,
+ )
+ if major_idx not in proj_problems:
+ return True
+ return True
+
+ if off_topic and base_id is not None:
+ if major_idx in base_off and major_idx not in proj_off:
+ return True
+ if prop_id is not None:
+ return _slot_diff_improves_path(diff, delta, off_topic=True)
+
+ if base_id is None and prop_id is not None:
+ return _slot_diff_improves_path(diff, delta, off_topic=False)
+
+ if base_id is not None and prop_id is not None:
+ return _slot_diff_improves_path(diff, delta, off_topic=False)
+
+ if base_id is None and prop_id is None and diff.get("proposed_is_ai_proposal"):
+ return _slot_diff_improves_path(
+ diff,
+ delta,
+ off_topic=off_topic or major_idx in base_off or slot_problem,
+ )
+ return False
+
+
+def _quality_delta(
+ baseline_score: Optional[float],
+ projected_score: Optional[float],
+) -> Optional[float]:
+ if baseline_score is None or projected_score is None:
+ return None
+ return round(float(projected_score) - float(baseline_score), 4)
+
+
+def _apply_slot_diff_to_steps(
+ baseline_steps: Sequence[Mapping[str, Any]],
+ diff: Mapping[str, Any],
+ proposed_steps: Sequence[Mapping[str, Any]],
+) -> List[Dict[str, Any]]:
+ """Einzeländerung auf Baseline-Pfad legen (für faire QS pro Vorschlag)."""
+ base_by = _steps_by_major_index(baseline_steps)
+ prop_by = _steps_by_major_index(proposed_steps)
+ try:
+ midx = int(diff.get("roadmap_major_step_index"))
+ except (TypeError, ValueError):
+ return [dict(s) for s in baseline_steps or []]
+ out_by: Dict[int, Dict[str, Any]] = {i: dict(s) for i, s in base_by.items()}
+ prop_step = prop_by.get(midx)
+ if isinstance(prop_step, dict):
+ merged = dict(out_by.get(midx, {}))
+ merged.update(prop_step)
+ merged["roadmap_major_step_index"] = midx
+ out_by[midx] = merged
+ elif diff.get("proposed_exercise_id") is not None:
+ merged = dict(out_by.get(midx, {}))
+ merged["exercise_id"] = int(diff["proposed_exercise_id"])
+ if diff.get("proposed_title"):
+ merged["title"] = diff.get("proposed_title")
+ merged["roadmap_major_step_index"] = midx
+ merged["slot_status"] = diff.get("proposed_slot_status") or "matched"
+ out_by[midx] = merged
+ elif diff.get("baseline_exercise_id") is not None and diff.get("proposed_exercise_id") is None:
+ merged = dict(out_by.get(midx, {}))
+ merged["exercise_id"] = None
+ merged["roadmap_major_step_index"] = midx
+ out_by[midx] = merged
+ return [out_by[i] for i in sorted(out_by.keys())]
+
+
+def _slot_diff_improves_path(
+ diff: Mapping[str, Any],
+ quality_delta: Optional[float],
+ *,
+ off_topic: bool = False,
+) -> bool:
+ """Nur Vorschläge mit messbarer Pfad-Verbesserung (Lücken/off-topic: neutral oder besser)."""
+ if quality_delta is None:
+ return False
+ try:
+ delta = float(quality_delta)
+ except (TypeError, ValueError):
+ return False
+ base_id = diff.get("baseline_exercise_id")
+ prop_id = diff.get("proposed_exercise_id")
+ if off_topic and base_id is not None:
+ return delta >= -0.001
+ if base_id is None and prop_id is not None:
+ return delta >= -0.001
+ if base_id is not None and prop_id is not None:
+ return delta > 0.005
+ return False
+
+
+def _score_incremental_slot_diffs(
+ cur,
+ *,
+ tenant: TenantContext,
+ body: ProgressionPathSuggestRequest,
+ baseline_steps: Sequence[Mapping[str, Any]],
+ proposed_steps: Sequence[Mapping[str, Any]],
+ baseline_path_qa: Optional[Mapping[str, Any]],
+ raw_diffs: Sequence[Mapping[str, Any]],
+) -> Dict[str, Any]:
+ """Bewertet jeden Slot-Diff isoliert gegen die Baseline-QS — filtert Verschlechterungen."""
+ baseline_score = _path_qa_quality_score(baseline_path_qa)
+ if baseline_score is None and baseline_steps:
+ baseline_eval = _evaluate_steps_for_compare_qa(
+ cur,
+ tenant=tenant,
+ body=body,
+ steps=baseline_steps,
+ )
+ if isinstance(baseline_eval, dict):
+ baseline_score = _path_qa_quality_score(baseline_eval.get("path_qa"))
+
+ annotated = _annotate_slot_diffs(list(raw_diffs or []))
+ candidates = _actionable_slot_diffs(annotated)
+ # Lücken zuerst, dann Ersetzungen — harte Obergrenze gegen Timeouts
+ candidates.sort(
+ key=lambda d: (
+ 0 if d.get("baseline_exercise_id") is None else 1,
+ int(d.get("roadmap_major_step_index") or 0),
+ )
+ )
+ candidates = candidates[:10]
+
+ scored: List[Dict[str, Any]] = []
+ improving: List[Dict[str, Any]] = []
+ rejected: List[Dict[str, Any]] = []
+
+ for diff in candidates:
+ merged_steps = _apply_slot_diff_to_steps(baseline_steps, diff, proposed_steps)
+ eval_res = _evaluate_steps_for_compare_qa(
+ cur,
+ tenant=tenant,
+ body=body,
+ steps=merged_steps,
+ )
+ projected_qa = (
+ eval_res.get("path_qa")
+ if isinstance(eval_res, dict) and isinstance(eval_res.get("path_qa"), dict)
+ else None
+ )
+ projected_score = _path_qa_quality_score(projected_qa)
+ delta: Optional[float] = None
+ if baseline_score is not None and projected_score is not None:
+ delta = round(projected_score - baseline_score, 4)
+ entry = {
+ **diff,
+ "projected_path_qa": projected_qa,
+ "projected_quality_score": projected_score,
+ "baseline_quality_score": baseline_score,
+ "quality_delta": delta,
+ "improves_path": _slot_diff_improves_path(diff, delta),
+ }
+ scored.append(entry)
+ if entry["improves_path"]:
+ improving.append(entry)
+ else:
+ rejected.append(entry)
+
+ return {
+ "baseline_quality_score": baseline_score,
+ "scored_diffs": scored,
+ "improvement_diffs": improving,
+ "rejected_diffs": rejected,
+ "improvement_count": len(improving),
+ "rejected_count": len(rejected),
+ }
+
+
+def _off_topic_reasons_by_slot(
+ off_topic_steps: Sequence[Mapping[str, Any]],
+) -> Dict[int, List[str]]:
+ out: Dict[int, List[str]] = {}
+ for item in off_topic_steps or []:
+ if not isinstance(item, dict):
+ continue
+ midx = item.get("roadmap_major_step_index")
+ if midx is None:
+ continue
+ try:
+ key = int(midx)
+ except (TypeError, ValueError):
+ continue
+ issue = str(item.get("issue") or "off_topic")
+ reasons = item.get("reasons") or [issue]
+ for raw in reasons:
+ text = str(raw or "").strip()
+ if text and text not in out.setdefault(key, []):
+ out[key].append(text[:400])
+ return out
+
+
+def _slot_issues_from_path_qa(
+ path_qa: Optional[Mapping[str, Any]],
+ major_idx: int,
+) -> List[str]:
+ texts: List[str] = []
+ if not isinstance(path_qa, dict):
+ return texts
+ for key in ("issues", "recommendations"):
+ for raw in path_qa.get(key) or []:
+ text = str(raw or "").strip()
+ if not text:
+ continue
+ if f"slot {major_idx + 1}" in text.lower() or f"stufe {major_idx + 1}" in text.lower():
+ if text not in texts:
+ texts.append(text[:400])
+ for hint in path_qa.get("optimization_hints") or []:
+ if not isinstance(hint, dict):
+ continue
+ hint_idx = hint.get("roadmap_major_step_index")
+ if hint_idx is None:
+ continue
+ try:
+ if int(hint_idx) != int(major_idx):
+ continue
+ except (TypeError, ValueError):
+ continue
+ text = str(hint.get("reason") or hint.get("issue") or "").strip()
+ if text and text not in texts:
+ texts.append(text[:400])
+ return texts
+
+
+def _build_slot_pro_contra(
+ *,
+ current_step: Mapping[str, Any],
+ proposed_step: Optional[Mapping[str, Any]],
+ suggestion_type: str,
+ baseline_qa: Optional[Mapping[str, Any]],
+ projected_qa: Optional[Mapping[str, Any]],
+ quality_delta: Optional[float],
+ off_topic_reasons: Sequence[str],
+ candidate_reasons: Sequence[str],
+ gap_offer: Optional[Mapping[str, Any]] = None,
+) -> Dict[str, Any]:
+ current_pro: List[str] = []
+ current_contra: List[str] = list(off_topic_reasons or [])[:4]
+ proposed_pro: List[str] = [str(r) for r in (candidate_reasons or []) if str(r or "").strip()][:4]
+ proposed_contra: List[str] = []
+
+ if current_step.get("exercise_id") is not None and not current_contra:
+ current_pro.append("Bestehende Zuordnung im Graph")
+ if current_step.get("is_ai_proposal"):
+ sketch = (current_step.get("title") or "KI-Entwurf").strip()
+ current_pro.append(f"KI-Entwurf: {sketch[:120]}")
+
+ major_idx = current_step.get("roadmap_major_step_index")
+ if major_idx is not None:
+ for text in _slot_issues_from_path_qa(baseline_qa, int(major_idx)):
+ if text not in current_contra:
+ current_contra.append(text)
+
+ if quality_delta is not None and quality_delta > 0:
+ proposed_pro.append(f"Pfad-QS +{round(float(quality_delta) * 100)} Prozentpunkte")
+ elif suggestion_type in {"library_fill", "remove_and_replace", "ai_gap"} and not current_contra:
+ proposed_pro.append("Schließt Lücke bzw. passt besser zur Stufe")
+
+ if isinstance(gap_offer, dict):
+ sketch = str(gap_offer.get("sketch") or gap_offer.get("title_hint") or "").strip()
+ if sketch:
+ proposed_pro.append(f"KI-Entwurf: {sketch[:160]}")
+ rationale = str(gap_offer.get("rationale") or "").strip()
+ if rationale:
+ proposed_pro.append(rationale[:200])
+
+ if isinstance(projected_qa, dict):
+ for text in _slot_issues_from_path_qa(projected_qa, int(major_idx or 0)):
+ if text not in proposed_contra:
+ proposed_contra.append(text)
+
+ if proposed_step and proposed_step.get("exercise_id") is not None and not proposed_pro:
+ proposed_pro.append("Bibliotheks-Treffer für Stufen-Lernziel")
+
+ return {
+ "current_pro": current_pro[:6],
+ "current_contra": current_contra[:6],
+ "proposed_pro": proposed_pro[:6],
+ "proposed_contra": proposed_contra[:6],
+ }
+
+
+def _roadmap_slot_library_candidates(
+ cur,
+ *,
+ tenant: TenantContext,
+ body: ProgressionPathSuggestRequest,
+ goal_query: str,
+ max_steps: int,
+ semantic_brief: PlanningSemanticBrief,
+ path_target_profile: PlanningTargetProfile,
+ path_intent: str,
+ roadmap_ctx: ProgressionRoadmapContext,
+ stage_spec: StageSpecArtifact,
+ step_index: int,
+ stage_count: int,
+ planned_ids: List[int],
+ anchor_id: Optional[int],
+ anchor_variant_id: Optional[int],
+ used: Set[int],
+ exclude_exercise_id: Optional[int] = None,
+ max_candidates: int = 5,
+ skip_post_match_gate: bool = False,
+) -> List[Dict[str, Any]]:
+ """Mehrere Bibliotheks-Kandidaten je Slot (beste zuerst, aktuelle optional ausgeschlossen)."""
+ pick_used = set(used)
+ if exclude_exercise_id is not None:
+ try:
+ pick_used.add(int(exclude_exercise_id))
+ except (TypeError, ValueError):
+ pass
+ candidates: List[Dict[str, Any]] = []
+ seen_ids: Set[int] = set()
+ for _ in range(max(1, max_candidates)):
+ step, _unfilled = _match_roadmap_slot(
+ cur,
+ tenant=tenant,
+ body=body,
+ goal_query=goal_query,
+ max_steps=max_steps,
+ semantic_brief=semantic_brief,
+ path_target_profile=path_target_profile,
+ path_intent=path_intent,
+ roadmap_ctx=roadmap_ctx,
+ stage_spec=stage_spec,
+ step_index=step_index,
+ stage_count=stage_count,
+ planned_ids=planned_ids,
+ anchor_id=anchor_id,
+ anchor_variant_id=anchor_variant_id,
+ used=pick_used,
+ slot_priority_exercise_id=None,
+ skip_post_match_gate=skip_post_match_gate,
+ )
+ if not step or step.get("exercise_id") is None:
+ break
+ try:
+ eid = int(step["exercise_id"])
+ except (TypeError, ValueError):
+ break
+ if eid in seen_ids:
+ break
+ seen_ids.add(eid)
+ candidates.append(step)
+ pick_used.add(eid)
+ return candidates
+
+
+def _suggestion_as_slot_diff(entry: Mapping[str, Any]) -> Dict[str, Any]:
+ return {
+ "roadmap_major_step_index": entry.get("roadmap_major_step_index"),
+ "baseline_exercise_id": entry.get("baseline_exercise_id"),
+ "baseline_title": entry.get("baseline_title"),
+ "proposed_exercise_id": entry.get("proposed_exercise_id"),
+ "proposed_title": entry.get("proposed_title"),
+ "baseline_slot_status": entry.get("baseline_slot_status"),
+ "proposed_slot_status": entry.get("proposed_slot_status"),
+ "changed": True,
+ "suggestion_type": entry.get("suggestion_type"),
+ "quality_delta": entry.get("quality_delta"),
+ "projected_quality_score": entry.get("projected_quality_score"),
+ "baseline_quality_score": entry.get("baseline_quality_score"),
+ "projected_path_qa": entry.get("projected_path_qa"),
+ "pro_contra": entry.get("pro_contra"),
+ "improves_path": entry.get("improves_path"),
+ "off_topic": entry.get("off_topic"),
+ "gap_offer": entry.get("gap_offer"),
+ "proposed_is_ai_proposal": entry.get("proposed_is_ai_proposal"),
+ }
+
+
+_SLOT_FIT_POOR_THRESHOLD = 0.30
+
+
+def _off_topic_semantic_scores_by_slot(
+ off_topic_steps: Sequence[Mapping[str, Any]],
+) -> Dict[int, float]:
+ scores: Dict[int, float] = {}
+ for item in off_topic_steps or []:
+ if not isinstance(item, dict):
+ continue
+ midx = item.get("roadmap_major_step_index")
+ if midx is None:
+ continue
+ try:
+ key = int(midx)
+ raw = item.get("semantic_score")
+ if raw is not None:
+ scores[key] = round(float(raw), 4)
+ except (TypeError, ValueError):
+ continue
+ return scores
+
+
+def _score_exercise_stage_fit_for_spec(
+ cur,
+ *,
+ exercise_id: int,
+ step: Mapping[str, Any],
+ stage_spec: StageSpecArtifact,
+ semantic_brief: PlanningSemanticBrief,
+ step_index: int,
+ stage_count: int,
+) -> Optional[float]:
+ try:
+ eid = int(exercise_id)
+ except (TypeError, ValueError):
+ return None
+ if eid < 1:
+ return None
+ bundle = _load_exercise_text_bundle(cur, eid)
+ stage_goal = (stage_spec.learning_goal or step.get("roadmap_learning_goal") or "").strip()
+ phase = (
+ (step.get("roadmap_phase") or "").strip().lower()
+ or step_phase_for_index(semantic_brief, step_index, stage_count)
+ )
+ stage_anti = list(stage_spec.anti_patterns or step.get("roadmap_anti_patterns") or [])
+ stage_match_brief = (
+ build_stage_match_brief(
+ learning_goal=stage_goal,
+ anti_patterns=stage_anti or None,
+ phase=phase or None,
+ )
+ if stage_goal
+ else None
+ )
+ if not stage_match_brief:
+ return None
+ score, _ = score_exercise_stage_fit(
+ title=bundle["title"],
+ summary=bundle["summary"],
+ goal=bundle["goal"],
+ variant_names=bundle["variant_names"],
+ stage_brief=stage_match_brief,
+ step_phase=phase,
+ )
+ return round(float(score), 4)
+
+
+def _slot_auto_select_library(
+ *,
+ baseline_slot_score: Optional[float],
+ proposed_slot_score: Optional[float],
+ baseline_exercise_id: Optional[int],
+ proposed_exercise_id: Optional[int],
+) -> bool:
+ if proposed_exercise_id is None:
+ return False
+ if baseline_exercise_id is not None and int(baseline_exercise_id) == int(proposed_exercise_id):
+ return False
+ if proposed_slot_score is None:
+ return False
+ if baseline_slot_score is None:
+ return True
+ return float(proposed_slot_score) > float(baseline_slot_score) + 0.001
+
+
+def _build_unified_slot_review_entry(
+ cur,
+ *,
+ tenant: TenantContext,
+ body: ProgressionPathSuggestRequest,
+ goal_query: str,
+ max_steps: int,
+ semantic_brief: PlanningSemanticBrief,
+ path_target_profile: PlanningTargetProfile,
+ path_intent: str,
+ roadmap_ctx: ProgressionRoadmapContext,
+ stage_spec: StageSpecArtifact,
+ step_index: int,
+ stage_count: int,
+ major_idx: int,
+ current: Mapping[str, Any],
+ baseline_steps: Sequence[Mapping[str, Any]],
+ baseline_qa: Mapping[str, Any],
+ baseline_score: Optional[float],
+ steps_by_major: Mapping[int, Mapping[str, Any]],
+ problem_slots: Mapping[int, Sequence[str]],
+ off_topic_map: Mapping[int, Sequence[str]],
+ off_topic_scores: Mapping[int, float],
+ gap_fill_offers: List[Dict[str, Any]],
+ suggestions: List[Dict[str, Any]],
+ rejected: List[Dict[str, Any]],
+) -> Dict[str, Any]:
+ current = dict(current or {})
+ current.setdefault("roadmap_major_step_index", major_idx)
+ current.setdefault("roadmap_learning_goal", stage_spec.learning_goal)
+ current_id = current.get("exercise_id")
+ slot_problem = major_idx in problem_slots
+ off_topic = slot_problem or major_idx in off_topic_map or bool(
+ current.get("slot_status") in {"off_topic", "stripped"}
+ )
+ off_reasons = list(problem_slots.get(major_idx, [])) + list(off_topic_map.get(major_idx, []))
+
+ baseline_slot_score: Optional[float] = off_topic_scores.get(major_idx)
+ if baseline_slot_score is None and current_id is not None and not current.get("is_ai_proposal"):
+ baseline_slot_score = _score_exercise_stage_fit_for_spec(
+ cur,
+ exercise_id=int(current_id),
+ step=current,
+ stage_spec=stage_spec,
+ semantic_brief=semantic_brief,
+ step_index=step_index,
+ stage_count=stage_count,
+ )
+
+ planned_ids = [
+ int(s["exercise_id"])
+ for midx, s in sorted(steps_by_major.items())
+ if midx != major_idx and s.get("exercise_id") is not None
+ ]
+ anchor_id: Optional[int] = None
+ anchor_variant_id: Optional[int] = None
+ used_other: Set[int] = set(planned_ids)
+ for midx in sorted(steps_by_major):
+ if midx >= major_idx:
+ break
+ step = steps_by_major[midx]
+ eid = step.get("exercise_id")
+ if eid is not None:
+ anchor_id = int(eid)
+ vid = step.get("variant_id")
+ anchor_variant_id = int(vid) if vid is not None else None
+
+ candidates = _roadmap_slot_library_candidates(
+ cur,
+ tenant=tenant,
+ body=body,
+ goal_query=goal_query,
+ max_steps=max_steps,
+ semantic_brief=semantic_brief,
+ path_target_profile=path_target_profile,
+ path_intent=path_intent,
+ roadmap_ctx=roadmap_ctx,
+ stage_spec=stage_spec,
+ step_index=step_index,
+ stage_count=stage_count,
+ planned_ids=planned_ids,
+ anchor_id=anchor_id,
+ anchor_variant_id=anchor_variant_id,
+ used=used_other,
+ exclude_exercise_id=int(current_id) if current_id is not None else None,
+ max_candidates=5,
+ skip_post_match_gate=True,
+ )
+
+ best_candidate: Optional[Dict[str, Any]] = None
+ for candidate in candidates:
+ try:
+ cand_id = int(candidate.get("exercise_id"))
+ except (TypeError, ValueError):
+ continue
+ if current_id is not None and int(current_id) == cand_id:
+ continue
+ best_candidate = candidate
+ break
+
+ library_alt: Optional[Dict[str, Any]] = None
+ if best_candidate is not None:
+ try:
+ cand_id = int(best_candidate.get("exercise_id"))
+ except (TypeError, ValueError):
+ cand_id = None
+ if cand_id is not None:
+ proposed_slot_score = _score_exercise_stage_fit_for_spec(
+ cur,
+ exercise_id=cand_id,
+ step={**current, **best_candidate, "roadmap_major_step_index": major_idx},
+ stage_spec=stage_spec,
+ semantic_brief=semantic_brief,
+ step_index=step_index,
+ stage_count=stage_count,
+ )
+ suggestion_type = (
+ "remove_and_replace"
+ if (off_topic or slot_problem) and current_id is not None
+ else ("library_fill" if current_id is None else "library_improvement")
+ )
+ auto_select = _slot_auto_select_library(
+ baseline_slot_score=baseline_slot_score,
+ proposed_slot_score=proposed_slot_score,
+ baseline_exercise_id=int(current_id) if current_id is not None else None,
+ proposed_exercise_id=cand_id,
+ )
+ slot_score_delta = (
+ round(float(proposed_slot_score) - float(baseline_slot_score), 4)
+ if proposed_slot_score is not None and baseline_slot_score is not None
+ else None
+ )
+ pro_contra = _build_slot_pro_contra(
+ current_step=current,
+ proposed_step=best_candidate,
+ suggestion_type=suggestion_type,
+ baseline_qa=baseline_qa,
+ projected_qa=None,
+ quality_delta=None,
+ off_topic_reasons=off_reasons,
+ candidate_reasons=best_candidate.get("reasons") or [],
+ )
+ if slot_score_delta is not None and slot_score_delta > 0:
+ fit_msg = f"Stufen-Fit +{round(slot_score_delta * 100)} Prozentpunkte"
+ if fit_msg not in pro_contra["proposed_pro"]:
+ pro_contra["proposed_pro"].insert(0, fit_msg)
+ library_alt = {
+ "exercise_id": cand_id,
+ "title": (best_candidate.get("title") or "").strip() or None,
+ "slot_score": proposed_slot_score,
+ "slot_score_delta": slot_score_delta,
+ "quality_delta": None,
+ "auto_select": auto_select,
+ "suggestion_type": suggestion_type,
+ "reasons": list(best_candidate.get("reasons") or [])[:4],
+ "pro_contra": pro_contra,
+ }
+ lib_entry = {
+ "roadmap_major_step_index": major_idx,
+ "baseline_exercise_id": int(current_id) if current_id is not None else None,
+ "baseline_title": (current.get("title") or "").strip() or None,
+ "proposed_exercise_id": cand_id,
+ "proposed_title": library_alt["title"],
+ "baseline_slot_status": current.get("slot_status"),
+ "proposed_slot_status": best_candidate.get("slot_status") or "matched",
+ "suggestion_type": suggestion_type,
+ "quality_delta": None,
+ "baseline_slot_score": baseline_slot_score,
+ "proposed_slot_score": proposed_slot_score,
+ "slot_score_delta": slot_score_delta,
+ "auto_select": auto_select,
+ "baseline_quality_score": baseline_score,
+ "improves_path": auto_select,
+ "off_topic": off_topic,
+ "slot_problem": slot_problem,
+ "problem_reasons": off_reasons[:6],
+ "proposed_is_ai_proposal": False,
+ "pro_contra": pro_contra,
+ }
+ if auto_select:
+ suggestions.append(lib_entry)
+ else:
+ rejected.append(lib_entry)
+
+ show_ai_option = bool(
+ body.include_ai_gap_fill
+ and (
+ current_id is None
+ or off_topic
+ or slot_problem
+ or bool(current.get("is_ai_proposal"))
+ or (
+ baseline_slot_score is not None
+ and baseline_slot_score < _SLOT_FIT_POOR_THRESHOLD
+ )
+ )
+ )
+ ai_alt: Optional[Dict[str, Any]] = None
+ if show_ai_option:
+ slot_offer = next(
+ (
+ o
+ for o in gap_fill_offers
+ if isinstance(o, dict) and _gap_offer_major_index(o) == major_idx
+ ),
+ None,
+ )
+ if not slot_offer:
+ gap_spec: Optional[Dict[str, Any]] = None
+ if current_id is None:
+ empty_specs = _build_evaluate_empty_slot_gap_specs(
+ [current],
+ goal_query=goal_query,
+ )
+ gap_spec = empty_specs[0] if empty_specs else None
+ elif off_topic or slot_problem:
+ gap_spec = _build_off_topic_slot_gap_spec(current, goal_query=goal_query)
+ if gap_spec:
+ slot_offer = build_gap_fill_offer(
+ spec=gap_spec,
+ steps=baseline_steps,
+ goal_query=goal_query,
+ brief=semantic_brief,
+ proposal=None,
+ roadmap_snapshot=_roadmap_gap_snapshot_for_spec(
+ cur,
+ roadmap_ctx,
+ gap_spec,
+ goal_query=goal_query,
+ semantic_brief=semantic_brief,
+ ),
+ )
+ gap_fill_offers.append(slot_offer)
+ if slot_offer:
+ ai_alt = {
+ "title_hint": slot_offer.get("title_hint") or f"Slot {major_idx + 1}",
+ "gap_offer": slot_offer,
+ "auto_select": False,
+ }
+
+ return {
+ "roadmap_major_step_index": major_idx,
+ "roadmap_learning_goal": (stage_spec.learning_goal or "").strip() or None,
+ "baseline_exercise_id": int(current_id) if current_id is not None else None,
+ "baseline_title": (current.get("title") or "").strip() or None,
+ "baseline_slot_score": baseline_slot_score,
+ "baseline_slot_status": current.get("slot_status"),
+ "slot_problem": slot_problem,
+ "off_topic": off_topic,
+ "problem_reasons": off_reasons[:6],
+ "library_alternative": library_alt,
+ "ai_alternative": ai_alt,
+ }
+
+
+def _run_unified_slot_improvement_review(
+ cur,
+ *,
+ tenant: TenantContext,
+ body: ProgressionPathSuggestRequest,
+ goal_query: str,
+ max_steps: int,
+ semantic_brief: PlanningSemanticBrief,
+ semantic_llm_applied: bool,
+ path_target_profile: PlanningTargetProfile,
+ path_intent: str,
+ first_intent_summary: Mapping[str, Any],
+ roadmap_ctx: ProgressionRoadmapContext,
+ progression_roadmap: Optional[Dict[str, Any]],
+ roadmap_edited: bool,
+) -> Dict[str, Any]:
+ """
+ Ein Workflow: Pfad bewerten → je Slot Alternativen suchen → Stufen-Fit vergleichen.
+ """
+ if not body.baseline_evaluate_steps:
+ raise HTTPException(
+ status_code=400,
+ detail="unified_slot_review erfordert baseline_evaluate_steps",
+ )
+ if roadmap_ctx is None or not roadmap_ctx.stage_specs:
+ raise HTTPException(
+ status_code=400,
+ detail="unified_slot_review erfordert Roadmap (roadmap_override / roadmap_first)",
+ )
+
+ try:
+ return _run_unified_slot_improvement_review_core(
+ cur,
+ tenant=tenant,
+ body=body,
+ goal_query=goal_query,
+ max_steps=max_steps,
+ semantic_brief=semantic_brief,
+ semantic_llm_applied=semantic_llm_applied,
+ path_target_profile=path_target_profile,
+ path_intent=path_intent,
+ first_intent_summary=first_intent_summary,
+ roadmap_ctx=roadmap_ctx,
+ progression_roadmap=progression_roadmap,
+ roadmap_edited=roadmap_edited,
+ )
+ except HTTPException:
+ raise
+ except Exception as exc:
+ raise HTTPException(
+ status_code=500,
+ detail=f"unified_slot_review fehlgeschlagen: {exc}",
+ ) from exc
+
+
+def _run_unified_slot_improvement_review_core(
+ cur,
+ *,
+ tenant: TenantContext,
+ body: ProgressionPathSuggestRequest,
+ goal_query: str,
+ max_steps: int,
+ semantic_brief: PlanningSemanticBrief,
+ semantic_llm_applied: bool,
+ path_target_profile: PlanningTargetProfile,
+ path_intent: str,
+ first_intent_summary: Mapping[str, Any],
+ roadmap_ctx: ProgressionRoadmapContext,
+ progression_roadmap: Optional[Dict[str, Any]],
+ roadmap_edited: bool,
+) -> Dict[str, Any]:
+ if not body.baseline_evaluate_steps:
+ raise HTTPException(status_code=400, detail="baseline_evaluate_steps fehlt")
+ if not roadmap_ctx.stage_specs:
+ raise HTTPException(status_code=400, detail="roadmap stage_specs fehlt")
+
+ baseline_steps = _evaluate_steps_from_payload(cur, body.baseline_evaluate_steps)
+ snapshot = (
+ dict(body.baseline_path_qa_snapshot)
+ if isinstance(body.baseline_path_qa_snapshot, dict)
+ else None
+ )
+ if snapshot:
+ baseline_qa = snapshot
+ if baseline_qa.get("quality_score") is None:
+ baseline_qa["quality_score"] = compute_deterministic_path_quality_score(
+ gaps=baseline_qa.get("large_gaps") or [],
+ off_topic_steps=baseline_qa.get("off_topic_steps") or [],
+ steps=baseline_steps,
+ multistage_qa=baseline_qa,
+ )
+ baseline_score = (
+ float(body.baseline_quality_score)
+ if body.baseline_quality_score is not None
+ else _path_qa_quality_score(baseline_qa)
+ )
+ gap_fill_offers: List[Dict[str, Any]] = []
+ else:
+ eval_body = body.model_copy(
+ update={
+ "include_llm_path_qa": body.include_llm_path_qa,
+ "include_ai_gap_fill": body.include_ai_gap_fill,
+ "auto_rematch_after_qa": False,
+ }
+ )
+ qa_pack = _run_evaluate_only_path_qa(
+ cur,
+ body=eval_body,
+ goal_query=goal_query,
+ semantic_brief=semantic_brief,
+ steps=list(baseline_steps),
+ roadmap_ctx=roadmap_ctx,
+ )
+ baseline_steps = list(qa_pack.get("steps") or baseline_steps)
+ baseline_qa = qa_pack.get("path_qa") if isinstance(qa_pack.get("path_qa"), dict) else {}
+ if baseline_qa.get("quality_score") is None:
+ baseline_qa = dict(baseline_qa)
+ baseline_qa["quality_score"] = compute_deterministic_path_quality_score(
+ gaps=baseline_qa.get("large_gaps") or [],
+ off_topic_steps=baseline_qa.get("off_topic_steps") or [],
+ steps=baseline_steps,
+ multistage_qa=baseline_qa,
+ )
+ baseline_score = _path_qa_quality_score(baseline_qa)
+ gap_fill_offers = list(qa_pack.get("gap_fill_offers") or [])
+ off_topic_map = _off_topic_reasons_by_slot(baseline_qa.get("off_topic_steps") or [])
+ problem_slots = _problematic_slots_from_path_qa(
+ baseline_qa,
+ baseline_steps,
+ roadmap_ctx.stage_specs,
+ )
+
+ steps_by_major = _steps_by_major_index(baseline_steps)
+ spec_by_major = {int(s.major_step_index): s for s in roadmap_ctx.stage_specs}
+ stage_count = len(roadmap_ctx.stage_specs)
+
+ off_topic_scores = _off_topic_semantic_scores_by_slot(
+ baseline_qa.get("off_topic_steps") or [],
+ )
+ slot_reviews: List[Dict[str, Any]] = []
+ suggestions: List[Dict[str, Any]] = []
+ rejected: List[Dict[str, Any]] = []
+
+ for step_index, stage_spec in enumerate(roadmap_ctx.stage_specs):
+ major_idx = int(stage_spec.major_step_index)
+ try:
+ slot_review = _build_unified_slot_review_entry(
+ cur,
+ tenant=tenant,
+ body=body,
+ goal_query=goal_query,
+ max_steps=max_steps,
+ semantic_brief=semantic_brief,
+ path_target_profile=path_target_profile,
+ path_intent=path_intent,
+ roadmap_ctx=roadmap_ctx,
+ stage_spec=stage_spec,
+ step_index=step_index,
+ stage_count=stage_count,
+ major_idx=major_idx,
+ current=steps_by_major.get(major_idx, {}),
+ baseline_steps=baseline_steps,
+ baseline_qa=baseline_qa,
+ baseline_score=baseline_score,
+ steps_by_major=steps_by_major,
+ problem_slots=problem_slots,
+ off_topic_map=off_topic_map,
+ off_topic_scores=off_topic_scores,
+ gap_fill_offers=gap_fill_offers,
+ suggestions=suggestions,
+ rejected=rejected,
+ )
+ except Exception as exc:
+ slot_review = {
+ "roadmap_major_step_index": major_idx,
+ "roadmap_learning_goal": (stage_spec.learning_goal or "").strip() or None,
+ "baseline_exercise_id": None,
+ "baseline_title": None,
+ "baseline_slot_score": None,
+ "baseline_slot_status": None,
+ "slot_problem": major_idx in problem_slots,
+ "off_topic": major_idx in off_topic_map,
+ "problem_reasons": [f"Slot-Review fehlgeschlagen: {exc}"[:300]],
+ "library_alternative": None,
+ "ai_alternative": None,
+ "review_error": str(exc)[:300],
+ }
+ slot_reviews.append(slot_review)
+
+ improvement_diffs = [_suggestion_as_slot_diff(s) for s in suggestions]
+ problem_slot_payload = {
+ str(k): v for k, v in sorted(problem_slots.items(), key=lambda x: x[0])
+ }
+ slot_diff_scoring = {
+ "baseline_quality_score": baseline_score,
+ "scored_diffs": improvement_diffs + [_suggestion_as_slot_diff(r) for r in rejected],
+ "improvement_diffs": improvement_diffs,
+ "rejected_diffs": [_suggestion_as_slot_diff(r) for r in rejected],
+ "improvement_count": len(improvement_diffs),
+ "rejected_count": len(rejected),
+ }
+
+ try:
+ target_summary = path_target_profile.to_summary_dict(cur)
+ except Exception:
+ target_summary = {}
+
+ return {
+ "goal_query": goal_query,
+ "max_steps_requested": max_steps,
+ "steps": baseline_steps,
+ "step_count": len(baseline_steps),
+ "target_profile_summary": target_summary,
+ "semantic_brief_summary": brief_to_summary_dict(semantic_brief),
+ "semantic_llm_applied": semantic_llm_applied,
+ "query_intent_summary": first_intent_summary,
+ "progression_graph_id": body.progression_graph_id,
+ "path_qa": baseline_qa,
+ "baseline_path_qa": baseline_qa,
+ "baseline_steps": baseline_steps,
+ "gap_fill_offers": gap_fill_offers,
+ "progression_roadmap": progression_roadmap,
+ "roadmap_first": True,
+ "roadmap_only": False,
+ "roadmap_edited": roadmap_edited,
+ "roadmap_unfilled_count": 0,
+ "path_skill_expectations": None,
+ "match_summary": {
+ "unified_slot_review": True,
+ "suggestion_count": len(suggestions),
+ "rejected_count": len(rejected),
+ "problem_slot_count": len(problem_slots),
+ "slot_review_count": len(slot_reviews),
+ },
+ "retrieval_phase": "unified_slot_review",
+ "unified_slot_review": True,
+ "slot_reviews": slot_reviews,
+ "problem_slots": problem_slot_payload,
+ "slot_suggestions": suggestions,
+ "slot_diff_scoring": slot_diff_scoring,
+ "comparison_mode": True,
+ }
+
+
+def _merge_gap_fill_offers_from_steps(
+ steps: Sequence[Mapping[str, Any]],
+ offers: Sequence[Mapping[str, Any]],
+) -> List[Dict[str, Any]]:
+ """Gap-Angebote aus Schritt-gap_offer + Top-Level-Liste vereinigen."""
+ merged: List[Dict[str, Any]] = [dict(o) for o in offers or [] if isinstance(o, dict)]
+ seen = {o.get("offer_id") for o in merged if o.get("offer_id")}
+ for raw in steps or []:
+ if not isinstance(raw, dict):
+ continue
+ go = raw.get("gap_offer")
+ if not isinstance(go, dict):
+ continue
+ oid = go.get("offer_id")
+ if oid and oid in seen:
+ continue
+ if oid:
+ seen.add(oid)
+ merged.append(dict(go))
+ return merged
+
+
+def _build_progression_compare_response(
+ baseline: Mapping[str, Any],
+ proposed: Mapping[str, Any],
+ *,
+ proposed_eval: Optional[Mapping[str, Any]] = None,
+) -> Dict[str, Any]:
+ baseline_steps = list(baseline.get("steps") or [])
+ proposed_steps = list(proposed.get("steps") or [])
+ baseline_qa = baseline.get("path_qa") if isinstance(baseline.get("path_qa"), dict) else {}
+ pipeline_qa = proposed.get("path_qa") if isinstance(proposed.get("path_qa"), dict) else {}
+ fair_qa = (
+ proposed_eval.get("path_qa")
+ if isinstance(proposed_eval, dict) and isinstance(proposed_eval.get("path_qa"), dict)
+ else pipeline_qa
+ )
+ slot_diffs = _annotate_slot_diffs(
+ _build_progression_slot_diffs(baseline_steps, proposed_steps),
+ )
+ actionable_diffs = _actionable_slot_diffs(slot_diffs)
+ apply_steps = list(proposed_steps)
+ gap_fill_offers = _merge_gap_fill_offers_from_steps(
+ apply_steps,
+ proposed.get("gap_fill_offers") or [],
+ )
+ return {
+ **dict(proposed),
+ "comparison_mode": True,
+ "baseline_steps": baseline_steps,
+ "baseline_path_qa": baseline_qa,
+ "proposed_steps": apply_steps,
+ "proposed_steps_pipeline": proposed_steps,
+ "proposed_path_qa": fair_qa,
+ "proposed_path_qa_pipeline": pipeline_qa,
+ "gap_fill_offers": gap_fill_offers,
+ "slot_diffs": slot_diffs,
+ "slot_diffs_actionable": actionable_diffs,
+ "slot_diff_count": len(actionable_diffs),
+ "slot_diff_count_including_trivial": len(slot_diffs),
+ "slot_diffs_source": "steps",
+ "optimization_actionable": len(actionable_diffs) > 0,
+ "baseline_quality_score": _path_qa_quality_score(baseline_qa),
+ "proposed_quality_score": _path_qa_quality_score(fair_qa),
+ "proposed_pipeline_quality_score": _path_qa_quality_score(pipeline_qa),
+ "path_qa": fair_qa,
+ "steps": apply_steps,
+ }
+
+
def suggest_progression_path(
cur,
*,
@@ -2126,6 +3736,45 @@ def suggest_progression_path(
if not _has_planning_role(role):
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Pfad-Vorschläge abrufen")
+ if body.compare_with_assignments:
+ eval_source = list(body.evaluate_steps or body.slot_assignments or [])
+ if len(eval_source) < 1:
+ raise HTTPException(
+ status_code=400,
+ detail="compare_with_assignments erfordert evaluate_steps",
+ )
+ baseline_body = body.model_copy(
+ update={
+ "evaluate_only": True,
+ "evaluate_steps": eval_source,
+ "compare_with_assignments": False,
+ "preserve_slot_assignments": False,
+ # Gleiche QS-Pipeline wie „Graph bewerten“ (kein Match/Rematch-Schönung)
+ "include_llm_intent": False,
+ "include_llm_path_qa": False,
+ "auto_rematch_after_qa": False,
+ "include_roadmap_preview": False,
+ }
+ )
+ baseline = suggest_progression_path(cur, tenant=tenant, body=baseline_body)
+ proposed_body = body.model_copy(
+ update={
+ "compare_with_assignments": False,
+ "preserve_slot_assignments": False,
+ "evaluate_only": False,
+ # Vergleich: deterministische QS + Rematch — kein zusätzlicher Ganzpfad-LLM-Lauf (Timeout)
+ "include_llm_path_qa": False,
+ }
+ )
+ proposed = suggest_progression_path(cur, tenant=tenant, body=proposed_body)
+ result = _build_progression_compare_response(baseline, proposed, proposed_eval=None)
+ if result.get("slot_diff_count", 0) == 0 and isinstance(baseline.get("path_qa"), dict):
+ fair = baseline["path_qa"]
+ result["proposed_path_qa"] = fair
+ result["path_qa"] = fair
+ result["proposed_quality_score"] = _path_qa_quality_score(fair)
+ return result
+
goal_query = _normalize_query(body.query)
if len(goal_query) < 3:
raise HTTPException(status_code=400, detail="Ziel-Anfrage: mindestens 3 Zeichen")
@@ -2311,6 +3960,23 @@ def suggest_progression_path(
path_target_profile = apply_expectations_to_target(path_target_profile, path_exp)
path_skill_expectations = path_exp.to_api_dict()
+ if body.unified_slot_review:
+ return _run_unified_slot_improvement_review(
+ cur,
+ tenant=tenant,
+ body=body,
+ goal_query=goal_query,
+ max_steps=max_steps,
+ semantic_brief=semantic_brief,
+ semantic_llm_applied=semantic_llm_applied,
+ path_target_profile=path_target_profile,
+ path_intent=path_intent,
+ first_intent_summary=first_intent_summary,
+ roadmap_ctx=roadmap_ctx,
+ progression_roadmap=progression_roadmap,
+ roadmap_edited=roadmap_edited,
+ )
+
roadmap_unfilled: List[Tuple[int, StageSpecArtifact]] = []
roadmap_gap_offers: List[Dict[str, Any]] = []
@@ -2424,6 +4090,7 @@ def suggest_progression_path(
reorder_notes: List[str] = []
roadmap_qa_mode: Optional[str] = None
+ preserve_assignments = _assignment_preservation_active(body)
if body.include_path_qa:
if roadmap_first:
roadmap_qa_mode = "roadmap_first_lite"
@@ -2459,7 +4126,9 @@ def suggest_progression_path(
elif gaps and roadmap_first:
unfilled_gaps = list(gaps)
- if body.include_llm_path_qa and not roadmap_first:
+ if body.include_llm_path_qa and (
+ not roadmap_first or preserve_assignments
+ ):
llm_qa, llm_qa_applied = try_llm_qa_progression_path(
cur,
goal_query=goal_query,
@@ -2490,11 +4159,14 @@ def suggest_progression_path(
goal_query=goal_query,
)
off_topic_before_strip = list(off_topic_steps)
- steps, stripped_off_topic = strip_off_topic_steps_from_path(
- steps,
- off_topic_steps,
- min_remaining=0 if roadmap_first else 2,
- )
+ if preserve_assignments:
+ stripped_off_topic = []
+ else:
+ steps, stripped_off_topic = strip_off_topic_steps_from_path(
+ steps,
+ off_topic_steps,
+ min_remaining=0 if roadmap_first else 2,
+ )
if stripped_off_topic:
off_topic_steps = []
gaps = detect_path_gaps(
@@ -2504,7 +4176,7 @@ def suggest_progression_path(
roadmap_first=roadmap_first,
)
- if roadmap_first and roadmap_ctx is not None:
+ if roadmap_first and roadmap_ctx is not None and not preserve_assignments:
(
steps,
rematch_log,
@@ -2538,7 +4210,7 @@ def suggest_progression_path(
roadmap_first=roadmap_first,
)
- if body.include_llm_path_qa and roadmap_first:
+ if body.include_llm_path_qa and roadmap_first and not preserve_assignments:
gaps = detect_path_gaps(
cur,
steps,
@@ -2649,6 +4321,8 @@ def suggest_progression_path(
path_qa["refine_applied"] = True
path_qa["refine_log"] = refine_log
path_qa["refine_count"] = len(refine_log)
+ if preserve_assignments:
+ path_qa["assignments_preserved"] = True
filled_library_steps = sum(1 for s in steps if s.get("exercise_id") is not None)
match_summary = {
@@ -2686,6 +4360,28 @@ def suggest_progression_path(
if refine_log:
retrieval_parts.append("stage_spec_refine")
+ slot_diff_scoring: Optional[Dict[str, Any]] = None
+ if (
+ body.include_incremental_diff_scoring
+ and body.baseline_evaluate_steps
+ and not evaluate_only
+ and not body.compare_with_assignments
+ ):
+ baseline_steps_for_scoring = _evaluate_steps_from_payload(cur, body.baseline_evaluate_steps)
+ raw_diffs = _build_progression_slot_diffs(baseline_steps_for_scoring, steps)
+ baseline_qa_for_scoring: Optional[Dict[str, Any]] = None
+ if body.baseline_quality_score is not None:
+ baseline_qa_for_scoring = {"quality_score": float(body.baseline_quality_score)}
+ slot_diff_scoring = _score_incremental_slot_diffs(
+ cur,
+ tenant=tenant,
+ body=body,
+ baseline_steps=baseline_steps_for_scoring,
+ proposed_steps=steps,
+ baseline_path_qa=baseline_qa_for_scoring,
+ raw_diffs=raw_diffs,
+ )
+
return {
"goal_query": goal_query,
"max_steps_requested": max_steps,
@@ -2706,6 +4402,7 @@ def suggest_progression_path(
"path_skill_expectations": path_skill_expectations,
"match_summary": match_summary,
"retrieval_phase": "+".join(retrieval_parts),
+ "slot_diff_scoring": slot_diff_scoring,
}
diff --git a/backend/planning_exercise_path_qa.py b/backend/planning_exercise_path_qa.py
index 472ec35..48770a1 100644
--- a/backend/planning_exercise_path_qa.py
+++ b/backend/planning_exercise_path_qa.py
@@ -745,12 +745,44 @@ def build_path_qa_summary(
f"Schritt „{o.get('title')}“ passt nicht zum Pfad-Thema"
for o in off_topic
]
+ summary["quality_score"] = compute_deterministic_path_quality_score(
+ gaps=gaps,
+ off_topic_steps=off_topic,
+ steps=steps,
+ multistage_qa=multistage_qa,
+ )
return summary
+def compute_deterministic_path_quality_score(
+ *,
+ gaps: Sequence[Mapping[str, Any]],
+ off_topic_steps: Sequence[Mapping[str, Any]],
+ steps: Optional[Sequence[Mapping[str, Any]]] = None,
+ multistage_qa: Optional[Mapping[str, Any]] = None,
+) -> float:
+ """Heuristische Pfad-QS ohne LLM — Basis für Slot-Vergleiche."""
+ score = 0.92
+ score -= 0.08 * len(off_topic_steps or [])
+ score -= 0.05 * len(gaps or [])
+ if steps:
+ empty = sum(
+ 1
+ for s in steps
+ if isinstance(s, dict)
+ and s.get("exercise_id") is None
+ and not s.get("is_ai_proposal")
+ )
+ score -= 0.06 * empty
+ hint_count = int((multistage_qa or {}).get("optimization_hint_count") or 0)
+ score -= min(0.14, 0.02 * hint_count)
+ return max(0.35, min(0.98, round(score, 4)))
+
+
__all__ = [
"apply_llm_path_reorder",
"build_path_qa_summary",
+ "compute_deterministic_path_quality_score",
"detect_off_topic_steps",
"detect_path_gaps",
"is_roadmap_planned_neighbor_pair",
diff --git a/backend/planning_path_rematch.py b/backend/planning_path_rematch.py
index 200c3c7..4f47dfd 100644
--- a/backend/planning_path_rematch.py
+++ b/backend/planning_path_rematch.py
@@ -8,6 +8,40 @@ from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
from planning_progression_roadmap import ProgressionRoadmapContext, StageSpecArtifact
+def _slot_priority_for_rematch(
+ body,
+ *,
+ major_idx: int,
+ old: Optional[Mapping[str, Any]],
+ rejected_by_major: Optional[Mapping[int, Set[int]]],
+) -> Optional[int]:
+ """Bestehende Slot-Zuordnung beim Rematch bevorzugen — außer explizit abgelehnt."""
+ priority_id: Optional[int] = None
+ if body is not None:
+ for raw in getattr(body, "slot_assignments", None) or []:
+ midx = getattr(raw, "roadmap_major_step_index", None)
+ if midx is None or int(midx) != int(major_idx):
+ continue
+ eid = getattr(raw, "exercise_id", None)
+ if eid is not None:
+ try:
+ priority_id = int(eid)
+ except (TypeError, ValueError):
+ priority_id = None
+ break
+ if priority_id is None and old and old.get("exercise_id") is not None:
+ try:
+ priority_id = int(old["exercise_id"])
+ except (TypeError, ValueError):
+ priority_id = None
+ if priority_id is None or priority_id < 1:
+ return None
+ rejected = rejected_by_major.get(int(major_idx), set()) if rejected_by_major else set()
+ if priority_id in rejected:
+ return None
+ return priority_id
+
+
def collect_rematch_slot_indices(
*,
stripped_off_topic: Sequence[Mapping[str, Any]],
@@ -80,6 +114,43 @@ def collect_rematch_slot_indices(
return indices, reasons
+def filter_rematch_slot_indices(
+ steps: Sequence[Mapping[str, Any]],
+ slot_indices: Set[int],
+ *,
+ stripped_off_topic: Sequence[Mapping[str, Any]],
+ off_topic_steps: Sequence[Mapping[str, Any]],
+) -> Set[int]:
+ """Trainer-Zuordnungen (slot_best_match) nicht rematchen, außer Slot ist explizit beanstandet."""
+ flagged: Set[int] = set()
+ for item in list(stripped_off_topic or []) + list(off_topic_steps or []):
+ if not isinstance(item, dict):
+ continue
+ midx = item.get("roadmap_major_step_index")
+ if midx is not None:
+ try:
+ flagged.add(int(midx))
+ except (TypeError, ValueError):
+ pass
+
+ preserved: Set[int] = set()
+ for raw in steps or []:
+ if not isinstance(raw, dict):
+ continue
+ midx = raw.get("roadmap_major_step_index")
+ if midx is None:
+ continue
+ try:
+ major_idx = int(midx)
+ except (TypeError, ValueError):
+ continue
+ if raw.get("roadmap_match_source") == "slot_best_match" or raw.get("slot_status") == "preserved":
+ if major_idx not in flagged:
+ preserved.add(major_idx)
+
+ return {idx for idx in slot_indices if idx not in preserved}
+
+
def _context_before_major(
steps_by_major: Mapping[int, Mapping[str, Any]],
target_major: int,
@@ -178,6 +249,12 @@ def rematch_roadmap_slots(
anchor_id=anchor_id,
anchor_variant_id=anchor_variant_id,
used=used,
+ slot_priority_exercise_id=_slot_priority_for_rematch(
+ body,
+ major_idx=major_idx,
+ old=old,
+ rejected_by_major=rejected_by_major,
+ ),
)
reason = str(rematch_reasons.get(int(major_idx)) or "rematch_slot")
@@ -186,12 +263,10 @@ def rematch_roadmap_slots(
new_eid = int(new_step.get("exercise_id") or 0)
except (TypeError, ValueError):
new_eid = 0
- hist = (
- slot_assignment_history.get(int(major_idx), set())
- if slot_assignment_history
- else set()
+ rejected = (
+ rejected_by_major.get(int(major_idx), set()) if rejected_by_major else set()
)
- if new_eid > 0 and new_eid in hist:
+ if new_eid > 0 and new_eid in rejected:
new_step = None
if new_step:
steps_by_major[int(major_idx)] = new_step
@@ -207,6 +282,26 @@ def rematch_roadmap_slots(
}
)
else:
+ if old and old.get("exercise_id") is not None:
+ try:
+ old_eid = int(old["exercise_id"])
+ except (TypeError, ValueError):
+ old_eid = 0
+ rejected = (
+ rejected_by_major.get(int(major_idx), set()) if rejected_by_major else set()
+ )
+ if old_eid > 0 and old_eid not in rejected:
+ steps_by_major[int(major_idx)] = dict(old)
+ rematch_log.append(
+ {
+ "roadmap_major_step_index": int(major_idx),
+ "action": "restored",
+ "reason": reason,
+ "restored_exercise_id": old_eid,
+ "restored_title": old.get("title"),
+ }
+ )
+ continue
goal = (stage_spec.learning_goal or "").strip()
major = None
if roadmap_ctx.roadmap:
@@ -278,6 +373,7 @@ def prune_stripped_after_rematch(
__all__ = [
"collect_rematch_slot_indices",
+ "filter_rematch_slot_indices",
"prune_stripped_after_rematch",
"rematch_roadmap_slots",
]
diff --git a/backend/progression_graph_planning_artifact.py b/backend/progression_graph_planning_artifact.py
index 3cffdaf..296446a 100644
--- a/backend/progression_graph_planning_artifact.py
+++ b/backend/progression_graph_planning_artifact.py
@@ -37,6 +37,7 @@ class GraphPlanningRoadmapArtifact(BaseModel):
path_skill_expectations: Optional[Dict[str, Any]] = None
slot_contents: Optional[List[SlotContentEntry]] = None
last_findings: Optional[Dict[str, Any]] = None
+ findings_stale: bool = Field(default=False)
planning_catalog_context: Optional[Dict[str, Any]] = None
@field_validator("progression_roadmap", "path_skill_expectations", "last_findings", "planning_catalog_context", mode="before")
diff --git a/backend/tests/test_planning_assignment_preservation.py b/backend/tests/test_planning_assignment_preservation.py
new file mode 100644
index 0000000..9b93741
--- /dev/null
+++ b/backend/tests/test_planning_assignment_preservation.py
@@ -0,0 +1,31 @@
+"""Tests Trainer-Pfad-Schutz (preserve_slot_assignments)."""
+from planning_exercise_path_builder import (
+ EvaluateStepPayload,
+ ProgressionPathSuggestRequest,
+ _assignment_preservation_active,
+)
+
+
+def test_assignment_preservation_explicit_flag():
+ body = ProgressionPathSuggestRequest(
+ query="Kumite Beinarbeit Progression",
+ preserve_slot_assignments=True,
+ )
+ assert _assignment_preservation_active(body)
+
+
+def test_assignment_preservation_not_auto_from_slot_assignments():
+ """Nur explizites preserve_slot_assignments — sonst wäre compare/match blockiert."""
+ body = ProgressionPathSuggestRequest(
+ query="Kumite Beinarbeit Progression",
+ slot_assignments=[
+ EvaluateStepPayload(exercise_id=10, roadmap_major_step_index=0),
+ EvaluateStepPayload(exercise_id=11, roadmap_major_step_index=1),
+ ],
+ )
+ assert not _assignment_preservation_active(body)
+
+
+def test_assignment_preservation_inactive_without_assignments():
+ body = ProgressionPathSuggestRequest(query="Kumite Beinarbeit Progression")
+ assert not _assignment_preservation_active(body)
diff --git a/backend/tests/test_planning_catalog_context.py b/backend/tests/test_planning_catalog_context.py
index f525be6..21d6330 100644
--- a/backend/tests/test_planning_catalog_context.py
+++ b/backend/tests/test_planning_catalog_context.py
@@ -45,3 +45,34 @@ def test_normalize_planning_roadmap_with_catalog_context():
}
)
assert out["planning_catalog_context"]["focus_areas"][0]["id"] == 4
+
+
+def test_multistage_qa_splits_llm_highlights_from_fix_hints():
+ from planning_path_qa_pipeline import run_multistage_path_qa
+
+ result = run_multistage_path_qa(
+ off_topic_steps=[],
+ stripped_off_topic=[
+ {
+ "issue": "roadmap_unfilled",
+ "step_index": 1,
+ "reasons": ["Keine passende Übung"],
+ }
+ ],
+ gaps=[],
+ llm_qa={
+ "overall_ok": True,
+ "quality_score": 0.88,
+ "recommendations": [
+ "Gute didaktische Progression",
+ "Optional: Vertiefung Koordination",
+ ],
+ },
+ llm_applied=True,
+ )
+ hints = result["optimization_hints"]
+ llm_hints = [h for h in hints if h.get("issue") == "llm_recommendation"]
+ fix_hints = [h for h in hints if h.get("issue") != "llm_recommendation"]
+ assert len(llm_hints) >= 2
+ assert any(h.get("issue") == "roadmap_unfilled" for h in fix_hints)
+ assert result["qa_tiers"][2]["recommendations"][0].startswith("Gute didaktische")
diff --git a/backend/tests/test_planning_compare_slot_diffs.py b/backend/tests/test_planning_compare_slot_diffs.py
new file mode 100644
index 0000000..45ed7c0
--- /dev/null
+++ b/backend/tests/test_planning_compare_slot_diffs.py
@@ -0,0 +1,127 @@
+"""Tests Vergleichs-Diffs (triviale ID-Tausche markieren, Rematch-Filter)."""
+from planning_exercise_path_builder import (
+ _actionable_slot_diffs,
+ _annotate_slot_diffs,
+ _build_progression_compare_response,
+ _build_progression_slot_diffs,
+ _build_rematch_suggestion_diffs,
+)
+
+
+def test_annotate_trivial_id_swap():
+ diffs = [
+ {
+ "roadmap_major_step_index": 1,
+ "baseline_exercise_id": 10,
+ "baseline_title": "Rhythmuswechsel in der Kumite-Beinarbeit",
+ "proposed_exercise_id": 99,
+ "proposed_title": "Rhythmuswechsel in der Kumite-Beinarbeit",
+ }
+ ]
+ annotated = _annotate_slot_diffs(diffs)
+ assert len(annotated) == 1
+ assert annotated[0]["trivial_id_swap"] is True
+ assert _actionable_slot_diffs(annotated) == []
+
+
+def test_annotate_keeps_real_title_change():
+ diffs = [
+ {
+ "roadmap_major_step_index": 1,
+ "baseline_exercise_id": 10,
+ "baseline_title": "Alt",
+ "proposed_exercise_id": 99,
+ "proposed_title": "Neu",
+ }
+ ]
+ annotated = _annotate_slot_diffs(diffs)
+ assert annotated[0]["trivial_id_swap"] is False
+ assert len(_actionable_slot_diffs(annotated)) == 1
+
+
+def test_build_slot_diffs_then_annotate():
+ baseline = [
+ {"roadmap_major_step_index": 0, "exercise_id": 1, "title": "A"},
+ {"roadmap_major_step_index": 1, "exercise_id": 10, "title": "Gleich"},
+ ]
+ proposed = [
+ {"roadmap_major_step_index": 0, "exercise_id": 1, "title": "A"},
+ {"roadmap_major_step_index": 1, "exercise_id": 77, "title": "Gleich"},
+ ]
+ raw = _build_progression_slot_diffs(baseline, proposed)
+ annotated = _annotate_slot_diffs(raw)
+ assert len(annotated) == 1
+ assert annotated[0]["trivial_id_swap"] is True
+ assert _actionable_slot_diffs(annotated) == []
+
+
+def test_rematch_suggestion_skips_filled_baseline_slot():
+ baseline = [
+ {
+ "roadmap_major_step_index": 1,
+ "exercise_id": 5727,
+ "title": "Einführung von Richtungswechseln",
+ "slot_status": "preserved",
+ },
+ ]
+ rematch_log = [
+ {
+ "roadmap_major_step_index": 1,
+ "action": "replaced",
+ "round": 3,
+ "new_exercise_id": 5594,
+ "new_title": "Kumite Beinarbeit — vertiefung",
+ "replaced_exercise_id": 5727,
+ },
+ ]
+ assert _build_rematch_suggestion_diffs(baseline, rematch_log) == []
+
+
+def test_rematch_suggestion_keeps_empty_baseline_slot():
+ baseline = [
+ {"roadmap_major_step_index": 1, "exercise_id": None, "title": "Lernziel Slot 2"},
+ ]
+ rematch_log = [
+ {
+ "roadmap_major_step_index": 1,
+ "action": "replaced",
+ "round": 1,
+ "new_exercise_id": 101,
+ "new_title": "Rhythmuswechsel in der Kumite-Beinarbeit",
+ },
+ ]
+ diffs = _build_rematch_suggestion_diffs(baseline, rematch_log)
+ assert len(diffs) == 1
+ assert diffs[0]["proposed_exercise_id"] == 101
+
+
+def test_compare_response_no_step_diffs_uses_baseline_qa_not_pipeline():
+ baseline = {
+ "steps": [{"roadmap_major_step_index": 0, "exercise_id": 1, "title": "A"}],
+ "path_qa": {"overall_ok": True, "quality_score": 0.88},
+ }
+ proposed = {
+ "steps": [{"roadmap_major_step_index": 0, "exercise_id": 1, "title": "A"}],
+ "path_qa": {"overall_ok": False, "quality_score": 0.65, "rematch_log": [{"action": "replaced"}]},
+ }
+ compare = _build_progression_compare_response(baseline, proposed, proposed_eval=None)
+ assert compare["slot_diff_count"] == 0
+ assert compare["slot_diffs_source"] == "steps"
+ assert compare["proposed_path_qa"]["quality_score"] == 0.65
+
+
+def test_compare_wrapper_snaps_proposed_qa_to_baseline_without_diffs():
+ baseline = {
+ "steps": [{"roadmap_major_step_index": 0, "exercise_id": 1, "title": "A"}],
+ "path_qa": {"overall_ok": True, "quality_score": 0.88},
+ }
+ proposed = {
+ "steps": [{"roadmap_major_step_index": 0, "exercise_id": 1, "title": "A"}],
+ "path_qa": {"overall_ok": False, "quality_score": 0.65},
+ }
+ raw = _build_progression_compare_response(baseline, proposed, proposed_eval=None)
+ assert raw["proposed_path_qa"]["quality_score"] == 0.65
+ if raw.get("slot_diff_count", 0) == 0:
+ fair = baseline["path_qa"]
+ raw["proposed_path_qa"] = fair
+ assert raw["proposed_path_qa"]["quality_score"] == 0.88
diff --git a/backend/tests/test_planning_deterministic_quality_score.py b/backend/tests/test_planning_deterministic_quality_score.py
new file mode 100644
index 0000000..f5a0975
--- /dev/null
+++ b/backend/tests/test_planning_deterministic_quality_score.py
@@ -0,0 +1,21 @@
+"""Deterministische Pfad-QS ohne LLM."""
+from planning_exercise_path_qa import compute_deterministic_path_quality_score
+
+
+def test_deterministic_quality_score_penalizes_off_topic():
+ base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[])
+ with_off = compute_deterministic_path_quality_score(
+ gaps=[],
+ off_topic_steps=[{"roadmap_major_step_index": 1}],
+ )
+ assert with_off < base
+
+
+def test_deterministic_quality_score_penalizes_empty_slots():
+ base = compute_deterministic_path_quality_score(gaps=[], off_topic_steps=[], steps=[])
+ with_empty = compute_deterministic_path_quality_score(
+ gaps=[],
+ off_topic_steps=[],
+ steps=[{"exercise_id": None}, {"exercise_id": 1}],
+ )
+ assert with_empty < base
diff --git a/backend/tests/test_planning_incremental_diff_scoring.py b/backend/tests/test_planning_incremental_diff_scoring.py
new file mode 100644
index 0000000..7e5eb2d
--- /dev/null
+++ b/backend/tests/test_planning_incremental_diff_scoring.py
@@ -0,0 +1,39 @@
+"""Tests inkrementelles Slot-Diff-Scoring (nur messbare Verbesserungen)."""
+from planning_exercise_path_builder import (
+ _apply_slot_diff_to_steps,
+ _slot_diff_improves_path,
+)
+
+
+def test_slot_diff_improves_path_fill_neutral_or_positive():
+ fill = {"baseline_exercise_id": None, "proposed_exercise_id": 101}
+ assert _slot_diff_improves_path(fill, 0.0) is True
+ assert _slot_diff_improves_path(fill, 0.04) is True
+ assert _slot_diff_improves_path(fill, -0.01) is False
+
+
+def test_slot_diff_improves_path_off_topic_allows_neutral_replace():
+ repl = {"baseline_exercise_id": 10, "proposed_exercise_id": 99}
+ assert _slot_diff_improves_path(repl, 0.0, off_topic=True) is True
+ assert _slot_diff_improves_path(repl, -0.02, off_topic=True) is False
+
+
+def test_apply_slot_diff_merges_proposed_step():
+ baseline = [
+ {"roadmap_major_step_index": 0, "exercise_id": 1, "title": "A"},
+ {"roadmap_major_step_index": 1, "exercise_id": None, "title": "Leer"},
+ ]
+ proposed = [
+ {"roadmap_major_step_index": 0, "exercise_id": 1, "title": "A"},
+ {"roadmap_major_step_index": 1, "exercise_id": 55, "title": "Neu", "slot_status": "matched"},
+ ]
+ diff = {
+ "roadmap_major_step_index": 1,
+ "baseline_exercise_id": None,
+ "proposed_exercise_id": 55,
+ "proposed_title": "Neu",
+ }
+ merged = _apply_slot_diff_to_steps(baseline, diff, proposed)
+ assert merged[0]["exercise_id"] == 1
+ assert merged[1]["exercise_id"] == 55
+ assert merged[1]["title"] == "Neu"
diff --git a/backend/tests/test_planning_path_rematch.py b/backend/tests/test_planning_path_rematch.py
index 9337c9e..2ee19f5 100644
--- a/backend/tests/test_planning_path_rematch.py
+++ b/backend/tests/test_planning_path_rematch.py
@@ -214,6 +214,7 @@ def test_rematch_unfilled_leaves_placeholder_step():
slot_indices={1},
rematch_reasons={1: "stage_mismatch"},
match_slot_fn=_no_match,
+ rejected_by_major={1: {99}},
)
assert len(ordered) == 2
@@ -235,3 +236,103 @@ def test_prune_filled_from_roadmap_unfilled():
unfilled_steps = [{"exercise_id": None, "roadmap_major_step_index": 5}]
kept2 = _prune_filled_from_roadmap_unfilled(unfilled_steps, [(4, spec)])
assert len(kept2) == 1
+
+
+def test_rematch_keeps_same_exercise_when_not_rejected():
+ """Regression: slot_assignment_history blockierte gültige Wiederzuordnung → leere Slots."""
+ specs = _stage_specs()
+ ctx = ProgressionRoadmapContext(
+ goal_query="Mae Geri",
+ max_steps=3,
+ stage_specs=specs,
+ )
+ steps = [
+ {"exercise_id": 10, "title": "OK", "roadmap_major_step_index": 0},
+ {"exercise_id": 42, "title": "Gut", "roadmap_major_step_index": 1},
+ ]
+
+ def _same_match(cur, *, stage_spec, slot_priority_exercise_id=None, **kwargs):
+ assert slot_priority_exercise_id == 42
+ return (
+ {"exercise_id": 42, "title": "Gut", "roadmap_major_step_index": stage_spec.major_step_index},
+ None,
+ )
+
+ ordered, log, unfilled = rematch_roadmap_slots(
+ None,
+ tenant=None,
+ body=None,
+ goal_query="Mae Geri",
+ max_steps=3,
+ semantic_brief=None,
+ path_target_profile=None,
+ path_intent="",
+ roadmap_ctx=ctx,
+ steps=steps,
+ slot_indices={1},
+ rematch_reasons={1: "refine_stage_spec"},
+ match_slot_fn=_same_match,
+ rejected_by_major={},
+ )
+
+ assert ordered[1]["exercise_id"] == 42
+ assert log[0]["action"] == "replaced"
+ assert not unfilled
+
+
+def test_rematch_restores_when_match_fails_and_not_rejected():
+ specs = _stage_specs()
+ ctx = ProgressionRoadmapContext(
+ goal_query="Mae Geri",
+ max_steps=3,
+ stage_specs=specs,
+ )
+ steps = [
+ {"exercise_id": 10, "title": "OK", "roadmap_major_step_index": 0},
+ {"exercise_id": 42, "title": "Gut", "roadmap_major_step_index": 1},
+ ]
+
+ def _no_match(cur, *, stage_spec, **kwargs):
+ return None, stage_spec
+
+ ordered, log, unfilled = rematch_roadmap_slots(
+ None,
+ tenant=None,
+ body=None,
+ goal_query="Mae Geri",
+ max_steps=3,
+ semantic_brief=None,
+ path_target_profile=None,
+ path_intent="",
+ roadmap_ctx=ctx,
+ steps=steps,
+ slot_indices={1},
+ rematch_reasons={1: "refine_stage_spec"},
+ match_slot_fn=_no_match,
+ rejected_by_major={},
+ )
+
+ assert ordered[1]["exercise_id"] == 42
+ assert log[0]["action"] == "restored"
+ assert not unfilled
+
+
+def test_filter_rematch_skips_preserved_slots():
+ from planning_path_rematch import filter_rematch_slot_indices
+
+ steps = [
+ {
+ "exercise_id": 10,
+ "roadmap_major_step_index": 0,
+ "roadmap_match_source": "slot_best_match",
+ "slot_status": "preserved",
+ },
+ {"exercise_id": 20, "roadmap_major_step_index": 1},
+ ]
+ filtered = filter_rematch_slot_indices(
+ steps,
+ {0, 1},
+ stripped_off_topic=[],
+ off_topic_steps=[],
+ )
+ assert filtered == {1}
diff --git a/backend/tests/test_planning_problematic_slots.py b/backend/tests/test_planning_problematic_slots.py
new file mode 100644
index 0000000..8276d70
--- /dev/null
+++ b/backend/tests/test_planning_problematic_slots.py
@@ -0,0 +1,130 @@
+"""Schachstellen-Erkennung für unified Slot-Review."""
+from planning_exercise_path_builder import (
+ _parse_slot_refs_from_text,
+ _problematic_slots_from_path_qa,
+ _slot_auto_select_library,
+ _slot_suggestion_accepted,
+)
+from planning_progression_roadmap import StageSpecArtifact
+
+
+def _spec(midx: int) -> StageSpecArtifact:
+ return StageSpecArtifact(
+ major_step_index=midx,
+ learning_goal=f"Lernziel Slot {midx + 1}",
+ load_profile=[],
+ exercise_type="",
+ success_criteria=[],
+ anti_patterns=[],
+ )
+
+
+def test_problematic_slots_from_optimization_hints():
+ qa = {
+ "optimization_hints": [
+ {
+ "action": "rematch_slot",
+ "step_index": 1,
+ "issue": "stage_mismatch",
+ "reason": "Übung passt nicht zur Stufe",
+ }
+ ],
+ "off_topic_steps": [],
+ }
+ steps = [
+ {"roadmap_major_step_index": 0, "exercise_id": 1, "title": "A"},
+ {"roadmap_major_step_index": 1, "exercise_id": 2, "title": "B"},
+ ]
+ specs = [_spec(0), _spec(1)]
+ problems = _problematic_slots_from_path_qa(qa, steps, specs)
+ assert 1 in problems
+ assert any("Stufe" in r or "passt" in r for r in problems[1])
+
+
+def test_slot_suggestion_accepted_for_problem_slot():
+ diff = {"baseline_exercise_id": 10, "proposed_exercise_id": 99}
+ assert _slot_suggestion_accepted(
+ baseline_qa={"optimization_hints": [{"action": "rematch_slot", "roadmap_major_step_index": 1}]},
+ projected_qa={"optimization_hints": []},
+ baseline_score=0.7,
+ projected_score=0.7,
+ diff=diff,
+ off_topic=False,
+ major_idx=1,
+ slot_problem=True,
+ )
+
+
+def test_parse_slot_refs_schritt_is_one_based():
+ assert _parse_slot_refs_from_text("Schritt 8 (Ukemi Vorwärts) entfernen") == {7}
+ assert _parse_slot_refs_from_text("slot 3 und Stufe 5") == {2, 4}
+
+
+def test_problematic_slots_from_refine_stage_spec_hint():
+ qa = {
+ "optimization_hints": [
+ {
+ "action": "refine_stage_spec",
+ "step_index": 7,
+ "issue": "stage_mismatch",
+ "reason": "Stufen-Fit zu schwach (0.00) für „Integration von Täuschung“",
+ }
+ ],
+ "off_topic_steps": [],
+ }
+ steps = [
+ {"roadmap_major_step_index": i, "exercise_id": i + 1, "title": f"Übung {i + 1}"}
+ for i in range(8)
+ ]
+ steps[7]["title"] = "Ukemi Vorwärts"
+ specs = [_spec(i) for i in range(8)]
+ problems = _problematic_slots_from_path_qa(qa, steps, specs)
+ assert 7 in problems
+
+
+def test_problematic_slots_from_llm_schritt_text():
+ qa = {
+ "optimization_hints": [],
+ "off_topic_steps": [],
+ "issues": [
+ "Schritt 8 (Ukemi Vorwärts) hat keinen Bezug zur Kumite-Beinarbeit",
+ ],
+ }
+ steps = [
+ {"roadmap_major_step_index": 7, "exercise_id": 99, "title": "Ukemi Vorwärts"},
+ ]
+ specs = [_spec(7)]
+ problems = _problematic_slots_from_path_qa(qa, steps, specs)
+ assert 7 in problems
+
+
+def test_slot_auto_select_requires_higher_score():
+ assert _slot_auto_select_library(
+ baseline_slot_score=0.5,
+ proposed_slot_score=0.51,
+ baseline_exercise_id=1,
+ proposed_exercise_id=2,
+ )
+ assert not _slot_auto_select_library(
+ baseline_slot_score=0.5,
+ proposed_slot_score=0.5,
+ baseline_exercise_id=1,
+ proposed_exercise_id=2,
+ )
+
+
+def test_off_topic_slot_gap_spec_for_filled_slot():
+ from planning_exercise_path_builder import _build_off_topic_slot_gap_spec
+
+ spec = _build_off_topic_slot_gap_spec(
+ {
+ "roadmap_major_step_index": 7,
+ "exercise_id": 99,
+ "title": "Ukemi Vorwärts",
+ "roadmap_learning_goal": "Integration Täuschung",
+ }
+ )
+ assert spec is not None
+ assert spec["source"] == "off_topic"
+ assert spec["roadmap_major_step_index"] == 7
+ assert "Ukemi" in spec["rationale"]
diff --git a/backend/tests/test_progression_graph_planning_artifact.py b/backend/tests/test_progression_graph_planning_artifact.py
index 1ae1932..4898070 100644
--- a/backend/tests/test_progression_graph_planning_artifact.py
+++ b/backend/tests/test_progression_graph_planning_artifact.py
@@ -57,3 +57,15 @@ def test_normalize_slot_contents():
)
assert len(out["slot_contents"]) == 2
assert out["slot_contents"][1]["primary"]["kind"] == "proposal"
+
+
+def test_normalize_planning_roadmap_with_findings_stale():
+ out = normalize_planning_roadmap_payload(
+ {
+ "goal_query": "Mae Geri",
+ "last_findings": {"overall_ok": False},
+ "findings_stale": True,
+ }
+ )
+ assert out["findings_stale"] is True
+ assert out["last_findings"]["overall_ok"] is False
diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md
index 06e5daf..a9d5820 100644
--- a/docs/HANDOVER.md
+++ b/docs/HANDOVER.md
@@ -1,7 +1,7 @@
# Shinkan Jinkendo – Entwicklungsstand & Handover
-**Stand:** 2026-06-07
-**App-Version / DB-Schema:** App **`0.8.208`** (Planungs-KI Phase D); DB **`20260606086`** — maßgeblich **`backend/version.py`**.
+**Stand:** 2026-05-22
+**App-Version / DB-Schema:** App **`0.8.233`** (Planungs-KI F11–F14, Katalog-Kontext); DB siehe **`backend/version.py`** (`DB_SCHEMA_VERSION`, Migration **088**).
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
@@ -37,6 +37,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
| Überblick DB | `.claude/docs/technical/DATABASE_SCHEMA.md` |
| Domäne | `.claude/docs/functional/DOMAIN_MODEL.md` |
| **Gewichtetes Fähigkeiten-Scoring (Phase 3)** | `.claude/docs/technical/SKILL_SCORING_SPEC.md` |
+| **Planungs-KI — Katalog-Prompt-Snippets (H1)** | **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`** |
| **Lieferliste inkl. Medien & Formular-UX** | `.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md` §6, §16 |
| **Fachlicher Nutzerüberblick (Design/Product)** | **`docs/FACHLICHE_NUTZERFUNKTIONEN.md`** |
@@ -89,7 +90,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
- **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`)
- **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions
-### 2.8 KI Assistenz Übungen & Planungs-KI (Stand **0.8.217**)
+### 2.8 KI Assistenz Übungen & Planungs-KI (Stand **0.8.233**)
**Zentrale Ist-Doku (Progressionsgraph-KI):** **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`** — bei Drift zuerst dort pflegen.
@@ -108,20 +109,32 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
| **F7** | `planning_skill_expectations` — Retrieval + UI + Gap | ✅ **0.8.215–216** |
| **F8** | Editierbare `stage_specs` (Belastung, Erfolgskriterien) | ✅ **0.8.216** |
| **F9** | `planning_roadmap` JSONB am Graph (Migration **088**) | ✅ **0.8.217** |
+| **F10** | Stufen-Lernziel-Gate, kein blindes Rank-Fallback | ✅ **0.8.218** |
+| **F11** | Auto-Rematch + Stufen-Spec-Refine + mehrstufige QS | ✅ **0.8.225–0.8.230** |
+| **F12** | Post-Match-Gate, LLM-QA nach Rematch, Gap-Timing, `roadmap_unfilled`-Sync | ✅ **0.8.231–0.8.232** |
+| **F13** | **`planning_catalog_context`** (Fokus/Stil/TT/ZG) im Match + Graph-Artefakt | ✅ **0.8.233** |
+| **F14** | **`ProgressionGraphEditor`** — Slot-UI + Planungskontext-Dropdowns | ✅ **0.8.233** |
+| **H1** | Katalog-Prompt-Snippets (modulare LLM-Anweisungen) | 🔲 Spec **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`** |
-**Architektur (verbindlich):** Progressionsgraph = **Roadmap-first**, **keine Gruppenanalyse**. Bestehender Graph = **leichter Nachfolger-Bias** ab Schritt 2, **kein** automatisches Erweitern ab letztem Knoten (siehe Ist-Doku §5). Trainingsplanung = **eigene Pipeline** (Phase G), wiederverwendet `planning_skill_expectations`.
+**Architektur (verbindlich):** Drei Schichten — (1) **Katalog-Dimensionen** (DB, jetzt im Match verdrahtet; **H1:** zusätzlich Prompt-Snippets), (2) **Technik-Disambiguierung** (Code, nur bei `topic_type=technique`), (3) **Didaktik** (Roadmap + LLM-QS, nicht im Vokabular). Progressionsgraph = **Roadmap-first**, **keine Gruppenanalyse**. Bestehender Graph = **leichter Nachfolger-Bias** ab Schritt 2. Trainingsplanung = **eigene Pipeline** (Phase G) — Wiederverwendung der Bausteine, siehe Ist-Doku §16.
-**Backend-Kern:** `planning_progression_roadmap.py`, `planning_exercise_path_builder.py`, `planning_skill_expectations.py`, `planning_exercise_form_context.py`, `planning_exercise_path_ai_fill.py`, `progression_graph_planning_artifact.py`
+**Validierung (Mae Geri, Härtetest):** Pfad-QS vor Optimierung ~65 % → nach Trainer-Roadmap + KI-Gap-Fill **~88 % OK**. Workbench ist **universell** gedacht; Mae Geri war Referenzfall, kein Sonder-Patch.
-**API:** `POST /api/planning/progression-path-suggest` · `PUT /api/exercise-progression-graphs/:id` (`planning_roadmap`) · `POST …/edges/sequence`
+**Backend-Kern:** `planning_progression_roadmap.py`, `planning_exercise_path_builder.py`, `planning_catalog_context.py`, `planning_path_rematch.py`, `planning_path_refine_stage.py`, `planning_path_qa_pipeline.py`, `planning_skill_expectations.py`, `planning_exercise_form_context.py`, `planning_exercise_path_ai_fill.py`, `progression_graph_planning_artifact.py`
-**Frontend:** `ExerciseProgressionPathBuilder`, `ExerciseGapFillPrepModal`, `planningContextForExerciseAi.js`
+**API:** `POST /api/planning/progression-path-suggest` · `PUT /api/exercise-progression-graphs/:id` (`planning_roadmap`, `planning_catalog_context`) · `POST …/edges/sequence`
+
+**Frontend:** **`ProgressionGraphEditor`** (primäre Workbench), `ExerciseProgressionPathBuilder`, `ExerciseGapFillPrepModal`, `progressionGraphDraft.js`, `planningContextForExerciseAi.js`
**Offen (priorisiert):**
-1. UI-Wizard (Scroll-Monolith → 4 Schritte) — **separater UI-Chat**
-2. Graph-Erweiterungsmodus (Start ab Knoten)
-3. Trainingsplanung Phase G (Gruppenkontext)
-4. Kontext-Anzeige auf allen Pfad-Schritten
+1. Dev-Regression: Gewaltschutz / Breitensport / Kinder (nicht nur Mae Geri)
+2. **PathBuilder-Parität** — gleiche Katalog-Dropdowns wie GraphEditor
+3. QS-UI — positive LLM-Hinweise als Highlights
+4. UI-Wizard (4 Schritte: Ziel → Roadmap → Match → Lücken)
+5. Graph-Erweiterungsmodus (Start ab Knoten)
+6. Phase D′ — Auto KI-Gap-Fill bei persistent leeren Slots
+7. **Trainingsplanung Phase G** — Gruppenkontext-Pack, Scopes `training_section` / `framework_slot` (Ist-Doku §16)
+8. Technik-Katalog konfigurierbar (Backlog)
#### Übungs-KI Formular / Schnellanlage (Stand **0.8.171**)
@@ -256,11 +269,15 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
### Planungs-KI (priorisiert)
-1. **Phase F2:** LLM für Roadmap (Prompts **078**) + `roadmap_first` Retrieval aus `stage_specs`.
-2. **Phase F4:** Roadmap-Review UI (Major Steps editierbar vor Übungs-Match).
-3. **Enrichment:** Skills/Tags pro Technik (Feinauflösung statt nur Geri Waza).
-4. **D — Neu-Anlage:** `planning_context_json` an `POST /api/exercises/ai/suggest`.
-5. **Trainingsplanung G:** Kontext-Pack Gruppe/Historie — eigene Pipeline (`AI_PLANNING_KI_MULTISTAGE_FORECAST`); Mitai Workflow-Engine erst danach.
+1. **H1 Katalog-Prompt-Snippets:** modulare LLM-Anweisungen — **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`** (Priorität: Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung).
+2. **Dev-Regression:** Katalog-Match für Gewaltschutz, Breitensport, Kinder — nicht nur Mae-Geri-Härtetest.
+2. **PathBuilder-Parität:** `planning_catalog_context`-Dropdowns auch in `ExerciseProgressionPathBuilder`.
+3. **QS-UI:** positive LLM-Empfehlungen als Highlights statt nur Optimierungspotenziale.
+4. **UI-Wizard:** 4 Schritte (Ziel & Katalog → Roadmap → Match → Lücken); Backend-Pipeline unverändert.
+5. **Phase D′:** automatisches KI-Gap-Fill bei persistent `roadmap_unfilled`.
+6. **Trainingsplanung G0–G4:** Katalog in Einheits-Editor, Scopes `training_section`/`framework_slot`, Abschnitts-QS, Gruppenkontext-Pack — Details **`PLANNING_PROGRESSION_GRAPH_KI.md`** §16, **`PLANNING_KI_ROADMAP.md`**.
+7. **Technik-Katalog externalisieren** (Backlog): `concept_groups` konfigurierbar statt Code-Tuples.
+8. **Mitai Workflow-Engine** erst nach stabiler Phase G.
### Allgemein
diff --git a/docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md b/docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md
new file mode 100644
index 0000000..61dacdd
--- /dev/null
+++ b/docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md
@@ -0,0 +1,229 @@
+# Planungs-KI — Katalog-Snippets für modulare Prompts
+
+**Stand:** 2026-05-22
+**Status:** Spezifikation (Phase **H1** — Umsetzung offen)
+**Bezüge:** `PLANNING_PROGRESSION_GRAPH_KI.md` §4.4 · `AI_PROMPT_TARGET_ARCHITECTURE.md` §2.4 · `planning_catalog_context.py`
+
+---
+
+## 1. Problem
+
+Seit **F13 (0.8.233)** fließen Primärfokus, Trainingsstil, Zielgruppe und Stilrichtung als `planning_catalog_context` in **Retrieval und Scoring** (`PlanningTargetProfile`).
+
+Die **LLM-Prompts** (Roadmap, Stufen-Spec, Pfad-QS, Gap-Fill) erhalten diese Dimensionen **nicht** als differenzierte Bewertungs- und Planungslogik — höchstens indirekt über Freitext oder JSON-Kataloglisten in Intent-Prompts.
+
+**Folge:** Ein Breitensport-Pfad kann mit Leistungsgruppen-Kriterien bewertet werden; Gewaltschutz wird wie Technik-Curriculum behandelt; QS-Hinweise und Rematch-Vorschläge passen fachlich nicht zum gewählten Kontext.
+
+**Ziel:** Gleiche **Prompt-Basis**, aber **kaskadierte Snippet-Blöcke** pro Katalog-Ausprägung — keine Matrix aus Voll-Prompt-Kopien.
+
+---
+
+## 2. Priorität der Dimensionen (absteigend)
+
+Verbindliche Reihenfolge bei Konflikten und beim Rollout:
+
+| Rang | Dimension | DB-Tabelle | Snippet-Rolle |
+|------|-----------|------------|----------------|
+| **1** | **Primärfokus** | `focus_areas` | Definiert *worüber* geplant und bewertet wird (Technik-Curriculum vs. Gewaltschutz vs. Fitness …). **Dominant.** |
+| **2** | **Trainingsstil** | `training_types` | Definiert *wie* trainiert wird (Methodik, Belastungsaufbau, Wettkampf vs. Breitenansatz im Training). |
+| **3** | **Zielgruppe** | `target_groups` | Definiert *für wen* (Kinder, Breitensport, Leistungsgruppe) — Tempo, Komplexität, Sicherheit. |
+| **4** | **Stilrichtung** | `style_directions` | Vereins-/Stil-Linie (Shotokan, WKF …) — **Nuancen**, kein neuer Planungstyp. |
+
+**Kaskaden-Regel:** Bei widersprüchlichen Snippet-Aussagen gilt: Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung.
+
+---
+
+## 3. Architektur — drei Schichten (Erinnerung)
+
+| Schicht | Heute | Mit H1 |
+|---------|-------|--------|
+| **Retrieval** | Katalog-Gewichte in `merge_catalog_context_into_target` | unverändert |
+| **Technik-Gates** | `planning_exercise_semantics.py`, nur `topic_type=technique` | unverändert |
+| **LLM-Prompts** | kaum Katalog-spezifisch | **`catalog_guidance_block`** pro Aufruf |
+
+Snippets **ersetzen** keine Technik-Disambiguierung und **duplizieren** keine Retrieval-Gewichte — sie steuern **Didaktik, Bewertungsmaßstäbe und Formulierung** für das Modell.
+
+---
+
+## 4. Snippet-Modell
+
+### 4.1 Lookup-Schlüssel
+
+Pro Katalog-Eintrag ein stabiler **`snippet_key`** (nicht nur numerische ID — IDs können sich in Dev/Import unterscheiden):
+
+```
+focus:{slug} z. B. focus:gewaltschutz
+training_type:{slug} z. B. training_type:kumite
+target_group:{slug} z. B. target_group:breitensport
+style:{slug} z. B. style:shotokan
+```
+
+**Primär** aus `slug` der DB-Zeile; Fallback normalisierter `name` (ASCII, lower, `_`).
+
+Mehrfachauswahl im UI: pro Dimension **höchstens ein Snippet** — die Zeile mit `is_primary: true`, sonst erste Zeile, sonst höchste `weight`.
+
+### 4.2 Snippet-Inhalt (Struktur)
+
+Jedes Snippet liefert strukturierte Textbausteine (Deutsch, für LLM):
+
+| Feld | Pflicht | Inhalt |
+|------|---------|--------|
+| `planning_lens` | ja | 2–4 Sätze: Was ist das Planungsziel in dieser Dimension? |
+| `qa_criteria` | ja | Bullet-artige Kriterien für Pfad-QS (was ist „gut“, was ist kein Mangel) |
+| `roadmap_hints` | empfohlen | Stufenlogik, typische Phasen, was vermeiden |
+| `anti_patterns` | optional | Explizite Fehlbewertungen vermeiden (z. B. „keine Wettkampf-Tiefe verlangen“) |
+| `rematch_guard` | optional | Wann **kein** Auto-Rematch sinnvoll (Breitensport: keine Perfektions-Slots erzwingen) |
+
+Phase **H1:** flache Markdown-Strings im Code-Modul.
+Phase **H2 (optional):** Tabelle `planning_catalog_prompt_snippets` oder JSONB an Katalog-Zeilen, Admin-editierbar.
+
+### 4.3 Platzhalter in `ai_prompts`
+
+Neue **gemeinsame** Platzhalter (Mustache), in alle betroffenen Prompts einfügen:
+
+| Platzhalter | Bedeutung |
+|-------------|-----------|
+| `{{catalog_guidance_block}}` | Gerenderter Gesamttext (alle aktiven Snippets, kaskadiert) |
+| `{{catalog_context_json}}` | Kompakte JSON-Zusammenfassung der gewählten IDs/Namen (Audit) |
+| `{{#has_catalog_guidance}}` … `{{/has_catalog_guidance}}` | Block nur wenn mindestens ein Snippet aktiv |
+
+**Optional fein (später):** `{{catalog_focus_snippet}}`, `{{catalog_training_type_snippet}}`, … — Phase H1 nur `_block` + JSON.
+
+### 4.4 Betroffene Prompt-Slugs (Reihenfolge Einbindung)
+
+| Priorität | Slug | Migration | Wirkung |
+|-----------|------|-----------|---------|
+| 1 | `planning_exercise_path_qa` | bestehend | Pfad-QS, `quality_score`, Empfehlungen |
+| 2 | `planning_progression_roadmap` | 078 | Major Steps, Didaktik |
+| 3 | `planning_progression_stage_spec` | 079 | Stufen-Gates, Erfolgskriterien |
+| 4 | `planning_progression_start_target` | 087 | Start/Ziel-Extraktion |
+| 5 | `planning_progression_goal_analysis` | 078 | Zielanalyse |
+| 6 | Gap-Fill / Übungs-KI | 085+ | `planning_context` ergänzen |
+
+Intent-Prompts (`planning_exercise_search_intent`, …) **optional** Phase H1.5 — dort bereits Katalog-JSON.
+
+---
+
+## 5. Builder (Backend)
+
+**Neues Modul:** `backend/planning_catalog_prompt_snippets.py`
+
+```python
+def build_catalog_guidance_for_prompt(
+ cur,
+ catalog: Optional[ProgressionPlanningCatalogContext],
+) -> Dict[str, str]:
+ """
+ Returns:
+ catalog_guidance_block: str
+ catalog_context_json: str
+ has_catalog_guidance: bool
+ snippet_keys: list[str] # Metadaten für Logs/Tests
+ """
+```
+
+**Ablauf:**
+
+1. `catalog` aus Request oder `planning_roadmap.planning_catalog_context` (wie F13).
+2. Pro Dimension aktives Item auflösen → `snippet_key` → Text aus Registry.
+3. Snippets in **Prioritätsreihenfolge** §2 zu `_block` zusammenfügen (Überschriften: „Primärfokus“, „Trainingsstil“, …).
+4. Fehlende Snippets: Dimension **weglassen** (kein Default-Text) — besser kein Snippet als falscher.
+
+**Einbindung:** Orchestratoren rufen Builder auf und mergen in `variables` vor `load_and_render_ai_prompt`:
+
+- `planning_exercise_path_qa.py` → `try_llm_qa_progression_path`
+- `planning_progression_roadmap.py` (Roadmap-/Stage-Pipeline)
+- `planning_exercise_path_builder.py` (catalog an QA/Match durchreichen)
+
+`ProgressionPathSuggestRequest` trägt `planning_catalog_context` bereits — kein neues API-Feld nötig.
+
+---
+
+## 6. Beispiel-Snippets (Review-Entwurf)
+
+### 6.1 Primärfokus — Gewaltschutz (`focus:gewaltschutz`)
+
+**planning_lens:** Planung zielt auf Prävention, Deeskalation, Grenzen und sichere Übungsformen — nicht auf Wettkampf-Perfektion oder Technik-Show.
+
+**qa_criteria:** Gute Pfade bauen Sicherheit, Kommunikation und Alternativen auf; „Lücken“ sind fehlende Deeskalations- oder Rollenspiel-Stufen, nicht fehlende Kick-Varianten.
+
+**anti_patterns:** Nicht nach Kumite-Tiefe, Explosivität oder Wettkampf-Belastung bewerten.
+
+### 6.2 Primärfokus — Technik / Kumite-Beinarbeit (`focus:kumite` o. ä.)
+
+**planning_lens:** Curriculum für eine Technik oder Kumite-Teilaspekt; aufeinander aufbauende Belastung und Anwendungsnähe sind erwünscht.
+
+**qa_criteria:** Kohärente Progression Grundlagen → Anwendung → Vertiefung; Übergänge ohne Sprünge; themenfremde Kraft-/Ausdauer-Inseln abwerten.
+
+### 6.3 Trainingsstil — Breitensport (`training_type:breitensport` o. Name-Match)
+
+**planning_lens:** Partizipation, Verständlichkeit, Freude am Bewegen; weniger maximale Spezialisierung.
+
+**qa_criteria:** Hohe OK-Rate bei moderatem Schwierigkeitsanstieg; „Perfektion“-Stufen nur optional, nicht als Pflicht-Lücke.
+
+**rematch_guard:** Keine leeren Slots erzwingen, nur um eine Leistungs-Perfektionsstufe zu füllen.
+
+### 6.4 Zielgruppe — Leistungsgruppe (`target_group:leistungsgruppe`)
+
+**qa_criteria:** Höhere Anspruchskurven, Belastungs- und Kombinationsprogressionen sind relevant; Lücken in Spezialisierung können echte Hinweise sein.
+
+*(Weitere Snippets iterativ ergänzen — nicht alle Katalog-Zeilen sofort.)*
+
+---
+
+## 7. Rollout-Phasen
+
+### H1 — Minimal viable (Progressionsgraph)
+
+- [ ] Modul `planning_catalog_prompt_snippets.py` + Registry (5–8 Snippets: 2–3 Foki, 2 Trainingsstile, 2 Zielgruppen)
+- [ ] Einbindung in **`planning_exercise_path_qa`** + **`planning_progression_roadmap`** + **`planning_progression_stage_spec`**
+- [ ] Migration Prompt-Templates: Abschnitt `{{#has_catalog_guidance}}…{{/has_catalog_guidance}}`
+- [ ] Tests: gleicher Pfad + unterschiedlicher Katalog → unterschiedlicher `catalog_guidance_block`; Snapshot QA-Variablen
+- [ ] Dev-Regression: Gewaltschutz, Breitensport, Kinder — **Hinweistexte** müssen zum Kontext passen (nicht Mae-Geri-Kriterien)
+
+### H1.5
+
+- [ ] Rematch/Refine: `rematch_guard` aus Snippets respektieren (weniger Ping-Pong bei Breitensport)
+- [ ] Intent-Prompts + Gap-Fill-Kontext
+
+### H2 — Betrieb
+
+- [ ] Snippets in DB, Admin-UI oder Markdown-Import
+- [ ] Versionierung / Audit wie `ai_prompts`
+
+### H3 — Phase G (Trainingsplanung)
+
+- [ ] Gleicher Builder, anderer Orchestrator (Abschnitts-QS, Slot-Suggest)
+
+---
+
+## 8. Tests & Akzeptanz
+
+| Test | Erwartung |
+|------|-----------|
+| `test_catalog_prompt_snippets_priority` | Bei Konflikt gewinnt Fokus-Snippet-Text in `_block`-Reihenfolge |
+| `test_path_qa_variables_include_guidance` | Mit Gewaltschutz-Kontext enthält gerendeter Prompt „Deeskalation“ o. ä., nicht „Kumite-Perfektion“ |
+| `test_path_qa_no_snippet_without_catalog` | Ohne Katalog: `has_catalog_guidance=false`, Prompt unverändert wie heute |
+| Manuell | Mae-Geri-Pfad + Breitensport-Kontext: QS-Highlights ohne Leistungs-Belastungs-Forderungen |
+
+**Nicht Ziel von H1:** Retrieval-Gewichte neu kalibrieren; Technik-Tuples externalisieren (separates Backlog **H** in Roadmap).
+
+---
+
+## 9. Abgrenzung zu anderen Fixes
+
+| Thema | Dokument / Fix |
+|-------|----------------|
+| 88 % vs. 65 % falsche Pipeline | Evaluate-only für Pfad-QS; fairer Compare — Code-Stand 2026-05-22 |
+| Triviale ID-Tausche im Dialog | `_filter_trivial_slot_diffs` |
+| Katalog nur im Retrieval | F13 — bleibt, Snippets ergänzen LLM-Schicht |
+
+Snippets lösen **fachliche Fehlbewertung** — nicht Pipeline-Inkonsistenz allein.
+
+---
+
+## 10. Changelog
+
+| Datum | Änderung |
+|-------|----------|
+| 2026-05-22 | Erstfassung — Priorität Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung; H1–H3 Rollout |
diff --git a/docs/architecture/PLANNING_KI_ROADMAP.md b/docs/architecture/PLANNING_KI_ROADMAP.md
index ee16d23..98b9cfb 100644
--- a/docs/architecture/PLANNING_KI_ROADMAP.md
+++ b/docs/architecture/PLANNING_KI_ROADMAP.md
@@ -1,7 +1,7 @@
# Planungs-KI — Produkt-Roadmap
**Stand:** 2026-05-22
-**App-Version:** **0.8.217** — maßgeblich `backend/version.py`
+**App-Version:** **0.8.233** — maßgeblich `backend/version.py`
Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROADMAP.md`) und gilt **nur für KI-gestützte Trainingsplanungsunterstützung**.
@@ -13,9 +13,10 @@ Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROA
## Strategische Entscheidung (verbindlich)
1. **Progressionsgraph:** Planung **vom Ziel rückwärts** (Roadmap-first), nicht Bibliothek-first.
-2. **Keine Gruppenanalyse** im Graphen — Kontext = Zieltext, Thema, Schrittanzahl, optional Graph-Kanten.
-3. **Trainingsplanung** (Einheit, Rahmen, Abschnitt): eigene Pipeline später, **mit** Gruppenkontext — siehe `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` S0–S4.
-4. **Orchestrierung:** Workflow-**lite** jetzt (`planning_progression_roadmap.py`); Mitai Workflow-Engine **später**, wenn 2–3 Pipelines stabil sind.
+2. **Keine Gruppenanalyse** im Graphen — Kontext = Zieltext, Katalog-Dimensionen, Start/Ziel, Roadmap, optional Graph-Kanten.
+3. **Drei Schichten statt monolithischem Vokabular:** Katalog (DB) · Technik-Disambiguierung (Code, nur bei Technik-Themen) · Didaktik (Roadmap + LLM-QS).
+4. **Trainingsplanung** (Einheit, Rahmen, Abschnitt): eigene Pipeline (Phase G), **mit** Gruppenkontext — siehe `AI_PLANNING_KI_MULTISTAGE_FORECAST.md` S0–S4 und Ist-Doku §16.
+5. **Orchestrierung:** Workflow-**lite** jetzt (`planning_progression_roadmap.py`, `planning_exercise_path_builder.py`); Mitai Workflow-Engine **später**, wenn Phase G stabil ist.
---
@@ -27,84 +28,152 @@ Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROA
| A–C2 | Übungssuche | Voll-Library, Graph, Varianten | ✅ |
| C3 | Progressionsgraph | Pfad-Builder (retrieval-first) | ✅ |
| E–E3 | Progressionsgraph | Semantik, QA, Lücken-Angebote | ✅ |
-| **F0–F4** | Progressionsgraph | Roadmap-Pipeline, LLM, roadmap-first, UI Review | ✅ **0.8.204–209** |
+| **F0–F4** | Progressionsgraph | Roadmap-Pipeline, LLM, roadmap_first, UI Review | ✅ **0.8.204–209** |
| **F5–F9** | Progressionsgraph | Start/Ziel, Gap-Prep, Skill-Expectations, Persistenz | ✅ **0.8.210–217** |
+| **F10** | Progressionsgraph | Stufen-Lernziel-Gate, kein blindes Rank-Fallback | ✅ **0.8.218** |
+| **F11–F12** | Progressionsgraph | Auto-Rematch, Spec-Refine, QS-Pipeline-Timing | ✅ **0.8.225–0.8.232** |
+| **F13–F14** | Progressionsgraph | Katalog-Kontext + GraphEditor-Workbench | ✅ **0.8.233** |
| D | Übungs-Neuanlage | `planning_context` an `suggestExerciseAi` | ✅ **0.8.208** |
-| **UX** | Progressionsgraph | Wizard/Stepper statt Scroll-UI | 🔲 |
-| G | Trainingsplanung | Kontext-Pack Gruppe/Historie, S0–S4 | 🔲 |
-| H | Plattform | Mitai-Workflow-Engine (optional) | 🔲 Backlog |
+| **UX** | Progressionsgraph | Wizard/Stepper; PathBuilder-Parität Katalog | 🔲 |
+| **D′** | Progressionsgraph | Auto KI-Gap-Fill bei persistent leeren Slots | 🔲 Backlog |
+| **G** | Trainingsplanung | Kontext-Pack Gruppe/Historie, G0–G4 | 🔲 |
+| **H** | Plattform | Technik-Katalog konfigurierbar; Mitai-Workflow | 🔲 Backlog |
---
-## Phase F — Progressions-Roadmap (aktiver Fokus)
+## Phase F — Progressions-Roadmap (abgeschlossen bis F14)
-### F0 — Foundation (0.8.204)
+Details und Module: **`PLANNING_PROGRESSION_GRAPH_KI.md`**.
-- [x] Spec `PLANNING_PROGRESSION_ROADMAP_SPEC.md`
-- [x] Modul `planning_progression_roadmap.py` (Pydantic, Pipeline-Skeleton)
-- [x] Migration **078** Prompt-Slugs (Zielanalyse, Roadmap)
-- [x] API: `include_roadmap_preview` auf `progression-path-suggest`
-- [x] Doku: HANDOVER, PLANNING_EXERCISE_SUGGEST_CONTEXT, MULTISTAGE_FORECAST
+### F0–F9 — (Kurz, siehe Ist-Doku)
-### F1 — Deterministische Roadmap
+- [x] F0 Foundation (0.8.204) — Spec, Pipeline-Skeleton, Prompts 078
+- [x] F1 Deterministische Roadmap — Phase A/B/C heuristisch
+- [x] F2 LLM Roadmap (0.8.205) — Prompts 078/079
+- [x] F3 roadmap-first (0.8.206) — Match pro `stage_spec`, `roadmap_unfilled`
+- [x] F4 UI Review (0.8.207) — `roadmap_override`, Major Steps editierbar
+- [x] F5 Start/Ziel (0.8.210–214) — Prompt **087**, Zwei-Schritt-UI
+- [x] F6 Gap-KI-Kontext (0.8.212–214) — `ExerciseGapFillPrepModal`
+- [x] F7 Fähigkeiten-Scoring (0.8.215–216) — `planning_skill_expectations`
+- [x] F8 Stufen-Details UI (0.8.216) — editierbare `stage_specs`
+- [x] F9 Persistenz (0.8.217) — Migration **088** `planning_roadmap` JSONB
-- [x] Phase A aus Semantic Brief
-- [x] Phase B: `micro_objectives` aus `development_arc` + Konsolidierung auf N
-- [x] Phase C: heuristische `stage_specs`
-- [ ] pytest für Konsolidierung
+### F10 — Stufen-Qualität (0.8.218)
-### F2 — LLM Roadmap (0.8.205)
+- [x] Stufen-Lernziel-Gate — kein Rank-Fallback ohne Pass
+- [x] Anti-Pattern-Sanitizer, `stage_mismatch` → leerer Slot + Gap
-- [x] Prompts **078/079** in `ai_prompts` — Code nur Slugs (`PROMPT_SLUG_*`)
-- [x] `include_llm_roadmap` + `load_and_render_ai_prompt` + JSON-Validierung
-- [x] Deterministischer Fallback wenn Prompt/OpenRouter fehlt
-- [ ] Response/UI: genutzte `prompt_slugs` sichtbar machen (Admin-Hinweis)
+### F11 — Auto-Optimierung (0.8.225–0.8.230)
-### F3 — roadmap-first (0.8.206)
+- [x] `planning_path_rematch.py` — Rematch-Schleife für `rematch_slot` / `roadmap_unfilled`
+- [x] `planning_path_refine_stage.py` — Spec-Schärfung aus QS
+- [x] `planning_path_qa_pipeline.py` — mehrstufige QS
-- [x] Retrieval pro `major_step` + `stage_spec` statt iterativem Pfad-Bau
-- [x] Gap-Angebote für unbesetzte Roadmap-Stufen (`roadmap_unfilled`)
-- [x] QA/Lücken an Roadmap gekoppelt (`roadmap_first_lite`: keine Brücken/Reorder zwischen Major Steps)
+### F12 — Pipeline-Timing & Sync (0.8.231–0.8.232)
-### F4 — UI (0.8.207)
+- [x] Post-Match-Gate vor Rematch-Akzeptanz
+- [x] LLM Pfad-QS **nach** Rematch
+- [x] Gap-Offers vor `path_qa`-Summary
+- [x] Frontend: `applyMatchStepsToSlots` sync per `majorStepIndex`
-- [x] Roadmap-Review im `ExerciseProgressionPathBuilder`
-- [x] Major Steps editierbar (Phase, Lernziel, Reihenfolge) vor Übungs-Match
-- [x] API `roadmap_only` + `roadmap_override`
+### F13 — Katalog-Kontext (0.8.233)
-### F5 — Start/Ziel (0.8.210–214)
+- [x] `planning_catalog_context.py` — Fokus, Stil, Trainingsstil, Zielgruppe
+- [x] Merge in `PlanningTargetProfile` + Text-Signale
+- [x] Persistenz im Graph-Artefakt
+- [x] Technik-Gates nur bei `topic_type == "technique"`
-- [x] Strukturierte Felder `start_situation`, `target_state`, `roadmap_notes`
-- [x] Prompt **087** `planning_progression_start_target`
-- [x] Priorität: Trainer > KI > Regex (`resolve_roadmap_structured_input`)
-- [x] Zwei-Schritt-UI: „Start/Ziel analysieren“ / „Roadmap vorschlagen“
+### F14 — GraphEditor Workbench (0.8.233)
-### F6 — Gap-KI-Kontext (0.8.212–214)
+- [x] `ProgressionGraphEditor` — primäre UI für Roadmap + Match + Lücken
+- [x] Vier Planungskontext-Dropdowns im Editor
+- [x] `progressionGraphDraft.js` — Artefakt + API-Payload
-- [x] `ExerciseGapFillPrepModal` vor KI-Call
-- [x] `planning_exercise_form_context.py` — Gap-Snapshot, `context_preview`
-- [x] Migration **085** — `planning_context` in Übungs-Prompts
+### Validierung (Referenz Mae Geri, 2026-05)
-### F7 — Fähigkeiten-Scoring (0.8.215–216)
+| Phase | Pfad-QS | Ergebnis |
+|-------|---------|----------|
+| Vor Roadmap/KI | ~65 % | Lücken, falsche Reihenfolge, Off-Topic |
+| Nach Trainer-Roadmap + KI-Gap-Fill | **~88 % OK** | Vollständige Abdeckung; positive LLM-Hinweise |
-- [x] `planning_skill_expectations.py` (Scopes: `progression_stage`, `progression_path`)
-- [x] Pro-Stufe-Retrieval + `path_skill_expectations` + UI-Tags
-- [x] `expected_skills` in Gap-Fill
+**Fazit:** Workbench + Katalog + Roadmap sind universell; Technik-Hardcoding allein reicht für Didaktik nicht.
-### F8 — Stufen-Details UI (0.8.216)
+---
-- [x] Editierbare `stage_specs` in `roadmap_override` (Belastung, Erfolgskriterien, Vermeiden)
+## UX — UI-Überarbeitung (offen)
-### F9 — Persistenz (0.8.217)
+- [ ] Wizard mit 4 Schritten (Ziel & Katalog → Roadmap → Match → Lücken)
+- [ ] Progressive disclosure — Details in Panels
+- [ ] PathBuilder-Parität: gleiche Katalog-Dropdowns wie GraphEditor
+- [ ] QS-UI: positive LLM-Hinweise als Highlights
+- Briefing: `PLANNING_PROGRESSION_GRAPH_KI.md` §12
-- [x] Migration **088** — `planning_roadmap` JSONB am Graph
-- [x] Laden/Speichern über `GET/PUT` Graph + Sequenz-Endpoint
+---
-### UX — UI-Überarbeitung (offen)
+## Phase D′ — Auto Gap-Fill (Backlog)
-- [ ] Wizard mit 4 Schritten (Ziel → Roadmap → Match → Lücken)
-- [ ] Progressive disclosure — Details in Panels, nicht alles gleichzeitig
-- [ ] Briefing: `PLANNING_PROGRESSION_GRAPH_KI.md` §10
+- [ ] Bei persistent `roadmap_unfilled` automatisch KI-Vorschlag vorbereiten (ohne manuelles Modal)
+- [ ] Governance: Trainer bestätigt vor Persistenz
+
+---
+
+## Phase G — Trainingsplanung (komplexere Domäne)
+
+**Ziel:** Einheiten, Rahmen-Slots, Abschnitte und parallele Streams KI-gestützt planen — **ohne** zweite Retrieval-Welt.
+
+### Wiederverwendung aus Progressionsgraph
+
+| Baustein | Progressionsgraph | Trainingsplanung |
+|----------|-------------------|------------------|
+| `PlanningTargetProfile` | Curriculum-Query + Katalog | Einheit + Abschnitt + Slot + Katalog + Historie |
+| `planning_catalog_context` | Am Graph gespeichert | Pro Einheit / Slot / Voreinstellung |
+| `planning_skill_expectations` | `progression_stage`, `progression_path` | **`training_section`**, **`framework_slot`** |
+| `planning_exercise_retrieval` | Roadmap-Stufen-Match | `suggest_planning_exercises` — **bereits produktiv** |
+| `planning_path_qa_pipeline` | Curriculum-QS | Abschnitts-QS (Kohärenz, Streams) |
+| `planning_exercise_form_context` | Pfad-Lücken | Abschnitts-/Slot-Lücken |
+| Roadmap-Pipeline | Major Steps über Wochen | **Nicht 1:1** — Phasen/Streams + Vorlagen |
+
+### Was Phase G neu braucht
+
+- Gruppen-/Historie-Kontext-Pack (`AI_PLANNING_KI_MULTISTAGE_FORECAST` S0–S4)
+- Abschnitts-Didaktik — Dauer, Parallel-Streams, Coaching
+- Rahmen-Blueprint-Anbindung (`training_framework_programs`, Slot-Blueprints)
+- Eigene Orchestrierung pro Einheit (kein Curriculum über N Wochen)
+
+### Integrations-Reihenfolge G0–G4
+
+| Schritt | Inhalt | Abhängigkeit |
+|---------|--------|--------------|
+| **G0** | Katalog in Einheits-Editor → bestehende Suggest-Pipeline | F13 ✅ |
+| **G1** | Scope `training_section` + Skill-Erwartungen aktiv | F7 ✅ |
+| **G2** | Abschnitts-QS (Hint-Struktur wie Graph) | F11–F12 ✅ |
+| **G3** | Framework-Slot + Gap-Fill | G0, G1 |
+| **G4** | Gruppenkontext-Pack | G0–G3 |
+
+**Nicht:** Roadmap-first-Loop 1:1 auf Trainingseinheit mappen.
+
+Details: **`PLANNING_PROGRESSION_GRAPH_KI.md`** §16 · Domäne **`DOMAIN_MODEL.md`** §1–2.
+
+---
+
+## Phase H1 — Katalog-Prompt-Snippets (Spez geplant)
+
+**Spec:** **`PLANNING_CATALOG_PROMPT_SNIPPETS.md`**
+
+Modulare Textbausteine pro Katalog-Ausprägung in LLM-Prompts (Roadmap, Pfad-QS, Stufen-Spec) — **nicht** neue Retrieval-Welt.
+
+**Priorität (absteigend):** Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung.
+
+- [ ] `planning_catalog_prompt_snippets.py` + Registry (5–8 Snippets)
+- [ ] Platzhalter `{{catalog_guidance_block}}` in Pfad-QS + Roadmap-Prompts
+- [ ] Dev-Regression: Gewaltschutz / Breitensport / Kinder — QS-Hinweise passend zum Kontext
+
+---
+
+## Phase H — Plattform (Backlog)
+
+- [ ] Technik-Disambiguierung konfigurierbar (DB statt `_GERI_TECHNIQUES` in Code)
+- [ ] Mitai Workflow-Engine — erst wenn G0–G4 stabil
---
@@ -112,10 +181,10 @@ Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROA
| Von | Nach | Hinweis |
|-----|------|---------|
-| F2 | Enrichment / Skills | Bessere Roadmap bei technikspezifischen Skills |
-| F3 | F2 | LLM-Roadmap oder stabile heuristische B |
-| G | F4 | Trainingsplanung kann Roadmap aus Graph referenzieren |
-| H | G + F4 | Workflow-Engine lohnt bei verzweigten Planungsflows |
+| F13 | G0, **H1** | Katalog-Kontext in Einheitsplanung; Snippets in LLM-Prompts |
+| F7, F11 | G1, G2 | Skill-Expectations + QS-Muster |
+| F4, F9 | G3 | Graph-Roadmap kann Rahmen referenzieren |
+| G | H | Workflow-Engine lohnt bei verzweigten Planungsflows |
---
diff --git a/docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md b/docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md
index 5b2df2d..647b4e2 100644
--- a/docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md
+++ b/docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md
@@ -1,7 +1,7 @@
# Progressionsgraph — KI-Planung (Ist-Stand)
-**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`)
+**Stand:** 2026-05-22 (Dokumentation) · **App-Version:** **0.8.233** · **DB:** Migration **088**
+**Maßgeblich für Code:** `backend/version.py` (`APP_VERSION`, `DB_SCHEMA_VERSION`, `MODULE_VERSIONS.planning_exercise_suggest`)
> **Diese Datei ist die zentrale Referenz** für die KI-gestützte Planung im Progressionsgraph.
> Ältere Abschnitte in `HANDOVER.md` §2.8 und `PLANNING_KI_ROADMAP.md` verweisen hierher.
@@ -10,6 +10,7 @@
`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md` (Zielarchitektur) ·
`.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` (Retrieval/Scoring) ·
`.claude/docs/technical/SKILL_SCORING_SPEC.md` (Fähigkeiten-Scoring) ·
+**`PLANNING_CATALOG_PROMPT_SNIPPETS.md`** (H1 — modulare Katalog-Prompts) ·
`docs/architecture/PLANNING_KI_ROADMAP.md` (Produkt-Roadmap Phase G+)
---
@@ -30,19 +31,20 @@
## 2. Trainer-Workflow (UI)
-Aktuell in `ExerciseProgressionPathBuilder.jsx` (eingebettet in `ExerciseProgressionGraphPanel.jsx`):
+**Primär:** `ProgressionGraphEditor.jsx` (integrierter Slot-Editor, Phase B).
+**Legacy/Parallel:** `ExerciseProgressionPathBuilder.jsx` (Scroll-Monolith — gleiche API, Katalog-Kontext-Dropdowns dort noch nachziehen).
```
-① Ziel eingeben (+ optional Start/Ziel-Felder manuell)
-② „Start/Ziel analysieren“ (optional, start_target_only)
-③ „Roadmap vorschlagen“ (roadmap_only, LLM-Roadmap)
+① Ziel eingeben (+ Planungskontext: Primärfokus, Stil, Trainingsstil, Zielgruppe)
+② Optional: Start/Ziel-Felder manuell oder „Start/Ziel analysieren“
+③ „Roadmap generieren“ (roadmap_only, LLM-Roadmap)
④ Roadmap bearbeiten (Major Steps + Stufen-Details)
-⑤ „Übungen matchen“ (roadmap_first + roadmap_override)
-⑥ Lücken mit KI schließen (gap_fill_offers + Vorbereitungs-Dialog)
-⑦ „Pfad in Graph speichern“ (Sequenz-Kanten)
+⑤ „Übungen matchen“ (roadmap_first + roadmap_override + Auto-QS/Rematch)
+⑥ Lücken: KI-Angebote → „KI anlegen“ (Gap-Prep-Modal) → in Slot
+⑦ „Graph speichern“ (planning_roadmap + optional Kanten-Sequenz)
```
-**Bekannte UX-Schuld:** Alle Schritte liegen auf **einer langen Scroll-Seite** — Überarbeitung als Wizard/Stepper ist geplant (separater UI-Chat). Briefing-Vorlage siehe unten §10.
+**Bekannte UX-Schuld:** PathBuilder = lange Scroll-Seite; GraphEditor = kompakter, aber noch kein Wizard. Stepper geplant (separater UI-Chat). Briefing §12.
---
@@ -51,6 +53,7 @@ Aktuell in `ExerciseProgressionPathBuilder.jsx` (eingebettet in `ExerciseProgres
```mermaid
flowchart TB
subgraph ui [Frontend]
+ PGE[ProgressionGraphEditor]
EPB[ExerciseProgressionPathBuilder]
GFM[ExerciseGapFillPrepModal]
PCtx[planningContextForExerciseAi.js]
@@ -71,6 +74,10 @@ flowchart TB
subgraph match [Match + QA]
PB[planning_exercise_path_builder.py]
+ PCC[planning_catalog_context.py]
+ REM[planning_path_rematch.py]
+ REF[planning_path_refine_stage.py]
+ QAP[planning_path_qa_pipeline.py]
RET[planning_exercise_retrieval.py]
PG[planning_exercise_progression.py]
SEM[planning_exercise_semantics.py]
@@ -88,11 +95,16 @@ flowchart TB
end
EPB --> PPS
- EPB --> SEQ
- EPB --> PUT
+ PGE --> PPS
+ PGE --> SEQ
+ PGE --> PUT
GFM --> EAI
PPS --> PR
PPS --> PB
+ PB --> PCC
+ PB --> REM
+ PB --> REF
+ PB --> QAP
PB --> RET
PB --> PG
PB --> PSE
@@ -108,12 +120,18 @@ flowchart TB
| Modul | Aufgabe |
|--------|---------|
| `planning_progression_roadmap.py` | Phasen A–C: Zielanalyse, Roadmap, `stage_specs`; Start/Ziel-Auflösung (Trainer > KI > Regex) |
-| `planning_exercise_path_builder.py` | `suggest_progression_path`: roadmap_first Match, QA, Gap-Offers |
+| `planning_exercise_path_builder.py` | `suggest_progression_path`: roadmap_first Match, Auto-QS, Rematch, Gap-Offers |
+| `planning_catalog_context.py` | **Expliziter Katalog-Kontext** (Fokus, Stil, Trainingsstil, Zielgruppe) → `PlanningTargetProfile` |
+| `planning_path_rematch.py` | Auto-Rematch betroffener Slots (`max_rematch_rounds`) |
+| `planning_path_refine_stage.py` | Stufen-Spec-Verfeinerung bei `stage_mismatch` (Phase C) |
+| `planning_path_qa_pipeline.py` | Mehrstufige QS → `optimization_hints` |
| `planning_exercise_progression.py` | Graph auflösen, Nachfolger-Kanten für Retrieval-Bias |
| `planning_skill_expectations.py` | Skill-Erwartungen pro Scope (`progression_stage`, `progression_path`, später `training_section`) |
| `planning_exercise_form_context.py` | `planning_context` / Gap-Snapshot für Übungs-KI |
| `planning_exercise_path_ai_fill.py` | Gap-Fill-Angebote, `goal_for_ai`, `context_preview` |
| `progression_graph_planning_artifact.py` | Validierung `planning_roadmap` JSON (Schema v1, max. 64 KB) |
+| `planning_exercise_profiles.py` | **Katalog-Scoring** (Fokus/Stil/TT/ZG/Skills) — gemeinsam mit Einheitsplanung |
+| `planning_exercise_target_pipeline.py` | Query-Intent-Pipeline — Progressionsgraph nutzt `query_only`-Modus + Katalog-Overlay |
---
@@ -131,10 +149,14 @@ flowchart TB
| `start_target_only` | bool | Nur Start/Ziel-Analyse |
| `roadmap_override` | object | Trainer-bearbeitete `major_steps` + `stage_specs` |
| `start_situation`, `target_state`, `roadmap_notes` | string? | Strukturierte Eingabe (Priorität vor KI) |
+| `planning_catalog_context` | object? | Primärfokus, Stilrichtung, Trainingsstil, Zielgruppe (IDs + `is_primary`) |
| `include_llm_start_target` | bool | LLM-Extraktion Start/Ziel (Prompt **087**) |
| `include_llm_roadmap` | bool | LLM Roadmap (Prompts **078/079**) |
-| `include_llm_intent` | bool | LLM Intent für Semantic Brief (Roadmap-Vorschlag: **true** seit 0.8.217) |
-| `include_path_qa`, `include_ai_gap_fill` | bool | QS, Lücken-Angebote |
+| `include_llm_intent` | bool | LLM Intent für Semantic Brief |
+| `auto_rematch_after_qa` | bool | Auto-Rematch nach QS (Default **true**) |
+| `auto_refine_stage_spec` | bool | Stufen-Spec bei `stage_mismatch` schärfen (Default **true**) |
+| `max_rematch_rounds` | int | Rematch-Runden 0–4 (Default **3**) |
+| `include_path_qa`, `include_llm_path_qa`, `include_ai_gap_fill` | bool | QS, LLM-Ganzpfad, Lücken-Angebote |
### 4.2 Wichtige Response-Felder
@@ -144,7 +166,29 @@ flowchart TB
| `steps[]` | Gematchte Übungen; pro Schritt u. a. `roadmap_*`, `skill_expectations` |
| `path_skill_expectations` | Pfadweite Skill-Erwartungen |
| `gap_fill_offers[]` | Lücken mit `context_preview`, `goal_for_ai` |
-| `path_qa` | QS inkl. `roadmap_qa_mode: roadmap_first_lite` |
+| `path_qa` | QS inkl. `qa_tiers`, `optimization_hints`, `rematch_log`, `refine_log` |
+| `target_profile_summary` | Erwartungsprofil inkl. Katalog-Dimensionen (nach Match) |
+| `match_summary` | `library_matches`, `gap_fill_offer_count`, `roadmap_unfilled_count` |
+
+---
+
+## 4.4 Planungskontext — Katalog vs. Technik-Vokabular
+
+Shinkan unterscheidet **drei Schichten** (kein monolithisches „Vokabular“):
+
+| Schicht | Was | Wo | Beispiel |
+|---------|-----|-----|----------|
+| **Katalog-Dimensionen** | Was für Training? | DB: `focus_areas`, `style_directions`, `training_types`, `target_groups`, `skills` | Gewaltschutz, Breitensport, Shotokan |
+| **Disambiguierung (Technik)** | Verwechslungs-Nachbarn | Code: `planning_exercise_semantics.py` (`_GERI_TECHNIQUES`, …) | Mae Geri ≠ Mawashi Geri |
+| **Didaktik / Kausalität** | Reihenfolge, Lernphasen | Roadmap + LLM Pfad-QS | Grundlagen vor Geschwindigkeit |
+
+**Seit 0.8.233:** `planning_catalog_context` im Request und im Graph-Artefakt (`planning_catalog_context` JSON). Fließt in `PlanningTargetProfile` → Hybrid-Retrieval (`score_exercise_against_target`: „Fokusbereich passend“, …). Zusätzlich additive Text-Signale aus Anfrage + Start/Ziel + Notizen (`planning_exercise_text_signals`).
+
+**Geplant (H1):** dieselben Dimensionen als **kaskadierte Prompt-Snippets** in Roadmap-, Stufen-Spec- und Pfad-QS-Prompts — Priorität Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung — siehe **`PLANNING_CATALOG_PROMPT_SNIPPETS.md`**.
+
+**Technik-Gates** (`technique_scope`, Geschwister-Ausschluss) nur bei `topic_type == "technique"` — Fokus-Pfade (Gewaltschutz, Fitness, …) werden nicht wie Mae-Geri-Pfade behandelt.
+
+Fallback: fehlt `planning_catalog_context` im Request, wird aus gespeichertem `planning_roadmap` am Graph geladen.
### 4.3 Prompt-Slugs (nur in `ai_prompts`, nie Hardcoding)
@@ -157,17 +201,32 @@ flowchart TB
---
-## 5. Roadmap-Match — Stufen-Qualität (0.8.218)
+## 5. Roadmap-Match — Stufen-Qualität (0.8.218–0.8.233)
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`.
+1. **Stufen-Brief** — `build_stage_match_brief()` aus Lernziel, `anti_patterns`, Erfolgskriterien, Pfad-Kontext.
+2. **Stufen-Gate** — `exercise_passes_stage_fit()` / `exercise_passes_stage_learning_goal_gate()` auf vollem Übungstext.
+3. **Kein blindes Rank-Fallback** — ohne Gate-Passung → `roadmap_unfilled`, nicht themenfremde Übung.
+4. **Post-Match-Gate** — `_roadmap_step_passes_post_match_gate()` = gleiche QS wie `detect_off_topic_steps` (kein Rematch-Treffer, der sofort wieder `stage_mismatch` wäre).
+5. **Retrieval** — Hybrid-Score: Volltext + Semantik + **Profil/Katalog** + Skill-Erwartungen + optional Graph-Bias.
+6. **Auto-Optimierung (ein Match-Lauf):**
+ - **Phase B:** Rematch-Schleife (`planning_path_rematch.py`) für `rematch_slot` / `roadmap_unfilled`
+ - **Phase C:** `planning_path_refine_stage.py` — `anti_patterns` / Erfolgskriterien aus QS
+ - Purge persistent `stage_mismatch` → Slot leeren + KI-Gap
+ - LLM Pfad-QS **nach** Rematch auf finalem Pfad
+ - Gap-Offers für alle leeren Slots **vor** `path_qa`-Summary
-Tests: `test_planning_roadmap_stage_match.py`
+Tests: `test_planning_roadmap_stage_match.py`, `test_planning_path_rematch.py`, `test_planning_path_refine_stage.py`, `test_planning_catalog_context.py`
+
+### Referenz-Validierung (Mae Geri, 2026-05)
+
+| Phase | Pfad-QS | Ergebnis |
+|-------|---------|----------|
+| Vor Roadmap/KI-Anpassung | ~65 % | Strukturelle Lücken (Grundlagen, Reihenfolge, Zielgenauigkeit) |
+| Nach Trainer-Roadmap + KI-Angebote in leeren Slots | **~88 % OK** | Vollständige Curriculum-Abdeckung; positive LLM-Empfehlungen |
+
+**Lesson:** Workbench + Katalog-Kontext + Roadmap sind der Hebel; Technik-Hardcoding allein reicht nicht für Didaktik.
---
@@ -209,7 +268,15 @@ Gespeichert am **Graph-Container** (`exercise_progression_graphs`), Schema v1:
"roadmap_notes": "…",
"max_steps": 5,
"progression_roadmap": { },
- "path_skill_expectations": { }
+ "path_skill_expectations": { },
+ "planning_catalog_context": {
+ "focus_areas": [{ "id": 1, "is_primary": true }],
+ "style_directions": [],
+ "training_types": [{ "id": 2, "is_primary": true }],
+ "target_groups": []
+ },
+ "slot_contents": [ ],
+ "last_findings": { }
}
```
@@ -307,26 +374,75 @@ 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 |
+| F10 | Stufen-Lernziel-Gate + kein goal_query-Fallback | ✅ | 0.8.218 |
+| **F11** | Auto-Rematch + Stufen-Spec-Refine + mehrstufige QS | ✅ | 0.8.225–0.8.230 |
+| **F12** | Post-Match-Gate, LLM-QA nach Rematch, Gap-Timing, `roadmap_unfilled`-Sync | ✅ | 0.8.231–0.8.232 |
+| **F13** | **Katalog-Kontext** (`planning_catalog_context`) im Match + Graph-Artefakt | ✅ | **0.8.233** |
+| **F14** | `ProgressionGraphEditor` Slot-UI + Planungskontext-Dropdowns | ✅ | 0.8.233 |
+| **H1** | **Katalog-Prompt-Snippets** — modulare LLM-Anweisungen pro Dimension | 🔲 | Spec **`PLANNING_CATALOG_PROMPT_SNIPPETS.md`** |
+| **G** | Trainingsplanung: eigene Pipeline + Wiederverwendung Bausteine (§16) | 🔲 | — |
+| **UX** | Wizard/Stepper; PathBuilder-Parität Katalog | 🔲 | — |
+| **H** | Technik-Disambiguierung konfigurierbar (DB statt Code-Tuples) | 🔲 | Backlog |
+| **D′** | Auto Gap-Fill (KI generiert bei persistent `roadmap_unfilled`) | 🔲 | Backlog |
---
## 12. Offenes Backlog (priorisiert)
-1. **UI-Überarbeitung** — Wizard mit 4 Schritten, progressive disclosure (Briefing unten)
-2. **Graph-Erweiterungsmodus** — Start ab gewähltem Knoten / letzter Sequenz
-3. **Kontext auf allen Pfad-Schritten** in UI (nicht nur Lücken)
-4. **Trainingsplanung Phase G** — Pipeline mit Gruppenkontext, Wiederverwendung `planning_skill_expectations`
-5. Enrichment / Prompt-Feintuning
-6. Mitai Workflow-Engine (langfristig)
+1. **H1 Katalog-Prompt-Snippets** — modulare LLM-Anweisungen (Priorität: Primärfokus → Trainingsstil → Zielgruppe → Stilrichtung) — **`PLANNING_CATALOG_PROMPT_SNIPPETS.md`**
+2. **Dev-Regression:** Gewaltschutz + Breitensport + Kinder (ohne Mae Geri) — Katalog-Match verifizieren
+2. **PathBuilder-Parität** — gleiche `planning_catalog_context`-Dropdowns in `ExerciseProgressionPathBuilder`
+3. **QS-UI** — positive LLM-Hinweise als „Highlights“, nicht als „Optimierungspotenziale“
+4. **UI-Wizard** — 4 Schritte (Ziel → Roadmap → Match → Lücken); Backend unverändert
+5. **Graph-Erweiterungsmodus** — Start ab gewähltem Knoten / letzter Sequenz
+6. **Phase D′** — automatisches KI-Gap-Fill bei persistent leeren Slots
+7. **Trainingsplanung Phase G** — siehe §16
+8. **Technik-Katalog externalisieren** — konfigurierbare `concept_groups` (Backlog)
+9. Graph-Metadaten: Primärfokus/Stil als Spalten (Reporting)
+10. Mitai Workflow-Engine (langfristig)
-### Briefing-Vorlage UI-Chat (Copy-Paste)
+### Briefing-Vorlage UI-Chat
-Siehe Nutzer-Chat 2026-05-22 oder `HANDOVER.md` §2.8 — Abschnitt „UI-Überarbeitung“.
+Kern: Wizard ① Ziel & Planungskontext → ② Roadmap → ③ Match → ④ Lücken & Speichern; Backend-Pipeline unverändert lassen.
-Kern: Wizard ① Ziel & Start/Ziel → ② Roadmap → ③ Match → ④ Lücken & Speichern; Backend-Pipeline unverändert lassen.
+---
+
+## 16. Wiederverwendung in der Trainingsplanung (Phase G)
+
+Die **komplexere Trainingsplanung** (Einheit, Rahmen-Slot, Abschnitt, parallele Streams) soll **keine zweite Retrieval-Welt** bauen, sondern bestehende Module mit **anderem Kontext-Pack** nutzen.
+
+### 16.1 Was Progressionsgraph liefert (Workbench-Muster)
+
+| Baustein | Progressionsgraph | Trainingsplanung (Ziel) |
+|----------|-------------------|-------------------------|
+| `PlanningTargetProfile` | Query + Katalog + Skills | Einheit + Abschnitt + Slot + Katalog + Historie |
+| `planning_catalog_context` | Am Graph gespeichert | Pro Einheit / Slot / Trainer-Voreinstellung |
+| `planning_skill_expectations` | `progression_stage` / `progression_path` | **`training_section`**, **`framework_slot`** |
+| `planning_exercise_retrieval` | Roadmap-Stufen-Match | Abschnitts-Suche (`suggest_planning_exercises`) — **produktiv** |
+| `planning_path_qa_pipeline` | Curriculum-QS | Abschnitts-QS (Kohärenz, Streams) |
+| `planning_intent_context` | Pfad-Ausschlüsse → Stufen | Abschnitts-Guidance → Brief |
+| `planning_exercise_form_context` | Pfad-Lücken | Abschnitts-/Slot-Lücken |
+| Roadmap-Pipeline | Curriculum Major Steps | **Nicht 1:1** — Phasen/Streams + Vorlagen |
+| Technik-Disambiguierung | bei `topic_type=technique` | nur bei explizitem Technik-Abschnitt |
+
+### 16.2 Was Phase G neu braucht
+
+- **Gruppen-/Historie-Kontext-Pack** (`AI_PLANNING_KI_MULTISTAGE_FORECAST` S0–S4)
+- **Abschnitts-Didaktik** — Dauer, Parallel-Streams, Coaching (`training_unit_phases`)
+- **Rahmen-Blueprint** — bereits `training_framework_programs` / Slot-Blueprints
+- **Eigene Orchestrierung** pro Einheit — kein Curriculum über N Wochen
+
+### 16.3 Integrations-Reihenfolge (Phase G)
+
+1. **G0** — Katalog in Einheits-Editor → bestehende Suggest-Pipeline
+2. **G1** — Scope `training_section` + Skill-Erwartungen aktiv
+3. **G2** — Abschnitts-QS (Hint-Struktur wie Graph)
+4. **G3** — Framework-Slot + Gap-Fill
+5. **G4** — Gruppenkontext-Pack
+
+**Nicht:** Roadmap-first-Loop 1:1 auf Trainingseinheit mappen.
+
+Domänenbezug: **`DOMAIN_MODEL.md`** §1–2 (Katalog-Dimensionen).
---
@@ -342,6 +458,10 @@ Kern: Wizard ① Ziel & Start/Ziel → ② Roadmap → ③ Match → ④ Lücken
| `test_planning_exercise_form_context.py` | `planning_context`, Gap-Snapshot |
| `test_progression_graph_planning_artifact.py` | JSONB-Artefakt-Validierung |
| `test_planning_exercise_progression.py` | Graph-Auflösung, Nachfolger |
+| `test_planning_path_rematch.py` | Auto-Rematch, unfilled-Platzhalter |
+| `test_planning_path_refine_stage.py` | Stufen-Spec-Refine |
+| `test_planning_stage_anti_patterns.py` | Anti-Pattern-Sanitizer, Stufen-Gate |
+| `test_planning_catalog_context.py` | Katalog-Kontext → Target-Profil |
---
@@ -366,3 +486,4 @@ Kern: Wizard ① Ziel & Start/Ziel → ② Roadmap → ③ Match → ④ Lücken
| Datum | Änderung |
|-------|----------|
| 2026-05-22 | Erstfassung Ist-Stand 0.8.217 — zentrale Referenz nach F5–F9 |
+| 2026-05-22 | F11–F14: Auto-Optimierung, Katalog-Kontext, GraphEditor, Mae-Geri-Validierung, Phase-G-Wiederverwendung §16 |
diff --git a/frontend/src/components/ExerciseProgressionPathBuilder.jsx b/frontend/src/components/ExerciseProgressionPathBuilder.jsx
index 9c83c04..7b2efc1 100644
--- a/frontend/src/components/ExerciseProgressionPathBuilder.jsx
+++ b/frontend/src/components/ExerciseProgressionPathBuilder.jsx
@@ -7,6 +7,19 @@ import api from '../utils/api'
import ExerciseAiQuickCreateModal from './exercises/ExerciseAiQuickCreateModal'
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
+import PlanningCatalogContextFields from './PlanningCatalogContextFields'
+import {
+ EMPTY_PLANNING_CATALOG_CONTEXT,
+ parsePlanningCatalogContextFromArtifact,
+ planningCatalogContextToApi,
+ pathQaQualityPercent,
+ pathQaShowsStrongResult,
+ setCatalogSelectItems,
+ splitPathQaHints,
+ draftHasLibrarySlotAssignments,
+ slotsToSlotAssignments,
+ draftRetrievalBoostExerciseIds,
+} from '../utils/progressionGraphDraft'
import {
aiPreviewToQuickCreateDraft,
buildQuickCreateAiPreview,
@@ -449,9 +462,13 @@ function buildPlanningRoadmapArtifactSnapshot({
maxSteps,
progressionRoadmap,
pathSkillExpectations,
+ planningCatalogContext,
}) {
const q = (goalQuery || '').trim()
if (!q && !progressionRoadmap) return null
+ const catalogPayload = planningCatalogContextToApi(
+ planningCatalogContext || EMPTY_PLANNING_CATALOG_CONTEXT,
+ )
return {
schema_version: PLANNING_ARTIFACT_SCHEMA,
goal_query: q,
@@ -461,6 +478,9 @@ function buildPlanningRoadmapArtifactSnapshot({
max_steps: Number(maxSteps) || 5,
progression_roadmap: progressionRoadmap || null,
path_skill_expectations: pathSkillExpectations || null,
+ ...(catalogPayload.planning_catalog_context
+ ? { planning_catalog_context: catalogPayload.planning_catalog_context }
+ : {}),
}
}
@@ -544,6 +564,12 @@ export default function ExerciseProgressionPathBuilder({
const [startTargetAnalyzed, setStartTargetAnalyzed] = useState(false)
const loading = loadingRoadmap || loadingStartTarget || loadingMatch
const [focusAreas, setFocusAreas] = useState([])
+ const [styleDirections, setStyleDirections] = useState([])
+ const [trainingTypes, setTrainingTypes] = useState([])
+ const [targetGroups, setTargetGroups] = useState([])
+ const [planningCatalogContext, setPlanningCatalogContext] = useState(() => ({
+ ...EMPTY_PLANNING_CATALOG_CONTEXT,
+ }))
const [skillsCatalog, setSkillsCatalog] = useState([])
const [generatingOfferId, setGeneratingOfferId] = useState(null)
@@ -571,6 +597,22 @@ export default function ExerciseProgressionPathBuilder({
[editableMajorSteps, pathSteps],
)
+ const catalogApiPayload = useMemo(
+ () => planningCatalogContextToApi(planningCatalogContext),
+ [planningCatalogContext],
+ )
+
+ const pathQaSplit = useMemo(() => splitPathQaHints(pathQa), [pathQa])
+ const pathQaHighlights = pathQaSplit.highlightTexts
+ const pathQaFixHints = pathQaSplit.fixHints
+
+ const patchCatalogDimension = useCallback((key, value) => {
+ setPlanningCatalogContext((prev) => ({
+ ...prev,
+ [key]: setCatalogSelectItems(prev?.[key], value),
+ }))
+ }, [])
+
const buildPlanningArtifact = useCallback(
() =>
buildPlanningRoadmapArtifactSnapshot({
@@ -581,6 +623,7 @@ export default function ExerciseProgressionPathBuilder({
maxSteps,
progressionRoadmap,
pathSkillExpectations,
+ planningCatalogContext,
}),
[
goalQuery,
@@ -590,6 +633,7 @@ export default function ExerciseProgressionPathBuilder({
maxSteps,
progressionRoadmap,
pathSkillExpectations,
+ planningCatalogContext,
],
)
@@ -634,6 +678,9 @@ export default function ExerciseProgressionPathBuilder({
if (art.roadmap_notes) setRoadmapNotes(String(art.roadmap_notes))
if (art.max_steps) setMaxSteps(Number(art.max_steps))
if (art.path_skill_expectations) setPathSkillExpectations(art.path_skill_expectations)
+ if (art.planning_catalog_context) {
+ setPlanningCatalogContext(parsePlanningCatalogContextFromArtifact(art))
+ }
if (art.progression_roadmap) {
setProgressionRoadmap(art.progression_roadmap)
const majors = mapMajorStepsFromApi(art.progression_roadmap)
@@ -670,16 +717,25 @@ export default function ExerciseProgressionPathBuilder({
let cancelled = false
Promise.all([
api.listFocusAreas({ status: 'active' }),
+ api.listStyleDirections({ status: 'active' }),
+ api.listTrainingTypes({ status: 'active' }),
+ api.listTargetGroups({ status: 'active' }),
api.listSkillsCatalog({ status: 'active' }),
])
- .then(([fa, sk]) => {
+ .then(([fa, sd, tt, tg, sk]) => {
if (cancelled) return
setFocusAreas(Array.isArray(fa) ? fa : [])
+ setStyleDirections(Array.isArray(sd) ? sd : [])
+ setTrainingTypes(Array.isArray(tt) ? tt : [])
+ setTargetGroups(Array.isArray(tg) ? tg : [])
setSkillsCatalog(Array.isArray(sk) ? sk : [])
})
.catch(() => {
if (!cancelled) {
setFocusAreas([])
+ setStyleDirections([])
+ setTrainingTypes([])
+ setTargetGroups([])
setSkillsCatalog([])
}
})
@@ -1095,6 +1151,7 @@ export default function ExerciseProgressionPathBuilder({
start_target_only: true,
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(startSituation, targetState, roadmapNotes),
+ ...catalogApiPayload,
})
applyStartTargetResponse(res)
} catch (e) {
@@ -1133,6 +1190,7 @@ export default function ExerciseProgressionPathBuilder({
roadmap_only: true,
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(startSituation, targetState, roadmapNotes),
+ ...catalogApiPayload,
})
const majors = mapMajorStepsFromApi(res?.progression_roadmap)
if (majors.length < 2) {
@@ -1190,6 +1248,22 @@ export default function ExerciseProgressionPathBuilder({
setError('')
try {
const override = majorStepsToOverridePayload(validSteps)
+ const preserveAssignments = draftHasLibrarySlotAssignments({
+ slots: validSteps.map((s, i) => ({
+ majorStepIndex: i,
+ phase: s.phase,
+ learning_goal: s.learning_goal,
+ primary:
+ pathSteps[i]?.exerciseId != null
+ ? {
+ kind: 'library',
+ exerciseId: pathSteps[i].exerciseId,
+ exerciseTitle: pathSteps[i].exerciseTitle,
+ variantId: pathSteps[i].variantId,
+ }
+ : { kind: 'empty' },
+ })),
+ })
const res = await api.suggestProgressionPath({
query: q,
max_steps: validSteps.length,
@@ -1202,8 +1276,24 @@ export default function ExerciseProgressionPathBuilder({
include_llm_roadmap: false,
roadmap_first: true,
roadmap_override: override,
+ preserve_slot_assignments: preserveAssignments,
+ slot_assignments: pathSteps
+ .map((row, i) => {
+ if (row.exerciseId == null) return null
+ return {
+ exercise_id: row.exerciseId,
+ variant_id: row.variantId || null,
+ title: row.exerciseTitle || null,
+ is_ai_proposal: false,
+ roadmap_major_step_index: i,
+ roadmap_phase: validSteps[i]?.phase || null,
+ roadmap_learning_goal: validSteps[i]?.learning_goal || null,
+ }
+ })
+ .filter(Boolean),
progression_graph_id: Number(graphId),
...roadmapStructuredPayload(startSituation, targetState, roadmapNotes),
+ ...catalogApiPayload,
})
applyPathMatchResponse(res, q)
setMaxSteps(validSteps.length)
@@ -1406,6 +1496,16 @@ export default function ExerciseProgressionPathBuilder({
/>
+
Optional zuerst „Start/Ziel analysieren“, anpassen, dann Roadmap-Stufen. Sind Start und Ziel leer, geschieht die Analyse beim Roadmap-Vorschlag automatisch mit. Manuelle Eingaben haben immer Vorrang. @@ -1826,11 +1926,40 @@ export default function ExerciseProgressionPathBuilder({ > Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'} - {pathQa.quality_score != null ? ` (${Math.round(Number(pathQa.quality_score) * 100)} %)` : ''} + {pathQaQualityPercent(pathQa) != null ? ` (${pathQaQualityPercent(pathQa)} %)` : ''} + {pathQaShowsStrongResult(pathQa) ? ( +
+ Starker Pfad — KI-Highlights können Feinschliff oder optionale Vertiefung sein. +
+ ) : null} {pathQa.topic_coverage ? ({pathQa.topic_coverage}
) : null} + {pathQaHighlights.length > 0 ? ( + <> ++ KI-Highlights ({pathQaHighlights.length}) +
++ Handlungsbedarf ({pathQaFixHints.length}) +
+{pathQa.bridge_insert_count} Brücken-Übung(en) aus der Bibliothek eingefügt. diff --git a/frontend/src/components/PlanningCatalogContextFields.jsx b/frontend/src/components/PlanningCatalogContextFields.jsx new file mode 100644 index 0000000..7877eb5 --- /dev/null +++ b/frontend/src/components/PlanningCatalogContextFields.jsx @@ -0,0 +1,99 @@ +/** + * Planungskontext — Katalog-Dimensionen für Progressionsgraph-Matching. + */ +import React from 'react' +import { getCatalogSelectId } from '../utils/progressionGraphDraft' + +export default function PlanningCatalogContextFields({ + catalogCtx, + onPatchDimension, + focusAreas = [], + styleDirections = [], + trainingTypes = [], + targetGroups = [], + disabled = false, + helperText = 'Planungskontext steuert Bibliotheks-Matching (Fokusbereich, Stil, Trainingsstil, Zielgruppe) — unabhängig von Technik-Pfaden.', +}) { + return ( + <> +
+ {helperText} +
+ ) : null} + > + ) +} diff --git a/frontend/src/components/ProgressionFindingsPanel.jsx b/frontend/src/components/ProgressionFindingsPanel.jsx index e733693..0b4135e 100644 --- a/frontend/src/components/ProgressionFindingsPanel.jsx +++ b/frontend/src/components/ProgressionFindingsPanel.jsx @@ -10,8 +10,11 @@ import { formatRematchLogEntry, formatRefineLogEntry, hasRematchSlotHints, + pathQaQualityPercent, + pathQaShowsStrongResult, resolveHintSlotIndex, resolveOfferSlotIndex, + splitPathQaHints, } from '../utils/progressionGraphDraft' function severityStyle(pathQa) { @@ -23,6 +26,131 @@ function severityStyle(pathQa) { } } +function PathQaPipelineDetails({ pathQa, fairQa = null, draft, title, compact = false }) { + const { fixHints: optimizationHints } = useMemo( + () => splitPathQaHints(pathQa), + [pathQa], + ) + const rematchLog = Array.isArray(pathQa?.rematch_log) ? pathQa.rematch_log : [] + const refineLog = Array.isArray(pathQa?.refine_log) ? pathQa.refine_log : [] + const qaTiers = Array.isArray(pathQa?.qa_tiers) ? pathQa.qa_tiers : [] + const qualityPct = pathQaQualityPercent(fairQa || pathQa) + const hasContent = + qaTiers.length > 0 + || (pathQa?.rematch_applied && rematchLog.length > 0) + || (pathQa?.refine_applied && refineLog.length > 0) + || optimizationHints.length > 0 + + if (!pathQa || !hasContent) return null + + return ( ++ Rematch-Protokoll (Pipeline-Score {pathQaQualityPercent(pathQa) ?? '—'} %) — nur Prozessinfo, nicht Pfad-QS. +
+ ) : null} + {qaTiers.length > 0 ? ( ++ Auto-Rematch + {pathQa.rematch_rounds != null ? ` (${pathQa.rematch_rounds} Runde(n))` : ''} +
++ Stufen-Spec verfeinert ({refineLog.length}) +
++ Handlungsbedarf ({optimizationHints.length}) +
+{hint.reason}
: null} +- Prüft den Slot-Stand und listet KI-Angebote für leere Stufen und Lücken. + Bewertet den aktuellen Slot-Stand (3-Stufen-QS, ohne Auto-Rematch). Nach Änderungen am Graphen + erscheint ein Hinweis — dann erneut „Graph bewerten“.
- Planungskontext steuert Bibliotheks-Matching (Fokusbereich, Stil, Trainingsstil, Zielgruppe) — unabhängig - von Technik-Pfaden wie Mae Geri. Wird mit dem Graph gespeichert. -
+
Optional zuerst „Start/Ziel analysieren“, anpassen, dann Roadmap-Stufen. Sind Start und Ziel leer,
geschieht die Analyse beim Roadmap-Vorschlag automatisch. Manuelle Eingaben haben Vorrang.
@@ -1018,9 +1101,25 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
className="btn btn-secondary"
disabled={busy || matching}
onClick={runMatch}
+ title={
+ draftHasLibrarySlotAssignments(draft)
+ ? 'Voller Match mit Auto-Optimierung — bei Abweichungen öffnet sich der Vergleichsdialog'
+ : 'Bibliotheks-Übungen für leere Slots finden'
+ }
>
{matching ? 'Match…' : 'Übungen matchen'}
+ {draftHasLibrarySlotAssignments(draft) ? (
+
+ {text}
+
+ Je Slot: aktuelle Bewertung, beste Bibliotheks-Alternative und optional KI. Haken nur
+ vorausgewählt, wenn die Alternative einen höheren Stufen-Fit hat.
+
+ {reviewError}
+ {baselineQa.topic_coverage}
+ {rejectedCount} Alternative(n) ohne Pfad-Gewinn
+ {baselinePct != null ? ` (Basis ${baselinePct} %)` : ''}.
+
+ Keine Slot-Daten — Backend-Stand prüfen oder erneut „Graph bewerten“ und „Übungen
+ matchen“.
+
+ {items.map((text, i) => (
+
+
+ )
+}
+
+function SlotReviewRow({ review, selected, onToggle, applying }) {
+ const midx = Number(review.roadmap_major_step_index)
+ const lib = review.library_alternative
+ const ai = review.ai_alternative
+ const libKey = slotReviewSelectionKey(midx, 'library')
+ const aiKey = slotReviewSelectionKey(midx, 'ai')
+ const pc = lib?.pro_contra || {}
+ const pathDelta = qualityDeltaPercent({ quality_delta: lib?.quality_delta })
+ const slotDelta = lib?.slot_score_delta
+
+ return (
+
+ {title}
+
+
+ {slotReviews.map((review) => (
+
+ )}
+
+