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}) +

+ + + ) : null} {Array.isArray(pathQa.issues) && pathQa.issues.length > 0 ? ( ) : null} + {pathQaFixHints.length > 0 ? ( + <> +

+ Handlungsbedarf ({pathQaFixHints.length}) +

+ + + ) : null} {Number(pathQa.bridge_insert_count) > 0 ? (

{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 ? ( +

+ {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 ( +
+ + {title} + {qualityPct != null + ? ` · ${(fairQa || pathQa)?.overall_ok ? 'OK' : 'Hinweise'} (${qualityPct} % fair bewertet)` + : ''} + + {fairQa && pathQa && pathQaQualityPercent(pathQa) !== qualityPct ? ( +

+ Rematch-Protokoll (Pipeline-Score {pathQaQualityPercent(pathQa) ?? '—'} %) — nur Prozessinfo, nicht Pfad-QS. +

+ ) : null} + {qaTiers.length > 0 ? ( + + ) : null} + {pathQa.rematch_applied && rematchLog.length > 0 ? ( + <> +

+ Auto-Rematch + {pathQa.rematch_rounds != null ? ` (${pathQa.rematch_rounds} Runde(n))` : ''} +

+ + + ) : null} + {pathQa.refine_applied && refineLog.length > 0 ? ( + <> +

+ Stufen-Spec verfeinert ({refineLog.length}) +

+ + + ) : null} + {optimizationHints.length > 0 ? ( + <> +

+ Handlungsbedarf ({optimizationHints.length}) +

+ + + ) : null} +
+ ) +} + function GapOfferCard({ offer, slotCount, @@ -159,21 +287,36 @@ export default function ProgressionFindingsPanel({ onInsertGapSlot, onGenerateGapAi, onRematchSlots = null, + onOptimizeCompare = null, + optimizationPreviewQa = null, + optimizationPreviewFairQa = null, + canOptimizeCompare = false, + optimizeCompareBusy = false, rematchBusy = false, generatingOfferId = null, aiBusy = false, evaluateDisabled = false, + evaluationStale = false, }) { - const optimizationHints = Array.isArray(pathQa?.optimization_hints) ? pathQa.optimization_hints : [] + const { fixHints: optimizationHints, highlightTexts } = 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 showRematchAction = hasRematchSlotHints(pathQa) && typeof onRematchSlots === 'function' + const showOptimizeCompare = + typeof onOptimizeCompare === 'function' + && (canOptimizeCompare || pathQa?.assignments_preserved || showRematchAction) + const qualityPct = pathQaQualityPercent(pathQa) + const strongResult = pathQaShowsStrongResult(pathQa) return (

Graph-Bewertung

- 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“.

+ ) : null} + {showRematchAction && !showOptimizeCompare ? (
) : (

diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index 97cd1c5..59875ad 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -8,6 +8,7 @@ import ExercisePickerModal from './ExercisePickerModal' import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal' import ProgressionSlotCard from './ProgressionSlotCard' import ProgressionFindingsPanel from './ProgressionFindingsPanel' +import PlanningCatalogContextFields from './PlanningCatalogContextFields' import { aiPreviewToQuickCreateDraft, buildQuickCreateAiPreview, @@ -21,34 +22,45 @@ import { initialStageLearningGoalFromOffer, } from '../utils/planningContextForExerciseAi' import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal' +import ProgressionOptimizeCompareModal from './ProgressionOptimizeCompareModal' import { addSlotToDraft, applyEvaluateResponseToDraft, applyGapOfferToDraft, - applyMatchResponseToDraft, + applySelectedCompareSteps, + applySelectedSlotSuggestions, applyResolvedStructuredToDraft, buildPlanningArtifactFromDraft, + buildProgressionComparePayload, + collectGapOffersFromApiResponse, + compareSlotDiffs, + compareDiffsForDialog, + dedupeGapOffersBySlot, + draftHasLibrarySlotAssignments, + draftRetrievalBoostExerciseIds, + EMPTY_PLANNING_CATALOG_CONTEXT, + filterGapOffersForUnfilledSlots, hydrateProgressionGraphDraft, - SLOT_MIN, insertSlotInDraft, librarySlotExercise, majorStepsToOverridePayload, + mergeGapOffersForDraft, moveSlotInDraft, patchSlotInDraft, + pathQaQualityPercent, + planningCatalogContextToApi, + rejectedCompareDiffs, removeSlotFromDraft, saveProgressionGraphDraft, + setCatalogSelectItems, setSlotPrimaryLibrary, SLOT_MAX, + SLOT_MIN, slotsAsPathStepRows, slotsToEvaluateSteps, - draftRetrievalBoostExerciseIds, slotsToSlotAssignments, syncProgressionRoadmapFromSlots, syncSlotPhasesFromRoadmap, - EMPTY_PLANNING_CATALOG_CONTEXT, - getCatalogSelectId, - planningCatalogContextToApi, - setCatalogSelectItems, } from '../utils/progressionGraphDraft' function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) { @@ -111,6 +123,12 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa const [slotQuickSaving, setSlotQuickSaving] = useState(false) const [slotQuickError, setSlotQuickError] = useState('') const [activePlanningContextLines, setActivePlanningContextLines] = useState([]) + const [compareOpen, setCompareOpen] = useState(false) + const [comparePayload, setComparePayload] = useState(null) + const [compareSource, setCompareSource] = useState('manual') + const [comparing, setComparing] = useState(false) + const [compareApplying, setCompareApplying] = useState(false) + const [proposedPathQa, setProposedPathQa] = useState(null) const loadGraph = useCallback(async () => { if (!graphId) return @@ -182,7 +200,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa setDraft((prev) => { if (!prev) return prev const next = patchFn(prev) - return { ...next, dirty: true } + return { ...next, dirty: true, findingsStale: true } }) }, []) @@ -346,7 +364,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa { ...prev, progressionRoadmap: roadmap }, roadmap, ) - return { ...structured, dirty: true } + return { ...structured, dirty: true, findingsStale: true } }) setStartTargetReady(true) setSemanticBrief(res?.semantic_brief_summary || null) @@ -415,7 +433,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa graphName: draft.graphName, }) const withPhases = syncSlotPhasesFromRoadmap(hydrated, roadmap) - setDraft({ ...withPhases, goalQuery: q, maxSteps: majorCount || withPhases.maxSteps, dirty: true }) + setDraft({ ...withPhases, goalQuery: q, maxSteps: majorCount || withPhases.maxSteps, dirty: true, findingsStale: true }) setSemanticBrief(res?.semantic_brief_summary || null) } catch (e) { setActionErr(e.message || 'Roadmap-Generierung fehlgeschlagen') @@ -424,6 +442,149 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa } } + const buildEvaluateRequest = (synced, { llmPathQa = true, aiGapFill = true } = {}) => { + const override = majorStepsToOverridePayload(synced.slots) + return { + query: (synced.goalQuery || '').trim(), + max_steps: synced.slots.length || draft?.maxSteps || 5, + include_path_qa: true, + include_llm_path_qa: llmPathQa, + include_ai_gap_fill: aiGapFill, + include_path_reorder: false, + include_llm_intent: false, + evaluate_only: true, + evaluate_steps: slotsToEvaluateSteps(synced), + roadmap_override: override, + slot_assignments: slotsToSlotAssignments(synced), + progression_graph_id: Number(graphId), + ...roadmapStructuredPayload(synced.startSituation, synced.targetState, synced.roadmapNotes), + ...catalogApiPayload, + } + } + + const fetchPathEvaluate = async (synced, options) => + api.suggestProgressionPath(buildEvaluateRequest(synced, options)) + + const applyEvaluateResult = (synced, res) => { + setSemanticBrief(res?.semantic_brief_summary || null) + setPathQa(res?.path_qa || null) + const { draft: evaluated, remainingOffers } = applyEvaluateResponseToDraft(synced, res) + return { + draft: { ...evaluated, lastFindings: res?.path_qa || null, findingsStale: false }, + remainingOffers, + } + } + + const buildMatchRequestBase = (synced) => { + const override = majorStepsToOverridePayload(synced.slots) + return { + query: (synced.goalQuery || '').trim(), + max_steps: synced.slots.length, + include_llm_intent: true, + include_path_qa: true, + include_llm_path_qa: true, + include_path_reorder: false, + include_ai_gap_fill: true, + include_roadmap_preview: true, + include_llm_roadmap: false, + roadmap_first: true, + roadmap_override: override, + slot_assignments: slotsToSlotAssignments(synced), + retrieval_boost_exercise_ids: draftRetrievalBoostExerciseIds(synced), + progression_graph_id: Number(graphId), + ...roadmapStructuredPayload(synced.startSituation, synced.targetState, synced.roadmapNotes), + ...catalogApiPayload, + } + } + + const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => { + setMatchNotice('Schritt 1/2: Pfad bewerten (wie „Graph bewerten“)…') + const baselineRes = await fetchPathEvaluate(synced) + const { draft: evaluated, remainingOffers } = applyEvaluateResult(synced, baselineRes) + setDraft(evaluated) + const mergedAfterEval = mergeGapOffersForDraft(evaluated, baselineRes) + setGapFillOffers(mergedAfterEval.length > 0 ? mergedAfterEval : remainingOffers) + + setMatchNotice('Schritt 2/2: Slot-Alternativen prüfen…') + let compareRes + let reviewError = null + try { + const reviewRes = await api.suggestProgressionPath({ + ...buildEvaluateRequest(synced), + evaluate_only: false, + unified_slot_review: true, + baseline_evaluate_steps: slotsToEvaluateSteps(synced), + baseline_path_qa_snapshot: baselineRes?.path_qa || null, + baseline_quality_score: + baselineRes?.path_qa?.quality_score != null + ? Number(baselineRes.path_qa.quality_score) + : null, + include_llm_path_qa: false, + include_llm_intent: false, + auto_rematch_after_qa: false, + }) + if (!reviewRes?.unified_slot_review) { + reviewError = + 'Slot-Review nicht verfügbar — Backend neu starten/deployen (unified_slot_review fehlt).' + compareRes = buildProgressionComparePayload(baselineRes, { + ...reviewRes, + unified_slot_review: true, + slot_reviews: [], + review_error: reviewError, + }) + } else { + compareRes = buildProgressionComparePayload(baselineRes, reviewRes) + } + setGapFillOffers(mergeGapOffersForDraft(evaluated, baselineRes, reviewRes)) + } catch (e) { + reviewError = e.message || 'Slot-Review fehlgeschlagen' + compareRes = buildProgressionComparePayload(baselineRes, { + unified_slot_review: true, + slot_reviews: [], + review_error: reviewError, + path_qa: baselineRes?.path_qa, + }) + } + + presentMatchCompare(compareRes, { source, reviewError }) + return compareRes + } + + const presentMatchCompare = (res, { source = 'manual', reviewError = null } = {}) => { + setSemanticBrief(res?.semantic_brief_summary || null) + setTargetSummary(res?.target_profile_summary || null) + setComparePayload(reviewError ? { ...res, review_error: reviewError } : res) + setCompareSource(source) + setProposedPathQa(res?.proposed_path_qa_pipeline || null) + setCompareOpen(true) + + const baselineQa = res?.baseline_path_qa || null + const slotReviews = res?.slot_reviews || [] + const autoCount = slotReviews.filter((r) => r?.library_alternative?.auto_select).length + const diffCount = autoCount || res?.slot_diff_count || 0 + const rejectedCount = res?.slot_diff_count_rejected ?? rejectedCompareDiffs(res).length + const problemCount = res?.match_summary?.problem_slot_count + ?? (res?.problem_slots ? Object.keys(res.problem_slots).length : 0) + const bPct = pathQaQualityPercent(baselineQa) + let notice = reviewError + ? `Match: Dialog geöffnet — ${reviewError}` + : slotReviews.length > 0 + ? `Match: ${slotReviews.length} Slot(s) geprüft, ${autoCount} Empfehlung(en) vorausgewählt.` + : diffCount > 0 + ? `Match: ${diffCount} Verbesserung(en).` + : problemCount > 0 + ? `Match: ${problemCount} Schachstelle(n), keine bessere Bibliotheks-Alternative.` + : 'Match: Pfad geprüft — siehe Dialog.' + if (rejectedCount > 0) { + notice += ` ${rejectedCount} Vorschlag/Vorschläge verworfen (Verschlechterung oder neutral).` + } + const gapCount = collectGapOffersFromApiResponse(res).length + if (gapCount > 0) { + notice += ` ${gapCount} KI-Angebot(e) für leere Slots im Panel „Graph-Bewertung“.` + } + setMatchNotice(notice) + } + const runMatch = async () => { const q = (draft?.goalQuery || '').trim() if (q.length < 3) { @@ -439,65 +600,8 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa setMatchNotice('') try { const synced = syncProgressionRoadmapFromSlots(draft) - const override = majorStepsToOverridePayload(synced.slots) - const res = await api.suggestProgressionPath({ - query: q, - max_steps: synced.slots.length, - include_llm_intent: true, - include_path_qa: true, - include_llm_path_qa: true, - include_path_reorder: false, - include_ai_gap_fill: true, - include_roadmap_preview: true, - include_llm_roadmap: false, - roadmap_first: true, - roadmap_override: override, - slot_assignments: slotsToSlotAssignments(synced), - retrieval_boost_exercise_ids: draftRetrievalBoostExerciseIds(synced), - progression_graph_id: Number(graphId), - ...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes), - ...catalogApiPayload, - }) - const { draft: matched, remainingOffers } = applyMatchResponseToDraft( - { - ...synced, - progressionRoadmap: res?.progression_roadmap || synced.progressionRoadmap, - pathSkillExpectations: res?.path_skill_expectations || synced.pathSkillExpectations, - }, - res, - ) - setDraft(matched) - setSemanticBrief(res?.semantic_brief_summary || null) - setTargetSummary(res?.target_profile_summary || null) - setPathQa(res?.path_qa || null) - setGapFillOffers(remainingOffers) - const ms = res?.match_summary - const rematchLog = res?.path_qa?.rematch_log - const rematchRounds = res?.path_qa?.rematch_rounds - if (ms) { - const parts = [ - `Match: ${ms.library_matches ?? 0}/${ms.slot_count ?? '?'} Slots aus Bibliothek, ${ms.gap_fill_offer_count ?? 0} KI-Angebote.`, - ] - if (rematchRounds > 0 && Array.isArray(rematchLog) && rematchLog.length > 0) { - parts.push( - `Auto-Rematch: ${rematchLog.length} Anpassung(en) in ${rematchRounds} Runde(n).`, - ) - } - const refineLog = res?.path_qa?.refine_log - if (Array.isArray(refineLog) && refineLog.length > 0) { - parts.push(`Stufen-Spec: ${refineLog.length} Slot(s) verfeinert.`) - } - setMatchNotice(parts.join(' ')) - } - try { - await saveProgressionGraphDraft(api, graphId, { - ...matched, - lastFindings: res?.path_qa || null, - }) - setDraft((prev) => (prev ? { ...prev, dirty: false } : prev)) - } catch (saveErr) { - console.warn('Match-Artefakt konnte nicht gespeichert werden', saveErr) - } + setProposedPathQa(null) + await runMatchCompareFlow(synced, { source: 'match' }) } catch (e) { setActionErr(e.message || 'Übungs-Match fehlgeschlagen') } finally { @@ -505,6 +609,61 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa } } + const runOptimizeCompare = async () => { + const q = (draft?.goalQuery || '').trim() + if (q.length < 3) { + alert('Ziel-Anfrage: mindestens 3 Zeichen.') + return + } + if (validMajorSteps.length < 2) { + alert('Mindestens zwei Slots mit Lernziel (je 3+ Zeichen) nötig.') + return + } + setComparing(true) + setActionErr('') + setMatchNotice('') + try { + const synced = syncProgressionRoadmapFromSlots(draft) + setProposedPathQa(null) + await runMatchCompareFlow(synced, { source: 'manual' }) + } catch (e) { + setActionErr(e.message || 'Optimierungs-Vergleich fehlgeschlagen') + } finally { + setComparing(false) + } + } + + const applyOptimizeCompare = async (selectedMajorIndices) => { + if (!comparePayload || !draft) return + setCompareApplying(true) + setMatchNotice('Übernahme: Slots aktualisieren …') + try { + const synced = syncProgressionRoadmapFromSlots(draft) + const nextDraft = comparePayload?.unified_slot_review + ? applySelectedSlotSuggestions(synced, comparePayload, selectedMajorIndices) + : applySelectedCompareSteps( + synced, + comparePayload.proposed_steps || comparePayload.steps, + selectedMajorIndices, + ) + const syncedNext = syncProgressionRoadmapFromSlots(nextDraft) + + setDraft({ ...syncedNext, dirty: false, findingsStale: true }) + setCompareOpen(false) + setComparePayload(null) + setProposedPathQa(null) + + await saveProgressionGraphDraft(api, graphId, { ...syncedNext, findingsStale: true }) + setMatchNotice( + 'Übernommen und gespeichert. Bewertung bezieht sich noch auf den vorherigen Stand — bitte „Graph bewerten“.', + ) + } catch (e) { + setActionErr(e.message || 'Übernahme fehlgeschlagen') + } finally { + setCompareApplying(false) + } + } + const runEvaluate = async () => { const q = (draft?.goalQuery || '').trim() if (q.length < 3) { @@ -513,30 +672,14 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa } setEvaluating(true) setActionErr('') + setProposedPathQa(null) try { const synced = syncProgressionRoadmapFromSlots(draft) - const override = - validMajorSteps.length >= 2 ? majorStepsToOverridePayload(synced.slots) : undefined - const res = await api.suggestProgressionPath({ - query: q, - max_steps: synced.slots.length || draft.maxSteps || 5, - include_path_qa: true, - include_llm_path_qa: true, - include_ai_gap_fill: true, - include_path_reorder: false, - include_llm_intent: false, - evaluate_only: true, - evaluate_steps: slotsToEvaluateSteps(synced), - roadmap_override: override, - progression_graph_id: Number(graphId), - ...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes), - ...catalogApiPayload, - }) - setSemanticBrief(res?.semantic_brief_summary || null) - setPathQa(res?.path_qa || null) - const { draft: evaluated, remainingOffers } = applyEvaluateResponseToDraft(synced, res) - setDraft({ ...evaluated, lastFindings: res?.path_qa || null }) - setGapFillOffers(remainingOffers) + const res = await fetchPathEvaluate(synced) + const { draft: evaluated, remainingOffers } = applyEvaluateResult(synced, res) + setDraft(evaluated) + const mergedOffers = mergeGapOffersForDraft(evaluated, res) + setGapFillOffers(mergedOffers.length > 0 ? mergedOffers : remainingOffers) } catch (e) { setActionErr(e.message || 'Bewertung fehlgeschlagen') } finally { @@ -563,7 +706,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa const handleApplyGapOffer = (offer, slotIndex) => { setDraft((prev) => { const next = applyGapOfferToDraft(prev, offer, { slotIndex }) - return { ...next, dirty: true } + return { ...next, dirty: true, findingsStale: true } }) setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id)) } @@ -575,7 +718,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa } setDraft((prev) => { const next = applyGapOfferToDraft(prev, offer, { insertNewSlot: true }) - return { ...next, dirty: true } + return { ...next, dirty: true, findingsStale: true } }) setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id)) } @@ -689,7 +832,10 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa : null if (resolvedSlot != null) { setSlotQuickCreateIndex(resolvedSlot) - setDraft((prev) => applyGapOfferToDraft(prev, enrichedOffer, { slotIndex: resolvedSlot })) + setDraft((prev) => ({ + ...applyGapOfferToDraft(prev, enrichedOffer, { slotIndex: resolvedSlot }), + findingsStale: true, + })) } setSlotQuickCreateDraft(aiDraft) setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id)) @@ -737,7 +883,11 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa const payload = buildQuickCreateExercisePayloadFromDraft(slotQuickCreateDraft) const created = await api.createExercise(payload) if (!created?.id) throw new Error('Anlegen fehlgeschlagen') - setDraft((prev) => setSlotPrimaryLibrary(prev, slotQuickCreateIndex, created)) + setDraft((prev) => ({ + ...setSlotPrimaryLibrary(prev, slotQuickCreateIndex, created), + dirty: true, + findingsStale: true, + })) setSlotQuickCreateDraft(null) setSlotQuickCreateIndex(null) setActiveOffer(null) @@ -909,83 +1059,16 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa /> -

-
- - -
-
- - -
-
- - -
-
- - -
-
-

- 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) ? ( + + ) : null} @@ -1077,11 +1176,15 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa slotCount={draft.slots.length} loading={evaluating} error="" + evaluationStale={Boolean(draft?.findingsStale)} onEvaluate={runEvaluate} onApplyGapOffer={handleApplyGapOffer} onInsertGapSlot={handleInsertGapSlot} onGenerateGapAi={openGapFillPrep} onRematchSlots={runMatch} + onOptimizeCompare={runOptimizeCompare} + canOptimizeCompare={validMajorSteps.length >= 2} + optimizeCompareBusy={comparing} rematchBusy={matching} generatingOfferId={generatingOfferId} aiBusy={gapAiBusy} @@ -1121,6 +1224,20 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa zIndex={2100} /> + { + if (compareApplying) return + setCompareOpen(false) + setComparePayload(null) + setProposedPathQa(null) + }} + onApplySelected={applyOptimizeCompare} + applying={compareApplying} + /> + +

{title}
+ + + ) +} + +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 ( +
  • +
    + Slot {midx + 1} + {review.slot_problem ? ( + Schachstelle + ) : review.off_topic ? ( + Passt nicht + ) : ( + OK + )} + {review.roadmap_learning_goal ? ( + + {review.roadmap_learning_goal} + + ) : null} +
    + +
    +
    +
    + Aktuell +
    +
    + {review.baseline_title || '— leer —'} + {review.baseline_exercise_id != null ? ` (#${review.baseline_exercise_id})` : ''} +
    +
    + {slotScoreLabel(review.baseline_slot_score)} +
    + {(review.problem_reasons || []).slice(0, 3).map((text, i) => ( +

    + {text} +

    + ))} +
    +
    +
    + Beste Bibliotheks-Alternative +
    + {lib ? ( + <> +
    + {lib.title || '—'} + {lib.exercise_id != null ? ` (#${lib.exercise_id})` : ''} +
    +
    + {slotScoreLabel(lib.slot_score)} + {slotDelta != null && Number(slotDelta) !== 0 ? ( + 0 ? 'var(--accent-dark)' : 'var(--text2)' }}> + ({Number(slotDelta) > 0 ? '+' : ''}{Math.round(Number(slotDelta) * 100)} PP) + + ) : null} + {pathDelta != null ? ( + · Pfad {pathDelta > 0 ? `+${pathDelta}` : pathDelta} % + ) : null} +
    + + + + ) : ( +
    Kein passender Bibliotheks-Treffer
    + )} +
    +
    + + {lib ? ( + + ) : null} + + {ai ? ( + + ) : null} +
  • + ) +} + +export default function ProgressionOptimizeCompareModal({ + open, + comparison, + mode = 'manual', + onClose, + onApplySelected, + applying = false, +}) { + const slotReviews = useMemo(() => compareSlotReviews(comparison), [comparison]) + const rejected = useMemo(() => rejectedCompareDiffs(comparison), [comparison]) + const defaultSelected = useMemo( + () => new Set(defaultSelectedCompareDiffs(comparison)), + [comparison], + ) + + const [selected, setSelected] = useState(() => new Set()) + + React.useEffect(() => { + if (!open) return + setSelected(new Set(defaultSelected)) + }, [open, defaultSelected]) + + if (!open || !comparison) return null + + const baselineQa = comparison.baseline_path_qa + const baselinePct = pathQaQualityPercent(baselineQa) + const rejectedCount = rejected.length + const reviewError = comparison.review_error || null + + const toggle = (key, kind) => { + setSelected((prev) => { + const next = new Set(prev) + const parsed = String(key) + const midx = Number(parsed.split(':')[0]) + const libKey = slotReviewSelectionKey(midx, 'library') + const aiKey = slotReviewSelectionKey(midx, 'ai') + if (next.has(parsed)) { + next.delete(parsed) + return next + } + if (kind === 'library') next.delete(aiKey) + if (kind === 'ai') next.delete(libKey) + next.add(parsed) + return next + }) + } + + const title = + mode === 'match' ? 'Übungs-Match — Slot-Bewertung' : 'Optimierung vergleichen' + + return ( + +
    e.stopPropagation()} + > +

    + {title} +

    +

    + Je Slot: aktuelle Bewertung, beste Bibliotheks-Alternative und optional KI. Haken nur + vorausgewählt, wenn die Alternative einen höheren Stufen-Fit hat. +

    + + {reviewError ? ( +

    + {reviewError} +

    + ) : null} + +
    + Dein Pfad +
    {qaLabel(baselineQa)}
    + {baselineQa?.topic_coverage ? ( +

    {baselineQa.topic_coverage}

    + ) : null} +
    + + {rejectedCount > 0 ? ( +

    + {rejectedCount} Alternative(n) ohne Pfad-Gewinn + {baselinePct != null ? ` (Basis ${baselinePct} %)` : ''}. +

    + ) : null} + + {slotReviews.length === 0 ? ( +

    + Keine Slot-Daten — Backend-Stand prüfen oder erneut „Graph bewerten“ und „Übungen + matchen“. +

    + ) : ( +
      + {slotReviews.map((review) => ( + + ))} +
    + )} + +
    + + +
    +
    +
    + ) +} diff --git a/frontend/src/utils/progressionGraphDraft.js b/frontend/src/utils/progressionGraphDraft.js index c654b17..eec0907 100644 --- a/frontend/src/utils/progressionGraphDraft.js +++ b/frontend/src/utils/progressionGraphDraft.js @@ -141,6 +141,44 @@ export function optimizationHintActionLabel(action) { return OPTIMIZATION_ACTION_LABELS[action] || action || 'Hinweis' } +/** LLM-Empfehlungen von technischen Fix-Hinweisen trennen (QS-UI). */ +export function splitPathQaHints(pathQa) { + const hints = Array.isArray(pathQa?.optimization_hints) ? pathQa.optimization_hints : [] + const fixHints = hints.filter((h) => String(h?.issue || '') !== 'llm_recommendation') + const highlightHints = hints.filter((h) => String(h?.issue || '') === 'llm_recommendation') + const recommendations = Array.isArray(pathQa?.recommendations) ? pathQa.recommendations : [] + const highlightTexts = [] + const seen = new Set() + for (const rec of recommendations) { + const text = String(rec || '').trim() + const key = text.toLowerCase() + if (text && !seen.has(key)) { + seen.add(key) + highlightTexts.push({ text, source: 'recommendation' }) + } + } + for (const hint of highlightHints) { + const text = String(hint.reason || hint.title || '').trim() + const key = text.toLowerCase() + if (text && !seen.has(key)) { + seen.add(key) + highlightTexts.push({ text, source: 'hint', hint }) + } + } + return { fixHints, highlightTexts } +} + +export function pathQaQualityPercent(pathQa) { + if (pathQa?.quality_score == null || !Number.isFinite(Number(pathQa.quality_score))) return null + return Math.round(Number(pathQa.quality_score) * 100) +} + +export function pathQaShowsStrongResult(pathQa) { + const pct = pathQaQualityPercent(pathQa) + if (pathQa?.overall_ok && pct != null && pct >= 85) return true + return Boolean(pathQa?.overall_ok && pct != null && pct >= 80 && !(pathQa?.issues || []).length) +} + /** Slot-Index aus optimization_hint (roadmap_major_step_index oder step_index). */ export function resolveHintSlotIndex(hint, draft = null) { if (!hint || typeof hint !== 'object') return null @@ -349,12 +387,29 @@ export function collectGapOffersFromApiResponse(res) { } for (const offer of res?.gap_fill_offers || []) add(offer) for (const offer of res?.path_qa?.gap_fill_offers || []) add(offer) - for (const step of res?.steps || []) { + const stepSources = [ + ...(res?.steps || []), + ...(res?.proposed_steps || []), + ...(res?.proposed_steps_pipeline || []), + ] + for (const step of stepSources) { if (step?.gap_offer) add(step.gap_offer) } return out } +/** KI-Angebote aus einer oder mehreren Planungs-Antworten für leere Slots sammeln. */ +export function mergeGapOffersForDraft(draft, ...responses) { + const collected = [] + for (const res of responses) { + if (res) collected.push(...collectGapOffersFromApiResponse(res)) + } + return filterGapOffersForUnfilledSlots( + draft, + dedupeGapOffersBySlot(collected, draft), + ) +} + /** Maximal ein Angebot pro Slot — Roadmap-Lücken vor Brücken/QS. */ export function dedupeGapOffersBySlot(offers, draft) { const bySlot = new Map() @@ -780,6 +835,7 @@ export function hydrateProgressionGraphDraft({ progressionRoadmap: artifact?.progression_roadmap || null, planningCatalogContext: parsePlanningCatalogContextFromArtifact(artifact), lastFindings: artifact?.last_findings || null, + findingsStale: Boolean(artifact?.findings_stale), primaryChainEdgeIds: primaryChain?.edges?.map((e) => e.id) || [], siblingEdgeIds: siblingEdges.map((e) => e.id), dirty: false, @@ -816,6 +872,7 @@ export function buildPlanningArtifactFromDraft(draft, { lastFindings = undefined const findings = lastFindings !== undefined ? lastFindings : draft.lastFindings if (findings) artifact.last_findings = findings + artifact.findings_stale = Boolean(draft.findingsStale) return artifact } @@ -868,6 +925,431 @@ export function slotsToSlotAssignments(draft) { })) } +/** Mindestens ein Bibliotheks-Slot belegt. */ +export function draftHasLibrarySlotAssignments(draft) { + return slotsToSlotAssignments(draft).length >= 1 +} + +function normalizeCompareSlotTitle(title) { + return (title || '').trim().toLowerCase() +} + +function stepsByMajorIndex(steps) { + const out = new Map() + for (const step of steps || []) { + if (step?.roadmap_major_step_index == null || !Number.isFinite(Number(step.roadmap_major_step_index))) { + continue + } + out.set(Number(step.roadmap_major_step_index), step) + } + return out +} + +function buildProgressionSlotDiffs(baselineSteps, proposedSteps) { + const baseBy = stepsByMajorIndex(baselineSteps) + const propBy = stepsByMajorIndex(proposedSteps) + const indices = new Set([...baseBy.keys(), ...propBy.keys()]) + const diffs = [] + for (const midx of [...indices].sort((a, b) => a - b)) { + const base = baseBy.get(midx) || {} + const prop = propBy.get(midx) || {} + const baseId = base.exercise_id + const propId = prop.exercise_id + if (baseId != null && propId != null && Number(baseId) === Number(propId)) continue + const baseTitle = (base.title || '').trim() || null + const propTitle = (prop.title || '').trim() || null + diffs.push({ + roadmap_major_step_index: midx, + baseline_exercise_id: baseId != null ? Number(baseId) : null, + baseline_title: baseTitle, + proposed_exercise_id: propId != null ? Number(propId) : null, + proposed_title: propTitle, + baseline_slot_status: base.slot_status, + proposed_slot_status: prop.slot_status, + changed: baseId !== propId || baseTitle !== propTitle, + }) + } + return diffs +} + +function annotateCompareSlotDiffs(diffs) { + return (diffs || []).map((raw) => { + const bt = normalizeCompareSlotTitle(raw.baseline_title) + const pt = normalizeCompareSlotTitle(raw.proposed_title) + return { + ...raw, + trivial_id_swap: Boolean(bt && pt && bt === pt), + } + }) +} + +function actionableCompareSlotDiffs(diffs) { + return (diffs || []).filter((d) => !d.trivial_id_swap) +} + +/** fill = leerer Slot + Bibliotheks-Treffer; replace = bestehende Übung tauschen; gap_only = nur KI-Angebot. */ +export function compareDiffKind(diff) { + if (!diff || diff.trivial_id_swap) return 'skip' + const hasBase = diff.baseline_exercise_id != null + const hasProp = diff.proposed_exercise_id != null + if (!hasBase && hasProp) return 'fill' + if (hasBase && hasProp) return 'replace' + if (!hasBase && !hasProp) return 'gap_only' + if (hasBase && !hasProp) return 'replace' + return 'skip' +} + +export function qualityDeltaPercent(diff) { + const delta = diff?.quality_delta + if (delta == null || !Number.isFinite(Number(delta))) return null + return Math.round(Number(delta) * 100) +} + +export function annotateCompareDiffKinds(diffs) { + return (diffs || []).map((d) => ({ + ...d, + diff_kind: compareDiffKind(d), + })) +} + +export function slotFitScorePercent(score) { + if (score == null || !Number.isFinite(Number(score))) return null + return Math.round(Number(score) * 100) +} + +export function slotReviewSelectionKey(midx, kind = 'library') { + return `${Number(midx)}:${kind}` +} + +export function parseSlotReviewSelection(raw) { + if (raw == null) return null + const text = String(raw) + if (text.includes(':')) { + const [midxRaw, kind] = text.split(':') + const midx = Number(midxRaw) + if (!Number.isFinite(midx)) return null + return { midx, kind: kind === 'ai' ? 'ai' : 'library' } + } + const midx = Number(text) + if (!Number.isFinite(midx)) return null + return { midx, kind: 'library' } +} + +/** Alle Slot-Reviews aus Match-Antwort (je Slot eine Zeile). */ +export function compareSlotReviews(comparison) { + return Array.isArray(comparison?.slot_reviews) ? comparison.slot_reviews : [] +} + +/** Nur übernehmbare Verbesserungsvorschläge (Bibliothek oder KI-Angebot). */ +export function compareDiffsForDialog(comparison) { + const reviews = compareSlotReviews(comparison) + if (reviews.length > 0) return reviews + + const fromSuggestions = (comparison?.slot_suggestions || []).filter((s) => s?.improves_path) + if (fromSuggestions.length > 0) { + return fromSuggestions + .map((s) => ({ ...s, diff_kind: suggestionDiffKind(s) })) + .filter( + (d) => + d.proposed_exercise_id != null + || (d.suggestion_type === 'ai_gap' && d.gap_offer), + ) + } + if (Array.isArray(comparison?.slot_diffs_improving)) { + return comparison.slot_diffs_improving.filter( + (d) => d?.proposed_exercise_id != null && !d?.trivial_id_swap, + ) + } + const diffs = annotateCompareDiffKinds( + compareSlotDiffs(comparison, { actionableOnly: true }), + ) + return diffs.filter( + (d) => + (d.diff_kind === 'fill' || d.diff_kind === 'replace') + && d.proposed_exercise_id != null, + ) +} + +export function defaultSelectedCompareDiffs(comparison) { + const reviews = compareSlotReviews(comparison) + if (reviews.length > 0) { + return reviews + .filter((review) => review?.library_alternative?.auto_select) + .map((review) => slotReviewSelectionKey(review.roadmap_major_step_index, 'library')) + } + return compareDiffsForDialog(comparison).map((d) => Number(d.roadmap_major_step_index)) +} + +export function suggestionDiffKind(suggestion) { + if (!suggestion) return 'skip' + if (suggestion.suggestion_type === 'ai_gap') return 'ai_gap' + if (suggestion.baseline_exercise_id == null && suggestion.proposed_exercise_id != null) { + return 'fill' + } + if (suggestion.baseline_exercise_id != null && suggestion.proposed_exercise_id != null) { + return 'replace' + } + return 'skip' +} + +export function recommendedCompareDiffs(comparison) { + return compareDiffsForDialog(comparison) +} + +export function optionalReplaceCompareDiffs(comparison) { + return [] +} + +export function rejectedCompareDiffs(comparison) { + return Array.isArray(comparison?.slot_diffs_rejected) + ? comparison.slot_diffs_rejected + : [] +} + +export function gapOnlyCompareDiffs(comparison) { + return annotateCompareDiffKinds( + compareSlotDiffs(comparison, { actionableOnly: true }), + ).filter((d) => d.diff_kind === 'gap_only') +} + +function mergeGapFillOffersFromSteps(steps, offers) { + const merged = (offers || []).map((o) => ({ ...o })) + const seen = new Set(merged.map((o) => o.offer_id).filter(Boolean)) + for (const step of steps || []) { + const go = step?.gap_offer + if (!go || typeof go !== 'object') continue + if (go.offer_id && seen.has(go.offer_id)) continue + if (go.offer_id) seen.add(go.offer_id) + merged.push({ ...go }) + } + return merged +} + +/** + * Vergleich aus unified_slot_review oder kaskadierten Antworten (Evaluate → Match). + */ +export function buildProgressionComparePayload(baselineRes, proposedRes) { + if (proposedRes?.unified_slot_review) { + return buildUnifiedSlotReviewComparePayload(proposedRes, baselineRes) + } + + const baselineSteps = Array.isArray(baselineRes?.steps) ? baselineRes.steps : [] + const proposedSteps = Array.isArray(proposedRes?.steps) ? proposedRes.steps : [] + const baselineQa = baselineRes?.path_qa || null + const pipelineQa = proposedRes?.path_qa || null + const scoring = proposedRes?.slot_diff_scoring + const rawDiffs = annotateCompareDiffKinds( + annotateCompareSlotDiffs( + buildProgressionSlotDiffs(baselineSteps, proposedSteps), + ), + ) + const improvingDiffs = annotateCompareDiffKinds( + (scoring?.improvement_diffs || []).filter((d) => d?.proposed_exercise_id != null), + ) + const rejectedDiffs = annotateCompareDiffKinds(scoring?.rejected_diffs || []) + const dialogDiffs = improvingDiffs.length > 0 + ? improvingDiffs + : rawDiffs.filter( + (d) => + !d.trivial_id_swap + && (d.diff_kind === 'fill' || d.diff_kind === 'replace') + && d.proposed_exercise_id != null + && d.improves_path !== false, + ) + const actionableDiffs = dialogDiffs + const gapFillOffers = mergeGapFillOffersFromSteps( + proposedSteps, + proposedRes?.gap_fill_offers || [], + ) + const baselineScore = scoring?.baseline_quality_score ?? baselineQa?.quality_score + const proposedQa = baselineQa + + return { + ...proposedRes, + comparison_mode: true, + baseline_steps: baselineSteps, + baseline_path_qa: baselineQa, + proposed_steps: proposedSteps, + proposed_steps_pipeline: proposedSteps, + proposed_path_qa: proposedQa, + proposed_path_qa_pipeline: pipelineQa, + gap_fill_offers: gapFillOffers, + slot_diffs: rawDiffs, + slot_diffs_actionable: actionableDiffs, + slot_diffs_improving: improvingDiffs, + slot_diffs_rejected: rejectedDiffs, + slot_diffs_dialog: dialogDiffs, + slot_diffs_recommended: dialogDiffs, + slot_diff_count: dialogDiffs.length, + slot_diff_count_recommended: dialogDiffs.length, + slot_diff_count_rejected: rejectedDiffs.length, + slot_diff_count_including_trivial: rawDiffs.length, + slot_diffs_source: scoring ? 'incremental_scoring' : 'steps', + slot_diff_scoring: scoring, + baseline_quality_score: baselineScore, + path_qa: proposedQa, + steps: proposedSteps, + } +} + +/** Einheitlicher Match-Review (Bewertung + Slot-Vorschläge in einem Lauf). */ +export function buildUnifiedSlotReviewComparePayload(res, baselineRes = null) { + const baselineSteps = Array.isArray(baselineRes?.steps) + ? baselineRes.steps + : (Array.isArray(res?.baseline_steps) ? res.baseline_steps : (res?.steps || [])) + const baselineQa = baselineRes?.path_qa || res?.baseline_path_qa || res?.path_qa || null + const scoring = res?.slot_diff_scoring + const slotReviews = compareSlotReviews(res) + const suggestions = Array.isArray(res?.slot_suggestions) ? res.slot_suggestions : [] + const improving = suggestions.filter((s) => s?.improves_path || s?.auto_select) + const rejected = Array.isArray(scoring?.rejected_diffs) ? scoring.rejected_diffs : [] + const proposedSteps = improving.map(suggestionToApplyStep).filter(Boolean) + const gapFillOffers = Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : [] + const autoSelectCount = slotReviews.filter((r) => r?.library_alternative?.auto_select).length + + return { + ...res, + comparison_mode: true, + unified_slot_review: true, + baseline_steps: baselineSteps, + baseline_path_qa: baselineQa, + proposed_steps: proposedSteps, + proposed_steps_pipeline: proposedSteps, + proposed_path_qa: baselineQa, + proposed_path_qa_pipeline: null, + gap_fill_offers: gapFillOffers, + slot_reviews: slotReviews, + slot_suggestions: suggestions, + slot_diffs: improving, + slot_diffs_improving: improving, + slot_diffs_rejected: rejected, + slot_diffs_dialog: slotReviews.length > 0 ? slotReviews : improving, + slot_diffs_recommended: improving, + slot_diff_count: autoSelectCount || improving.length, + slot_diff_count_recommended: autoSelectCount || improving.length, + slot_diff_count_rejected: rejected.length, + slot_diffs_source: 'unified_slot_review', + slot_diff_scoring: scoring, + baseline_quality_score: scoring?.baseline_quality_score ?? baselineQa?.quality_score, + path_qa: baselineQa, + steps: baselineSteps, + } +} + +function suggestionToApplyStep(suggestion) { + if (!suggestion || suggestion.roadmap_major_step_index == null) return null + const midx = Number(suggestion.roadmap_major_step_index) + if (suggestion.suggestion_type === 'ai_gap' && suggestion.gap_offer) { + const offer = suggestion.gap_offer + return { + roadmap_major_step_index: midx, + exercise_id: null, + title: offer.title_hint || suggestion.proposed_title || `Slot ${midx + 1}`, + is_ai_proposal: true, + proposal_key: offer.offer_id || `roadmap-unfilled-${midx}`, + gap_offer: offer, + slot_status: 'ai_proposal', + } + } + if (suggestion.proposed_exercise_id == null) return null + return { + roadmap_major_step_index: midx, + exercise_id: suggestion.proposed_exercise_id, + title: suggestion.proposed_title, + slot_status: suggestion.proposed_slot_status || 'matched', + is_ai_proposal: false, + } +} + +/** Ausgewählte Slot-Vorschläge aus unified review übernehmen. */ +export function applySelectedSlotSuggestions(draft, comparison, selectedKeys) { + const reviews = compareSlotReviews(comparison) + if (reviews.length > 0) { + const selected = new Set((selectedKeys || []).map((x) => String(x))) + const steps = [] + for (const review of reviews) { + const midx = Number(review.roadmap_major_step_index) + const libKey = slotReviewSelectionKey(midx, 'library') + const aiKey = slotReviewSelectionKey(midx, 'ai') + if (selected.has(aiKey) && review.ai_alternative?.gap_offer) { + const offer = review.ai_alternative.gap_offer + steps.push({ + roadmap_major_step_index: midx, + exercise_id: null, + title: offer.title_hint || review.ai_alternative.title_hint || `Slot ${midx + 1}`, + is_ai_proposal: true, + proposal_key: offer.offer_id || `roadmap-unfilled-${midx}`, + gap_offer: offer, + slot_status: 'ai_proposal', + }) + continue + } + if (selected.has(libKey) && review.library_alternative?.exercise_id != null) { + steps.push({ + roadmap_major_step_index: midx, + exercise_id: review.library_alternative.exercise_id, + title: review.library_alternative.title, + slot_status: 'matched', + is_ai_proposal: false, + }) + } + } + if (steps.length) return applyMatchStepsToSlots(draft, steps) + } + + const selected = new Set( + (selectedKeys || []) + .map((x) => parseSlotReviewSelection(x)?.midx ?? Number(x)) + .filter((x) => Number.isFinite(x)), + ) + if (!selected.size) return draft + const legacySteps = (comparison?.slot_suggestions || []) + .filter((s) => selected.has(Number(s.roadmap_major_step_index))) + .map(suggestionToApplyStep) + .filter(Boolean) + if (!legacySteps.length) { + return applySelectedCompareSteps( + draft, + comparison?.proposed_steps || comparison?.steps, + selectedKeys, + ) + } + return applyMatchStepsToSlots(draft, legacySteps) +} + +/** Alle Slot-Diffs inkl. reiner ID-Tausche (gleicher Titel). */ +export function compareSlotDiffs(comparison, { actionableOnly = false } = {}) { + if (actionableOnly && Array.isArray(comparison?.slot_diffs_actionable)) { + return comparison.slot_diffs_actionable + } + return Array.isArray(comparison?.slot_diffs) ? comparison.slot_diffs : [] +} + +/** Inhaltliche Abweichungen (nicht nur gleicher Titel, andere ID). */ +export function compareResponseHasActionableSlotChanges(res) { + const count = res?.slot_diff_count + if (count != null) return Number(count) > 0 + return compareSlotDiffs(res, { actionableOnly: true }).length > 0 +} + +/** Diff-Einträge nur für Slots, die vorher schon eine Bibliotheks-Übung hatten. */ +export function curatedSlotDiffs(comparison, { actionableOnly = true } = {}) { + return compareSlotDiffs(comparison, { actionableOnly }).filter( + (d) => d?.baseline_exercise_id != null, + ) +} + +/** Vergleich würde eine bestehende Zuordnung inhaltlich ändern (Dialog bei Match). */ +export function compareResponseHasCuratedSlotChanges(res) { + return curatedSlotDiffs(res, { actionableOnly: true }).length > 0 +} + +export function compareResponseHadRematchWithoutActionableDiffs(res) { + if (compareResponseHasActionableSlotChanges(res)) return false + const rematch = res?.proposed_path_qa_pipeline?.rematch_log + return Array.isArray(rematch) && rematch.length > 0 +} + /** Alle Graph-Übungs-IDs für Retriever-Boost (Slots + Geschwister + gespeichertes Artefakt). */ export function draftRetrievalBoostExerciseIds(draft) { const ids = new Set() @@ -986,15 +1468,59 @@ export function applyMatchStepsToSlots(draft, apiSteps) { if (!step) { return base } + const mappedPrimary = mapStepToPrimary(step, slot) + const apiUnfilled = + step.exercise_id == null && + (step.slot_status === 'unfilled' || + step.roadmap_match_source === 'unfilled' || + mappedPrimary.kind === 'empty') + if ( + apiUnfilled && + slot.primary?.kind === 'library' && + slot.primary.exerciseId != null + ) { + return base + } return { ...base, - primary: mapStepToPrimary(step, slot), + primary: mappedPrimary, } }) return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true }) } +/** Vergleichs-Antwort: mindestens ein inhaltlicher Slot-Unterschied. */ +export function compareResponseHasSlotChanges(res) { + return compareResponseHasActionableSlotChanges(res) +} + +/** Nur ausgewählte Slots aus Optimierungs-Vorschlag übernehmen. */ +export function applySelectedCompareSteps(draft, proposedSteps, selectedMajorIndices) { + const selected = new Set( + (selectedMajorIndices || []) + .map((x) => Number(x)) + .filter((x) => Number.isFinite(x)), + ) + if (!selected.size) return draft + const stepByMajor = new Map() + for (const step of proposedSteps || []) { + if (step?.roadmap_major_step_index == null) continue + stepByMajor.set(Number(step.roadmap_major_step_index), step) + } + const nextSlots = (draft.slots || []).map((slot) => { + const midx = Number(slot.majorStepIndex) + if (!selected.has(midx)) { + return { ...slot, primary: { ...slot.primary }, siblings: [...(slot.siblings || [])] } + } + const step = stepByMajor.get(midx) + if (!step) return slot + const patched = applyMatchStepsToSlots({ ...draft, slots: [slot] }, [step]) + return patched.slots[0] + }) + return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true }) +} + /** Lücken-Angebote in leere Slots legen; Panel nur für verbleibende Lücken. */ export function applyGapOffersFromResponse(draft, res, { replaceOffTopicSlots = false } = {}) { let next = draft