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..2a59513
--- /dev/null
+++ b/.claude/docs/working/PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md
@@ -0,0 +1,79 @@
+# 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 & Routing
+
+- **B.4:** Route `/progression-graphs/:id` — Slots links, Findings rechts
+- **Phase C:** Übersicht mit Kacheln (Name, Start, Ziel)
+
+## Ersetzt schrittweise
+
+- Getrennte `ExerciseProgressionPathBuilder`-Wizard-UI + `ProgressionChainEditor` → integrierter `ProgressionGraphEditor`
+
+## 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/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py
index 2184805..f910df4 100644
--- a/backend/planning_exercise_path_builder.py
+++ b/backend/planning_exercise_path_builder.py
@@ -74,6 +74,18 @@ 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)
@@ -88,6 +100,8 @@ 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
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)
@@ -606,6 +620,162 @@ 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 _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)
+ 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,
+ )
+ 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,
+ )
+
+ 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,
+ )
+ return {
+ "path_qa": path_qa,
+ "gap_fill_offers": gap_fill_offers,
+ "steps": steps,
+ }
+
+
def suggest_progression_path(
cur,
*,
@@ -631,6 +801,7 @@ def suggest_progression_path(
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
)
@@ -719,6 +890,42 @@ 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,
@@ -1030,6 +1237,7 @@ def suggest_progression_path(
__all__ = [
+ "EvaluateStepPayload",
"ProgressionPathSuggestRequest",
"suggest_progression_path",
"_pick_best_path_hit",
diff --git a/backend/progression_graph_planning_artifact.py b/backend/progression_graph_planning_artifact.py
index 64020d4..42988ad 100644
--- a/backend/progression_graph_planning_artifact.py
+++ b/backend/progression_graph_planning_artifact.py
@@ -10,6 +10,22 @@ 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)
@@ -19,14 +35,23 @@ class GraphPlanningRoadmapArtifact(BaseModel):
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", mode="before")
+ @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."""
@@ -45,5 +70,7 @@ def normalize_planning_roadmap_payload(raw: Any) -> Optional[Dict[str, Any]]:
__all__ = [
"ARTIFACT_SCHEMA_VERSION",
"GraphPlanningRoadmapArtifact",
+ "SlotContentEntry",
+ "SlotExerciseContent",
"normalize_planning_roadmap_payload",
]
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:
- Ein Graph enthält eine oder mehrere Reihen (lineare Pfade Übung → Übung) sowie optional{' '} - Schwester-Alternativen. Reihen bearbeiten Sie direkt in der Liste; mit dem KI-Planer legen - Sie neue Pfade in vier Schritten an. + Ein Graph = ein linearer Primärpfad (Roadmap-Slots) plus optionale{' '} + Schwestern. Für die integrierte Bearbeitung (Slots, KI-Entwürfe, Graph-Bewertung){' '} + Slot-Editor öffnen — unten weiterhin Kurzansicht und KI-Wizard.
{loadErr && ( @@ -525,6 +525,15 @@ export default function ExerciseProgressionGraphPanel({ + {selectedGraphId ? ( + + Slot-Editor öffnen + + ) : null}