diff --git a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md
index bbce56c..a8c1ccd 100644
--- a/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md
+++ b/.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md
@@ -493,23 +493,37 @@ Nach Pfad-Bildung:
---
-## 24. Phase F — Roadmap-first Progressionsgraph (0.8.204+) 🔄
+## 24. Phase F — Roadmap-first Progressionsgraph (0.8.204–217) ✅
**Entscheidung:** Progressionsgraph plant **vom Ziel rückwärts** (Roadmap → Stufenspezifikation → Bibliothek/KI). **Keine Gruppenanalyse** — die gehört zur Trainingsplanung.
+**Ist-Stand (vollständig):** `docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`
**Spec:** `working/PLANNING_PROGRESSION_ROADMAP_SPEC.md` · **Roadmap:** `docs/architecture/PLANNING_KI_ROADMAP.md`
| Teil | Modul / API |
|------|-------------|
| Pipeline | `planning_progression_roadmap.py` (Workflow-lite) |
-| API | `include_roadmap_preview`, `include_llm_roadmap`, `roadmap_first` auf `progression-path-suggest` |
-| Prompts | Migration **078/079** — Slugs in `ai_prompts` (Admin), **kein** Template im Python-Code |
-| UI | `ExerciseProgressionPathBuilder` — Roadmap-Box (Major Steps) |
+| Match | `planning_exercise_path_builder.py` — `roadmap_first`, `roadmap_override` |
+| Skills | `planning_skill_expectations.py` — pro Stufe + Pfad |
+| Gap-KI | `planning_exercise_form_context.py`, `planning_exercise_path_ai_fill.py` |
+| Persistenz | `planning_roadmap` JSONB (Migration **088**) |
+| API | `progression-path-suggest`, `PUT` Graph, `POST …/edges/sequence` |
+| Prompts | **078/079/087** — Slugs nur in `ai_prompts` |
+| UI | `ExerciseProgressionPathBuilder`, `ExerciseGapFillPrepModal` |
-**F3 (0.8.206):** `roadmap_first=true` (Default im UI) — Retrieval pro `stage_spec`/Major Step; `roadmap_unfilled` Gap-Angebote. Ohne Flag: retrieval-first wie bisher, Roadmap nur Preview.
+**Graph-Bias:** `progression_graph_id` bevorzugt **bestehende Nachfolger** ab Schritt 2 (Gewicht ~4–10 %), baut aber **keinen** Pfad aus vorhandenen Knoten — siehe Ist-Doku §5.
**Mitai Workflow-Engine:** bewusst **nicht** jetzt — Pipeline workflow-ready für spätere Anbindung.
---
## 25. Backlog (offen)
+
+Siehe priorisierte Liste in **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`** §10:
+
+1. UI-Wizard (Progressionsgraph) — separater Chat
+2. Graph-Erweiterungsmodus (Start ab Knoten)
+3. Trainingsplanung Phase G (Gruppenkontext, `planning_skill_expectations`)
+4. Kontext auf allen Pfad-Schritten in der UI
+5. Enrichment / Prompt-Feintuning
+6. Mitai Workflow-Engine (langfristig)
diff --git a/.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md b/.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md
index 6e0eadb..b6d7307 100644
--- a/.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md
+++ b/.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md
@@ -2,9 +2,11 @@
**Version:** 0.1
**Datum:** 2026-06-07
-**Status:** VERBINDLICHE ZIELARCHITEKTUR — Umsetzung gestartet (0.8.204+)
+**Status:** VERBINDLICHE ZIELARCHITEKTUR — **F0–F9 umgesetzt** (0.8.217)
**Geltungsbereich:** **Progressionsgraph** (`exercise_progression_graphs`) — **ohne** Gruppenanalyse
+**Ist-Stand (Module, API, Graph-Verhalten, Persistenz):** `docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`
+
**Bezüge:**
`working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md` · `working/AI_PLANNING_KI_MULTISTAGE_FORECAST.md` · `technical/AI_TRAINING_PLANNING_CONCEPT.md` · `technical/AI_PROMPT_TARGET_ARCHITECTURE.md` · `docs/architecture/PLANNING_KI_ROADMAP.md` · `docs/HANDOVER.md`
@@ -152,6 +154,7 @@ Jede Phase: `(ctx) → ctx`, Zwischenergebnisse in API-Response für **Human-in-
| Slug | Phase | Migration |
|------|-------|-----------|
+| `planning_progression_start_target` | Start/Ziel | **087** |
| `planning_progression_goal_analysis` | A | **078** |
| `planning_progression_roadmap` | B | **078** |
| `planning_progression_stage_spec` | C | **079** |
@@ -184,15 +187,23 @@ Jede Phase: `(ctx) → ctx`, Zwischenergebnisse in API-Response für **Human-in-
| ID | Inhalt | Status |
|----|--------|--------|
-| **F0** | Spec + Doku + `planning_progression_roadmap.py` Scaffold | 🔄 0.8.204 |
-| **F1** | `include_roadmap_preview` in API + deterministische A/B | 🔄 0.8.204 |
-| **F2** | LLM Phase A/B/C über `ai_prompts` (078/079), `include_llm_roadmap` | 🔄 0.8.205 |
-| **F3** | Retrieval aus `stage_specs` (roadmap_first) | ✅ 0.8.206 |
-| **F4** | UI Roadmap-Review | ✅ 0.8.207 |
-| **F5** | Trainingsplanung: eigene Pipeline + ggf. Workflow-Engine | 🔲 |
+| **F0** | Spec + Doku + `planning_progression_roadmap.py` Scaffold | ✅ 0.8.204 |
+| **F1** | `include_roadmap_preview` in API + deterministische A/B | ✅ 0.8.204 |
+| **F2** | LLM Phase A/B/C über `ai_prompts` (078/079), `include_llm_roadmap` | ✅ 0.8.205 |
+| **F3** | Retrieval aus `stage_specs` (roadmap_first) | ✅ 0.8.206–209 |
+| **F4** | UI Roadmap-Review + `roadmap_override` | ✅ 0.8.207 |
+| **F5** | Start/Ziel strukturiert + Prompt **087** + Zwei-Schritt-UI | ✅ 0.8.210–214 |
+| **F6** | Gap-Prep + `planning_context` an Übungs-KI | ✅ 0.8.212–214 |
+| **F7** | `planning_skill_expectations` | ✅ 0.8.215–216 |
+| **F8** | Editierbare `stage_specs` in UI | ✅ 0.8.216 |
+| **F9** | `planning_roadmap` JSONB (Migration **088**) | ✅ 0.8.217 |
+| **G** | Trainingsplanung: eigene Pipeline + Workflow-Engine | 🔲 |
+
+Details: `docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`
---
## 9. Changelog
+- **2026-05-22:** Ist-Stand F5–F9 dokumentiert; Verweis auf `PLANNING_PROGRESSION_GRAPH_KI.md`.
- **2026-06-07:** Erstfassung — Roadmap-first Entscheidung, Abgrenzung Graphen vs. Planung, Workflow-lite.
diff --git a/.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md b/.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md
new file mode 100644
index 0000000..3f5d0cf
--- /dev/null
+++ b/.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md
@@ -0,0 +1,81 @@
+# Progressionsgraph — Slot-Editor (Phase B)
+
+**Stand:** 2026-06-10 · **Status:** In Umsetzung
+
+## Ziel
+
+Ein Progressionsgraph = **ein linearer Hauptpfad** (Roadmap = strukturgebend). Jeder **Major Step** ist ein **Slot** mit:
+
+- **primary** — Hauptübung des Slots (Pfadknoten)
+- **siblings** — 0..n Schwestern (gleiche Stufe, `edge_type: sibling`)
+
+KI-Entwürfe und Bibliotheksübungen leben **im selben Slot-Modell**, ohne sofortige Übungsanlage.
+
+## Slot-Zustände (`kind`)
+
+| kind | Bedeutung |
+|------|-----------|
+| `empty` | Noch keine Übung |
+| `library` | `exercise_id` (+ optional `variant_id`) |
+| `proposal` | KI-Entwurf (`ai_suggestion`, kein `exercise_id`) |
+
+## Kanten
+
+- `primary(n) → primary(n+1)` — `next_exercise` (nur befüllte Primärkette, lückenlos verbunden)
+- `primary ↔ sibling` — `sibling` (pro Slot)
+
+Leere Slots in der Roadmap sind erlaubt; Kanten nur zwischen aufeinanderfolgenden befüllten Primär-Slots.
+
+## Editor-Zustand (`ProgressionGraphDraft`)
+
+```ts
+{
+ goalQuery, startSituation, targetState, roadmapNotes, maxSteps,
+ majorSteps: MajorStep[],
+ slots: Slot[], // index = major_step_index
+ pathSkillExpectations?,
+ lastFindings?, // path_qa-Snapshot
+ dirty: boolean,
+}
+```
+
+**Hydration:** `planning_roadmap` + Kanten → Slots; `slot_contents[]` für Entwürfe; Primärkette aus `next_exercise`.
+
+**Speichern:** Batch-Delete bestehender Pfad-/Schwester-Kanten → `edges/sequence` (Primärkette) → einzelne `sibling`-Kanten → `PUT`/`sequence` mit Artefakt inkl. `slot_contents`, optional `last_findings`.
+
+## Findings-Panel
+
+Nutzt `path_qa` (`overall_ok`, `quality_score`, `issues`, `recommendations`, `gap_fill_offers`, …).
+
+**API:** `POST /api/planning/progression-path-suggest` mit `evaluate_only: true` und `evaluate_steps[]` — QA ohne Re-Match.
+
+Persistenz: `planning_roadmap.last_findings`.
+
+## Artefakt-Erweiterung (`GraphPlanningRoadmapArtifact`)
+
+Zusätzlich optional:
+
+- `slot_contents[]` — `{ major_step_index, primary, siblings[] }`
+- `last_findings` — letzter `path_qa`-Snapshot
+
+## UI (konsolidiert)
+
+- **Eine Oberfläche:** `ExerciseProgressionGraphPanel` embeddet `ProgressionGraphEditor` (Slots + Findings)
+- Kein separater Slot-Editor, kein 4-Schritt-KI-Wizard, kein `ProgressionChainEditor` im Panel
+- Route `/progression-graphs/:id` → Redirect nach `/exercises` (Deep-Link wählt Graph)
+- **Phase C:** Übersicht mit Kacheln (Name, Start, Ziel)
+
+## Ersetzt (Legacy, nicht mehr im Panel)
+
+- `ExerciseProgressionPathBuilder` · `ProgressionChainEditor` — Code bleibt vorerst, nicht eingebunden
+
+## Implementierungsreihenfolge
+
+| ID | Inhalt |
+|----|--------|
+| B.0 | Draft + Laden/Speichern Slots ↔ Kanten |
+| B.1 | Slot-Karten, Bibliothek + Entwurf |
+| B.2 | Findings-Panel + `evaluate_only` |
+| B.3 | Entwürfe im Artefakt + „Übung anlegen“ |
+| B.4 | Route + Panel vereinfachen |
+| B.5 | `last_findings` + Phase-C-Vorbereitung |
diff --git a/CLAUDE.md b/CLAUDE.md
index f8d36f2..03e9f8a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -18,7 +18,7 @@
> | Architektur-Zielbild, Refaktor-Roadmap, verbindliche Shinkan-Regeln | **`docs/architecture/README.md`** |
> | Performance-Baseline (Phase 0) | **`docs/architecture/BASELINE_SNAPSHOT.md`** |
> | KI-Prompt-System — Zielarchitektur | `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` |
-> | Planungs-KI Progressions-Roadmap (Phase F) | **`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`** · **`docs/architecture/PLANNING_KI_ROADMAP.md`** |
+> | Planungs-KI Progressionsgraph (Ist-Stand) | **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`** · Spec **`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`** · Roadmap **`docs/architecture/PLANNING_KI_ROADMAP.md`** |
## Projekt-Übersicht
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 41de5da..eb64367 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -2,14 +2,16 @@ FROM python:3.12-slim
WORKDIR /app
-# Install system dependencies
+# Install system dependencies (tzdata für zoneinfo/ZoneInfo unter Linux)
RUN apt-get update && apt-get install -y \
postgresql-client \
+ tzdata \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install dependencies
COPY requirements.txt .
-RUN pip install --no-cache-dir -r requirements.txt
+ENV PIP_DEFAULT_TIMEOUT=120
+RUN pip install --no-cache-dir --retries 5 -r requirements.txt
# Copy application code
COPY . .
diff --git a/backend/migrations/088_exercise_progression_graph_planning_roadmap.sql b/backend/migrations/088_exercise_progression_graph_planning_roadmap.sql
new file mode 100644
index 0000000..12eb27a
--- /dev/null
+++ b/backend/migrations/088_exercise_progression_graph_planning_roadmap.sql
@@ -0,0 +1,8 @@
+-- Migration 088: Planungs-Roadmap-Artefakt am Progressionsgraph (JSONB, optional).
+-- Speichert Ziel, Start/Ziel, progression_roadmap + stage_specs für Wiederaufnahme der KI-Planung.
+
+ALTER TABLE exercise_progression_graphs
+ ADD COLUMN IF NOT EXISTS planning_roadmap JSONB;
+
+COMMENT ON COLUMN exercise_progression_graphs.planning_roadmap IS
+ 'Optionales Planungs-Artefakt (goal_query, resolved_structured, progression_roadmap, stage_specs) — Schema v1 im App-Code.';
diff --git a/backend/migrations/089_ai_prompt_planning_intent_enrichment.sql b/backend/migrations/089_ai_prompt_planning_intent_enrichment.sql
new file mode 100644
index 0000000..7ebcff7
--- /dev/null
+++ b/backend/migrations/089_ai_prompt_planning_intent_enrichment.sql
@@ -0,0 +1,67 @@
+-- Migration 089: Planungs-Intent — Zielanalyse + Stufenspecs (anti_patterns, success_criteria)
+
+UPDATE ai_prompts SET
+ description = 'Phase A: Ist-/Soll, Erfolgskriterien und explizite Ausschlüsse (ohne Gruppenkontext).',
+ template = $t$Du bist Assistent für Kampfsport-Trainer und analysierst eine Anfrage für einen Progressionsgraphen.
+
+Anfrage: {{goal_query}}
+Semantic Brief: {{semantic_brief_json}}
+
+Wichtig:
+- Keine Gruppenanalyse — nur didaktischer Pfad für die Technik/das Thema.
+- Explizite Negationen aus der Anfrage (ohne/kein/nicht …) in constraints.excluded_themes übernehmen — nicht raten.
+- success_criteria: messbar, für späteres Übungs-Matching (Titel + Kurzbeschreibung + Übungsziel).
+
+Antworte NUR mit JSON:
+{
+ "primary_topic": "Hauptthema",
+ "start_assumption": "Voraussetzungen für den Einstieg",
+ "target_state": "Konkreter Zielzustand der Progression",
+ "success_criteria": ["messbare Kriterien entlang des Pfads"],
+ "constraints": {
+ "partner_required": false,
+ "excluded_themes": ["wörtliche Negationen, z. B. keine Kumite-Anwendung"],
+ "trainer_notes": "optional: Fokus aus Ergänzungen"
+ }
+}$t$,
+ default_template = template
+WHERE slug = 'planning_progression_goal_analysis';
+
+UPDATE ai_prompts SET
+ description = 'Phase C: Belastung, Übungstyp, Erfolgskriterien und anti_patterns je Major Step — für Retrieval-Matching.',
+ template = $t$Du bist Assistent für Kampfsport-Trainer und spezifizierst didaktische Stufen eines Progressionsgraphen.
+
+Anfrage: {{goal_query}}
+Zielanalyse: {{goal_analysis_json}}
+Major Steps: {{major_steps_json}}
+Planungs-Intent (Pfadweite Regeln): {{intent_context_json}}
+Semantic Brief: {{semantic_brief_json}}
+
+Aufgabe je Major Step — Felder für automatisches Übungs-Matching (nicht nur Titel):
+- learning_goal: messbares Stufen-Lernziel (was die Übung bringen soll)
+- load_profile: z. B. koordination, präzision, kraft, athletik
+- exercise_type: kihon_einzel | partner_drill | kombination | kraft_auxiliary
+- success_criteria: 2–4 prüfbare Kriterien an Kurzbeschreibung + Übungsziel (nicht nur Technikname im Titel)
+- anti_patterns: 2–5 Dinge, die für diese Stufe unpassend sind
+
+Regeln:
+1. Jede explicit_exclusions / excluded_themes aus intent_context und Zielanalyse MUSS in anti_patterns jeder Stufe vorkommen (umformuliert ok).
+2. Keine neuen Ausschlüsse erfinden, die nicht in Anfrage/Intent/Zielanalyse stehen.
+3. success_criteria Pfad-weit + stufenspezifisch kombinieren.
+4. partner_drill nur wenn Partner/Kumite nicht ausgeschlossen ist.
+
+Antworte NUR mit JSON:
+{
+ "stage_specs": [
+ {
+ "major_step_index": 0,
+ "learning_goal": "…",
+ "load_profile": ["koordination"],
+ "exercise_type": "kihon_einzel",
+ "success_criteria": ["…"],
+ "anti_patterns": ["…"]
+ }
+ ]
+}$t$,
+ default_template = template
+WHERE slug = 'planning_progression_stage_spec';
diff --git a/backend/migrations/090_ai_prompt_stage_transition_states.sql b/backend/migrations/090_ai_prompt_stage_transition_states.sql
new file mode 100644
index 0000000..1a04b80
--- /dev/null
+++ b/backend/migrations/090_ai_prompt_stage_transition_states.sql
@@ -0,0 +1,43 @@
+-- Migration 090: Stufenspecs — start_state / target_state pro Major Step (Soll-Verkettung)
+
+UPDATE ai_prompts SET
+ description = 'Phase C: Stufenspezifikation inkl. Soll-Start und Stufen-Ziel je Major Step.',
+ template = $t$Du bist Assistent für Kampfsport-Trainer und spezifizierst didaktische Stufen eines Progressionsgraphen.
+
+Anfrage: {{goal_query}}
+Zielanalyse: {{goal_analysis_json}}
+Major Steps: {{major_steps_json}}
+Planungs-Intent (Pfadweite Regeln): {{intent_context_json}}
+Semantic Brief: {{semantic_brief_json}}
+
+Jede Stufe ist ein Übergang im Gesamtpfad:
+- start_state: Soll-Zustand zu Beginn (= Ziel der vorherigen Stufe; Stufe 0 = Pfad-Start)
+- target_state: Zielzustand nach dieser Stufe (= Soll für die nächste Stufe)
+- learning_goal: messbares Lernziel der Übungssuche (was die Übung bringen soll)
+
+Felder je Major Step:
+- load_profile, exercise_type, success_criteria, anti_patterns (wie bisher)
+
+Regeln:
+1. start_state/target_state aus Zielanalyse und Major Steps ableiten — konsistente Kette.
+2. explicit_exclusions aus intent_context in anti_patterns jeder Stufe.
+3. success_criteria: prüfbar an Kurzbeschreibung + Übungsziel.
+4. Keine erfundenen Ausschlüsse.
+
+Antworte NUR mit JSON:
+{
+ "stage_specs": [
+ {
+ "major_step_index": 0,
+ "start_state": "…",
+ "target_state": "…",
+ "learning_goal": "…",
+ "load_profile": ["koordination"],
+ "exercise_type": "kihon_einzel",
+ "success_criteria": ["…"],
+ "anti_patterns": ["…"]
+ }
+ ]
+}$t$,
+ default_template = template
+WHERE slug = 'planning_progression_stage_spec';
diff --git a/backend/planning_exercise_form_context.py b/backend/planning_exercise_form_context.py
index 3394261..63c9d04 100644
--- a/backend/planning_exercise_form_context.py
+++ b/backend/planning_exercise_form_context.py
@@ -6,7 +6,7 @@ Wird als ``planning_context_json`` in Übungs-Prompts (summary, skills, instruct
from __future__ import annotations
import json
-from typing import Any, Dict, List, Mapping, Optional
+from typing import Any, Dict, List, Mapping, Optional, Sequence
_MAX_JSON_CHARS = 6000
_MAX_STRING = 800
@@ -85,6 +85,163 @@ def planning_context_prompt_variables(
}
+def _major_index_from_step(step: Mapping[str, Any]) -> Optional[int]:
+ for key in ("roadmap_major_step_index", "major_step_index"):
+ raw = step.get(key)
+ if raw is None:
+ continue
+ try:
+ return int(raw)
+ except (TypeError, ValueError):
+ continue
+ return None
+
+
+def prior_path_steps_before_major(
+ steps: Sequence[Mapping[str, Any]],
+ major_idx: int,
+) -> List[Dict[str, Any]]:
+ """Pfadschritte mit kleinerem roadmap_major_step_index, sortiert."""
+ prior: List[Dict[str, Any]] = []
+ for step in steps:
+ mi = _major_index_from_step(step)
+ if mi is not None and mi < major_idx:
+ prior.append(dict(step))
+ prior.sort(key=lambda s: _major_index_from_step(s) or 0)
+ return prior
+
+
+def _step_display_fields(step: Mapping[str, Any]) -> Dict[str, Any]:
+ title = _trim_str(
+ step.get("title") or step.get("exercise_title"),
+ limit=200,
+ )
+ learning_goal = _trim_str(
+ step.get("roadmap_learning_goal") or step.get("learning_goal"),
+ limit=500,
+ )
+ summary = _trim_str(step.get("summary"), limit=400)
+ start_state = _trim_str(step.get("roadmap_start_state") or step.get("start_state"))
+ target_state = _trim_str(step.get("roadmap_target_state") or step.get("target_state"))
+ phase = _trim_str(step.get("roadmap_phase") or step.get("phase"))
+ criteria_raw = step.get("stage_success_criteria") or step.get("success_criteria") or []
+ criteria = [
+ t
+ for x in criteria_raw
+ if (t := _trim_str(x, limit=200))
+ ][:4]
+ out: Dict[str, Any] = {
+ "title": title,
+ "learning_goal": learning_goal,
+ "summary": summary,
+ "start_state": start_state,
+ "target_state": target_state,
+ "phase": phase,
+ "success_criteria": criteria or None,
+ "major_step_index": _major_index_from_step(step),
+ }
+ return {k: v for k, v in out.items() if v is not None and v != "" and v != []}
+
+
+def build_progression_entry_state(
+ *,
+ major_step_index: Optional[int] = None,
+ prior_steps: Sequence[Mapping[str, Any]] = (),
+ start_situation: Optional[str] = None,
+ current_stage_start: Optional[str] = None,
+) -> Dict[str, Any]:
+ """
+ Eingangszustand für eine Roadmap-Stufe: erreichte Voraussetzungen aus Vorstufen.
+ """
+ prior_compact = [_step_display_fields(s) for s in prior_steps]
+ prior_compact = [
+ p
+ for p in prior_compact
+ if any(p.get(k) for k in ("title", "learning_goal", "summary", "success_criteria"))
+ ]
+
+ achievements: List[str] = []
+ detail_lines: List[str] = []
+ for p in prior_compact:
+ if p.get("success_criteria"):
+ achievements.extend(p["success_criteria"])
+ elif p.get("learning_goal"):
+ achievements.append(p["learning_goal"])
+
+ label_parts: List[str] = []
+ if p.get("major_step_index") is not None:
+ label_parts.append(f"Stufe {int(p['major_step_index']) + 1}")
+ if p.get("phase"):
+ label_parts.append(f"({p['phase']})")
+ if p.get("title"):
+ label_parts.append(f"„{p['title']}\"")
+ prefix = " ".join(label_parts) if label_parts else "Vorstufe"
+ achieved = ""
+ if p.get("target_state"):
+ achieved = p["target_state"]
+ elif p.get("success_criteria"):
+ achieved = "; ".join(p["success_criteria"])
+ elif p.get("learning_goal"):
+ achieved = p["learning_goal"]
+ elif p.get("summary"):
+ achieved = p["summary"]
+ if achieved:
+ detail_lines.append(f"{prefix}: erreicht — {achieved}")
+
+ immediate_entry: Optional[str] = _trim_str(current_stage_start)
+ if not immediate_entry and prior_compact:
+ immediate = prior_compact[-1]
+ if immediate.get("target_state"):
+ immediate_entry = immediate["target_state"]
+ elif immediate.get("success_criteria"):
+ immediate_entry = "; ".join(immediate["success_criteria"])
+ elif immediate.get("learning_goal"):
+ immediate_entry = immediate["learning_goal"]
+ elif immediate.get("summary"):
+ immediate_entry = immediate["summary"]
+ elif not immediate_entry and start_situation:
+ immediate_entry = start_situation
+
+ entry_state = immediate_entry or start_situation
+ if prior_compact and start_situation and not immediate_entry:
+ detail_lines.insert(0, f"Ausgangsbasis Pfad: {start_situation}")
+
+ out: Dict[str, Any] = {}
+ if entry_state:
+ out["entry_state"] = _trim_str(entry_state, limit=1200)
+ if detail_lines:
+ out["entry_state_detail"] = _trim_str("\n".join(detail_lines), limit=2000)
+ if prior_compact:
+ out["prior_steps"] = prior_compact[:6]
+ if achievements:
+ out["prior_achievements"] = list(dict.fromkeys(achievements))[:8]
+ return out
+
+
+def enrich_gap_snapshot_with_entry_state(
+ snapshot: Mapping[str, Any],
+ *,
+ steps: Sequence[Mapping[str, Any]],
+ major_step_index: Optional[int],
+) -> Dict[str, Any]:
+ snap = dict(snapshot)
+ if major_step_index is None:
+ return snap
+ try:
+ mi = int(major_step_index)
+ except (TypeError, ValueError):
+ return snap
+ prior = prior_path_steps_before_major(steps, mi)
+ entry = build_progression_entry_state(
+ major_step_index=mi,
+ prior_steps=prior,
+ start_situation=snap.get("start_situation"),
+ current_stage_start=snap.get("stage_start_state"),
+ )
+ snap.update(entry)
+ return snap
+
+
def build_progression_gap_snapshot(
*,
goal_analysis: Optional[Mapping[str, Any]] = None,
@@ -141,6 +298,8 @@ def build_progression_gap_snapshot(
"stage_learning_goal": _trim_str(
spec.get("learning_goal"), limit=1200
),
+ "stage_start_state": _trim_str(spec.get("start_state")),
+ "stage_target_state": _trim_str(spec.get("target_state")),
"stage_phase": _trim_str(spec.get("phase")),
"stage_exercise_type": _trim_str(spec.get("exercise_type")),
"stage_load_profile": load_profile or None,
@@ -160,6 +319,7 @@ def build_progression_path_gap_planning_context(
offer: Optional[Mapping[str, Any]] = None,
neighbor_before: Optional[Mapping[str, Any]] = None,
neighbor_after: Optional[Mapping[str, Any]] = None,
+ prior_path_steps: Optional[Sequence[Mapping[str, Any]]] = None,
path_step_count: int = 0,
major_step_count: Optional[int] = None,
roadmap_phase: Optional[str] = None,
@@ -168,6 +328,8 @@ def build_progression_path_gap_planning_context(
resolved_structured: Optional[Mapping[str, Any]] = None,
stage_spec: Optional[Mapping[str, Any]] = None,
semantic_brief: Optional[Mapping[str, Any]] = None,
+ stage_learning_goal_override: Optional[str] = None,
+ gap_trainer_supplements: Optional[str] = None,
) -> Dict[str, Any]:
"""Kontext für KI-Neuanlage aus Progressionsgraph-Pfad-Lücke."""
offer = offer or {}
@@ -205,12 +367,28 @@ def build_progression_path_gap_planning_context(
semantic_brief=semantic_brief,
)
ctx.update(snap)
+ if major_idx is not None and prior_path_steps:
+ ctx.update(
+ build_progression_entry_state(
+ major_step_index=major_idx,
+ prior_steps=list(prior_path_steps),
+ start_situation=ctx.get("start_situation"),
+ )
+ )
+ if stage_learning_goal_override and stage_learning_goal_override.strip():
+ ctx["stage_learning_goal"] = _trim_str(stage_learning_goal_override, limit=1200)
+ ctx["roadmap_learning_goal"] = ctx["stage_learning_goal"]
+ if gap_trainer_supplements and gap_trainer_supplements.strip():
+ ctx["gap_trainer_supplements"] = _trim_str(gap_trainer_supplements, limit=2000)
return sanitize_planning_context_for_ai(ctx)
__all__ = [
+ "build_progression_entry_state",
"build_progression_gap_snapshot",
"build_progression_path_gap_planning_context",
+ "enrich_gap_snapshot_with_entry_state",
+ "prior_path_steps_before_major",
"compact_planning_context_json",
"planning_context_prompt_variables",
"sanitize_planning_context_for_ai",
diff --git a/backend/planning_exercise_path_ai_fill.py b/backend/planning_exercise_path_ai_fill.py
index 25e28da..81373bf 100644
--- a/backend/planning_exercise_path_ai_fill.py
+++ b/backend/planning_exercise_path_ai_fill.py
@@ -12,12 +12,174 @@ from ai_prompt_job import run_exercise_form_ai_suggestion
from exercise_ai import strip_html_to_plain
from planning_exercise_path_qa import find_step_pair_index
-from planning_exercise_form_context import build_progression_gap_snapshot
+from planning_exercise_form_context import (
+ build_progression_entry_state,
+ build_progression_gap_snapshot,
+ enrich_gap_snapshot_with_entry_state,
+ prior_path_steps_before_major,
+)
from planning_exercise_semantics import PlanningSemanticBrief, brief_to_summary_dict
_logger = logging.getLogger("shinkan.planning_exercise_path_ai_fill")
+def _resolve_neighbor_steps_by_major_index(
+ steps: Sequence[Mapping[str, Any]],
+ major_idx: int,
+) -> Tuple[Optional[Mapping[str, Any]], Optional[Mapping[str, Any]]]:
+ """Nachbarn im Pfad anhand roadmap_major_step_index (nicht Array-Position)."""
+ step_before: Optional[Mapping[str, Any]] = None
+ step_after: Optional[Mapping[str, Any]] = None
+ for step in steps:
+ raw = step.get("roadmap_major_step_index")
+ if raw is None:
+ continue
+ try:
+ mi = int(raw)
+ except (TypeError, ValueError):
+ continue
+ if mi < major_idx:
+ step_before = step
+ elif mi > major_idx and step_after is None:
+ step_after = step
+ return step_before, step_after
+
+
+def _build_stage_ai_context(
+ *,
+ goal_query: str,
+ brief: PlanningSemanticBrief,
+ spec: Mapping[str, Any],
+ step_before: Optional[Mapping[str, Any]] = None,
+ step_after: Optional[Mapping[str, Any]] = None,
+ prior_steps: Optional[Sequence[Mapping[str, Any]]] = None,
+ start_situation: Optional[str] = None,
+) -> ExerciseFormAiPromptContext:
+ """KI-Kontext für unbesetzte Roadmap-Stufe (keine Brücke zwischen falschen Array-Indizes)."""
+ gap = dict(spec.get("gap") or {})
+ phase = spec.get("phase") or gap.get("expected_phase") or "vertiefung"
+ topic = (brief.primary_topic or "Technik").strip()
+ learning_goal = (
+ gap.get("learning_goal")
+ or spec.get("title_hint")
+ or spec.get("sketch")
+ or ""
+ ).strip()
+ title = (spec.get("title_hint") or f"{topic} — {phase}").strip()[:280]
+ major_idx = spec.get("roadmap_major_step_index")
+ entry: Dict[str, Any] = {}
+ if prior_steps is not None and major_idx is not None:
+ entry = build_progression_entry_state(
+ major_step_index=major_idx,
+ prior_steps=prior_steps,
+ start_situation=start_situation,
+ )
+
+ goal_parts = [
+ f"Planungsziel: {goal_query}",
+ f"Roadmap-Stufe ({phase}): {learning_goal}",
+ "Erstelle eine Übung, die dieses Stufen-Lernziel erfüllt — keine generische Brücken-Übung.",
+ ]
+ if entry.get("entry_state"):
+ goal_parts.append(
+ f"Eingangszustand (erreichte Voraussetzungen): {entry['entry_state']}"
+ )
+ if entry.get("entry_state_detail") and entry.get("entry_state_detail") != entry.get("entry_state"):
+ goal_parts.append(f"Bisheriger Pfad:\n{entry['entry_state_detail']}")
+ if step_before:
+ goal_parts.append(
+ f"Vorherige Stufe im Pfad: „{(step_before.get('title') or '').strip()}“"
+ )
+ if step_after:
+ goal_parts.append(
+ f"Nächste Stufe im Pfad: „{(step_after.get('title') or '').strip()}“"
+ )
+ sketch = (spec.get("sketch") or "").strip()
+ if sketch and sketch != learning_goal:
+ goal_parts.extend(["", f"Kontext: {sketch}"])
+ goal = "\n".join(goal_parts)
+
+ focus_hint = topic if brief.topic_type == "technique" else None
+ if brief.must_phrases:
+ focus_hint = ", ".join(brief.must_phrases[:2])
+
+ return ExerciseFormAiPromptContext(
+ title=title[:280],
+ goal=goal[:8000],
+ execution=None,
+ focus_hint=focus_hint,
+ )
+
+
+def try_suggest_ai_stage_step(
+ cur,
+ *,
+ goal_query: str,
+ brief: PlanningSemanticBrief,
+ spec: Mapping[str, Any],
+ steps: Sequence[Mapping[str, Any]],
+) -> Optional[Dict[str, Any]]:
+ """KI-Vorschlag für leere Roadmap-Stufe."""
+ major_idx = spec.get("roadmap_major_step_index")
+ if major_idx is None:
+ return None
+ try:
+ mi = int(major_idx)
+ except (TypeError, ValueError):
+ return None
+ step_before, step_after = _resolve_neighbor_steps_by_major_index(steps, mi)
+ prior_steps = prior_path_steps_before_major(steps, mi)
+ gap = dict(spec.get("gap") or {})
+ if not gap.get("expected_phase"):
+ gap["expected_phase"] = spec.get("phase") or "vertiefung"
+ gap["roadmap_major_step_index"] = mi
+ if not gap.get("learning_goal"):
+ gap["learning_goal"] = spec.get("title_hint") or spec.get("sketch")
+
+ ctx = _build_stage_ai_context(
+ goal_query=goal_query,
+ brief=brief,
+ spec=spec,
+ step_before=step_before,
+ step_after=step_after,
+ prior_steps=prior_steps,
+ )
+ try:
+ ai_payload = run_exercise_form_ai_suggestion(cur, ctx=ctx)
+ except Exception:
+ _logger.exception("roadmap_unfilled AI suggest failed")
+ return None
+ if not ai_payload:
+ return None
+
+ summary_text = ""
+ summary_obj = ai_payload.get("summary")
+ if isinstance(summary_obj, dict):
+ summary_text = str(summary_obj.get("text") or "").strip()
+ elif isinstance(summary_obj, str):
+ summary_text = summary_obj.strip()
+
+ proposal_key = f"ai-{uuid.uuid4().hex[:10]}"
+ title = (ctx.title or spec.get("title_hint") or "KI-Vorschlag").strip()
+ return {
+ "exercise_id": None,
+ "proposal_key": proposal_key,
+ "variant_id": None,
+ "title": title,
+ "summary": summary_text or None,
+ "score": None,
+ "semantic_score": None,
+ "reasons": ["KI-Neuanlage für Roadmap-Stufe ohne Bibliothekstreffer"],
+ "variants": [],
+ "is_bridge": False,
+ "is_ai_proposal": True,
+ "ai_suggestion": dict(ai_payload),
+ "roadmap_major_step_index": mi,
+ "roadmap_phase": gap.get("expected_phase"),
+ "roadmap_learning_goal": gap.get("learning_goal"),
+ }
+
+
def _build_gap_ai_context(
*,
goal_query: str,
@@ -175,6 +337,18 @@ def _spec_dedupe_key(spec: Mapping[str, Any]) -> Tuple[Any, ...]:
)
+def _step_neighbors_at_index(
+ steps: Sequence[Mapping[str, Any]],
+ idx: int,
+) -> Tuple[Optional[Mapping[str, Any]], Optional[Mapping[str, Any]]]:
+ """Vorheriger/nächster Pfadschritt ohne IndexError (Rand-Slots, leere Stufen)."""
+ if idx < 0 or idx >= len(steps):
+ return None, None
+ step_a = steps[idx - 1] if idx > 0 else None
+ step_b = steps[idx + 1] if idx + 1 < len(steps) else None
+ return step_a, step_b
+
+
def collect_gap_fill_specs(
*,
steps: Sequence[Mapping[str, Any]],
@@ -202,8 +376,10 @@ def collect_gap_fill_specs(
int(gap["from_exercise_id"]),
int(gap["to_exercise_id"]),
)
- if idx is None:
+ if idx is None or idx + 1 >= len(steps):
continue
+ step_a = steps[idx]
+ step_b = steps[idx + 1]
phase = gap.get("expected_phase") or "vertiefung"
add(
{
@@ -215,25 +391,46 @@ def collect_gap_fill_specs(
"sketch": _default_sketch(
goal_query=goal_query,
brief=brief,
- step_a=steps[idx],
- step_b=steps[idx + 1],
+ step_a=step_a,
+ step_b=step_b,
phase=str(phase),
rationale="Bibliothek enthält keine passende Brücke.",
),
- "rationale": "Lücke zwischen benachbarten Schritten — keine passende Bibliotheks-Übung.",
+ "rationale": "Lücke zwischen benachbaren Schritten — keine passende Bibliotheks-Übung.",
}
)
for ot in off_topic_steps:
- idx = int(ot.get("step_index") or 0)
- if idx <= 0 or idx >= len(steps) - 1:
+ major_idx = ot.get("roadmap_major_step_index")
+ idx: Optional[int] = None
+ if major_idx is not None:
+ try:
+ mi = int(major_idx)
+ except (TypeError, ValueError):
+ mi = None
+ if mi is not None:
+ idx = next(
+ (
+ i
+ for i, s in enumerate(steps)
+ if s.get("roadmap_major_step_index") is not None
+ and int(s["roadmap_major_step_index"]) == mi
+ ),
+ None,
+ )
+ if idx is None:
+ idx = int(ot.get("step_index") or 0)
+ if idx < 0 or idx >= len(steps):
continue
+ step_a, step_b = _step_neighbors_at_index(steps, idx)
phase = ot.get("expected_phase") or "vertiefung"
+ insert_after = max(idx - 1, -1)
add(
{
"source": "off_topic",
- "insert_after_index": idx - 1,
+ "insert_after_index": insert_after,
"replace_step_index": idx,
+ "roadmap_major_step_index": major_idx,
"gap": {
"expected_phase": phase,
"off_topic_title": ot.get("title"),
@@ -244,8 +441,8 @@ def collect_gap_fill_specs(
"sketch": _default_sketch(
goal_query=goal_query,
brief=brief,
- step_a=steps[idx - 1],
- step_b=steps[idx + 1],
+ step_a=step_a,
+ step_b=step_b,
phase=str(phase),
rationale=f"Ersetzt themenfremden Schritt „{ot.get('title')}“.",
),
@@ -282,8 +479,16 @@ def build_gap_fill_goal_text(
f"Planungsziel (gesamter Pfad): {goal_query}",
f"Hauptthema: {snap.get('primary_topic') or topic}",
]
- if snap.get("start_situation"):
+ if snap.get("entry_state"):
+ parts.append(
+ f"Eingangszustand (erreichte Voraussetzungen aus Vorstufen): {snap['entry_state']}"
+ )
+ if snap.get("entry_state_detail") and snap.get("entry_state_detail") != snap.get("entry_state"):
+ parts.append(f"Bisheriger Pfad:\n{snap['entry_state_detail']}")
+ if snap.get("start_situation") and not snap.get("entry_state"):
parts.append(f"Voraussetzung / Ausgangslage (Progression): {snap['start_situation']}")
+ elif snap.get("start_situation") and snap.get("prior_steps"):
+ parts.append(f"Ausgangsbasis des gesamten Pfads: {snap['start_situation']}")
if snap.get("target_state"):
parts.append(f"Gesamtziel der Progression: {snap['target_state']}")
if snap.get("roadmap_notes"):
@@ -291,13 +496,20 @@ def build_gap_fill_goal_text(
stage_goal = snap.get("stage_learning_goal") or spec.get("title_hint")
if stage_goal:
parts.append(f"Lernziel dieser Roadmap-Stufe: {stage_goal}")
- parts.extend(
- [
- f"Entwicklungsphase dieser Übung: {snap.get('stage_phase') or phase}",
- f"Erwarteter Entwicklungsbogen: {arc}",
- f"Einordnung: didaktische Zwischenstufe zwischen „{from_title}“ und „{to_title}“.",
- ]
- )
+ parts.append(f"Entwicklungsphase dieser Übung: {snap.get('stage_phase') or phase}")
+ parts.append(f"Erwarteter Entwicklungsbogen: {arc}")
+ if spec.get("source") == "roadmap_unfilled":
+ parts.append(
+ "Einordnung: Übung für diese Roadmap-Stufe — das Stufen-Lernziel steht im Vordergrund."
+ )
+ if step_a:
+ parts.append(f"Vorherige Stufe: „{from_title}“")
+ if step_b:
+ parts.append(f"Nächste Stufe: „{to_title}“")
+ else:
+ parts.append(
+ f"Einordnung: didaktische Zwischenstufe zwischen „{from_title}“ und „{to_title}“."
+ )
if snap.get("stage_load_profile"):
parts.append(f"Belastungsschwerpunkte: {', '.join(snap['stage_load_profile'])}")
if snap.get("stage_success_criteria"):
@@ -314,6 +526,17 @@ def build_gap_fill_goal_text(
"Fähigkeiten-/Fokus-Hinweise: "
+ "; ".join(str(x) for x in snap["skill_hints"][:4])
)
+ expected = snap.get("expected_skills") or []
+ if expected:
+ names = [
+ str(s.get("skill_name") or "").strip()
+ for s in expected[:5]
+ if str(s.get("skill_name") or "").strip()
+ ]
+ if names:
+ parts.append(
+ "Erwartete Fähigkeiten (Scoring): " + ", ".join(names)
+ )
if spec.get("rationale"):
parts.append(f"Qualitätsprüfung: {spec['rationale']}")
if spec.get("sketch"):
@@ -335,10 +558,28 @@ def build_gap_fill_offer(
proposal: Optional[Mapping[str, Any]] = None,
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
) -> Dict[str, Any]:
+ source = spec.get("source")
idx = int(spec.get("insert_after_index") or 0)
+ major_idx = spec.get("roadmap_major_step_index")
+ if source == "roadmap_unfilled" and major_idx is not None:
+ try:
+ mi = int(major_idx)
+ except (TypeError, ValueError):
+ mi = idx
+ step_a, step_b = _resolve_neighbor_steps_by_major_index(steps, mi)
+ idx = mi
+ else:
+ step_a = steps[idx] if idx < len(steps) else None
+ step_b = steps[idx + 1] if idx + 1 < len(steps) else None
offer_id = f"{spec.get('source')}-{idx}-{uuid.uuid4().hex[:8]}"
- step_a = steps[idx] if idx < len(steps) else None
- step_b = steps[idx + 1] if idx + 1 < len(steps) else None
+ enriched_snapshot = dict(roadmap_snapshot) if roadmap_snapshot else {}
+ major_raw = spec.get("roadmap_major_step_index")
+ if major_raw is not None:
+ enriched_snapshot = enrich_gap_snapshot_with_entry_state(
+ enriched_snapshot,
+ steps=steps,
+ major_step_index=major_raw,
+ )
goal_for_ai = ""
if brief and goal_query:
goal_for_ai = build_gap_fill_goal_text(
@@ -347,9 +588,9 @@ def build_gap_fill_offer(
spec=spec,
step_a=step_a,
step_b=step_b,
- roadmap_snapshot=roadmap_snapshot,
+ roadmap_snapshot=enriched_snapshot or None,
)
- ctx_preview = dict(roadmap_snapshot) if roadmap_snapshot else None
+ ctx_preview = enriched_snapshot or None
offer: Dict[str, Any] = {
"offer_id": offer_id,
"source": spec.get("source"),
@@ -400,6 +641,38 @@ def apply_gap_fill_after_qa(
offers: List[Dict[str, Any]] = []
for spec in specs:
+ source = spec.get("source")
+
+ if source == "roadmap_unfilled":
+ proposal: Optional[Dict[str, Any]] = None
+ if include_ai_calls and len(proposals) < max_ai_proposals:
+ proposal = try_suggest_ai_stage_step(
+ cur,
+ goal_query=goal_query,
+ brief=brief,
+ spec=spec,
+ steps=out,
+ )
+ offer = build_gap_fill_offer(
+ spec=spec,
+ steps=out,
+ goal_query=goal_query,
+ brief=brief,
+ proposal=proposal,
+ roadmap_snapshot=roadmap_snapshot,
+ )
+ offers.append(offer)
+ if proposal and auto_insert_proposals:
+ proposals.append(
+ {
+ "roadmap_major_step_index": spec.get("roadmap_major_step_index"),
+ "proposal_key": proposal.get("proposal_key"),
+ "proposal_title": proposal.get("title"),
+ "offer_id": offer.get("offer_id"),
+ }
+ )
+ continue
+
idx = int(spec.get("insert_after_index") or 0)
if idx < 0 or idx >= len(out) - 1:
continue
@@ -421,7 +694,7 @@ def apply_gap_fill_after_qa(
if not gap.get("expected_phase"):
gap["expected_phase"] = spec.get("phase") or "vertiefung"
- proposal: Optional[Dict[str, Any]] = None
+ proposal = None
if include_ai_calls and len(proposals) < max_ai_proposals:
proposal = try_suggest_ai_bridge_step(
cur,
@@ -497,4 +770,5 @@ __all__ = [
"collect_gap_fill_specs",
"insert_ai_proposals_for_gaps",
"try_suggest_ai_bridge_step",
+ "try_suggest_ai_stage_step",
]
diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py
index efef5cf..58c08fe 100644
--- a/backend/planning_exercise_path_builder.py
+++ b/backend/planning_exercise_path_builder.py
@@ -6,13 +6,24 @@ planning_progression_roadmap.py und PLANNING_PROGRESSION_ROADMAP_SPEC.md.
"""
from __future__ import annotations
-from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Tuple
+from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple
from fastapi import HTTPException
from pydantic import BaseModel, Field
-from tenant_context import TenantContext, library_content_visibility_sql
+from tenant_context import (
+ TenantContext,
+ library_content_visibility_for_progression_graph_sql,
+ library_content_visibility_sql,
+)
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,
+ prune_stripped_after_rematch,
+ rematch_roadmap_slots,
+)
+from planning_stage_context import build_contextualized_stage_goal, resolve_path_start_target
from planning_exercise_path_qa import (
apply_llm_path_reorder,
build_path_qa_summary,
@@ -32,9 +43,14 @@ from planning_exercise_retrieval import run_multistage_planning_retrieval
from planning_exercise_semantics import (
PlanningSemanticBrief,
apply_path_retrieval_weights,
+ apply_stage_match_retrieval_weights,
brief_to_summary_dict,
build_semantic_brief,
+ build_stage_match_brief,
+ enrich_brief_with_path_constraints,
enrich_target_with_semantic_expectations,
+ resolve_path_anti_patterns,
+ resolve_path_primary_topic,
exercise_passes_path_semantic_gate,
pick_best_path_hit,
resolve_semantic_skill_weights,
@@ -51,6 +67,12 @@ from planning_exercise_suggest import (
resolve_planning_exercise_intent,
)
from planning_exercise_form_context import build_progression_gap_snapshot
+from planning_skill_expectations import (
+ apply_expectations_to_target,
+ build_planning_skill_expectations,
+ expectation_input_from_progression_path,
+ expectation_input_from_progression_stage,
+)
from planning_progression_roadmap import (
MajorStep,
ProgressionRoadmapContext,
@@ -68,11 +90,25 @@ from planning_progression_roadmap import (
from routers.training_planning import _has_planning_role
+class EvaluateStepPayload(BaseModel):
+ exercise_id: Optional[int] = Field(default=None, ge=1)
+ variant_id: Optional[int] = Field(default=None, ge=1)
+ title: Optional[str] = Field(default=None, max_length=500)
+ is_ai_proposal: bool = False
+ ai_suggestion: Optional[Dict[str, Any]] = None
+ proposal_key: Optional[str] = Field(default=None, max_length=120)
+ roadmap_major_step_index: Optional[int] = Field(default=None, ge=0, le=20)
+ roadmap_phase: Optional[str] = Field(default=None, max_length=80)
+ roadmap_learning_goal: Optional[str] = Field(default=None, max_length=2000)
+
+
class ProgressionPathSuggestRequest(BaseModel):
query: str = Field(..., min_length=3, max_length=2000)
max_steps: int = Field(default=5, ge=2, le=10)
include_llm_intent: bool = True
include_path_qa: bool = True
+ auto_rematch_after_qa: bool = True
+ max_rematch_rounds: int = Field(default=2, ge=0, le=3)
include_llm_path_qa: bool = True
include_path_reorder: bool = True
include_ai_gap_fill: bool = True
@@ -82,6 +118,11 @@ class ProgressionPathSuggestRequest(BaseModel):
roadmap_first: bool = False
roadmap_only: bool = False
start_target_only: bool = False
+ evaluate_only: bool = False
+ evaluate_steps: Optional[List[EvaluateStepPayload]] = None
+ slot_assignments: Optional[List[EvaluateStepPayload]] = None
+ preserve_slot_assignments: bool = False
+ retrieval_boost_exercise_ids: Optional[List[int]] = None
roadmap_override: Optional[RoadmapOverridePayload] = None
start_situation: Optional[str] = Field(default=None, max_length=2000)
target_state: Optional[str] = Field(default=None, max_length=2000)
@@ -91,14 +132,17 @@ class ProgressionPathSuggestRequest(BaseModel):
def _roadmap_gap_snapshot_for_spec(
+ cur,
roadmap_ctx: Optional[ProgressionRoadmapContext],
spec: Mapping[str, Any],
*,
+ goal_query: str,
semantic_brief: PlanningSemanticBrief,
) -> Dict[str, Any]:
- """Roadmap-Kontext für KI-Lücken-Übung (Start, Ziel, Stufenspec)."""
+ """Roadmap-Kontext für KI-Lücken-Übung (Start, Ziel, Stufenspec, Fähigkeiten)."""
major_idx = spec.get("roadmap_major_step_index")
stage_spec_dict: Optional[Dict[str, Any]] = None
+ major_dict: Optional[Dict[str, Any]] = None
if roadmap_ctx and major_idx is not None:
for s in roadmap_ctx.stage_specs or []:
if int(s.major_step_index) == int(major_idx):
@@ -107,6 +151,7 @@ def _roadmap_gap_snapshot_for_spec(
for m in roadmap_ctx.roadmap.major_steps:
if m.index == int(major_idx):
stage_spec_dict["phase"] = m.phase
+ major_dict = m.model_dump()
break
break
ga = roadmap_ctx.goal_analysis.model_dump() if roadmap_ctx and roadmap_ctx.goal_analysis else None
@@ -120,12 +165,25 @@ def _roadmap_gap_snapshot_for_spec(
if roadmap_ctx and roadmap_ctx.semantic_brief
else brief_to_summary_dict(semantic_brief)
)
- return build_progression_gap_snapshot(
+ snap = build_progression_gap_snapshot(
goal_analysis=ga,
resolved_structured=rs,
stage_spec=stage_spec_dict,
semantic_brief=brief_summary,
)
+ inp = expectation_input_from_progression_stage(
+ goal_query=goal_query,
+ goal_analysis=ga,
+ resolved_structured=rs,
+ stage_spec=stage_spec_dict,
+ semantic_brief_summary=brief_summary,
+ major_step=major_dict,
+ )
+ exp = build_planning_skill_expectations(cur, inp, semantic_brief=semantic_brief)
+ if exp.items:
+ snap["expected_skills"] = exp.to_api_dict()["expected_skills"]
+ snap["skill_expectation_sources"] = exp.sources
+ return snap
def _roadmap_structured_from_body(body: ProgressionPathSuggestRequest) -> Optional[RoadmapStructuredInput]:
@@ -146,8 +204,24 @@ def _pick_best_path_hit(
used_exercise_ids: Set[int],
*,
semantic_brief: Optional[PlanningSemanticBrief] = None,
+ stage_learning_goal: Optional[str] = None,
+ stage_anti_patterns: Optional[List[str]] = None,
+ roadmap_stage_match: bool = False,
+ stage_match_brief: Optional[PlanningSemanticBrief] = None,
+ path_primary_topic: Optional[str] = None,
+ path_technique_excludes: Optional[List[str]] = None,
) -> Optional[Dict[str, Any]]:
- return pick_best_path_hit(hits, used_exercise_ids, semantic_brief=semantic_brief)
+ return pick_best_path_hit(
+ hits,
+ used_exercise_ids,
+ semantic_brief=semantic_brief,
+ stage_learning_goal=stage_learning_goal,
+ stage_anti_patterns=stage_anti_patterns,
+ roadmap_stage_match=roadmap_stage_match,
+ stage_match_brief=stage_match_brief,
+ path_primary_topic=path_primary_topic,
+ path_technique_excludes=path_technique_excludes,
+ )
def _build_path_target_profile(
@@ -196,6 +270,344 @@ def _build_path_target_profile(
return target, query_intent_summary, intent
+def _graph_edge_exercise_ids(cur, graph_id: Optional[int]) -> List[int]:
+ """Übungs-IDs aus gespeicherten Graph-Kanten (für Re-Match-Boost)."""
+ if not graph_id or int(graph_id) < 1:
+ return []
+ cur.execute(
+ """
+ SELECT from_exercise_id AS eid FROM exercise_progression_edges
+ WHERE graph_id = %s AND from_exercise_id IS NOT NULL
+ UNION
+ SELECT to_exercise_id AS eid FROM exercise_progression_edges
+ WHERE graph_id = %s AND to_exercise_id IS NOT NULL
+ """,
+ (int(graph_id), int(graph_id)),
+ )
+ out: List[int] = []
+ for row in cur.fetchall() or []:
+ try:
+ eid = int(row.get("eid") or 0)
+ except (TypeError, ValueError):
+ continue
+ if eid > 0:
+ out.append(eid)
+ return out
+
+
+def _supplemental_exercise_ids_from_body(
+ cur,
+ body: ProgressionPathSuggestRequest,
+) -> List[int]:
+ """Kandidatenpool erweitern (Graph-Kanten, Boost, Slot-Zuordnungen)."""
+ ids: List[int] = []
+ for raw in body.evaluate_steps or []:
+ if raw.exercise_id is not None:
+ try:
+ eid = int(raw.exercise_id)
+ except (TypeError, ValueError):
+ continue
+ if eid > 0:
+ ids.append(eid)
+ for raw in body.slot_assignments or []:
+ if raw.exercise_id is not None:
+ try:
+ eid = int(raw.exercise_id)
+ except (TypeError, ValueError):
+ continue
+ if eid > 0:
+ ids.append(eid)
+ for eid in body.retrieval_boost_exercise_ids or []:
+ try:
+ val = int(eid)
+ except (TypeError, ValueError):
+ continue
+ if val > 0:
+ ids.append(val)
+ ids.extend(_graph_edge_exercise_ids(cur, body.progression_graph_id))
+ return list(dict.fromkeys(ids))
+
+
+def _graph_visibility_context(
+ cur,
+ progression_graph_id: Optional[int],
+) -> Tuple[str, Optional[int]]:
+ if not progression_graph_id or int(progression_graph_id) < 1:
+ return "private", None
+ cur.execute(
+ "SELECT visibility, club_id FROM exercise_progression_graphs WHERE id = %s",
+ (int(progression_graph_id),),
+ )
+ row = cur.fetchone()
+ if not row:
+ return "private", None
+ g_club = row.get("club_id")
+ return (
+ str(row.get("visibility") or "private"),
+ int(g_club) if g_club is not None else None,
+ )
+
+
+def _safe_tsquery_fragment(text: str) -> str:
+ import re
+
+ cleaned = re.sub(r"[^\w\säöüßÄÖÜ]", " ", text or "", flags=re.UNICODE)
+ words = [w for w in cleaned.split() if len(w) >= 2][:10]
+ return " ".join(words) if words else (text or "")[:60].strip()
+
+
+def _fetch_learning_goal_library_candidate_ids(
+ cur,
+ *,
+ tenant: TenantContext,
+ progression_graph_id: Optional[int],
+ learning_goal: str,
+ limit: int = 24,
+) -> List[int]:
+ """Sichtbare Übungen, deren Titel/Volltext zum Stufen-Lernziel passt."""
+ lg = (learning_goal or "").strip()
+ if len(lg) < 3:
+ return []
+ vis_sql, vis_params = _planning_visibility_sql(cur, tenant, progression_graph_id)
+ tsq = _safe_tsquery_fragment(lg)
+ like_pat = f"%{lg[:100].lower()}%"
+ try:
+ cur.execute(
+ f"""
+ SELECT e.id
+ FROM exercises e
+ WHERE ({vis_sql})
+ AND COALESCE(e.status, '') <> %s
+ AND (
+ lower(trim(e.title)) = lower(trim(%s))
+ OR lower(e.title) LIKE %s
+ OR (%s <> '' AND e.search_vector @@ plainto_tsquery('german', %s))
+ )
+ ORDER BY
+ CASE WHEN lower(trim(e.title)) = lower(trim(%s)) THEN 0 ELSE 1 END,
+ CASE WHEN %s <> '' THEN ts_rank_cd(e.search_vector, plainto_tsquery('german', %s)) ELSE 0 END DESC,
+ e.id ASC
+ LIMIT %s
+ """,
+ [
+ *vis_params,
+ "archived",
+ lg,
+ like_pat,
+ tsq,
+ tsq,
+ lg,
+ tsq,
+ tsq,
+ int(limit),
+ ],
+ )
+ except Exception:
+ cur.execute(
+ f"""
+ SELECT e.id
+ FROM exercises e
+ WHERE ({vis_sql})
+ AND COALESCE(e.status, '') <> %s
+ AND (
+ lower(trim(e.title)) = lower(trim(%s))
+ OR lower(e.title) LIKE %s
+ )
+ ORDER BY CASE WHEN lower(trim(e.title)) = lower(trim(%s)) THEN 0 ELSE 1 END, e.id ASC
+ LIMIT %s
+ """,
+ [*vis_params, "archived", lg, like_pat, lg, int(limit)],
+ )
+ out: List[int] = []
+ for row in cur.fetchall() or []:
+ try:
+ eid = int(row.get("id") or 0)
+ except (TypeError, ValueError):
+ continue
+ if eid > 0:
+ out.append(eid)
+ return out
+
+
+def _load_supplemental_exercise_rows(
+ cur,
+ *,
+ tenant: TenantContext,
+ progression_graph_id: Optional[int],
+ exercise_ids: Optional[Sequence[int]],
+ vis_sql: str,
+ vis_params: Sequence[Any],
+) -> List[Dict[str, Any]]:
+ """Supplemental-Übungen mit Graph-Sichtbarkeit, Fallback Library-vis_sql."""
+ ids: List[int] = []
+ for raw in exercise_ids or []:
+ if raw is None:
+ continue
+ try:
+ eid = int(raw)
+ except (TypeError, ValueError):
+ continue
+ if eid > 0:
+ ids.append(eid)
+ ids = list(dict.fromkeys(ids))
+ if not ids:
+ return []
+ if progression_graph_id and int(progression_graph_id) > 0:
+ from planning_exercise_retrieval import fetch_exercise_rows_by_ids_for_graph
+
+ gvis, gclub = _graph_visibility_context(cur, progression_graph_id)
+ graph_rows = fetch_exercise_rows_by_ids_for_graph(
+ cur,
+ ids,
+ graph_visibility=gvis,
+ graph_club_id=gclub,
+ profile_id=tenant.profile_id,
+ role=tenant.global_role,
+ exercise_allowed_fn=_exercise_allowed_in_progression_graph,
+ )
+ if graph_rows:
+ return graph_rows
+ from planning_exercise_retrieval import fetch_exercise_rows_by_ids
+
+ return fetch_exercise_rows_by_ids(
+ cur,
+ ids,
+ vis_sql=vis_sql,
+ vis_params=vis_params,
+ )
+
+
+def _planning_visibility_sql(
+ cur,
+ tenant: TenantContext,
+ progression_graph_id: Optional[int],
+) -> Tuple[str, List[Any]]:
+ if progression_graph_id and int(progression_graph_id) > 0:
+ cur.execute(
+ "SELECT visibility, club_id FROM exercise_progression_graphs WHERE id = %s",
+ (int(progression_graph_id),),
+ )
+ grow = cur.fetchone()
+ if grow:
+ g_club = grow.get("club_id")
+ return library_content_visibility_for_progression_graph_sql(
+ alias="e",
+ profile_id=tenant.profile_id,
+ role=tenant.global_role,
+ effective_club_id=tenant.effective_club_id,
+ graph_visibility=str(grow.get("visibility") or "private"),
+ graph_club_id=int(g_club) if g_club is not None else None,
+ )
+ return library_content_visibility_sql(
+ alias="e",
+ profile_id=tenant.profile_id,
+ role=tenant.global_role,
+ effective_club_id=tenant.effective_club_id,
+ )
+
+
+def _exercise_allowed_in_progression_graph(
+ exercise_row: Mapping[str, Any],
+ *,
+ graph_visibility: str,
+ graph_club_id: Optional[int],
+ profile_id: int,
+ role: str,
+) -> bool:
+ """Prüft, ob eine Übung zur Sichtbarkeit des Progressionsgraphen passt."""
+ from club_tenancy import is_platform_admin
+
+ ex_vis = (exercise_row.get("visibility") or "private").strip().lower()
+ gvis = (graph_visibility or "private").strip().lower()
+ if gvis == "private":
+ if ex_vis == "official":
+ return True
+ if ex_vis == "club":
+ return True
+ if ex_vis == "private":
+ if is_platform_admin(role):
+ return True
+ try:
+ return int(exercise_row.get("created_by") or 0) == int(profile_id)
+ except (TypeError, ValueError):
+ return False
+ return False
+ if gvis == "club":
+ if ex_vis == "official":
+ return True
+ if ex_vis != "club":
+ return False
+ ex_club = exercise_row.get("club_id")
+ if ex_club is None:
+ return False
+ if graph_club_id is None:
+ return True
+ return int(ex_club) == int(graph_club_id)
+ return ex_vis == "official"
+
+
+def _slot_assignments_by_major_index(
+ assignments: Optional[List[EvaluateStepPayload]],
+) -> Dict[int, EvaluateStepPayload]:
+ out: Dict[int, EvaluateStepPayload] = {}
+ for raw in assignments or []:
+ if raw.exercise_id is None or raw.roadmap_major_step_index is None:
+ continue
+ out[int(raw.roadmap_major_step_index)] = raw
+ return out
+
+
+def _path_step_from_slot_assignment(
+ cur,
+ *,
+ assignment: EvaluateStepPayload,
+ stage_spec: StageSpecArtifact,
+ major_step: Optional[MajorStep],
+ tenant: Optional[TenantContext] = None,
+ progression_graph_id: Optional[int] = None,
+) -> Optional[Dict[str, Any]]:
+ """Bestehende Slot-Zuordnung aus dem Graph-Editor (nach KI-Anlage) übernehmen."""
+ eid = int(assignment.exercise_id)
+ cur.execute(
+ "SELECT id, title, summary, visibility, club_id, created_by FROM exercises WHERE id = %s",
+ (eid,),
+ )
+ row = cur.fetchone()
+ if not row:
+ return None
+ if tenant and progression_graph_id:
+ cur.execute(
+ "SELECT visibility, club_id FROM exercise_progression_graphs WHERE id = %s",
+ (int(progression_graph_id),),
+ )
+ grow = cur.fetchone()
+ if grow and not _exercise_allowed_in_progression_graph(
+ row,
+ graph_visibility=str(grow.get("visibility") or "private"),
+ graph_club_id=int(grow["club_id"]) if grow.get("club_id") is not None else None,
+ profile_id=tenant.profile_id,
+ role=tenant.global_role,
+ ):
+ return None
+ title = (assignment.title or row.get("title") or "").strip() or str(row.get("title") or "")
+ step = {
+ "exercise_id": eid,
+ "variant_id": assignment.variant_id,
+ "title": title,
+ "summary": row.get("summary"),
+ "score": None,
+ "semantic_score": None,
+ "reasons": ["Bestehende Slot-Zuordnung (Graph-Editor)"],
+ "variants": [],
+ "slot_assignment": True,
+ }
+ return _annotate_roadmap_step(
+ step,
+ stage_spec=stage_spec,
+ major_step=major_step,
+ )
+
+
def _hit_to_path_step(hit: Dict[str, Any], *, is_bridge: bool = False) -> Dict[str, Any]:
raw_vid = hit.get("suggested_variant_id")
variant_id: Optional[int] = None
@@ -244,6 +656,17 @@ def _run_path_step_retrieval(
path_intent: Optional[str] = None,
step_query_override: Optional[str] = None,
step_phase_override: Optional[str] = None,
+ step_target_profile_override: Optional[PlanningTargetProfile] = None,
+ stage_learning_goal: Optional[str] = None,
+ stage_anti_patterns: Optional[List[str]] = None,
+ stage_match_brief: Optional[PlanningSemanticBrief] = None,
+ stage_success_criteria: Optional[List[str]] = None,
+ stage_load_profile: Optional[List[str]] = None,
+ path_context_note: Optional[str] = None,
+ path_primary_topic: Optional[str] = None,
+ path_technique_excludes: Optional[List[str]] = None,
+ supplemental_exercise_ids: Optional[List[int]] = None,
+ priority_exercise_ids: Optional[List[int]] = None,
) -> Tuple[List[Dict[str, Any]], PlanningTargetProfile, Dict[str, Any], str]:
step_query = step_query_override or step_retrieval_query(
semantic_brief, goal_query, step_index, max_steps
@@ -279,6 +702,15 @@ def _run_path_step_retrieval(
"retrieval_query": step_query,
"path_step_phase": step_phase_override
or step_phase_for_index(semantic_brief, step_index, max_steps),
+ "stage_learning_goal": (stage_learning_goal or "").strip() or None,
+ "stage_anti_patterns": list(stage_anti_patterns or []),
+ "roadmap_stage_match": bool((stage_learning_goal or "").strip()),
+ "stage_match_brief": stage_match_brief,
+ "stage_success_criteria": list(stage_success_criteria or []),
+ "stage_load_profile": list(stage_load_profile or []),
+ "path_context_note": (path_context_note or "").strip() or None,
+ "path_primary_topic": (path_primary_topic or "").strip() or None,
+ "path_technique_excludes": list(path_technique_excludes or []),
}
pack = apply_progression_context_to_pack(
cur,
@@ -311,7 +743,11 @@ def _run_path_step_retrieval(
"expectation_mode": "query_only" if step_index == 0 and not planned_ids else "planning_hybrid",
}
- if path_target_profile is not None:
+ if step_target_profile_override is not None:
+ target_profile = step_target_profile_override
+ intent = path_intent or resolve_planning_exercise_intent(goal_query, "free_search")
+ query_intent_summary = {}
+ elif path_target_profile is not None:
target_profile = path_target_profile
intent = path_intent or resolve_planning_exercise_intent(goal_query, "free_search")
query_intent_summary = {}
@@ -329,17 +765,25 @@ def _run_path_step_retrieval(
has_planning_reference=has_plan_ref,
)
- weights = apply_path_retrieval_weights(semantic_brief)
+ if pack.get("roadmap_stage_match"):
+ weights = apply_stage_match_retrieval_weights(semantic_brief)
+ else:
+ weights = apply_path_retrieval_weights(semantic_brief)
- profile_id = tenant.profile_id
- role = tenant.global_role
- vis_sql, vis_params = library_content_visibility_sql(
- alias="e",
- profile_id=profile_id,
- role=role,
- effective_club_id=tenant.effective_club_id,
+ vis_sql, vis_params = _planning_visibility_sql(
+ cur,
+ tenant,
+ progression_graph_id,
)
+ supplemental_rows = _load_supplemental_exercise_rows(
+ cur,
+ tenant=tenant,
+ progression_graph_id=progression_graph_id,
+ exercise_ids=supplemental_exercise_ids,
+ vis_sql=vis_sql,
+ vis_params=vis_params,
+ )
hits, _skills_by_ex, _full_lib = run_multistage_planning_retrieval(
cur,
vis_sql=vis_sql,
@@ -350,8 +794,19 @@ def _run_path_step_retrieval(
intent=intent,
intent_weights=weights,
pack=pack,
+ supplemental_rows_preloaded=supplemental_rows,
)
- hits = _enrich_planning_hits_with_variant_meta(cur, hits[:32])
+ from planning_exercise_retrieval import trim_hits_preserving_priority_ids
+
+ priority_ids = list(
+ dict.fromkeys(
+ int(x)
+ for x in (priority_exercise_ids or supplemental_exercise_ids or [])
+ if int(x) > 0
+ )
+ )
+ hits = trim_hits_preserving_priority_ids(hits, priority_ids, limit=48)
+ hits = _enrich_planning_hits_with_variant_meta(cur, hits)
return hits, target_profile, query_intent_summary, intent
@@ -368,6 +823,7 @@ def _make_bridge_search_fn(
planned_ids: List[int],
path_target_profile: PlanningTargetProfile,
path_intent: str,
+ supplemental_exercise_ids: Optional[List[int]] = None,
) -> Callable[..., List[Dict[str, Any]]]:
def _bridge_search(
step_a: Dict[str, Any],
@@ -391,6 +847,7 @@ def _make_bridge_search_fn(
step_a=step_a,
step_b=step_b,
path_target_profile=path_target_profile,
+ supplemental_exercise_ids=supplemental_exercise_ids,
path_intent=path_intent,
)
gated = [
@@ -414,6 +871,8 @@ def _annotate_roadmap_step(
*,
stage_spec: StageSpecArtifact,
major_step: Optional[MajorStep],
+ skill_expectations: Optional[Dict[str, Any]] = None,
+ anti_patterns_override: Optional[List[str]] = None,
) -> Dict[str, Any]:
reasons = list(step.get("reasons") or [])
learning_goal = (stage_spec.learning_goal or "").strip()
@@ -421,14 +880,487 @@ def _annotate_roadmap_step(
roadmap_reason = f"Roadmap: {learning_goal[:120]}"
if roadmap_reason not in reasons:
reasons.insert(0, roadmap_reason)
+ if skill_expectations and skill_expectations.get("expected_skills"):
+ names = [
+ str(s.get("skill_name") or "").strip()
+ for s in skill_expectations["expected_skills"][:3]
+ if str(s.get("skill_name") or "").strip()
+ ]
+ if names:
+ skill_reason = f"Fähigkeiten: {', '.join(names)}"
+ if skill_reason not in reasons:
+ reasons.append(skill_reason)
step["reasons"] = reasons[:4]
step["roadmap_major_step_index"] = stage_spec.major_step_index
step["roadmap_phase"] = major_step.phase if major_step else None
step["roadmap_learning_goal"] = learning_goal or None
- step["roadmap_match_source"] = "stage_spec"
+ anti = list(anti_patterns_override or stage_spec.anti_patterns or [])
+ if anti:
+ step["roadmap_anti_patterns"] = anti
+ if (stage_spec.start_state or "").strip():
+ step["roadmap_start_state"] = stage_spec.start_state.strip()
+ if (stage_spec.target_state or "").strip():
+ step["roadmap_target_state"] = stage_spec.target_state.strip()
+ if stage_spec.success_criteria:
+ step["success_criteria"] = list(stage_spec.success_criteria)
+ step["stage_success_criteria"] = list(stage_spec.success_criteria)
+ if not step.get("roadmap_match_source"):
+ step["roadmap_match_source"] = "stage_spec"
+ if step.get("exercise_id") is not None:
+ step["slot_status"] = step.get("slot_status") or (
+ "preserved" if step.get("roadmap_match_source") == "slot_best_match" else "matched"
+ )
+ else:
+ step["slot_status"] = step.get("slot_status") or "unfilled"
+ if skill_expectations:
+ step["skill_expectations"] = skill_expectations
return step
+def _stage_validation_context_for_spec(
+ cur,
+ *,
+ body: ProgressionPathSuggestRequest,
+ goal_query: str,
+ semantic_brief: PlanningSemanticBrief,
+ path_target_profile: PlanningTargetProfile,
+ roadmap_ctx: ProgressionRoadmapContext,
+ stage_spec: StageSpecArtifact,
+ step_index: int,
+ stage_count: int,
+ major: Optional[MajorStep],
+) -> Dict[str, Any]:
+ """Gemeinsamer Kontext für Reconcile + Match eines Roadmap-Slots."""
+ ga_dump = (
+ roadmap_ctx.goal_analysis.model_dump() if roadmap_ctx.goal_analysis else None
+ )
+ rs_dump = (
+ roadmap_ctx.resolved_structured.model_dump()
+ if roadmap_ctx.resolved_structured
+ else None
+ )
+ path_start, path_target = resolve_path_start_target(
+ structured=roadmap_ctx.resolved_structured,
+ goal_analysis=roadmap_ctx.goal_analysis,
+ )
+ stage_goal = (stage_spec.learning_goal or "").strip()
+ stage_start = (stage_spec.start_state or "").strip()
+ stage_target = (stage_spec.target_state or "").strip()
+ contextual_goal = build_contextualized_stage_goal(
+ learning_goal=stage_goal,
+ start_state=stage_start,
+ target_state=stage_target,
+ path_target_state=path_target,
+ path_start_state=path_start,
+ stage_index=step_index,
+ stage_count=stage_count,
+ )
+ path_context_note = None
+ if rs_dump:
+ ctx_parts = [
+ str(rs_dump.get("start_situation") or "").strip()[:120],
+ str(rs_dump.get("target_state") or "").strip()[:120],
+ str(rs_dump.get("roadmap_notes") or "").strip()[:120],
+ ]
+ path_context_note = " ".join(p for p in ctx_parts if p)[:240] or None
+ path_anti = resolve_path_anti_patterns(
+ goal_query,
+ semantic_brief=semantic_brief,
+ extra_context=path_context_note,
+ )
+ stage_anti = list(dict.fromkeys([*(stage_spec.anti_patterns or []), *path_anti]))
+ path_primary = (
+ resolve_path_primary_topic(
+ goal_query,
+ semantic_brief,
+ stage_learning_goal=stage_goal,
+ extra_context=path_context_note,
+ )
+ or ""
+ ).strip()
+ path_tech_excludes = list(semantic_brief.exclude_phrases or [])
+ if path_primary:
+ from planning_exercise_semantics import technique_sibling_excludes
+
+ for item in technique_sibling_excludes(path_primary):
+ if item not in path_tech_excludes:
+ path_tech_excludes.append(item)
+ stage_match_brief = build_stage_match_brief(
+ learning_goal=stage_goal,
+ anti_patterns=stage_anti,
+ success_criteria=list(stage_spec.success_criteria or []),
+ load_profile=list(stage_spec.load_profile or []),
+ phase=major.phase if major else None,
+ path_context_note=path_context_note,
+ path_anti_patterns=path_anti,
+ path_primary_topic=path_primary or None,
+ path_technique_excludes=path_tech_excludes or None,
+ stage_start_state=stage_start or None,
+ stage_target_state=stage_target or None,
+ path_target_state=path_target or None,
+ contextualized_learning_goal=contextual_goal or None,
+ )
+ return {
+ "stage_goal": stage_goal,
+ "stage_anti": stage_anti,
+ "path_primary": path_primary,
+ "path_tech_excludes": path_tech_excludes,
+ "stage_match_brief": stage_match_brief,
+ "path_context_note": path_context_note,
+ "path_anti": path_anti,
+ "path_start": path_start,
+ "path_target": path_target,
+ "ga_dump": ga_dump,
+ "rs_dump": rs_dump,
+ }
+
+
+def _match_roadmap_slot(
+ 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],
+ slot_priority_exercise_id: Optional[int] = None,
+) -> Tuple[Optional[Dict[str, Any]], Optional[StageSpecArtifact]]:
+ """Einzelnen Roadmap-Slot matchen (Initial-Build und Auto-Rematch)."""
+ major_by_index: Dict[int, MajorStep] = {}
+ if roadmap_ctx.roadmap:
+ major_by_index = {m.index: m for m in roadmap_ctx.roadmap.major_steps}
+ major = major_by_index.get(stage_spec.major_step_index)
+
+ ctx = _stage_validation_context_for_spec(
+ cur,
+ body=body,
+ goal_query=goal_query,
+ semantic_brief=semantic_brief,
+ path_target_profile=path_target_profile,
+ roadmap_ctx=roadmap_ctx,
+ stage_spec=stage_spec,
+ step_index=step_index,
+ stage_count=stage_count,
+ major=major,
+ )
+ stage_goal = ctx["stage_goal"]
+ stage_anti = ctx["stage_anti"]
+ path_primary = ctx["path_primary"]
+ path_tech_excludes = ctx["path_tech_excludes"]
+ stage_match_brief = ctx["stage_match_brief"]
+ path_context_note = ctx["path_context_note"]
+ ga_dump = ctx["ga_dump"]
+ rs_dump = ctx["rs_dump"]
+
+ brief_summary = (
+ roadmap_ctx.semantic_brief
+ if roadmap_ctx.semantic_brief
+ else brief_to_summary_dict(semantic_brief)
+ )
+
+ stage_spec_dict = stage_spec.model_dump()
+ if major:
+ stage_spec_dict["phase"] = major.phase
+ stage_inp = expectation_input_from_progression_stage(
+ goal_query=goal_query,
+ goal_analysis=ga_dump,
+ resolved_structured=rs_dump,
+ stage_spec=stage_spec_dict,
+ semantic_brief_summary=brief_summary,
+ major_step=major.model_dump() if major else None,
+ )
+ stage_exp = build_planning_skill_expectations(cur, stage_inp, semantic_brief=semantic_brief)
+ step_target = apply_expectations_to_target(path_target_profile, stage_exp)
+ skill_exp_api = stage_exp.to_api_dict() if stage_exp.items else None
+
+ step_query = stage_spec_retrieval_query(
+ semantic_brief=semantic_brief,
+ goal_query=goal_query,
+ stage_spec=stage_spec,
+ major_step=major,
+ )
+ step_kind = resolve_step_exercise_kind_filter(stage_spec, body.exercise_kind_any)
+
+ supplemental_ids = _supplemental_exercise_ids_from_body(cur, body)
+ lg_candidates = _fetch_learning_goal_library_candidate_ids(
+ cur,
+ tenant=tenant,
+ progression_graph_id=body.progression_graph_id,
+ learning_goal=stage_goal,
+ )
+ supplemental_ids = list(
+ dict.fromkeys(
+ int(x)
+ for x in [
+ *supplemental_ids,
+ *lg_candidates,
+ slot_priority_exercise_id,
+ ]
+ if x is not None and int(x) > 0
+ )
+ )
+ priority_ids = list(
+ dict.fromkeys(
+ int(x)
+ for x in [
+ slot_priority_exercise_id,
+ *(body.retrieval_boost_exercise_ids or []),
+ *lg_candidates[:8],
+ ]
+ if x is not None and int(x) > 0
+ )
+ )
+
+ hits, _, _, _ = _run_path_step_retrieval(
+ cur,
+ tenant=tenant,
+ goal_query=goal_query,
+ step_index=step_index,
+ max_steps=max_steps,
+ planned_ids=planned_ids,
+ anchor_id=anchor_id,
+ anchor_variant_id=anchor_variant_id,
+ progression_graph_id=body.progression_graph_id,
+ include_llm_intent=body.include_llm_intent and step_index == 0,
+ exercise_kind_any=step_kind,
+ semantic_brief=stage_match_brief,
+ path_target_profile=path_target_profile,
+ path_intent=path_intent,
+ step_query_override=step_query,
+ step_phase_override=major.phase if major else None,
+ step_target_profile_override=step_target,
+ stage_learning_goal=stage_goal or None,
+ stage_anti_patterns=stage_anti or None,
+ stage_match_brief=stage_match_brief,
+ stage_success_criteria=list(stage_spec.success_criteria or []),
+ stage_load_profile=list(stage_spec.load_profile or []),
+ path_context_note=path_context_note,
+ path_primary_topic=path_primary or None,
+ path_technique_excludes=path_tech_excludes or None,
+ supplemental_exercise_ids=supplemental_ids,
+ priority_exercise_ids=priority_ids,
+ )
+
+ hit = _pick_best_path_hit(
+ hits,
+ used,
+ semantic_brief=stage_match_brief,
+ stage_learning_goal=stage_goal or None,
+ stage_anti_patterns=stage_anti or None,
+ roadmap_stage_match=True,
+ stage_match_brief=stage_match_brief,
+ path_primary_topic=path_primary or None,
+ path_technique_excludes=path_tech_excludes or None,
+ )
+
+ if not hit:
+ return None, stage_spec
+
+ step = _annotate_roadmap_step(
+ _hit_to_path_step(hit),
+ stage_spec=stage_spec,
+ major_step=major,
+ skill_expectations=skill_exp_api,
+ anti_patterns_override=stage_anti,
+ )
+ if (
+ slot_priority_exercise_id is not None
+ and int(step["exercise_id"]) == int(slot_priority_exercise_id)
+ ):
+ step["slot_status"] = "preserved"
+ step["roadmap_match_source"] = "slot_best_match"
+ step["reasons"] = ["Bester Treffer (bestehende Zuordnung)"] + list(step.get("reasons") or [])[:2]
+ else:
+ step["slot_status"] = "matched"
+ step["roadmap_match_source"] = "stage_spec"
+ return step, None
+
+
+def _normalize_roadmap_steps_coverage(
+ steps: List[Dict[str, Any]],
+ *,
+ roadmap_ctx: ProgressionRoadmapContext,
+ max_steps: int,
+) -> List[Dict[str, Any]]:
+ """Ein Eintrag pro Roadmap-Major-Step — fehlende Slots als leere Platzhalter."""
+ stage_specs = list(roadmap_ctx.stage_specs or [])[:max_steps]
+ if not stage_specs:
+ return steps
+
+ major_by_index: Dict[int, MajorStep] = {}
+ if roadmap_ctx.roadmap:
+ major_by_index = {m.index: m for m in roadmap_ctx.roadmap.major_steps}
+
+ by_major: Dict[int, Dict[str, Any]] = {}
+ for raw in steps:
+ step = dict(raw)
+ midx = step.get("roadmap_major_step_index")
+ if midx is not None:
+ by_major[int(midx)] = step
+
+ out: List[Dict[str, Any]] = []
+ for spec in sorted(stage_specs, key=lambda s: s.major_step_index):
+ midx = int(spec.major_step_index)
+ if midx in by_major:
+ out.append(by_major[midx])
+ continue
+ major = major_by_index.get(midx)
+ goal = (spec.learning_goal or "").strip()
+ out.append(
+ {
+ "exercise_id": None,
+ "variant_id": None,
+ "title": goal or f"Slot {midx + 1}",
+ "is_ai_proposal": False,
+ "roadmap_major_step_index": midx,
+ "roadmap_phase": major.phase if major else None,
+ "roadmap_learning_goal": goal or None,
+ "roadmap_match_source": "unfilled",
+ "slot_status": "unfilled",
+ "reasons": [],
+ }
+ )
+ return out
+
+
+def _merge_rematch_unfilled(
+ roadmap_unfilled: List[Tuple[int, StageSpecArtifact]],
+ rematch_new_unfilled: List[Tuple[int, StageSpecArtifact]],
+) -> List[Tuple[int, StageSpecArtifact]]:
+ if not rematch_new_unfilled:
+ return roadmap_unfilled
+ remapped = {sp.major_step_index for _, sp in rematch_new_unfilled}
+ kept = [item for item in roadmap_unfilled if item[1].major_step_index not in remapped]
+ kept.extend(rematch_new_unfilled)
+ return kept
+
+
+def _run_roadmap_rematch_loop(
+ cur,
+ *,
+ tenant: TenantContext,
+ body: ProgressionPathSuggestRequest,
+ goal_query: str,
+ max_steps: int,
+ semantic_brief: PlanningSemanticBrief,
+ path_target_profile: PlanningTargetProfile,
+ path_intent: str,
+ roadmap_ctx: ProgressionRoadmapContext,
+ steps: List[Dict[str, Any]],
+ stripped_off_topic: List[Dict[str, Any]],
+ off_topic_before_strip: List[Dict[str, Any]],
+ roadmap_unfilled: List[Tuple[int, StageSpecArtifact]],
+ gaps: List[Dict[str, Any]],
+) -> Tuple[
+ List[Dict[str, Any]],
+ List[Dict[str, Any]],
+ List[Dict[str, Any]],
+ List[Dict[str, Any]],
+ int,
+ List[Tuple[int, StageSpecArtifact]],
+]:
+ """Phase A/B: Rematch-Schleife aus Strip, unfilled Slots und optimization_hints."""
+ rematch_log: List[Dict[str, Any]] = []
+ rematch_rounds = 0
+ max_rounds = int(body.max_rematch_rounds or 0)
+ if not body.auto_rematch_after_qa or max_rounds <= 0 or not roadmap_ctx.stage_specs:
+ off_topic_steps = detect_off_topic_steps(
+ cur,
+ steps,
+ brief=semantic_brief,
+ goal_query=goal_query,
+ )
+ return steps, rematch_log, stripped_off_topic, off_topic_steps, rematch_rounds, roadmap_unfilled
+
+ current_stripped = list(stripped_off_topic or [])
+ use_initial_off_topic = not current_stripped
+ off_topic_steps: List[Dict[str, Any]] = []
+
+ for round_idx in range(max_rounds):
+ mini_qa = run_multistage_path_qa(
+ off_topic_steps=off_topic_steps if round_idx > 0 else [],
+ stripped_off_topic=current_stripped if round_idx == 0 else [],
+ gaps=gaps if round_idx == 0 else [],
+ llm_qa=None,
+ llm_applied=False,
+ roadmap_unfilled=roadmap_unfilled,
+ )
+ optimization_hints = list(mini_qa.get("optimization_hints") or [])
+
+ slot_indices, rematch_reasons = collect_rematch_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 [],
+ optimization_hints=optimization_hints,
+ stage_specs=roadmap_ctx.stage_specs,
+ roadmap_unfilled=roadmap_unfilled,
+ )
+ if not slot_indices:
+ break
+
+ steps, round_log, rematch_new_unfilled = rematch_roadmap_slots(
+ 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,
+ steps=steps,
+ slot_indices=slot_indices,
+ rematch_reasons=rematch_reasons,
+ match_slot_fn=_match_roadmap_slot,
+ )
+ rematch_rounds += 1
+ for entry in round_log:
+ tagged = dict(entry)
+ tagged["round"] = rematch_rounds
+ rematch_log.append(tagged)
+
+ current_stripped = prune_stripped_after_rematch(current_stripped, round_log)
+ roadmap_unfilled = _merge_rematch_unfilled(roadmap_unfilled, rematch_new_unfilled)
+ use_initial_off_topic = False
+
+ off_topic_steps = detect_off_topic_steps(
+ cur,
+ steps,
+ brief=semantic_brief,
+ goal_query=goal_query,
+ )
+ if round_idx + 1 >= max_rounds:
+ break
+ if not off_topic_steps and not roadmap_unfilled:
+ break
+
+ if not off_topic_steps:
+ off_topic_steps = detect_off_topic_steps(
+ cur,
+ steps,
+ brief=semantic_brief,
+ goal_query=goal_query,
+ )
+
+ return (
+ steps,
+ rematch_log,
+ current_stripped,
+ off_topic_steps,
+ rematch_rounds,
+ roadmap_unfilled,
+ )
+
+
def _build_steps_roadmap_first(
cur,
*,
@@ -452,77 +1384,52 @@ def _build_steps_roadmap_first(
for m in roadmap_ctx.roadmap.major_steps[:max_steps]
]
- major_by_index: Dict[int, MajorStep] = {}
- if roadmap_ctx.roadmap:
- major_by_index = {m.index: m for m in roadmap_ctx.roadmap.major_steps}
-
used: Set[int] = set()
steps: List[Dict[str, Any]] = []
planned_ids: List[int] = []
anchor_id: Optional[int] = None
anchor_variant_id: Optional[int] = None
unfilled: List[Tuple[int, StageSpecArtifact]] = []
+ stage_count = len(stage_specs)
+ assignments = _slot_assignments_by_major_index(body.slot_assignments)
+ majors_by_index: Dict[int, MajorStep] = {}
+ if roadmap_ctx.roadmap:
+ majors_by_index = {m.index: m for m in roadmap_ctx.roadmap.major_steps}
for step_index, stage_spec in enumerate(stage_specs):
- major = major_by_index.get(stage_spec.major_step_index)
- step_query = stage_spec_retrieval_query(
- semantic_brief=semantic_brief,
- goal_query=goal_query,
- stage_spec=stage_spec,
- major_step=major,
- )
- step_kind = resolve_step_exercise_kind_filter(stage_spec, body.exercise_kind_any)
+ major_idx = stage_spec.major_step_index
+ major = majors_by_index.get(major_idx)
+ slot_priority_id: Optional[int] = None
- hits, _, _, _ = _run_path_step_retrieval(
+ if major_idx in assignments:
+ try:
+ slot_priority_id = int(assignments[major_idx].exercise_id)
+ except (TypeError, ValueError):
+ slot_priority_id = None
+
+ step, unfilled_spec = _match_roadmap_slot(
cur,
tenant=tenant,
+ body=body,
goal_query=goal_query,
- step_index=step_index,
max_steps=max_steps,
- planned_ids=planned_ids,
- anchor_id=anchor_id,
- anchor_variant_id=anchor_variant_id,
- progression_graph_id=body.progression_graph_id,
- include_llm_intent=body.include_llm_intent and step_index == 0,
- exercise_kind_any=step_kind,
semantic_brief=semantic_brief,
path_target_profile=path_target_profile,
path_intent=path_intent,
- step_query_override=step_query,
- step_phase_override=major.phase if major else None,
+ 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,
+ slot_priority_exercise_id=slot_priority_id,
)
-
- hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief)
- if not hit and step_query != goal_query:
- hits, _, _, _ = _run_path_step_retrieval(
- cur,
- tenant=tenant,
- goal_query=goal_query,
- step_index=step_index,
- max_steps=max_steps,
- planned_ids=planned_ids,
- anchor_id=anchor_id,
- anchor_variant_id=anchor_variant_id,
- progression_graph_id=body.progression_graph_id,
- include_llm_intent=False,
- exercise_kind_any=step_kind,
- semantic_brief=semantic_brief,
- path_target_profile=path_target_profile,
- path_intent=path_intent,
- step_query_override=goal_query,
- step_phase_override=major.phase if major else None,
- )
- hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief)
-
- if not hit:
- unfilled.append((step_index, stage_spec))
+ if not step:
+ unfilled.append((step_index, unfilled_spec or stage_spec))
continue
- step = _annotate_roadmap_step(
- _hit_to_path_step(hit),
- stage_spec=stage_spec,
- major_step=major,
- )
steps.append(step)
eid = int(step["exercise_id"])
used.add(eid)
@@ -533,6 +1440,237 @@ def _build_steps_roadmap_first(
return steps, unfilled
+def _evaluate_steps_from_payload(
+ cur,
+ payloads: List[EvaluateStepPayload],
+) -> List[Dict[str, Any]]:
+ steps: List[Dict[str, Any]] = []
+ for raw in payloads:
+ is_proposal = bool(raw.is_ai_proposal) or raw.exercise_id is None
+ title = (raw.title or "").strip() or None
+ if is_proposal:
+ steps.append(
+ {
+ "exercise_id": None,
+ "variant_id": None,
+ "title": title or "KI-Vorschlag",
+ "is_ai_proposal": True,
+ "ai_suggestion": raw.ai_suggestion,
+ "proposal_key": raw.proposal_key,
+ "roadmap_major_step_index": raw.roadmap_major_step_index,
+ "roadmap_phase": raw.roadmap_phase,
+ "roadmap_learning_goal": raw.roadmap_learning_goal,
+ "reasons": [],
+ }
+ )
+ continue
+ eid = int(raw.exercise_id)
+ cur.execute(
+ "SELECT id, title, summary FROM exercises WHERE id = %s",
+ (eid,),
+ )
+ row = cur.fetchone()
+ if not row:
+ raise HTTPException(status_code=400, detail=f"Übung {eid} nicht gefunden")
+ steps.append(
+ {
+ "exercise_id": eid,
+ "variant_id": raw.variant_id,
+ "title": title or row.get("title"),
+ "summary": row.get("summary"),
+ "is_ai_proposal": False,
+ "roadmap_major_step_index": raw.roadmap_major_step_index,
+ "roadmap_phase": raw.roadmap_phase,
+ "roadmap_learning_goal": raw.roadmap_learning_goal,
+ "reasons": [],
+ }
+ )
+ return steps
+
+
+def _build_evaluate_empty_slot_gap_specs(
+ steps: List[Dict[str, Any]],
+ *,
+ goal_query: str,
+) -> List[Dict[str, Any]]:
+ """Gap-Angebote für leere Roadmap-Slots im evaluate_only-Modus."""
+ specs: List[Dict[str, Any]] = []
+ for step in steps:
+ if step.get("exercise_id") is not None:
+ continue
+ major_idx = step.get("roadmap_major_step_index")
+ if major_idx is None:
+ continue
+ try:
+ roadmap_idx = int(major_idx)
+ except (TypeError, ValueError):
+ continue
+ phase = (step.get("roadmap_phase") or "vertiefung").strip().lower()
+ learning_goal = (step.get("roadmap_learning_goal") or step.get("title") or "").strip()
+ title_hint = learning_goal[:120] if learning_goal else f"Slot {roadmap_idx + 1}"
+ specs.append(
+ {
+ "source": "roadmap_unfilled",
+ "insert_after_index": max(roadmap_idx - 1, -1),
+ "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": (
+ f"Slot {roadmap_idx + 1} ohne Übung — KI-Entwurf für diese Roadmap-Stufe."
+ ),
+ "roadmap_major_step_index": roadmap_idx,
+ }
+ )
+ return specs[:8]
+
+
+def _run_evaluate_only_path_qa(
+ cur,
+ *,
+ body: ProgressionPathSuggestRequest,
+ goal_query: str,
+ semantic_brief: PlanningSemanticBrief,
+ steps: List[Dict[str, Any]],
+ roadmap_ctx: Optional[ProgressionRoadmapContext],
+) -> Dict[str, Any]:
+ roadmap_first = roadmap_ctx is not None
+ gaps: List[Dict[str, Any]] = []
+ bridge_inserts: List[Dict[str, Any]] = []
+ unfilled_gaps: List[Dict[str, Any]] = []
+ llm_qa: Optional[Dict[str, Any]] = None
+ llm_qa_applied = False
+ off_topic_steps: List[Dict[str, Any]] = []
+ stripped_off_topic: List[Dict[str, Any]] = []
+ ai_proposals: List[Dict[str, Any]] = []
+ gap_fill_offers: List[Dict[str, Any]] = []
+ roadmap_qa_mode: Optional[str] = None
+
+ if body.include_path_qa:
+ if roadmap_first:
+ roadmap_qa_mode = "roadmap_first_lite"
+ gaps = detect_path_gaps(
+ cur,
+ steps,
+ brief=semantic_brief,
+ roadmap_first=roadmap_first,
+ )
+ if gaps and roadmap_first:
+ unfilled_gaps = list(gaps)
+
+ if body.include_llm_path_qa:
+ llm_qa, llm_qa_applied = try_llm_qa_progression_path(
+ cur,
+ goal_query=goal_query,
+ brief=semantic_brief,
+ steps=steps,
+ gaps=gaps,
+ bridge_inserts=bridge_inserts,
+ )
+
+ off_topic_steps = detect_off_topic_steps(
+ cur,
+ steps,
+ brief=semantic_brief,
+ goal_query=goal_query,
+ )
+ llm_gap_specs = parse_llm_suggested_new_exercises(
+ llm_qa,
+ brief=semantic_brief,
+ step_count=len(steps),
+ )
+
+ if body.include_ai_gap_fill:
+ fresh_large_gaps = [g for g in gaps if g.get("is_large_gap")]
+ gap_specs = collect_gap_fill_specs(
+ steps=steps,
+ unfilled_gaps=fresh_large_gaps or unfilled_gaps,
+ off_topic_steps=off_topic_steps,
+ llm_specs=llm_gap_specs,
+ brief=semantic_brief,
+ goal_query=goal_query,
+ )
+ empty_slot_specs = _build_evaluate_empty_slot_gap_specs(
+ steps,
+ goal_query=goal_query,
+ )
+ seen_spec_keys = {
+ (
+ s.get("source"),
+ s.get("roadmap_major_step_index"),
+ s.get("insert_after_index"),
+ )
+ for s in gap_specs
+ }
+ for spec in empty_slot_specs:
+ key = (
+ spec.get("source"),
+ spec.get("roadmap_major_step_index"),
+ spec.get("insert_after_index"),
+ )
+ if key not in seen_spec_keys:
+ gap_specs.append(spec)
+ seen_spec_keys.add(key)
+ path_roadmap_snapshot = None
+ if roadmap_ctx:
+ path_roadmap_snapshot = build_progression_gap_snapshot(
+ goal_analysis=(
+ roadmap_ctx.goal_analysis.model_dump()
+ if roadmap_ctx.goal_analysis
+ else None
+ ),
+ resolved_structured=(
+ roadmap_ctx.resolved_structured.model_dump()
+ if roadmap_ctx.resolved_structured
+ else None
+ ),
+ semantic_brief=roadmap_ctx.semantic_brief
+ or brief_to_summary_dict(semantic_brief),
+ )
+ _, ai_proposals, gap_fill_offers = apply_gap_fill_after_qa(
+ cur,
+ steps,
+ gap_specs,
+ goal_query=goal_query,
+ brief=semantic_brief,
+ include_ai_calls=False,
+ max_ai_proposals=0,
+ auto_insert_proposals=False,
+ roadmap_snapshot=path_roadmap_snapshot,
+ )
+
+ multistage_qa = run_multistage_path_qa(
+ off_topic_steps=off_topic_steps,
+ stripped_off_topic=stripped_off_topic,
+ gaps=gaps,
+ llm_qa=llm_qa,
+ llm_applied=llm_qa_applied,
+ )
+ path_qa = build_path_qa_summary(
+ gaps=gaps,
+ bridge_inserts=bridge_inserts,
+ ai_proposals=ai_proposals,
+ gap_fill_offers=gap_fill_offers,
+ off_topic_steps=off_topic_steps,
+ stripped_off_topic=stripped_off_topic,
+ llm_qa=llm_qa,
+ llm_applied=llm_qa_applied,
+ reorder_applied=False,
+ reorder_notes=[],
+ roadmap_qa_mode=roadmap_qa_mode,
+ multistage_qa=multistage_qa,
+ )
+ return {
+ "path_qa": path_qa,
+ "gap_fill_offers": gap_fill_offers,
+ "steps": steps,
+ }
+
+
def suggest_progression_path(
cur,
*,
@@ -554,10 +1692,25 @@ def suggest_progression_path(
semantic_brief, semantic_llm_applied = try_enrich_semantic_brief_with_llm(
cur, goal_query, semantic_brief
)
+ extra_path_ctx = " ".join(
+ p
+ for p in (
+ (body.start_situation or "").strip(),
+ (body.target_state or "").strip(),
+ (body.roadmap_notes or "").strip(),
+ )
+ if p
+ )
+ semantic_brief = enrich_brief_with_path_constraints(
+ semantic_brief,
+ goal_query,
+ extra_context=extra_path_ctx or None,
+ )
roadmap_first = bool(body.roadmap_first)
roadmap_only = bool(body.roadmap_only)
start_target_only = bool(body.start_target_only)
+ evaluate_only = bool(body.evaluate_only)
include_roadmap = (
roadmap_first or body.include_roadmap_preview or roadmap_only or start_target_only
)
@@ -646,12 +1799,68 @@ def suggest_progression_path(
"retrieval_phase": "roadmap_only",
}
+ if evaluate_only:
+ if not body.evaluate_steps:
+ raise HTTPException(
+ status_code=400,
+ detail="evaluate_only erfordert evaluate_steps",
+ )
+ eval_steps = _evaluate_steps_from_payload(cur, body.evaluate_steps)
+ qa_pack = _run_evaluate_only_path_qa(
+ cur,
+ body=body,
+ goal_query=goal_query,
+ semantic_brief=semantic_brief,
+ steps=eval_steps,
+ roadmap_ctx=roadmap_ctx,
+ )
+ return {
+ "goal_query": goal_query,
+ "max_steps_requested": max_steps,
+ "steps": qa_pack["steps"],
+ "step_count": len(qa_pack["steps"]),
+ "target_profile_summary": None,
+ "semantic_brief_summary": brief_to_summary_dict(semantic_brief),
+ "semantic_llm_applied": semantic_llm_applied,
+ "query_intent_summary": {},
+ "progression_graph_id": body.progression_graph_id,
+ "path_qa": qa_pack["path_qa"],
+ "gap_fill_offers": qa_pack["gap_fill_offers"],
+ "progression_roadmap": progression_roadmap,
+ "roadmap_first": bool(roadmap_ctx),
+ "roadmap_only": False,
+ "roadmap_edited": roadmap_edited,
+ "roadmap_unfilled_count": 0,
+ "path_skill_expectations": None,
+ "retrieval_phase": "evaluate_only",
+ }
+
path_target_profile, first_intent_summary, path_intent = _build_path_target_profile(
cur,
goal_query=goal_query,
semantic_brief=semantic_brief,
include_llm_intent=body.include_llm_intent,
)
+ path_skill_expectations: Optional[Dict[str, Any]] = None
+ if roadmap_ctx and roadmap_ctx.goal_analysis:
+ path_inp = expectation_input_from_progression_path(
+ goal_query=goal_query,
+ goal_analysis=roadmap_ctx.goal_analysis.model_dump(),
+ resolved_structured=(
+ roadmap_ctx.resolved_structured.model_dump()
+ if roadmap_ctx.resolved_structured
+ else None
+ ),
+ semantic_brief_summary=(
+ roadmap_ctx.semantic_brief
+ if roadmap_ctx.semantic_brief
+ else brief_to_summary_dict(semantic_brief)
+ ),
+ )
+ path_exp = build_planning_skill_expectations(cur, path_inp, semantic_brief=semantic_brief)
+ if path_exp.items:
+ path_target_profile = apply_expectations_to_target(path_target_profile, path_exp)
+ path_skill_expectations = path_exp.to_api_dict()
roadmap_unfilled: List[Tuple[int, StageSpecArtifact]] = []
roadmap_gap_offers: List[Dict[str, Any]] = []
@@ -702,7 +1911,11 @@ def suggest_progression_path(
brief=semantic_brief,
proposal=None,
roadmap_snapshot=_roadmap_gap_snapshot_for_spec(
- roadmap_ctx, spec, semantic_brief=semantic_brief
+ cur,
+ roadmap_ctx,
+ spec,
+ goal_query=goal_query,
+ semantic_brief=semantic_brief,
),
)
)
@@ -723,6 +1936,7 @@ def suggest_progression_path(
semantic_brief=semantic_brief,
path_target_profile=path_target_profile,
path_intent=path_intent,
+ supplemental_exercise_ids=_supplemental_exercise_ids_from_body(cur, body),
)
hit = _pick_best_path_hit(hits, used, semantic_brief=semantic_brief)
@@ -737,7 +1951,10 @@ def suggest_progression_path(
anchor_id = eid
anchor_variant_id = step.get("variant_id")
- if len(steps) < 2:
+ stage_spec_count = len(roadmap_ctx.stage_specs or []) if roadmap_ctx else 0
+ if roadmap_first and stage_spec_count >= 2:
+ pass
+ elif len(steps) < 2:
raise HTTPException(
status_code=422,
detail="Zu wenig passende Übungen für einen Pfad (mindestens 2 Schritte). Ziel präzisieren oder max_steps senken.",
@@ -749,6 +1966,8 @@ def suggest_progression_path(
gap_fill_offers: List[Dict[str, Any]] = []
off_topic_steps: List[Dict[str, Any]] = []
stripped_off_topic: List[Dict[str, Any]] = []
+ rematch_log: List[Dict[str, Any]] = []
+ rematch_rounds = 0
llm_qa: Optional[Dict[str, Any]] = None
llm_qa_applied = False
reorder_applied = False
@@ -778,6 +1997,7 @@ def suggest_progression_path(
planned_ids=planned_ids,
path_target_profile=path_target_profile,
path_intent=path_intent,
+ supplemental_exercise_ids=_supplemental_exercise_ids_from_body(cur, body),
)
steps, bridge_inserts, unfilled_gaps = insert_bridge_exercises(
cur,
@@ -813,8 +2033,18 @@ def suggest_progression_path(
if llm_qa.get("overall_ok") or (q_val is not None and q_val >= 0.45):
steps, reorder_applied, reorder_notes = apply_llm_path_reorder(steps, llm_qa)
- off_topic_steps = detect_off_topic_steps(cur, steps, brief=semantic_brief)
- steps, stripped_off_topic = strip_off_topic_steps_from_path(steps, off_topic_steps)
+ off_topic_steps = detect_off_topic_steps(
+ cur,
+ steps,
+ brief=semantic_brief,
+ 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 stripped_off_topic:
off_topic_steps = []
gaps = detect_path_gaps(
@@ -824,6 +2054,39 @@ def suggest_progression_path(
roadmap_first=roadmap_first,
)
+ if roadmap_first and roadmap_ctx is not None:
+ (
+ steps,
+ rematch_log,
+ stripped_off_topic,
+ rematch_off_topic,
+ rematch_rounds,
+ roadmap_unfilled,
+ ) = _run_roadmap_rematch_loop(
+ 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,
+ steps=steps,
+ stripped_off_topic=stripped_off_topic,
+ off_topic_before_strip=off_topic_before_strip,
+ roadmap_unfilled=roadmap_unfilled,
+ gaps=gaps,
+ )
+ if rematch_off_topic:
+ off_topic_steps = rematch_off_topic
+ gaps = detect_path_gaps(
+ cur,
+ steps,
+ brief=semantic_brief,
+ roadmap_first=roadmap_first,
+ )
+
llm_gap_specs = parse_llm_suggested_new_exercises(
llm_qa,
brief=semantic_brief,
@@ -873,6 +2136,14 @@ def suggest_progression_path(
if offer.get("offer_id") not in seen_offer_ids:
gap_fill_offers.append(offer)
+ multistage_qa = run_multistage_path_qa(
+ off_topic_steps=off_topic_steps,
+ stripped_off_topic=stripped_off_topic,
+ gaps=gaps,
+ llm_qa=llm_qa,
+ llm_applied=llm_qa_applied,
+ roadmap_unfilled=roadmap_unfilled if roadmap_first else None,
+ )
path_qa = build_path_qa_summary(
gaps=gaps,
bridge_inserts=bridge_inserts,
@@ -885,7 +2156,86 @@ def suggest_progression_path(
reorder_applied=reorder_applied,
reorder_notes=reorder_notes,
roadmap_qa_mode=roadmap_qa_mode,
+ multistage_qa=multistage_qa,
)
+ if rematch_log:
+ path_qa["rematch_applied"] = True
+ path_qa["rematch_log"] = rematch_log
+ path_qa["rematch_rounds"] = rematch_rounds
+
+ if roadmap_first and roadmap_ctx is not None:
+ steps = _normalize_roadmap_steps_coverage(
+ steps,
+ roadmap_ctx=roadmap_ctx,
+ max_steps=max_steps,
+ )
+ if body.include_ai_gap_fill:
+ seen_offer_ids = {o.get("offer_id") for o in gap_fill_offers if o.get("offer_id")}
+ for step in steps:
+ if step.get("exercise_id") is not None:
+ continue
+ try:
+ major_idx = int(step["roadmap_major_step_index"])
+ except (TypeError, ValueError, KeyError):
+ continue
+ if step.get("gap_offer") and step.get("proposal_key"):
+ oid = step["gap_offer"].get("offer_id")
+ if oid and oid not in seen_offer_ids:
+ gap_fill_offers.append(dict(step["gap_offer"]))
+ seen_offer_ids.add(oid)
+ continue
+ stage_spec = next(
+ (
+ s
+ for s in (roadmap_ctx.stage_specs or [])
+ if int(s.major_step_index) == major_idx
+ ),
+ None,
+ )
+ learning_goal = (
+ (stage_spec.learning_goal if stage_spec else None)
+ or step.get("roadmap_learning_goal")
+ or step.get("title")
+ or ""
+ ).strip()
+ spec = {
+ "source": "roadmap_unfilled",
+ "insert_after_index": max(major_idx - 1, -1),
+ "roadmap_major_step_index": major_idx,
+ "phase": (step.get("roadmap_phase") or "vertiefung").strip().lower(),
+ "title_hint": (learning_goal or f"Slot {major_idx + 1}")[:120],
+ "sketch": learning_goal,
+ "rationale": f"Slot {major_idx + 1} — keine passende Bibliotheks-Übung; KI-Entwurf für diese Stufe.",
+ }
+ offer = build_gap_fill_offer(
+ spec=spec,
+ steps=steps,
+ goal_query=goal_query,
+ brief=semantic_brief,
+ proposal=None,
+ roadmap_snapshot=_roadmap_gap_snapshot_for_spec(
+ cur,
+ roadmap_ctx,
+ spec,
+ goal_query=goal_query,
+ semantic_brief=semantic_brief,
+ ),
+ )
+ step["gap_offer"] = offer
+ step["proposal_key"] = offer.get("offer_id")
+ step["slot_status"] = "unfilled"
+ if offer.get("offer_id") and offer.get("offer_id") not in seen_offer_ids:
+ gap_fill_offers.append(offer)
+ seen_offer_ids.add(offer.get("offer_id"))
+
+ filled_library_steps = sum(1 for s in steps if s.get("exercise_id") is not None)
+ match_summary = {
+ "roadmap_first": roadmap_first,
+ "library_matches": filled_library_steps,
+ "slot_count": len(steps),
+ "gap_fill_offer_count": len(gap_fill_offers),
+ "roadmap_unfilled_count": len(roadmap_unfilled),
+ }
target_profile_summary = path_target_profile.to_summary_dict(cur)
retrieval_parts = ["profile_v1", "full_library", "path_builder", "semantics"]
@@ -909,6 +2259,8 @@ def suggest_progression_path(
retrieval_parts.append("roadmap_edited")
if roadmap_unfilled:
retrieval_parts.append("roadmap_unfilled")
+ if rematch_log:
+ retrieval_parts.append("path_rematch")
return {
"goal_query": goal_query,
@@ -927,11 +2279,14 @@ def suggest_progression_path(
"roadmap_only": False,
"roadmap_edited": roadmap_edited,
"roadmap_unfilled_count": len(roadmap_unfilled),
+ "path_skill_expectations": path_skill_expectations,
+ "match_summary": match_summary,
"retrieval_phase": "+".join(retrieval_parts),
}
__all__ = [
+ "EvaluateStepPayload",
"ProgressionPathSuggestRequest",
"suggest_progression_path",
"_pick_best_path_hit",
diff --git a/backend/planning_exercise_path_qa.py b/backend/planning_exercise_path_qa.py
index 2f41247..d3e50a7 100644
--- a/backend/planning_exercise_path_qa.py
+++ b/backend/planning_exercise_path_qa.py
@@ -18,10 +18,18 @@ from openrouter_chat import (
from planning_exercise_semantics import (
PlanningSemanticBrief,
+ _blob_from_fields,
+ _blob_matches_stage_excludes,
brief_to_summary_dict,
exercise_passes_path_semantic_gate,
+ exercise_passes_stage_learning_goal_gate,
+ exercise_passes_technique_path_scope,
+ resolve_path_anti_patterns,
+ resolve_path_primary_topic,
score_exercise_semantic_relevance,
+ semantic_brief_for_stage,
step_phase_for_index,
+ technique_sibling_excludes,
)
_logger = logging.getLogger("shinkan.planning_exercise_path_qa")
@@ -174,6 +182,8 @@ def detect_path_gaps(
for i in range(total_segments):
step_a = steps[i]
step_b = steps[i + 1]
+ if step_a.get("exercise_id") is None or step_b.get("exercise_id") is None:
+ continue
if roadmap_first and is_roadmap_planned_neighbor_pair(step_a, step_b):
continue
gap = measure_step_transition_gap(
@@ -391,52 +401,165 @@ def apply_llm_path_reorder(
_OFF_TOPIC_SEMANTIC_MAX = 0.10
+def _with_roadmap_major_index(
+ step: Mapping[str, Any],
+ entry: Dict[str, Any],
+) -> Dict[str, Any]:
+ midx = step.get("roadmap_major_step_index")
+ if midx is not None:
+ entry["roadmap_major_step_index"] = int(midx)
+ return entry
+
+
def detect_off_topic_steps(
cur,
steps: Sequence[Mapping[str, Any]],
*,
brief: PlanningSemanticBrief,
+ goal_query: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Schritte ohne Bezug zum Pfad-Thema (z. B. reine Kraftübungen bei Mae Geri)."""
- if brief.semantic_strength < 0.55 or len(steps) < 2:
+ if len(steps) < 2:
+ return []
+ roadmap_stage_steps = any(
+ (step.get("roadmap_match_source") == "stage_spec")
+ or (step.get("roadmap_learning_goal") or "").strip()
+ for step in steps
+ )
+ if brief.semantic_strength < 0.55 and not roadmap_stage_steps:
return []
+ path_anti = resolve_path_anti_patterns(goal_query or "", semantic_brief=brief)
off_topic: List[Dict[str, Any]] = []
total = len(steps)
for idx, step in enumerate(steps):
if step.get("is_ai_proposal") or step.get("exercise_id") is None:
continue
bundle = _load_exercise_text_bundle(cur, int(step["exercise_id"]))
- phase = step_phase_for_index(brief, idx, total)
+ blob = _blob_from_fields(
+ bundle["title"],
+ bundle["summary"],
+ bundle["goal"],
+ bundle["variant_names"],
+ )
+ step_anti = list(step.get("roadmap_anti_patterns") or []) + path_anti
+ if step_anti and _blob_matches_stage_excludes(blob, step_anti):
+ off_topic.append(
+ _with_roadmap_major_index(
+ step,
+ {
+ "step_index": idx,
+ "exercise_id": int(step["exercise_id"]),
+ "title": step.get("title") or bundle["title"],
+ "semantic_score": 0.0,
+ "expected_phase": (step.get("roadmap_phase") or "").strip().lower() or None,
+ "issue": "path_exclude",
+ "reasons": ["Widerspricht Pfad-Ausschlüssen (z. B. Kumite)"],
+ },
+ )
+ )
+ continue
+ stage_goal_pre = (step.get("roadmap_learning_goal") or "").strip()
+ primary = (
+ resolve_path_primary_topic(
+ goal_query or "",
+ brief,
+ stage_learning_goal=stage_goal_pre or None,
+ )
+ or ""
+ ).strip()
+ if primary:
+ siblings = technique_sibling_excludes(primary)
+ if not exercise_passes_technique_path_scope(
+ primary_topic=primary,
+ title=bundle["title"],
+ summary=bundle["summary"],
+ goal=bundle["goal"],
+ learning_goal=stage_goal_pre,
+ sibling_excludes=siblings,
+ relaxed=False,
+ ):
+ off_topic.append(
+ _with_roadmap_major_index(
+ step,
+ {
+ "step_index": idx,
+ "exercise_id": int(step["exercise_id"]),
+ "title": step.get("title") or bundle["title"],
+ "semantic_score": 0.0,
+ "expected_phase": (step.get("roadmap_phase") or "").strip().lower() or None,
+ "issue": "technique_scope",
+ "reasons": [f"Passt nicht zur Haupttechnik „{primary}“"],
+ },
+ )
+ )
+ continue
+ stage_goal = (step.get("roadmap_learning_goal") or "").strip()
+ phase = (step.get("roadmap_phase") or "").strip().lower() or step_phase_for_index(
+ brief, idx, total
+ )
+ step_brief = (
+ semantic_brief_for_stage(brief, learning_goal=stage_goal, phase=phase or None)
+ if stage_goal
+ else brief
+ )
sem, sem_reasons = score_exercise_semantic_relevance(
title=bundle["title"],
summary=bundle["summary"],
goal=bundle["goal"],
variant_names=bundle["variant_names"],
- brief=brief,
+ brief=step_brief,
step_phase=phase,
)
+ stage_anti = list(step.get("roadmap_anti_patterns") or [])
+ if stage_goal and not exercise_passes_stage_learning_goal_gate(
+ learning_goal=stage_goal,
+ title=bundle["title"],
+ summary=bundle["summary"],
+ goal=bundle["goal"],
+ semantic_score=sem,
+ anti_patterns=stage_anti or None,
+ ):
+ off_topic.append(
+ _with_roadmap_major_index(
+ step,
+ {
+ "step_index": idx,
+ "exercise_id": int(step["exercise_id"]),
+ "title": step.get("title") or bundle["title"],
+ "semantic_score": round(sem, 4),
+ "expected_phase": phase,
+ "issue": "stage_mismatch",
+ "roadmap_learning_goal": stage_goal,
+ "reasons": sem_reasons[:3],
+ },
+ )
+ )
+ continue
if exercise_passes_path_semantic_gate(
semantic_score=sem,
title=bundle["title"],
summary=bundle["summary"],
goal=bundle["goal"],
- brief=brief,
+ brief=step_brief,
strict=True,
):
continue
if sem > _OFF_TOPIC_SEMANTIC_MAX:
continue
off_topic.append(
- {
- "step_index": idx,
- "exercise_id": int(step["exercise_id"]),
- "title": step.get("title") or bundle["title"],
- "semantic_score": round(sem, 4),
- "expected_phase": phase,
- "issue": "off_topic",
- "reasons": sem_reasons[:3],
- }
+ _with_roadmap_major_index(
+ step,
+ {
+ "step_index": idx,
+ "exercise_id": int(step["exercise_id"]),
+ "title": step.get("title") or bundle["title"],
+ "semantic_score": round(sem, 4),
+ "expected_phase": phase,
+ "issue": "off_topic",
+ "reasons": sem_reasons[:3],
+ },
+ )
)
return off_topic
@@ -497,9 +620,10 @@ def strip_off_topic_steps_from_path(
return steps, []
by_index = {int(o["step_index"]): dict(o) for o in off_topic_steps if o.get("step_index") is not None}
- indices = sorted(by_index.keys(), reverse=True)
- if len(steps) - len(indices) < min_remaining:
+ max_remove = max(0, len(steps) - min_remaining)
+ if max_remove <= 0:
return steps, []
+ indices = sorted(by_index.keys(), reverse=True)[:max_remove]
out = list(steps)
removed: List[Dict[str, Any]] = []
@@ -541,6 +665,7 @@ def build_path_qa_summary(
reorder_applied: bool = False,
reorder_notes: Optional[Sequence[str]] = None,
roadmap_qa_mode: Optional[str] = None,
+ multistage_qa: Optional[Mapping[str, Any]] = None,
) -> Dict[str, Any]:
offers = list(gap_fill_offers or [])
off_topic = list(off_topic_steps or [])
@@ -561,6 +686,10 @@ def build_path_qa_summary(
"reorder_notes": list(reorder_notes or []),
"roadmap_qa_mode": roadmap_qa_mode,
}
+ if multistage_qa:
+ summary["qa_tiers"] = list(multistage_qa.get("qa_tiers") or [])
+ summary["optimization_hints"] = list(multistage_qa.get("optimization_hints") or [])
+ summary["optimization_hint_count"] = int(multistage_qa.get("optimization_hint_count") or 0)
if llm_qa:
summary["overall_ok"] = bool(llm_qa.get("overall_ok", True))
summary["quality_score"] = llm_qa.get("quality_score")
diff --git a/backend/planning_exercise_retrieval.py b/backend/planning_exercise_retrieval.py
index dcb928e..085e9e7 100644
--- a/backend/planning_exercise_retrieval.py
+++ b/backend/planning_exercise_retrieval.py
@@ -14,10 +14,14 @@ from planning_exercise_profiles import (
load_exercise_match_profiles_bulk,
score_exercise_against_target,
)
+from exercise_ai import strip_html_to_plain
from planning_exercise_semantics import (
PlanningSemanticBrief,
+ build_stage_match_brief,
exercise_passes_path_semantic_gate,
+ exercise_passes_stage_fit,
score_exercise_semantic_relevance,
+ score_exercise_stage_fit,
)
_MAX_LIBRARY_ROWS = 8000
@@ -54,6 +58,119 @@ def _normalize_exercise_kind_filter(exercise_kind_any: Optional[List[str]]) -> L
return out
+_EXERCISE_ROW_SELECT = """
+ SELECT e.id, e.title, e.summary, e.method_archetype,
+ e.visibility, e.club_id, e.created_by,
+ (
+ SELECT fa.name FROM exercise_focus_areas efa
+ JOIN focus_areas fa ON fa.id = efa.focus_area_id
+ WHERE efa.exercise_id = e.id
+ ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
+ LIMIT 1
+ ) AS primary_focus_name,
+ 0.0::float AS ft_rank
+ FROM exercises e
+"""
+
+
+def fetch_exercise_rows_by_ids(
+ cur,
+ exercise_ids: Sequence[int],
+ *,
+ vis_sql: str,
+ vis_params: Sequence[Any],
+) -> List[Dict[str, Any]]:
+ """Lädt konkrete Übungen nach, wenn sie im Graph/Slot verankert sind (Pin-Sicherheit)."""
+ ids = sorted({int(x) for x in exercise_ids if int(x) > 0})
+ if not ids:
+ return []
+ ph = ",".join(["%s"] * len(ids))
+ sql = f"""
+ {_EXERCISE_ROW_SELECT.strip()}
+ WHERE e.id IN ({ph})
+ AND ({vis_sql})
+ AND COALESCE(e.status, '') <> %s
+ """
+ params: List[Any] = list(ids) + list(vis_params) + ["archived"]
+ cur.execute(sql, params)
+ return [dict(r) for r in cur.fetchall()]
+
+
+def fetch_exercise_rows_by_ids_for_graph(
+ cur,
+ exercise_ids: Sequence[int],
+ *,
+ graph_visibility: str,
+ graph_club_id: Optional[int],
+ profile_id: int,
+ role: str,
+ exercise_allowed_fn,
+) -> List[Dict[str, Any]]:
+ """
+ Lädt Übungen nach ID mit Graph-Sichtbarkeitsregeln (nicht Library-vis_sql).
+
+ Ermöglicht Re-Match für im Graph verankerte private Übungen auf Club-Graphen
+ (eigene private) bzw. alle graph-konformen Übungen.
+ """
+ ids = sorted({int(x) for x in exercise_ids if int(x) > 0})
+ if not ids:
+ return []
+ ph = ",".join(["%s"] * len(ids))
+ sql = f"""
+ {_EXERCISE_ROW_SELECT.strip()}
+ WHERE e.id IN ({ph})
+ AND COALESCE(e.status, '') <> %s
+ """
+ cur.execute(sql, [*ids, "archived"])
+ out: List[Dict[str, Any]] = []
+ for row in cur.fetchall() or []:
+ if exercise_allowed_fn(
+ row,
+ graph_visibility=graph_visibility,
+ graph_club_id=graph_club_id,
+ profile_id=profile_id,
+ role=role,
+ ):
+ out.append(dict(row))
+ return out
+
+
+def trim_hits_preserving_priority_ids(
+ hits: Sequence[Mapping[str, Any]],
+ priority_ids: Optional[Sequence[int]],
+ *,
+ limit: int = 48,
+) -> List[Dict[str, Any]]:
+ """Behält priorisierte Graph-/Slot-Übungen im Kandidatenpool (vor pick_best_path_hit)."""
+ priority_set = {int(x) for x in (priority_ids or []) if int(x) > 0}
+ if not priority_set:
+ return list(hits)[:limit]
+ by_id: Dict[int, Dict[str, Any]] = {}
+ for hit in hits:
+ try:
+ by_id[int(hit["id"])] = dict(hit)
+ except (TypeError, ValueError, KeyError):
+ continue
+ priority_hits = [by_id[eid] for eid in sorted(priority_set) if eid in by_id]
+ rest = [dict(h) for h in hits if int(h.get("id") or 0) not in priority_set]
+ merged = priority_hits + rest
+ return merged[: max(limit, len(priority_hits))]
+
+
+def merge_supplemental_exercise_rows(
+ rows: Sequence[Dict[str, Any]],
+ supplemental: Sequence[Dict[str, Any]],
+) -> List[Dict[str, Any]]:
+ seen = {int(r["id"]) for r in rows if r.get("id") is not None}
+ out = list(rows)
+ for row in supplemental:
+ rid = int(row["id"])
+ if rid not in seen:
+ seen.add(rid)
+ out.append(dict(row))
+ return out
+
+
def fetch_all_visible_exercise_rows(
cur,
*,
@@ -148,7 +265,7 @@ def _load_exercise_goals_chunked(cur, exercise_ids: Sequence[int], *, batch: int
ph = ",".join(["%s"] * len(chunk))
cur.execute(f"SELECT id, goal FROM exercises WHERE id IN ({ph})", chunk)
for row in cur.fetchall():
- out[int(row["id"])] = str(row.get("goal") or "")
+ out[int(row["id"])] = strip_html_to_plain(row.get("goal"), max_len=1200)
return out
@@ -200,6 +317,21 @@ def rank_visible_library_hits(
semantic_brief = semantic_brief_raw
step_phase = pack.get("path_step_phase")
path_mode = pack.get("context_mode") == "progression_path"
+ stage_learning_goal = (pack.get("stage_learning_goal") or "").strip()
+ roadmap_stage_match = bool(pack.get("roadmap_stage_match"))
+ stage_match_brief_raw = pack.get("stage_match_brief")
+ stage_match_brief: Optional[PlanningSemanticBrief] = None
+ if isinstance(stage_match_brief_raw, PlanningSemanticBrief):
+ stage_match_brief = stage_match_brief_raw
+ elif roadmap_stage_match and stage_learning_goal:
+ stage_match_brief = build_stage_match_brief(
+ learning_goal=stage_learning_goal,
+ anti_patterns=pack.get("stage_anti_patterns"),
+ success_criteria=pack.get("stage_success_criteria"),
+ load_profile=pack.get("stage_load_profile"),
+ phase=step_phase,
+ path_context_note=pack.get("path_context_note"),
+ )
last_planned_skills: Set[int] = set()
planned_ids = pack.get("planned_exercise_ids") or []
@@ -226,7 +358,11 @@ def rank_visible_library_hits(
skills_by_ex = _load_skill_sets_chunked(cur, cand_ids)
goals_by_ex: Dict[int, str] = {}
variants_by_ex: Dict[int, List[str]] = {}
- if semantic_brief and semantic_brief.semantic_strength > 0.05:
+ need_exercise_semantic_text = (
+ (semantic_brief and semantic_brief.semantic_strength > 0.05)
+ or (stage_match_brief and stage_match_brief.semantic_strength > 0.05)
+ )
+ if need_exercise_semantic_text:
goals_by_ex = _load_exercise_goals_chunked(cur, cand_ids)
variants_by_ex = _load_variant_names_chunked(cur, cand_ids)
@@ -267,37 +403,99 @@ def rank_visible_library_hits(
emp, target, intent=intent
)
+ title_s = str(row.get("title") or "")
+ summary_s = str(row.get("summary") or "")
+ goal_s = goals_by_ex.get(eid, "")
+
semantic_score = 0.0
semantic_reasons: List[str] = []
if semantic_brief and semantic_brief.semantic_strength > 0.05:
semantic_score, semantic_reasons = score_exercise_semantic_relevance(
- title=str(row.get("title") or ""),
- summary=str(row.get("summary") or ""),
- goal=goals_by_ex.get(eid, ""),
+ title=title_s,
+ summary=summary_s,
+ goal=goal_s,
variant_names=variants_by_ex.get(eid, []),
brief=semantic_brief,
step_phase=step_phase,
)
+ stage_semantic_score = 0.0
+ stage_semantic_reasons: List[str] = []
+ if stage_match_brief and stage_match_brief.semantic_strength > 0.05:
+ stage_semantic_score, stage_semantic_reasons = score_exercise_stage_fit(
+ title=title_s,
+ summary=summary_s,
+ goal=goal_s,
+ variant_names=variants_by_ex.get(eid, []),
+ stage_brief=stage_match_brief,
+ step_phase=step_phase,
+ )
+
+ rank_stage_sem = stage_semantic_score
+ stage_lg = (stage_learning_goal or "").strip()
+ if roadmap_stage_match and stage_lg:
+ raw_brief = build_stage_match_brief(
+ learning_goal=stage_lg,
+ anti_patterns=pack.get("stage_anti_patterns"),
+ phase=step_phase,
+ )
+ raw_sem, raw_reasons = score_exercise_stage_fit(
+ title=title_s,
+ summary=summary_s,
+ goal=goal_s,
+ variant_names=variants_by_ex.get(eid, []),
+ stage_brief=raw_brief,
+ step_phase=step_phase,
+ )
+ rank_stage_sem = max(stage_semantic_score, raw_sem)
+ if raw_sem > stage_semantic_score and raw_reasons:
+ for rr in raw_reasons:
+ if rr not in stage_semantic_reasons:
+ stage_semantic_reasons.append(rr)
+
+ effective_semantic = (
+ rank_stage_sem
+ if roadmap_stage_match and stage_match_brief
+ else semantic_score
+ )
+
+ score_penalty = 0.0
+ stage_match_reason: Optional[str] = None
if (
path_mode
+ and not roadmap_stage_match
and semantic_brief
and semantic_brief.semantic_strength >= 0.55
and not exercise_passes_path_semantic_gate(
semantic_score=semantic_score,
- title=str(row.get("title") or ""),
- summary=str(row.get("summary") or ""),
- goal=goals_by_ex.get(eid, ""),
+ title=title_s,
+ summary=summary_s,
+ goal=goal_s,
brief=semantic_brief,
strict=True,
)
):
score_penalty = 0.42
- else:
- score_penalty = 0.0
+ if roadmap_stage_match and stage_learning_goal:
+ if exercise_passes_stage_fit(
+ learning_goal=stage_learning_goal,
+ title=title_s,
+ summary=summary_s,
+ goal=goal_s,
+ stage_brief=stage_match_brief,
+ stage_semantic_score=rank_stage_sem,
+ anti_patterns=pack.get("stage_anti_patterns"),
+ step_phase=step_phase,
+ path_primary_topic=pack.get("path_primary_topic"),
+ path_technique_excludes=pack.get("path_technique_excludes"),
+ ):
+ score_penalty = max(0.0, score_penalty - 0.10)
+ stage_match_reason = "Passt zum Stufen-Lernziel"
+ else:
+ score_penalty += 0.48
score = (
- weights.get("semantic", 0.0) * semantic_score
+ weights.get("semantic", 0.0) * effective_semantic
+ weights["fulltext"] * ft_norm
+ weights["progression"] * prog_hit
+ weights["skill"] * skill_sim
@@ -309,7 +507,13 @@ def rank_visible_library_hits(
)
reasons: List[str] = []
- if semantic_score >= 0.35 and semantic_reasons:
+ if stage_match_reason:
+ reasons.append(stage_match_reason)
+ if roadmap_stage_match and stage_semantic_score >= 0.30 and stage_semantic_reasons:
+ for sr in stage_semantic_reasons:
+ if sr not in reasons:
+ reasons.append(sr)
+ elif semantic_score >= 0.35 and semantic_reasons:
for sr in semantic_reasons:
if sr not in reasons:
reasons.append(sr)
@@ -345,6 +549,9 @@ def rank_visible_library_hits(
"score": round(max(0.0, min(1.0, score)), 4),
"reasons": reasons,
"semantic_score": round(semantic_score, 4),
+ "stage_semantic_score": round(stage_semantic_score, 4),
+ "stage_rank_semantic": round(rank_stage_sem, 4),
+ "goal": goal_s,
}
)
succ_variants = pack.get("progression_successor_variants") or {}
@@ -367,6 +574,8 @@ def run_multistage_planning_retrieval(
intent: str,
intent_weights: Mapping[str, float],
pack: Mapping[str, Any],
+ supplemental_exercise_ids: Optional[Sequence[int]] = None,
+ supplemental_rows_preloaded: Optional[Sequence[Dict[str, Any]]] = None,
) -> Tuple[List[Dict[str, Any]], Dict[int, Set[int]], bool]:
"""Orchestriert S1b-0 → S1b-1 (Voll-Library-Ranking)."""
rows = fetch_all_visible_exercise_rows(
@@ -376,6 +585,16 @@ def run_multistage_planning_retrieval(
query=pack.get("retrieval_query") or query,
exercise_kind_any=exercise_kind_any,
)
+ if supplemental_rows_preloaded:
+ rows = merge_supplemental_exercise_rows(rows, supplemental_rows_preloaded)
+ elif supplemental_exercise_ids:
+ extra = fetch_exercise_rows_by_ids(
+ cur,
+ supplemental_exercise_ids,
+ vis_sql=vis_sql,
+ vis_params=vis_params,
+ )
+ rows = merge_supplemental_exercise_rows(rows, extra)
hits, skills_by_ex = rank_visible_library_hits(
cur,
rows,
@@ -411,8 +630,10 @@ def profile_preselect_rows(
__all__ = [
"fetch_all_visible_exercise_rows",
+ "fetch_exercise_rows_by_ids",
"fetch_retrieval_candidate_rows",
"hybrid_score_planning_hits",
+ "merge_supplemental_exercise_rows",
"profile_preselect_rows",
"rank_visible_library_hits",
"run_multistage_planning_retrieval",
diff --git a/backend/planning_exercise_semantics.py b/backend/planning_exercise_semantics.py
index 7571233..501c884 100644
--- a/backend/planning_exercise_semantics.py
+++ b/backend/planning_exercise_semantics.py
@@ -9,6 +9,7 @@ from __future__ import annotations
import json
import logging
import re
+from dataclasses import dataclass, field
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
from pydantic import BaseModel, Field, field_validator
@@ -152,6 +153,48 @@ def _normalize_phrase(text: str) -> str:
return re.sub(r"\s+", " ", (text or "").strip().lower())
+_STAGE_TITLE_STOP = frozenset(
+ {"für", "fur", "und", "der", "die", "das", "mit", "im", "in", "am", "an", "zur", "zum", "den", "dem", "des"}
+)
+
+
+def _stage_title_tokens(text: str) -> List[str]:
+ return [
+ tok
+ for tok in _normalize_phrase(text).split()
+ if tok not in _STAGE_TITLE_STOP and len(tok) > 1
+ ]
+
+
+def exercise_title_equivalent_to_stage_goal(title: str, learning_goal: str) -> bool:
+ """
+ Titel entspricht dem Stufen-Lernziel (wortgleich oder nahezu identisch).
+
+ Deckt Graph-Slots ab, bei denen die Übung gezielt zum Lernziel angelegt wurde,
+ ohne dass die Pfad-Haupttechnik im Übungstext vorkommt.
+ """
+ t = _normalize_phrase(title)
+ lg = _normalize_phrase(learning_goal)
+ if len(t) < 3 or len(lg) < 3:
+ return False
+ if t == lg:
+ return True
+ shorter, longer = (t, lg) if len(t) <= len(lg) else (lg, t)
+ if shorter in longer and len(shorter) >= 8 and len(shorter) / max(len(longer), 1) >= 0.72:
+ return True
+ t_tok = _stage_title_tokens(title)
+ lg_tok = _stage_title_tokens(learning_goal)
+ if len(t_tok) >= 2 and t_tok == lg_tok:
+ return True
+ if len(t_tok) >= 2 and len(lg_tok) >= 2:
+ t_set = set(t_tok)
+ lg_set = set(lg_tok)
+ overlap = len(t_set & lg_set)
+ if overlap >= 2 and overlap / max(len(t_set), len(lg_set)) >= 0.85:
+ return True
+ return False
+
+
def _normalize_query(text: str) -> str:
return re.sub(r"\s+", " ", (text or "").strip())
@@ -179,6 +222,79 @@ def _find_technique_in_text(q_lower: str) -> Optional[Tuple[str, Tuple[str, ...]
return None
+def resolve_path_primary_topic(
+ goal_query: str,
+ semantic_brief: Optional[PlanningSemanticBrief] = None,
+ *,
+ stage_learning_goal: Optional[str] = None,
+ extra_context: Optional[str] = None,
+) -> Optional[str]:
+ """
+ Haupttechnik aus Anfrage, Kontext oder Stufen-Lernziel — nicht nur aus goal_query.
+ """
+ if semantic_brief:
+ primary = (semantic_brief.primary_topic or "").strip()
+ if primary:
+ return primary
+ parts = [goal_query or "", extra_context or "", stage_learning_goal or ""]
+ combined = _normalize_phrase(" ".join(p for p in parts if p))
+ if not combined:
+ return None
+ hit = _find_technique_in_text(combined.lower())
+ return hit[0] if hit else None
+
+
+def technique_sibling_excludes(primary_topic: str) -> List[str]:
+ """Andere Techniken derselben Familie (z. B. Mae/Yoko bei Mawashi) — aus Katalog."""
+ topic = _normalize_phrase(primary_topic)
+ if not topic:
+ return []
+ hit = _find_technique_in_text(topic)
+ if not hit:
+ return []
+ out: List[str] = []
+ for raw in hit[1]:
+ for expanded in _expand_stage_exclude_phrase(raw):
+ if expanded and expanded not in out:
+ out.append(expanded)
+ return out[:16]
+
+
+def exercise_passes_technique_path_scope(
+ *,
+ primary_topic: str,
+ title: str,
+ summary: str = "",
+ goal: str = "",
+ learning_goal: str = "",
+ sibling_excludes: Optional[Sequence[str]] = None,
+ relaxed: bool = False,
+) -> bool:
+ """
+ Technik-Pfad: keine Geschwister-Technik; Haupttechnik muss im Übungstext vorkommen.
+
+ Das Stufen-Lernziel allein reicht nicht — sonst würden themenfremde Übungen (z. B. Kumite)
+ nur wegen „Mawashi Geri“ im Lernziel durch das Gate rutschen.
+ """
+ primary = _normalize_phrase(primary_topic)
+ if not primary:
+ return True
+
+ blob = _blob_from_fields(title, summary, goal, [])
+ excludes = list(sibling_excludes or technique_sibling_excludes(primary))
+ if excludes and _blob_matches_stage_excludes(blob, excludes):
+ return False
+
+ if _phrase_in_blob(primary, blob):
+ return True
+
+ if relaxed:
+ parts = [p for p in primary.split() if len(p) >= 4]
+ if parts and any(_phrase_in_blob(part, blob) for part in parts):
+ return True
+ return False
+
+
def _detect_development_arc(q_lower: str) -> List[str]:
found: List[str] = []
for phase, markers in _ARC_PHASES:
@@ -245,6 +361,11 @@ def build_semantic_brief(query: Optional[str]) -> PlanningSemanticBrief:
if len(q) >= 24 and not technique:
strength = max(strength, 0.4)
+ path_constraints = parse_stage_goal_constraints(q)
+ for item in path_constraints.exclude_phrases:
+ if item not in exclude:
+ exclude.append(item)
+
return PlanningSemanticBrief(
primary_topic=primary,
topic_type=topic_type,
@@ -462,7 +583,7 @@ def score_exercise_semantic_relevance(
core_hits = sum(1 for ph in core if _phrase_in_blob(ph, blob))
must_hits = sum(1 for ph in must if _phrase_in_blob(ph, blob))
- exclude_hits = sum(1 for ph in exclude if _phrase_in_blob(ph, blob))
+ exclude_hits = sum(1 for ph in exclude if _phrase_excluded_in_blob(ph, blob))
score = 0.0
if core:
@@ -604,6 +725,510 @@ def apply_path_retrieval_weights(brief: PlanningSemanticBrief) -> Dict[str, floa
}
+_STAGE_GOAL_STOPWORDS = _QUERY_STOPWORDS | frozenset(
+ {
+ "stufe",
+ "phase",
+ "lernziel",
+ "grundlage",
+ "vertiefung",
+ "anwendung",
+ "perfektion",
+ "einstieg",
+ "sicher",
+ "sauber",
+ "korrekt",
+ "technik",
+ "training",
+ }
+)
+
+
+_STAGE_NEGATION_PATTERNS = (
+ r"\bohne\s+([^,.;]+)",
+ r"\bkein(?:e|en|er|em)?\s+([^,.;]+)",
+ r"\bnicht\s+([^,.;]+)",
+)
+
+# Aus „ohne Tritttechnik“ etc. — erweiterte Treffer im Übungstext
+_STAGE_EXCLUDE_ALIASES: Dict[str, Tuple[str, ...]] = {
+ "tritttechnik": (
+ "tritttechnik",
+ "trittpraezision",
+ "trittpräzision",
+ "tritt praesision",
+ "tritt-präzision",
+ "kicktechnik",
+ "tritt ausführung",
+ "tritt ausfuehrung",
+ ),
+ "kumite": ("kumite", "partnerkampf", "freikampf", "jiyu kumite"),
+ "kraftuebung": ("kraftuebung", "kraftübung", "krafttraining", "kraftübungen"),
+ "anwendung": ("kumite anwendung", "kampfanwendung"),
+}
+
+_STAGE_FOCUS_TOKENS = frozenset(
+ {
+ "koordination",
+ "absprung",
+ "beinhebung",
+ "landung",
+ "sprung",
+ "sprungphase",
+ "balance",
+ "gleichgewicht",
+ "timing",
+ "vorbereitung",
+ "athletik",
+ "mobilitaet",
+ "mobilität",
+ "stabilisation",
+ "stabilisierung",
+ }
+)
+
+
+@dataclass
+class StageGoalConstraints:
+ positive_tokens: List[str] = field(default_factory=list)
+ exclude_phrases: List[str] = field(default_factory=list)
+ has_negation: bool = False
+ strict_positive: bool = False
+
+
+def _expand_stage_exclude_phrase(phrase: str) -> List[str]:
+ norm = _normalize_phrase(phrase)
+ if not norm:
+ return []
+ out: List[str] = [norm]
+ compact = norm.replace(" ", "")
+ if compact and compact not in out:
+ out.append(compact)
+ for key, aliases in _STAGE_EXCLUDE_ALIASES.items():
+ if key in norm or norm in key:
+ for alias in aliases:
+ a = _normalize_phrase(alias)
+ if a and a not in out:
+ out.append(a)
+ return out[:12]
+
+
+def _significant_stage_tokens(learning_goal: str, *, strip_negated: bool = True) -> List[str]:
+ """Wörter aus Stufen-Lernziel für Text-Match (ohne Füllwörter, ohne Negationssegmente)."""
+ text = _normalize_phrase(learning_goal)
+ if strip_negated:
+ for pat in _STAGE_NEGATION_PATTERNS:
+ text = re.sub(pat, " ", text)
+ raw = re.findall(r"[a-zäöüß]{4,}", text, flags=re.IGNORECASE)
+ out: List[str] = []
+ for w in raw:
+ low = w.lower().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue")
+ if low in _STAGE_GOAL_STOPWORDS:
+ continue
+ if low not in out:
+ out.append(low)
+ return out[:10]
+
+
+def parse_stage_goal_constraints(
+ learning_goal: str,
+ anti_patterns: Optional[Sequence[str]] = None,
+) -> StageGoalConstraints:
+ """Positiv/Negativ aus Stufen-Lernziel + anti_patterns (Roadmap-Stufe)."""
+ lg = (learning_goal or "").strip()
+ if len(lg) < 3:
+ return StageGoalConstraints()
+
+ norm = _normalize_phrase(lg)
+ exclude: List[str] = []
+ has_negation = False
+ for pat in _STAGE_NEGATION_PATTERNS:
+ for m in re.finditer(pat, norm):
+ has_negation = True
+ chunk = (m.group(1) or "").strip()
+ if chunk:
+ exclude.extend(_expand_stage_exclude_phrase(chunk))
+
+ for raw in anti_patterns or []:
+ s = _normalize_phrase(str(raw or ""))
+ if s:
+ exclude.extend(_expand_stage_exclude_phrase(s))
+
+ positive = _significant_stage_tokens(lg, strip_negated=True)
+ focus_hits = [t for t in positive if t in _STAGE_FOCUS_TOKENS]
+ strict_positive = bool(focus_hits) or has_negation
+
+ dedup_exclude: List[str] = []
+ for item in exclude:
+ if item and item not in dedup_exclude:
+ dedup_exclude.append(item)
+
+ return StageGoalConstraints(
+ positive_tokens=positive,
+ exclude_phrases=dedup_exclude[:16],
+ has_negation=has_negation,
+ strict_positive=strict_positive,
+ )
+
+
+def _phrase_excluded_in_blob(phrase: str, blob: str) -> bool:
+ """Treffer nur wenn das Ausschluss-Thema nicht selbst negiert beschrieben ist."""
+ if not phrase or not blob:
+ return False
+ if not _phrase_in_blob(phrase, blob):
+ return False
+ norm = _normalize_phrase(phrase)
+ for pat in _STAGE_NEGATION_PATTERNS:
+ for m in re.finditer(pat, blob):
+ chunk = _normalize_phrase(m.group(1) or "")
+ if not chunk:
+ continue
+ if norm in chunk or chunk in norm or _phrase_in_blob(norm, chunk):
+ return False
+ return True
+
+
+def _blob_matches_stage_excludes(blob: str, exclude_phrases: Sequence[str]) -> bool:
+ for phrase in exclude_phrases:
+ if _phrase_excluded_in_blob(phrase, blob):
+ return True
+ return False
+
+
+def resolve_path_anti_patterns(
+ goal_query: str,
+ *,
+ semantic_brief: Optional[PlanningSemanticBrief] = None,
+ extra_context: Optional[str] = None,
+) -> List[str]:
+ """
+ Pfadweite Ausschlüsse — nur aus expliziten Quellen, kein Themen-Raten.
+
+ Quellen (in dieser Reihenfolge):
+ 1. Negationen in Anfrage/Kontext (ohne/kein/nicht …) via parse_stage_goal_constraints
+ 2. exclude_phrases im Semantic Brief (inkl. LLM/Technik-Regeln)
+ 3. stage_specs.anti_patterns (Roadmap-Stufe, vom Trainer oder LLM)
+
+ Keine stillen Ausschlüsse aus dem Hauptthema (z. B. „Mawashi“ → kein Kumite).
+ """
+ parts = [str(goal_query or "").strip(), str(extra_context or "").strip()]
+ combined = " ".join(p for p in parts if p)
+ if not combined and not semantic_brief:
+ return []
+
+ constraints = parse_stage_goal_constraints(combined) if combined else StageGoalConstraints()
+ out: List[str] = []
+ for item in constraints.exclude_phrases:
+ if item and item not in out:
+ out.append(item)
+
+ if semantic_brief:
+ for raw in semantic_brief.exclude_phrases or []:
+ for expanded in _expand_stage_exclude_phrase(str(raw or "")):
+ if expanded and expanded not in out:
+ out.append(expanded)
+
+ return out[:24]
+
+
+def enrich_brief_with_path_constraints(
+ brief: PlanningSemanticBrief,
+ goal_query: str,
+ *,
+ extra_context: Optional[str] = None,
+) -> PlanningSemanticBrief:
+ """Negationen/Ausschlüsse aus der Gesamtanfrage in den Semantic Brief übernehmen."""
+ anti = resolve_path_anti_patterns(
+ goal_query,
+ semantic_brief=brief,
+ extra_context=extra_context,
+ )
+ if not anti:
+ return brief
+ exclude = list(brief.exclude_phrases or [])
+ for item in anti:
+ if item not in exclude:
+ exclude.append(item)
+ return brief.model_copy(update={"exclude_phrases": exclude[:16]})
+
+
+_MIN_STAGE_FIT_SEMANTIC = 0.30
+_MIN_STAGE_FIT_RELAXED = 0.20
+_MIN_TITLE_EQUIV_SEMANTIC = 0.15
+_MIN_ROADMAP_FALLBACK_RANK = 0.15
+
+
+def build_stage_match_brief(
+ *,
+ learning_goal: str,
+ anti_patterns: Optional[Sequence[str]] = None,
+ success_criteria: Optional[Sequence[str]] = None,
+ load_profile: Optional[Sequence[str]] = None,
+ phase: Optional[str] = None,
+ path_context_note: Optional[str] = None,
+ path_anti_patterns: Optional[Sequence[str]] = None,
+ path_primary_topic: Optional[str] = None,
+ path_technique_excludes: Optional[Sequence[str]] = None,
+ stage_start_state: Optional[str] = None,
+ stage_target_state: Optional[str] = None,
+ path_target_state: Optional[str] = None,
+ contextualized_learning_goal: Optional[str] = None,
+) -> PlanningSemanticBrief:
+ """
+ Stufen-zentrierter Semantik-Brief — unabhängig vom Gesamt-Pfad-Thema.
+
+ Primär für Roadmap-Match: Bewertung gegen Titel + Kurzbeschreibung + Übungsziel.
+ """
+ lg = (contextualized_learning_goal or learning_goal or "").strip()
+ if len(lg) < 3:
+ return PlanningSemanticBrief(semantic_strength=0.0)
+
+ merged_anti: List[str] = []
+ for raw in list(anti_patterns or []) + list(path_anti_patterns or []):
+ s = str(raw or "").strip()
+ if s and s not in merged_anti:
+ merged_anti.append(s)
+ primary_path = _normalize_phrase(path_primary_topic or "")
+ if primary_path:
+ for item in technique_sibling_excludes(primary_path):
+ if item not in merged_anti:
+ merged_anti.append(item)
+ for raw in path_technique_excludes or []:
+ for expanded in _expand_stage_exclude_phrase(str(raw or "")):
+ if expanded and expanded not in merged_anti:
+ merged_anti.append(expanded)
+ constraints = parse_stage_goal_constraints(lg, merged_anti)
+ must: List[str] = []
+ norm_lg = _normalize_phrase(lg)
+ if primary_path and primary_path not in must:
+ must.insert(0, primary_path[:120])
+ for token in constraints.positive_tokens:
+ if token not in must:
+ must.append(token)
+ if norm_lg and norm_lg not in must:
+ must.append(norm_lg[:120])
+ for raw in success_criteria or []:
+ s = _normalize_phrase(str(raw or ""))
+ if s and s not in must:
+ must.append(s[:100])
+ for raw in load_profile or []:
+ s = _normalize_phrase(str(raw or ""))
+ if s and s not in must:
+ must.append(s[:60])
+
+ retrieval_parts = [norm_lg]
+ for raw in (stage_start_state, stage_target_state, path_target_state):
+ s = _normalize_phrase(str(raw or ""))[:200]
+ if s and s not in retrieval_parts:
+ retrieval_parts.append(s)
+ if path_context_note:
+ note = _normalize_phrase(path_context_note)[:200]
+ if note:
+ retrieval_parts.append(note)
+
+ arc: List[str] = []
+ ph = (phase or "").strip().lower()
+ if ph:
+ arc.append(ph)
+
+ return PlanningSemanticBrief(
+ primary_topic="",
+ topic_type="focus",
+ must_phrases=must[:12],
+ exclude_phrases=list(constraints.exclude_phrases)[:12],
+ development_arc=arc[:4],
+ retrieval_query=" ".join(p for p in retrieval_parts if p)[:500],
+ semantic_strength=0.78,
+ rationale="stage_match_brief",
+ )
+
+
+def score_exercise_stage_fit(
+ *,
+ title: str,
+ summary: str,
+ goal: str,
+ stage_brief: PlanningSemanticBrief,
+ variant_names: Optional[Sequence[str]] = None,
+ step_phase: Optional[str] = None,
+) -> Tuple[float, List[str]]:
+ """Semantik-Score Übung ↔ Stufen-Lernziel (Titel + Summary + Goal)."""
+ score, reasons = score_exercise_semantic_relevance(
+ title=title,
+ summary=summary,
+ goal=goal,
+ variant_names=variant_names or [],
+ brief=stage_brief,
+ step_phase=step_phase,
+ )
+ blob = _blob_from_fields(title, summary, goal, variant_names or [])
+ focus_tokens = [
+ t
+ for t in (stage_brief.must_phrases or [])
+ if t and " " not in t and len(t) >= 4
+ ][:6]
+ if focus_tokens:
+ hits = sum(1 for t in focus_tokens if _phrase_in_blob(t, blob))
+ ratio = hits / len(focus_tokens)
+ bonus = 0.28 * ratio
+ if bonus > 0:
+ score = min(1.0, score + bonus)
+ if hits >= max(1, len(focus_tokens) // 2):
+ reasons = ["Stufen-Schwerpunkte im Übungstext", *reasons]
+ return max(0.0, min(1.0, round(score, 4))), reasons[:4]
+
+
+def exercise_passes_stage_fit(
+ *,
+ learning_goal: str,
+ title: str,
+ summary: str = "",
+ goal: str = "",
+ stage_brief: Optional[PlanningSemanticBrief] = None,
+ stage_semantic_score: Optional[float] = None,
+ anti_patterns: Optional[Sequence[str]] = None,
+ step_phase: Optional[str] = None,
+ path_primary_topic: Optional[str] = None,
+ path_technique_excludes: Optional[Sequence[str]] = None,
+ min_stage_semantic: float = _MIN_STAGE_FIT_SEMANTIC,
+ relaxed: bool = False,
+) -> bool:
+ """Allgemeines Stufen-Fit-Gate: voller Übungstext vs. Stufen-Brief."""
+ lg = (learning_goal or "").strip()
+ if len(lg) < 3 and not (path_primary_topic or "").strip():
+ return True
+
+ blob = _blob_from_fields(title, summary, goal, [])
+ constraints = parse_stage_goal_constraints(lg, anti_patterns)
+ if constraints.exclude_phrases and _blob_matches_stage_excludes(blob, constraints.exclude_phrases):
+ return False
+
+ title_equiv = exercise_title_equivalent_to_stage_goal(title, learning_goal or lg)
+
+ primary_path = (path_primary_topic or "").strip()
+ if not primary_path and lg:
+ hit = _find_technique_in_text(_normalize_phrase(lg))
+ if hit:
+ primary_path = hit[0]
+ tech_excludes = list(path_technique_excludes or [])
+ if primary_path:
+ for item in technique_sibling_excludes(primary_path):
+ if item not in tech_excludes:
+ tech_excludes.append(item)
+ if primary_path and not title_equiv and not exercise_passes_technique_path_scope(
+ primary_topic=primary_path,
+ title=title,
+ summary=summary,
+ goal=goal,
+ learning_goal=lg,
+ sibling_excludes=tech_excludes,
+ relaxed=relaxed,
+ ):
+ return False
+
+ brief = stage_brief or build_stage_match_brief(
+ learning_goal=lg,
+ anti_patterns=anti_patterns,
+ )
+ stage_sem = stage_semantic_score
+ if stage_sem is None:
+ stage_sem, _ = score_exercise_stage_fit(
+ title=title,
+ summary=summary,
+ goal=goal,
+ stage_brief=brief,
+ step_phase=step_phase,
+ )
+
+ if relaxed:
+ threshold = _MIN_STAGE_FIT_RELAXED
+ elif title_equiv:
+ threshold = _MIN_TITLE_EQUIV_SEMANTIC
+ else:
+ threshold = min_stage_semantic
+ return float(stage_sem or 0.0) >= threshold
+
+
+def apply_stage_match_retrieval_weights(brief: PlanningSemanticBrief) -> Dict[str, float]:
+ """Roadmap-Stufe: Stufen-Semantik (Ziel/Summary/Goal) dominiert."""
+ return {
+ "semantic": 0.58,
+ "fulltext": 0.14,
+ "profile": 0.18,
+ "progression": 0.04,
+ "skill": 0.04,
+ "plan": 0.02,
+ "repeat_unit": -0.40,
+ "repeat_group": -0.15,
+ }
+
+
+def semantic_brief_for_stage(
+ brief: PlanningSemanticBrief,
+ *,
+ learning_goal: str,
+ phase: Optional[str] = None,
+ anti_patterns: Optional[Sequence[str]] = None,
+) -> PlanningSemanticBrief:
+ """Legacy: globalen Brief anreichern — bevorzugt build_stage_match_brief für Roadmap-Match."""
+ lg = _normalize_phrase(learning_goal)
+ if not lg:
+ return brief
+ constraints = parse_stage_goal_constraints(learning_goal, anti_patterns)
+ must = list(brief.must_phrases or [])
+ for token in constraints.positive_tokens[:4]:
+ if token not in must:
+ must.append(token)
+ if lg not in must:
+ must.insert(0, lg[:120])
+ exclude = list(brief.exclude_phrases or [])
+ for item in constraints.exclude_phrases:
+ if item not in exclude:
+ exclude.append(item)
+ arc = list(brief.development_arc or [])
+ ph = (phase or "").strip().lower()
+ if ph and ph not in arc:
+ arc = [ph, *arc]
+ strength = max(float(brief.semantic_strength or 0.0), 0.58)
+ return brief.model_copy(
+ update={
+ "must_phrases": must[:12],
+ "exclude_phrases": exclude[:12],
+ "development_arc": arc[:8],
+ "semantic_strength": min(1.0, strength),
+ }
+ )
+
+
+def exercise_passes_stage_learning_goal_gate(
+ *,
+ learning_goal: str,
+ title: str,
+ summary: str = "",
+ goal: str = "",
+ semantic_score: float = 0.0,
+ min_semantic: float = 0.20,
+ relaxed: bool = False,
+ anti_patterns: Optional[Sequence[str]] = None,
+ stage_brief: Optional[PlanningSemanticBrief] = None,
+ stage_semantic_score: Optional[float] = None,
+ step_phase: Optional[str] = None,
+) -> bool:
+ """Roadmap-Stufe: delegiert an exercise_passes_stage_fit (Titel + Summary + Goal)."""
+ del semantic_score, min_semantic
+ return exercise_passes_stage_fit(
+ learning_goal=learning_goal,
+ title=title,
+ summary=summary,
+ goal=goal,
+ stage_brief=stage_brief,
+ stage_semantic_score=stage_semantic_score,
+ anti_patterns=anti_patterns,
+ step_phase=step_phase,
+ relaxed=relaxed,
+ )
+
+
def exercise_passes_path_semantic_gate(
*,
semantic_score: float,
@@ -636,16 +1261,101 @@ def exercise_passes_path_semantic_gate(
return False
+def _pick_roadmap_rank_fallback(
+ hits: List[Dict[str, Any]],
+ used_exercise_ids: Set[int],
+ *,
+ stage_learning_goal: str,
+ stage_anti_patterns: Optional[Sequence[str]] = None,
+ path_primary_topic: Optional[str] = None,
+ path_technique_excludes: Optional[Sequence[str]] = None,
+) -> Optional[Dict[str, Any]]:
+ """
+ Roadmap-Notfall: bester Treffer nach Stufen-Ranking, wenn striktes Gate leer läuft.
+
+ Filtert weiterhin Ausschlüsse und Technik-Scope (Kumite etc.), aber ohne
+ Mindest-Semantik-Schwelle — so finden auch wortnahe Bibliotheks-Übungen den Slot.
+ """
+ stage_goal = (stage_learning_goal or "").strip()
+ if not stage_goal or not hits:
+ return None
+
+ best: Optional[Dict[str, Any]] = None
+ best_key: Tuple[float, float] = (-1.0, -1.0)
+ for hit in hits:
+ try:
+ eid = int(hit["id"])
+ except (TypeError, ValueError, KeyError):
+ continue
+ if eid in used_exercise_ids:
+ continue
+ title = str(hit.get("title") or "")
+ summary = str(hit.get("summary") or "")
+ goal_text = str(hit.get("goal") or hit.get("exercise_goal") or "")
+ blob = _blob_from_fields(title, summary, goal_text, [])
+ constraints = parse_stage_goal_constraints(stage_goal, stage_anti_patterns)
+ if constraints.exclude_phrases and _blob_matches_stage_excludes(
+ blob, constraints.exclude_phrases
+ ):
+ continue
+ title_equiv = exercise_title_equivalent_to_stage_goal(title, stage_goal)
+ primary = (path_primary_topic or "").strip()
+ if primary and not title_equiv:
+ tech_excludes = list(path_technique_excludes or [])
+ for item in technique_sibling_excludes(primary):
+ if item not in tech_excludes:
+ tech_excludes.append(item)
+ if not exercise_passes_technique_path_scope(
+ primary_topic=primary,
+ title=title,
+ summary=summary,
+ goal=goal_text,
+ learning_goal=stage_goal,
+ sibling_excludes=tech_excludes,
+ relaxed=True,
+ ):
+ continue
+ rank_sem = float(
+ hit.get("stage_rank_semantic")
+ or hit.get("stage_semantic_score")
+ or hit.get("semantic_score")
+ or 0.0
+ )
+ score = float(hit.get("score") or 0.0)
+ key = (rank_sem, score)
+ if key > best_key:
+ best_key = key
+ best = hit
+ if best is None or best_key[0] < _MIN_ROADMAP_FALLBACK_RANK:
+ return None
+ return best
+
+
def pick_best_path_hit(
hits: List[Dict[str, Any]],
used_exercise_ids: Set[int],
*,
semantic_brief: Optional[PlanningSemanticBrief] = None,
+ stage_learning_goal: Optional[str] = None,
+ stage_anti_patterns: Optional[Sequence[str]] = None,
+ roadmap_stage_match: bool = False,
+ stage_match_brief: Optional[PlanningSemanticBrief] = None,
+ path_primary_topic: Optional[str] = None,
+ path_technique_excludes: Optional[Sequence[str]] = None,
) -> Optional[Dict[str, Any]]:
- """Gestufte Auswahl: strikt → relaxed → bester Semantik-Score."""
+ """Gestufte Auswahl: strikt → relaxed → optional Notfall-Fallback."""
if not hits:
return None
+ stage_goal = (stage_learning_goal or "").strip()
+
+ stage_brief: Optional[PlanningSemanticBrief] = stage_match_brief
+ if roadmap_stage_match and stage_goal and stage_brief is None:
+ stage_brief = build_stage_match_brief(
+ learning_goal=stage_goal,
+ anti_patterns=stage_anti_patterns,
+ )
+
def _scan(*, strict: bool) -> Optional[Dict[str, Any]]:
best: Optional[Dict[str, Any]] = None
best_key: Tuple[float, float] = (-1.0, -1.0)
@@ -653,18 +1363,44 @@ def pick_best_path_hit(
eid = int(hit["id"])
if eid in used_exercise_ids:
continue
+ title = str(hit.get("title") or "")
+ summary = str(hit.get("summary") or "")
+ goal_text = str(hit.get("goal") or hit.get("exercise_goal") or "")
sem = float(hit.get("semantic_score") or 0.0)
- if semantic_brief and not exercise_passes_path_semantic_gate(
- semantic_score=sem,
- title=str(hit.get("title") or ""),
- summary=str(hit.get("summary") or ""),
- goal="",
- brief=semantic_brief,
- strict=strict,
- ):
- continue
+ stage_sem = float(
+ hit.get("stage_rank_semantic")
+ or hit.get("stage_semantic_score")
+ or sem
+ )
+
+ if roadmap_stage_match and stage_goal:
+ if not exercise_passes_stage_fit(
+ learning_goal=stage_goal,
+ title=title,
+ summary=summary,
+ goal=goal_text,
+ stage_brief=stage_brief,
+ stage_semantic_score=stage_sem,
+ anti_patterns=stage_anti_patterns,
+ path_primary_topic=path_primary_topic,
+ path_technique_excludes=path_technique_excludes,
+ relaxed=not strict,
+ ):
+ continue
+ else:
+ if semantic_brief and not exercise_passes_path_semantic_gate(
+ semantic_score=sem,
+ title=title,
+ summary=summary,
+ goal=goal_text,
+ brief=semantic_brief,
+ strict=strict,
+ ):
+ continue
+
score = float(hit.get("score") or 0.0)
- key = (sem, score)
+ rank_sem = stage_sem if roadmap_stage_match and stage_goal else sem
+ key = (rank_sem, score)
if key > best_key:
best_key = key
best = hit
@@ -673,11 +1409,25 @@ def pick_best_path_hit(
chosen = _scan(strict=True)
if chosen:
return chosen
+
+ if roadmap_stage_match:
+ chosen = _scan(strict=False)
+ if chosen:
+ return chosen
+ return _pick_roadmap_rank_fallback(
+ hits,
+ used_exercise_ids,
+ stage_learning_goal=stage_goal,
+ stage_anti_patterns=stage_anti_patterns,
+ path_primary_topic=path_primary_topic,
+ path_technique_excludes=path_technique_excludes,
+ )
+
chosen = _scan(strict=False)
if chosen:
return chosen
- # Notfall: bester verbleibender Treffer mit Semantik > 0 (Thema trotzdem priorisieren)
+ # Notfall (nur retrieval-first / Brücken): bester verbleibender Treffer
fallback: Optional[Dict[str, Any]] = None
fallback_key: Tuple[float, float] = (-1.0, -1.0)
for hit in hits:
@@ -706,8 +1456,22 @@ __all__ = [
"build_semantic_brief",
"enrich_target_with_semantic_expectations",
"exercise_passes_path_semantic_gate",
+ "StageGoalConstraints",
+ "apply_stage_match_retrieval_weights",
+ "build_stage_match_brief",
+ "enrich_brief_with_path_constraints",
+ "exercise_passes_stage_fit",
+ "exercise_title_equivalent_to_stage_goal",
+ "resolve_path_primary_topic",
+ "resolve_path_anti_patterns",
+ "exercise_passes_stage_learning_goal_gate",
"merge_semantic_brief_llm",
+ "parse_stage_goal_constraints",
"pick_best_path_hit",
+ "exercise_passes_technique_path_scope",
+ "score_exercise_stage_fit",
+ "semantic_brief_for_stage",
+ "technique_sibling_excludes",
"resolve_semantic_skill_weights",
"score_exercise_semantic_relevance",
"semantic_core_phrases",
diff --git a/backend/planning_intent_context.py b/backend/planning_intent_context.py
new file mode 100644
index 0000000..ce6e774
--- /dev/null
+++ b/backend/planning_intent_context.py
@@ -0,0 +1,248 @@
+"""
+Gemeinsame Intent-Anreicherung für Planungs-Retrieval.
+
+Progressionsgraph (Roadmap stage_specs) und später Trainingsplanung
+(Abschnitt/Slot) nutzen dieselben Bausteine:
+ Intent-Kontext bauen → Specs finalisieren → Matching-Gates.
+"""
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass, field
+from typing import Any, Dict, List, Mapping, Optional, Sequence
+
+from planning_exercise_semantics import (
+ PlanningSemanticBrief,
+ resolve_path_anti_patterns,
+ technique_sibling_excludes,
+)
+
+_NEGATION_CLAUSE_RE = re.compile(
+ r"\b(?:ohne|kein(?:e|en|er|em)?|nicht)\s+[^,.;\n]+",
+ flags=re.IGNORECASE,
+)
+
+
+def extract_explicit_exclusions(*texts: Optional[str]) -> List[str]:
+ """Lesbare Negationsklauseln aus Freitext (ohne Themen-Raten)."""
+ out: List[str] = []
+ for raw in texts:
+ s = (raw or "").strip()
+ if not s:
+ continue
+ for m in _NEGATION_CLAUSE_RE.finditer(s):
+ clause = m.group(0).strip().rstrip(".,;")
+ if clause and clause.lower() not in {x.lower() for x in out}:
+ out.append(clause[:220])
+ return out[:12]
+
+
+@dataclass
+class PlanningIntentContext:
+ """Pfad-/Abschnittsweiter Planungs-Intent — domänenneutral."""
+
+ source_query: str = ""
+ primary_topic: str = ""
+ path_anti_patterns: List[str] = field(default_factory=list)
+ path_success_criteria: List[str] = field(default_factory=list)
+ explicit_exclusions: List[str] = field(default_factory=list)
+ context_notes: str = ""
+ topic_type: str = "general"
+ technique_sibling_excludes: List[str] = field(default_factory=list)
+
+ def to_api_dict(self) -> Dict[str, Any]:
+ return {
+ "source_query": self.source_query,
+ "primary_topic": self.primary_topic,
+ "topic_type": self.topic_type,
+ "path_anti_patterns": self.path_anti_patterns[:16],
+ "path_success_criteria": self.path_success_criteria[:10],
+ "explicit_exclusions": self.explicit_exclusions[:10],
+ "technique_sibling_excludes": self.technique_sibling_excludes[:16],
+ "context_notes": self.context_notes[:1200] or None,
+ }
+
+
+def build_planning_intent_context(
+ goal_query: str,
+ *,
+ semantic_brief: Optional[PlanningSemanticBrief] = None,
+ goal_analysis: Optional[Mapping[str, Any]] = None,
+ extra_context: Optional[str] = None,
+ primary_topic: Optional[str] = None,
+) -> PlanningIntentContext:
+ """Intent aus Anfrage, Zielanalyse und optionalem Kontext — ohne Sonderregeln pro Thema."""
+ ga = dict(goal_analysis or {})
+ notes_parts = [extra_context or ""]
+ constraints = ga.get("constraints") if isinstance(ga.get("constraints"), dict) else {}
+ if isinstance(constraints, dict):
+ trainer_notes = str(constraints.get("trainer_notes") or "").strip()
+ if trainer_notes:
+ notes_parts.append(trainer_notes)
+
+ combined_notes = " ".join(p.strip() for p in notes_parts if p and p.strip())
+ explicit = extract_explicit_exclusions(goal_query, combined_notes or None)
+ ga_excluded = constraints.get("excluded_themes") if isinstance(constraints, dict) else None
+ if isinstance(ga_excluded, list):
+ for item in ga_excluded:
+ s = str(item or "").strip()
+ if s and s.lower() not in {x.lower() for x in explicit}:
+ explicit.append(s[:220])
+
+ path_anti = resolve_path_anti_patterns(
+ goal_query,
+ semantic_brief=semantic_brief,
+ extra_context=combined_notes or None,
+ )
+ path_success: List[str] = []
+ for item in ga.get("success_criteria") or []:
+ s = str(item or "").strip()
+ if s and s not in path_success:
+ path_success.append(s[:240])
+ target = str(ga.get("target_state") or "").strip()
+ if target and len(target) >= 8:
+ line = f"Zielzustand erreichbar: {target[:200]}"
+ if line not in path_success:
+ path_success.append(line)
+
+ topic = (primary_topic or ga.get("primary_topic") or "").strip()
+ topic_type = "general"
+ siblings: List[str] = []
+ if semantic_brief:
+ if not topic:
+ topic = (semantic_brief.primary_topic or "").strip()
+ topic_type = (semantic_brief.topic_type or "general").strip().lower()
+ if topic_type == "technique" and topic:
+ siblings = technique_sibling_excludes(topic)
+ for raw in semantic_brief.exclude_phrases or []:
+ s = str(raw or "").strip()
+ if s and s.lower() not in {x.lower() for x in siblings}:
+ siblings.append(s[:120])
+
+ if topic_type == "technique" and topic:
+ line = f"Haupttechnik {topic} in Kurzbeschreibung oder Übungsziel erkennbar"
+ if line not in path_success:
+ path_success.insert(0, line)
+
+ return PlanningIntentContext(
+ source_query=(goal_query or "").strip(),
+ primary_topic=topic,
+ topic_type=topic_type,
+ path_anti_patterns=path_anti,
+ path_success_criteria=path_success,
+ explicit_exclusions=explicit,
+ technique_sibling_excludes=siblings[:16],
+ context_notes=combined_notes[:1200],
+ )
+
+
+def _dedupe_preserve(items: Sequence[str], *, limit: int = 14) -> List[str]:
+ out: List[str] = []
+ seen: set[str] = set()
+ for raw in items:
+ s = str(raw or "").strip()
+ if not s:
+ continue
+ key = s.lower()
+ if key in seen:
+ continue
+ seen.add(key)
+ out.append(s[:240])
+ if len(out) >= limit:
+ break
+ return out
+
+
+def finalize_stage_spec_artifact(
+ spec: "StageSpecArtifact",
+ *,
+ major_step: Optional["MajorStep"] = None,
+ intent: PlanningIntentContext,
+) -> "StageSpecArtifact":
+ """Pfad-Intent in eine Stufenspezifikation mergen (LLM oder heuristisch)."""
+ from planning_progression_roadmap import MajorStep, StageSpecArtifact
+
+ learning_goal = (spec.learning_goal or (major_step.learning_goal if major_step else "")).strip()
+ phase = (major_step.phase if major_step else "").strip().lower()
+
+ anti = _dedupe_preserve(
+ [
+ *(spec.anti_patterns or []),
+ *intent.explicit_exclusions,
+ *intent.path_anti_patterns,
+ *intent.technique_sibling_excludes,
+ (
+ f"andere Technik als {intent.primary_topic}"
+ if intent.topic_type == "technique" and intent.primary_topic
+ else ""
+ ),
+ ],
+ limit=14,
+ )
+ stage_start = (spec.start_state or "").strip()
+ stage_target = (spec.target_state or "").strip()
+ success = _dedupe_preserve(
+ [
+ *(spec.success_criteria or []),
+ *intent.path_success_criteria,
+ (f"Soll-Start der Stufe erreichbar: {stage_start[:180]}" if stage_start else ""),
+ (f"Stufen-Ziel erreichbar: {stage_target[:180]}" if stage_target else ""),
+ (
+ f"Übung liefert messbar: {learning_goal[:160]}"
+ if learning_goal
+ else ""
+ ),
+ (
+ f"Kurzbeschreibung und Übungsziel passen zur Phase {phase}"
+ if phase
+ else "Kurzbeschreibung und Übungsziel passen zum Stufen-Lernziel"
+ ),
+ ],
+ limit=8,
+ )
+
+ idx = spec.major_step_index
+ if major_step is not None:
+ idx = major_step.index
+
+ return StageSpecArtifact(
+ major_step_index=idx,
+ learning_goal=learning_goal,
+ load_profile=list(spec.load_profile or []),
+ exercise_type=(spec.exercise_type or "").strip(),
+ success_criteria=success,
+ anti_patterns=anti,
+ )
+
+
+def finalize_stage_specs_with_intent(
+ specs: Sequence["StageSpecArtifact"],
+ major_steps: Sequence["MajorStep"],
+ *,
+ intent: PlanningIntentContext,
+ fallback_specs: Optional[Sequence["StageSpecArtifact"]] = None,
+) -> List["StageSpecArtifact"]:
+ """Alle Stufen mit gleichem Pfad-Intent anreichern; fehlende Indizes aus Fallback."""
+ from planning_progression_roadmap import MajorStep, StageSpecArtifact
+
+ by_idx = {int(s.major_step_index): s for s in specs}
+ fallback_by_idx = {int(s.major_step_index): s for s in (fallback_specs or [])}
+ out: List[StageSpecArtifact] = []
+ for major in major_steps:
+ raw = by_idx.get(major.index) or fallback_by_idx.get(major.index)
+ if raw is None:
+ raw = StageSpecArtifact(
+ major_step_index=major.index,
+ learning_goal=major.learning_goal,
+ )
+ out.append(finalize_stage_spec_artifact(raw, major_step=major, intent=intent))
+ return out
+
+
+__all__ = [
+ "PlanningIntentContext",
+ "build_planning_intent_context",
+ "extract_explicit_exclusions",
+ "finalize_stage_spec_artifact",
+ "finalize_stage_specs_with_intent",
+]
diff --git a/backend/planning_path_qa_pipeline.py b/backend/planning_path_qa_pipeline.py
new file mode 100644
index 0000000..2073880
--- /dev/null
+++ b/backend/planning_path_qa_pipeline.py
@@ -0,0 +1,176 @@
+"""
+Mehrstufige Pfad-QS — Findings pro Stufe, daraus Optimierungspotenziale ableiten.
+
+Stufen (allgemein, domänenneutral):
+ 1. deterministische Gates (Technik-Scope, Ausschlüsse, Stufen-Fit)
+ 2. Übergangs-Kohärenz (Lücken zwischen Schritten)
+ 3. LLM-Ganzpfad-Bewertung (Empfehlungen, keine Auto-Patches)
+"""
+from __future__ import annotations
+
+from typing import Any, Dict, List, Mapping, Optional, Sequence
+
+
+_ACTION_BY_ISSUE: Dict[str, str] = {
+ "technique_scope": "rematch_slot",
+ "path_exclude": "rematch_slot",
+ "stage_mismatch": "refine_stage_spec",
+ "off_topic": "rematch_slot",
+ "gap": "bridge_or_gap_fill",
+ "large_gap": "bridge_or_gap_fill",
+ "roadmap_unfilled": "rematch_slot",
+}
+
+
+def _action_for_finding(finding: Mapping[str, Any]) -> str:
+ issue = str(finding.get("issue") or finding.get("type") or "").strip().lower()
+ if finding.get("is_large_gap"):
+ return "bridge_or_gap_fill"
+ return _ACTION_BY_ISSUE.get(issue, "review")
+
+
+def _hint_from_finding(finding: Mapping[str, Any], *, tier: str) -> Dict[str, Any]:
+ step_index = finding.get("step_index")
+ if step_index is None:
+ step_index = finding.get("major_step_index")
+ issue = str(finding.get("issue") or finding.get("type") or tier)
+ action = _action_for_finding(finding)
+ title = str(finding.get("title") or finding.get("removed_title") or "").strip()
+ reasons = finding.get("reasons") or []
+ reason = reasons[0] if reasons else str(finding.get("rationale") or finding.get("detail") or "")
+
+ hint: Dict[str, Any] = {
+ "tier": tier,
+ "action": action,
+ "issue": issue,
+ "step_index": step_index,
+ "title": title or None,
+ "reason": (reason or "")[:400] or None,
+ }
+ if finding.get("roadmap_learning_goal"):
+ hint["roadmap_learning_goal"] = finding.get("roadmap_learning_goal")
+ if finding.get("roadmap_major_step_index") is not None:
+ hint["roadmap_major_step_index"] = finding.get("roadmap_major_step_index")
+ return {k: v for k, v in hint.items() if v is not None and v != ""}
+
+
+def derive_optimization_hints(
+ tiers: Sequence[Mapping[str, Any]],
+) -> List[Dict[str, Any]]:
+ """Aus QS-Stufen konkrete Optimierungsaktionen (ohne anfrage-spezifische Heuristiken)."""
+ hints: List[Dict[str, Any]] = []
+ seen: set[tuple] = set()
+ for tier in tiers:
+ tier_id = str(tier.get("id") or "")
+ for finding in tier.get("findings") or []:
+ if not isinstance(finding, dict):
+ continue
+ hint = _hint_from_finding(finding, tier=tier_id)
+ key = (
+ hint.get("tier"),
+ hint.get("action"),
+ hint.get("step_index"),
+ hint.get("issue"),
+ )
+ if key in seen:
+ continue
+ seen.add(key)
+ hints.append(hint)
+ return hints[:24]
+
+
+def run_multistage_path_qa(
+ *,
+ off_topic_steps: Sequence[Mapping[str, Any]],
+ stripped_off_topic: Sequence[Mapping[str, Any]],
+ gaps: Sequence[Mapping[str, Any]],
+ llm_qa: Optional[Mapping[str, Any]] = None,
+ llm_applied: bool = False,
+ roadmap_unfilled: Optional[Sequence[Mapping[str, Any]]] = None,
+) -> Dict[str, Any]:
+ """Orchestriert QS-Stufen und leitet Optimierungspotenziale ab."""
+ tier1_findings: List[Dict[str, Any]] = []
+ for item in stripped_off_topic or off_topic_steps or []:
+ if isinstance(item, dict):
+ tier1_findings.append(dict(item))
+
+ tier2_findings: List[Dict[str, Any]] = [dict(g) for g in gaps if isinstance(g, dict)]
+
+ tier3_findings: List[Dict[str, Any]] = []
+ llm_recommendations: List[str] = []
+ if llm_applied and llm_qa:
+ q_score = llm_qa.get("quality_score")
+ tier3_findings.append(
+ {
+ "issue": "llm_assessment",
+ "quality_score": q_score,
+ "overall_ok": llm_qa.get("overall_ok"),
+ "detail": llm_qa.get("summary") or llm_qa.get("assessment"),
+ }
+ )
+ for raw in llm_qa.get("recommendations") or llm_qa.get("suggestions") or []:
+ s = str(raw or "").strip()
+ if s:
+ llm_recommendations.append(s[:500])
+
+ unfilled = list(roadmap_unfilled or [])
+ if unfilled:
+ for item in unfilled:
+ if isinstance(item, (list, tuple)) and len(item) >= 2:
+ idx, spec = item[0], item[1]
+ tier1_findings.append(
+ {
+ "issue": "roadmap_unfilled",
+ "step_index": int(idx),
+ "roadmap_major_step_index": getattr(spec, "major_step_index", idx),
+ "roadmap_learning_goal": getattr(spec, "learning_goal", None),
+ "reasons": ["Keine passende Übung für Roadmap-Stufe"],
+ }
+ )
+ elif isinstance(item, dict):
+ tier1_findings.append({**item, "issue": item.get("issue") or "roadmap_unfilled"})
+
+ tiers: List[Dict[str, Any]] = [
+ {
+ "id": "tier1_deterministic",
+ "label": "Deterministische Gates",
+ "finding_count": len(tier1_findings),
+ "findings": tier1_findings[:16],
+ },
+ {
+ "id": "tier2_transitions",
+ "label": "Übergangs-Kohärenz",
+ "finding_count": len(tier2_findings),
+ "findings": tier2_findings[:12],
+ },
+ {
+ "id": "tier3_llm_holistic",
+ "label": "LLM-Ganzpfad",
+ "finding_count": len(tier3_findings),
+ "findings": tier3_findings,
+ "recommendations": llm_recommendations[:8],
+ "applied": bool(llm_applied),
+ },
+ ]
+ optimization_hints = derive_optimization_hints(tiers)
+ for rec in llm_recommendations[:5]:
+ optimization_hints.append(
+ {
+ "tier": "tier3_llm_holistic",
+ "action": "review_roadmap",
+ "issue": "llm_recommendation",
+ "reason": rec,
+ }
+ )
+
+ return {
+ "qa_tiers": tiers,
+ "optimization_hints": optimization_hints[:28],
+ "optimization_hint_count": len(optimization_hints),
+ }
+
+
+__all__ = [
+ "derive_optimization_hints",
+ "run_multistage_path_qa",
+]
diff --git a/backend/planning_path_rematch.py b/backend/planning_path_rematch.py
new file mode 100644
index 0000000..4bc19dd
--- /dev/null
+++ b/backend/planning_path_rematch.py
@@ -0,0 +1,245 @@
+"""
+Auto-Rematch nach Pfad-QS — betroffene Roadmap-Slots erneut matchen (Phase A/B).
+"""
+from __future__ import annotations
+
+from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
+
+from planning_progression_roadmap import ProgressionRoadmapContext, StageSpecArtifact
+
+
+def collect_rematch_slot_indices(
+ *,
+ stripped_off_topic: Sequence[Mapping[str, Any]],
+ off_topic_steps: Sequence[Mapping[str, Any]],
+ optimization_hints: Sequence[Mapping[str, Any]],
+ stage_specs: Sequence[StageSpecArtifact],
+ roadmap_unfilled: Optional[Sequence[Any]] = None,
+) -> Tuple[Set[int], Dict[int, str]]:
+ """Major-Step-Indizes für rematch_slot + Begründung pro Slot."""
+ spec_by_pos = list(stage_specs)
+ indices: Set[int] = set()
+ reasons: Dict[int, str] = {}
+
+ def _register(midx: int, reason: str) -> None:
+ indices.add(int(midx))
+ if midx not in reasons and reason:
+ reasons[int(midx)] = reason[:400]
+
+ def _resolve_major(item: Mapping[str, Any]) -> Optional[int]:
+ raw = item.get("roadmap_major_step_index")
+ if raw is not None:
+ return int(raw)
+ si = item.get("step_index")
+ if si is not None:
+ pos = int(si)
+ if 0 <= pos < len(spec_by_pos):
+ return int(spec_by_pos[pos].major_step_index)
+ return None
+
+ for item in stripped_off_topic or []:
+ if not isinstance(item, dict):
+ continue
+ midx = _resolve_major(item)
+ if midx is not None:
+ issue = str(item.get("issue") or "stripped_off_topic")
+ r = (item.get("reasons") or [issue])[0] if item.get("reasons") else issue
+ _register(midx, str(r))
+
+ for item in off_topic_steps or []:
+ if not isinstance(item, dict):
+ continue
+ midx = _resolve_major(item)
+ if midx is None:
+ continue
+ issue = str(item.get("issue") or "off_topic")
+ r = (item.get("reasons") or [issue])[0] if item.get("reasons") else issue
+ _register(midx, str(r))
+
+ for hint in optimization_hints or []:
+ if not isinstance(hint, dict):
+ continue
+ if str(hint.get("action") or "") != "rematch_slot":
+ continue
+ midx = _resolve_major(hint)
+ if midx is not None:
+ _register(midx, str(hint.get("reason") or hint.get("issue") or "rematch_slot"))
+
+ for item in roadmap_unfilled or []:
+ if isinstance(item, (list, tuple)) and len(item) >= 2:
+ idx, spec = item[0], item[1]
+ midx = getattr(spec, "major_step_index", idx)
+ _register(int(midx), "Keine passende Übung für Roadmap-Stufe")
+ elif isinstance(item, dict):
+ midx = _resolve_major(item)
+ if midx is not None:
+ issue = str(item.get("issue") or "roadmap_unfilled")
+ r = (item.get("reasons") or [issue])[0] if item.get("reasons") else issue
+ _register(midx, str(r))
+
+ return indices, reasons
+
+
+def _context_before_major(
+ steps_by_major: Mapping[int, Mapping[str, Any]],
+ target_major: int,
+) -> Tuple[List[int], Optional[int], Optional[int]]:
+ planned: List[int] = []
+ anchor: Optional[int] = None
+ anchor_vid: Optional[int] = None
+ for midx in sorted(steps_by_major):
+ if midx >= target_major:
+ break
+ step = steps_by_major[midx]
+ eid = step.get("exercise_id")
+ if eid is not None:
+ planned.append(int(eid))
+ anchor = int(eid)
+ vid = step.get("variant_id")
+ anchor_vid = int(vid) if vid is not None else None
+ return planned, anchor, anchor_vid
+
+
+def rematch_roadmap_slots(
+ cur,
+ *,
+ tenant,
+ body,
+ goal_query: str,
+ max_steps: int,
+ semantic_brief,
+ path_target_profile,
+ path_intent: str,
+ roadmap_ctx: ProgressionRoadmapContext,
+ steps: Sequence[Mapping[str, Any]],
+ slot_indices: Set[int],
+ rematch_reasons: Mapping[int, str],
+ match_slot_fn,
+) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Tuple[int, StageSpecArtifact]]]:
+ """
+ Ersetzt nur betroffene Slots; andere Schritte und used-Set bleiben konsistent.
+
+ match_slot_fn: _match_roadmap_slot aus path_builder (Injection gegen Zirkularität).
+ """
+ stage_specs = list(roadmap_ctx.stage_specs or [])[:max_steps]
+ if not stage_specs or not slot_indices:
+ return list(steps), [], []
+
+ spec_by_major = {int(s.major_step_index): s for s in stage_specs}
+ steps_by_major: Dict[int, Dict[str, Any]] = {}
+ for raw in steps:
+ step = dict(raw)
+ midx = step.get("roadmap_major_step_index")
+ if midx is not None:
+ steps_by_major[int(midx)] = step
+
+ rematch_log: List[Dict[str, Any]] = []
+ new_unfilled: List[Tuple[int, StageSpecArtifact]] = []
+
+ for major_idx in sorted(slot_indices):
+ stage_spec = spec_by_major.get(int(major_idx))
+ if stage_spec is None:
+ continue
+ step_index = next(
+ (i for i, sp in enumerate(stage_specs) if int(sp.major_step_index) == int(major_idx)),
+ major_idx,
+ )
+ old = steps_by_major.pop(int(major_idx), None)
+ used = {
+ int(s["exercise_id"])
+ for m, s in steps_by_major.items()
+ if s.get("exercise_id") is not None
+ }
+ if old and old.get("exercise_id") is not None:
+ used.add(int(old["exercise_id"]))
+ planned_ids, anchor_id, anchor_variant_id = _context_before_major(
+ steps_by_major, int(major_idx)
+ )
+
+ new_step, unfilled_spec = match_slot_fn(
+ 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=len(stage_specs),
+ planned_ids=planned_ids,
+ anchor_id=anchor_id,
+ anchor_variant_id=anchor_variant_id,
+ used=used,
+ )
+
+ reason = str(rematch_reasons.get(int(major_idx)) or "rematch_slot")
+ if new_step:
+ steps_by_major[int(major_idx)] = new_step
+ rematch_log.append(
+ {
+ "roadmap_major_step_index": int(major_idx),
+ "action": "replaced",
+ "reason": reason,
+ "replaced_exercise_id": old.get("exercise_id") if old else None,
+ "replaced_title": old.get("title") if old else None,
+ "new_exercise_id": new_step.get("exercise_id"),
+ "new_title": new_step.get("title"),
+ }
+ )
+ else:
+ if unfilled_spec is not None:
+ new_unfilled.append((step_index, unfilled_spec))
+ rematch_log.append(
+ {
+ "roadmap_major_step_index": int(major_idx),
+ "action": "rematch_unfilled",
+ "reason": reason,
+ "replaced_exercise_id": old.get("exercise_id") if old else None,
+ "replaced_title": old.get("title") if old else None,
+ }
+ )
+
+ ordered: List[Dict[str, Any]] = []
+ for spec in sorted(stage_specs, key=lambda s: s.major_step_index):
+ midx = int(spec.major_step_index)
+ if midx in steps_by_major:
+ ordered.append(steps_by_major[midx])
+
+ return ordered, rematch_log, new_unfilled
+
+
+def prune_stripped_after_rematch(
+ stripped_off_topic: Sequence[Mapping[str, Any]],
+ rematch_log: Sequence[Mapping[str, Any]],
+) -> List[Dict[str, Any]]:
+ """Entfernt aus stripped_off_topic Slots, die per Rematch ersetzt wurden."""
+ replaced: Set[int] = set()
+ for entry in rematch_log or []:
+ if not isinstance(entry, dict):
+ continue
+ if str(entry.get("action") or "") != "replaced":
+ continue
+ midx = entry.get("roadmap_major_step_index")
+ if midx is not None:
+ replaced.add(int(midx))
+ if not replaced:
+ return list(stripped_off_topic or [])
+ out: List[Dict[str, Any]] = []
+ for item in stripped_off_topic or []:
+ if not isinstance(item, dict):
+ continue
+ midx = item.get("roadmap_major_step_index")
+ if midx is not None and int(midx) in replaced:
+ continue
+ out.append(dict(item))
+ return out
+
+
+__all__ = [
+ "collect_rematch_slot_indices",
+ "prune_stripped_after_rematch",
+ "rematch_roadmap_slots",
+]
diff --git a/backend/planning_progression_roadmap.py b/backend/planning_progression_roadmap.py
index e1e0a6c..dfab0c2 100644
--- a/backend/planning_progression_roadmap.py
+++ b/backend/planning_progression_roadmap.py
@@ -104,6 +104,10 @@ class RoadmapArtifact(BaseModel):
class StageSpecArtifact(BaseModel):
major_step_index: int = Field(ge=0)
learning_goal: str = ""
+ """Soll-Start dieser Stufe (= Zielzustand der vorherigen Stufe / Pfad-Start)."""
+ start_state: str = ""
+ """Zielzustand dieser Stufe (= Soll für den nächsten Schritt)."""
+ target_state: str = ""
load_profile: List[str] = Field(default_factory=list)
exercise_type: str = ""
success_criteria: List[str] = Field(default_factory=list)
@@ -298,6 +302,8 @@ def try_llm_stage_specs(
goal_query: str,
goal_analysis: GoalAnalysisArtifact,
major_steps: Sequence[MajorStep],
+ intent_context: Optional[Mapping[str, Any]] = None,
+ semantic_brief: Optional[PlanningSemanticBrief] = None,
) -> Tuple[Optional[List[StageSpecArtifact]], bool]:
obj = _run_prompt_json(
cur,
@@ -306,6 +312,11 @@ def try_llm_stage_specs(
"goal_query": goal_query or "",
"goal_analysis_json": json.dumps(goal_analysis.model_dump(), ensure_ascii=False),
"major_steps_json": json.dumps([m.model_dump() for m in major_steps], ensure_ascii=False),
+ "intent_context_json": json.dumps(dict(intent_context or {}), ensure_ascii=False),
+ "semantic_brief_json": json.dumps(
+ brief_to_summary_dict(semantic_brief) if semantic_brief else {},
+ ensure_ascii=False,
+ ),
},
)
if not obj:
@@ -522,9 +533,14 @@ def build_goal_analysis(
if notes.strip():
criteria.append(f"Berücksichtigung: {notes.strip()[:200]}")
+ from planning_intent_context import extract_explicit_exclusions
+
constraints: Dict[str, Any] = {"partner_required": False, "group_analysis": False}
if notes.strip():
constraints["trainer_notes"] = notes.strip()[:500]
+ excluded = extract_explicit_exclusions(goal_query, notes or None)
+ if excluded:
+ constraints["excluded_themes"] = excluded
return GoalAnalysisArtifact(
primary_topic=topic,
@@ -840,19 +856,31 @@ def build_roadmap_unfilled_gap_specs(
"roadmap_major_step_index": stage_spec.major_step_index,
}
)
- return specs[:5]
+ return specs[:12]
def build_stage_specs(
major_steps: Sequence[MajorStep],
*,
goal_analysis: GoalAnalysisArtifact,
+ goal_query: str = "",
+ semantic_brief: Optional[PlanningSemanticBrief] = None,
) -> List[StageSpecArtifact]:
"""Phase C — Stufenspezifikation je Major Step (heuristisch)."""
+ from planning_exercise_semantics import resolve_path_anti_patterns
+
topic = goal_analysis.primary_topic or "Technik"
+ path_anti = resolve_path_anti_patterns(goal_query, semantic_brief=semantic_brief)
specs: List[StageSpecArtifact] = []
for step in major_steps:
phase = (step.phase or "vertiefung").lower()
+ anti = [
+ "reine Kraftübung ohne Technikbezug",
+ f"andere Technik als {topic}" if topic else "themenfremde Übung",
+ ]
+ for item in path_anti:
+ if item not in anti:
+ anti.append(item)
specs.append(
StageSpecArtifact(
major_step_index=step.index,
@@ -863,10 +891,7 @@ def build_stage_specs(
f"Bezug zu {topic}",
f"Phase {phase} erkennbar im Übungsziel",
],
- anti_patterns=[
- "reine Kraftübung ohne Technikbezug",
- f"andere Technik als {topic}" if topic else "themenfremde Übung",
- ],
+ anti_patterns=anti[:14],
)
)
return specs
@@ -920,6 +945,8 @@ def roadmap_context_from_override(
StageSpecArtifact(
major_step_index=i,
learning_goal=(spec.learning_goal or majors[i].learning_goal).strip(),
+ start_state=(spec.start_state or "").strip(),
+ target_state=(spec.target_state or "").strip(),
load_profile=list(spec.load_profile or []),
exercise_type=(spec.exercise_type or "").strip(),
success_criteria=list(spec.success_criteria or []),
@@ -927,19 +954,81 @@ def roadmap_context_from_override(
)
)
if not all(s.exercise_type for s in stage_specs):
- rebuilt = build_stage_specs(majors, goal_analysis=goal_analysis)
+ rebuilt = build_stage_specs(
+ majors,
+ goal_analysis=goal_analysis,
+ goal_query=goal_query.strip(),
+ semantic_brief=semantic_brief,
+ )
for i, spec in enumerate(stage_specs):
if not spec.exercise_type:
spec.exercise_type = rebuilt[i].exercise_type
if not spec.load_profile:
spec.load_profile = list(rebuilt[i].load_profile)
else:
- stage_specs = build_stage_specs(majors, goal_analysis=goal_analysis)
+ stage_specs = build_stage_specs(
+ majors,
+ goal_analysis=goal_analysis,
+ goal_query=goal_query.strip(),
+ semantic_brief=semantic_brief,
+ )
+
+ from planning_exercise_semantics import enrich_brief_with_path_constraints
+ from planning_intent_context import (
+ build_planning_intent_context,
+ finalize_stage_specs_with_intent,
+ )
+
+ enriched_brief = enrich_brief_with_path_constraints(
+ semantic_brief,
+ goal_query.strip(),
+ extra_context=_merge_roadmap_notes(
+ structured.roadmap_notes if structured else None,
+ structured.start_situation if structured else None,
+ structured.target_state if structured else None,
+ ),
+ )
+ intent = build_planning_intent_context(
+ goal_query.strip(),
+ semantic_brief=enriched_brief,
+ goal_analysis=goal_analysis.model_dump(),
+ extra_context=_merge_roadmap_notes(
+ structured.roadmap_notes if structured else None,
+ structured.start_situation if structured else None,
+ structured.target_state if structured else None,
+ ),
+ primary_topic=goal_analysis.primary_topic,
+ )
+ stage_specs = finalize_stage_specs_with_intent(
+ stage_specs,
+ majors,
+ intent=intent,
+ fallback_specs=build_stage_specs(
+ majors,
+ goal_analysis=goal_analysis,
+ goal_query=goal_query.strip(),
+ semantic_brief=enriched_brief,
+ ),
+ )
+ from planning_stage_context import derive_stage_specs_transition_states, resolve_path_start_target
+
+ path_start, path_target = resolve_path_start_target(
+ structured=structured,
+ goal_analysis=goal_analysis,
+ )
+ stage_specs = derive_stage_specs_transition_states(
+ stage_specs,
+ majors,
+ path_start=path_start,
+ path_target=path_target,
+ goal_analysis=goal_analysis,
+ )
return ProgressionRoadmapContext(
goal_query=goal_query.strip(),
max_steps=effective_max,
semantic_brief=brief_to_summary_dict(semantic_brief),
+ resolved_structured=structured,
goal_analysis=goal_analysis,
roadmap=RoadmapArtifact(major_steps=majors),
stage_specs=stage_specs,
@@ -1103,19 +1192,72 @@ def run_progression_roadmap_pipeline(
)
ctx.roadmap = roadmap
- stage_specs = build_stage_specs(roadmap.major_steps, goal_analysis=goal_analysis)
+ from planning_exercise_semantics import enrich_brief_with_path_constraints
+ from planning_intent_context import (
+ build_planning_intent_context,
+ finalize_stage_specs_with_intent,
+ )
+
+ brief = enrich_brief_with_path_constraints(
+ brief,
+ goal_query,
+ extra_context=_merge_roadmap_notes(
+ resolved.roadmap_notes,
+ resolved.start_situation,
+ resolved.target_state,
+ ),
+ )
+ intent = build_planning_intent_context(
+ goal_query,
+ semantic_brief=brief,
+ goal_analysis=goal_analysis.model_dump(),
+ extra_context=_merge_roadmap_notes(
+ resolved.roadmap_notes,
+ resolved.start_situation,
+ resolved.target_state,
+ ),
+ primary_topic=goal_analysis.primary_topic,
+ )
+
+ heuristic_specs = build_stage_specs(
+ roadmap.major_steps,
+ goal_analysis=goal_analysis,
+ goal_query=goal_query,
+ semantic_brief=brief,
+ )
+ stage_specs = list(heuristic_specs)
if include_llm_roadmap and cur is not None:
llm_specs, spec_ok = try_llm_stage_specs(
cur,
goal_query=llm_goal_query,
goal_analysis=goal_analysis,
major_steps=roadmap.major_steps,
+ intent_context=intent.to_api_dict(),
+ semantic_brief=brief,
)
if spec_ok and llm_specs:
- stage_specs = llm_specs
+ stage_specs = list(llm_specs)
ctx.llm_stage_spec_applied = True
ctx.prompt_slugs.append(PROMPT_SLUG_STAGE_SPEC)
- ctx.stage_specs = stage_specs
+ ctx.stage_specs = finalize_stage_specs_with_intent(
+ stage_specs,
+ roadmap.major_steps,
+ intent=intent,
+ fallback_specs=heuristic_specs,
+ )
+ from planning_stage_context import derive_stage_specs_transition_states, resolve_path_start_target
+
+ path_start, path_target = resolve_path_start_target(
+ structured=resolved,
+ goal_analysis=goal_analysis,
+ )
+ ctx.stage_specs = derive_stage_specs_transition_states(
+ ctx.stage_specs,
+ roadmap.major_steps,
+ path_start=path_start,
+ path_target=path_target,
+ goal_analysis=goal_analysis,
+ )
if ctx.llm_goal_analysis_applied or ctx.llm_roadmap_applied or ctx.llm_stage_spec_applied:
ctx.pipeline_phase = "roadmap_v1_llm"
diff --git a/backend/planning_skill_expectations.py b/backend/planning_skill_expectations.py
new file mode 100644
index 0000000..e996349
--- /dev/null
+++ b/backend/planning_skill_expectations.py
@@ -0,0 +1,334 @@
+"""
+Wiederverwendbare Fähigkeiten-Erwartungen für Planungs-KI.
+
+Domänen-Scopes (gleiches Input-/Output-Modell):
+- ``progression_stage`` — ein Major Step / stage_spec im Progressionsgraphen
+- ``progression_path`` — gesamter Pfad (Ziel + Start/Ziel)
+- ``training_section`` — Abschnitt einer Trainingseinheit (Phase G, später)
+- ``framework_slot`` — Rahmen-Session-Slot (Phase G, später)
+
+Konsumenten mergen ``skill_weights`` in ``PlanningTargetProfile`` (Retrieval)
+oder liefern ``expected_skills`` an Übungs-KI (planning_context).
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
+
+from planning_exercise_profiles import _merge_weight_maps, _normalize_weight_map
+from planning_exercise_semantics import PlanningSemanticBrief, resolve_semantic_skill_weights
+from planning_exercise_text_signals import _load_skills_for_text_match, _match_skills_in_text
+
+# Scope-Strings — stabil für API/UI und spätere Trainingsplanung
+SCOPE_PROGRESSION_STAGE = "progression_stage"
+SCOPE_PROGRESSION_PATH = "progression_path"
+SCOPE_TRAINING_SECTION = "training_section"
+SCOPE_FRAMEWORK_SLOT = "framework_slot"
+
+_LOAD_PROFILE_SKILL_TERMS: Dict[str, Tuple[str, ...]] = {
+ "koordination": ("Koordination",),
+ "präzision": ("Präzision",),
+ "praezision": ("Präzision",),
+ "kraft": ("Kraft", "Kime"),
+ "geschwindigkeit": ("Geschwindigkeit", "Schnelligkeit"),
+ "schnelligkeit": ("Schnelligkeit", "Geschwindigkeit"),
+ "timing": ("Timing", "Reaktion"),
+ "reaktion": ("Reaktion", "Timing"),
+ "distanz": ("Distanz",),
+ "raum": ("Raum", "Distanz"),
+ "gleichgewicht": ("Gleichgewicht",),
+ "kime": ("Kime",),
+ "ausdauer": ("Ausdauer",),
+ "beweglichkeit": ("Beweglichkeit",),
+}
+
+
+@dataclass
+class PlanningSkillExpectationInput:
+ scope: str = SCOPE_PROGRESSION_STAGE
+ primary_topic: str = ""
+ goal_query: str = ""
+ start_situation: str = ""
+ target_state: str = ""
+ stage_learning_goal: str = ""
+ roadmap_notes: str = ""
+ load_profile: List[str] = field(default_factory=list)
+ phase: str = ""
+ skill_hints: List[str] = field(default_factory=list)
+
+
+@dataclass
+class PlanningSkillExpectationItem:
+ skill_id: int
+ skill_name: str
+ weight: float
+ source: str
+
+
+@dataclass
+class PlanningSkillExpectations:
+ scope: str
+ skill_weights: Dict[int, float]
+ items: List[PlanningSkillExpectationItem]
+ sources: List[str]
+
+ def to_api_dict(self) -> Dict[str, Any]:
+ return {
+ "scope": self.scope,
+ "sources": list(self.sources),
+ "expected_skills": [
+ {
+ "skill_id": it.skill_id,
+ "skill_name": it.skill_name,
+ "weight": round(it.weight, 4),
+ "source": it.source,
+ }
+ for it in self.items
+ ],
+ }
+
+
+def _norm_load_key(s: str) -> str:
+ return (s or "").strip().lower().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue")
+
+
+def _text_blob(inp: PlanningSkillExpectationInput) -> str:
+ parts = [
+ inp.primary_topic,
+ inp.goal_query,
+ inp.start_situation,
+ inp.target_state,
+ inp.stage_learning_goal,
+ inp.roadmap_notes,
+ inp.phase,
+ " ".join(inp.load_profile or []),
+ " ".join(inp.skill_hints or []),
+ ]
+ return "\n".join(p for p in parts if (p or "").strip()).lower()
+
+
+def _resolve_skills_by_name_terms(
+ cur,
+ terms: Sequence[str],
+ *,
+ weight: float = 0.9,
+ source: str,
+ weights: Dict[int, float],
+ items: Dict[int, PlanningSkillExpectationItem],
+) -> bool:
+ found = False
+ for name in terms:
+ if not name:
+ continue
+ cur.execute(
+ """
+ SELECT id, name FROM skills
+ WHERE (status IS NULL OR status = 'active')
+ AND LOWER(name) LIKE %s
+ ORDER BY CASE WHEN LOWER(name) = %s THEN 0 WHEN LOWER(name) LIKE %s THEN 1 ELSE 2 END,
+ LENGTH(name) ASC
+ LIMIT 1
+ """,
+ (f"%{name.lower()}%", name.lower(), f"{name.lower()}%"),
+ )
+ row = cur.fetchone()
+ if not row:
+ continue
+ sid = int(row["id"])
+ w = max(weights.get(sid, 0.0), weight)
+ weights[sid] = w
+ items[sid] = PlanningSkillExpectationItem(
+ skill_id=sid,
+ skill_name=str(row.get("name") or "").strip(),
+ weight=w,
+ source=source,
+ )
+ found = True
+ return found
+
+
+def _merge_weights_into(
+ weights: Dict[int, float],
+ items: Dict[int, PlanningSkillExpectationItem],
+ incoming: Dict[int, float],
+ *,
+ source: str,
+ skill_rows: Sequence[Tuple[int, str, int]],
+) -> None:
+ name_by_id = {sid: name for sid, name, _ in skill_rows}
+ for sid, w in incoming.items():
+ if w <= 0:
+ continue
+ merged = max(weights.get(sid, 0.0), float(w))
+ weights[sid] = merged
+ items[sid] = PlanningSkillExpectationItem(
+ skill_id=sid,
+ skill_name=name_by_id.get(sid, f"Skill #{sid}"),
+ weight=merged,
+ source=source,
+ )
+
+
+def build_planning_skill_expectations(
+ cur,
+ inp: PlanningSkillExpectationInput,
+ *,
+ semantic_brief: Optional[PlanningSemanticBrief] = None,
+) -> PlanningSkillExpectations:
+ """Deterministisch: Thema + Text + load_profile → skill_weights."""
+ weights: Dict[int, float] = {}
+ items: Dict[int, PlanningSkillExpectationItem] = {}
+ sources: List[str] = []
+
+ skill_rows = _load_skills_for_text_match(cur)
+
+ if semantic_brief is not None:
+ topic_weights = resolve_semantic_skill_weights(cur, semantic_brief)
+ if topic_weights:
+ sources.append("semantic_topic")
+ _merge_weights_into(
+ weights, items, topic_weights, source="semantic_topic", skill_rows=skill_rows
+ )
+
+ blob = _text_blob(inp)
+ if blob:
+ text_weights = _match_skills_in_text(blob, skill_rows)
+ if text_weights:
+ sources.append("text_match")
+ _merge_weights_into(
+ weights, items, text_weights, source="text_match", skill_rows=skill_rows
+ )
+
+ load_found = False
+ for raw in inp.load_profile or []:
+ key = _norm_load_key(str(raw))
+ terms = _LOAD_PROFILE_SKILL_TERMS.get(key, (str(raw).strip(),) if key else ())
+ if _resolve_skills_by_name_terms(
+ cur, terms, weight=0.88, source="load_profile", weights=weights, items=items
+ ):
+ load_found = True
+ if load_found and "load_profile" not in sources:
+ sources.append("load_profile")
+
+ normalized = _normalize_weight_map(weights)
+ out_items = sorted(
+ [
+ PlanningSkillExpectationItem(
+ skill_id=sid,
+ skill_name=items[sid].skill_name,
+ weight=normalized[sid],
+ source=items[sid].source,
+ )
+ for sid in normalized
+ ],
+ key=lambda x: (-x.weight, x.skill_name.lower()),
+ )[:8]
+
+ return PlanningSkillExpectations(
+ scope=inp.scope or SCOPE_PROGRESSION_STAGE,
+ skill_weights=normalized,
+ items=out_items,
+ sources=sources,
+ )
+
+
+def expectation_input_from_progression_stage(
+ *,
+ goal_query: str,
+ goal_analysis: Optional[Mapping[str, Any]] = None,
+ resolved_structured: Optional[Mapping[str, Any]] = None,
+ stage_spec: Optional[Mapping[str, Any]] = None,
+ semantic_brief_summary: Optional[Mapping[str, Any]] = None,
+ major_step: Optional[Mapping[str, Any]] = None,
+) -> PlanningSkillExpectationInput:
+ """Roadmap-Stufe → PlanningSkillExpectationInput (Progressionsgraph)."""
+ ga = dict(goal_analysis or {})
+ rs = dict(resolved_structured or {})
+ spec = dict(stage_spec or {})
+ brief = dict(semantic_brief_summary or {})
+ major = dict(major_step or {})
+
+ skill_hints: List[str] = []
+ for item in (brief.get("must_phrases") or [])[:4]:
+ s = str(item or "").strip()
+ if s:
+ skill_hints.append(s)
+
+ return PlanningSkillExpectationInput(
+ scope=SCOPE_PROGRESSION_STAGE,
+ primary_topic=str(ga.get("primary_topic") or brief.get("primary_topic") or "").strip(),
+ goal_query=(goal_query or "").strip(),
+ start_situation=str(rs.get("start_situation") or ga.get("start_assumption") or "").strip(),
+ target_state=str(rs.get("target_state") or ga.get("target_state") or "").strip(),
+ stage_learning_goal=str(
+ spec.get("learning_goal") or major.get("learning_goal") or ""
+ ).strip(),
+ roadmap_notes=str(rs.get("roadmap_notes") or "").strip(),
+ load_profile=[str(x).strip() for x in (spec.get("load_profile") or []) if str(x).strip()],
+ phase=str(spec.get("phase") or major.get("phase") or "").strip(),
+ skill_hints=skill_hints,
+ )
+
+
+def expectation_input_from_progression_path(
+ *,
+ goal_query: str,
+ goal_analysis: Optional[Mapping[str, Any]] = None,
+ resolved_structured: Optional[Mapping[str, Any]] = None,
+ semantic_brief_summary: Optional[Mapping[str, Any]] = None,
+) -> PlanningSkillExpectationInput:
+ """Gesamtpfad-Kontext (z. B. einmaliges Pfad-Profil)."""
+ ga = dict(goal_analysis or {})
+ rs = dict(resolved_structured or {})
+ brief = dict(semantic_brief_summary or {})
+ skill_hints: List[str] = []
+ for item in (brief.get("must_phrases") or [])[:4]:
+ s = str(item or "").strip()
+ if s:
+ skill_hints.append(s)
+ return PlanningSkillExpectationInput(
+ scope=SCOPE_PROGRESSION_PATH,
+ primary_topic=str(ga.get("primary_topic") or brief.get("primary_topic") or "").strip(),
+ goal_query=(goal_query or "").strip(),
+ start_situation=str(rs.get("start_situation") or ga.get("start_assumption") or "").strip(),
+ target_state=str(rs.get("target_state") or ga.get("target_state") or "").strip(),
+ roadmap_notes=str(rs.get("roadmap_notes") or "").strip(),
+ skill_hints=skill_hints,
+ )
+
+
+def apply_expectations_to_target(target, expectations: PlanningSkillExpectations):
+ """Merge Erwartungs-Skills in PlanningTargetProfile (Retrieval)."""
+ from planning_exercise_semantics import enrich_target_with_semantic_expectations
+
+ if not expectations.skill_weights:
+ return target
+ return enrich_target_with_semantic_expectations(
+ target, skill_weights=dict(expectations.skill_weights)
+ )
+
+
+def merge_expectation_skill_weights(
+ base: Dict[int, float],
+ extra: Dict[int, float],
+ *,
+ extra_scale: float = 1.0,
+) -> Dict[int, float]:
+ merged = _merge_weight_maps(base, extra, scale=extra_scale)
+ return _normalize_weight_map(merged)
+
+
+__all__ = [
+ "SCOPE_FRAMEWORK_SLOT",
+ "SCOPE_PROGRESSION_PATH",
+ "SCOPE_PROGRESSION_STAGE",
+ "SCOPE_TRAINING_SECTION",
+ "PlanningSkillExpectationInput",
+ "PlanningSkillExpectationItem",
+ "PlanningSkillExpectations",
+ "apply_expectations_to_target",
+ "build_planning_skill_expectations",
+ "expectation_input_from_progression_path",
+ "expectation_input_from_progression_stage",
+ "merge_expectation_skill_weights",
+]
diff --git a/backend/planning_stage_context.py b/backend/planning_stage_context.py
new file mode 100644
index 0000000..0ed2aa8
--- /dev/null
+++ b/backend/planning_stage_context.py
@@ -0,0 +1,140 @@
+"""
+Stufen-Kontext im Gesamtziel — Start/Ziel pro Roadmap-Stufe für Matching und QS.
+
+Übertragbar auf Trainingsplanung: Abschnitt-Soll (= Ende Vorabschnitt), Abschnitt-Ziel.
+"""
+from __future__ import annotations
+
+from typing import List, Optional, Sequence
+
+from planning_progression_roadmap import (
+ GoalAnalysisArtifact,
+ MajorStep,
+ RoadmapStructuredInput,
+ StageSpecArtifact,
+)
+
+
+def build_contextualized_stage_goal(
+ *,
+ learning_goal: str,
+ start_state: str = "",
+ target_state: str = "",
+ path_target_state: str = "",
+ path_start_state: str = "",
+ stage_index: int = 0,
+ stage_count: int = 1,
+) -> str:
+ """Stufen-Lernziel eingebettet in Übergang und Gesamtziel (für Brief/Retrieval)."""
+ lg = (learning_goal or "").strip()
+ if not lg:
+ return ""
+
+ parts: List[str] = []
+ start = (start_state or "").strip()
+ target = (target_state or "").strip()
+ path_end = (path_target_state or "").strip()
+ path_begin = (path_start_state or "").strip()
+
+ if start:
+ parts.append(f"Soll-Start: {start[:220]}")
+ elif path_begin and stage_index == 0:
+ parts.append(f"Pfad-Start: {path_begin[:220]}")
+ if target:
+ parts.append(f"Stufen-Ziel: {target[:220]}")
+ parts.append(f"Lernziel: {lg[:280]}")
+ if path_end:
+ if stage_index >= max(0, stage_count - 1):
+ parts.append(f"Gesamtziel: {path_end[:220]}")
+ else:
+ parts.append(f"Gesamtziel (Kontext): {path_end[:180]}")
+
+ return " | ".join(parts)[:900]
+
+
+def derive_stage_specs_transition_states(
+ stage_specs: Sequence[StageSpecArtifact],
+ major_steps: Sequence[MajorStep],
+ *,
+ path_start: str = "",
+ path_target: str = "",
+ goal_analysis: Optional[GoalAnalysisArtifact] = None,
+) -> List[StageSpecArtifact]:
+ """
+ Verkettete Soll-/Zielzustände je Stufe.
+
+ - Stufe 0 start = Pfad-Start
+ - Stufe n start = Zielzustand Stufe n-1 (Ziel des vorherigen Schritts)
+ - Letzte Stufe target = Pfad-Gesamtziel (falls gesetzt)
+ """
+ start_path = (path_start or "").strip()
+ end_path = (path_target or "").strip()
+ if goal_analysis:
+ if not start_path:
+ start_path = (goal_analysis.start_assumption or "").strip()
+ if not end_path:
+ end_path = (goal_analysis.target_state or "").strip()
+
+ by_idx = {int(s.major_step_index): s for s in stage_specs}
+ majors = sorted(major_steps, key=lambda m: m.index)
+ if not majors:
+ return list(stage_specs)
+
+ out: List[StageSpecArtifact] = []
+ prev_target = start_path
+ last_idx = majors[-1].index
+
+ for major in majors:
+ spec = by_idx.get(major.index)
+ if spec is None:
+ spec = StageSpecArtifact(
+ major_step_index=major.index,
+ learning_goal=major.learning_goal,
+ )
+
+ explicit_start = (spec.start_state or "").strip()
+ explicit_target = (spec.target_state or "").strip()
+ stage_start = explicit_start or prev_target or start_path
+ if explicit_target:
+ stage_target = explicit_target
+ elif major.index == last_idx and end_path:
+ stage_target = end_path
+ else:
+ stage_target = (major.learning_goal or spec.learning_goal or "").strip()
+
+ prev_target = stage_target
+ out.append(
+ spec.model_copy(
+ update={
+ "start_state": (stage_start or "")[:500],
+ "target_state": (stage_target or "")[:500],
+ }
+ )
+ )
+ return out
+
+
+def resolve_path_start_target(
+ *,
+ structured: Optional[RoadmapStructuredInput] = None,
+ goal_analysis: Optional[GoalAnalysisArtifact] = None,
+) -> tuple[str, str]:
+ """Pfadweiter Start- und Zielzustand für Stufen-Verkettung."""
+ start = ""
+ target = ""
+ if structured:
+ start = (structured.start_situation or "").strip()
+ target = (structured.target_state or "").strip()
+ if goal_analysis:
+ if not start:
+ start = (goal_analysis.start_assumption or "").strip()
+ if not target:
+ target = (goal_analysis.target_state or "").strip()
+ return start, target
+
+
+__all__ = [
+ "build_contextualized_stage_goal",
+ "derive_stage_specs_transition_states",
+ "resolve_path_start_target",
+]
diff --git a/backend/progression_graph_planning_artifact.py b/backend/progression_graph_planning_artifact.py
new file mode 100644
index 0000000..0a75480
--- /dev/null
+++ b/backend/progression_graph_planning_artifact.py
@@ -0,0 +1,76 @@
+"""Validierung und Normalisierung des Planungs-Artefakts am Progressionsgraph."""
+from __future__ import annotations
+
+import json
+from typing import Any, Dict, List, Optional
+
+from pydantic import BaseModel, Field, field_validator
+
+ARTIFACT_SCHEMA_VERSION = 1
+_MAX_JSON_BYTES = 64_000
+
+
+class SlotExerciseContent(BaseModel):
+ kind: str = Field(default="empty", pattern=r"^(empty|library|proposal)$")
+ exercise_id: Optional[int] = Field(default=None, ge=1)
+ variant_id: Optional[int] = Field(default=None, ge=1)
+ title: Optional[str] = Field(default=None, max_length=500)
+ variant_name: Optional[str] = Field(default=None, max_length=200)
+ proposal_key: Optional[str] = Field(default=None, max_length=120)
+ ai_suggestion: Optional[Dict[str, Any]] = None
+
+
+class SlotContentEntry(BaseModel):
+ major_step_index: int = Field(ge=0, le=20)
+ primary: SlotExerciseContent = Field(default_factory=SlotExerciseContent)
+ siblings: List[SlotExerciseContent] = Field(default_factory=list)
+
+
+class GraphPlanningRoadmapArtifact(BaseModel):
+ schema_version: int = Field(default=ARTIFACT_SCHEMA_VERSION, ge=1, le=1)
+ goal_query: str = Field(default="", max_length=2000)
+ start_situation: Optional[str] = Field(default=None, max_length=2000)
+ target_state: Optional[str] = Field(default=None, max_length=2000)
+ roadmap_notes: Optional[str] = Field(default=None, max_length=2000)
+ max_steps: int = Field(default=5, ge=2, le=10)
+ progression_roadmap: Optional[Dict[str, Any]] = None
+ path_skill_expectations: Optional[Dict[str, Any]] = None
+ slot_contents: Optional[List[SlotContentEntry]] = None
+ last_findings: Optional[Dict[str, Any]] = None
+
+ @field_validator("progression_roadmap", "path_skill_expectations", "last_findings", mode="before")
+ @classmethod
+ def _empty_dict_to_none(cls, v):
+ if v == {}:
+ return None
+ return v
+
+ @field_validator("slot_contents", mode="before")
+ @classmethod
+ def _empty_slot_list_to_none(cls, v):
+ if v == []:
+ return None
+ return v
+
+
+def normalize_planning_roadmap_payload(raw: Any) -> Optional[Dict[str, Any]]:
+ """None erlaubt (löschen); sonst validiertes Dict."""
+ if raw is None:
+ return None
+ if not isinstance(raw, dict):
+ raise ValueError("planning_roadmap muss ein JSON-Objekt sein")
+ artifact = GraphPlanningRoadmapArtifact.model_validate(raw)
+ out = artifact.model_dump(exclude_none=True)
+ blob = json.dumps(out, ensure_ascii=False)
+ if len(blob.encode("utf-8")) > _MAX_JSON_BYTES:
+ raise ValueError("planning_roadmap ist zu groß (max. 64 KB)")
+ return out
+
+
+__all__ = [
+ "ARTIFACT_SCHEMA_VERSION",
+ "GraphPlanningRoadmapArtifact",
+ "SlotContentEntry",
+ "SlotExerciseContent",
+ "normalize_planning_roadmap_payload",
+]
diff --git a/backend/requirements.txt b/backend/requirements.txt
index ebc6416..a7f455a 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -10,5 +10,5 @@ bcrypt==4.1.3
slowapi==0.1.9
psycopg2-binary==2.9.9
python-dateutil==2.9.0
-tzdata>=2024.1 # ZoneInfo (Europe/Berlin) auch unter Windows
+tzdata>=2024.1; sys_platform == "win32" # ZoneInfo lokal; Linux/Docker: apt tzdata
sqlparse>=0.5.0 # Migrationen: Statements splitten (Fallback ohne psql)
diff --git a/backend/routers/exercise_progression_graphs.py b/backend/routers/exercise_progression_graphs.py
index 212359f..9e4edaa 100644
--- a/backend/routers/exercise_progression_graphs.py
+++ b/backend/routers/exercise_progression_graphs.py
@@ -3,13 +3,16 @@ Progressionsgraph zwischen Übungen (Übung → Übung), Migration 032–034.
Optional Übungsvarianten als Knoten-Endpunkte; Sequenz-Bulk-Anlage.
AuthZ analog training_plan_templates: Bearbeiten/Löschen wie Übungen; Governance-Übergänge zentral.
"""
-from typing import Any, List, Optional
+import json
+from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field, model_validator
from psycopg2 import IntegrityError
+from psycopg2.extras import Json
from db import get_db, get_cursor, r2d
+from progression_graph_planning_artifact import normalize_planning_roadmap_payload
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
from club_tenancy import (
assert_library_content_deletable,
@@ -36,6 +39,7 @@ class ProgressionGraphUpdate(BaseModel):
description: Optional[str] = None
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
club_id: Optional[int] = None
+ planning_roadmap: Optional[Dict[str, Any]] = None
class ProgressionEdgeCreate(BaseModel):
@@ -59,6 +63,7 @@ class SequenceStep(BaseModel):
class ProgressionSequenceCreate(BaseModel):
steps: List[SequenceStep] = Field(..., min_length=2)
segment_notes: Optional[List[Optional[str]]] = None
+ planning_roadmap: Optional[Dict[str, Any]] = None
"""Länge muss len(steps)-1 sein, wenn gesetzt; Notiz pro Kante Zwischen je zwei Schritten."""
@model_validator(mode="after")
@@ -116,6 +121,17 @@ def _assert_graph_writable(cur, row: dict, profile_id: int, role: str) -> None:
assert_library_content_editable(cur, profile_id, role, row)
+def _persist_graph_planning_roadmap(cur, graph_id: int, raw: Optional[Dict[str, Any]]) -> None:
+ try:
+ normalized = normalize_planning_roadmap_payload(raw)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ cur.execute(
+ "UPDATE exercise_progression_graphs SET planning_roadmap = %s WHERE id = %s",
+ (Json(normalized) if normalized is not None else None, graph_id),
+ )
+
+
def _require_graph_read(cur, graph_id: int, profile_id: int, role: str) -> dict:
row = _graph_row(cur, graph_id)
_assert_graph_readable(cur, row, profile_id, role)
@@ -241,6 +257,127 @@ def get_progression_graph(
return row
+def _exercise_ids_from_planning_roadmap(artifact: Optional[Dict[str, Any]]) -> set[int]:
+ ids: set[int] = set()
+ if not artifact or not isinstance(artifact, dict):
+ return ids
+ for slot in artifact.get("slot_contents") or []:
+ if not isinstance(slot, dict):
+ continue
+ primary = slot.get("primary") if isinstance(slot.get("primary"), dict) else {}
+ if primary.get("kind") == "library" and primary.get("exercise_id") is not None:
+ try:
+ ids.add(int(primary["exercise_id"]))
+ except (TypeError, ValueError):
+ pass
+ for sib in slot.get("siblings") or []:
+ if not isinstance(sib, dict):
+ continue
+ if sib.get("kind") == "library" and sib.get("exercise_id") is not None:
+ try:
+ ids.add(int(sib["exercise_id"]))
+ except (TypeError, ValueError):
+ pass
+ return ids
+
+
+def _collect_graph_referenced_exercise_ids(cur, graph_id: int) -> set[int]:
+ ids: set[int] = set()
+ cur.execute(
+ """
+ SELECT from_exercise_id, to_exercise_id
+ FROM exercise_progression_edges
+ WHERE graph_id = %s
+ """,
+ (graph_id,),
+ )
+ for row in cur.fetchall():
+ for key in ("from_exercise_id", "to_exercise_id"):
+ raw = row.get(key)
+ if raw is not None:
+ ids.add(int(raw))
+ cur.execute(
+ "SELECT planning_roadmap FROM exercise_progression_graphs WHERE id = %s",
+ (graph_id,),
+ )
+ prow = cur.fetchone()
+ if prow and prow.get("planning_roadmap"):
+ art = prow["planning_roadmap"]
+ if isinstance(art, str):
+ try:
+ art = json.loads(art)
+ except json.JSONDecodeError:
+ art = None
+ ids |= _exercise_ids_from_planning_roadmap(art if isinstance(art, dict) else None)
+ return ids
+
+
+@router.get("/exercise-progression-graphs/{graph_id}/visibility-promotion-candidates")
+def list_visibility_promotion_candidates(
+ graph_id: int,
+ target_visibility: str = Query(default="club", pattern="^(club|official)$"),
+ tenant: TenantContext = Depends(get_tenant_context),
+):
+ """
+ Private Übungen im Graph, die bei Promotion des Graphen mit angehoben werden müssten.
+ """
+ profile_id = tenant.profile_id
+ role = tenant.global_role
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ row = _require_graph_read(cur, graph_id, profile_id, role)
+ graph_vis = (row.get("visibility") or "private").strip().lower()
+ if graph_vis != "private" or target_visibility != "club":
+ return {
+ "graph_id": graph_id,
+ "graph_visibility": graph_vis,
+ "target_visibility": target_visibility,
+ "exercises": [],
+ }
+ ref_ids = _collect_graph_referenced_exercise_ids(cur, graph_id)
+ if not ref_ids:
+ return {
+ "graph_id": graph_id,
+ "graph_visibility": graph_vis,
+ "target_visibility": target_visibility,
+ "exercises": [],
+ }
+ ph = ",".join(["%s"] * len(ref_ids))
+ cur.execute(
+ f"""
+ SELECT id, title, visibility, club_id, created_by
+ FROM exercises
+ WHERE id IN ({ph})
+ AND LOWER(TRIM(COALESCE(visibility, ''))) = 'private'
+ ORDER BY title
+ """,
+ list(ref_ids),
+ )
+ exercises = []
+ for ex in cur.fetchall():
+ exd = r2d(ex)
+ if not library_content_visible_to_profile(
+ cur,
+ profile_id,
+ role,
+ exd,
+ ):
+ continue
+ exercises.append(
+ {
+ "id": exd["id"],
+ "title": exd.get("title"),
+ "visibility": exd.get("visibility"),
+ }
+ )
+ return {
+ "graph_id": graph_id,
+ "graph_visibility": graph_vis,
+ "target_visibility": target_visibility,
+ "exercises": exercises,
+ }
+
+
@router.post("/exercise-progression-graphs", status_code=201)
def create_progression_graph(
body: ProgressionGraphCreate,
@@ -353,15 +490,24 @@ def update_progression_graph(
fields.append("club_id = %s")
params.append(next_club if next_vis == "club" else None)
- if not fields:
+ if "planning_roadmap" in original:
+ _persist_graph_planning_roadmap(cur, graph_id, original.get("planning_roadmap"))
+
+ if not fields and "planning_roadmap" not in original:
return get_progression_graph(graph_id, include_edges=False, tenant=tenant)
- fields.append("updated_at = NOW()")
- params.append(graph_id)
- cur.execute(
- f"UPDATE exercise_progression_graphs SET {', '.join(fields)} WHERE id = %s",
- tuple(params),
- )
+ if fields:
+ fields.append("updated_at = NOW()")
+ params.append(graph_id)
+ cur.execute(
+ f"UPDATE exercise_progression_graphs SET {', '.join(fields)} WHERE id = %s",
+ tuple(params),
+ )
+ elif "planning_roadmap" in original:
+ cur.execute(
+ "UPDATE exercise_progression_graphs SET updated_at = NOW() WHERE id = %s",
+ (graph_id,),
+ )
conn.commit()
return get_progression_graph(graph_id, include_edges=False, tenant=tenant)
@@ -488,6 +634,12 @@ def create_progression_sequence(
note,
)
created.append(row)
+ if body.planning_roadmap is not None:
+ _persist_graph_planning_roadmap(cur, graph_id, body.planning_roadmap)
+ cur.execute(
+ "UPDATE exercise_progression_graphs SET updated_at = NOW() WHERE id = %s",
+ (graph_id,),
+ )
conn.commit()
except IntegrityError as e:
conn.rollback()
diff --git a/backend/tenant_context.py b/backend/tenant_context.py
index 175221e..d3ab1ae 100644
--- a/backend/tenant_context.py
+++ b/backend/tenant_context.py
@@ -108,6 +108,57 @@ def library_content_visibility_sql(
return "(" + " OR ".join(parts) + ")", params
+def library_content_visibility_for_progression_graph_sql(
+ *,
+ alias: str,
+ profile_id: int,
+ role: str,
+ effective_club_id: Optional[int],
+ graph_visibility: str,
+ graph_club_id: Optional[int] = None,
+) -> tuple[str, List[Any]]:
+ """
+ Übungs-Sichtbarkeit für Progressionsgraph-Match/Planung.
+
+ - private Graph: private (eigene) + Verein + offiziell — volle Nutzer-Bibliothek
+ - club Graph: nur Verein (aktiver Graph-Verein) + offiziell
+ - official Graph: nur offiziell
+ """
+ gvis = (graph_visibility or "private").strip().lower()
+ if gvis == "private":
+ return library_content_visibility_sql(
+ alias=alias,
+ profile_id=profile_id,
+ role=role,
+ effective_club_id=effective_club_id,
+ )
+ if gvis == "club":
+ parts: List[str] = [f"{alias}.visibility = 'official'"]
+ params: List[Any] = []
+ club_id = graph_club_id if graph_club_id is not None else effective_club_id
+ if club_id is not None:
+ plat = is_platform_admin(role)
+ if plat:
+ parts.append(f"({alias}.visibility = 'club' AND {alias}.club_id = %s)")
+ params.append(int(club_id))
+ else:
+ parts.append(
+ f"""(
+ {alias}.visibility = 'club'
+ AND {alias}.club_id = %s
+ AND EXISTS (
+ SELECT 1 FROM club_members cm
+ WHERE cm.profile_id = %s
+ AND cm.club_id = {alias}.club_id
+ AND cm.status = 'active'
+ )
+ )"""
+ )
+ params.extend([int(club_id), profile_id])
+ return "(" + " OR ".join(parts) + ")", params
+ return f"({alias}.visibility = 'official')", []
+
+
def club_library_visibility_sql(
*,
alias: str,
diff --git a/backend/tests/test_access_layer.py b/backend/tests/test_access_layer.py
index 915c0ee..8169dc7 100644
--- a/backend/tests/test_access_layer.py
+++ b/backend/tests/test_access_layer.py
@@ -2,7 +2,12 @@
import pytest
from fastapi import HTTPException
-from tenant_context import library_content_visibility_sql, parse_active_club_header, resolve_tenant_context
+from tenant_context import (
+ library_content_visibility_for_progression_graph_sql,
+ library_content_visibility_sql,
+ parse_active_club_header,
+ resolve_tenant_context,
+)
def test_library_visibility_sql_platform_admin_restricts_club_by_membership():
@@ -41,6 +46,37 @@ def test_library_visibility_sql_trainer_without_active_club_no_shared_club_branc
assert params == [42]
+def test_progression_graph_visibility_sql_private_matches_library():
+ base_sql, base_params = library_content_visibility_sql(
+ alias="e", profile_id=5, role="trainer", effective_club_id=12
+ )
+ graph_sql, graph_params = library_content_visibility_for_progression_graph_sql(
+ alias="e",
+ profile_id=5,
+ role="trainer",
+ effective_club_id=12,
+ graph_visibility="private",
+ graph_club_id=None,
+ )
+ assert graph_sql == base_sql
+ assert graph_params == base_params
+
+
+def test_progression_graph_visibility_sql_club_excludes_private():
+ sql, params = library_content_visibility_for_progression_graph_sql(
+ alias="e",
+ profile_id=5,
+ role="trainer",
+ effective_club_id=12,
+ graph_visibility="club",
+ graph_club_id=12,
+ )
+ assert "official" in sql
+ assert "visibility = 'club'" in sql
+ assert "visibility = 'private'" not in sql
+ assert 12 in params
+
+
def test_library_visibility_sql_user_with_active_club_includes_club_branch():
sql, params = library_content_visibility_sql(
alias="t",
diff --git a/backend/tests/test_planning_exercise_form_context.py b/backend/tests/test_planning_exercise_form_context.py
index 772e2ff..5e0e2ba 100644
--- a/backend/tests/test_planning_exercise_form_context.py
+++ b/backend/tests/test_planning_exercise_form_context.py
@@ -1,7 +1,9 @@
"""Tests Planungs-KI Phase D — planning_context für suggestExerciseAi."""
from planning_exercise_form_context import (
+ build_progression_entry_state,
build_progression_gap_snapshot,
build_progression_path_gap_planning_context,
+ enrich_gap_snapshot_with_entry_state,
planning_context_prompt_variables,
sanitize_planning_context_for_ai,
)
@@ -78,3 +80,55 @@ def test_gap_planning_context_carries_snapshot_fields():
)
assert ctx["start_situation"] == "Start"
assert ctx["stage_learning_goal"] == "Stufenziel"
+
+
+def test_build_progression_entry_state_from_prior_steps():
+ entry = build_progression_entry_state(
+ major_step_index=2,
+ prior_steps=[
+ {
+ "roadmap_major_step_index": 0,
+ "title": "Schritt-Stand",
+ "roadmap_phase": "einstieg",
+ "success_criteria": ["stabile Grundstellung"],
+ },
+ {
+ "roadmap_major_step_index": 1,
+ "title": "Mawashi Vorbereitung",
+ "roadmap_target_state": "Hüfte dreht vor dem Knie",
+ "roadmap_phase": "grundlage",
+ },
+ ],
+ start_situation="Anfänger ohne Kumite-Erfahrung",
+ current_stage_start="Hüfte dreht vor dem Knie, sicherer Stand",
+ )
+ assert entry["entry_state"] == "Hüfte dreht vor dem Knie, sicherer Stand"
+ assert "Mawashi Vorbereitung" in entry["entry_state_detail"]
+ assert "stabile Grundstellung" in entry["prior_achievements"][0]
+
+
+def test_enrich_gap_snapshot_with_entry_state():
+ snap = enrich_gap_snapshot_with_entry_state(
+ {"start_situation": "Basis", "stage_learning_goal": "Rhythmen"},
+ steps=[
+ {
+ "roadmap_major_step_index": 0,
+ "title": "A",
+ "success_criteria": ["Timing erkannt"],
+ }
+ ],
+ major_step_index=1,
+ )
+ assert snap["entry_state"] == "Timing erkannt"
+ assert snap["prior_steps"][0]["title"] == "A"
+
+
+def test_gap_planning_context_trainer_supplements_and_stage_override():
+ ctx = build_progression_path_gap_planning_context(
+ goal_query="Kumite",
+ stage_spec={"learning_goal": "Original"},
+ stage_learning_goal_override="Angepasstes Stufenziel",
+ gap_trainer_supplements="Nur Partnerübung, Kindergruppe",
+ )
+ assert ctx["stage_learning_goal"] == "Angepasstes Stufenziel"
+ assert ctx["gap_trainer_supplements"] == "Nur Partnerübung, Kindergruppe"
diff --git a/backend/tests/test_planning_exercise_path_ai_fill.py b/backend/tests/test_planning_exercise_path_ai_fill.py
index 54b317a..f39b3bf 100644
--- a/backend/tests/test_planning_exercise_path_ai_fill.py
+++ b/backend/tests/test_planning_exercise_path_ai_fill.py
@@ -119,6 +119,86 @@ def test_build_gap_fill_goal_text_includes_roadmap_snapshot():
assert "timing" in text
+def test_build_gap_fill_goal_text_includes_expected_skills():
+ brief = build_semantic_brief("Kumite Beinarbeit")
+ text = build_gap_fill_goal_text(
+ goal_query="Kumite Beinarbeit",
+ brief=brief,
+ spec={"phase": "vertiefung", "title_hint": "Rhythmen"},
+ roadmap_snapshot={
+ "expected_skills": [
+ {"skill_name": "Timing", "weight": 0.9},
+ {"skill_name": "Distanz", "weight": 0.8},
+ ],
+ },
+ )
+ assert "Erwartete Fähigkeiten" in text
+ assert "Timing" in text
+
+
+def test_build_gap_fill_offer_roadmap_unfilled_uses_major_step_neighbors():
+ """Leere Stufe 2 zwischen Stufe 1 und 3 — Nachbarn per roadmap_major_step_index."""
+ brief = build_semantic_brief("Kumite Beinarbeit")
+ steps = [
+ {
+ "title": "Explosive Angriffe",
+ "exercise_id": 10,
+ "roadmap_major_step_index": 0,
+ },
+ {
+ "title": "Kumite-Anwendung",
+ "exercise_id": 30,
+ "roadmap_major_step_index": 2,
+ },
+ ]
+ offer = build_gap_fill_offer(
+ spec={
+ "source": "roadmap_unfilled",
+ "roadmap_major_step_index": 1,
+ "phase": "grundlage",
+ "title_hint": "Grundlegende Kumite-Steppbewegungen",
+ "gap": {"learning_goal": "Grundlegende Kumite-Steppbewegungen", "expected_phase": "grundlage"},
+ },
+ steps=steps,
+ goal_query="Kumite Beinarbeit",
+ brief=brief,
+ )
+ assert offer["roadmap_major_step_index"] == 1
+ assert "Explosive Angriffe" in offer["from_title"]
+ assert "Kumite-Anwendung" in offer["to_title"]
+ assert "Stufen-Lernziel" in offer["goal_for_ai"] or "Roadmap-Stufe" in offer["goal_for_ai"]
+
+
+def test_build_gap_fill_offer_includes_entry_state_from_prior_steps():
+ brief = build_semantic_brief("Kumite Beinarbeit")
+ steps = [
+ {
+ "roadmap_major_step_index": 0,
+ "title": "Schritt A",
+ "roadmap_target_state": "gleichmäßige Distanz",
+ "success_criteria": ["Partnerabstand stabil"],
+ },
+ {"roadmap_major_step_index": 2, "title": "Schritt C"},
+ ]
+ offer = build_gap_fill_offer(
+ spec={
+ "source": "roadmap_unfilled",
+ "phase": "vertiefung",
+ "title_hint": "Rhythmen",
+ "roadmap_major_step_index": 1,
+ },
+ steps=steps,
+ goal_query="Kumite Beinarbeit",
+ brief=brief,
+ roadmap_snapshot={
+ "start_situation": "Steppbewegung",
+ "stage_learning_goal": "variable Rhythmen",
+ },
+ )
+ assert offer["context_preview"]["entry_state"] == "gleichmäßige Distanz"
+ assert "Eingangszustand" in offer["goal_for_ai"]
+
+
def test_build_gap_fill_offer_exposes_context_preview():
brief = build_semantic_brief("Kumite Beinarbeit")
offer = build_gap_fill_offer(
@@ -134,3 +214,55 @@ def test_build_gap_fill_offer_exposes_context_preview():
)
assert offer["context_preview"]["start_situation"] == "Steppbewegung"
assert "variable Rhythmen" in offer["goal_for_ai"]
+
+
+def test_collect_gap_fill_specs_off_topic_last_step_no_crash():
+ """Rand-Slot: off_topic am letzten Schritt darf keinen IndexError auslösen (500)."""
+ brief = build_semantic_brief("Mawashi Geri Kumite")
+ steps = [
+ {"exercise_id": 1, "title": "Stand", "roadmap_major_step_index": 0},
+ {"exercise_id": 2, "title": "Yoko Geri", "roadmap_major_step_index": 1},
+ ]
+ specs = collect_gap_fill_specs(
+ steps=steps,
+ unfilled_gaps=[],
+ off_topic_steps=[
+ {
+ "step_index": 1,
+ "roadmap_major_step_index": 1,
+ "title": "Yoko Geri",
+ "expected_phase": "anwendung",
+ }
+ ],
+ llm_specs=[],
+ brief=brief,
+ goal_query="Mawashi Geri Kumite",
+ )
+ assert len(specs) == 1
+ assert specs[0]["source"] == "off_topic"
+ assert "Stand" in specs[0]["sketch"]
+
+
+def test_collect_gap_fill_specs_off_topic_first_step_uses_safe_neighbors():
+ brief = build_semantic_brief("Mawashi Geri")
+ steps = [
+ {"exercise_id": 1, "title": "Yoko Geri", "roadmap_major_step_index": 0},
+ {"exercise_id": 2, "title": "Mawashi", "roadmap_major_step_index": 1},
+ ]
+ specs = collect_gap_fill_specs(
+ steps=steps,
+ unfilled_gaps=[],
+ off_topic_steps=[
+ {
+ "step_index": 0,
+ "roadmap_major_step_index": 0,
+ "title": "Yoko Geri",
+ }
+ ],
+ llm_specs=[],
+ brief=brief,
+ goal_query="Mawashi Geri",
+ )
+ assert len(specs) == 1
+ assert "Mawashi" in specs[0]["sketch"]
+ assert "vorherigem Schritt" in specs[0]["sketch"]
diff --git a/backend/tests/test_planning_exercise_path_builder.py b/backend/tests/test_planning_exercise_path_builder.py
index dc9d598..ef20664 100644
--- a/backend/tests/test_planning_exercise_path_builder.py
+++ b/backend/tests/test_planning_exercise_path_builder.py
@@ -1,12 +1,37 @@
"""Tests Planungs-KI Phase C3/E/F — Pfad-Vorschläge."""
from planning_exercise_path_builder import (
+ EvaluateStepPayload,
+ ProgressionPathSuggestRequest,
_annotate_roadmap_step,
_hit_to_path_step,
_pick_best_path_hit,
+ _supplemental_exercise_ids_from_body,
)
from planning_progression_roadmap import MajorStep, StageSpecArtifact
+class _FakeCur:
+ def execute(self, *_args, **_kwargs):
+ return None
+
+ def fetchall(self):
+ return []
+
+
+def test_supplemental_boost_includes_slot_assignments_and_retrieval_boost():
+ body = ProgressionPathSuggestRequest(
+ query="Mawashi Geri Progression",
+ slot_assignments=[
+ EvaluateStepPayload(exercise_id=99, roadmap_major_step_index=0),
+ ],
+ retrieval_boost_exercise_ids=[42, 7],
+ )
+ ids = _supplemental_exercise_ids_from_body(_FakeCur(), body)
+ assert 99 in ids
+ assert 42 in ids
+ assert 7 in ids
+
+
def test_pick_next_path_hit_skips_used():
hits = [{"id": 1, "title": "A", "semantic_score": 0.2}, {"id": 2, "title": "B", "semantic_score": 0.2}, {"id": 3, "title": "C", "semantic_score": 0.2}]
assert _pick_best_path_hit(hits, {1})["id"] == 2
@@ -42,3 +67,21 @@ def test_annotate_roadmap_step_adds_metadata():
assert step["roadmap_phase"] == "grundlage"
assert step["roadmap_match_source"] == "stage_spec"
assert any("Roadmap:" in r for r in step["reasons"])
+
+
+def test_annotate_roadmap_step_adds_skill_expectations():
+ spec = StageSpecArtifact(major_step_index=0, learning_goal="Timing und Distanz")
+ step = _annotate_roadmap_step(
+ {"exercise_id": 5, "title": "Test", "reasons": []},
+ stage_spec=spec,
+ major_step=None,
+ skill_expectations={
+ "scope": "progression_stage",
+ "expected_skills": [
+ {"skill_id": 2, "skill_name": "Timing", "weight": 0.9},
+ {"skill_id": 3, "skill_name": "Distanz", "weight": 0.8},
+ ],
+ },
+ )
+ assert step["skill_expectations"]["expected_skills"][0]["skill_name"] == "Timing"
+ assert any("Fähigkeiten:" in r for r in step["reasons"])
diff --git a/backend/tests/test_planning_exercise_path_qa.py b/backend/tests/test_planning_exercise_path_qa.py
index 929e187..f6a8d8a 100644
--- a/backend/tests/test_planning_exercise_path_qa.py
+++ b/backend/tests/test_planning_exercise_path_qa.py
@@ -106,6 +106,42 @@ def test_detect_path_gaps_skips_roadmap_neighbors():
assert gaps == []
+def test_detect_path_gaps_skips_empty_slots():
+ """Graph-Bewertung: leere Slots dürfen keinen 500er durch Übergangs-Lücken auslösen."""
+ brief = build_semantic_brief("Mawashi Geri Kumite")
+ steps = [
+ {
+ "exercise_id": 10,
+ "title": "Stand",
+ "roadmap_major_step_index": 0,
+ },
+ {
+ "exercise_id": None,
+ "title": "(leer: Slot 2)",
+ "is_ai_proposal": True,
+ "roadmap_major_step_index": 1,
+ },
+ {
+ "exercise_id": 11,
+ "title": "Anwendung",
+ "roadmap_major_step_index": 2,
+ },
+ ]
+
+ class _FakeCur:
+ def execute(self, *args, **kwargs):
+ return None
+
+ def fetchall(self):
+ return []
+
+ def fetchone(self):
+ return {"title": "X", "summary": "", "goal": ""}
+
+ gaps = detect_path_gaps(_FakeCur(), steps, brief=brief, roadmap_first=True)
+ assert isinstance(gaps, list)
+
+
def test_apply_llm_path_reorder_invalid_ignored():
steps = [{"exercise_id": 1}, {"exercise_id": 2}]
reordered, applied, _ = apply_llm_path_reorder(steps, {"ordered_step_indices": [0, 0]})
diff --git a/backend/tests/test_planning_intent_context.py b/backend/tests/test_planning_intent_context.py
new file mode 100644
index 0000000..e85d5a8
--- /dev/null
+++ b/backend/tests/test_planning_intent_context.py
@@ -0,0 +1,58 @@
+"""Tests gemeinsames Planungs-Intent-Modul (Progressionsgraph → Trainingsplanung)."""
+from planning_intent_context import (
+ build_planning_intent_context,
+ extract_explicit_exclusions,
+ finalize_stage_specs_with_intent,
+)
+from planning_progression_roadmap import (
+ MajorStep,
+ StageSpecArtifact,
+ build_goal_analysis,
+ build_stage_specs,
+ run_progression_roadmap_pipeline,
+)
+from planning_exercise_semantics import build_semantic_brief, enrich_brief_with_path_constraints
+
+
+def test_extract_explicit_exclusions_parses_negations():
+ q = "gesprungener Mawashi Geri, keine Kumite-Anwendung gewünscht"
+ out = extract_explicit_exclusions(q)
+ assert any("kumite" in x.lower() for x in out)
+
+
+def test_build_planning_intent_context_includes_path_anti():
+ q = "Mawashi Geri Sprungkraft, keine Kumite-Anwendung"
+ brief = enrich_brief_with_path_constraints(build_semantic_brief(q), q)
+ ga = build_goal_analysis(q, brief)
+ intent = build_planning_intent_context(q, semantic_brief=brief, goal_analysis=ga.model_dump())
+ assert intent.explicit_exclusions
+ assert any("kumite" in a for a in intent.path_anti_patterns)
+
+
+def test_finalize_stage_specs_merges_intent_into_each_stage():
+ q = "gesprungener Mawashi Geri, keine Kumite-Anwendung"
+ brief = enrich_brief_with_path_constraints(build_semantic_brief(q), q)
+ ga = build_goal_analysis(q, brief)
+ intent = build_planning_intent_context(q, semantic_brief=brief, goal_analysis=ga.model_dump())
+ majors = [
+ MajorStep(index=0, phase="grundlage", learning_goal="Grundtechnik Mawashi", consolidates=["m1"]),
+ MajorStep(index=1, phase="vertiefung", learning_goal="Sprungkoordination", consolidates=["m2"]),
+ ]
+ raw_specs = [
+ StageSpecArtifact(major_step_index=0, learning_goal=majors[0].learning_goal),
+ StageSpecArtifact(major_step_index=1, learning_goal=majors[1].learning_goal),
+ ]
+ finalized = finalize_stage_specs_with_intent(raw_specs, majors, intent=intent)
+ assert len(finalized) == 2
+ for spec in finalized:
+ assert spec.success_criteria
+ assert any("kumite" in a.lower() for a in spec.anti_patterns)
+ assert any("messbar" in c.lower() or "übungsziel" in c.lower() for c in spec.success_criteria)
+
+
+def test_pipeline_stage_specs_carry_exclusions_without_llm():
+ q = "gesprungener Mawashi Geri Sprungphase, keine Kumite-Anwendung"
+ ctx = run_progression_roadmap_pipeline(q, max_steps=3, include_llm_roadmap=False)
+ assert len(ctx.stage_specs) == 3
+ for spec in ctx.stage_specs:
+ assert any("kumite" in a.lower() for a in (spec.anti_patterns or []))
diff --git a/backend/tests/test_planning_path_rematch.py b/backend/tests/test_planning_path_rematch.py
new file mode 100644
index 0000000..78cc99f
--- /dev/null
+++ b/backend/tests/test_planning_path_rematch.py
@@ -0,0 +1,185 @@
+"""Tests Auto-Rematch nach Pfad-QS (Phase A)."""
+from planning_path_rematch import collect_rematch_slot_indices, rematch_roadmap_slots
+from planning_progression_roadmap import ProgressionRoadmapContext, StageSpecArtifact
+
+
+def _stage_specs():
+ return [
+ StageSpecArtifact(major_step_index=0, learning_goal="Grundlage"),
+ StageSpecArtifact(major_step_index=1, learning_goal="Vertiefung"),
+ StageSpecArtifact(major_step_index=2, learning_goal="Anwendung"),
+ ]
+
+
+def test_collect_rematch_slot_indices_from_stripped_with_major_index():
+ specs = _stage_specs()
+ stripped = [
+ {
+ "step_index": 1,
+ "roadmap_major_step_index": 1,
+ "issue": "technique_scope",
+ "reasons": ["Passt nicht zur Haupttechnik"],
+ }
+ ]
+ indices, reasons = collect_rematch_slot_indices(
+ stripped_off_topic=stripped,
+ off_topic_steps=[],
+ optimization_hints=[],
+ stage_specs=specs,
+ )
+ assert indices == {1}
+ assert "Haupttechnik" in reasons[1]
+
+
+def test_collect_rematch_slot_indices_resolves_step_index_to_major():
+ specs = _stage_specs()
+ off_topic = [
+ {
+ "step_index": 2,
+ "issue": "stage_mismatch",
+ "reasons": ["Ziel passt nicht"],
+ }
+ ]
+ indices, reasons = collect_rematch_slot_indices(
+ stripped_off_topic=[],
+ off_topic_steps=off_topic,
+ optimization_hints=[],
+ stage_specs=specs,
+ )
+ assert indices == {2}
+ assert reasons[2] == "Ziel passt nicht"
+
+
+def test_collect_rematch_slot_indices_from_optimization_hints():
+ specs = _stage_specs()
+ hints = [
+ {
+ "action": "rematch_slot",
+ "roadmap_major_step_index": 0,
+ "reason": "QS-Tier-1",
+ }
+ ]
+ indices, _ = collect_rematch_slot_indices(
+ stripped_off_topic=[],
+ off_topic_steps=[],
+ optimization_hints=hints,
+ stage_specs=specs,
+ )
+ assert indices == {0}
+
+
+def test_collect_rematch_slot_indices_from_roadmap_unfilled():
+ specs = _stage_specs()
+ indices, reasons = collect_rematch_slot_indices(
+ stripped_off_topic=[],
+ off_topic_steps=[],
+ optimization_hints=[],
+ stage_specs=specs,
+ roadmap_unfilled=[(1, specs[1])],
+ )
+ assert indices == {1}
+ assert "Roadmap-Stufe" in reasons[1]
+
+
+def test_rematch_roadmap_slots_replaces_only_target_slot():
+ specs = _stage_specs()
+ ctx = ProgressionRoadmapContext(
+ goal_query="Mawashi Geri",
+ max_steps=3,
+ stage_specs=specs,
+ )
+ steps = [
+ {
+ "exercise_id": 10,
+ "title": "Slot 0 OK",
+ "roadmap_major_step_index": 0,
+ },
+ {
+ "exercise_id": 20,
+ "title": "Mae Geri falsch",
+ "roadmap_major_step_index": 1,
+ },
+ {
+ "exercise_id": 30,
+ "title": "Slot 2 OK",
+ "roadmap_major_step_index": 2,
+ },
+ ]
+
+ def _fake_match(cur, *, stage_spec, used, **kwargs):
+ assert stage_spec.major_step_index == 1
+ assert 20 in used
+ assert 10 in used
+ assert 30 in used
+ return (
+ {
+ "exercise_id": 21,
+ "title": "Sprungkraft Mawashi",
+ "roadmap_major_step_index": 1,
+ },
+ None,
+ )
+
+ ordered, log, unfilled = rematch_roadmap_slots(
+ None,
+ tenant=None,
+ body=None,
+ goal_query="Mawashi Geri",
+ max_steps=3,
+ semantic_brief=None,
+ path_target_profile=None,
+ path_intent="",
+ roadmap_ctx=ctx,
+ steps=steps,
+ slot_indices={1},
+ rematch_reasons={1: "technique_scope"},
+ match_slot_fn=_fake_match,
+ )
+
+ assert len(ordered) == 3
+ assert ordered[0]["exercise_id"] == 10
+ assert ordered[1]["exercise_id"] == 21
+ assert ordered[2]["exercise_id"] == 30
+ assert len(log) == 1
+ assert log[0]["action"] == "replaced"
+ assert log[0]["replaced_exercise_id"] == 20
+ assert log[0]["new_exercise_id"] == 21
+ assert not unfilled
+
+
+def test_rematch_excludes_replaced_exercise_from_used():
+ specs = _stage_specs()
+ ctx = ProgressionRoadmapContext(
+ goal_query="Mawashi Geri",
+ max_steps=3,
+ stage_specs=specs,
+ )
+ steps = [
+ {"exercise_id": 10, "title": "OK", "roadmap_major_step_index": 0},
+ {"exercise_id": 99, "title": "Mae Geri", "roadmap_major_step_index": 1},
+ ]
+ seen_used = []
+
+ def _fake_match(cur, *, used, stage_spec, **kwargs):
+ seen_used.append(set(used))
+ return (
+ {"exercise_id": 42, "title": "Neu", "roadmap_major_step_index": stage_spec.major_step_index},
+ None,
+ )
+
+ rematch_roadmap_slots(
+ None,
+ tenant=None,
+ body=None,
+ goal_query="Mawashi",
+ max_steps=3,
+ semantic_brief=None,
+ path_target_profile=None,
+ path_intent="",
+ roadmap_ctx=ctx,
+ steps=steps,
+ slot_indices={1},
+ rematch_reasons={1: "technique_scope"},
+ match_slot_fn=_fake_match,
+ )
+ assert 99 in seen_used[0]
diff --git a/backend/tests/test_planning_roadmap_stage_match.py b/backend/tests/test_planning_roadmap_stage_match.py
new file mode 100644
index 0000000..21ae1c2
--- /dev/null
+++ b/backend/tests/test_planning_roadmap_stage_match.py
@@ -0,0 +1,572 @@
+"""Tests Roadmap-Stufen-Match — Gate gegen themenfremde Übungen."""
+from planning_exercise_semantics import (
+ build_semantic_brief,
+ build_stage_match_brief,
+ enrich_brief_with_path_constraints,
+ exercise_passes_stage_learning_goal_gate,
+ exercise_passes_stage_fit,
+ exercise_passes_technique_path_scope,
+ pick_best_path_hit,
+ resolve_path_anti_patterns,
+ resolve_path_primary_topic,
+ score_exercise_stage_fit,
+ semantic_brief_for_stage,
+ technique_sibling_excludes,
+)
+from planning_exercise_path_qa import strip_off_topic_steps_from_path
+
+
+def test_stage_gate_accepts_learning_goal_in_title():
+ assert exercise_passes_stage_learning_goal_gate(
+ learning_goal="variable Rhythmen und Hüftmobilität für Mae Geri",
+ title="Mae Geri — variable Rhythmen",
+ summary="",
+ semantic_score=0.1,
+ )
+
+
+def test_stage_gate_rejects_unrelated_kumite():
+ assert not exercise_passes_stage_learning_goal_gate(
+ learning_goal="variable Rhythmen und Hüftmobilität für Mae Geri",
+ title="Kumite Grundstellungen",
+ summary="Partnerarbeit Distanz",
+ semantic_score=0.05,
+ )
+
+
+def test_semantic_brief_for_stage_adds_learning_goal():
+ brief = build_semantic_brief("Mae Geri Perfektion")
+ stage = semantic_brief_for_stage(
+ brief,
+ learning_goal="Hüftmobilität und Kammerhaltung",
+ phase="grundlage",
+ )
+ assert "hüftmobilität und kammerhaltung" in stage.must_phrases[0]
+
+
+def test_build_stage_match_brief_uses_stage_tokens_not_global_topic():
+ brief = build_stage_match_brief(
+ learning_goal="Koordination von Absprung und Beinhebung ohne Tritttechnik",
+ phase="vertiefung",
+ )
+ must_blob = " ".join(brief.must_phrases or []).lower()
+ assert "mawashi" not in must_blob
+ assert "absprung" in must_blob
+ assert not (brief.primary_topic or "").strip()
+
+
+def test_stage_fit_prefers_goal_over_misleading_title():
+ stage_goal = "Koordination von Absprung und Beinhebung ohne Tritttechnik"
+ stage_brief = build_stage_match_brief(learning_goal=stage_goal)
+ kick_score, _ = score_exercise_stage_fit(
+ title="Mawashi Geri Trittpräzision",
+ summary="Kicktechnik",
+ goal="Präzision im Tritt und Hüftarbeit",
+ stage_brief=stage_brief,
+ )
+ coord_score, _ = score_exercise_stage_fit(
+ title="Allgemeines Sprungtraining",
+ summary="Athletik",
+ goal="Absprung, Beinhebung und Landung koordinieren — ohne Trittausführung",
+ stage_brief=stage_brief,
+ )
+ assert coord_score > kick_score
+
+
+def test_pick_best_path_hit_roadmap_stage_no_weak_fallback():
+ stage_brief = build_stage_match_brief(
+ learning_goal="Hüftmobilität für Mae Geri",
+ phase="grundlage",
+ )
+ hits = [
+ {
+ "id": 1,
+ "title": "Kumite Stellungen",
+ "summary": "Partner Distanz",
+ "score": 0.92,
+ "semantic_score": 0.08,
+ },
+ {
+ "id": 2,
+ "title": "Kraft-Ausdauer Zirkel",
+ "summary": "allgemeine Fitness",
+ "score": 0.88,
+ "semantic_score": 0.02,
+ },
+ ]
+ chosen = pick_best_path_hit(
+ hits,
+ set(),
+ semantic_brief=stage_brief,
+ stage_learning_goal="Hüftmobilität für Mae Geri",
+ roadmap_stage_match=True,
+ )
+ assert chosen is None
+
+
+def test_pick_best_path_hit_roadmap_stage_picks_relevant():
+ stage_brief = build_stage_match_brief(
+ learning_goal="Hüftmobilität für Mae Geri",
+ phase="grundlage",
+ )
+ hits = [
+ {"id": 1, "title": "Kumite", "score": 0.9, "semantic_score": 0.1},
+ {
+ "id": 2,
+ "title": "Mae Geri Hüftmobilität",
+ "summary": "Kammerhaltung und Hüfte",
+ "score": 0.7,
+ "semantic_score": 0.55,
+ },
+ ]
+ chosen = pick_best_path_hit(
+ hits,
+ set(),
+ semantic_brief=stage_brief,
+ stage_learning_goal="Hüftmobilität für Mae Geri",
+ roadmap_stage_match=True,
+ )
+ assert chosen is not None
+ assert int(chosen["id"]) == 2
+
+
+def test_stage_gate_rejects_tritt_when_goal_says_ohne_tritttechnik():
+ """Regression: gesprungener Mawashi — Slot Koordination ohne Tritttechnik."""
+ goal = "Koordination von Absprung und Beinhebung ohne Tritttechnik"
+ assert not exercise_passes_stage_learning_goal_gate(
+ learning_goal=goal,
+ title="Verbesserung der Trittpräzision des Mawashi Geri und der Hüftbewegung",
+ summary="Präzision und Hüftarbeit im Stand",
+ semantic_score=0.72,
+ )
+
+
+def test_stage_gate_accepts_absprung_drill_not_kick_focus():
+ goal = "Koordination von Absprung und Beinhebung ohne Tritttechnik"
+ assert exercise_passes_stage_learning_goal_gate(
+ learning_goal=goal,
+ title="Sprungkoordination — Absprung und Beinhebung",
+ summary="Ohne Trittausführung, Fokus Gleichgewicht und Timing",
+ semantic_score=0.35,
+ )
+
+
+def test_pick_best_rejects_mawashi_tritt_precision_for_coordination_slot():
+ stage_goal = "Koordination von Absprung und Beinhebung ohne Tritttechnik"
+ stage_brief = build_stage_match_brief(learning_goal=stage_goal, phase="vertiefung")
+ hits = [
+ {
+ "id": 99,
+ "title": "Verbesserung der Trittpräzision des Mawashi Geri und der Hüftbewegung",
+ "summary": "Tritttechnik und Hüfte im Stand",
+ "score": 0.91,
+ "semantic_score": 0.68,
+ },
+ {
+ "id": 100,
+ "title": "Absprung und Beinhebung — Koordination ohne Kick",
+ "summary": "Sprungvorbereitung, kein Tritt",
+ "score": 0.62,
+ "semantic_score": 0.41,
+ },
+ ]
+ chosen = pick_best_path_hit(
+ hits,
+ set(),
+ semantic_brief=stage_brief,
+ stage_learning_goal=stage_goal,
+ roadmap_stage_match=True,
+ )
+ assert chosen is not None
+ assert int(chosen["id"]) == 100
+
+
+def test_path_anti_patterns_from_keine_kumite_anwendung():
+ q = "gesprungener Mawashi Geri Sprungphase, keine Kumite-Anwendung gewünscht"
+ brief = enrich_brief_with_path_constraints(build_semantic_brief(q), q)
+ anti = resolve_path_anti_patterns(q, semantic_brief=brief)
+ assert any("kumite" in a for a in anti)
+
+
+def test_stage_fit_rejects_kumite_when_path_excludes_kumite():
+ q = "gesprungener Mawashi Geri, keine Kumite-Anwendung"
+ brief = enrich_brief_with_path_constraints(build_semantic_brief(q), q)
+ path_anti = resolve_path_anti_patterns(q, semantic_brief=brief)
+ stage_goal = "Sprungkraft und Koordination für gesprungenen Mawashi Geri"
+ stage_brief = build_stage_match_brief(
+ learning_goal=stage_goal,
+ anti_patterns=path_anti,
+ path_anti_patterns=path_anti,
+ )
+ assert not exercise_passes_stage_fit(
+ learning_goal=stage_goal,
+ title="Kumite Distanztraining Mawashi",
+ summary="Partner-Kumite mit Trittanwendung",
+ goal="Anwendung im freien Kampf",
+ stage_brief=stage_brief,
+ anti_patterns=path_anti,
+ )
+
+
+def test_pick_best_skips_kumite_for_mawashi_athletic_path():
+ q = "gesprungener Mawashi Geri Sprungkraft, keine Kumite-Anwendung"
+ brief = enrich_brief_with_path_constraints(build_semantic_brief(q), q)
+ path_anti = resolve_path_anti_patterns(q, semantic_brief=brief)
+ stage_goal = "Athletisches Sprungtraining für Mawashi Geri"
+ stage_brief = build_stage_match_brief(
+ learning_goal=stage_goal,
+ anti_patterns=path_anti,
+ path_anti_patterns=path_anti,
+ )
+ hits = [
+ {
+ "id": 1,
+ "title": "Kumite Mawashi Anwendung",
+ "summary": "Partner Kumite",
+ "goal": "Kampfanwendung",
+ "score": 0.95,
+ "semantic_score": 0.55,
+ "stage_semantic_score": 0.55,
+ },
+ {
+ "id": 2,
+ "title": "Sprungkraft Plyometrie",
+ "summary": "Absprung und Landung",
+ "goal": "Sprungkraft für Mawashi Geri Vorbereitung",
+ "score": 0.62,
+ "semantic_score": 0.38,
+ "stage_semantic_score": 0.38,
+ },
+ ]
+ primary = resolve_path_primary_topic(q, brief, stage_learning_goal=stage_goal)
+ chosen = pick_best_path_hit(
+ hits,
+ set(),
+ stage_learning_goal=stage_goal,
+ stage_anti_patterns=path_anti,
+ roadmap_stage_match=True,
+ stage_match_brief=stage_brief,
+ path_primary_topic=primary,
+ path_technique_excludes=technique_sibling_excludes(primary or "mawashi geri"),
+ )
+ assert chosen is not None
+ assert int(chosen["id"]) == 2
+
+
+def test_resolve_path_primary_topic_from_stage_learning_goal():
+ brief = build_semantic_brief("Trainingsprogression gesprungener Tritt")
+ primary = resolve_path_primary_topic(
+ "Trainingsprogression gesprungener Tritt",
+ brief,
+ stage_learning_goal="Perfektionierung der statischen Mawashi Geri Technik",
+ )
+ assert primary and "mawashi" in primary
+
+
+def test_pick_roadmap_relaxed_for_non_technique_stage():
+ stage_goal = "Progression Hüftflexibilität und Adduktoren dehnen"
+ stage_brief = build_stage_match_brief(learning_goal=stage_goal)
+ hits = [
+ {
+ "id": 11,
+ "title": "Adduktoren Dehnung am Boden",
+ "summary": "Flexibilität Hüfte",
+ "goal": "Mobilität",
+ "score": 0.68,
+ "semantic_score": 0.22,
+ "stage_semantic_score": 0.22,
+ },
+ ]
+ chosen = pick_best_path_hit(
+ hits,
+ set(),
+ stage_learning_goal=stage_goal,
+ roadmap_stage_match=True,
+ stage_match_brief=stage_brief,
+ path_primary_topic=None,
+ )
+ assert chosen is not None
+ assert int(chosen["id"]) == 11
+
+
+def test_pick_rejects_kumite_when_primary_only_in_stage_goal():
+ brief = build_semantic_brief("Trainingsprogression")
+ stage_goal = "Perfektionierung der statischen Mawashi Geri Technik"
+ stage_brief = build_stage_match_brief(learning_goal=stage_goal)
+ primary = resolve_path_primary_topic("Trainingsprogression", brief, stage_learning_goal=stage_goal)
+ hits = [
+ {
+ "id": 4,
+ "title": "4 Kumite Reaktions Übungen",
+ "summary": "Partner",
+ "goal": "Kumite",
+ "score": 0.95,
+ "semantic_score": 0.4,
+ "stage_semantic_score": 0.35,
+ },
+ {
+ "id": 2,
+ "title": "Mawashi Geri Standtechnik",
+ "summary": "Rundtritt",
+ "goal": "Mawashi Geri Basis",
+ "score": 0.7,
+ "semantic_score": 0.5,
+ "stage_semantic_score": 0.48,
+ },
+ ]
+ chosen = pick_best_path_hit(
+ hits,
+ set(),
+ stage_learning_goal=stage_goal,
+ roadmap_stage_match=True,
+ stage_match_brief=stage_brief,
+ path_primary_topic=primary,
+ path_technique_excludes=technique_sibling_excludes(primary or "mawashi geri"),
+ )
+ assert chosen is not None
+ assert int(chosen["id"]) == 2
+
+
+def test_technique_scope_rejects_kumite_when_only_stage_goal_mentions_mawashi():
+ siblings = technique_sibling_excludes("mawashi geri")
+ assert not exercise_passes_technique_path_scope(
+ primary_topic="mawashi geri",
+ title="Kumite Grundstellungen",
+ summary="Partner-Distanz und freier Kampf",
+ goal="Kumite-Technik",
+ learning_goal="Sprungkraft und Koordination für Mawashi Geri",
+ sibling_excludes=siblings,
+ relaxed=True,
+ )
+
+
+def test_title_equivalent_to_stage_goal():
+ from planning_exercise_semantics import exercise_title_equivalent_to_stage_goal
+
+ assert exercise_title_equivalent_to_stage_goal(
+ "Hüftmobilität für Mae Geri",
+ "Hüftmobilität für Mae Geri",
+ )
+ assert exercise_title_equivalent_to_stage_goal(
+ "Hüftmobilität Mae Geri",
+ "Hüftmobilität für Mae Geri",
+ )
+ assert not exercise_title_equivalent_to_stage_goal("Kumite", "Hüftmobilität für Mae Geri")
+
+
+def test_stage_fit_passes_for_title_equivalent_with_sufficient_semantic_score():
+ stage_goal = "Koordination Absprung ohne Kick"
+ assert exercise_passes_stage_fit(
+ learning_goal=stage_goal,
+ title=stage_goal,
+ summary="Absprung und Landung koordinieren",
+ goal="",
+ path_primary_topic="mawashi geri",
+ path_technique_excludes=["kumite"],
+ stage_semantic_score=0.42,
+ )
+
+
+def test_roadmap_rank_fallback_picks_best_stage_semantic():
+ from planning_exercise_semantics import _pick_roadmap_rank_fallback
+
+ stage_goal = "Hüftmobilität für Mawashi Geri"
+ hits = [
+ {
+ "id": 1,
+ "title": "Hüftmobilität für Mawashi Geri",
+ "summary": "Aufwärmen",
+ "goal": "",
+ "score": 0.9,
+ "stage_rank_semantic": 0.32,
+ },
+ {
+ "id": 2,
+ "title": "Mawashi Hüftdehnung",
+ "summary": "Adduktoren und Hüfte",
+ "goal": "Mobilität für Mawashi Geri",
+ "score": 0.7,
+ "stage_rank_semantic": 0.58,
+ },
+ ]
+ chosen = _pick_roadmap_rank_fallback(
+ hits,
+ set(),
+ stage_learning_goal=stage_goal,
+ path_primary_topic="mawashi geri",
+ path_technique_excludes=technique_sibling_excludes("mawashi geri"),
+ )
+ assert chosen is not None
+ assert int(chosen["id"]) == 2
+
+
+def test_pick_best_prefers_semantic_fit_over_coincidental_title():
+ stage_goal = "Hüftmobilität für Mawashi Geri"
+ stage_brief = build_stage_match_brief(learning_goal=stage_goal)
+ hits = [
+ {
+ "id": 1,
+ "title": "Hüftmobilität für Mawashi Geri",
+ "summary": "allgemeine Aufwärmung",
+ "goal": "",
+ "score": 0.9,
+ "semantic_score": 0.12,
+ "stage_semantic_score": 0.12,
+ "stage_rank_semantic": 0.35,
+ },
+ {
+ "id": 2,
+ "title": "Mawashi Hüftmobilität und Adduktoren",
+ "summary": "Dehnung Hüfte für Rundtritt",
+ "goal": "Mawashi Geri Hüftbeweglichkeit",
+ "score": 0.72,
+ "semantic_score": 0.58,
+ "stage_semantic_score": 0.58,
+ "stage_rank_semantic": 0.62,
+ },
+ ]
+ chosen = pick_best_path_hit(
+ hits,
+ set(),
+ stage_learning_goal=stage_goal,
+ roadmap_stage_match=True,
+ stage_match_brief=stage_brief,
+ path_primary_topic="mawashi geri",
+ path_technique_excludes=technique_sibling_excludes("mawashi geri"),
+ )
+ assert chosen is not None
+ assert int(chosen["id"]) == 2
+
+
+def test_pick_roadmap_relaxed_with_path_primary_when_strict_fails():
+ """Bestehende Graph-Übungen: relaxed Gate auch bei gesetztem path_primary_topic."""
+ stage_goal = "Hüftmobilität für Mawashi Geri"
+ primary = "mawashi geri"
+ stage_brief = build_stage_match_brief(
+ learning_goal=stage_goal,
+ path_primary_topic=primary,
+ path_technique_excludes=technique_sibling_excludes(primary),
+ )
+ hits = [
+ {
+ "id": 42,
+ "title": "Mawashi Geri Hüftmobilität — Vereinsübung",
+ "summary": "Dehnung und Hüfte für Rundtritt",
+ "goal": "Mobilität Mawashi Geri",
+ "score": 0.55,
+ "semantic_score": 0.25,
+ "stage_semantic_score": 0.25,
+ },
+ ]
+ chosen = pick_best_path_hit(
+ hits,
+ set(),
+ stage_learning_goal=stage_goal,
+ roadmap_stage_match=True,
+ stage_match_brief=stage_brief,
+ path_primary_topic=primary,
+ path_technique_excludes=technique_sibling_excludes(primary),
+ )
+ assert chosen is not None
+ assert int(chosen["id"]) == 42
+
+
+def test_pick_best_roadmap_rejects_kumite_with_path_primary_topic():
+ q = "gesprungener Mawashi Geri Sprungphase"
+ brief = enrich_brief_with_path_constraints(build_semantic_brief(q), q)
+ primary = (brief.primary_topic or "mawashi geri").strip()
+ stage_goal = "Sprungvorbereitung für Mawashi Geri"
+ stage_brief = build_stage_match_brief(
+ learning_goal=stage_goal,
+ path_primary_topic=primary,
+ path_technique_excludes=technique_sibling_excludes(primary),
+ )
+ hits = [
+ {
+ "id": 1,
+ "title": "Kumite Distanztraining",
+ "summary": "Partnerarbeit",
+ "goal": "Kampfvorbereitung",
+ "score": 0.95,
+ "semantic_score": 0.4,
+ "stage_semantic_score": 0.35,
+ },
+ {
+ "id": 2,
+ "title": "Sprungkraft Plyometrie",
+ "summary": "Absprung für Tritttechnik",
+ "goal": "Sprungkraft Mawashi Geri Vorbereitung",
+ "score": 0.7,
+ "semantic_score": 0.45,
+ "stage_semantic_score": 0.42,
+ },
+ ]
+ chosen = pick_best_path_hit(
+ hits,
+ set(),
+ stage_learning_goal=stage_goal,
+ roadmap_stage_match=True,
+ stage_match_brief=stage_brief,
+ path_primary_topic=primary,
+ path_technique_excludes=technique_sibling_excludes(primary),
+ )
+ assert chosen is not None
+ assert int(chosen["id"]) == 2
+
+
+def test_technique_scope_rejects_sibling_geri_for_mawashi_path():
+ siblings = technique_sibling_excludes("mawashi geri")
+ assert any("mae" in s for s in siblings)
+ assert not exercise_passes_technique_path_scope(
+ primary_topic="mawashi geri",
+ title="Mae Geri Grundtechnik",
+ summary="Front kick",
+ goal="Präzision Mae Geri",
+ learning_goal="Sprungvorbereitung für Mawashi Geri",
+ sibling_excludes=siblings,
+ )
+ assert exercise_passes_technique_path_scope(
+ primary_topic="mawashi geri",
+ title="Sprungkraft Plyometrie",
+ summary="Absprung",
+ goal="Vorbereitung gesprungener Mawashi Geri",
+ learning_goal="Sprungvorbereitung für Mawashi Geri",
+ sibling_excludes=siblings,
+ )
+
+
+def test_stage_fit_rejects_yoko_geri_on_mawashi_roadmap_stage():
+ brief = build_semantic_brief("gesprungener Mawashi Geri Sprungphase")
+ primary = brief.primary_topic or "mawashi geri"
+ stage_goal = "Koordination Sprungphase Mawashi Geri"
+ stage_brief = build_stage_match_brief(
+ learning_goal=stage_goal,
+ path_primary_topic=primary,
+ path_technique_excludes=technique_sibling_excludes(primary),
+ )
+ assert not exercise_passes_stage_fit(
+ learning_goal=stage_goal,
+ title="Yoko Geri seitlicher Tritt",
+ summary="Seitwärtskick",
+ goal="Yoko Geri Technik",
+ stage_brief=stage_brief,
+ path_primary_topic=primary,
+ path_technique_excludes=technique_sibling_excludes(primary),
+ )
+
+
+def test_strip_off_topic_removes_partial_when_most_steps_bad():
+ steps = [{"exercise_id": i, "title": f"E{i}"} for i in range(1, 8)]
+ off_topic = [{"step_index": i, "issue": "path_exclude"} for i in range(5)]
+ out, removed = strip_off_topic_steps_from_path(steps, off_topic, min_remaining=2)
+ assert len(out) == 2
+ assert len(removed) == 5
+
+
+def test_parse_stage_goal_constraints_extracts_ohne_tritttechnik():
+ from planning_exercise_semantics import parse_stage_goal_constraints
+
+ c = parse_stage_goal_constraints("Koordination von Absprung und Beinhebung ohne Tritttechnik")
+ assert c.has_negation
+ assert "absprung" in c.positive_tokens
+ assert any("tritt" in ex for ex in c.exclude_phrases)
diff --git a/backend/tests/test_planning_skill_expectations.py b/backend/tests/test_planning_skill_expectations.py
new file mode 100644
index 0000000..f35be9a
--- /dev/null
+++ b/backend/tests/test_planning_skill_expectations.py
@@ -0,0 +1,121 @@
+"""Tests wiederverwendbare Fähigkeiten-Erwartungen (Progressionsgraph + später Planung)."""
+from unittest.mock import MagicMock
+
+from planning_skill_expectations import (
+ SCOPE_PROGRESSION_PATH,
+ SCOPE_PROGRESSION_STAGE,
+ PlanningSkillExpectationInput,
+ apply_expectations_to_target,
+ build_planning_skill_expectations,
+ expectation_input_from_progression_path,
+ expectation_input_from_progression_stage,
+)
+
+
+class _FakeCursor:
+ def __init__(self, skills):
+ self._skills = skills
+
+ def execute(self, query, params=None):
+ del query
+ if params and len(params) >= 2:
+ needle = str(params[0]).strip("%").lower()
+ exact = str(params[1]).lower()
+ matches = [
+ s
+ for s in self._skills
+ if needle in s["name"].lower() or s["name"].lower() == exact
+ ]
+ self._row = matches[0] if matches else None
+ else:
+ self._row = None
+
+ def fetchone(self):
+ return self._row
+
+
+def _fake_skill_rows():
+ return [
+ (1, "Koordination", 0),
+ (2, "Timing", 0),
+ (3, "Distanz", 0),
+ (4, "Kime", 0),
+ ]
+
+
+def test_expectation_input_from_progression_stage_merges_sources(monkeypatch):
+ monkeypatch.setattr(
+ "planning_skill_expectations._load_skills_for_text_match",
+ lambda cur: _fake_skill_rows(),
+ )
+ inp = expectation_input_from_progression_stage(
+ goal_query="Kumite Beinarbeit",
+ goal_analysis={
+ "primary_topic": "Kumite",
+ "start_assumption": "gleichförmige Steppbewegung",
+ "target_state": "explosiver Angriff",
+ },
+ resolved_structured={"roadmap_notes": "Kindergruppe"},
+ stage_spec={
+ "learning_goal": "variable Rhythmen",
+ "load_profile": ["timing", "distanz"],
+ "phase": "vertiefung",
+ },
+ semantic_brief_summary={"must_phrases": ["Beinarbeit"], "primary_topic": "Kumite"},
+ major_step={"phase": "vertiefung", "learning_goal": "Major-Ziel"},
+ )
+ assert inp.scope == SCOPE_PROGRESSION_STAGE
+ assert inp.primary_topic == "Kumite"
+ assert inp.start_situation == "gleichförmige Steppbewegung"
+ assert inp.load_profile == ["timing", "distanz"]
+ assert "Beinarbeit" in inp.skill_hints
+
+
+def test_build_planning_skill_expectations_load_profile_and_text(monkeypatch):
+ monkeypatch.setattr(
+ "planning_skill_expectations._load_skills_for_text_match",
+ lambda cur: _fake_skill_rows(),
+ )
+ cur = _FakeCursor(
+ [
+ {"id": 2, "name": "Timing"},
+ {"id": 3, "name": "Distanz"},
+ ]
+ )
+ inp = PlanningSkillExpectationInput(
+ scope=SCOPE_PROGRESSION_STAGE,
+ primary_topic="Kumite",
+ goal_query="Kumite Beinarbeit mit Timing",
+ load_profile=["timing", "distanz"],
+ )
+ exp = build_planning_skill_expectations(cur, inp)
+ assert exp.scope == SCOPE_PROGRESSION_STAGE
+ assert "load_profile" in exp.sources or "text_match" in exp.sources
+ names = {it.skill_name for it in exp.items}
+ assert "Timing" in names or "Distanz" in names
+ assert exp.skill_weights
+
+
+def test_expectation_input_from_progression_path_scope():
+ inp = expectation_input_from_progression_path(
+ goal_query="Mae Geri Perfektion",
+ goal_analysis={"primary_topic": "Mae Geri"},
+ resolved_structured={"start_situation": "Grundstellung", "target_state": "freier Kick"},
+ )
+ assert inp.scope == SCOPE_PROGRESSION_PATH
+ assert inp.start_situation == "Grundstellung"
+ assert inp.target_state == "freier Kick"
+
+
+def test_apply_expectations_to_target_noop_without_weights():
+ class _Target:
+ skill_weights = {}
+
+ def to_summary_dict(self, cur):
+ return {}
+
+ target = _Target()
+ from planning_skill_expectations import PlanningSkillExpectations
+
+ empty = PlanningSkillExpectations(scope=SCOPE_PROGRESSION_STAGE, skill_weights={}, items=[], sources=[])
+ assert apply_expectations_to_target(target, empty) is target
diff --git a/backend/tests/test_planning_stage_context.py b/backend/tests/test_planning_stage_context.py
new file mode 100644
index 0000000..dae3fa7
--- /dev/null
+++ b/backend/tests/test_planning_stage_context.py
@@ -0,0 +1,65 @@
+"""Tests Stufen-Kontext (Start/Ziel-Verkettung) und mehrstufige QS."""
+from planning_path_qa_pipeline import derive_optimization_hints, run_multistage_path_qa
+from planning_progression_roadmap import MajorStep, StageSpecArtifact
+from planning_stage_context import (
+ build_contextualized_stage_goal,
+ derive_stage_specs_transition_states,
+)
+
+
+def test_derive_stage_transition_chain():
+ majors = [
+ MajorStep(index=0, phase="grundlage", learning_goal="Stand-Mawashi", consolidates=["m1"]),
+ MajorStep(index=1, phase="vertiefung", learning_goal="Sprungvorbereitung", consolidates=["m2"]),
+ MajorStep(index=2, phase="perfektion", learning_goal="Gesprungener Mawashi", consolidates=["m3"]),
+ ]
+ specs = [
+ StageSpecArtifact(major_step_index=0, learning_goal=majors[0].learning_goal),
+ StageSpecArtifact(major_step_index=1, learning_goal=majors[1].learning_goal),
+ StageSpecArtifact(major_step_index=2, learning_goal=majors[2].learning_goal),
+ ]
+ out = derive_stage_specs_transition_states(
+ specs,
+ majors,
+ path_start="Anfänger mit Grundstellung",
+ path_target="Sauberer gesprungener Mawashi Geri",
+ )
+ assert out[0].start_state == "Anfänger mit Grundstellung"
+ assert out[1].start_state == out[0].target_state
+ assert out[2].target_state == "Sauberer gesprungener Mawashi Geri"
+
+
+def test_contextualized_stage_goal_includes_path_target():
+ text = build_contextualized_stage_goal(
+ learning_goal="Sprungkoordination",
+ start_state="Stand-Mawashi sicher",
+ target_state="Explosiver Absprung",
+ path_target_state="Gesprungener Mawashi Geri",
+ stage_index=1,
+ stage_count=3,
+ )
+ assert "Sprungkoordination" in text
+ assert "Gesamtziel" in text
+ assert "Soll-Start" in text
+
+
+def test_multistage_qa_emits_optimization_hints():
+ result = run_multistage_path_qa(
+ off_topic_steps=[],
+ stripped_off_topic=[
+ {
+ "step_index": 2,
+ "issue": "technique_scope",
+ "title": "Yoko Geri",
+ "reasons": ["Passt nicht zur Haupttechnik"],
+ }
+ ],
+ gaps=[{"from_title": "A", "to_title": "B", "gap_score": 0.6, "is_large_gap": True}],
+ llm_qa={"quality_score": 0.25, "recommendations": ["Athletisches Training ergänzen"]},
+ llm_applied=True,
+ )
+ assert len(result["qa_tiers"]) == 3
+ hints = result["optimization_hints"]
+ assert any(h.get("action") == "rematch_slot" for h in hints)
+ assert any(h.get("action") == "bridge_or_gap_fill" for h in hints)
+ assert derive_optimization_hints(result["qa_tiers"])
diff --git a/backend/tests/test_progression_graph_planning_artifact.py b/backend/tests/test_progression_graph_planning_artifact.py
new file mode 100644
index 0000000..1ae1932
--- /dev/null
+++ b/backend/tests/test_progression_graph_planning_artifact.py
@@ -0,0 +1,59 @@
+"""Tests Planungs-Artefakt am Progressionsgraph."""
+import pytest
+
+from progression_graph_planning_artifact import (
+ ARTIFACT_SCHEMA_VERSION,
+ normalize_planning_roadmap_payload,
+)
+
+
+def test_normalize_planning_roadmap_minimal():
+ out = normalize_planning_roadmap_payload(
+ {
+ "schema_version": ARTIFACT_SCHEMA_VERSION,
+ "goal_query": "Mae Geri Perfektion",
+ "max_steps": 5,
+ }
+ )
+ assert out["goal_query"] == "Mae Geri Perfektion"
+ assert out["max_steps"] == 5
+
+
+def test_normalize_planning_roadmap_with_progression_roadmap():
+ out = normalize_planning_roadmap_payload(
+ {
+ "goal_query": "Kumite Beinarbeit",
+ "progression_roadmap": {
+ "stage_specs": [{"major_step_index": 0, "learning_goal": "Grundstellung"}],
+ },
+ }
+ )
+ assert out["progression_roadmap"]["stage_specs"][0]["learning_goal"] == "Grundstellung"
+
+
+def test_normalize_rejects_invalid_type():
+ with pytest.raises(ValueError, match="JSON-Objekt"):
+ normalize_planning_roadmap_payload("not-json")
+
+
+def test_normalize_slot_contents():
+ out = normalize_planning_roadmap_payload(
+ {
+ "goal_query": "Gerade-Tritt",
+ "max_steps": 3,
+ "slot_contents": [
+ {
+ "major_step_index": 0,
+ "primary": {"kind": "library", "exercise_id": 12, "title": "Grundstellung"},
+ "siblings": [],
+ },
+ {
+ "major_step_index": 1,
+ "primary": {"kind": "proposal", "title": "KI-Entwurf", "proposal_key": "p1"},
+ "siblings": [],
+ },
+ ],
+ }
+ )
+ assert len(out["slot_contents"]) == 2
+ assert out["slot_contents"][1]["primary"]["kind"] == "proposal"
diff --git a/backend/version.py b/backend/version.py
index f3eec53..b09870e 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.213"
-BUILD_DATE = "2026-06-07"
-DB_SCHEMA_VERSION = "20260607087"
+APP_VERSION = "0.8.226"
+BUILD_DATE = "2026-05-22"
+DB_SCHEMA_VERSION = "20260607090"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
@@ -38,7 +38,7 @@ MODULE_VERSIONS = {
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0",
"exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume
- "planning_exercise_suggest": "0.21.1", # start_target_only + reicher gap-fill planning_context
+ "planning_exercise_suggest": "0.23.1", # Phase B: Rematch-Schleife mit optimization_hints + roadmap_unfilled
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
"training_programs": "0.1.0",
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
@@ -53,6 +53,120 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.226",
+ "date": "2026-05-22",
+ "changes": [
+ "Progressionsgraph Phase B: Rematch-Schleife (max_rematch_rounds) mit optimization_hints und roadmap_unfilled.",
+ "Fix: Graph-Bewertung/Match 500 bei off-topic am Rand-Slot (collect_gap_fill_specs IndexError).",
+ ],
+ },
+ {
+ "version": "0.8.225",
+ "date": "2026-06-07",
+ "changes": [
+ "Stufen start_state/target_state (Soll-Verkettung) + kontextualisiertes Matching.",
+ "Mehrstufige Pfad-QS (tier1–3) mit optimization_hints; Migration 090 Prompt.",
+ ],
+ },
+ {
+ "version": "0.8.224",
+ "date": "2026-06-07",
+ "changes": [
+ "Technik-Pfad-Scope: Geschwister-Techniken (Mae/Yoko bei Mawashi) als hartes Gate in Match/QS.",
+ "path_primary_topic in build_stage_match_brief; Intent technique_sibling_excludes.",
+ ],
+ },
+ {
+ "version": "0.8.223",
+ "date": "2026-06-07",
+ "changes": [
+ "planning_intent_context: gemeinsamer Intent für anti_patterns/success_criteria (Phase G-ready).",
+ "Migration 089: LLM-Prompts Zielanalyse + stage_spec mit Intent-Kontext; finalize nach Pipeline.",
+ ],
+ },
+ {
+ "version": "0.8.222",
+ "date": "2026-06-07",
+ "changes": [
+ "Pfad-Ausschlüsse: athletic→Kumite-Heuristik entfernt — nur explizite Negationen und anti_patterns.",
+ ],
+ },
+ {
+ "version": "0.8.221",
+ "date": "2026-06-07",
+ "changes": [
+ "Pfad-Ausschlüsse (keine Kumite etc.) aus Anfrage in Brief, stage_specs und Matching-Gates.",
+ "QS entfernt path_exclude-Schritte; partielles Strippen wenn die meisten Slots falsch sind.",
+ ],
+ },
+ {
+ "version": "0.8.220",
+ "date": "2026-06-07",
+ "changes": [
+ "Roadmap-Stufen-Match: build_stage_match_brief + stage_semantic_score über Titel, Summary und Goal.",
+ "Retriever lädt Übungsziele immer bei Stufen-Match; Ranking nach Stufen-Fit statt Gesamtthema.",
+ ],
+ },
+ {
+ "version": "0.8.219",
+ "date": "2026-06-07",
+ "changes": [
+ "Roadmap-Stufen-Gate: Negationen (ohne Tritttechnik) + Pflicht-Treffer Absprung/Beinhebung.",
+ "anti_patterns in Stufen-Match; Gesamt-Thema allein reicht bei strict_positive nicht mehr.",
+ ],
+ },
+ {
+ "version": "0.8.218",
+ "date": "2026-06-07",
+ "changes": [
+ "Roadmap-Match: Stufen-Lernziel-Gate (semantic_brief_for_stage, stage_learning_goal).",
+ "Kein Fallback auf globale goal_query bei roadmap_first — Lücke statt falscher Übung.",
+ "Retrieval-Strafe/Bonus für Stufen-Passung; QS erkennt stage_mismatch.",
+ ],
+ },
+ {
+ "version": "0.8.217",
+ "date": "2026-06-07",
+ "changes": [
+ "F9: planning_roadmap JSONB am Progressionsgraph (Migration 088).",
+ "PUT Graph + POST edges/sequence speichern Planungs-Artefakt; Laden beim Graph-Wechsel.",
+ "Roadmap-Vorschlag: include_llm_intent=true für reicheren Semantic Brief.",
+ "Doku: docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md als zentrale Ist-Referenz.",
+ ],
+ },
+ {
+ "version": "0.8.216",
+ "date": "2026-06-07",
+ "changes": [
+ "F8: Editierbare stage_specs in UI (Belastung, Erfolgskriterien) → roadmap_override.",
+ "Erwartete Fähigkeiten als Tags pro Pfadschritt und auf Pfad-Ebene.",
+ ],
+ },
+ {
+ "version": "0.8.215",
+ "date": "2026-06-07",
+ "changes": [
+ "F7: planning_skill_expectations — pro Stufe Retrieval, path_skill_expectations, Gap-Kontext.",
+ "Wiederverwendbare Scopes für spätere Trainingsplanung (training_section, framework_slot).",
+ ],
+ },
+ {
+ "version": "0.8.214",
+ "date": "2026-06-07",
+ "changes": [
+ "F6: ExerciseGapFillPrepModal — Trainer-Ergänzungen vor KI-Entwurf.",
+ "context_preview und reicher goal_for_ai aus Roadmap-Snapshot.",
+ ],
+ },
+ {
+ "version": "0.8.210",
+ "date": "2026-06-07",
+ "changes": [
+ "F5: Strukturierte Start/Ziel-Felder, Prompt 087, Zwei-Schritt-UI (start_target_only).",
+ "Priorität Trainer-Eingabe > KI > Regex; heuristische Start→Ziel-Roadmap.",
+ ],
+ },
{
"version": "0.8.209",
"date": "2026-06-07",
diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md
index 869ba42..06e5daf 100644
--- a/docs/HANDOVER.md
+++ b/docs/HANDOVER.md
@@ -89,38 +89,39 @@ 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 Übungssuche (Stand **0.8.183**)
+### 2.8 KI Assistenz Übungen & Planungs-KI (Stand **0.8.217**)
-**Spec / Pipeline:** `.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md`
+**Zentrale Ist-Doku (Progressionsgraph-KI):** **`docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md`** — bei Drift zuerst dort pflegen.
+
+**Retrieval / Scoring:** `.claude/docs/working/PLANNING_EXERCISE_SUGGEST_CONTEXT.md`
+**Zielarchitektur Roadmap:** `.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`
+**Produkt-Roadmap Phase G+:** `docs/architecture/PLANNING_KI_ROADMAP.md`
| Phase | Inhalt | Status |
|-------|--------|--------|
-| **P0** | Kontext-Pack, Hybrid-Score, Planungs-Picker | ✅ |
-| **P0.1** | `ExerciseMatchProfile` / `PlanningTargetProfile`, `profile_v1` | ✅ |
-| **P1** | Szenario-Pipeline + LLM Intent (`073`) + Erwartungsprofil (`074`) | ✅ |
-| **P2 / B2** | LLM-Rerank (`072`) bei engem Top-Feld, max. 2 LLM-Calls | ✅ **0.8.182** |
-| **A** | Voll-Library deterministisch ranken (kein OR-Profil-Pool) | ✅ **0.8.177** |
-| **B** | Text-Signale aus guidance/Rahmen-Zielen (`planning_text_signals`) | ✅ **0.8.181** |
-| **C1** | Progressionsgraph auto-match + variantenbewusste Nachfolger | ✅ **0.8.183** |
-| **C2** | Varianten in Trefferliste / Picker-Auswahl | ✅ **0.8.184** |
-| **C3** | Graph-Builder: Ziel → Pfad vorschlagen → in Graph speichern | ✅ **0.8.185** |
-| **E** | Semantik-Schicht (Brief, Phrasen-Score) + Pfad-QA (Lücken, Brücken, LLM-QS) | ✅ **0.8.186** |
-| **E2** | Pfad-Neuordnung (LLM) + KI-Neuanlage bei unüberbrückbaren Lücken | ✅ **0.8.187** |
-| **E3** | `gap_fill_offers`, Off-Topic-Strip, voller KI-Call bei Lücken | ✅ **0.8.203** |
-| **F0–F2** | Roadmap-Pipeline + LLM-Prompts (078/079) | ✅ **0.8.205** |
-| **F3** | `roadmap_first` — Retrieval + QA lite (keine Brücken/Reorder) | ✅ **0.8.209** |
-| **F4** | Roadmap-Review UI + `roadmap_override` | ✅ **0.8.207** |
-| **D** | `planning_context` an `suggestExerciseAi` (Neu-Anlage) | ✅ **0.8.208** |
+| **P0–P2, A–C2** | Übungssuche, Voll-Library, Graph-Bias, Varianten | ✅ bis **0.8.184** |
+| **C3, E–E3** | Pfad-Builder, Semantik, QA, Gap-Offers | ✅ bis **0.8.203** |
+| **D** | `planning_context` an `suggestExerciseAi` | ✅ **0.8.208** |
+| **F0–F4** | Roadmap-Pipeline, LLM 078/079, `roadmap_first`, UI-Review | ✅ **0.8.205–209** |
+| **F5** | Start/Ziel strukturiert, LLM **087**, Zwei-Schritt-UI | ✅ **0.8.210–214** |
+| **F6** | Gap-Prep-Modal, reicher KI-Kontext (`planning_exercise_form_context`) | ✅ **0.8.212–214** |
+| **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** |
-**Architektur-Entscheidung (2026-06-07):** Progressionsgraph = **Roadmap-first** (Ziel → Major Steps → Übungs-Match). **Keine Gruppenanalyse** im Graphen. Mitai Workflow-Engine **später** — jetzt `planning_progression_roadmap.py`. Spec: **`.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`** · Roadmap: **`docs/architecture/PLANNING_KI_ROADMAP.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`.
-**Backend:** `planning_exercise_suggest.py`, `planning_exercise_retrieval.py`, `planning_exercise_path_builder.py`, **`planning_progression_roadmap.py`** · Router `POST /api/planning/exercise-suggest`, `POST /api/planning/progression-path-suggest` (`roadmap_first`, `include_roadmap_preview`)
+**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`
-**Frontend:** `ExerciseProgressionPathBuilder` — Roadmap-Box + Pfad je Major Step (`roadmap_first`) · `ExercisePickerModal` (Planung)
+**API:** `POST /api/planning/progression-path-suggest` · `PUT /api/exercise-progression-graphs/:id` (`planning_roadmap`) · `POST …/edges/sequence`
-**Superadmin:** Übungs-Anreicherung (Skills) — `exercise_enrichment_admin` (**0.8.178+**), separater Admin-Flow
+**Frontend:** `ExerciseProgressionPathBuilder`, `ExerciseGapFillPrepModal`, `planningContextForExerciseAi.js`
-**Offen (F4+):** Roadmap-UI editierbar; Trainingsplanung eigene Pipeline (Gruppenkontext); Enrichment
+**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
#### Übungs-KI Formular / Schnellanlage (Stand **0.8.171**)
diff --git a/docs/architecture/PLANNING_KI_ROADMAP.md b/docs/architecture/PLANNING_KI_ROADMAP.md
index 8f0cda3..ee16d23 100644
--- a/docs/architecture/PLANNING_KI_ROADMAP.md
+++ b/docs/architecture/PLANNING_KI_ROADMAP.md
@@ -1,10 +1,11 @@
# Planungs-KI — Produkt-Roadmap
-**Stand:** 2026-06-07
-**App-Version:** ab **0.8.204** — maßgeblich `backend/version.py`
+**Stand:** 2026-05-22
+**App-Version:** **0.8.217** — 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**.
+**Ist-Stand Progressionsgraph (detailliert):** `PLANNING_PROGRESSION_GRAPH_KI.md`
**Leit-Spec:** `.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md`
---
@@ -26,9 +27,10 @@ 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–F1** | Progressionsgraph | Roadmap-Pipeline Scaffold + API-Preview | 🔄 **0.8.204** |
-| **F2–F4** | Progressionsgraph | LLM Roadmap, roadmap-first Retrieval, UI Review | 🔲 |
+| **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** |
| 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 |
@@ -70,6 +72,40 @@ Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROA
- [x] Major Steps editierbar (Phase, Lernziel, Reihenfolge) vor Übungs-Match
- [x] API `roadmap_only` + `roadmap_override`
+### F5 — Start/Ziel (0.8.210–214)
+
+- [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“
+
+### F6 — Gap-KI-Kontext (0.8.212–214)
+
+- [x] `ExerciseGapFillPrepModal` vor KI-Call
+- [x] `planning_exercise_form_context.py` — Gap-Snapshot, `context_preview`
+- [x] Migration **085** — `planning_context` in Übungs-Prompts
+
+### F7 — Fähigkeiten-Scoring (0.8.215–216)
+
+- [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
+
+### F8 — Stufen-Details UI (0.8.216)
+
+- [x] Editierbare `stage_specs` in `roadmap_override` (Belastung, Erfolgskriterien, Vermeiden)
+
+### F9 — Persistenz (0.8.217)
+
+- [x] Migration **088** — `planning_roadmap` JSONB am Graph
+- [x] Laden/Speichern über `GET/PUT` Graph + Sequenz-Endpoint
+
+### UX — UI-Überarbeitung (offen)
+
+- [ ] Wizard mit 4 Schritten (Ziel → Roadmap → Match → Lücken)
+- [ ] Progressive disclosure — Details in Panels, nicht alles gleichzeitig
+- [ ] Briefing: `PLANNING_PROGRESSION_GRAPH_KI.md` §10
+
---
## Abhängigkeiten
@@ -85,4 +121,4 @@ Diese Roadmap ergänzt die **Architektur-Refaktor-Roadmap** (`UMSETZUNGSPLAN_ROA
## Pflege
-Bei Abschluss einer Teilphase: diese Datei, `HANDOVER.md` §2.8, `PLANNING_EXERCISE_SUGGEST_CONTEXT.md` §24, Changelog in `version.py`.
+Bei Abschluss einer Teilphase: **`PLANNING_PROGRESSION_GRAPH_KI.md`** (Ist-Stand), diese Datei, `HANDOVER.md` §2.8, `PLANNING_EXERCISE_SUGGEST_CONTEXT.md` §24, Changelog in `version.py`.
diff --git a/docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md b/docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md
new file mode 100644
index 0000000..5b2df2d
--- /dev/null
+++ b/docs/architecture/PLANNING_PROGRESSION_GRAPH_KI.md
@@ -0,0 +1,368 @@
+# 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`)
+
+> **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.
+
+**Bezüge:**
+`.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) ·
+`docs/architecture/PLANNING_KI_ROADMAP.md` (Produkt-Roadmap Phase G+)
+
+---
+
+## 1. Fachliche Abgrenzung
+
+| Thema | Progressionsgraph (dieses Feature) | Trainingsplanung (Phase G, später) |
+|--------|-------------------------------------|-------------------------------------|
+| Ziel | Curriculum / Technikpfad über Übungen | Konkrete Trainingseinheit für Gruppe |
+| Kontext | Zieltext, Start/Ziel, Roadmap, optional Graph-Kanten | Gruppe, Historie, Termin, Rahmen |
+| Pipeline | `planning_progression_roadmap.py` → Path-Builder | Eigene Pipeline (noch offen) |
+| Gruppenanalyse | **Nein** | **Ja** |
+| Wiederverwendung | `planning_skill_expectations.py`, `planning_exercise_form_context.py` | Gleiche Bausteine, andere Scopes |
+
+**Shinkan-Regel:** Der Progressionsgraph ist **keine** persönliche Tracking-App und plant **nicht** für einzelne Sportler.
+
+---
+
+## 2. Trainer-Workflow (UI)
+
+Aktuell in `ExerciseProgressionPathBuilder.jsx` (eingebettet in `ExerciseProgressionGraphPanel.jsx`):
+
+```
+① Ziel eingeben (+ optional Start/Ziel-Felder manuell)
+② „Start/Ziel analysieren“ (optional, start_target_only)
+③ „Roadmap vorschlagen“ (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)
+```
+
+**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.
+
+---
+
+## 3. Architektur (Module)
+
+```mermaid
+flowchart TB
+ subgraph ui [Frontend]
+ EPB[ExerciseProgressionPathBuilder]
+ GFM[ExerciseGapFillPrepModal]
+ PCtx[planningContextForExerciseAi.js]
+ end
+
+ subgraph api [API]
+ PPS[POST /api/planning/progression-path-suggest]
+ EAI[POST /api/exercises/ai/suggest]
+ SEQ[POST /api/exercise-progression-graphs/:id/edges/sequence]
+ PUT[PUT /api/exercise-progression-graphs/:id]
+ end
+
+ subgraph roadmap [Roadmap-Pipeline]
+ PR[planning_progression_roadmap.py]
+ ST087[Prompt 087 start_target]
+ ST078[Prompts 078/079 roadmap + stage_spec]
+ end
+
+ subgraph match [Match + QA]
+ PB[planning_exercise_path_builder.py]
+ RET[planning_exercise_retrieval.py]
+ PG[planning_exercise_progression.py]
+ SEM[planning_exercise_semantics.py]
+ end
+
+ subgraph skills [Fähigkeiten — wiederverwendbar]
+ PSE[planning_skill_expectations.py]
+ PEFC[planning_exercise_form_context.py]
+ AIF[planning_exercise_path_ai_fill.py]
+ end
+
+ subgraph persist [Persistenz Graph]
+ G088[(planning_roadmap JSONB — Migration 088)]
+ EDGES[(exercise_progression_edges)]
+ end
+
+ EPB --> PPS
+ EPB --> SEQ
+ EPB --> PUT
+ GFM --> EAI
+ PPS --> PR
+ PPS --> PB
+ PB --> RET
+ PB --> PG
+ PB --> PSE
+ AIF --> PEFC
+ PSE --> RET
+ SEQ --> EDGES
+ PUT --> G088
+ SEQ --> G088
+```
+
+### 3.1 Verantwortlichkeiten
+
+| 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_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) |
+
+---
+
+## 4. API — `POST /api/planning/progression-path-suggest`
+
+### 4.1 Wichtige Request-Flags
+
+| Feld | Typ | Bedeutung |
+|------|-----|-----------|
+| `query` | string | Ziel / Entwicklungsrichtung (min. 3 Zeichen) |
+| `max_steps` | int | Anzahl Major Steps (2–10) |
+| `progression_graph_id` | int? | Gewählter Graph — siehe §5 |
+| `roadmap_first` | bool | Match pro `stage_spec` statt iterativem Pfad |
+| `roadmap_only` | bool | Nur Roadmap, keine Übungen |
+| `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) |
+| `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 |
+
+### 4.2 Wichtige Response-Felder
+
+| Feld | Bedeutung |
+|------|-----------|
+| `progression_roadmap` | Artefakte A/B/C inkl. `resolved_structured`, `stage_specs`, `prompt_slugs` |
+| `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` |
+
+### 4.3 Prompt-Slugs (nur in `ai_prompts`, nie Hardcoding)
+
+| Slug | Migration | Phase |
+|------|-----------|-------|
+| `planning_progression_start_target` | **087** | Start/Ziel-Extraktion |
+| `planning_progression_goal_analysis` | **078** | Zielanalyse |
+| `planning_progression_roadmap` | **078** | Roadmap (micro → major) |
+| `planning_progression_stage_spec` | **079** | Stufenspezifikation |
+
+---
+
+## 5. Roadmap-Match — Stufen-Qualität (0.8.218)
+
+Pro Major Step gilt:
+
+1. **Stufen-Brief** — `semantic_brief_for_stage()` ergänzt `must_phrases` um das Stufen-Lernziel.
+2. **Stufen-Gate** — `exercise_passes_stage_learning_goal_gate()` prüft Lernziel-Text in Titel/Summary/Ziel oder Mindest-`semantic_score`.
+3. **Kein Fallback** — Bei `roadmap_first` wird **nicht** auf die globale `goal_query` zurückgefallen; passt keine Übung → **Lücke** (`roadmap_unfilled`) statt themenfremder Übung.
+4. **Retrieval** — Bonus/Strafe im Hybrid-Score je nach Stufen-Passung.
+5. **QS** — `detect_off_topic_steps` erkennt `stage_mismatch` anhand `roadmap_learning_goal`.
+
+Tests: `test_planning_roadmap_stage_match.py`
+
+---
+
+## 6. Rolle des bestehenden Graphs
+
+**Wichtig — häufiges Missverständnis:**
+
+| Aspekt | Verhalten |
+|--------|-----------|
+| **KI-Pfadvorschlag** | Baut einen **neuen** Übungspfad aus der **Bibliothek** (Roadmap-first), nicht aus vorhandenen Graph-Knoten als Start |
+| **Schritt 1** | Kein Anker → **kein** Graph-Bias |
+| **Ab Schritt 2** | Anker = vorherige Übung im Vorschlag → ausgehende Kanten im Graph werden geladen |
+| **Scoring** | Nachfolger im Graph: Bonus `progression` (ca. **4–10 %** Gewicht, Semantik/Profil dominieren) |
+| **Speichern** | `POST …/edges/sequence` **fügt Kanten hinzu** — ersetzt den Graph nicht |
+| **Nicht implementiert** | „Ab letztem Graph-Knoten erweitern“, bestehende Sequenz als Pfad-Start |
+
+Code: `planning_exercise_progression.py` → `apply_progression_context_to_pack` → `planning_exercise_retrieval.py` (`prog_hit`).
+
+---
+
+## 7. Persistenz
+
+### 6.1 Kanten (`exercise_progression_edges`)
+
+- Übungsfolge als gerichtete Kanten `next_exercise`
+- Varianten-aware (Migration **034**)
+- Kanten-Notizen aus Pfad-Begründungen oder Fallback-`segment_notes`
+
+### 6.2 Planungs-Artefakt (`planning_roadmap` JSONB, Migration **088**)
+
+Gespeichert am **Graph-Container** (`exercise_progression_graphs`), Schema v1:
+
+```json
+{
+ "schema_version": 1,
+ "goal_query": "…",
+ "start_situation": "…",
+ "target_state": "…",
+ "roadmap_notes": "…",
+ "max_steps": 5,
+ "progression_roadmap": { },
+ "path_skill_expectations": { }
+}
+```
+
+| Aktion | API |
+|--------|-----|
+| Speichern (Roadmap/Match) | `PUT /api/exercise-progression-graphs/:id` |
+| Speichern (mit Pfad) | `POST …/edges/sequence` (optional `planning_roadmap` im Body) |
+| Laden | `GET /api/exercise-progression-graphs/:id` → Frontend stellt Workflow-Felder wieder her |
+
+Validierung: `progression_graph_planning_artifact.py` · Tests: `test_progression_graph_planning_artifact.py`
+
+**`planning_roadmap` ≠ Graph-Kanten:** Metadaten für Wiederaufnahme der KI-Planung, nicht der Übungsgraph selbst.
+
+---
+
+## 8. Planungs-Intent (gemeinsam mit Trainingsplanung)
+
+Modul: **`planning_intent_context.py`** — domänenneutral, wiederverwendbar in Phase G.
+
+| Baustein | Progressionsgraph heute | Trainingsplanung (Phase G) |
+|----------|-------------------------|----------------------------|
+| `build_planning_intent_context` | Aus `goal_query`, Zielanalyse, Notizen | Aus `section_guidance`, Slot, Einheit |
+| `explicit_exclusions` | Negationen (`ohne/kein/nicht …`) | gleich |
+| `path_anti_patterns` | → jede `stage_spec.anti_patterns` | → Abschnitts-/Slot-Brief |
+| `path_success_criteria` | → `success_criteria` + Matching | → Slot-Erwartung |
+| `finalize_stage_specs_with_intent` | Nach heuristischer/LLM-Roadmap | Analog für Sektionen |
+
+LLM: Migration **089** — Prompts `planning_progression_goal_analysis` + `planning_progression_stage_spec` erhalten `intent_context_json`; Ausschlüsse nicht erfinden, nur aus Anfrage übernehmen.
+
+Matching: `anti_patterns` + `success_criteria` → `build_stage_match_brief` → Retrieval-Gate (Titel + Summary + Goal).
+
+### Stufen im Gesamtziel (`planning_stage_context.py`)
+
+| Feld | Bedeutung |
+|------|-----------|
+| `start_state` | Soll-Start der Stufe (= `target_state` der Vorstufe / Pfad-Start) |
+| `target_state` | Ziel nach dieser Stufe (= Soll für den nächsten Schritt) |
+| `build_contextualized_stage_goal()` | Lernziel + Start + Stufen-Ziel + Gesamtziel → Brief/Retrieval |
+
+Deterministisch: `derive_stage_specs_transition_states()` nach Roadmap-Pipeline; LLM kann Felder überschreiben (Prompt **090**).
+
+### Mehrstufige Pfad-QS (`planning_path_qa_pipeline.py`)
+
+| Stufe | Inhalt | Ableitung |
+|-------|--------|-----------|
+| **tier1** | Deterministische Gates (Technik-Scope, Ausschlüsse, unfilled) | `optimization_hints` → `rematch_slot`, `refine_stage_spec` |
+| **tier2** | Übergangs-Lücken zwischen Schritten | `bridge_or_gap_fill` |
+| **tier3** | LLM-Ganzpfad + Empfehlungen | `review_roadmap` |
+
+API: `path_qa.qa_tiers`, `path_qa.optimization_hints` — **kein** anfrage-spezifischer Patch, sondern strukturierte Rückkopplung. Auto-Rematch-Schleife: Backlog (QS → Aktion → erneutes Match).
+
+Phase G: gleiche Tiers für Abschnitts-QS nach Einheits-Match.
+
+## 9. Fähigkeiten-Scoring-Anbindung
+
+Modul: `planning_skill_expectations.py`
+
+| Scope | Verwendung heute | Phase G (vorbereitet) |
+|-------|------------------|------------------------|
+| `progression_path` | Einmaliges Pfad-Profil (`path_skill_expectations`) | — |
+| `progression_stage` | Pro Roadmap-Stufe vor Retrieval + in Gap-Kontext | — |
+| `training_section` | — | Trainingsabschnitt |
+| `framework_slot` | — | Rahmen-Slot |
+
+Quellen: `semantic_topic`, `text_match`, `load_profile` (aus `stage_spec`).
+
+Integration:
+- Retrieval: `apply_expectations_to_target` → Hybrid-Score
+- UI: Tags pro Pfadschritt + Pfad-Header; Gap-Kontext „Erwartete Fähigkeiten“
+- KI-Neuanlage: `expected_skills` in `context_preview` / `goal_for_ai`
+
+---
+
+## 10. KI-Lücken (Gap-Fill)
+
+Flow:
+1. `roadmap_unfilled` / QA-Lücken → `gap_fill_offers`
+2. Trainer: **„Vorbereiten & KI anlegen“** → `ExerciseGapFillPrepModal` (Titel, Stufen-Lernziel, Ergänzungen)
+3. `POST /api/exercises/ai/suggest` mit `planning_context`
+4. Vorschau → Übung anlegen → in Pfad einfügen
+
+Kontext-Helfer: `frontend/src/utils/planningContextForExerciseAi.js`
+
+---
+
+## 11. Implementierungsstände (Phasen)
+
+| Phase | Inhalt | Status | Version |
+|-------|--------|--------|---------|
+| F0–F2 | Roadmap-Pipeline + LLM-Prompts 078/079 | ✅ | 0.8.204–205 |
+| F3 | `roadmap_first` Match pro Stufe | ✅ | 0.8.206–209 |
+| F4 | Roadmap-Review UI + `roadmap_override` | ✅ | 0.8.207 |
+| F5 | Start/Ziel strukturiert + LLM **087** + Zwei-Schritt-UI | ✅ | 0.8.210–214 |
+| F6 | Gap-Prep-Modal + reicher `planning_context` | ✅ | 0.8.212–214 |
+| 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 |
+
+---
+
+## 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)
+
+### Briefing-Vorlage UI-Chat (Copy-Paste)
+
+Siehe Nutzer-Chat 2026-05-22 oder `HANDOVER.md` §2.8 — Abschnitt „UI-Überarbeitung“.
+
+Kern: Wizard ① Ziel & Start/Ziel → ② Roadmap → ③ Match → ④ Lücken & Speichern; Backend-Pipeline unverändert lassen.
+
+---
+
+## 13. Tests
+
+| Datei | Abdeckung |
+|-------|-----------|
+| `test_planning_progression_roadmap.py` | Roadmap-Pipeline, Override, Start/Ziel |
+| `test_planning_exercise_path_builder.py` | Pfad-Annotierung, Skill-Expectations auf Steps |
+| `test_planning_skill_expectations.py` | Skill-Erwartungen, Scopes |
+| `test_planning_intent_context.py` | Intent-Kontext, finalize stage_specs |
+| `test_planning_exercise_path_ai_fill.py` | Gap-Fill, `expected_skills` in goal text |
+| `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 |
+
+---
+
+## 14. Dokumenten-Index (Drift vermeiden)
+
+| Frage | Primäre Quelle |
+|-------|----------------|
+| Was ist der aktuelle Ist-Stand? | **Diese Datei** |
+| Zielarchitektur / JSON-Artefakte | `PLANNING_PROGRESSION_ROADMAP_SPEC.md` |
+| Retrieval, Hybrid-Score, Intents | `PLANNING_EXERCISE_SUGGEST_CONTEXT.md` |
+| Produkt-Roadmap Phase G+ | `PLANNING_KI_ROADMAP.md` |
+| Session-Handover / nächste Schritte | `docs/HANDOVER.md` §2.8 |
+| Fähigkeiten-Scoring allgemein | `SKILL_SCORING_SPEC.md` |
+| Versionen / Changelog | `backend/version.py` |
+
+**Pflege-Regel:** Bei jeder abgeschlossenen Teilphase diese Datei + `HANDOVER.md` §2.8 + `version.py` CHANGELOG aktualisieren.
+
+---
+
+## 15. Changelog (Dokument)
+
+| Datum | Änderung |
+|-------|----------|
+| 2026-05-22 | Erstfassung Ist-Stand 0.8.217 — zentrale Referenz nach F5–F9 |
diff --git a/docs/architecture/README.md b/docs/architecture/README.md
index 40061f6..a3a4c40 100644
--- a/docs/architecture/README.md
+++ b/docs/architecture/README.md
@@ -14,7 +14,8 @@ Dieses Bündel ist die **Leitlinie für die große Refaktorierung** nach dem MVP
| [`frontend/src/api/planning.js`](../../frontend/src/api/planning.js) | Phase 4: Trainingsplanung (Einheiten, Vorlagen, Module, Rahmen, KPIs) |
| [BASELINE_SNAPSHOT.md](./BASELINE_SNAPSHOT.md) | Phase 0: Bundle-, API- und Last-Baseline (Messvorlagen, Vergleich nach Phase 2) |
| [KI-Prompt-Zielarchitektur](../../.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md) | Roadmap: Kontext-Arten, Composition, Planung/Rahmen, Phasenplan (verbindliche Zielrichtung) |
-| [PLANNING_KI_ROADMAP.md](./PLANNING_KI_ROADMAP.md) | **Planungs-KI Produkt-Roadmap** (Phase F Roadmap-first, Abgrenzung Trainingsplanung) |
+| [PLANNING_PROGRESSION_GRAPH_KI.md](./PLANNING_PROGRESSION_GRAPH_KI.md) | **Progressionsgraph-KI Ist-Stand** (Module, API, Graph-Verhalten, Persistenz — zentrale Referenz) |
+| [PLANNING_KI_ROADMAP.md](./PLANNING_KI_ROADMAP.md) | **Planungs-KI Produkt-Roadmap** (Phase F–G, Abgrenzung Trainingsplanung) |
| [Progressions-Roadmap Spec](../../.claude/docs/working/PLANNING_PROGRESSION_ROADMAP_SPEC.md) | Phase F: Artefakte A→B→C, API, Workflow-lite |
## Tests (E2E / Refaktor-Budget)
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index acb42d4..1f695a6 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -31,6 +31,7 @@ const SettingsSystemInfoPage = lazy(() => import('./pages/SettingsSystemInfoPage
const ExercisesListPage = lazy(() => import('./pages/ExercisesListPage'))
const ExerciseDetailPage = lazy(() => import('./pages/ExerciseDetailPage'))
const ExerciseFormPage = lazy(() => import('./pages/ExerciseFormPage'))
+const ProgressionGraphEditPage = lazy(() => import('./pages/ProgressionGraphEditPage'))
const ClubsPage = lazy(() => import('./pages/ClubsPage'))
const InboxPage = lazy(() => import('./pages/InboxPage'))
const SkillsPage = lazy(() => import('./pages/SkillsPage'))
@@ -244,6 +245,7 @@ const appRouter = createBrowserRouter([
{ path: 'settings/system', element: },
{ path: 'settings/legal', element: },
{ path: 'media', element: },
+ { path: 'progression-graphs/:id', element: },
{
path: 'exercises',
children: [
diff --git a/frontend/src/api/exercises.js b/frontend/src/api/exercises.js
index 4eee53d..4e72274 100644
--- a/frontend/src/api/exercises.js
+++ b/frontend/src/api/exercises.js
@@ -521,6 +521,16 @@ export async function getExerciseProgressionGraph(id, { includeEdges = false } =
return request(`/api/exercise-progression-graphs/${id}${q}`)
}
+export async function getProgressionGraphVisibilityPromotionCandidates(
+ graphId,
+ { targetVisibility = 'club' } = {},
+) {
+ const q = new URLSearchParams({ target_visibility: targetVisibility })
+ return request(
+ `/api/exercise-progression-graphs/${graphId}/visibility-promotion-candidates?${q}`,
+ )
+}
+
export async function createExerciseProgressionGraph(data) {
return request('/api/exercise-progression-graphs', {
method: 'POST',
diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx
index cd92620..2d02149 100644
--- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx
+++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx
@@ -1,15 +1,21 @@
/**
- * Progressionsgraphen: Sequenz-Editor (mehrere Nachfolger auf einmal), Ketten-Ansicht,
- * Varianten als eigene Knoten-Endpunkte, Schwester-Kanten gesondert, Tabelle als Fallback.
+ * Progressionsgraphen — Kachel-Übersicht (wie Übungen) + Editor-Detailansicht.
*/
-import React, { useCallback, useEffect, useMemo, useState } from 'react'
-import { Link } from 'react-router-dom'
+import React, {
+ forwardRef,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useState,
+} from 'react'
+import { Link, useLocation } from 'react-router-dom'
import api from '../utils/api'
import SkillProfilePanel from './skills/SkillProfilePanel'
import { useAuth } from '../context/AuthContext'
-import { getTenantClubDependencyKey } from '../utils/activeClub'
-import ExercisePickerModal from './ExercisePickerModal'
-import ExerciseProgressionPathBuilder from './ExerciseProgressionPathBuilder'
+import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub'
+import ProgressionGraphEditor from './ProgressionGraphEditor'
+import ProgressionGraphListCard from './ProgressionGraphListCard'
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels'
const VIS_OPTIONS = [
@@ -24,87 +30,16 @@ function edgeTypeLabel(type) {
return type || '—'
}
-/** Maximale lineare Segmente aus next_exercise-Kanten (jedes Segment deckt zusammenhängende „Pfade“ ab). */
-function maximalLinearChains(nextEdges) {
- if (!nextEdges?.length) return []
- const outMap = new Map()
- const inMap = new Map()
- const nodeKey = (ex, v) => `${ex}:${v ?? ''}`
-
- for (const e of nextEdges) {
- const f = nodeKey(e.from_exercise_id, e.from_exercise_variant_id)
- const t = nodeKey(e.to_exercise_id, e.to_exercise_variant_id)
- if (!outMap.has(f)) outMap.set(f, [])
- outMap.get(f).push(e)
- if (!inMap.has(t)) inMap.set(t, [])
- inMap.get(t).push(e)
- }
-
- const used = new Set()
- const chains = []
-
- for (const startEdge of nextEdges) {
- if (used.has(startEdge.id)) continue
-
- const edgesSeq = [startEdge]
-
- let fk = nodeKey(startEdge.from_exercise_id, startEdge.from_exercise_variant_id)
- while (true) {
- const preds = inMap.get(fk)
- if (!preds || preds.length !== 1) break
- const pred = preds[0]
- if (used.has(pred.id)) break
- edgesSeq.unshift(pred)
- fk = nodeKey(pred.from_exercise_id, pred.from_exercise_variant_id)
- }
-
- let tk = nodeKey(startEdge.to_exercise_id, startEdge.to_exercise_variant_id)
- while (true) {
- const outs = outMap.get(tk)
- if (!outs || outs.length !== 1) break
- const nx = outs[0]
- if (used.has(nx.id)) break
- edgesSeq.push(nx)
- tk = nodeKey(nx.to_exercise_id, nx.to_exercise_variant_id)
- }
-
- edgesSeq.forEach((ed) => used.add(ed.id))
-
- const first = edgesSeq[0]
- const nodes = [
- {
- exercise_id: first.from_exercise_id,
- variant_id: first.from_exercise_variant_id ?? null,
- title: first.from_exercise_title,
- variant_name: first.from_variant_name ?? null,
- },
- ]
- for (const ed of edgesSeq) {
- nodes.push({
- exercise_id: ed.to_exercise_id,
- variant_id: ed.to_exercise_variant_id ?? null,
- title: ed.to_exercise_title,
- variant_name: ed.to_variant_name ?? null,
- })
- }
- chains.push({ nodes, edges: edgesSeq })
- }
- return chains
-}
-
-function emptySeqStep() {
- return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [] }
-}
-
-function emptyEndpoint() {
- return { exerciseId: null, exerciseTitle: '', variantId: null, variants: [] }
-}
-
-export default function ExerciseProgressionGraphPanel({
- anchorExerciseId = null,
- anchorTitle = null,
-}) {
+function ExerciseProgressionGraphPanel(
+ {
+ anchorExerciseId = null,
+ anchorTitle = null,
+ initialGraphId = null,
+ },
+ ref,
+) {
const { user } = useAuth()
+ const location = useLocation()
const isSuperadmin = user?.role === 'superadmin'
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
@@ -119,6 +54,7 @@ export default function ExerciseProgressionGraphPanel({
const [busy, setBusy] = useState(false)
const [loadErr, setLoadErr] = useState(null)
+ const [createModalOpen, setCreateModalOpen] = useState(false)
const [newGraphName, setNewGraphName] = useState('')
const [newGraphVisibility, setNewGraphVisibility] = useState('private')
@@ -126,26 +62,35 @@ export default function ExerciseProgressionGraphPanel({
const [metaDescription, setMetaDescription] = useState('')
const [metaVisibility, setMetaVisibility] = useState('private')
- const [sequenceSteps, setSequenceSteps] = useState([emptySeqStep(), emptySeqStep()])
- const [sequenceBulkNotes, setSequenceBulkNotes] = useState('')
- const [pickContext, setPickContext] = useState(null)
-
- const [relationKind, setRelationKind] = useState('progression')
- const [firstEp, setFirstEp] = useState(emptyEndpoint)
- const [secondEp, setSecondEp] = useState(emptyEndpoint)
- const [edgeNotes, setEdgeNotes] = useState('')
-
const [filterAnchorOnly, setFilterAnchorOnly] = useState(!!anchorExerciseId)
const [editingEdgeNotes, setEditingEdgeNotes] = useState(null)
const [notesDraft, setNotesDraft] = useState('')
- const [uiTab, setUiTab] = useState('overview')
const [skillProfileData, setSkillProfileData] = useState(null)
const [skillProfileLoading, setSkillProfileLoading] = useState(false)
const [skillProfileError, setSkillProfileError] = useState('')
+ useImperativeHandle(ref, () => ({
+ openCreateDialog: () => {
+ setNewGraphName('')
+ setNewGraphVisibility('private')
+ setCreateModalOpen(true)
+ },
+ }))
+
useEffect(() => {
- setSelectedGraphId(null)
- }, [tenantClubDepKey])
+ const gid =
+ initialGraphId ??
+ location.state?.progressionGraphId
+ if (gid != null && Number.isFinite(Number(gid))) {
+ setSelectedGraphId(Number(gid))
+ }
+ }, [location.state?.progressionGraphId, initialGraphId])
+
+ useEffect(() => {
+ if (!initialGraphId && !location.state?.progressionGraphId) {
+ setSelectedGraphId(null)
+ }
+ }, [tenantClubDepKey, initialGraphId, location.state?.progressionGraphId])
const refreshGraphs = useCallback(async () => {
const list = await api.listExerciseProgressionGraphs()
@@ -162,12 +107,6 @@ export default function ExerciseProgressionGraphPanel({
setEdges(Array.isArray(list) ? list : [])
}, [])
- const loadVariantsForExercise = useCallback(async (exerciseId) => {
- if (!exerciseId) return []
- const ex = await api.getExercise(exerciseId)
- return Array.isArray(ex?.variants) ? ex.variants : []
- }, [])
-
useEffect(() => {
let cancelled = false
;(async () => {
@@ -239,30 +178,6 @@ export default function ExerciseProgressionGraphPanel({
}
}, [selectedGraphId, graphs, refreshEdges])
- useEffect(() => {
- let cancelled = false
- ;(async () => {
- if (!firstEp.exerciseId) return
- const vars = await loadVariantsForExercise(firstEp.exerciseId)
- if (!cancelled) setFirstEp((p) => ({ ...p, variants: vars }))
- })()
- return () => {
- cancelled = true
- }
- }, [firstEp.exerciseId, loadVariantsForExercise])
-
- useEffect(() => {
- let cancelled = false
- ;(async () => {
- if (!secondEp.exerciseId) return
- const vars = await loadVariantsForExercise(secondEp.exerciseId)
- if (!cancelled) setSecondEp((p) => ({ ...p, variants: vars }))
- })()
- return () => {
- cancelled = true
- }
- }, [secondEp.exerciseId, loadVariantsForExercise])
-
const filteredEdges = useMemo(() => {
if (!filterAnchorOnly || anchorExerciseId == null) return edges
return edges.filter(
@@ -271,17 +186,6 @@ export default function ExerciseProgressionGraphPanel({
)
}, [edges, filterAnchorOnly, anchorExerciseId])
- const nextEdgesFiltered = useMemo(
- () => filteredEdges.filter((e) => e.edge_type === 'next_exercise'),
- [filteredEdges],
- )
- const siblingEdgesFiltered = useMemo(
- () => filteredEdges.filter((e) => e.edge_type === 'sibling'),
- [filteredEdges],
- )
-
- const flowChains = useMemo(() => maximalLinearChains(nextEdgesFiltered), [nextEdgesFiltered])
-
const handleCreateGraph = async (e) => {
e.preventDefault()
const name = newGraphName.trim()
@@ -295,6 +199,7 @@ export default function ExerciseProgressionGraphPanel({
name,
visibility: newGraphVisibility,
})
+ setCreateModalOpen(false)
setNewGraphName('')
await refreshGraphs()
if (created?.id != null) setSelectedGraphId(created.id)
@@ -305,6 +210,30 @@ export default function ExerciseProgressionGraphPanel({
}
}
+ const handleDeleteGraph = async (graph) => {
+ const gid = graph?.id ?? selectedGraphId
+ if (!gid) return
+ if (!window.confirm(`Progressionsgraph „${graph?.name || gid}" wirklich löschen?`)) return
+ setBusy(true)
+ try {
+ await api.deleteExerciseProgressionGraph(gid)
+ if (selectedGraphId === gid) setSelectedGraphId(null)
+ await refreshGraphs()
+ } catch (err) {
+ alert(err.message || String(err))
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const resolvePromoteClubId = () => {
+ const g = graphs.find((x) => x.id === selectedGraphId)
+ if (g?.club_id != null) return Number(g.club_id)
+ const memberships = activeClubMemberships(user?.clubs)
+ const active = memberships.find((c) => c.is_active) || memberships[0]
+ return active?.club_id != null ? Number(active.club_id) : null
+ }
+
const handleSaveMeta = async () => {
if (!selectedGraphId) return
const name = metaName.trim()
@@ -312,145 +241,57 @@ export default function ExerciseProgressionGraphPanel({
alert('Name ist Pflicht')
return
}
+ const prevGraph = graphs.find((x) => x.id === selectedGraphId)
+ const prevVis = (prevGraph?.visibility || 'private').trim().toLowerCase()
+ const nextVis = (metaVisibility || 'private').trim().toLowerCase()
+
setBusy(true)
try {
+ if (prevVis === 'private' && nextVis === 'club') {
+ const preview = await api.getProgressionGraphVisibilityPromotionCandidates(
+ selectedGraphId,
+ { targetVisibility: 'club' },
+ )
+ const privateExercises = Array.isArray(preview?.exercises) ? preview.exercises : []
+ if (privateExercises.length > 0) {
+ const titles = privateExercises
+ .slice(0, 8)
+ .map((ex) => `• ${ex.title || `Übung #${ex.id}`}`)
+ .join('\n')
+ const more =
+ privateExercises.length > 8
+ ? `\n… und ${privateExercises.length - 8} weitere`
+ : ''
+ const promote = window.confirm(
+ `Der Graph wird auf „Verein“ gestellt. Im Graph sind noch ${privateExercises.length} private Übung(en):\n\n${titles}${more}\n\nDiese Übungen ebenfalls auf Vereins-Sichtbarkeit anheben?`,
+ )
+ if (promote) {
+ const clubId = resolvePromoteClubId()
+ if (!clubId) {
+ alert('Kein aktiver Verein — Übungen können nicht auf Verein promoted werden.')
+ } else {
+ const ids = privateExercises.map((ex) => ex.id).filter((id) => id != null)
+ const res = await api.bulkPatchExercisesMetadata({
+ exercise_ids: ids,
+ visibility: 'club',
+ club_id: clubId,
+ })
+ if ((res?.failed || []).length) {
+ const f = res.failed[0]
+ throw new Error(f?.detail || 'Übungs-Promotion fehlgeschlagen')
+ }
+ }
+ }
+ }
+ }
+
await api.updateExerciseProgressionGraph(selectedGraphId, {
name,
description: metaDescription.trim() || null,
visibility: metaVisibility,
})
await refreshGraphs()
- alert('Graph gespeichert.')
- } catch (err) {
- alert(err.message || String(err))
- } finally {
- setBusy(false)
- }
- }
-
- const handleDeleteGraph = async () => {
- if (!selectedGraphId) return
- if (!confirm('Diesen Progressionsgraphen und alle Kanten wirklich löschen?')) return
- setBusy(true)
- try {
- await api.deleteExerciseProgressionGraph(selectedGraphId)
- setSelectedGraphId(null)
- await refreshGraphs()
- } catch (err) {
- alert(err.message || String(err))
- } finally {
- setBusy(false)
- }
- }
-
- const patchSeqStep = (idx, patch) => {
- setSequenceSteps((prev) => prev.map((s, i) => (i === idx ? { ...s, ...patch } : s)))
- }
-
- const addSeqStep = () => setSequenceSteps((prev) => [...prev, emptySeqStep()])
-
- const removeSeqStep = (idx) => {
- setSequenceSteps((prev) => {
- if (prev.length <= 2) return prev
- return prev.filter((_, i) => i !== idx)
- })
- }
-
- const moveSeqStep = (idx, dir) => {
- setSequenceSteps((prev) => {
- const j = idx + dir
- if (j < 0 || j >= prev.length) return prev
- const next = [...prev]
- const t = next[idx]
- next[idx] = next[j]
- next[j] = t
- return next
- })
- }
-
- const submitSequence = async () => {
- if (!selectedGraphId) {
- alert('Zuerst einen Graphen wählen.')
- return
- }
- const steps = sequenceSteps.filter((s) => s.exerciseId != null)
- if (steps.length < 2) {
- alert('Mindestens zwei Schritte mit gewählter Übung.')
- return
- }
- const n = steps.length - 1
- const noteRaw = sequenceBulkNotes.trim()
- const segment_notes = Array.from({ length: n }, () => (noteRaw ? noteRaw : null))
-
- setBusy(true)
- try {
- await api.createExerciseProgressionSequence(selectedGraphId, {
- steps: steps.map((s) => ({
- exercise_id: s.exerciseId,
- variant_id: s.variantId || null,
- })),
- segment_notes,
- })
- setSequenceBulkNotes('')
- await refreshEdges(selectedGraphId)
- alert(`${n} Nachfolger-Kante(n) angelegt.`)
- } catch (err) {
- alert(err.message || String(err))
- } finally {
- setBusy(false)
- }
- }
-
- const deleteChain = async (edgeObjs) => {
- if (!selectedGraphId || !edgeObjs?.length) return
- if (!confirm(`${edgeObjs.length} Kante(n) dieser Reihe löschen?`)) return
- setBusy(true)
- try {
- await api.deleteExerciseProgressionEdgesBatch(
- selectedGraphId,
- edgeObjs.map((e) => e.id),
- )
- await refreshEdges(selectedGraphId)
- } catch (err) {
- alert(err.message || String(err))
- } finally {
- setBusy(false)
- }
- }
-
- const handleAddEdge = async () => {
- if (!selectedGraphId) {
- alert('Zuerst einen Graphen wählen.')
- return
- }
- if (!firstEp.exerciseId || !secondEp.exerciseId) {
- alert('Beide Enden müssen eine Übung haben.')
- return
- }
- if (
- firstEp.exerciseId === secondEp.exerciseId &&
- (firstEp.variantId == null ||
- secondEp.variantId == null ||
- firstEp.variantId === secondEp.variantId)
- ) {
- alert('Bei derselben Übung bitte zwei verschiedene Varianten wählen (oder unterschiedliche Übungen).')
- return
- }
- const edge_type = relationKind === 'sibling' ? 'sibling' : 'next_exercise'
- const notes = edgeNotes.trim() || null
- const body = {
- from_exercise_id: firstEp.exerciseId,
- to_exercise_id: secondEp.exerciseId,
- from_exercise_variant_id: firstEp.variantId || null,
- to_exercise_variant_id: secondEp.variantId || null,
- edge_type,
- notes,
- }
- setBusy(true)
- try {
- await api.createExerciseProgressionEdge(selectedGraphId, body)
- setEdgeNotes('')
- await refreshEdges(selectedGraphId)
+ alert('Graph-Metadaten gespeichert.')
} catch (err) {
alert(err.message || String(err))
} finally {
@@ -460,7 +301,6 @@ export default function ExerciseProgressionGraphPanel({
const handleDeleteEdge = async (edgeId) => {
if (!selectedGraphId) return
- if (!confirm('Kante löschen?')) return
setBusy(true)
try {
await api.deleteExerciseProgressionEdge(selectedGraphId, edgeId)
@@ -493,58 +333,164 @@ export default function ExerciseProgressionGraphPanel({
}
}
- const swapEnds = () => {
- const a = firstEp
- setFirstEp(secondEp)
- setSecondEp(a)
- }
+ const handleEditorSaved = useCallback(async () => {
+ if (!selectedGraphId) return
+ await Promise.all([refreshEdges(selectedGraphId), refreshGraphs()])
+ }, [selectedGraphId, refreshEdges, refreshGraphs])
- const applyPickedExercise = async (ex) => {
- const title = ex.title || `Übung #${ex.id}`
- const variants = Array.isArray(ex.variants) && ex.variants.length
- ? ex.variants
- : await loadVariantsForExercise(ex.id)
- const variantId =
- ex.exercise_variant_id ?? ex.suggested_variant_id ?? null
+ const selectedGraph = graphs.find((g) => g.id === selectedGraphId)
- if (pickContext?.kind === 'sequence') {
- patchSeqStep(pickContext.index, {
- exerciseId: ex.id,
- exerciseTitle: title,
- variantId: variantId != null ? Number(variantId) : null,
- variants,
- })
- setPickContext(null)
- return
- }
- if (pickContext?.kind === 'single') {
- const patch = {
- exerciseId: ex.id,
- exerciseTitle: title,
- variantId: variantId != null ? Number(variantId) : null,
- variants,
- }
- if (pickContext.slot === 'first') setFirstEp(patch)
- else setSecondEp(patch)
- setPickContext(null)
- }
- }
-
- function formatNodeLine(n) {
+ if (loadErr) {
return (
- <>
- {n.title}
- {n.variant_name ? (
- {` · ${n.variant_name}`}
- ) : null}
- >
+
)
}
- const pickerOpen = pickContext != null
+ if (!selectedGraphId) {
+ return (
+
+ {anchorExerciseId != null && (
+
+ Kontext:{' '}
+ {anchorTitle?.trim() || `Übung #${anchorExerciseId}`}
+ {' · '}
+ Ansehen
+
+ )}
+
+
+ Progressionsgraphen planen didaktische Entwicklungspfade mit Roadmap-Slots, KI-Match und
+ Bewertung — analog zur Übungsbibliothek als eigene Sammlung.
+
+
+ {busy && graphs.length === 0 ? (
+
+
+
+ Lade Progressionsgraphen…
+
+
+ ) : graphs.length === 0 ? (
+
+
+ Noch keine Progressionsgraphen — lege den ersten an.
+
+
{
+ setCreateModalOpen(true)
+ }}
+ >
+ + Neu
+
+
+ ) : (
+
+ {graphs.map((g) => (
+
setSelectedGraphId(row.id)}
+ onDelete={handleDeleteGraph}
+ />
+ ))}
+
+ )}
+
+ {createModalOpen ? (
+
{
+ if (e.target === e.currentTarget && !busy) setCreateModalOpen(false)
+ }}
+ >
+
e.stopPropagation()}
+ style={{ maxWidth: '480px' }}
+ >
+
+
+ Neuer Progressionsgraph
+
+ setCreateModalOpen(false)}
+ >
+ Schließen
+
+
+
+
+
+ ) : null}
+
+ )
+ }
return (
+
+ setSelectedGraphId(null)}
+ >
+ ← Zur Übersicht
+
+
+ {selectedGraph?.name || `Graph #${selectedGraphId}`}
+
+
+
{anchorExerciseId != null && (
Kontext:{' '}
@@ -554,86 +500,27 @@ export default function ExerciseProgressionGraphPanel({
)}
-
- Pro Graph mehrere Reihen und Alternativen : eine{' '}
- Sequenz legt automatisch alle Schritte Übung1 → Übung2 → … als Nachfolger-Kanten an.
- Optional pro Schritt eine Variante — sie wirkt wie ein eigener Knoten. Verzweigungen und
- Schwestern trennst du weiterhin mit Einzelkanten oder mehreren Sequenzen aus dem gleichen Knoten.
-
-
- {loadErr && (
-
+ {selectedGraphId && anchorExerciseId != null && (
+
+ setFilterAnchorOnly(e.target.checked)}
+ />
+ Technische Kantenliste: nur Kanten mit dieser Übung
+
)}
-
-
Graph auswählen
-
-
- Aktiver Graph
- setSelectedGraphId(e.target.value ? parseInt(e.target.value, 10) : null)}
- >
- — wählen —
- {graphs.map((g) => (
-
- {g.name} ({g.edges_count ?? 0} Kanten)
-
- ))}
-
-
-
refreshEdges(selectedGraphId)}
- >
- Aktualisieren
-
-
- Graph löschen
-
-
+
-
-
-
- {selectedGraphId && (
-
-
Graph bearbeiten
+
+ Graph-Einstellungen (Name, Sichtbarkeit)
+
Name
setMetaName(e.target.value)} />
@@ -649,7 +536,11 @@ export default function ExerciseProgressionGraphPanel({
{EXERCISE_VISIBILITY_FIELD_LABEL}
- setMetaVisibility(e.target.value)}>
+ setMetaVisibility(e.target.value)}
+ >
{filteredGraphVisOptions.map((o) => (
{o.label}
@@ -657,379 +548,33 @@ export default function ExerciseProgressionGraphPanel({
))}
-
- Metadaten speichern
-
-
- )}
-
- {selectedGraphId && (
-
- setUiTab('overview')}
- >
- Übersicht & Sequenz
-
- setUiTab('table')}
- >
- Alle Kanten (Tabelle)
-
-
- )}
-
- {selectedGraphId && uiTab === 'overview' && (
- <>
-
- {
- await refreshEdges(selectedGraphId)
- }}
- />
-
-
Sequenz / Reihe anlegen
-
- Reihenfolge von links nach rechts: jede Zeile ein Schritt. Es werden automatisch alle Nachfolger-Kanten
- zwischen benachbarten Schritten erzeugt (ein API-Vorgang).
-
- {sequenceSteps.map((step, idx) => (
-
-
-
Schritt {idx + 1}
-
-
- {step.exerciseId ? (
- <>
- {step.exerciseTitle}
- (#{step.exerciseId})
- >
- ) : (
- Übung wählen
- )}
-
- setPickContext({ kind: 'sequence', index: idx })}
- >
- Übung…
-
- {anchorExerciseId != null && (
- {
- const variants = await loadVariantsForExercise(anchorExerciseId)
- patchSeqStep(idx, {
- exerciseId: anchorExerciseId,
- exerciseTitle: anchorTitle?.trim() || `Übung #${anchorExerciseId}`,
- variantId: null,
- variants,
- })
- }}
- >
- Diese Übung
-
- )}
-
-
-
- Variante (optional)
-
- patchSeqStep(idx, {
- variantId: e.target.value === '' ? null : parseInt(e.target.value, 10),
- })
- }
- >
- Gesamte Übung (ohne feste Variante)
- {(step.variants || []).map((v) => (
-
- {v.variant_name || `Variante #${v.id}`}
-
- ))}
-
-
-
- moveSeqStep(idx, -1)}>
- ↑
-
- moveSeqStep(idx, 1)}>
- ↓
-
- removeSeqStep(idx)}>
- Zeile entfernen
-
-
-
- ))}
-
- + Schritt
+
+
+ Metadaten speichern
-
- Entwicklungsziel für alle neuen Zwischen-Kanten (optional)
-
-
- Sequenz als Nachfolger-Kanten speichern
+ handleDeleteGraph(selectedGraph)}>
+ Graph löschen
+
+
-
-
Nachfolger als Reihen (Lesart)
- {flowChains.length === 0 ? (
-
Keine Nachfolger-Kanten im aktuellen Filter.
- ) : (
- flowChains.map((chain, ci) => (
-
-
- {chain.nodes.map((n, ni) => (
-
- {ni > 0 && (
-
- →
-
- )}
- {formatNodeLine(n)}
-
- ))}
-
-
- deleteChain(chain.edges)}>
- Diese Reihe löschen ({chain.edges.length} Kante(n))
-
-
-
- ))
- )}
-
+
-
-
Schwestern & Alternativen
- {siblingEdgesFiltered.length === 0 ? (
-
Keine Schwester-Kanten im aktuellen Filter.
- ) : (
-
- {siblingEdgesFiltered.map((row) => (
-
-
- {formatNodeLine({
- exercise_id: row.from_exercise_id,
- variant_id: row.from_exercise_variant_id,
- title: row.from_exercise_title,
- variant_name: row.from_variant_name,
- })}
- · Schwester ·
- {formatNodeLine({
- exercise_id: row.to_exercise_id,
- variant_id: row.to_exercise_variant_id,
- title: row.to_exercise_title,
- variant_name: row.to_variant_name,
- })}
- {row.notes ? (
-
- {row.notes}
-
- ) : null}
-
- handleDeleteEdge(row.id)}
- >
- Löschen
-
-
- ))}
-
- )}
-
-
-
- Einzelkante (Nachfolger oder Schwester)
-
-
- Beziehung
- setRelationKind(e.target.value)}>
- Nachfolger
- Schwester
-
-
-
- {['first', 'second'].map((slot) => {
- const ep = slot === 'first' ? firstEp : secondEp
- const setEp = slot === 'first' ? setFirstEp : setSecondEp
- return (
-
-
{slot === 'first' ? 'Von (Quelle)' : 'Nach (Ziel)'}
-
-
- {ep.exerciseId ? (
- <>
- {ep.exerciseTitle}
- (#{ep.exerciseId})
- >
- ) : (
- — Übung wählen —
- )}
-
- setPickContext({ kind: 'single', slot })}
- >
- Übung…
-
- {anchorExerciseId != null && (
- {
- const variants = await loadVariantsForExercise(anchorExerciseId)
- setEp({
- exerciseId: anchorExerciseId,
- exerciseTitle: anchorTitle?.trim() || `Übung #${anchorExerciseId}`,
- variantId: null,
- variants,
- })
- }}
- >
- Diese Übung
-
- )}
-
-
- setEp((p) => ({
- ...p,
- variantId: e.target.value === '' ? null : parseInt(e.target.value, 10),
- }))
- }
- >
- Gesamte Übung
- {(ep.variants || []).map((v) => (
-
- {v.variant_name || `Variante #${v.id}`}
-
- ))}
-
-
- )
- })}
-
- {relationKind === 'progression' && (
-
- Reihenfolge tauschen
-
- )}
-
-
- Notiz
-
-
- Kante speichern
-
-
-
- >
- )}
-
- {selectedGraphId && anchorExerciseId != null && (
-
- setFilterAnchorOnly(e.target.checked)}
- />
- Nur Kanten, die diese Übung betreffen (Übersicht & Tabelle)
-
- )}
-
- {selectedGraphId && uiTab === 'table' && (
-
-
- Alle Kanten ({filteredEdges.length}
- {edges.length !== filteredEdges.length ? ` von ${edges.length}` : ''})
-
+
+
+ Technische Kantenliste ({filteredEdges.length}
+ {edges.length !== filteredEdges.length ? ` von ${edges.length}` : ''})
+
+
{filteredEdges.length === 0 ? (
Keine Kanten.
) : (
@@ -1049,19 +594,17 @@ export default function ExerciseProgressionGraphPanel({
{filteredEdges.map((row) => (
- {row.from_exercise_title || `#${row.from_exercise_id}`}
- {row.from_variant_name ? (
- {row.from_variant_name}
- ) : null}
+
+ {row.from_exercise_title || `#${row.from_exercise_id}`}
+
{row.edge_type === 'sibling' ? '·' : '→'}
- {row.to_exercise_title || `#${row.to_exercise_id}`}
- {row.to_variant_name ? (
- {row.to_variant_name}
- ) : null}
+
+ {row.to_exercise_title || `#${row.to_exercise_id}`}
+
{edgeTypeLabel(row.edge_type)}
@@ -1072,18 +615,19 @@ export default function ExerciseProgressionGraphPanel({
rows={2}
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
- style={{ marginBottom: '6px' }}
/>
- saveNotes(row.id)}>
+ saveNotes(row.id)}
+ >
Speichern
- setEditingEdgeNotes(null)}>
- Abbrechen
-
>
) : (
<>
- {row.notes || '—'}
+ {row.notes || '—'}
handleDeleteEdge(row.id)}
>
Löschen
@@ -1112,14 +662,9 @@ export default function ExerciseProgressionGraphPanel({
)}
- )}
-
- setPickContext(null)}
- onSelectExercise={applyPickedExercise}
- exerciseKindAny={['simple']}
- />
+
)
}
+
+export default forwardRef(ExerciseProgressionGraphPanel)
diff --git a/frontend/src/components/ExerciseProgressionPathBuilder.jsx b/frontend/src/components/ExerciseProgressionPathBuilder.jsx
index 6b3f535..9c83c04 100644
--- a/frontend/src/components/ExerciseProgressionPathBuilder.jsx
+++ b/frontend/src/components/ExerciseProgressionPathBuilder.jsx
@@ -1,9 +1,11 @@
/**
* Planungs-KI Phase C3/E3: Ziel → Übungspfad vorschlagen → Lücken mit KI anlegen → in Graph speichern.
*/
-import React, { useCallback, useEffect, useState } from 'react'
+import React, { useCallback, useEffect, useMemo, useState } from 'react'
+import { Link } from 'react-router-dom'
import api from '../utils/api'
import ExerciseAiQuickCreateModal from './exercises/ExerciseAiQuickCreateModal'
+import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
import {
aiPreviewToQuickCreateDraft,
@@ -13,6 +15,7 @@ import {
import {
buildPathGapPlanningContextForAi,
gapOfferContextDisplayLines,
+ initialStageLearningGoalFromOffer,
} from '../utils/planningContextForExerciseAi'
function applyResolvedStructuredFromRoadmap(progressionRoadmap, setters) {
@@ -105,6 +108,7 @@ function mapApiStepToRow(step) {
step?.roadmap_major_step_index != null ? Number(step.roadmap_major_step_index) : null,
roadmapPhase: step?.roadmap_phase || null,
roadmapLearningGoal: step?.roadmap_learning_goal || null,
+ skillExpectations: step?.skill_expectations || null,
}
}
@@ -131,20 +135,274 @@ const OFFER_SOURCE_LABELS = {
roadmap_unfilled: 'Roadmap-Stufe',
}
+function normalizeTitleKey(text) {
+ return String(text || '')
+ .trim()
+ .toLowerCase()
+ .replace(/\s+/g, ' ')
+}
+
+function mergeGraphIntoPathSteps(pathRows, graphNodes, { skipGraphMerge = false } = {}) {
+ if (skipGraphMerge || !Array.isArray(graphNodes) || !graphNodes.length || !pathRows.length) {
+ return pathRows
+ }
+ return pathRows.map((row, i) => {
+ const node = graphNodes[i]
+ if (!node?.exercise_id) return row
+ if (row.exerciseId != null) return row
+ return {
+ ...row,
+ exerciseId: Number(node.exercise_id),
+ exerciseTitle: node.title || `Übung #${node.exercise_id}`,
+ variantId: node.variant_id != null ? Number(node.variant_id) : null,
+ variants: row.variants || [],
+ isFromGraph: true,
+ reasons: [...(row.reasons || []), 'Aus bestehendem Graph übernommen'],
+ }
+ })
+}
+
+function filterGapOffersForGraph(offers, pathRows, graphNodes) {
+ if (!Array.isArray(offers) || !offers.length) return offers
+ const graphIds = new Set(
+ (graphNodes || []).map((n) => Number(n.exercise_id)).filter(Number.isFinite),
+ )
+ const graphTitles = new Set(
+ (graphNodes || []).map((n) => normalizeTitleKey(n.title)).filter(Boolean),
+ )
+ const pathIds = new Set(
+ (pathRows || []).map((r) => r.exerciseId).filter((id) => id != null),
+ )
+
+ return offers.filter((offer) => {
+ const hint = normalizeTitleKey(offer?.title_hint)
+ const majorIdx =
+ offer?.roadmap_major_step_index != null ? Number(offer.roadmap_major_step_index) : null
+
+ if (majorIdx != null && Number.isFinite(majorIdx) && graphNodes?.[majorIdx]) {
+ const gid = Number(graphNodes[majorIdx].exercise_id)
+ if (graphIds.has(gid) && pathIds.has(gid)) return false
+ }
+
+ for (const gid of graphIds) {
+ if (pathIds.has(gid) && hint) {
+ const node = graphNodes.find((n) => Number(n.exercise_id) === gid)
+ const nodeTitle = normalizeTitleKey(node?.title)
+ if (nodeTitle && (nodeTitle === hint || nodeTitle.includes(hint) || hint.includes(nodeTitle))) {
+ return false
+ }
+ }
+ }
+
+ for (const t of graphTitles) {
+ if (hint && (t === hint || t.includes(hint) || hint.includes(t))) return false
+ }
+ return true
+ })
+}
+
+function SavedGraphPathStrip({ nodes, hasDraft }) {
+ if (!Array.isArray(nodes) || nodes.length === 0) {
+ return (
+
+ Im Graph gespeichert: noch kein Pfad — der erste
+ Speichervorgang legt die Übungsfolge an.
+
+ )
+ }
+ return (
+
+
+ Im Graph gespeichert ({nodes.length} Schritte)
+ {hasDraft ? (
+
+ — KI-Entwurf unten; Speichern ersetzt diesen Pfad
+
+ ) : null}
+
+
+ {nodes.map((node, idx) => (
+
+ {node.title || `Übung #${node.exercise_id}`}
+ {node.variant_name ? (
+ {` · ${node.variant_name}`}
+ ) : null}
+
+ ))}
+
+
+ )
+}
+
const PATH_STEPS_HARD_MAX = 10
+const WIZARD_STEPS = [
+ { id: 1, label: 'Ziel & Start/Ziel', short: 'Ziel' },
+ { id: 2, label: 'Roadmap', short: 'Roadmap' },
+ { id: 3, label: 'Match', short: 'Match' },
+ { id: 4, label: 'Lücken & Speichern', short: 'Speichern' },
+]
+
+function computeMaxReachableStep(editableMajorSteps, pathSteps) {
+ if (pathSteps.length > 0) return 4
+ if (editableMajorSteps.length >= 2) return 2
+ return 1
+}
+
+function PlanningWizardStepper({ currentStep, maxReachable, onStepChange, disabled }) {
+ return (
+
+ {WIZARD_STEPS.map((step, idx) => {
+ const reachable = step.id <= maxReachable
+ const active = currentStep === step.id
+ const done = step.id < currentStep && reachable
+ const canClick = reachable && !disabled && step.id <= maxReachable
+ return (
+
+ {idx > 0 ? (
+
+ →
+
+ ) : null}
+ canClick && onStepChange(step.id)}
+ style={{
+ flex: '1 1 120px',
+ minWidth: '100px',
+ fontSize: '12px',
+ padding: '8px 10px',
+ opacity: reachable ? 1 : 0.45,
+ borderColor: active
+ ? 'var(--accent)'
+ : done
+ ? 'color-mix(in srgb, var(--accent) 40%, var(--border))'
+ : undefined,
+ }}
+ title={step.label}
+ aria-current={active ? 'step' : undefined}
+ >
+ {step.id}
+ {step.label}
+
+ {step.short}
+
+
+
+ )
+ })}
+
+
+ )
+}
+
const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion']
+const LOAD_PROFILE_OPTIONS = [
+ 'koordination',
+ 'präzision',
+ 'kraft',
+ 'geschwindigkeit',
+ 'timing',
+ 'reaktion',
+ 'distanz',
+ 'gleichgewicht',
+ 'kime',
+ 'ausdauer',
+ 'beweglichkeit',
+]
+
function mapMajorStepsFromApi(apiRoadmap) {
const raw = apiRoadmap?.roadmap?.major_steps
if (!Array.isArray(raw)) return []
- return raw.map((s, i) => ({
+ const rows = raw.map((s, i) => ({
index: i,
phase: s.phase || 'vertiefung',
learning_goal: (s.learning_goal || '').trim(),
consolidates: Array.isArray(s.consolidates) ? s.consolidates : [],
rationale: s.rationale || '',
+ load_profile: [],
+ success_criteria: [],
+ anti_patterns: [],
+ exercise_type: '',
}))
+ return mergeStageSpecsIntoMajorSteps(rows, apiRoadmap)
+}
+
+function mergeStageSpecsIntoMajorSteps(rows, apiRoadmap) {
+ const specs = apiRoadmap?.stage_specs
+ if (!Array.isArray(specs) || !rows.length) return rows
+ return rows.map((row, i) => {
+ const spec =
+ specs.find((s) => Number(s.major_step_index) === i) ||
+ specs.find((s) => Number(s.major_step_index) === row.index) ||
+ specs[i]
+ if (!spec) return row
+ return {
+ ...row,
+ load_profile: Array.isArray(spec.load_profile) ? [...spec.load_profile] : [],
+ success_criteria: Array.isArray(spec.success_criteria) ? [...spec.success_criteria] : [],
+ anti_patterns: Array.isArray(spec.anti_patterns) ? [...spec.anti_patterns] : [],
+ exercise_type: (spec.exercise_type || '').trim(),
+ }
+ })
+}
+
+function linesToStringList(text) {
+ return String(text || '')
+ .split('\n')
+ .map((s) => s.trim())
+ .filter(Boolean)
+}
+
+function listToMultiline(arr) {
+ return Array.isArray(arr) ? arr.join('\n') : ''
}
function reindexMajorSteps(rows) {
@@ -152,14 +410,57 @@ function reindexMajorSteps(rows) {
}
function majorStepsToOverridePayload(rows) {
+ const indexed = reindexMajorSteps(rows)
return {
- major_steps: reindexMajorSteps(rows).map((row) => ({
+ major_steps: indexed.map((row) => ({
index: row.index,
phase: row.phase || 'vertiefung',
learning_goal: row.learning_goal.trim(),
consolidates: row.consolidates || [],
rationale: row.rationale || '',
})),
+ stage_specs: indexed.map((row, i) => ({
+ major_step_index: i,
+ learning_goal: row.learning_goal.trim(),
+ load_profile: Array.isArray(row.load_profile) ? row.load_profile : [],
+ exercise_type: (row.exercise_type || '').trim(),
+ success_criteria: Array.isArray(row.success_criteria) ? row.success_criteria : [],
+ anti_patterns: Array.isArray(row.anti_patterns) ? row.anti_patterns : [],
+ })),
+ }
+}
+
+function formatExpectedSkillNames(skillExpectations, limit = 4) {
+ const items = skillExpectations?.expected_skills
+ if (!Array.isArray(items)) return []
+ return items
+ .map((s) => String(s?.skill_name || '').trim())
+ .filter(Boolean)
+ .slice(0, limit)
+}
+
+const PLANNING_ARTIFACT_SCHEMA = 1
+
+function buildPlanningRoadmapArtifactSnapshot({
+ goalQuery,
+ startSituation,
+ targetState,
+ roadmapNotes,
+ maxSteps,
+ progressionRoadmap,
+ pathSkillExpectations,
+}) {
+ const q = (goalQuery || '').trim()
+ if (!q && !progressionRoadmap) return null
+ return {
+ schema_version: PLANNING_ARTIFACT_SCHEMA,
+ goal_query: q,
+ start_situation: (startSituation || '').trim() || null,
+ target_state: (targetState || '').trim() || null,
+ roadmap_notes: (roadmapNotes || '').trim() || null,
+ max_steps: Number(maxSteps) || 5,
+ progression_roadmap: progressionRoadmap || null,
+ path_skill_expectations: pathSkillExpectations || null,
}
}
@@ -217,6 +518,8 @@ export default function ExerciseProgressionPathBuilder({
graphId,
disabled = false,
onSaved,
+ graphChainNodes = null,
+ graphChainEdgeIds = null,
}) {
const [goalQuery, setGoalQuery] = useState('')
const [startSituation, setStartSituation] = useState('')
@@ -232,6 +535,7 @@ export default function ExerciseProgressionPathBuilder({
const [pathSteps, setPathSteps] = useState([])
const [gapFillOffers, setGapFillOffers] = useState([])
const [progressionRoadmap, setProgressionRoadmap] = useState(null)
+ const [pathSkillExpectations, setPathSkillExpectations] = useState(null)
const [editableMajorSteps, setEditableMajorSteps] = useState([])
const [roadmapDirty, setRoadmapDirty] = useState(false)
const [loadingRoadmap, setLoadingRoadmap] = useState(false)
@@ -252,6 +556,115 @@ export default function ExerciseProgressionPathBuilder({
const [quickSaving, setQuickSaving] = useState(false)
const [quickAiError, setQuickAiError] = useState('')
const [activePlanningContextLines, setActivePlanningContextLines] = useState([])
+ const [gapPrepOpen, setGapPrepOpen] = useState(false)
+ const [gapPrepTitle, setGapPrepTitle] = useState('')
+ const [gapPrepStageGoal, setGapPrepStageGoal] = useState('')
+ const [gapPrepSupplements, setGapPrepSupplements] = useState('')
+ const [gapPrepFocusAreaId, setGapPrepFocusAreaId] = useState('')
+ const [gapPrepError, setGapPrepError] = useState('')
+ const [loadedPlanningHint, setLoadedPlanningHint] = useState(false)
+ const [wizardStep, setWizardStep] = useState(1)
+ const [pathInsertNotice, setPathInsertNotice] = useState('')
+
+ const maxReachableStep = useMemo(
+ () => computeMaxReachableStep(editableMajorSteps, pathSteps),
+ [editableMajorSteps, pathSteps],
+ )
+
+ const buildPlanningArtifact = useCallback(
+ () =>
+ buildPlanningRoadmapArtifactSnapshot({
+ goalQuery,
+ startSituation,
+ targetState,
+ roadmapNotes,
+ maxSteps,
+ progressionRoadmap,
+ pathSkillExpectations,
+ }),
+ [
+ goalQuery,
+ startSituation,
+ targetState,
+ roadmapNotes,
+ maxSteps,
+ progressionRoadmap,
+ pathSkillExpectations,
+ ],
+ )
+
+ const persistPlanningRoadmapToGraph = useCallback(async () => {
+ if (!graphId) return
+ const artifact = buildPlanningArtifact()
+ if (!artifact?.goal_query && !artifact?.progression_roadmap) return
+ try {
+ await api.updateExerciseProgressionGraph(Number(graphId), { planning_roadmap: artifact })
+ } catch (e) {
+ console.warn('Planungs-Artefakt konnte nicht gespeichert werden', e)
+ }
+ }, [graphId, buildPlanningArtifact])
+
+ useEffect(() => {
+ if (!graphId) return
+ let cancelled = false
+ setLoadedPlanningHint(false)
+ setPathSteps([])
+ setTargetSummary(null)
+ setSemanticBrief(null)
+ setPathQa(null)
+ setGapFillOffers([])
+ setPathSkillExpectations(null)
+ setEditableMajorSteps([])
+ setProgressionRoadmap(null)
+ setRoadmapDirty(false)
+ setStartTargetAnalyzed(false)
+ setError('')
+ setWizardStep(1)
+ setPathInsertNotice('')
+
+ api
+ .getExerciseProgressionGraph(Number(graphId))
+ .then((g) => {
+ if (cancelled) return
+ const art = g?.planning_roadmap
+ if (!art) return
+ if (art.goal_query) setGoalQuery(String(art.goal_query))
+ if (art.start_situation) setStartSituation(String(art.start_situation))
+ if (art.target_state) setTargetState(String(art.target_state))
+ 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.progression_roadmap) {
+ setProgressionRoadmap(art.progression_roadmap)
+ const majors = mapMajorStepsFromApi(art.progression_roadmap)
+ if (majors.length >= 2) {
+ setEditableMajorSteps(majors)
+ setWizardStep(2)
+ }
+ }
+ if (
+ art.start_situation ||
+ art.target_state ||
+ art.progression_roadmap?.resolved_structured
+ ) {
+ setStartTargetAnalyzed(true)
+ }
+ setLoadedPlanningHint(true)
+ })
+ .catch((e) => {
+ console.warn(e)
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [graphId])
+
+ useEffect(() => {
+ if (wizardStep > maxReachableStep) {
+ setWizardStep(maxReachableStep)
+ }
+ }, [wizardStep, maxReachableStep])
useEffect(() => {
let cancelled = false
@@ -323,6 +736,10 @@ export default function ExerciseProgressionPathBuilder({
learning_goal: '',
consolidates: [],
rationale: '',
+ load_profile: [],
+ success_criteria: [],
+ anti_patterns: [],
+ exercise_type: '',
},
])
})
@@ -391,13 +808,6 @@ export default function ExerciseProgressionPathBuilder({
setQuickAiError('')
}
- const handleGapFillClick = async (offer) => {
- if (!confirmPathExpansionIfNeeded(offer, pathSteps.length, maxSteps, setMaxSteps)) {
- return
- }
- await runGapFillAiSuggest(offer)
- }
-
const gapContextFallbackParams = {
goalQuery,
semanticBrief,
@@ -410,21 +820,70 @@ export default function ExerciseProgressionPathBuilder({
roadmapNotes,
}
- const runGapFillAiSuggest = async (offer) => {
- const title = (offer?.title_hint || '').trim()
- if (title.length < 3) {
- alert('Titel-Hinweis fehlt — bitte Pfad erneut vorschlagen.')
+ const closeGapFillPrep = () => {
+ if (quickSaving) return
+ setGapPrepOpen(false)
+ setGapPrepError('')
+ }
+
+ const openGapFillPrep = (offer) => {
+ const defaultFocus = resolveDefaultFocusAreaId(targetSummary, focusAreas)
+ setActiveOffer(offer)
+ setGapPrepTitle((offer?.title_hint || '').trim())
+ setGapPrepStageGoal(initialStageLearningGoalFromOffer(offer, gapContextFallbackParams))
+ setGapPrepSupplements('')
+ setGapPrepFocusAreaId(defaultFocus ? String(defaultFocus) : '')
+ setActivePlanningContextLines(gapOfferContextDisplayLines(offer, gapContextFallbackParams))
+ setGapPrepError('')
+ setGapPrepOpen(true)
+ }
+
+ const handleGapFillClick = (offer) => {
+ if (!confirmPathExpansionIfNeeded(offer, pathSteps.length, maxSteps, setMaxSteps)) {
return
}
- const goalText = (offer?.goal_for_ai || offer?.sketch || '').trim()
- const focusId = resolveDefaultFocusAreaId(targetSummary, focusAreas)
+ openGapFillPrep(offer)
+ }
+
+ const submitGapFillPrep = async () => {
+ const title = (gapPrepTitle || '').trim()
+ if (title.length < 3) {
+ alert('Titel: mindestens 3 Zeichen.')
+ return
+ }
+ const focusId = parseInt(String(gapPrepFocusAreaId).trim(), 10)
+ if (!Number.isFinite(focusId) || focusId < 1) {
+ alert('Bitte einen Fokusbereich wählen.')
+ return
+ }
+ if (!activeOffer) return
+ setGapPrepError('')
+ await runGapFillAiSuggest(activeOffer, {
+ title,
+ stageLearningGoal: (gapPrepStageGoal || '').trim(),
+ supplements: (gapPrepSupplements || '').trim(),
+ focusAreaId: focusId,
+ })
+ }
+
+ const runGapFillAiSuggest = async (offer, prep = null) => {
+ const title = (prep?.title || offer?.title_hint || '').trim()
+ if (title.length < 3) {
+ alert('Titel: mindestens 3 Zeichen.')
+ return
+ }
+ const supplements = (prep?.supplements || '').trim()
+ const stageGoal = (prep?.stageLearningGoal || '').trim()
+ let goalText = (offer?.goal_for_ai || offer?.sketch || '').trim()
+ if (supplements) {
+ goalText = `${goalText}\n\nTrainer-Ergänzungen für diese Übung:\n${supplements}`.trim()
+ }
+ const focusId =
+ prep?.focusAreaId != null && Number.isFinite(Number(prep.focusAreaId))
+ ? Number(prep.focusAreaId)
+ : resolveDefaultFocusAreaId(targetSummary, focusAreas)
if (!focusId) {
- alert('Kein Fokusbereich verfügbar — bitte Kataloge laden oder manuell wählen.')
- setQuickTitle(title)
- setQuickSketch(goalText)
- setQuickFocusAreaId('')
- setActiveOffer(offer)
- setQuickCreateOpen(true)
+ alert('Kein Fokusbereich verfügbar — bitte im Vorbereitungsdialog wählen.')
return
}
const focusRow = (focusAreas || []).find((x) => Number(x.id) === focusId)
@@ -438,11 +897,16 @@ export default function ExerciseProgressionPathBuilder({
setQuickCreateDraft(null)
setQuickSaving(true)
setGeneratingOfferId(offer?.offer_id || null)
- const contextLines = gapOfferContextDisplayLines(offer, gapContextFallbackParams)
+ const contextParams = {
+ ...gapContextFallbackParams,
+ stageLearningGoalOverride: stageGoal,
+ gapTrainerSupplements: supplements,
+ }
+ const contextLines = gapOfferContextDisplayLines(offer, contextParams)
setActivePlanningContextLines(contextLines)
const planningContext = buildPathGapPlanningContextForAi({
offer,
- ...gapContextFallbackParams,
+ ...contextParams,
})
try {
const aiRes = await api.suggestExerciseAi({
@@ -450,7 +914,7 @@ export default function ExerciseProgressionPathBuilder({
goal: goalText || undefined,
execution: '',
preparation: '',
- trainer_notes: '',
+ trainer_notes: supplements || '',
focus_area_hint: focusHint || undefined,
focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
planning_context: planningContext || undefined,
@@ -469,12 +933,13 @@ export default function ExerciseProgressionPathBuilder({
sketchPlain: goalText,
}),
)
+ setGapPrepOpen(false)
setQuickCreateOpen(false)
} catch (e) {
console.error(e)
const msg = e?.message || String(e)
+ setGapPrepError(msg)
setQuickAiError(msg)
- setQuickCreateOpen(true)
} finally {
setQuickSaving(false)
setGeneratingOfferId(null)
@@ -541,6 +1006,11 @@ export default function ExerciseProgressionPathBuilder({
insertExerciseFromOffer(created, activeOffer)
setQuickCreateDraft(null)
setActiveOffer(null)
+ const title = (created.title || quickTitle || 'Übung').trim()
+ setPathInsertNotice(
+ `„${title}" wurde in den KI-Entwurf eingefügt. Mit «Pfad im Graph speichern» wird der gesamte Pfad übernommen.`,
+ )
+ setWizardStep(4)
} catch (e) {
console.error(e)
const msg = e?.message || String(e)
@@ -551,30 +1021,34 @@ export default function ExerciseProgressionPathBuilder({
}
}
- const applyPathMatchResponse = (res, q) => {
+ const applyPathMatchResponse = (res, q, { skipGraphMerge = true } = {}) => {
const qa = res?.path_qa || null
const rawRows = (Array.isArray(res?.steps) ? res.steps : []).map(mapApiStepToRow)
- const rows =
- Array.isArray(qa?.stripped_off_topic_steps) && qa.stripped_off_topic_steps.length > 0
- ? rawRows
- : applyOffTopicFlags(rawRows, qa)
+ const rows = applyOffTopicFlags(rawRows, qa)
+ const mergedRows = mergeGraphIntoPathSteps(rows, graphChainNodes, { skipGraphMerge })
if (rows.length < 2) {
throw new Error('Zu wenig Schritte im Vorschlag.')
}
- setPathSteps(rows)
+ const rawGaps = Array.isArray(res?.gap_fill_offers)
+ ? res.gap_fill_offers
+ : Array.isArray(qa?.gap_fill_offers)
+ ? qa.gap_fill_offers
+ : []
+ const gaps = filterGapOffersForGraph(rawGaps, mergedRows, graphChainNodes)
+ setPathSteps(mergedRows)
setTargetSummary(res?.target_profile_summary || null)
setSemanticBrief(res?.semantic_brief_summary || null)
setPathQa(qa)
- setGapFillOffers(
- Array.isArray(res?.gap_fill_offers)
- ? res.gap_fill_offers
- : Array.isArray(qa?.gap_fill_offers)
- ? qa.gap_fill_offers
- : [],
- )
+ setGapFillOffers(gaps)
setProgressionRoadmap(res?.progression_roadmap || null)
+ setPathSkillExpectations(res?.path_skill_expectations || null)
setRoadmapDirty(false)
if (!segmentNotes.trim() && q) setSegmentNotes(q.slice(0, 400))
+ if (res?.progression_roadmap?.stage_specs?.length) {
+ setEditableMajorSteps((prev) =>
+ prev.length ? mergeStageSpecsIntoMajorSteps(prev, res.progression_roadmap) : prev,
+ )
+ }
}
const applyStartTargetResponse = (res) => {
@@ -648,7 +1122,7 @@ export default function ExerciseProgressionPathBuilder({
const res = await api.suggestProgressionPath({
query: q,
max_steps: Number(maxSteps),
- include_llm_intent: false,
+ include_llm_intent: true,
include_path_qa: false,
include_llm_path_qa: false,
include_path_reorder: false,
@@ -681,12 +1155,17 @@ export default function ExerciseProgressionPathBuilder({
setTargetSummary(null)
setPathQa(null)
setGapFillOffers([])
+ setPathSkillExpectations(null)
setRoadmapDirty(false)
+ setLoadedPlanningHint(false)
+ setWizardStep(2)
+ await persistPlanningRoadmapToGraph()
} catch (e) {
console.error(e)
setError(e.message || 'Roadmap-Vorschlag fehlgeschlagen')
setEditableMajorSteps([])
setProgressionRoadmap(null)
+ setPathSkillExpectations(null)
} finally {
setLoadingRoadmap(false)
}
@@ -728,6 +1207,9 @@ export default function ExerciseProgressionPathBuilder({
})
applyPathMatchResponse(res, q)
setMaxSteps(validSteps.length)
+ setLoadedPlanningHint(false)
+ setWizardStep(3)
+ await persistPlanningRoadmapToGraph()
} catch (e) {
console.error(e)
setError(e.message || 'Übungs-Match fehlgeschlagen')
@@ -762,12 +1244,20 @@ export default function ExerciseProgressionPathBuilder({
setSaving(true)
setError('')
try {
+ const edgeIds = Array.isArray(graphChainEdgeIds)
+ ? graphChainEdgeIds.filter((id) => Number.isFinite(Number(id)))
+ : []
+ if (edgeIds.length > 0) {
+ await api.deleteExerciseProgressionEdgesBatch(Number(graphId), edgeIds)
+ }
+ const planningArtifact = buildPlanningArtifact()
await api.createExerciseProgressionSequence(Number(graphId), {
steps: steps.map((s) => ({
exercise_id: s.exerciseId,
variant_id: s.variantId || null,
})),
segment_notes,
+ ...(planningArtifact ? { planning_roadmap: planningArtifact } : {}),
})
setPathSteps([])
setTargetSummary(null)
@@ -775,17 +1265,27 @@ export default function ExerciseProgressionPathBuilder({
setPathQa(null)
setGapFillOffers([])
setProgressionRoadmap(null)
+ setPathSkillExpectations(null)
setEditableMajorSteps([])
setRoadmapDirty(false)
+ setWizardStep(1)
+ setPathInsertNotice('')
if (typeof onSaved === 'function') await onSaved()
const msg =
skippedAi > 0
- ? `${n} Kante(n) gespeichert. ${skippedAi} KI-Vorschlag/Vorschläge nicht im Graph (noch nicht angelegt).`
- : `${n} Nachfolger-Kante(n) aus KI-Pfad gespeichert.`
+ ? `Pfad gespeichert (${n} Kante(n)). ${skippedAi} KI-Vorschlag/Vorschläge noch nicht angelegt.`
+ : edgeIds.length > 0
+ ? `Progressionspfad aktualisiert (${n} Kante(n)).`
+ : `Progressionspfad angelegt (${n} Kante(n)).`
alert(msg)
} catch (e) {
console.error(e)
- setError(e.message || 'Speichern fehlgeschlagen')
+ const detail = e?.message || String(e)
+ setError(
+ detail.includes('409') || detail.toLowerCase().includes('duplikat')
+ ? 'Speichern fehlgeschlagen: Pfad-Konflikt. Bitte erneut versuchen — bestehende Kanten werden beim Speichern ersetzt.'
+ : detail || 'Speichern fehlgeschlagen',
+ )
} finally {
setSaving(false)
}
@@ -793,18 +1293,54 @@ export default function ExerciseProgressionPathBuilder({
return (
-
KI: Pfad zum Ziel
+
Progressionspfad planen (KI)
- Zuerst didaktische Roadmap vorschlagen und anpassen, dann Übungen je Major Step aus der Bibliothek matchen.
- Lücken können mit KI als Übung angelegt werden.
+ Ein Graph hat einen linearen Pfad. Oben der gespeicherte Stand, darunter der KI-Entwurf in vier Schritten.
+ Speichern übernimmt den Entwurf und ersetzt den bisherigen Pfad.
-
+
+
0} />
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ {wizardStep === 1 ? (
+
+
+ Schritt 1 — Ziel & Start/Ziel
+
+ {loadedPlanningHint && editableMajorSteps.length > 0 && pathSteps.length === 0 ? (
+
+ Gespeicherte Planung geladen — Sie können bei Schritt 2 weitermachen oder hier neu starten.
+
+ ) : null}
+
-
- 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.
-
-
-
- {loadingStartTarget ? 'Analyse …' : 'Start/Ziel analysieren'}
-
-
- {loadingRoadmap ? 'Roadmap …' : 'Roadmap vorschlagen'}
-
- {startTargetAnalyzed && !editableMajorSteps.length ? (
-
- Start/Ziel bereit — Roadmap als Nächstes
-
- ) : null}
-
- {loadingMatch ? 'Match …' : roadmapDirty ? 'Übungen neu matchen' : 'Übungen matchen'}
-
-
+
+ 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.
+
+
+
+ {loadingStartTarget ? 'Analyse …' : 'Start/Ziel analysieren'}
+
+
+ {loadingRoadmap ? 'Roadmap …' : 'Roadmap vorschlagen →'}
+
+ {startTargetAnalyzed && !editableMajorSteps.length ? (
+
+ Start/Ziel bereit
+
+ ) : null}
+
- {error ? (
-
- {error}
-
- ) : null}
-
- {(progressionRoadmap?.goal_analysis ||
- progressionRoadmap?.pipeline_phase === 'start_target_only') ? (
-
+ {(progressionRoadmap?.goal_analysis ||
+ progressionRoadmap?.pipeline_phase === 'start_target_only') ? (
+
+
+ KI-Zielanalyse (Details)
+
+
Zielanalyse
{progressionRoadmap.llm_start_target_applied ? (
@@ -989,35 +1503,32 @@ export default function ExerciseProgressionPathBuilder({
) : null}
-
- ) : null}
-
- {(semanticBrief || targetSummary) && pathSteps.length > 0 ? (
-
- {semanticBrief?.primary_topic ? (
-
- Thema: {semanticBrief.primary_topic}
-
+
+
) : null}
- {Array.isArray(semanticBrief?.development_arc) &&
- semanticBrief.development_arc.slice(0, 3).map((phase) => (
-
- {phase}
-
- ))}
- {Array.isArray(targetSummary?.focus_areas) &&
- targetSummary.focus_areas.slice(0, 1).map((fa) => (
-
- Fokus: {fa}
-
- ))}
-
+
) : null}
- {editableMajorSteps.length > 0 ? (
+ {wizardStep === 2 ? (
+
+
+ Schritt 2 — Didaktische Roadmap
+
+ {editableMajorSteps.length === 0 ? (
+
+ Noch keine Roadmap — zuerst in Schritt 1 „Roadmap vorschlagen“.
+ setWizardStep(1)}
+ >
+ Zu Schritt 1
+
+
+ ) : (
-
Didaktische Roadmap — bearbeiten
+
Major Steps bearbeiten
{roadmapDirty ? (
Geändert — bitte erneut matchen
@@ -1046,7 +1557,7 @@ export default function ExerciseProgressionPathBuilder({
: progressionRoadmap
? ' (heuristisch/KI)'
: ''}
- . Phasen und Lernziele anpassen, dann „Übungen matchen“.
+ . Phasen und Lernziele anpassen; optional Stufen-Details für Match und KI-Lücken, dann „Übungen matchen“.
{editableMajorSteps.map((step, idx) => (
@@ -1057,66 +1568,141 @@ export default function ExerciseProgressionPathBuilder({
borderRadius: '8px',
background: 'var(--surface2)',
border: '1px solid var(--border)',
- display: 'grid',
- gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
- gap: '10px',
- alignItems: 'end',
}}
>
-
- Stufe {idx + 1} · Phase
- patchMajorStep(idx, { phase: e.target.value })}
- disabled={disabled || loading || saving}
- >
- {ROADMAP_PHASES.map((p) => (
-
- {p}
-
- ))}
-
-
-
- Lernziel
- patchMajorStep(idx, { learning_goal: e.target.value })}
- placeholder="z. B. Grundstellung und Hüftmobilität für Mae Geri"
- disabled={disabled || loading || saving}
- />
-
-
-
moveMajorStep(idx, -1)}
- disabled={disabled || loading || saving || idx === 0}
- >
- ↑
-
-
moveMajorStep(idx, 1)}
- disabled={disabled || loading || saving || idx >= editableMajorSteps.length - 1}
- >
- ↓
-
-
removeMajorStep(idx)}
- disabled={disabled || loading || saving || editableMajorSteps.length <= 2}
- >
- Entfernen
-
+
+
+ Stufe {idx + 1} · Phase
+ patchMajorStep(idx, { phase: e.target.value })}
+ disabled={disabled || loading || saving}
+ >
+ {ROADMAP_PHASES.map((p) => (
+
+ {p}
+
+ ))}
+
+
+
+ Lernziel (Major Step)
+ patchMajorStep(idx, { learning_goal: e.target.value })}
+ placeholder="z. B. Grundstellung und Hüftmobilität für Mae Geri"
+ disabled={disabled || loading || saving}
+ />
+
+
+ moveMajorStep(idx, -1)}
+ disabled={disabled || loading || saving || idx === 0}
+ >
+ ↑
+
+ moveMajorStep(idx, 1)}
+ disabled={disabled || loading || saving || idx >= editableMajorSteps.length - 1}
+ >
+ ↓
+
+ removeMajorStep(idx)}
+ disabled={disabled || loading || saving || editableMajorSteps.length <= 2}
+ >
+ Entfernen
+
+
+
+
+ Stufen-Details (Match & KI)
+ {step.load_profile?.length
+ ? ` · ${step.load_profile.slice(0, 2).join(', ')}`
+ : ''}
+
+
+
+
Belastungsschwerpunkte
+
+ {LOAD_PROFILE_OPTIONS.map((opt) => {
+ const active = (step.load_profile || []).includes(opt)
+ return (
+
+ {
+ const current = step.load_profile || []
+ const next = active
+ ? current.filter((x) => x !== opt)
+ : [...current, opt]
+ patchMajorStep(idx, { load_profile: next })
+ }}
+ />
+ {opt}
+
+ )
+ })}
+
+
+
+ Erfolgskriterien (je Zeile)
+
+
+ Vermeiden (optional, je Zeile)
+
+
+
))}
@@ -1132,9 +1718,102 @@ export default function ExerciseProgressionPathBuilder({
) : null}
+ )}
+ {editableMajorSteps.length >= 2 ? (
+
+ setWizardStep(1)}
+ disabled={disabled || loading || saving}
+ >
+ ← Zurück
+
+
+ {loadingMatch ? 'Match …' : roadmapDirty ? 'Übungen neu matchen →' : 'Übungen matchen →'}
+
+
+ ) : null}
+
) : null}
- {pathQa && pathSteps.length > 0 ? (
+ {wizardStep === 3 ? (
+
+
+ Schritt 3 — Übungen & Qualität
+
+ {pathSteps.length === 0 ? (
+
+ Noch kein Match — zuerst in Schritt 2 „Übungen matchen“.
+ setWizardStep(editableMajorSteps.length >= 2 ? 2 : 1)}
+ >
+ Zurück
+
+
+ ) : (
+ <>
+ {(semanticBrief || targetSummary || pathSkillExpectations) ? (
+
+
+ Pfad-Kontext (Thema, Fokus, Fähigkeiten)
+
+
+ {semanticBrief?.primary_topic ? (
+
+ Thema: {semanticBrief.primary_topic}
+
+ ) : null}
+ {Array.isArray(semanticBrief?.development_arc) &&
+ semanticBrief.development_arc.slice(0, 3).map((phase) => (
+
+ {phase}
+
+ ))}
+ {Array.isArray(targetSummary?.focus_areas) &&
+ targetSummary.focus_areas.slice(0, 1).map((fa) => (
+
+ Fokus: {fa}
+
+ ))}
+ {formatExpectedSkillNames(pathSkillExpectations, 5).map((name) => (
+
+ {name}
+
+ ))}
+
+
+ ) : null}
+
+ {pathQa ? (
) : Number(pathQa.off_topic_count) > 0 ? (
- {pathQa.off_topic_count} Schritt(e) ohne Bezug zum Pfad-Thema — siehe Lücken-Angebote unten.
+ {pathQa.off_topic_count} Schritt(e) ohne Bezug zum Pfad-Thema — Lücken in Schritt 4 schließen.
) : null}
{pathQa.reorder_applied ? (
@@ -1190,88 +1869,7 @@ export default function ExerciseProgressionPathBuilder({
) : null}
- {gapFillOffers.length > 0 ? (
-
-
Fehlende Schritte — mit KI anlegen
-
- Fehlende oder zu ersetzende Schritte ({pathSteps.length}/{maxSteps} im Pfad).
- {pathSteps.length >= maxSteps
- ? ' Der Pfad ist voll — beim Einfügen können Sie die Pfadlänge dynamisch vergrößern (ohne neuen Vorschlag); Ersatz-Angebote ersetzen einen Schritt.'
- : ' „Mit KI anlegen“ erzeugt einen vollständigen Entwurf und fügt die Übung ein.'}
-
- {gapFillOffers.map((offer) => (
-
-
-
-
- {OFFER_SOURCE_LABELS[offer.source] || offer.source || 'Lücke'}
- {offer.phase ? ` · ${offer.phase}` : ''}
-
-
{offer.title_hint}
- {offer.rationale ? (
-
{offer.rationale}
- ) : null}
- {offer.from_title && offer.to_title ? (
-
- Zwischen „{offer.from_title}“ und „{offer.to_title}“
- {offer.replace_step_index != null ? ' (ersetzt themenfremden Schritt)' : ''}
-
- ) : null}
-
-
-
= PATH_STEPS_HARD_MAX)
- }
- onClick={() => handleGapFillClick(offer)}
- title={
- isGapOfferBlockedByPathCapacity(offer, pathSteps.length, maxSteps)
- ? maxSteps >= PATH_STEPS_HARD_MAX
- ? `Maximal ${PATH_STEPS_HARD_MAX} Schritte — zuerst einen Schritt entfernen.`
- : 'Pfad voll — Klick fragt, ob die Pfadlänge vergrößert werden soll'
- : offer.replace_step_index != null
- ? 'Ersetzt den themenfremden Schritt im Pfad'
- : 'KI-Entwurf mit Pfad-Kontext generieren'
- }
- >
- {generatingOfferId === offer.offer_id
- ? 'KI erstellt Entwurf …'
- : 'Mit KI anlegen'}
-
-
-
- ))}
-
-
- ) : null}
-
- {pathSteps.length > 0 ? (
- <>
-
{pathSteps.map((step, idx) => (
@@ -1298,6 +1894,12 @@ export default function ExerciseProgressionPathBuilder({
: ''}
{step.roadmapPhase ? ` (${step.roadmapPhase})` : ''}
{step.isOffTopic ? ' (themenfremd)' : ''}
+ {step.semanticScore != null &&
+ Number(step.semanticScore) < 0.22 &&
+ step.roadmapLearningGoal ? (
+
(schwaches Stufen-Match)
+ ) : null}
+ {step.isFromGraph ? ' (im Graph)' : ''}
{step.isAiProposal ? ' (KI-Neu)' : step.isBridge ? ' (Brücke)' : ''}
{step.roadmapLearningGoal ? (
@@ -1305,6 +1907,30 @@ export default function ExerciseProgressionPathBuilder({
Ziel: {step.roadmapLearningGoal}
) : null}
+ {formatExpectedSkillNames(step.skillExpectations).length ? (
+
+ {formatExpectedSkillNames(step.skillExpectations).map((name) => (
+
+ {name}
+
+ ))}
+
+ ) : null}
{step.exerciseTitle}
{step.exerciseId ? (
@@ -1368,6 +1994,201 @@ export default function ExerciseProgressionPathBuilder({
))}
+
+ setWizardStep(2)}
+ disabled={disabled || loading || saving}
+ >
+ ← Roadmap
+
+ setWizardStep(4)}
+ disabled={disabled || loading || saving}
+ >
+ Weiter zu Lücken & Speichern →
+
+ {gapFillOffers.length > 0 ? (
+
+ {gapFillOffers.length} Lücke(n) offen
+
+ ) : null}
+
+ >
+ )}
+
+ ) : null}
+
+ {wizardStep === 4 ? (
+
+
+ Schritt 4 — Lücken schließen & Pfad speichern
+
+ {pathSteps.length === 0 ? (
+
+ Noch kein Pfad — zuerst Schritt 3 abschließen.
+ setWizardStep(3)}
+ >
+ Zu Schritt 3
+
+
+ ) : (
+ <>
+
+
Wichtig: Der KI-Entwurf ist noch nicht gespeichert. «Pfad im Graph speichern» übernimmt
+ ihn und ersetzt den oben gezeigten Pfad.
+ {pathInsertNotice ?
{pathInsertNotice}
: null}
+
+
+ {gapFillOffers.length > 0 ? (
+
+
Fehlende Schritte — mit KI anlegen
+
+ Fehlende oder zu ersetzende Schritte ({pathSteps.length}/{maxSteps} im Pfad).
+ {pathSteps.length >= maxSteps
+ ? ' Der Pfad ist voll — beim Einfügen können Sie die Pfadlänge dynamisch vergrößern (ohne neuen Vorschlag); Ersatz-Angebote ersetzen einen Schritt.'
+ : ' Zuerst Kontext prüfen und ergänzen, dann KI-Entwurf erstellen und einfügen.'}
+
+
+ {gapFillOffers.map((offer) => (
+
+
+
+
+ {OFFER_SOURCE_LABELS[offer.source] || offer.source || 'Lücke'}
+ {offer.phase ? ` · ${offer.phase}` : ''}
+
+
{offer.title_hint}
+ {offer.rationale ? (
+
{offer.rationale}
+ ) : null}
+ {offer.from_title && offer.to_title ? (
+
+ Zwischen „{offer.from_title}“ und „{offer.to_title}“
+ {offer.replace_step_index != null ? ' (ersetzt themenfremden Schritt)' : ''}
+
+ ) : null}
+
+
+
= PATH_STEPS_HARD_MAX)
+ }
+ onClick={() => handleGapFillClick(offer)}
+ title={
+ isGapOfferBlockedByPathCapacity(offer, pathSteps.length, maxSteps)
+ ? maxSteps >= PATH_STEPS_HARD_MAX
+ ? `Maximal ${PATH_STEPS_HARD_MAX} Schritte — zuerst einen Schritt entfernen.`
+ : 'Pfad voll — Klick fragt, ob die Pfadlänge vergrößert werden soll'
+ : offer.replace_step_index != null
+ ? 'Ersetzt den themenfremden Schritt im Pfad'
+ : 'Kontext prüfen, Ergänzungen mitgeben, dann KI-Entwurf'
+ }
+ >
+ {generatingOfferId === offer.offer_id
+ ? 'KI erstellt Entwurf …'
+ : 'Vorbereiten & KI anlegen'}
+
+
+
+ ))}
+
+
+ ) : (
+
+ Keine offenen Lücken — Pfad kann direkt gespeichert werden.
+
+ )}
+
+
+
+ Pfad vor dem Speichern ({pathSteps.length} Schritte)
+
+
+ {pathSteps.map((step, idx) => (
+
+ {step.exerciseTitle}
+ {step.exerciseId ? (
+ (#{step.exerciseId})
+ ) : (
+ — noch nicht angelegt
+ )}
+ {step.roadmapPhase ? ` · ${step.roadmapPhase}` : ''}
+ {step.isFromGraph ? ' · bereits im Graph' : ''}
+
+ ))}
+
+
+
Notiz für Kanten (Fallback, optional)
-
+
+ setWizardStep(3)}
+ disabled={disabled || loading || saving}
+ >
+ ← Match anpassen
+
s.exerciseId).length < 2}
onClick={savePathToGraph}
>
- {saving ? 'Speichern …' : 'Pfad in Graph speichern'}
+ {saving
+ ? 'Speichern …'
+ : graphChainEdgeIds?.length
+ ? 'Pfad im Graph speichern (ersetzen)'
+ : 'Pfad im Graph speichern'}
= 2 ? 2 : 1)
}}
>
Vorschlag verwerfen
- >
+ >
+ )}
+
) : null}
+
+
{
setQuickCreateDraft(null)
- setActivePlanningContextLines([])
- if (activeOffer) setQuickCreateOpen(true)
+ if (activeOffer) {
+ setGapPrepOpen(true)
+ } else {
+ setActivePlanningContextLines([])
+ }
}}
planningContextLines={activePlanningContextLines}
onApply={applyQuickCreateDraft}
diff --git a/frontend/src/components/ProgressionChainEditor.jsx b/frontend/src/components/ProgressionChainEditor.jsx
new file mode 100644
index 0000000..5ab21e0
--- /dev/null
+++ b/frontend/src/components/ProgressionChainEditor.jsx
@@ -0,0 +1,516 @@
+/**
+ * Bearbeitbare Darstellung linearer Progressions-Reihen im Graphen.
+ */
+import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react'
+import { Link } from 'react-router-dom'
+import api from '../utils/api'
+
+function emptyNode() {
+ return {
+ exerciseId: null,
+ exerciseTitle: '',
+ variantId: null,
+ variantName: null,
+ variants: [],
+ }
+}
+
+function chainToDraft(chain) {
+ return {
+ key: `chain-${chain.edges[0]?.id ?? 'x'}`,
+ edgeIds: chain.edges.map((e) => e.id),
+ segmentNotes: chain.edges.map((e) => e.notes || ''),
+ nodes: chain.nodes.map((n) => ({
+ exerciseId: n.exercise_id,
+ exerciseTitle: n.title || `Übung #${n.exercise_id}`,
+ variantId: n.variant_id ?? null,
+ variantName: n.variant_name ?? null,
+ variants: [],
+ })),
+ dirty: false,
+ isNew: false,
+ }
+}
+
+function newChainDraft() {
+ const key = `new-${Date.now()}`
+ return {
+ key,
+ edgeIds: [],
+ segmentNotes: [],
+ nodes: [emptyNode(), emptyNode()],
+ dirty: true,
+ isNew: true,
+ }
+}
+
+function formatNodeLabel(node) {
+ if (!node.exerciseId) return '— Übung wählen —'
+ return (
+ <>
+ {node.exerciseTitle}
+ {node.variantName ? (
+ {` · ${node.variantName}`}
+ ) : null}
+ >
+ )
+}
+
+const ProgressionChainEditor = forwardRef(function ProgressionChainEditor(
+ {
+ graphId,
+ chains = [],
+ busy = false,
+ anchorExerciseId = null,
+ anchorTitle = null,
+ onRefresh,
+ onPickExercise,
+ loadVariantsForExercise,
+ singlePathMode = false,
+ },
+ ref,
+) {
+ const [drafts, setDrafts] = useState([])
+ const [savingKey, setSavingKey] = useState(null)
+
+ const chainSignature = useMemo(
+ () =>
+ chains
+ .map((c) => c.edges.map((e) => e.id).join(','))
+ .join('|'),
+ [chains],
+ )
+
+ useEffect(() => {
+ setDrafts(chains.map(chainToDraft))
+ }, [chainSignature, chains])
+
+ const patchDraft = useCallback((key, patchFn) => {
+ setDrafts((prev) =>
+ prev.map((d) => {
+ if (d.key !== key) return d
+ const next = patchFn(d)
+ return { ...next, dirty: true }
+ }),
+ )
+ }, [])
+
+ const moveNode = (key, idx, dir) => {
+ patchDraft(key, (d) => {
+ const j = idx + dir
+ if (j < 0 || j >= d.nodes.length) return d
+ const nodes = [...d.nodes]
+ const t = nodes[idx]
+ nodes[idx] = nodes[j]
+ nodes[j] = t
+ return { ...d, nodes }
+ })
+ }
+
+ const removeNode = (key, idx) => {
+ patchDraft(key, (d) => {
+ if (d.nodes.length <= 2) return d
+ const nodes = d.nodes.filter((_, i) => i !== idx)
+ const segmentNotes = d.segmentNotes.slice(0, Math.max(0, nodes.length - 1))
+ return { ...d, nodes, segmentNotes }
+ })
+ }
+
+ const setVariant = (key, idx, variantId) => {
+ patchDraft(key, (d) => ({
+ ...d,
+ nodes: d.nodes.map((n, i) => {
+ if (i !== idx) return n
+ const v = (n.variants || []).find((x) => Number(x.id) === Number(variantId))
+ return {
+ ...n,
+ variantId: variantId === '' || variantId == null ? null : Number(variantId),
+ variantName: v?.variant_name || null,
+ }
+ }),
+ }))
+ }
+
+ const applyExerciseToNode = useCallback(async (key, idx, ex) => {
+ const title = ex.title || `Übung #${ex.id}`
+ const variants =
+ Array.isArray(ex.variants) && ex.variants.length
+ ? ex.variants
+ : await loadVariantsForExercise(ex.id)
+ const variantId = ex.exercise_variant_id ?? ex.suggested_variant_id ?? null
+ patchDraft(key, (d) => ({
+ ...d,
+ nodes: d.nodes.map((n, i) =>
+ i === idx
+ ? {
+ exerciseId: ex.id,
+ exerciseTitle: title,
+ variantId: variantId != null ? Number(variantId) : null,
+ variantName:
+ variantId != null
+ ? variants.find((v) => Number(v.id) === Number(variantId))?.variant_name || null
+ : null,
+ variants,
+ }
+ : n,
+ ),
+ }))
+ }, [patchDraft, loadVariantsForExercise])
+
+ const insertNodeAfter = (key, idx) => {
+ patchDraft(key, (d) => {
+ const nodes = [...d.nodes]
+ nodes.splice(idx + 1, 0, emptyNode())
+ return { ...d, nodes }
+ })
+ onPickExercise({ kind: 'chain', draftKey: key, nodeIndex: idx + 1 })
+ }
+
+ const addNewChain = () => {
+ setDrafts((prev) => [...prev, newChainDraft()])
+ }
+
+ const discardDraft = (key) => {
+ setDrafts((prev) => {
+ const draft = prev.find((d) => d.key === key)
+ if (!draft) return prev
+ if (draft.isNew) return prev.filter((d) => d.key !== key)
+ const original = chains.find((c) => `chain-${c.edges[0]?.id}` === key)
+ if (!original) return prev.filter((d) => d.key !== key)
+ return prev.map((d) => (d.key === key ? chainToDraft(original) : d))
+ })
+ }
+
+ const deleteChain = async (draft) => {
+ if (!graphId) return
+ if (draft.isNew) {
+ setDrafts((prev) => prev.filter((d) => d.key !== draft.key))
+ return
+ }
+ if (!draft.edgeIds.length) return
+ if (!window.confirm(`Reihe mit ${draft.nodes.length} Schritten löschen?`)) return
+ setSavingKey(draft.key)
+ try {
+ await api.deleteExerciseProgressionEdgesBatch(graphId, draft.edgeIds)
+ await onRefresh()
+ } catch (e) {
+ alert(e.message || String(e))
+ } finally {
+ setSavingKey(null)
+ }
+ }
+
+ const saveDraft = async (draft) => {
+ if (!graphId) return
+ const steps = draft.nodes.filter((n) => n.exerciseId != null)
+ if (steps.length < 2) {
+ alert('Mindestens zwei Schritte mit gewählter Übung.')
+ return
+ }
+ const n = steps.length - 1
+ let segment_notes = draft.segmentNotes.slice(0, n)
+ while (segment_notes.length < n) segment_notes.push(null)
+ segment_notes = segment_notes.slice(0, n)
+
+ setSavingKey(draft.key)
+ try {
+ if (!draft.isNew && draft.edgeIds.length) {
+ await api.deleteExerciseProgressionEdgesBatch(graphId, draft.edgeIds)
+ }
+ await api.createExerciseProgressionSequence(graphId, {
+ steps: steps.map((s) => ({
+ exercise_id: s.exerciseId,
+ variant_id: s.variantId || null,
+ })),
+ segment_notes,
+ })
+ await onRefresh()
+ } catch (e) {
+ alert(e.message || String(e))
+ } finally {
+ setSavingKey(null)
+ }
+ }
+
+ const ensureVariantsLoaded = async (key, idx) => {
+ const draft = drafts.find((d) => d.key === key)
+ const node = draft?.nodes[idx]
+ if (!node?.exerciseId || (node.variants || []).length) return
+ const variants = await loadVariantsForExercise(node.exerciseId)
+ setDrafts((prev) =>
+ prev.map((d) => {
+ if (d.key !== key) return d
+ return {
+ ...d,
+ nodes: d.nodes.map((n, i) => (i === idx ? { ...n, variants } : n)),
+ }
+ }),
+ )
+ }
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ applyExercise: applyExerciseToNode,
+ }),
+ [applyExerciseToNode],
+ )
+
+ if (!graphId) return null
+
+ return (
+
+
+
+
+ {singlePathMode ? 'Manuell bearbeiten' : 'Reihen im Graph'}
+
+
+ Schritt 1 → 2 → …: Reihenfolge ändern, Übungen tauschen oder dazwischen einfügen, dann speichern.
+
+
+ {!singlePathMode || drafts.length === 0 ? (
+
+ {singlePathMode ? '+ Pfad anlegen' : '+ Neue Reihe'}
+
+ ) : null}
+
+
+ {drafts.length === 0 ? (
+
+ {singlePathMode
+ ? 'Noch kein gespeicherter Pfad — manuell anlegen oder mit dem KI-Planer unten.'
+ : 'Noch keine Reihen in diesem Graph.'}
+
+ ) : (
+ drafts.map((draft, chainIdx) => (
+
+
+
+ {singlePathMode ? 'Gespeicherter Pfad' : `Reihe ${chainIdx + 1}`}
+
+ {draft.dirty ? (
+
+ Ungespeichert
+
+ ) : null}
+ {draft.isNew ? (
+ Neu
+ ) : (
+ {draft.nodes.length} Schritte
+ )}
+
+
+
+ {draft.nodes.map((node, idx) => (
+
+ {idx > 0 ? (
+
+ ↓ Nachfolger
+
+ ) : null}
+
+
+
Schritt {idx + 1}
+
+
+ {formatNodeLabel(node)}
+
+
+ onPickExercise({ kind: 'chain', draftKey: draft.key, nodeIndex: idx })
+ }
+ >
+ {node.exerciseId ? 'Tauschen…' : 'Übung…'}
+
+ {anchorExerciseId != null ? (
+ {
+ const variants = await loadVariantsForExercise(anchorExerciseId)
+ await applyExerciseToNode(draft.key, idx, {
+ id: anchorExerciseId,
+ title: anchorTitle?.trim() || `Übung #${anchorExerciseId}`,
+ variants,
+ })
+ }}
+ >
+ Kontext-Übung
+
+ ) : null}
+
+
+
+ Variante
+ ensureVariantsLoaded(draft.key, idx)}
+ onChange={(e) =>
+ setVariant(
+ draft.key,
+ idx,
+ e.target.value === '' ? null : parseInt(e.target.value, 10),
+ )
+ }
+ >
+ Gesamte Übung
+ {(node.variants || []).map((v) => (
+
+ {v.variant_name || `Variante #${v.id}`}
+
+ ))}
+
+
+
+ moveNode(draft.key, idx, -1)}
+ >
+ ↑
+
+ = draft.nodes.length - 1}
+ onClick={() => moveNode(draft.key, idx, 1)}
+ >
+ ↓
+
+ insertNodeAfter(draft.key, idx)}
+ >
+ + Einfügen
+
+ removeNode(draft.key, idx)}
+ >
+ Entfernen
+
+
+
+
+ ))}
+
+
+
+ saveDraft(draft)}
+ >
+ {savingKey === draft.key ? 'Speichern …' : singlePathMode ? 'Pfad speichern' : 'Reihe speichern'}
+
+ {draft.dirty ? (
+ discardDraft(draft.key)}
+ >
+ Verwerfen
+
+ ) : null}
+ deleteChain(draft)}
+ >
+ {singlePathMode ? 'Pfad löschen' : 'Reihe löschen'}
+
+
+
+ ))
+ )}
+
+ )
+})
+
+export default ProgressionChainEditor
diff --git a/frontend/src/components/ProgressionFindingsPanel.jsx b/frontend/src/components/ProgressionFindingsPanel.jsx
new file mode 100644
index 0000000..ebfc1cb
--- /dev/null
+++ b/frontend/src/components/ProgressionFindingsPanel.jsx
@@ -0,0 +1,259 @@
+/**
+ * Zentrales Findings-Panel für Progressionsgraph-QA (Phase B.2).
+ */
+import React, { useMemo, useState } from 'react'
+import {
+ offerCanExpandSlots,
+ offerNeedsNewSlot,
+ offerSourceLabel,
+ resolveOfferSlotIndex,
+} from '../utils/progressionGraphDraft'
+
+function severityStyle(pathQa) {
+ if (!pathQa) return {}
+ return {
+ background: pathQa.overall_ok
+ ? 'color-mix(in srgb, var(--accent) 8%, var(--surface2))'
+ : 'color-mix(in srgb, var(--danger) 8%, var(--surface2))',
+ }
+}
+
+function GapOfferCard({
+ offer,
+ slotCount,
+ draft,
+ onApplyDraft,
+ onInsertSlot,
+ onGenerateAi,
+ generatingOfferId,
+ aiBusy,
+}) {
+ const defaultSlot = resolveOfferSlotIndex(draft, offer)
+ const [slotPick, setSlotPick] = useState(
+ defaultSlot != null && Number.isFinite(defaultSlot) ? String(defaultSlot) : '',
+ )
+ const needsInsert = offerNeedsNewSlot(offer)
+ const canInsert = offerCanExpandSlots(draft, offer)
+
+ const slotOptions = useMemo(() => {
+ const rows = []
+ for (let i = 0; i < slotCount; i += 1) {
+ rows.push({ value: String(i), label: `Slot ${i + 1}` })
+ }
+ return rows
+ }, [slotCount])
+
+ const applyToSlot = () => {
+ const idx = slotPick !== '' ? Number(slotPick) : defaultSlot
+ if (!Number.isFinite(idx)) {
+ alert('Bitte einen Slot wählen.')
+ return
+ }
+ onApplyDraft(offer, idx)
+ }
+
+ return (
+
+
+ {offerSourceLabel(offer.source)}
+ {offer.phase ? ` · ${offer.phase}` : ''}
+ {offer.has_ai_payload ? ' · KI-Entwurf bereit' : ''}
+
+ {offer.title_hint || offer.proposal_title || 'Übungsvorschlag'}
+ {offer.rationale ? (
+ {offer.rationale}
+ ) : null}
+ {offer.from_title && offer.to_title ? (
+
+ Zwischen „{offer.from_title}“ und „{offer.to_title}“
+
+ ) : null}
+
+
+
+ Ziel-Slot
+ setSlotPick(e.target.value)}
+ >
+ — wählen —
+ {slotOptions.map((o) => (
+
+ {o.label}
+
+ ))}
+
+
+
+
+
+ {offer.has_ai_payload ? (
+ applyToSlot()}
+ >
+ Entwurf in Slot
+
+ ) : (
+ onGenerateAi(offer, slotPick !== '' ? Number(slotPick) : defaultSlot)}
+ >
+ {generatingOfferId === offer.offer_id ? 'KI erstellt…' : 'KI anlegen'}
+
+ )}
+ applyToSlot()}
+ >
+ Platzhalter in Slot
+
+ {needsInsert ? (
+ onInsertSlot(offer)}
+ >
+ Neuen Slot einfügen
+
+ ) : null}
+
+
+ )
+}
+
+export default function ProgressionFindingsPanel({
+ pathQa = null,
+ gapFillOffers = [],
+ draft = null,
+ slotCount = 0,
+ loading = false,
+ error = '',
+ onEvaluate,
+ onApplyGapOffer,
+ onInsertGapSlot,
+ onGenerateGapAi,
+ generatingOfferId = null,
+ aiBusy = false,
+ evaluateDisabled = false,
+}) {
+ return (
+
+
Graph-Bewertung
+
+ Prüft den Slot-Stand und listet KI-Angebote für leere Stufen und Lücken.
+
+
+
+ {loading ? 'Bewertung läuft…' : 'Graph bewerten'}
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ {pathQa ? (
+
+
+ Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'}
+ {pathQa.quality_score != null
+ ? ` (${Math.round(Number(pathQa.quality_score) * 100)} %)`
+ : ''}
+
+ {pathQa.topic_coverage ? (
+
{pathQa.topic_coverage}
+ ) : null}
+ {Array.isArray(pathQa.issues) && pathQa.issues.length > 0 ? (
+
+ {pathQa.issues.map((issue) => (
+ {issue}
+ ))}
+
+ ) : null}
+ {Array.isArray(pathQa.recommendations) && pathQa.recommendations.length > 0 ? (
+ <>
+
Empfehlungen
+
+ {pathQa.recommendations.map((rec) => (
+ {rec}
+ ))}
+
+ >
+ ) : null}
+ {Number(pathQa.off_topic_count) > 0 ? (
+
+ {pathQa.off_topic_count} Schritt(e) ohne Bezug zum Pfad-Thema.
+
+ ) : null}
+
+ ) : (
+
+ Noch keine Bewertung. Roadmap anlegen, dann „Graph bewerten“ oder „Übungen matchen“.
+
+ )}
+
+
+
+ KI-Angebote {gapFillOffers.length > 0 ? `(${gapFillOffers.length})` : ''}
+
+ {gapFillOffers.length === 0 ? (
+
+ Keine offenen Angebote. Nach Match oder Bewertung erscheinen Vorschläge für leere Slots und Lücken.
+
+ ) : (
+
+ {gapFillOffers.map((offer) => (
+ onApplyGapOffer(o, idx)}
+ onInsertSlot={onInsertGapSlot}
+ onGenerateAi={onGenerateGapAi}
+ />
+ ))}
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx
new file mode 100644
index 0000000..ecb7fd4
--- /dev/null
+++ b/frontend/src/components/ProgressionGraphEditor.jsx
@@ -0,0 +1,1027 @@
+/**
+ * Integrierter Slot-Editor für Progressionsgraphen (Phase B).
+ */
+import React, { useCallback, useEffect, useMemo, useState } from 'react'
+import { Link } from 'react-router-dom'
+import api from '../utils/api'
+import ExercisePickerModal from './ExercisePickerModal'
+import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
+import ProgressionSlotCard from './ProgressionSlotCard'
+import ProgressionFindingsPanel from './ProgressionFindingsPanel'
+import {
+ aiPreviewToQuickCreateDraft,
+ buildQuickCreateAiPreview,
+ buildQuickCreateExercisePayloadFromDraft,
+ ensureQuickCreateDraftFromAiSuggestion,
+} from '../utils/exerciseAiQuickCreate'
+import {
+ buildPathGapPlanningContextForAi,
+ buildSlotGapGoalForAi,
+ gapOfferContextDisplayLines,
+ initialStageLearningGoalFromOffer,
+} from '../utils/planningContextForExerciseAi'
+import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
+import {
+ addSlotToDraft,
+ applyEvaluateResponseToDraft,
+ applyGapOfferToDraft,
+ applyMatchResponseToDraft,
+ applyResolvedStructuredToDraft,
+ buildPlanningArtifactFromDraft,
+ hydrateProgressionGraphDraft,
+ SLOT_MIN,
+ insertSlotInDraft,
+ librarySlotExercise,
+ majorStepsToOverridePayload,
+ moveSlotInDraft,
+ patchSlotInDraft,
+ removeSlotFromDraft,
+ saveProgressionGraphDraft,
+ setSlotPrimaryLibrary,
+ SLOT_MAX,
+ slotsAsPathStepRows,
+ slotsToEvaluateSteps,
+ draftRetrievalBoostExerciseIds,
+ slotsToSlotAssignments,
+ syncProgressionRoadmapFromSlots,
+ syncSlotPhasesFromRoadmap,
+} from '../utils/progressionGraphDraft'
+
+function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) {
+ const body = {}
+ const start = (startSituation || '').trim()
+ const target = (targetState || '').trim()
+ const notes = (roadmapNotes || '').trim()
+ if (start) body.start_situation = start
+ if (target) body.target_state = target
+ if (notes) body.roadmap_notes = notes
+ return body
+}
+
+function resolveDefaultFocusAreaId(targetSummary, focusAreas) {
+ const targetName = targetSummary?.focus_areas?.[0]
+ if (targetName && Array.isArray(focusAreas) && focusAreas.length) {
+ const norm = String(targetName).trim().toLowerCase()
+ const hit = focusAreas.find((fa) => String(fa.name || '').trim().toLowerCase() === norm)
+ if (hit?.id) return Number(hit.id)
+ }
+ return focusAreas?.[0]?.id ? Number(focusAreas[0].id) : null
+}
+
+export default function ProgressionGraphEditor({ graphId, embedded = false, onSaved }) {
+ const [graphMeta, setGraphMeta] = useState(null)
+ const [draft, setDraft] = useState(null)
+ const [busy, setBusy] = useState(false)
+ const [loadErr, setLoadErr] = useState('')
+ const [actionErr, setActionErr] = useState('')
+ const [matchNotice, setMatchNotice] = useState('')
+ const [pickContext, setPickContext] = useState(null)
+ const [pathQa, setPathQa] = useState(null)
+ const [gapFillOffers, setGapFillOffers] = useState([])
+ const [evaluating, setEvaluating] = useState(false)
+ const [matching, setMatching] = useState(false)
+ const [roadmapLoading, setRoadmapLoading] = useState(false)
+ const [startTargetLoading, setStartTargetLoading] = useState(false)
+ const [startTargetReady, setStartTargetReady] = useState(false)
+ const [semanticBrief, setSemanticBrief] = useState(null)
+ const [targetSummary, setTargetSummary] = useState(null)
+ const [focusAreas, setFocusAreas] = useState([])
+ const [skillsCatalog, setSkillsCatalog] = useState([])
+
+ const [activeOffer, setActiveOffer] = useState(null)
+ const [activeOfferSlotIndex, setActiveOfferSlotIndex] = useState(null)
+ const [gapPrepOpen, setGapPrepOpen] = useState(false)
+ const [gapPrepTitle, setGapPrepTitle] = useState('')
+ const [gapPrepStageGoal, setGapPrepStageGoal] = useState('')
+ const [gapPrepSupplements, setGapPrepSupplements] = useState('')
+ const [gapPrepFocusAreaId, setGapPrepFocusAreaId] = useState('')
+ const [gapPrepError, setGapPrepError] = useState('')
+ const [generatingOfferId, setGeneratingOfferId] = useState(null)
+ const [gapAiBusy, setGapAiBusy] = useState(false)
+ const [currentEdges, setCurrentEdges] = useState([])
+ const [slotQuickCreateIndex, setSlotQuickCreateIndex] = useState(null)
+ const [slotQuickCreateDraft, setSlotQuickCreateDraft] = useState(null)
+ const [slotQuickSaving, setSlotQuickSaving] = useState(false)
+ const [slotQuickError, setSlotQuickError] = useState('')
+ const [activePlanningContextLines, setActivePlanningContextLines] = useState([])
+
+ const loadGraph = useCallback(async () => {
+ if (!graphId) return
+ setBusy(true)
+ setLoadErr('')
+ try {
+ const [graph, edges] = await Promise.all([
+ api.getExerciseProgressionGraph(Number(graphId)),
+ api.listExerciseProgressionEdges(Number(graphId)),
+ ])
+ const edgeList = Array.isArray(edges) ? edges : []
+ setCurrentEdges(edgeList)
+ setGraphMeta(graph)
+ const hydrated = hydrateProgressionGraphDraft({
+ artifact: graph?.planning_roadmap,
+ edges: edgeList,
+ graphName: graph?.name,
+ })
+ setDraft(hydrated)
+ setStartTargetReady(
+ Boolean((hydrated.startSituation || '').trim() && (hydrated.targetState || '').trim()),
+ )
+ const findings = graph?.planning_roadmap?.last_findings
+ if (findings) setPathQa(findings)
+ } catch (e) {
+ setLoadErr(e.message || 'Graph konnte nicht geladen werden')
+ setDraft(null)
+ } finally {
+ setBusy(false)
+ }
+ }, [graphId])
+
+ useEffect(() => {
+ loadGraph()
+ }, [loadGraph])
+
+ useEffect(() => {
+ let cancelled = false
+ Promise.all([
+ api.listFocusAreas({ status: 'active' }),
+ api.listSkillsCatalog({ status: 'active' }),
+ ])
+ .then(([fa, sk]) => {
+ if (cancelled) return
+ setFocusAreas(Array.isArray(fa) ? fa : [])
+ setSkillsCatalog(Array.isArray(sk) ? sk : [])
+ })
+ .catch(() => {
+ if (!cancelled) {
+ setFocusAreas([])
+ setSkillsCatalog([])
+ }
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ const patchDraft = useCallback((patchFn) => {
+ setDraft((prev) => {
+ if (!prev) return prev
+ const next = patchFn(prev)
+ return { ...next, dirty: true }
+ })
+ }, [])
+
+ const gapContextParams = useMemo(() => {
+ if (!draft) return {}
+ return {
+ goalQuery: draft.goalQuery,
+ semanticBrief,
+ graphId,
+ pathSteps: slotsAsPathStepRows(draft),
+ editableMajorSteps: draft.majorSteps,
+ progressionRoadmap: draft.progressionRoadmap,
+ startSituation: draft.startSituation,
+ targetState: draft.targetState,
+ roadmapNotes: draft.roadmapNotes,
+ }
+ }, [draft, semanticBrief, graphId])
+
+ const handlePickExercise = async (exercise) => {
+ if (!pickContext || !exercise?.id) return
+ const { slotIndex, role } = pickContext
+ const entry = librarySlotExercise({
+ exerciseId: exercise.id,
+ exerciseTitle: exercise.title || `Übung #${exercise.id}`,
+ })
+ patchDraft((d) => {
+ const slots = d.slots.map((s, i) => {
+ if (i !== slotIndex) return s
+ if (role === 'primary') return { ...s, primary: entry }
+ const siblings = [...(s.siblings || [])]
+ if (!siblings.some((x) => x.exerciseId === entry.exerciseId)) siblings.push(entry)
+ return { ...s, siblings }
+ })
+ return syncProgressionRoadmapFromSlots({ ...d, slots })
+ })
+ setPickContext(null)
+ }
+
+ const handlePatchLearningGoal = (slotIndex, value) => {
+ patchDraft((d) => patchSlotInDraft(d, slotIndex, { learning_goal: value }))
+ }
+
+ const handlePatchPhase = (slotIndex, value) => {
+ patchDraft((d) => patchSlotInDraft(d, slotIndex, { phase: value }))
+ }
+
+ const handleMoveSlot = (slotIndex, dir) => {
+ patchDraft((d) => moveSlotInDraft(d, slotIndex, dir))
+ }
+
+ const handleRemoveSlot = (slotIndex) => {
+ if ((draft?.slots?.length || 0) <= 2) {
+ alert('Mindestens zwei Slots müssen bleiben.')
+ return
+ }
+ if (!window.confirm(`Slot ${slotIndex + 1} wirklich entfernen?`)) return
+ patchDraft((d) => removeSlotFromDraft(d, slotIndex))
+ }
+
+ const handleInsertAfter = (slotIndex) => {
+ if ((draft?.slots?.length || 0) >= SLOT_MAX) {
+ alert(`Maximal ${SLOT_MAX} Slots.`)
+ return
+ }
+ patchDraft((d) => insertSlotInDraft(d, slotIndex))
+ }
+
+ const handleAddSlot = () => {
+ if ((draft?.slots?.length || 0) >= SLOT_MAX) {
+ alert(`Maximal ${SLOT_MAX} Slots.`)
+ return
+ }
+ patchDraft((d) => addSlotToDraft(d))
+ }
+
+ const handleClearPrimary = (slotIndex) => {
+ patchDraft((d) => {
+ const slots = d.slots.map((s, i) =>
+ i === slotIndex
+ ? {
+ ...s,
+ primary: {
+ kind: 'empty',
+ exerciseId: null,
+ variantId: null,
+ exerciseTitle: '',
+ variantName: null,
+ proposalKey: null,
+ aiSuggestion: null,
+ },
+ siblings: [],
+ }
+ : s,
+ )
+ return syncProgressionRoadmapFromSlots({ ...d, slots })
+ })
+ }
+
+ const handleRemoveSibling = (slotIndex, sibIdx) => {
+ patchDraft((d) => {
+ const slots = d.slots.map((s, i) => {
+ if (i !== slotIndex) return s
+ return { ...s, siblings: s.siblings.filter((_, j) => j !== sibIdx) }
+ })
+ return { ...d, slots }
+ })
+ }
+
+ const validMajorSteps = useMemo(() => {
+ if (!draft?.slots) return []
+ return draft.slots.filter((s) => (s.learning_goal || '').trim().length >= 3)
+ }, [draft?.slots])
+
+ const runAnalyzeStartTarget = async () => {
+ const q = (draft?.goalQuery || '').trim()
+ if (q.length < 3) {
+ alert('Ziel-Anfrage: mindestens 3 Zeichen.')
+ return
+ }
+ setStartTargetLoading(true)
+ setActionErr('')
+ try {
+ const res = await api.suggestProgressionPath({
+ query: q,
+ max_steps: draft.maxSteps || 5,
+ include_llm_intent: false,
+ include_path_qa: false,
+ include_llm_path_qa: false,
+ include_path_reorder: false,
+ include_ai_gap_fill: false,
+ include_roadmap_preview: false,
+ include_llm_roadmap: false,
+ include_llm_start_target: true,
+ start_target_only: true,
+ progression_graph_id: Number(graphId),
+ ...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
+ })
+ const roadmap = res?.progression_roadmap
+ if (!roadmap) throw new Error('Keine Start/Ziel-Analyse in der Antwort')
+ setDraft((prev) => {
+ const structured = applyResolvedStructuredToDraft(
+ { ...prev, progressionRoadmap: roadmap },
+ roadmap,
+ )
+ return { ...structured, dirty: true }
+ })
+ setStartTargetReady(true)
+ setSemanticBrief(res?.semantic_brief_summary || null)
+ } catch (e) {
+ setActionErr(e.message || 'Start/Ziel-Analyse fehlgeschlagen')
+ } finally {
+ setStartTargetLoading(false)
+ }
+ }
+
+ const runRoadmapGenerate = async () => {
+ const q = (draft?.goalQuery || '').trim()
+ if (q.length < 3) {
+ alert('Ziel-Anfrage: mindestens 3 Zeichen.')
+ return
+ }
+ const fieldsEmpty = !(draft.startSituation || '').trim() && !(draft.targetState || '').trim()
+ setRoadmapLoading(true)
+ setActionErr('')
+ try {
+ const res = await api.suggestProgressionPath({
+ query: q,
+ max_steps: draft.maxSteps || 5,
+ include_llm_intent: true,
+ include_path_qa: false,
+ include_llm_path_qa: false,
+ include_path_reorder: false,
+ include_ai_gap_fill: false,
+ include_roadmap_preview: true,
+ include_llm_roadmap: true,
+ include_llm_start_target: fieldsEmpty,
+ roadmap_only: true,
+ progression_graph_id: Number(graphId),
+ ...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
+ })
+ const roadmap = res?.progression_roadmap
+ if (!roadmap) throw new Error('Keine Roadmap in der Antwort')
+ const majorCount = (roadmap?.roadmap?.major_steps || []).length
+ if (majorCount < SLOT_MIN) throw new Error('Roadmap hat zu wenig Stufen.')
+ const preservedArtifact = buildPlanningArtifactFromDraft(draft) || {}
+ let startSituation = draft.startSituation
+ let targetState = draft.targetState
+ let roadmapNotes = draft.roadmapNotes
+ if (fieldsEmpty) {
+ const patch = applyResolvedStructuredToDraft(
+ { startSituation, targetState, roadmapNotes },
+ roadmap,
+ )
+ startSituation = patch.startSituation
+ targetState = patch.targetState
+ roadmapNotes = patch.roadmapNotes
+ setStartTargetReady(true)
+ }
+ const hydrated = hydrateProgressionGraphDraft({
+ artifact: {
+ ...preservedArtifact,
+ goal_query: q,
+ progression_roadmap: roadmap,
+ start_situation: startSituation,
+ target_state: targetState,
+ roadmap_notes: roadmapNotes,
+ max_steps: majorCount || draft.maxSteps,
+ },
+ edges: currentEdges,
+ graphName: draft.graphName,
+ })
+ const withPhases = syncSlotPhasesFromRoadmap(hydrated, roadmap)
+ setDraft({ ...withPhases, goalQuery: q, maxSteps: majorCount || withPhases.maxSteps, dirty: true })
+ setSemanticBrief(res?.semantic_brief_summary || null)
+ } catch (e) {
+ setActionErr(e.message || 'Roadmap-Generierung fehlgeschlagen')
+ } finally {
+ setRoadmapLoading(false)
+ }
+ }
+
+ const runMatch = 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
+ }
+ setMatching(true)
+ setActionErr('')
+ 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),
+ })
+ 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
+ if (ms) {
+ setMatchNotice(
+ `Match: ${ms.library_matches ?? 0}/${ms.slot_count ?? '?'} Slots aus Bibliothek, ${ms.gap_fill_offer_count ?? 0} KI-Angebote.`,
+ )
+ }
+ 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)
+ }
+ } catch (e) {
+ setActionErr(e.message || 'Übungs-Match fehlgeschlagen')
+ } finally {
+ setMatching(false)
+ }
+ }
+
+ const runEvaluate = async () => {
+ const q = (draft?.goalQuery || '').trim()
+ if (q.length < 3) {
+ alert('Ziel-Anfrage: mindestens 3 Zeichen.')
+ return
+ }
+ setEvaluating(true)
+ setActionErr('')
+ 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),
+ })
+ 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)
+ } catch (e) {
+ setActionErr(e.message || 'Bewertung fehlgeschlagen')
+ } finally {
+ setEvaluating(false)
+ }
+ }
+
+ const handleSave = async () => {
+ if (!draft || !graphId) return
+ setBusy(true)
+ setActionErr('')
+ try {
+ await saveProgressionGraphDraft(api, graphId, { ...draft, lastFindings: pathQa })
+ await loadGraph()
+ if (typeof onSaved === 'function') await onSaved()
+ alert('Progressionsgraph gespeichert.')
+ } catch (e) {
+ setActionErr(e.message || 'Speichern fehlgeschlagen')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const handleApplyGapOffer = (offer, slotIndex) => {
+ setDraft((prev) => {
+ const next = applyGapOfferToDraft(prev, offer, { slotIndex })
+ return { ...next, dirty: true }
+ })
+ setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
+ }
+
+ const handleInsertGapSlot = (offer) => {
+ if ((draft?.slots?.length || 0) >= SLOT_MAX) {
+ alert(`Maximal ${SLOT_MAX} Slots — zuerst einen Slot entfernen.`)
+ return
+ }
+ setDraft((prev) => {
+ const next = applyGapOfferToDraft(prev, offer, { insertNewSlot: true })
+ return { ...next, dirty: true }
+ })
+ setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
+ }
+
+ const slotOfferContext = (slotIndex) => {
+ const slot = draft?.slots?.[slotIndex]
+ if (!draft || !slot) return null
+ const goalForAi =
+ buildSlotGapGoalForAi(draft, slotIndex, { goalQuery: draft.goalQuery }) ||
+ slot.learning_goal
+ const priorSlot =
+ slotIndex > 0 && draft.slots[slotIndex - 1]
+ ? draft.slots[slotIndex - 1]
+ : null
+ return {
+ offer_id: `slot-${slotIndex}`,
+ title_hint: slot.primary?.exerciseTitle || slot.learning_goal,
+ roadmap_major_step_index: slot.majorStepIndex,
+ phase: slot.phase,
+ source: 'roadmap_unfilled',
+ goal_for_ai: goalForAi,
+ sketch: goalForAi,
+ from_title: priorSlot?.primary?.exerciseTitle || null,
+ }
+ }
+
+ const openGapFillPrep = (offer, slotIndex = null) => {
+ const defaultFocus = resolveDefaultFocusAreaId(targetSummary, focusAreas)
+ setActiveOffer(offer)
+ setActiveOfferSlotIndex(slotIndex)
+ setGapPrepTitle((offer?.title_hint || '').trim())
+ setGapPrepStageGoal(initialStageLearningGoalFromOffer(offer, gapContextParams))
+ setGapPrepSupplements('')
+ setGapPrepFocusAreaId(defaultFocus ? String(defaultFocus) : '')
+ setActivePlanningContextLines(gapOfferContextDisplayLines(offer, gapContextParams))
+ setGapPrepError('')
+ setGapPrepOpen(true)
+ }
+
+ const runGapFillAiSuggest = async (offer, prep, slotIndex) => {
+ const title = (prep?.title || offer?.title_hint || '').trim()
+ if (title.length < 3) {
+ alert('Titel: mindestens 3 Zeichen.')
+ return
+ }
+ const supplements = (prep?.supplements || '').trim()
+ const stageGoal = (prep?.stageLearningGoal || '').trim()
+ let goalText = (offer?.goal_for_ai || offer?.sketch || '').trim()
+ if (supplements) {
+ goalText = `${goalText}\n\nTrainer-Ergänzungen:\n${supplements}`.trim()
+ }
+ const focusId =
+ prep?.focusAreaId != null && Number.isFinite(Number(prep.focusAreaId))
+ ? Number(prep.focusAreaId)
+ : resolveDefaultFocusAreaId(targetSummary, focusAreas)
+ if (!focusId) {
+ alert('Bitte einen Fokusbereich wählen.')
+ return
+ }
+ const focusRow = (focusAreas || []).find((x) => Number(x.id) === focusId)
+ const focusHint = (focusRow?.name || offer?.primary_topic || '').trim()
+
+ setGapAiBusy(true)
+ setGeneratingOfferId(offer?.offer_id || null)
+ setGapPrepError('')
+ setSlotQuickError('')
+ const contextParams = {
+ ...gapContextParams,
+ stageLearningGoalOverride: stageGoal,
+ gapTrainerSupplements: supplements,
+ }
+ setActivePlanningContextLines(gapOfferContextDisplayLines(offer, contextParams))
+ try {
+ const planningContext = buildPathGapPlanningContextForAi({
+ offer,
+ ...contextParams,
+ })
+ const aiRes = await api.suggestExerciseAi({
+ title,
+ goal: goalText || undefined,
+ execution: '',
+ preparation: '',
+ trainer_notes: supplements || '',
+ focus_area_hint: focusHint || undefined,
+ focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
+ planning_context: planningContext || undefined,
+ include_summary: true,
+ include_skills: true,
+ include_instructions: true,
+ })
+ const preview = buildQuickCreateAiPreview(aiRes, { sketchPlain: goalText })
+ if (!preview.hasSummaryProposal && !preview.hasInstructionChoices && !preview.hasSkillChoices) {
+ throw new Error('Die KI lieferte keinen verwertbaren Vorschlag.')
+ }
+ const aiDraft = aiPreviewToQuickCreateDraft(preview, {
+ title,
+ focusAreaId: focusId,
+ sketchPlain: goalText,
+ })
+ const enrichedOffer = {
+ ...offer,
+ proposal_title: title,
+ ai_suggestion: aiDraft,
+ has_ai_payload: true,
+ }
+ const resolvedSlot =
+ slotIndex != null && Number.isFinite(slotIndex)
+ ? slotIndex
+ : activeOfferSlotIndex != null && Number.isFinite(activeOfferSlotIndex)
+ ? activeOfferSlotIndex
+ : null
+ if (resolvedSlot != null) {
+ setSlotQuickCreateIndex(resolvedSlot)
+ setDraft((prev) => applyGapOfferToDraft(prev, enrichedOffer, { slotIndex: resolvedSlot }))
+ }
+ setSlotQuickCreateDraft(aiDraft)
+ setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
+ setGapPrepOpen(false)
+ } catch (e) {
+ setGapPrepError(e.message || 'KI-Anlage fehlgeschlagen')
+ } finally {
+ setGapAiBusy(false)
+ setGeneratingOfferId(null)
+ }
+ }
+
+ const openSlotQuickCreate = (slotIndex) => {
+ const slot = draft?.slots?.[slotIndex]
+ if (!slot) return
+ const primary = slot.primary
+ const offer = slotOfferContext(slotIndex)
+ setSlotQuickCreateIndex(slotIndex)
+ setSlotQuickError('')
+ setActiveOffer(offer)
+ setActiveOfferSlotIndex(slotIndex)
+ setActivePlanningContextLines(gapOfferContextDisplayLines(offer, gapContextParams))
+
+ if (primary?.kind === 'proposal' && primary.aiSuggestion) {
+ const focusId = resolveDefaultFocusAreaId(targetSummary, focusAreas)
+ const draftReady = ensureQuickCreateDraftFromAiSuggestion(primary.aiSuggestion, {
+ title: primary.exerciseTitle || slot.learning_goal,
+ focusAreaId: focusId,
+ sketchPlain: (offer?.goal_for_ai || slot.learning_goal || '').trim(),
+ })
+ if (draftReady) {
+ setSlotQuickCreateDraft(draftReady)
+ return
+ }
+ }
+
+ openGapFillPrep(offer, slotIndex)
+ }
+
+ const applySlotQuickCreate = async () => {
+ if (slotQuickCreateIndex == null || !slotQuickCreateDraft) return
+ setSlotQuickSaving(true)
+ setSlotQuickError('')
+ try {
+ const payload = buildQuickCreateExercisePayloadFromDraft(slotQuickCreateDraft)
+ const created = await api.createExercise(payload)
+ if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
+ setDraft((prev) => setSlotPrimaryLibrary(prev, slotQuickCreateIndex, created))
+ setSlotQuickCreateDraft(null)
+ setSlotQuickCreateIndex(null)
+ setActiveOffer(null)
+ setActiveOfferSlotIndex(null)
+ setActivePlanningContextLines([])
+ } catch (e) {
+ const msg = e.message || 'Übung konnte nicht angelegt werden'
+ setSlotQuickError(msg)
+ alert(msg)
+ } finally {
+ setSlotQuickSaving(false)
+ }
+ }
+
+ const submitGapFillPrep = async () => {
+ const title = (gapPrepTitle || '').trim()
+ if (title.length < 3) {
+ alert('Titel: mindestens 3 Zeichen.')
+ return
+ }
+ const focusId = parseInt(String(gapPrepFocusAreaId).trim(), 10)
+ if (!Number.isFinite(focusId) || focusId < 1) {
+ alert('Bitte einen Fokusbereich wählen.')
+ return
+ }
+ if (!activeOffer) return
+ await runGapFillAiSuggest(
+ activeOffer,
+ {
+ title,
+ stageLearningGoal: (gapPrepStageGoal || '').trim(),
+ supplements: (gapPrepSupplements || '').trim(),
+ focusAreaId: focusId,
+ },
+ activeOfferSlotIndex,
+ )
+ }
+
+ if (loadErr) {
+ return (
+
+
{loadErr}
+
+ Zurück
+
+
+ )
+ }
+
+ if (!draft) {
+ return (
+
+ )
+ }
+
+ return (
+
+ {!embedded ? (
+
+
+
+ {graphMeta?.name || draft.graphName || `Graph #${graphId}`}
+
+
+ Roadmap-Slots · KI-Match · Graph-Bewertung (max. {SLOT_MAX} Slots)
+
+
+
+ Zur Übersicht
+
+
+ ) : null}
+
+ {actionErr ? (
+
+ {actionErr}
+
+ ) : null}
+
+
+
+
+
Ziel & Roadmap
+
+
+
+ Startpunkt / Ausgangslage
+
+
+ Zielzustand
+
+
+ Ergänzungen (Fokus, Gruppe, Besonderheiten)
+
+
+
+ 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.
+
+
+
+ {startTargetLoading ? 'Analyse…' : 'Start/Ziel analysieren'}
+
+
+ {roadmapLoading ? 'Roadmap…' : 'Roadmap generieren'}
+
+ {startTargetReady ? (
+
+ Start/Ziel bereit
+
+ ) : null}
+
+ {matching ? 'Match…' : 'Übungen matchen'}
+
+
+ {busy ? 'Speichern…' : 'Graph speichern'}
+
+
+ {matchNotice ? (
+
{matchNotice}
+ ) : null}
+ {draft.dirty ? (
+
+ Ungespeicherte Änderungen
+
+ ) : null}
+
+
+
+
Slots ({draft.slots.length})
+ = SLOT_MAX}
+ onClick={handleAddSlot}
+ >
+ Slot am Ende
+
+
+
+ {draft.slots.map((slot, idx) => (
+
setPickContext({ slotIndex: i, role: 'primary' })}
+ onPickSibling={(i) => setPickContext({ slotIndex: i, role: 'sibling' })}
+ onClearPrimary={handleClearPrimary}
+ onRemoveSibling={handleRemoveSibling}
+ onPatchLearningGoal={handlePatchLearningGoal}
+ onPatchPhase={handlePatchPhase}
+ onMoveUp={(i) => handleMoveSlot(i, -1)}
+ onMoveDown={(i) => handleMoveSlot(i, 1)}
+ onRemoveSlot={handleRemoveSlot}
+ onInsertAfter={handleInsertAfter}
+ onCreateFromProposal={openSlotQuickCreate}
+ />
+ ))}
+
+
+
+
+
+ {pickContext ? (
+
setPickContext(null)}
+ onSelectExercise={handlePickExercise}
+ />
+ ) : null}
+
+ {
+ if (slotQuickSaving) return
+ setSlotQuickCreateDraft(null)
+ setSlotQuickError('')
+ if (activeOffer) {
+ setGapPrepOpen(true)
+ } else {
+ setActivePlanningContextLines([])
+ }
+ }}
+ planningContextLines={activePlanningContextLines}
+ onApply={applySlotQuickCreate}
+ focusAreas={focusAreas}
+ skillsCatalog={skillsCatalog}
+ dialogTitle="Progressions-Slot — KI-Entwurf bearbeiten"
+ hint="Texte prüfen und anpassen, dann als Übung speichern — sie wird dem Slot zugeordnet."
+ applyLabel={slotQuickSaving ? 'Wird angelegt …' : 'Anlegen und Slot zuweisen'}
+ applyDisabled={slotQuickSaving}
+ zIndex={2100}
+ />
+
+ {
+ if (gapAiBusy) return
+ setGapPrepOpen(false)
+ setGapPrepError('')
+ }}
+ title={gapPrepTitle}
+ onTitleChange={setGapPrepTitle}
+ stageLearningGoal={gapPrepStageGoal}
+ onStageLearningGoalChange={setGapPrepStageGoal}
+ supplements={gapPrepSupplements}
+ onSupplementsChange={setGapPrepSupplements}
+ focusAreaId={gapPrepFocusAreaId}
+ onFocusAreaChange={setGapPrepFocusAreaId}
+ focusAreas={focusAreas}
+ contextLines={gapOfferContextDisplayLines(activeOffer, gapContextParams)}
+ error={gapPrepError}
+ busy={gapAiBusy}
+ onSubmit={submitGapFillPrep}
+ />
+
+
+
+ )
+}
diff --git a/frontend/src/components/ProgressionGraphListCard.jsx b/frontend/src/components/ProgressionGraphListCard.jsx
new file mode 100644
index 0000000..fb63381
--- /dev/null
+++ b/frontend/src/components/ProgressionGraphListCard.jsx
@@ -0,0 +1,126 @@
+import React from 'react'
+import { GitBranch, Lock, Users, Globe, Pencil, Trash2 } from 'lucide-react'
+import { graphGoalQueryFromRow, graphSlotCountFromRow } from '../utils/progressionGraphDraft'
+import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels'
+
+const VIS_LABELS = { official: 'Global', club: 'Verein', private: 'Privat' }
+
+function visibilityLabel(v) {
+ return VIS_LABELS[v] || v || '—'
+}
+
+function cardClassName(graph, userId) {
+ const vis = graph.visibility || 'private'
+ const visKey = vis === 'official' || vis === 'club' || vis === 'private' ? vis : 'private'
+ const mine = userId != null && Number(graph.created_by) === Number(userId)
+ return ['card', 'exercise-card', 'progression-graph-card', `exercise-card--scope-${visKey}`, mine ? 'exercise-card--mine' : '']
+ .filter(Boolean)
+ .join(' ')
+}
+
+function VisIcon({ visibility }) {
+ if (visibility === 'official') return
+ if (visibility === 'club') return
+ return
+}
+
+export default function ProgressionGraphListCard({
+ graph,
+ userId = null,
+ onOpen,
+ onDelete,
+ disabled = false,
+}) {
+ const goalQuery = graphGoalQueryFromRow(graph)
+ const slotCount = graphSlotCountFromRow(graph)
+ const edgesCount = Number(graph.edges_count) || 0
+ const description = (graph.description || '').trim()
+
+ return (
+
+
+
+
+ onOpen?.(graph)}
+ >
+ {graph.name || `Graph #${graph.id}`}
+
+
+
+
+
+
+ {visibilityLabel(graph.visibility)}
+
+ {slotCount != null ? (
+ {slotCount} Stufen
+ ) : null}
+
+ {edgesCount === 1 ? '1 Kante' : `${edgesCount} Kanten`}
+
+
+
+ {goalQuery ? (
+
+ Ziel:
+ {goalQuery.length > 160 ? `${goalQuery.slice(0, 160)}…` : goalQuery}
+
+ ) : description ? (
+
+ {description.length > 160 ? `${description.slice(0, 160)}…` : description}
+
+ ) : (
+
+ Noch kein Planungsziel hinterlegt — öffnen und Roadmap anlegen.
+
+ )}
+
+
+
+
onOpen?.(graph)}
+ >
+
+ Bearbeiten
+
+
onDelete?.(graph)}
+ >
+
+ Löschen
+
+
+
+ )
+}
diff --git a/frontend/src/components/ProgressionSlotCard.jsx b/frontend/src/components/ProgressionSlotCard.jsx
new file mode 100644
index 0000000..c0017c0
--- /dev/null
+++ b/frontend/src/components/ProgressionSlotCard.jsx
@@ -0,0 +1,242 @@
+/**
+ * Einzelner Roadmap-Slot im Progressionsgraph-Editor.
+ */
+import React from 'react'
+import { Link } from 'react-router-dom'
+import { ROADMAP_PHASES } from '../utils/progressionGraphDraft'
+
+function exerciseLabel(entry) {
+ if (!entry || entry.kind === 'empty') return '— noch leer —'
+ if (entry.kind === 'proposal') return entry.exerciseTitle || 'KI-Entwurf'
+ return entry.exerciseTitle || `Übung #${entry.exerciseId}`
+}
+
+export default function ProgressionSlotCard({
+ slot,
+ slotIndex,
+ slotCount = 1,
+ onPickPrimary,
+ onPickSibling,
+ onClearPrimary,
+ onRemoveSibling,
+ onPatchLearningGoal,
+ onPatchPhase,
+ onMoveUp,
+ onMoveDown,
+ onRemoveSlot,
+ onInsertAfter,
+ onCreateFromProposal,
+ disabled = false,
+}) {
+ const { primary, siblings = [], phase, learning_goal: learningGoal } = slot
+
+ return (
+
+
+
+
Slot {slotIndex + 1}
+
+
+
+ {primary.kind === 'empty' ? 'leer' : primary.kind === 'proposal' ? 'KI-Entwurf' : 'Bibliothek'}
+
+ onMoveUp(slotIndex)}
+ title="Nach oben"
+ >
+ ↑
+
+ = slotCount - 1}
+ onClick={() => onMoveDown(slotIndex)}
+ title="Nach unten"
+ >
+ ↓
+
+ onRemoveSlot(slotIndex)}
+ title="Slot entfernen"
+ >
+ ✕
+
+
+
+
+
+
+ Lernziel (Major Step)
+ onPatchLearningGoal(slotIndex, e.target.value)}
+ placeholder="Was soll in dieser Stufe erreicht werden?"
+ />
+
+
+ Phase
+ onPatchPhase(slotIndex, e.target.value)}
+ >
+ {ROADMAP_PHASES.map((p) => (
+
+ {p}
+
+ ))}
+
+
+
+
+
+
Hauptpfad (primary)
+
+ {primary.kind === 'library' && primary.exerciseId ? (
+ {exerciseLabel(primary)}
+ ) : (
+ exerciseLabel(primary)
+ )}
+ {primary.variantName ? (
+ {` · ${primary.variantName}`}
+ ) : null}
+
+ {primary.kind === 'proposal' && primary.aiSuggestion?.summary?.text ? (
+
+ {primary.aiSuggestion.summary.text.slice(0, 220)}
+ {primary.aiSuggestion.summary.text.length > 220 ? '…' : ''}
+
+ ) : null}
+
+ onPickPrimary(slotIndex)}
+ >
+ {primary.kind === 'empty' ? 'Übung wählen' : 'Übung ändern'}
+
+ {primary.kind === 'proposal' && typeof onCreateFromProposal === 'function' ? (
+ onCreateFromProposal(slotIndex)}
+ >
+ {primary.aiSuggestion ? 'Als Übung anlegen' : 'Mit KI anlegen'}
+
+ ) : null}
+ {primary.kind !== 'empty' ? (
+ onClearPrimary(slotIndex)}
+ >
+ Leeren
+
+ ) : null}
+
+
+
+
+
Schwestern (Alternativen)
+ {siblings.length === 0 ? (
+
Keine Schwestern.
+ ) : (
+
+ {siblings.map((sib, sibIdx) => (
+
+
+ {sib.kind === 'library' && sib.exerciseId ? (
+ {exerciseLabel(sib)}
+ ) : (
+ exerciseLabel(sib)
+ )}
+
+ onRemoveSibling(slotIndex, sibIdx)}
+ >
+ Entfernen
+
+
+ ))}
+
+ )}
+
+ onPickSibling(slotIndex)}
+ title={primary.kind !== 'library' ? 'Zuerst eine Hauptübung wählen' : undefined}
+ >
+ Schwester hinzufügen
+
+ = 10}
+ onClick={() => onInsertAfter(slotIndex)}
+ >
+ Slot darunter einfügen
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/exercises/ExerciseGapFillPrepModal.jsx b/frontend/src/components/exercises/ExerciseGapFillPrepModal.jsx
new file mode 100644
index 0000000..196982a
--- /dev/null
+++ b/frontend/src/components/exercises/ExerciseGapFillPrepModal.jsx
@@ -0,0 +1,186 @@
+import React, { useEffect } from 'react'
+
+/**
+ * Vorbereitung vor KI-Übungsanlage aus Pfad-Lücke: Kontext prüfen, Ergänzungen mitgeben.
+ */
+export default function ExerciseGapFillPrepModal({
+ open,
+ onClose,
+ offer = null,
+ contextLines = [],
+ title = '',
+ onTitleChange,
+ stageLearningGoal = '',
+ onStageLearningGoalChange,
+ supplements = '',
+ onSupplementsChange,
+ focusAreaId = '',
+ onFocusAreaChange,
+ focusAreas = [],
+ busy = false,
+ error = '',
+ onSubmit,
+}) {
+ useEffect(() => {
+ if (!open) return undefined
+ const onKey = (e) => {
+ if (e.key === 'Escape' && !busy) {
+ e.preventDefault()
+ onClose()
+ }
+ }
+ window.addEventListener('keydown', onKey)
+ return () => window.removeEventListener('keydown', onKey)
+ }, [open, busy, onClose])
+
+ if (!open || !offer) return null
+
+ const readOnlyLines = (contextLines || []).filter(
+ (line) => line.label !== 'Stufen-Lernziel',
+ )
+
+ return (
+ {
+ if (e.target === e.currentTarget && !busy) onClose()
+ }}
+ >
+
e.stopPropagation()}
+ style={{ maxWidth: '680px' }}
+ >
+
+
+ Übung mit KI vorbereiten
+
+
+ Schließen
+
+
+
+
+ Prüfen und anpassen, was an die KI geht. Ergänzungen fließen in Ziel, Trainerhinweise und
+ Planungskontext ein — erst danach wird der Entwurf erzeugt.
+
+
+ {readOnlyLines.length > 0 ? (
+
+
Pfad-Kontext (aus Roadmap)
+
+ {readOnlyLines.map(({ label, value }) => (
+
+
{label}
+ {value}
+
+ ))}
+
+
+ ) : null}
+
+
+
+
+ Titel-Vorschlag *
+
+ onTitleChange(e.target.value)}
+ disabled={busy}
+ maxLength={280}
+ />
+
+
+
+ Stufen-Lernziel (anpassbar)
+
+
+
+
+ Ergänzungen / Anpassungen für die KI
+
+
+
+
+ Fokusbereich *
+
+ onFocusAreaChange(e.target.value)}
+ disabled={busy}
+ >
+ Bitte wählen …
+ {(focusAreas || []).map((fa) => (
+
+ {fa.name}
+
+ ))}
+
+
+
+
+ {offer.from_title && offer.to_title ? (
+
+ Einordnung: zwischen „{offer.from_title}“ und „{offer.to_title}“
+
+ ) : null}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+ Abbrechen
+
+
+ {busy ? 'KI erstellt Entwurf …' : 'KI-Entwurf erstellen'}
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/exercises/ExercisesListPageRoot.jsx b/frontend/src/components/exercises/ExercisesListPageRoot.jsx
index 361a38a..ea03bee 100644
--- a/frontend/src/components/exercises/ExercisesListPageRoot.jsx
+++ b/frontend/src/components/exercises/ExercisesListPageRoot.jsx
@@ -95,6 +95,7 @@ function ExercisesListPageRoot() {
const [quickSaving, setQuickSaving] = useState(false)
const [quickAiError, setQuickAiError] = useState('')
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
+ const progressionPanelRef = useRef(null)
const planningKi = usePlanningExerciseSuggestSearch({
enabled: pageTab === 'list' && aiQuickCreateEnabled,
@@ -653,7 +654,13 @@ function ExercisesListPageRoot() {
)}
) : (
-
+ progressionPanelRef.current?.openCreateDialog?.()}
+ >
+ + Neu
+
)}
@@ -676,7 +683,7 @@ function ExercisesListPageRoot() {
}
>
-
+
) : (
<>
diff --git a/frontend/src/pages/ProgressionGraphEditPage.jsx b/frontend/src/pages/ProgressionGraphEditPage.jsx
new file mode 100644
index 0000000..d2a1e7d
--- /dev/null
+++ b/frontend/src/pages/ProgressionGraphEditPage.jsx
@@ -0,0 +1,15 @@
+import React from 'react'
+import { Navigate, useParams } from 'react-router-dom'
+
+/** Alte Deep-Links → Übungen-Liste mit Graph-Auswahl. */
+export default function ProgressionGraphEditPage() {
+ const { id } = useParams()
+ const graphId = Number(id)
+ return (
+ 0 ? { progressionGraphId: graphId } : undefined}
+ />
+ )
+}
diff --git a/frontend/src/utils/exerciseAiQuickCreate.js b/frontend/src/utils/exerciseAiQuickCreate.js
index 46f9b89..298a364 100644
--- a/frontend/src/utils/exerciseAiQuickCreate.js
+++ b/frontend/src/utils/exerciseAiQuickCreate.js
@@ -166,6 +166,24 @@ export function describeAiSkillRowForPreview(row, skillsCatalog) {
}
/** KI-Vorschau → bearbeitbarer Entwurf (Rich-Text-Felder). */
+/** Rohes API-ai_suggestion oder bereits bearbeiteter Entwurf → Preview-Entwurf. */
+export function ensureQuickCreateDraftFromAiSuggestion(
+ aiSuggestion,
+ { title = '', focusAreaId = '', sketchPlain = '' } = {},
+) {
+ if (!aiSuggestion || typeof aiSuggestion !== 'object') return null
+ if (aiSuggestion.instructionFields) return aiSuggestion
+ const preview = buildQuickCreateAiPreview(aiSuggestion, { sketchPlain })
+ if (
+ !preview.hasSummaryProposal &&
+ !preview.hasInstructionChoices &&
+ !preview.hasSkillChoices
+ ) {
+ return null
+ }
+ return aiPreviewToQuickCreateDraft(preview, { title, focusAreaId, sketchPlain })
+}
+
export function aiPreviewToQuickCreateDraft(preview, { title, focusAreaId, sketchPlain }) {
const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain)
const instructionFields = {
diff --git a/frontend/src/utils/planningContextForExerciseAi.js b/frontend/src/utils/planningContextForExerciseAi.js
index 3896470..1d1da9e 100644
--- a/frontend/src/utils/planningContextForExerciseAi.js
+++ b/frontend/src/utils/planningContextForExerciseAi.js
@@ -2,6 +2,8 @@
* Planungs-KI Phase D: strukturierter Kontext für suggestExerciseAi.
*/
+import { slotsAsPathStepRows } from './progressionGraphDraft.js'
+
export function buildPickerPlanningContextForAi({
planningContextSummary = null,
planningContext = null,
@@ -30,6 +32,109 @@ export function buildPickerPlanningContextForAi({
return Object.fromEntries(Object.entries(ctx).filter(([, v]) => v != null && v !== ''))
}
+function majorIndexFromStep(step) {
+ const raw = step?.roadmap_major_step_index ?? step?.roadmapMajorStepIndex
+ if (raw == null || !Number.isFinite(Number(raw))) return null
+ return Number(raw)
+}
+
+function priorPathStepsBeforeMajor(pathSteps, majorIdx) {
+ if (majorIdx == null || !Number.isFinite(Number(majorIdx))) return []
+ const mi = Number(majorIdx)
+ return (pathSteps || [])
+ .filter((s) => {
+ const idx = majorIndexFromStep(s)
+ return idx != null && idx < mi
+ })
+ .sort((a, b) => (majorIndexFromStep(a) || 0) - (majorIndexFromStep(b) || 0))
+}
+
+function stepDisplayFields(step) {
+ if (!step) return null
+ const title = String(step.title || step.exerciseTitle || '').trim()
+ const learningGoal = String(
+ step.roadmap_learning_goal || step.roadmapLearningGoal || step.learning_goal || '',
+ ).trim()
+ const phase = String(step.roadmap_phase || step.roadmapPhase || step.phase || '').trim()
+ const startState = String(step.roadmap_start_state || step.start_state || '').trim()
+ const targetState = String(step.roadmap_target_state || step.target_state || '').trim()
+ const criteria = Array.isArray(step.success_criteria)
+ ? step.success_criteria.map((x) => String(x || '').trim()).filter(Boolean).slice(0, 4)
+ : []
+ const majorStepIndex = majorIndexFromStep(step)
+ const out = {
+ title: title || null,
+ learning_goal: learningGoal || null,
+ start_state: startState || null,
+ target_state: targetState || null,
+ phase: phase || null,
+ success_criteria: criteria.length ? criteria : null,
+ major_step_index: majorStepIndex,
+ }
+ const hasData = Object.values(out).some((v) => v != null && v !== '')
+ return hasData ? out : null
+}
+
+export function buildProgressionEntryState({
+ majorStepIndex = null,
+ priorSteps = [],
+ startSituation = '',
+ currentStageStart = '',
+} = {}) {
+ const priorCompact = (priorSteps || [])
+ .map(stepDisplayFields)
+ .filter(Boolean)
+
+ const achievements = []
+ const detailLines = []
+ for (const p of priorCompact) {
+ if (Array.isArray(p.success_criteria) && p.success_criteria.length) {
+ achievements.push(...p.success_criteria)
+ } else if (p.learning_goal) {
+ achievements.push(p.learning_goal)
+ }
+
+ const labelParts = []
+ if (p.major_step_index != null) labelParts.push(`Stufe ${p.major_step_index + 1}`)
+ if (p.phase) labelParts.push(`(${p.phase})`)
+ if (p.title) labelParts.push(`„${p.title}"`)
+ const prefix = labelParts.length ? labelParts.join(' ') : 'Vorstufe'
+ const achieved =
+ p.target_state ||
+ (Array.isArray(p.success_criteria) && p.success_criteria.length
+ ? p.success_criteria.join('; ')
+ : '') ||
+ p.learning_goal ||
+ ''
+ if (achieved) detailLines.push(`${prefix}: erreicht — ${achieved}`)
+ }
+
+ let entryState = (currentStageStart || '').trim()
+ if (!entryState && priorCompact.length) {
+ const immediate = priorCompact[priorCompact.length - 1]
+ entryState =
+ immediate.target_state ||
+ (Array.isArray(immediate.success_criteria) && immediate.success_criteria.length
+ ? immediate.success_criteria.join('; ')
+ : '') ||
+ immediate.learning_goal ||
+ ''
+ } else if (!entryState && (startSituation || '').trim()) {
+ entryState = startSituation.trim()
+ }
+
+ if (priorCompact.length && (startSituation || '').trim() && !entryState) {
+ detailLines.unshift(`Ausgangsbasis Pfad: ${startSituation.trim()}`)
+ }
+
+ const out = {}
+ if (entryState) out.entry_state = entryState
+ if (detailLines.length) out.entry_state_detail = detailLines.join('\n')
+ if (priorCompact.length) out.prior_steps = priorCompact.slice(0, 6)
+ if (achievements.length) out.prior_achievements = [...new Set(achievements)].slice(0, 8)
+ return out
+}
+
function stageSpecForMajorIndex(progressionRoadmap, majorIdx) {
if (majorIdx == null || !progressionRoadmap) return null
const specs = progressionRoadmap?.stage_specs
@@ -53,15 +158,28 @@ export function buildPathGapPlanningContextForAi({
startSituation = '',
targetState = '',
roadmapNotes = '',
+ stageLearningGoalOverride = '',
+ gapTrainerSupplements = '',
} = {}) {
- const afterIdx = Number(offer?.insert_after_index)
- const stepA = Number.isFinite(afterIdx) && afterIdx >= 0 ? pathSteps[afterIdx] : null
- const stepB =
- Number.isFinite(afterIdx) && afterIdx >= 0 ? pathSteps[afterIdx + 1] : null
const majorIdxRaw =
offer?.roadmap_major_step_index ?? offer?.gap?.roadmap_major_step_index
const majorIdx =
majorIdxRaw != null && Number.isFinite(Number(majorIdxRaw)) ? Number(majorIdxRaw) : null
+ const priorSteps = majorIdx != null ? priorPathStepsBeforeMajor(pathSteps, majorIdx) : []
+ const afterIdx = Number(offer?.insert_after_index)
+ const stepA =
+ priorSteps.length > 0
+ ? priorSteps[priorSteps.length - 1]
+ : Number.isFinite(afterIdx) && afterIdx >= 0
+ ? pathSteps[afterIdx]
+ : null
+ const stepB =
+ majorIdx != null
+ ? (pathSteps || []).find((s) => majorIndexFromStep(s) === majorIdx + 1) ||
+ (Number.isFinite(afterIdx) && afterIdx >= 0 ? pathSteps[afterIdx + 1] : null)
+ : Number.isFinite(afterIdx) && afterIdx >= 0
+ ? pathSteps[afterIdx + 1]
+ : null
const majorStep =
majorIdx != null && editableMajorSteps[majorIdx] ? editableMajorSteps[majorIdx] : null
const stageSpec = stageSpecForMajorIndex(progressionRoadmap, majorIdx)
@@ -90,6 +208,13 @@ export function buildPathGapPlanningContextForAi({
)
}
+ const entryState = buildProgressionEntryState({
+ majorStepIndex: majorIdx,
+ priorSteps,
+ startSituation: start,
+ currentStageStart: stageSpec?.start_state || '',
+ })
+
const ctx = {
source: 'progression_path_gap_fill',
goal_query: (goalQuery || '').trim() || null,
@@ -105,7 +230,11 @@ export function buildPathGapPlanningContextForAi({
start_situation: start,
target_state: target,
roadmap_notes: notes,
- stage_learning_goal: stageSpec?.learning_goal || null,
+ stage_learning_goal:
+ (stageLearningGoalOverride || '').trim() || stageSpec?.learning_goal || null,
+ stage_start_state: stageSpec?.start_state || null,
+ stage_target_state: stageSpec?.target_state || null,
+ gap_trainer_supplements: (gapTrainerSupplements || '').trim() || null,
stage_phase: stageSpec?.phase || majorStep?.phase || null,
stage_exercise_type: stageSpec?.exercise_type || null,
stage_load_profile: Array.isArray(stageSpec?.load_profile)
@@ -121,8 +250,9 @@ export function buildPathGapPlanningContextForAi({
? ga.success_criteria.slice(0, 4)
: null,
skill_hints: skillHints.length ? skillHints : null,
- neighbor_before_title: stepA?.exerciseTitle || offer?.from_title || null,
- neighbor_after_title: stepB?.exerciseTitle || offer?.to_title || null,
+ neighbor_before_title: stepA?.exerciseTitle || stepA?.title || offer?.from_title || null,
+ neighbor_after_title: stepB?.exerciseTitle || stepB?.title || offer?.to_title || null,
+ ...entryState,
path_step_count: Array.isArray(pathSteps) ? pathSteps.length : 0,
major_step_count:
editableMajorSteps?.length ||
@@ -144,7 +274,14 @@ export function gapOfferContextDisplayLines(offer, fallbackParams = null) {
const v = String(value || '').trim()
if (v) lines.push({ label, value: v })
}
- push('Ausgangslage (Pfad)', raw.start_situation)
+ push('Eingangszustand (Vorstufen)', raw.entry_state)
+ if (raw.entry_state_detail && raw.entry_state_detail !== raw.entry_state) {
+ push('Bisheriger Pfad', raw.entry_state_detail)
+ }
+ if (Array.isArray(raw.prior_achievements) && raw.prior_achievements.length) {
+ push('Erreichte Voraussetzungen', raw.prior_achievements.slice(0, 6).join(' · '))
+ }
+ push('Ausgangslage (gesamter Pfad)', raw.start_situation)
push('Gesamtziel (Pfad)', raw.target_state)
push('Ergänzungen', raw.roadmap_notes)
push('Stufen-Lernziel', raw.stage_learning_goal || raw.roadmap_learning_goal)
@@ -158,5 +295,56 @@ export function gapOfferContextDisplayLines(offer, fallbackParams = null) {
if (Array.isArray(raw.skill_hints) && raw.skill_hints.length) {
push('Fähigkeiten-Fokus', raw.skill_hints.slice(0, 3).join(' · '))
}
+ if (Array.isArray(raw.expected_skills) && raw.expected_skills.length) {
+ const names = raw.expected_skills
+ .map((s) => String(s?.skill_name || '').trim())
+ .filter(Boolean)
+ .slice(0, 5)
+ if (names.length) push('Erwartete Fähigkeiten', names.join(' · '))
+ }
+ push('Trainer-Ergänzungen', raw.gap_trainer_supplements)
return lines
}
+
+/** Zieltext für KI aus Slot-Kontext (Graph-Editor ohne API-Offer). */
+export function buildSlotGapGoalForAi(draft, slotIndex, { goalQuery = '' } = {}) {
+ const slot = draft?.slots?.[slotIndex]
+ if (!slot) return ''
+ const pathSteps = slotsAsPathStepRows(draft)
+ const majorIdx = slot.majorStepIndex
+ const priorSteps = priorPathStepsBeforeMajor(pathSteps, majorIdx)
+ const start = (draft.startSituation || '').trim()
+ const stageSpec =
+ majorIdx != null && draft.progressionRoadmap
+ ? stageSpecForMajorIndex(draft.progressionRoadmap, majorIdx)
+ : null
+ const entry = buildProgressionEntryState({
+ majorStepIndex: majorIdx,
+ priorSteps,
+ startSituation: start,
+ currentStageStart: stageSpec?.start_state || '',
+ })
+ const parts = [
+ goalQuery ? `Planungsziel (gesamter Pfad): ${goalQuery}` : '',
+ entry.entry_state
+ ? `Eingangszustand (erreichte Voraussetzungen): ${entry.entry_state}`
+ : start
+ ? `Ausgangslage (Pfad): ${start}`
+ : '',
+ entry.entry_state_detail && entry.entry_state_detail !== entry.entry_state
+ ? `Bisheriger Pfad:\n${entry.entry_state_detail}`
+ : '',
+ (slot.learning_goal || '').trim()
+ ? `Lernziel dieser Roadmap-Stufe: ${(slot.learning_goal || '').trim()}`
+ : '',
+ (slot.phase || '').trim() ? `Entwicklungsphase: ${slot.phase}` : '',
+ 'Die Übung baut didaktisch auf den Vorstufen auf — Voraussetzungen explizit benennen, messbares Stufenziel.',
+ ].filter(Boolean)
+ return parts.join('\n\n').trim()
+}
+
+export function initialStageLearningGoalFromOffer(offer, fallbackParams = null) {
+ const lines = gapOfferContextDisplayLines(offer, fallbackParams)
+ const hit = lines.find((l) => l.label === 'Stufen-Lernziel')
+ return hit?.value || (offer?.title_hint || '').trim()
+}
diff --git a/frontend/src/utils/progressionGraphDraft.js b/frontend/src/utils/progressionGraphDraft.js
new file mode 100644
index 0000000..4c57960
--- /dev/null
+++ b/frontend/src/utils/progressionGraphDraft.js
@@ -0,0 +1,1024 @@
+/**
+ * Progressionsgraph Slot-Editor — Draft-Hydration und Speichern (Phase B).
+ */
+
+export const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion']
+export const SLOT_MAX = 10
+export const SLOT_MIN = 2
+export const PLANNING_ARTIFACT_SCHEMA = 1
+
+/** Start/Ziel/Ergänzungen aus KI-Roadmap-Antwort (resolved_structured). */
+export function resolvedStructuredFromRoadmap(progressionRoadmap) {
+ const rs = progressionRoadmap?.resolved_structured
+ if (!rs) return null
+ const patch = {}
+ if (rs.start_situation) patch.startSituation = String(rs.start_situation)
+ if (rs.target_state) patch.targetState = String(rs.target_state)
+ if (rs.roadmap_notes) patch.roadmapNotes = String(rs.roadmap_notes)
+ return Object.keys(patch).length ? patch : null
+}
+
+export function applyResolvedStructuredToDraft(draft, progressionRoadmap, { onlyIfEmpty = false } = {}) {
+ const patch = resolvedStructuredFromRoadmap(progressionRoadmap)
+ if (!patch) return draft
+ const next = { ...draft }
+ if (patch.startSituation && (!onlyIfEmpty || !(draft.startSituation || '').trim())) {
+ next.startSituation = patch.startSituation
+ }
+ if (patch.targetState && (!onlyIfEmpty || !(draft.targetState || '').trim())) {
+ next.targetState = patch.targetState
+ }
+ if (patch.roadmapNotes && (!onlyIfEmpty || !(draft.roadmapNotes || '').trim())) {
+ next.roadmapNotes = patch.roadmapNotes
+ }
+ return { ...next, dirty: true }
+}
+
+export function graphGoalQueryFromRow(graph) {
+ const art = graph?.planning_roadmap
+ if (!art || typeof art !== 'object') return ''
+ return (art.goal_query || '').trim()
+}
+
+export function graphSlotCountFromRow(graph) {
+ const art = graph?.planning_roadmap
+ if (!art || typeof art !== 'object') return null
+ const slots = art.slot_contents
+ if (Array.isArray(slots) && slots.length) return slots.length
+ const majors = art.progression_roadmap?.roadmap?.major_steps
+ if (Array.isArray(majors) && majors.length) return majors.length
+ const ms = Number(art.max_steps)
+ return Number.isFinite(ms) && ms > 0 ? ms : null
+}
+
+const OFFER_SOURCE_LABELS = {
+ unfilled_gap: 'Lücke',
+ off_topic: 'Themenfremd',
+ llm_suggested: 'QS-Empfehlung',
+ roadmap_unfilled: 'Roadmap-Stufe',
+}
+
+export function offerSourceLabel(source) {
+ return OFFER_SOURCE_LABELS[source] || source || 'Angebot'
+}
+
+function createEmptySlot(index) {
+ const phase = ROADMAP_PHASES[Math.min(index, ROADMAP_PHASES.length - 1)]
+ return {
+ majorStepIndex: index,
+ phase,
+ learning_goal: '',
+ consolidates: [],
+ rationale: '',
+ load_profile: [],
+ success_criteria: [],
+ anti_patterns: [],
+ exercise_type: '',
+ primary: emptySlotExercise(),
+ siblings: [],
+ }
+}
+
+function majorStepFromSlot(slot, index) {
+ return {
+ index,
+ phase: slot.phase || ROADMAP_PHASES[Math.min(index, ROADMAP_PHASES.length - 1)],
+ learning_goal: slot.learning_goal || '',
+ consolidates: slot.consolidates || [],
+ rationale: slot.rationale || '',
+ load_profile: slot.load_profile || [],
+ success_criteria: slot.success_criteria || [],
+ anti_patterns: slot.anti_patterns || [],
+ exercise_type: slot.exercise_type || '',
+ }
+}
+
+export function reindexSlots(slots) {
+ return (slots || []).map((slot, i) => ({
+ ...slot,
+ majorStepIndex: i,
+ primary: { ...slot.primary },
+ siblings: [...(slot.siblings || [])],
+ }))
+}
+
+/** progression_roadmap aus aktuellen Slots ableiten (nach Verschieben/Hinzufügen). */
+export function syncProgressionRoadmapFromSlots(draft) {
+ const slots = reindexSlots(draft.slots || [])
+ const existing = draft.progressionRoadmap || {}
+ const major_steps = slots.map((s, i) => ({
+ index: i,
+ phase: s.phase || 'vertiefung',
+ learning_goal: (s.learning_goal || '').trim(),
+ consolidates: s.consolidates || [],
+ rationale: s.rationale || '',
+ }))
+ const stage_specs = slots.map((s, i) => ({
+ major_step_index: i,
+ learning_goal: (s.learning_goal || '').trim(),
+ load_profile: Array.isArray(s.load_profile) ? s.load_profile : [],
+ exercise_type: (s.exercise_type || '').trim(),
+ success_criteria: Array.isArray(s.success_criteria) ? s.success_criteria : [],
+ anti_patterns: Array.isArray(s.anti_patterns) ? s.anti_patterns : [],
+ }))
+ return {
+ ...draft,
+ slots,
+ majorSteps: slots.map(majorStepFromSlot),
+ maxSteps: slots.length,
+ progressionRoadmap: {
+ ...existing,
+ major_step_count: slots.length,
+ max_steps: slots.length,
+ roadmap: { ...(existing.roadmap || {}), major_steps },
+ stage_specs,
+ },
+ }
+}
+
+export function moveSlotInDraft(draft, slotIndex, direction) {
+ const slots = [...(draft.slots || [])]
+ const j = slotIndex + direction
+ if (j < 0 || j >= slots.length) return draft
+ const tmp = slots[slotIndex]
+ slots[slotIndex] = slots[j]
+ slots[j] = tmp
+ return syncProgressionRoadmapFromSlots({ ...draft, slots, dirty: true })
+}
+
+export function removeSlotFromDraft(draft, slotIndex) {
+ const slots = draft.slots || []
+ if (slots.length <= 2) return draft
+ const next = slots.filter((_, i) => i !== slotIndex)
+ return syncProgressionRoadmapFromSlots({ ...draft, slots: next, dirty: true })
+}
+
+export function insertSlotInDraft(draft, afterIndex, partial = {}) {
+ const slots = [...(draft.slots || [])]
+ if (slots.length >= SLOT_MAX) return draft
+ const insertAt = afterIndex < 0 ? 0 : Math.min(afterIndex + 1, slots.length)
+ const newSlot = {
+ ...createEmptySlot(insertAt),
+ ...partial,
+ primary: partial.primary || emptySlotExercise(),
+ siblings: partial.siblings || [],
+ }
+ slots.splice(insertAt, 0, newSlot)
+ return syncProgressionRoadmapFromSlots({ ...draft, slots, dirty: true })
+}
+
+export function addSlotToDraft(draft) {
+ const slots = draft.slots || []
+ if (slots.length >= SLOT_MAX) return draft
+ return syncProgressionRoadmapFromSlots({
+ ...draft,
+ slots: [...slots, createEmptySlot(slots.length)],
+ dirty: true,
+ })
+}
+
+export function patchSlotInDraft(draft, slotIndex, patch) {
+ const slots = (draft.slots || []).map((s, i) => (i === slotIndex ? { ...s, ...patch } : s))
+ return syncProgressionRoadmapFromSlots({ ...draft, slots, dirty: true })
+}
+
+export function resolveOfferSlotIndex(draft, offer) {
+ if (offer?.roadmap_major_step_index != null && Number.isFinite(Number(offer.roadmap_major_step_index))) {
+ return Number(offer.roadmap_major_step_index)
+ }
+ if (offer?.replace_step_index != null && Number.isFinite(Number(offer.replace_step_index))) {
+ return Number(offer.replace_step_index)
+ }
+ if (offer?.insert_after_index != null && Number.isFinite(Number(offer.insert_after_index))) {
+ const after = Number(offer.insert_after_index)
+ if (offer.source === 'roadmap_unfilled') return after + 1
+ return Math.min(after + 1, (draft.slots?.length || 1) - 1)
+ }
+ return null
+}
+
+export function offerNeedsNewSlot(offer) {
+ return offer?.source === 'unfilled_gap' || offer?.source === 'llm_suggested'
+}
+
+export function offerCanExpandSlots(draft, offer) {
+ if (!offerNeedsNewSlot(offer)) return false
+ return (draft.slots?.length || 0) < SLOT_MAX
+}
+
+const GAP_OFFER_SOURCE_PRIORITY = {
+ roadmap_unfilled: 0,
+ unfilled_gap: 1,
+ llm_suggested: 2,
+ off_topic: 3,
+}
+
+export function collectGapOffersFromApiResponse(res) {
+ const out = []
+ const seen = new Set()
+ const add = (offer) => {
+ if (!offer || typeof offer !== 'object') return
+ const id = offer.offer_id || `${offer.source}-${offer.roadmap_major_step_index}`
+ if (seen.has(id)) return
+ seen.add(id)
+ out.push(offer)
+ }
+ 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 || []) {
+ if (step?.gap_offer) add(step.gap_offer)
+ }
+ return out
+}
+
+/** Maximal ein Angebot pro Slot — Roadmap-Lücken vor Brücken/QS. */
+export function dedupeGapOffersBySlot(offers, draft) {
+ const bySlot = new Map()
+ for (const offer of offers || []) {
+ const idx = resolveOfferSlotIndex(draft, offer)
+ if (idx == null || !Number.isFinite(idx) || idx < 0) continue
+ const existing = bySlot.get(idx)
+ const prio = GAP_OFFER_SOURCE_PRIORITY[offer?.source] ?? 9
+ const existingPrio = existing ? (GAP_OFFER_SOURCE_PRIORITY[existing?.source] ?? 9) : 99
+ if (!existing || prio < existingPrio) {
+ bySlot.set(idx, offer)
+ }
+ }
+ return Array.from(bySlot.entries())
+ .sort(([a], [b]) => a - b)
+ .map(([, offer]) => offer)
+}
+
+/** Angebote nur für Slots ohne belegte Primary (Bibliothek oder KI-Entwurf). */
+export function filterGapOffersForUnfilledSlots(draft, offers) {
+ return (offers || []).filter((offer) => {
+ const idx = resolveOfferSlotIndex(draft, offer)
+ if (idx == null || idx < 0 || idx >= (draft?.slots?.length || 0)) return true
+ const p = draft.slots[idx]?.primary
+ if (p?.kind === 'library' && p.exerciseId != null) return false
+ if (p?.kind === 'proposal' && p.aiSuggestion) return false
+ return true
+ })
+}
+
+export function syncSlotPhasesFromRoadmap(draft, progressionRoadmap) {
+ if (!progressionRoadmap) return draft
+ const majors = mapMajorStepsFromApi(progressionRoadmap)
+ if (!majors.length) return draft
+ const slots = (draft.slots || []).map((slot, i) => {
+ const m = majors[i]
+ if (!m) return slot
+ return {
+ ...slot,
+ phase: m.phase || slot.phase,
+ learning_goal: m.learning_goal || slot.learning_goal,
+ load_profile: m.load_profile?.length ? m.load_profile : slot.load_profile,
+ success_criteria: m.success_criteria?.length ? m.success_criteria : slot.success_criteria,
+ anti_patterns: m.anti_patterns?.length ? m.anti_patterns : slot.anti_patterns,
+ exercise_type: m.exercise_type || slot.exercise_type,
+ }
+ })
+ return syncProgressionRoadmapFromSlots({
+ ...draft,
+ slots,
+ progressionRoadmap,
+ majorSteps: majors,
+ maxSteps: Math.max(slots.length, majors.length),
+ })
+}
+
+export function slotsAsPathStepRows(draft) {
+ return (draft.slots || []).map((slot) => ({
+ exerciseId: slot.primary?.exerciseId ?? null,
+ exerciseTitle: slot.primary?.exerciseTitle || '',
+ title: slot.primary?.exerciseTitle || '',
+ roadmap_major_step_index: slot.majorStepIndex,
+ roadmapMajorStepIndex: slot.majorStepIndex,
+ roadmap_phase: slot.phase,
+ roadmapPhase: slot.phase,
+ roadmap_learning_goal: slot.learning_goal,
+ roadmapLearningGoal: slot.learning_goal,
+ learning_goal: slot.learning_goal,
+ success_criteria: Array.isArray(slot.success_criteria) ? slot.success_criteria : [],
+ isAiProposal: slot.primary?.kind === 'proposal',
+ }))
+}
+
+export function emptySlotExercise() {
+ return {
+ kind: 'empty',
+ exerciseId: null,
+ variantId: null,
+ exerciseTitle: '',
+ variantName: null,
+ proposalKey: null,
+ aiSuggestion: null,
+ }
+}
+
+export function librarySlotExercise({ exerciseId, exerciseTitle, variantId = null, variantName = null }) {
+ return {
+ kind: 'library',
+ exerciseId: Number(exerciseId),
+ variantId: variantId != null ? Number(variantId) : null,
+ exerciseTitle: exerciseTitle || `Übung #${exerciseId}`,
+ variantName: variantName || null,
+ proposalKey: null,
+ aiSuggestion: null,
+ }
+}
+
+export function proposalSlotExercise({ title, proposalKey = null, aiSuggestion = null }) {
+ return {
+ kind: 'proposal',
+ exerciseId: null,
+ variantId: null,
+ exerciseTitle: (title || 'KI-Vorschlag').trim(),
+ variantName: null,
+ proposalKey: proposalKey || `proposal-${Date.now()}`,
+ aiSuggestion: aiSuggestion || null,
+ }
+}
+
+export function slotExerciseFromApi(raw) {
+ if (!raw || typeof raw !== 'object') return emptySlotExercise()
+ const kind = raw.kind || (raw.exercise_id != null ? 'library' : raw.ai_suggestion ? 'proposal' : 'empty')
+ if (kind === 'proposal' || raw.ai_suggestion) {
+ return proposalSlotExercise({
+ title: raw.title || raw.exercise_title,
+ proposalKey: raw.proposal_key,
+ aiSuggestion: raw.ai_suggestion,
+ })
+ }
+ if (kind === 'library' && raw.exercise_id != null) {
+ return librarySlotExercise({
+ exerciseId: raw.exercise_id,
+ exerciseTitle: raw.title || raw.exercise_title,
+ variantId: raw.variant_id,
+ variantName: raw.variant_name,
+ })
+ }
+ return emptySlotExercise()
+}
+
+export function slotExerciseToApi(entry) {
+ if (!entry || entry.kind === 'empty') {
+ return { kind: 'empty' }
+ }
+ if (entry.kind === 'proposal') {
+ return {
+ kind: 'proposal',
+ title: entry.exerciseTitle || 'KI-Vorschlag',
+ proposal_key: entry.proposalKey || null,
+ ai_suggestion: entry.aiSuggestion || null,
+ }
+ }
+ return {
+ kind: 'library',
+ exercise_id: entry.exerciseId,
+ variant_id: entry.variantId || null,
+ title: entry.exerciseTitle || null,
+ variant_name: entry.variantName || null,
+ }
+}
+
+export function mapMajorStepsFromApi(apiRoadmap) {
+ const raw = apiRoadmap?.roadmap?.major_steps
+ if (!Array.isArray(raw)) return []
+ const rows = raw.map((s, i) => ({
+ index: i,
+ phase: s.phase || 'vertiefung',
+ learning_goal: (s.learning_goal || '').trim(),
+ consolidates: Array.isArray(s.consolidates) ? s.consolidates : [],
+ rationale: s.rationale || '',
+ load_profile: [],
+ success_criteria: [],
+ anti_patterns: [],
+ exercise_type: '',
+ }))
+ const specs = apiRoadmap?.stage_specs
+ if (!Array.isArray(specs) || !rows.length) return rows
+ return rows.map((row, i) => {
+ const spec =
+ specs.find((s) => Number(s.major_step_index) === i) ||
+ specs.find((s) => Number(s.major_step_index) === row.index) ||
+ specs[i]
+ if (!spec) return row
+ return {
+ ...row,
+ load_profile: Array.isArray(spec.load_profile) ? [...spec.load_profile] : [],
+ success_criteria: Array.isArray(spec.success_criteria) ? [...spec.success_criteria] : [],
+ anti_patterns: Array.isArray(spec.anti_patterns) ? [...spec.anti_patterns] : [],
+ exercise_type: (spec.exercise_type || '').trim(),
+ }
+ })
+}
+
+export function reindexMajorSteps(rows) {
+ return rows.map((row, i) => ({ ...row, index: i }))
+}
+
+export function majorStepsToOverridePayload(rows) {
+ const indexed = reindexMajorSteps(rows)
+ return {
+ major_steps: indexed.map((row) => ({
+ index: row.index,
+ phase: row.phase || 'vertiefung',
+ learning_goal: (row.learning_goal || '').trim(),
+ consolidates: row.consolidates || [],
+ rationale: row.rationale || '',
+ })),
+ stage_specs: indexed.map((row, i) => ({
+ major_step_index: i,
+ learning_goal: (row.learning_goal || '').trim(),
+ load_profile: Array.isArray(row.load_profile) ? row.load_profile : [],
+ exercise_type: (row.exercise_type || '').trim(),
+ success_criteria: Array.isArray(row.success_criteria) ? row.success_criteria : [],
+ anti_patterns: Array.isArray(row.anti_patterns) ? row.anti_patterns : [],
+ })),
+ }
+}
+
+function nodeKey(exerciseId, variantId) {
+ return `${exerciseId}:${variantId ?? ''}`
+}
+
+/** Maximale lineare Segmente aus next_exercise-Kanten. */
+export function maximalLinearChains(nextEdges) {
+ if (!nextEdges?.length) return []
+ const outMap = new Map()
+ const inMap = new Map()
+
+ for (const e of nextEdges) {
+ const f = nodeKey(e.from_exercise_id, e.from_exercise_variant_id)
+ const t = nodeKey(e.to_exercise_id, e.to_exercise_variant_id)
+ if (!outMap.has(f)) outMap.set(f, [])
+ outMap.get(f).push(e)
+ if (!inMap.has(t)) inMap.set(t, [])
+ inMap.get(t).push(e)
+ }
+
+ const used = new Set()
+ const chains = []
+
+ for (const startEdge of nextEdges) {
+ if (used.has(startEdge.id)) continue
+ const edgesSeq = [startEdge]
+
+ let fk = nodeKey(startEdge.from_exercise_id, startEdge.from_exercise_variant_id)
+ while (true) {
+ const preds = inMap.get(fk)
+ if (!preds || preds.length !== 1) break
+ const pred = preds[0]
+ if (used.has(pred.id)) break
+ edgesSeq.unshift(pred)
+ fk = nodeKey(pred.from_exercise_id, pred.from_exercise_variant_id)
+ }
+
+ let tk = nodeKey(startEdge.to_exercise_id, startEdge.to_exercise_variant_id)
+ while (true) {
+ const outs = outMap.get(tk)
+ if (!outs || outs.length !== 1) break
+ const nx = outs[0]
+ if (used.has(nx.id)) break
+ edgesSeq.push(nx)
+ tk = nodeKey(nx.to_exercise_id, nx.to_exercise_variant_id)
+ }
+
+ edgesSeq.forEach((ed) => used.add(ed.id))
+ const first = edgesSeq[0]
+ const nodes = [
+ {
+ exercise_id: first.from_exercise_id,
+ variant_id: first.from_exercise_variant_id ?? null,
+ title: first.from_exercise_title,
+ variant_name: first.from_variant_name ?? null,
+ },
+ ]
+ for (const ed of edgesSeq) {
+ nodes.push({
+ exercise_id: ed.to_exercise_id,
+ variant_id: ed.to_exercise_variant_id ?? null,
+ title: ed.to_exercise_title,
+ variant_name: ed.to_variant_name ?? null,
+ })
+ }
+ chains.push({ nodes, edges: edgesSeq })
+ }
+ return chains
+}
+
+function chainNodeToLibrary(node) {
+ if (!node?.exercise_id) return emptySlotExercise()
+ return librarySlotExercise({
+ exerciseId: node.exercise_id,
+ exerciseTitle: node.title || `Übung #${node.exercise_id}`,
+ variantId: node.variant_id,
+ variantName: node.variant_name,
+ })
+}
+
+function buildSlotsFromSources({ majorSteps, slotContents, primaryChain, siblingEdges }) {
+ const slotCount = Math.max(majorSteps.length, primaryChain?.nodes?.length || 0, 2)
+ const slots = []
+
+ for (let i = 0; i < slotCount; i += 1) {
+ const major = majorSteps[i] || {
+ index: i,
+ phase: ROADMAP_PHASES[Math.min(i, ROADMAP_PHASES.length - 1)],
+ learning_goal: '',
+ consolidates: [],
+ rationale: '',
+ load_profile: [],
+ success_criteria: [],
+ anti_patterns: [],
+ exercise_type: '',
+ }
+ const saved = Array.isArray(slotContents)
+ ? slotContents.find((s) => Number(s.major_step_index) === i)
+ : null
+
+ const hasSavedSlotContents = Array.isArray(slotContents) && slotContents.length > 0
+ let primary = saved?.primary
+ ? slotExerciseFromApi(saved.primary)
+ : hasSavedSlotContents
+ ? emptySlotExercise()
+ : chainNodeToLibrary(primaryChain?.nodes?.[i])
+
+ if (primary.kind === 'empty' && saved?.primary) {
+ primary = slotExerciseFromApi(saved.primary)
+ }
+
+ const siblings = []
+ const seenSiblingKeys = new Set()
+
+ if (Array.isArray(saved?.siblings)) {
+ for (const sib of saved.siblings) {
+ const entry = slotExerciseFromApi(sib)
+ if (entry.kind !== 'empty') {
+ const key = entry.kind === 'library' ? `lib-${entry.exerciseId}` : `prop-${entry.proposalKey}`
+ if (!seenSiblingKeys.has(key)) {
+ seenSiblingKeys.add(key)
+ siblings.push(entry)
+ }
+ }
+ }
+ }
+
+ if (primary.kind === 'library' && Array.isArray(siblingEdges)) {
+ const pid = primary.exerciseId
+ for (const edge of siblingEdges) {
+ let other = null
+ if (Number(edge.from_exercise_id) === pid) {
+ other = librarySlotExercise({
+ exerciseId: edge.to_exercise_id,
+ exerciseTitle: edge.to_exercise_title,
+ variantId: edge.to_exercise_variant_id,
+ variantName: edge.to_variant_name,
+ })
+ } else if (Number(edge.to_exercise_id) === pid) {
+ other = librarySlotExercise({
+ exerciseId: edge.from_exercise_id,
+ exerciseTitle: edge.from_exercise_title,
+ variantId: edge.from_exercise_variant_id,
+ variantName: edge.from_variant_name,
+ })
+ }
+ if (other) {
+ const key = `lib-${other.exerciseId}`
+ if (!seenSiblingKeys.has(key)) {
+ seenSiblingKeys.add(key)
+ siblings.push(other)
+ }
+ }
+ }
+ }
+
+ slots.push({
+ majorStepIndex: i,
+ phase: major.phase,
+ learning_goal: major.learning_goal,
+ consolidates: major.consolidates,
+ rationale: major.rationale,
+ load_profile: major.load_profile,
+ success_criteria: major.success_criteria,
+ anti_patterns: major.anti_patterns,
+ exercise_type: major.exercise_type,
+ primary,
+ siblings,
+ })
+ }
+
+ return slots
+}
+
+export function hydrateProgressionGraphDraft({
+ artifact = null,
+ edges = [],
+ graphName = '',
+}) {
+ const nextEdges = (edges || []).filter((e) => (e.edge_type || 'next_exercise') === 'next_exercise')
+ const siblingEdges = (edges || []).filter((e) => e.edge_type === 'sibling')
+ const chains = maximalLinearChains(nextEdges)
+ const primaryChain = chains.sort((a, b) => b.nodes.length - a.nodes.length)[0] || null
+
+ const majorSteps = artifact?.progression_roadmap
+ ? mapMajorStepsFromApi(artifact.progression_roadmap)
+ : []
+
+ const slots = buildSlotsFromSources({
+ majorSteps,
+ slotContents: artifact?.slot_contents,
+ primaryChain,
+ siblingEdges,
+ })
+
+ return {
+ graphName: graphName || '',
+ goalQuery: artifact?.goal_query || '',
+ startSituation: artifact?.start_situation || '',
+ targetState: artifact?.target_state || '',
+ roadmapNotes: artifact?.roadmap_notes || '',
+ maxSteps: Number(artifact?.max_steps) || Math.max(slots.length, 5),
+ majorSteps: majorSteps.length ? majorSteps : slots.map((s, i) => ({
+ index: i,
+ phase: s.phase,
+ learning_goal: s.learning_goal,
+ consolidates: s.consolidates,
+ rationale: s.rationale,
+ load_profile: s.load_profile,
+ success_criteria: s.success_criteria,
+ anti_patterns: s.anti_patterns,
+ exercise_type: s.exercise_type,
+ })),
+ slots,
+ pathSkillExpectations: artifact?.path_skill_expectations || null,
+ progressionRoadmap: artifact?.progression_roadmap || null,
+ lastFindings: artifact?.last_findings || null,
+ primaryChainEdgeIds: primaryChain?.edges?.map((e) => e.id) || [],
+ siblingEdgeIds: siblingEdges.map((e) => e.id),
+ dirty: false,
+ }
+}
+
+export function buildPlanningArtifactFromDraft(draft, { lastFindings = undefined } = {}) {
+ const q = (draft.goalQuery || '').trim()
+ const progressionRoadmap = draft.progressionRoadmap || null
+ if (!q && !progressionRoadmap && !draft.slots?.length) return null
+
+ const slot_contents = (draft.slots || []).map((slot) => ({
+ major_step_index: slot.majorStepIndex,
+ primary: slotExerciseToApi(slot.primary),
+ siblings: (slot.siblings || []).map(slotExerciseToApi).filter((s) => s.kind !== 'empty'),
+ }))
+
+ const artifact = {
+ schema_version: PLANNING_ARTIFACT_SCHEMA,
+ goal_query: q,
+ start_situation: (draft.startSituation || '').trim() || null,
+ target_state: (draft.targetState || '').trim() || null,
+ roadmap_notes: (draft.roadmapNotes || '').trim() || null,
+ max_steps: Number(draft.maxSteps) || draft.slots?.length || 5,
+ progression_roadmap: progressionRoadmap,
+ path_skill_expectations: draft.pathSkillExpectations || null,
+ slot_contents,
+ }
+
+ const findings = lastFindings !== undefined ? lastFindings : draft.lastFindings
+ if (findings) artifact.last_findings = findings
+
+ return artifact
+}
+
+/** Befüllte Primärkette (nur library) für edges/sequence. */
+export function draftPrimaryChainSteps(draft) {
+ const steps = []
+ for (const slot of draft.slots || []) {
+ if (slot.primary?.kind === 'library' && slot.primary.exerciseId != null) {
+ steps.push({
+ exerciseId: slot.primary.exerciseId,
+ variantId: slot.primary.variantId,
+ exerciseTitle: slot.primary.exerciseTitle,
+ majorStepIndex: slot.majorStepIndex,
+ phase: slot.phase,
+ learningGoal: slot.learning_goal,
+ })
+ }
+ }
+ return steps
+}
+
+export function draftSiblingEdgePairs(draft) {
+ const pairs = []
+ for (const slot of draft.slots || []) {
+ if (slot.primary?.kind !== 'library' || slot.primary.exerciseId == null) continue
+ for (const sib of slot.siblings || []) {
+ if (sib.kind !== 'library' || sib.exerciseId == null) continue
+ pairs.push({
+ from: { exerciseId: slot.primary.exerciseId, variantId: slot.primary.variantId },
+ to: { exerciseId: sib.exerciseId, variantId: sib.variantId },
+ })
+ }
+ }
+ return pairs
+}
+
+/** Slot-Zuordnungen für Backend-Reconciliation (validiert, nicht blind gepinnt). */
+export function slotsToSlotAssignments(draft) {
+ return (draft.slots || [])
+ .filter((slot) => slot.primary?.kind === 'library' && slot.primary.exerciseId != null)
+ .map((slot) => ({
+ exercise_id: slot.primary.exerciseId,
+ variant_id: slot.primary.variantId || null,
+ title: slot.primary.exerciseTitle || null,
+ is_ai_proposal: false,
+ roadmap_major_step_index: slot.majorStepIndex,
+ roadmap_phase: slot.phase || null,
+ roadmap_learning_goal: slot.learning_goal || null,
+ }))
+}
+
+/** Alle Graph-Übungs-IDs für Retriever-Boost (Slots + Geschwister + gespeichertes Artefakt). */
+export function draftRetrievalBoostExerciseIds(draft) {
+ const ids = new Set()
+ for (const slot of draft.slots || []) {
+ const p = slot.primary
+ if (p?.kind === 'library' && p.exerciseId != null) ids.add(p.exerciseId)
+ for (const sib of slot.siblings || []) {
+ if (sib.kind === 'library' && sib.exerciseId != null) ids.add(sib.exerciseId)
+ }
+ }
+ const saved = draft?.slot_contents || draft?.planningArtifact?.slot_contents
+ if (Array.isArray(saved)) {
+ for (const raw of saved) {
+ const eid = raw?.primary?.exercise_id ?? raw?.exercise_id
+ if (eid != null && Number.isFinite(Number(eid))) ids.add(Number(eid))
+ }
+ }
+ return [...ids]
+}
+
+export function slotsToEvaluateSteps(draft) {
+ return (draft.slots || []).map((slot) => {
+ const p = slot.primary
+ if (p.kind === 'proposal') {
+ return {
+ exercise_id: null,
+ variant_id: null,
+ title: p.exerciseTitle,
+ is_ai_proposal: true,
+ ai_suggestion: p.aiSuggestion,
+ proposal_key: p.proposalKey,
+ roadmap_major_step_index: slot.majorStepIndex,
+ roadmap_phase: slot.phase,
+ roadmap_learning_goal: slot.learning_goal,
+ }
+ }
+ if (p.kind === 'library' && p.exerciseId != null) {
+ return {
+ exercise_id: p.exerciseId,
+ variant_id: p.variantId || null,
+ title: p.exerciseTitle,
+ is_ai_proposal: false,
+ roadmap_major_step_index: slot.majorStepIndex,
+ roadmap_phase: slot.phase,
+ roadmap_learning_goal: slot.learning_goal,
+ }
+ }
+ return {
+ exercise_id: null,
+ variant_id: null,
+ title: `(leer: ${slot.learning_goal || `Slot ${slot.majorStepIndex + 1}`})`,
+ is_ai_proposal: true,
+ roadmap_major_step_index: slot.majorStepIndex,
+ roadmap_phase: slot.phase,
+ roadmap_learning_goal: slot.learning_goal,
+ }
+ })
+}
+
+export function applyMatchStepsToSlots(draft, apiSteps) {
+ const steps = Array.isArray(apiSteps) ? apiSteps : []
+ const nextSlots = (draft.slots || []).map((slot) => ({
+ ...slot,
+ primary: { ...slot.primary },
+ siblings: [...(slot.siblings || [])],
+ }))
+
+ const touchedMajors = new Set()
+ for (const step of steps) {
+ if (step.roadmap_major_step_index == null || !Number.isFinite(Number(step.roadmap_major_step_index))) {
+ continue
+ }
+ const idx = Number(step.roadmap_major_step_index)
+ if (idx < 0 || idx >= nextSlots.length) continue
+ touchedMajors.add(idx)
+
+ const isProposal = Boolean(step.is_ai_proposal) || step.exercise_id == null
+ const hasAiPayload = Boolean(step.ai_suggestion) || Boolean(step.proposal_key)
+ const isUnfilledSlot =
+ step.slot_status === 'unfilled' ||
+ step.slot_status === 'stripped' ||
+ step.roadmap_match_source === 'unfilled' ||
+ Boolean(step.gap_offer)
+ if (isProposal && !hasAiPayload && isUnfilledSlot) {
+ const offer = step.gap_offer || {}
+ nextSlots[idx].primary = proposalSlotExercise({
+ title:
+ offer.title_hint ||
+ step.roadmap_learning_goal ||
+ step.title ||
+ nextSlots[idx].learning_goal ||
+ `Slot ${idx + 1}`,
+ proposalKey: offer.offer_id || step.proposal_key || `roadmap-unfilled-${idx}`,
+ aiSuggestion: offer.ai_suggestion || null,
+ })
+ } else if (isProposal && !hasAiPayload) {
+ nextSlots[idx].primary = emptySlotExercise()
+ } else if (isProposal) {
+ nextSlots[idx].primary = proposalSlotExercise({
+ title: step.title || nextSlots[idx].learning_goal,
+ proposalKey: step.proposal_key,
+ aiSuggestion: step.ai_suggestion,
+ })
+ } else {
+ nextSlots[idx].primary = librarySlotExercise({
+ exerciseId: step.exercise_id,
+ exerciseTitle: step.title || `Übung #${step.exercise_id}`,
+ variantId: step.variant_id,
+ })
+ }
+ }
+
+ 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
+ if (res?.progression_roadmap) {
+ next = syncSlotPhasesFromRoadmap(next, res.progression_roadmap)
+ }
+
+ const offers = dedupeGapOffersBySlot(collectGapOffersFromApiResponse(res), next)
+ const placedIds = new Set()
+
+ for (const offer of offers) {
+ const idx = resolveOfferSlotIndex(next, offer)
+ if (idx == null || idx < 0 || idx >= (next.slots?.length || 0)) continue
+ const primary = next.slots[idx]?.primary
+ const isOffTopicReplace =
+ replaceOffTopicSlots &&
+ offer?.source === 'off_topic' &&
+ offer?.replace_step_index != null
+ if (!isOffTopicReplace) {
+ if (primary?.kind === 'library' && primary.exerciseId != null) continue
+ if (primary?.kind === 'proposal' && primary.aiSuggestion) continue
+ }
+
+ next = applyGapOfferToSlot(next, idx, offer)
+ if (offer?.offer_id) placedIds.add(offer.offer_id)
+ }
+
+ const remainingOffers = filterGapOffersForUnfilledSlots(
+ next,
+ dedupeGapOffersBySlot(
+ collectGapOffersFromApiResponse(res).filter((o) => !placedIds.has(o?.offer_id)),
+ next,
+ ),
+ )
+
+ return { draft: { ...next, dirty: true }, remainingOffers }
+}
+
+/** Match-Antwort: Schritte + Lücken-Angebote direkt in Slots (wie früher im Pfad-Wizard sichtbar). */
+export function applyMatchResponseToDraft(draft, res, { replaceOffTopicSlots = true } = {}) {
+ let next = applyMatchStepsToSlots(draft, res?.steps)
+ if (res?.progression_roadmap) {
+ next = {
+ ...syncSlotPhasesFromRoadmap(next, res.progression_roadmap),
+ pathSkillExpectations: res?.path_skill_expectations || next.pathSkillExpectations,
+ }
+ }
+ const { draft: withOffers, remainingOffers } = applyGapOffersFromResponse(next, res, {
+ replaceOffTopicSlots,
+ })
+ return { draft: withOffers, remainingOffers }
+}
+
+/** Evaluate-Antwort: KI-Angebote in leere Slots (ohne Schritte neu zu matchen). */
+export function applyEvaluateResponseToDraft(draft, res) {
+ return applyGapOffersFromResponse(draft, res)
+}
+
+export function setSlotPrimaryLibrary(draft, slotIndex, exercise) {
+ if (!exercise?.id) return draft
+ const slots = (draft.slots || []).map((s, i) =>
+ i === slotIndex
+ ? {
+ ...s,
+ primary: librarySlotExercise({
+ exerciseId: exercise.id,
+ exerciseTitle: exercise.title || `Übung #${exercise.id}`,
+ }),
+ }
+ : s,
+ )
+ return syncProgressionRoadmapFromSlots({ ...draft, slots, dirty: true })
+}
+
+export function applyGapOfferToSlot(draft, slotIndex, offer, aiSuggestion = null) {
+ const nextSlots = (draft.slots || []).map((s) => ({ ...s, primary: { ...s.primary }, siblings: [...(s.siblings || [])] }))
+ if (slotIndex < 0 || slotIndex >= nextSlots.length) return draft
+ const title =
+ offer?.proposal_title ||
+ offer?.title_hint ||
+ offer?.title ||
+ nextSlots[slotIndex].learning_goal ||
+ 'KI-Vorschlag'
+ nextSlots[slotIndex].primary = proposalSlotExercise({
+ title,
+ proposalKey: offer?.proposal_key || offer?.offer_id || null,
+ aiSuggestion: aiSuggestion || offer?.ai_suggestion || null,
+ })
+ return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true })
+}
+
+/**
+ * Angebot einem Slot zuordnen — optional neuen Slot einfügen (Brücke / QS-Neuanlage).
+ */
+export function applyGapOfferToDraft(draft, offer, { slotIndex = null, insertNewSlot = false } = {}) {
+ let next = { ...draft }
+ if (insertNewSlot && offerNeedsNewSlot(offer)) {
+ const afterIdx = Number(offer?.insert_after_index)
+ if (Number.isFinite(afterIdx)) {
+ if ((next.slots?.length || 0) >= SLOT_MAX) return next
+ next = insertSlotInDraft(next, afterIdx, {
+ learning_goal: (offer?.title_hint || offer?.sketch || '').trim().split('\n')[0] || '',
+ phase: offer?.phase || offer?.gap?.expected_phase || 'vertiefung',
+ })
+ slotIndex = afterIdx + 1
+ }
+ }
+ const idx = slotIndex != null ? slotIndex : resolveOfferSlotIndex(next, offer)
+ if (idx == null || !Number.isFinite(idx) || idx < 0 || idx >= (next.slots?.length || 0)) {
+ return next
+ }
+ return applyGapOfferToSlot(next, idx, offer)
+}
+
+export async function saveProgressionGraphDraft(api, graphId, draft) {
+ const synced = syncProgressionRoadmapFromSlots(draft)
+ const primarySteps = draftPrimaryChainSteps(synced)
+ const siblingPairs = draftSiblingEdgePairs(synced)
+ const artifact = buildPlanningArtifactFromDraft(synced)
+
+ // Kanten frisch laden — hydrate-Arrays können nach Zwischen-Speichern veraltet sein.
+ const currentEdges = await api.listExerciseProgressionEdges(Number(graphId))
+ const nextEdgeIds = (currentEdges || [])
+ .filter((e) => (e.edge_type || 'next_exercise') === 'next_exercise')
+ .map((e) => e.id)
+ .filter((id) => Number.isFinite(Number(id)))
+ const siblingEdgeIds = (currentEdges || [])
+ .filter((e) => e.edge_type === 'sibling')
+ .map((e) => e.id)
+ .filter((id) => Number.isFinite(Number(id)))
+
+ let artifactPersisted = false
+
+ // Primärkette nur ersetzen, wenn mindestens zwei Bibliotheks-Übungen im Pfad sind.
+ // Sonst bestehende next_exercise-Kanten erhalten (nur Artefakt/Slots speichern).
+ if (primarySteps.length >= 2) {
+ if (nextEdgeIds.length > 0) {
+ await api.deleteExerciseProgressionEdgesBatch(Number(graphId), nextEdgeIds)
+ }
+ await api.createExerciseProgressionSequence(Number(graphId), {
+ steps: primarySteps.map((s) => ({
+ exercise_id: s.exerciseId,
+ variant_id: s.variantId || null,
+ })),
+ segment_notes: primarySteps.slice(1).map(() => null),
+ ...(artifact ? { planning_roadmap: artifact } : {}),
+ })
+ artifactPersisted = Boolean(artifact)
+ }
+
+ if (siblingEdgeIds.length > 0) {
+ await api.deleteExerciseProgressionEdgesBatch(Number(graphId), siblingEdgeIds)
+ }
+ for (const pair of siblingPairs) {
+ await api.createExerciseProgressionEdge(Number(graphId), {
+ from_exercise_id: pair.from.exerciseId,
+ from_exercise_variant_id: pair.from.variantId,
+ to_exercise_id: pair.to.exerciseId,
+ to_exercise_variant_id: pair.to.variantId,
+ edge_type: 'sibling',
+ })
+ }
+
+ if (artifact && !artifactPersisted) {
+ await api.updateExerciseProgressionGraph(Number(graphId), { planning_roadmap: artifact })
+ }
+
+ return { primaryCount: primarySteps.length, siblingCount: siblingPairs.length }
+}