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: }, { path: 'settings/legal', element: }, { path: 'media', element: }, + { path: 'progression-graphs/:id', element: }, { path: 'exercises', children: [ diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx index e62a75f..330c80a 100644 --- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx +++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx @@ -485,9 +485,9 @@ export default function ExerciseProgressionGraphPanel({ )}

- 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}
diff --git a/frontend/src/components/ProgressionFindingsPanel.jsx b/frontend/src/components/ProgressionFindingsPanel.jsx new file mode 100644 index 0000000..af70fa5 --- /dev/null +++ b/frontend/src/components/ProgressionFindingsPanel.jsx @@ -0,0 +1,141 @@ +/** + * Zentrales Findings-Panel für Progressionsgraph-QA (Phase B.2). + */ +import React from 'react' + +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))', + } +} + +export default function ProgressionFindingsPanel({ + pathQa = null, + gapFillOffers = [], + loading = false, + error = '', + onEvaluate, + onApplyGapOffer, + evaluateDisabled = false, +}) { + return ( +
+

Graph-Bewertung

+

+ Prüft den aktuellen Slot-Stand (inkl. KI-Entwürfe) ohne erneutes Übungs-Matching. +

+ + + + {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} + {pathQa.roadmap_qa_mode === 'roadmap_first_lite' ? ( +

+ QS an Roadmap gekoppelt (keine Brücken zwischen Major Steps). +

+ ) : null} +
+ ) : ( +

+ Noch keine Bewertung. Slots befüllen und „Graph bewerten“ ausführen. +

+ )} + + {Array.isArray(gapFillOffers) && gapFillOffers.length > 0 ? ( +
+

Lücken-Angebote

+
    + {gapFillOffers.slice(0, 6).map((offer) => ( +
  • +
    + {offer.title_hint || 'Übungsvorschlag'} + {offer.roadmap_major_step_index != null + ? ` · Slot ${Number(offer.roadmap_major_step_index) + 1}` + : ''} +
    + {offer.rationale ? ( +

    {offer.rationale}

    + ) : null} + {typeof onApplyGapOffer === 'function' ? ( + + ) : null} +
  • + ))} +
+
+ ) : null} +
+ ) +} diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx new file mode 100644 index 0000000..4c1bedc --- /dev/null +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -0,0 +1,523 @@ +/** + * 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 ProgressionSlotCard from './ProgressionSlotCard' +import ProgressionFindingsPanel from './ProgressionFindingsPanel' +import { + applyGapOfferToSlot, + applyMatchStepsToSlots, + buildPlanningArtifactFromDraft, + hydrateProgressionGraphDraft, + librarySlotExercise, + majorStepsToOverridePayload, + reindexMajorSteps, + saveProgressionGraphDraft, + slotsToEvaluateSteps, +} from '../utils/progressionGraphDraft' + +const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion'] + +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 +} + +export default function ProgressionGraphEditor({ graphId }) { + const [graphMeta, setGraphMeta] = useState(null) + const [draft, setDraft] = useState(null) + const [busy, setBusy] = useState(false) + const [loadErr, setLoadErr] = useState('') + const [actionErr, setActionErr] = 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 loadGraph = useCallback(async () => { + if (!graphId) return + setBusy(true) + setLoadErr('') + try { + const [graph, edges] = await Promise.all([ + api.getExerciseProgressionGraph(Number(graphId)), + api.listExerciseProgressionEdges(Number(graphId)), + ]) + setGraphMeta(graph) + setDraft( + hydrateProgressionGraphDraft({ + artifact: graph?.planning_roadmap, + edges: Array.isArray(edges) ? edges : [], + graphName: graph?.name, + }), + ) + 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]) + + const patchDraft = useCallback((patchFn) => { + setDraft((prev) => { + if (!prev) return prev + const next = patchFn(prev) + return { ...next, dirty: true } + }) + }, []) + + 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 { ...d, slots } + }) + setPickContext(null) + } + + const handlePatchLearningGoal = (slotIndex, value) => { + patchDraft((d) => { + const slots = d.slots.map((s, i) => (i === slotIndex ? { ...s, learning_goal: value } : s)) + const majorSteps = reindexMajorSteps( + (d.majorSteps || []).map((m, i) => (i === slotIndex ? { ...m, learning_goal: value } : m)), + ) + return { ...d, slots, majorSteps } + }) + } + + 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 { ...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 handleAddSlot = () => { + patchDraft((d) => { + const idx = d.slots.length + const phase = ROADMAP_PHASES[Math.min(idx, ROADMAP_PHASES.length - 1)] + const slot = { + majorStepIndex: idx, + phase, + learning_goal: '', + consolidates: [], + rationale: '', + load_profile: [], + success_criteria: [], + anti_patterns: [], + exercise_type: '', + primary: { kind: 'empty', exerciseId: null, variantId: null, exerciseTitle: '', variantName: null, proposalKey: null, aiSuggestion: null }, + siblings: [], + } + const major = { + index: idx, + phase, + learning_goal: '', + consolidates: [], + rationale: '', + load_profile: [], + success_criteria: [], + anti_patterns: [], + exercise_type: '', + } + return { + ...d, + slots: [...d.slots, slot], + majorSteps: [...(d.majorSteps || []), major], + maxSteps: Math.max(d.maxSteps, idx + 1), + } + }) + } + + const validMajorSteps = useMemo(() => { + if (!draft?.slots) return [] + return draft.slots.filter((s) => (s.learning_goal || '').trim().length >= 3) + }, [draft?.slots]) + + const runRoadmapGenerate = async () => { + const q = (draft?.goalQuery || '').trim() + if (q.length < 3) { + alert('Ziel-Anfrage: mindestens 3 Zeichen.') + return + } + 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, + 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 majors = (roadmap?.roadmap?.major_steps || []).map((s, i) => ({ + index: i, + phase: s.phase || ROADMAP_PHASES[Math.min(i, ROADMAP_PHASES.length - 1)], + 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 hydrated = hydrateProgressionGraphDraft({ + artifact: { + ...buildPlanningArtifactFromDraft({ ...draft, progressionRoadmap: roadmap }), + progression_roadmap: roadmap, + }, + edges: [], + graphName: draft.graphName, + }) + setDraft({ ...hydrated, goalQuery: q, dirty: true }) + } 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('') + try { + const override = majorStepsToOverridePayload(draft.slots.map((s) => ({ + index: s.majorStepIndex, + 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, + }))) + const res = await api.suggestProgressionPath({ + query: q, + max_steps: validMajorSteps.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, + progression_graph_id: Number(graphId), + ...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes), + }) + const next = applyMatchStepsToSlots( + { + ...draft, + progressionRoadmap: res?.progression_roadmap || draft.progressionRoadmap, + pathSkillExpectations: res?.path_skill_expectations || draft.pathSkillExpectations, + }, + res?.steps, + ) + setDraft(next) + setPathQa(res?.path_qa || null) + setGapFillOffers(Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : []) + } 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 override = + validMajorSteps.length >= 2 + ? majorStepsToOverridePayload( + draft.slots.map((s) => ({ + index: s.majorStepIndex, + 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, + })), + ) + : undefined + const res = await api.suggestProgressionPath({ + query: q, + max_steps: draft.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(draft), + roadmap_override: override, + progression_graph_id: Number(graphId), + ...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes), + }) + setPathQa(res?.path_qa || null) + setGapFillOffers(Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : []) + setDraft((prev) => (prev ? { ...prev, lastFindings: res?.path_qa || null } : prev)) + } 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() + alert('Progressionsgraph gespeichert.') + } catch (e) { + setActionErr(e.message || 'Speichern fehlgeschlagen') + } finally { + setBusy(false) + } + } + + const handleApplyGapOffer = (offer) => { + const idx = + offer?.roadmap_major_step_index != null ? Number(offer.roadmap_major_step_index) : null + if (idx == null || !Number.isFinite(idx)) { + alert('Angebot ohne Slot-Zuordnung — bitte manuell zuweisen.') + return + } + setDraft((prev) => applyGapOfferToSlot(prev, idx, offer)) + } + + if (loadErr) { + return ( +
+

{loadErr}

+ + Zurück + +
+ ) + } + + if (!draft) { + return ( +
+
+
+ ) + } + + return ( +
+
+
+

+ {graphMeta?.name || draft.graphName || `Graph #${graphId}`} +

+

+ Slot-Editor · Roadmap = Struktur · ein Primärpfad + Schwestern +

+
+ + Zur Übersicht + +
+ + {actionErr ? ( +

+ {actionErr} +

+ ) : null} + +
+
+
+

Ziel & Roadmap

+
+ +