From c1bf9279ad0d59a0f1d34d6db0c09725acbf3831 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 10 Jun 2026 15:34:37 +0200 Subject: [PATCH] Add Gap Offer Handling and UI Enhancements in Progression Graph Components - Implemented `_build_evaluate_empty_slot_gap_specs` function to generate gap offer specifications for unfilled roadmap slots in evaluate-only mode. - Enhanced `ProgressionFindingsPanel` to display AI offers for empty slots and gaps, improving user interaction and clarity. - Updated `ProgressionGraphEditor` and `ProgressionSlotCard` components to support new functionalities for managing slots and offers. - Refactored utility functions in `progressionGraphDraft.js` to streamline slot management and offer handling. - Incremented application version to reflect these updates. --- backend/planning_exercise_path_builder.py | 62 +++ .../progression_graph_planning_artifact.py | 2 +- ...est_progression_graph_planning_artifact.py | 23 + .../components/ProgressionFindingsPanel.jsx | 206 +++++++-- .../src/components/ProgressionGraphEditor.jsx | 437 +++++++++++++----- .../src/components/ProgressionSlotCard.jsx | 128 +++-- frontend/src/utils/progressionGraphDraft.js | 262 ++++++++++- 7 files changed, 914 insertions(+), 206 deletions(-) diff --git a/backend/planning_exercise_path_builder.py b/backend/planning_exercise_path_builder.py index f910df4..364448a 100644 --- a/backend/planning_exercise_path_builder.py +++ b/backend/planning_exercise_path_builder.py @@ -668,6 +668,47 @@ def _evaluate_steps_from_payload( 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, *, @@ -728,6 +769,27 @@ def _run_evaluate_only_path_qa( 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( diff --git a/backend/progression_graph_planning_artifact.py b/backend/progression_graph_planning_artifact.py index 42988ad..0a75480 100644 --- a/backend/progression_graph_planning_artifact.py +++ b/backend/progression_graph_planning_artifact.py @@ -2,7 +2,7 @@ from __future__ import annotations import json -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional from pydantic import BaseModel, Field, field_validator diff --git a/backend/tests/test_progression_graph_planning_artifact.py b/backend/tests/test_progression_graph_planning_artifact.py index 523e6ea..1ae1932 100644 --- a/backend/tests/test_progression_graph_planning_artifact.py +++ b/backend/tests/test_progression_graph_planning_artifact.py @@ -34,3 +34,26 @@ def test_normalize_planning_roadmap_with_progression_roadmap(): 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/frontend/src/components/ProgressionFindingsPanel.jsx b/frontend/src/components/ProgressionFindingsPanel.jsx index af70fa5..ebfc1cb 100644 --- a/frontend/src/components/ProgressionFindingsPanel.jsx +++ b/frontend/src/components/ProgressionFindingsPanel.jsx @@ -1,7 +1,13 @@ /** * Zentrales Findings-Panel für Progressionsgraph-QA (Phase B.2). */ -import React from 'react' +import React, { useMemo, useState } from 'react' +import { + offerCanExpandSlots, + offerNeedsNewSlot, + offerSourceLabel, + resolveOfferSlotIndex, +} from '../utils/progressionGraphDraft' function severityStyle(pathQa) { if (!pathQa) return {} @@ -12,20 +18,150 @@ function severityStyle(pathQa) { } } +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} + +
    + +
    + +
    + {offer.has_ai_payload ? ( + + ) : ( + + )} + + {needsInsert ? ( + + ) : 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 aktuellen Slot-Stand (inkl. KI-Entwürfe) ohne erneutes Übungs-Matching. + Prüft den Slot-Stand und listet KI-Angebote für leere Stufen und Lücken.

    ) : (

    - Noch keine Bewertung. Slots befüllen und „Graph bewerten“ ausführen. + Noch keine Bewertung. Roadmap anlegen, dann „Graph bewerten“ oder „Übungen matchen“.

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

    Lücken-Angebote

    +
    +

    + 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.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} -
    • + {gapFillOffers.map((offer) => ( + onApplyGapOffer(o, idx)} + onInsertSlot={onInsertGapSlot} + onGenerateAi={onGenerateGapAi} + /> ))}
    -
    - ) : null} + )} +
    ) } diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index 4c1bedc..1c71a24 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -5,22 +5,37 @@ 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 { - applyGapOfferToSlot, + aiPreviewToQuickCreateDraft, + buildQuickCreateAiPreview, +} from '../utils/exerciseAiQuickCreate' +import { + buildPathGapPlanningContextForAi, + gapOfferContextDisplayLines, + initialStageLearningGoalFromOffer, +} from '../utils/planningContextForExerciseAi' +import { + addSlotToDraft, + applyGapOfferToDraft, applyMatchStepsToSlots, - buildPlanningArtifactFromDraft, + collectGapOffersFromApiResponse, hydrateProgressionGraphDraft, + insertSlotInDraft, librarySlotExercise, majorStepsToOverridePayload, - reindexMajorSteps, + moveSlotInDraft, + patchSlotInDraft, + removeSlotFromDraft, saveProgressionGraphDraft, + SLOT_MAX, + slotsAsPathStepRows, slotsToEvaluateSteps, + syncProgressionRoadmapFromSlots, } from '../utils/progressionGraphDraft' -const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion'] - function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) { const body = {} const start = (startSituation || '').trim() @@ -32,6 +47,16 @@ function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) { 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 }) { const [graphMeta, setGraphMeta] = useState(null) const [draft, setDraft] = useState(null) @@ -44,6 +69,20 @@ export default function ProgressionGraphEditor({ graphId }) { const [evaluating, setEvaluating] = useState(false) const [matching, setMatching] = useState(false) const [roadmapLoading, setRoadmapLoading] = useState(false) + const [semanticBrief, setSemanticBrief] = useState(null) + const [targetSummary, setTargetSummary] = useState(null) + const [focusAreas, setFocusAreas] = 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 loadGraph = useCallback(async () => { if (!graphId) return @@ -76,6 +115,21 @@ export default function ProgressionGraphEditor({ graphId }) { loadGraph() }, [loadGraph]) + useEffect(() => { + let cancelled = false + api + .listFocusAreas({ status: 'active' }) + .then((fa) => { + if (!cancelled) setFocusAreas(Array.isArray(fa) ? fa : []) + }) + .catch(() => { + if (!cancelled) setFocusAreas([]) + }) + return () => { + cancelled = true + } + }, []) + const patchDraft = useCallback((patchFn) => { setDraft((prev) => { if (!prev) return prev @@ -84,6 +138,21 @@ export default function ProgressionGraphEditor({ graphId }) { }) }, []) + 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 @@ -99,27 +168,68 @@ export default function ProgressionGraphEditor({ graphId }) { if (!siblings.some((x) => x.exerciseId === entry.exerciseId)) siblings.push(entry) return { ...s, siblings } }) - return { ...d, slots } + return syncProgressionRoadmapFromSlots({ ...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 } - }) + 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, + i === slotIndex + ? { + ...s, + primary: { + kind: 'empty', + exerciseId: null, + variantId: null, + exerciseTitle: '', + variantName: null, + proposalKey: null, + aiSuggestion: null, + }, + siblings: [], + } + : s, ) - return { ...d, slots } + return syncProgressionRoadmapFromSlots({ ...d, slots }) }) } @@ -133,48 +243,18 @@ export default function ProgressionGraphEditor({ graphId }) { }) } - 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 applyApiResponse = (res) => { + setSemanticBrief(res?.semantic_brief_summary || null) + setTargetSummary(res?.target_profile_summary || null) + setPathQa(res?.path_qa || null) + setGapFillOffers(collectGapOffersFromApiResponse(res)) + } + const runRoadmapGenerate = async () => { const q = (draft?.goalQuery || '').trim() if (q.length < 3) { @@ -200,26 +280,20 @@ export default function ProgressionGraphEditor({ graphId }) { }) 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 }), + goal_query: q, progression_roadmap: roadmap, + start_situation: draft.startSituation, + target_state: draft.targetState, + roadmap_notes: draft.roadmapNotes, + max_steps: (roadmap?.roadmap?.major_steps || []).length || draft.maxSteps, }, edges: [], graphName: draft.graphName, }) setDraft({ ...hydrated, goalQuery: q, dirty: true }) + setSemanticBrief(res?.semantic_brief_summary || null) } catch (e) { setActionErr(e.message || 'Roadmap-Generierung fehlgeschlagen') } finally { @@ -240,20 +314,11 @@ export default function ProgressionGraphEditor({ graphId }) { 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 synced = syncProgressionRoadmapFromSlots(draft) + const override = majorStepsToOverridePayload(synced.slots) const res = await api.suggestProgressionPath({ query: q, - max_steps: validMajorSteps.length, + max_steps: synced.slots.length, include_llm_intent: true, include_path_qa: true, include_llm_path_qa: true, @@ -268,15 +333,14 @@ export default function ProgressionGraphEditor({ graphId }) { }) const next = applyMatchStepsToSlots( { - ...draft, - progressionRoadmap: res?.progression_roadmap || draft.progressionRoadmap, - pathSkillExpectations: res?.path_skill_expectations || draft.pathSkillExpectations, + ...synced, + progressionRoadmap: res?.progression_roadmap || synced.progressionRoadmap, + pathSkillExpectations: res?.path_skill_expectations || synced.pathSkillExpectations, }, res?.steps, ) setDraft(next) - setPathQa(res?.path_qa || null) - setGapFillOffers(Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : []) + applyApiResponse(res) } catch (e) { setActionErr(e.message || 'Übungs-Match fehlgeschlagen') } finally { @@ -293,38 +357,24 @@ export default function ProgressionGraphEditor({ graphId }) { setEvaluating(true) setActionErr('') try { + const synced = syncProgressionRoadmapFromSlots(draft) 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 + validMajorSteps.length >= 2 ? majorStepsToOverridePayload(synced.slots) : undefined const res = await api.suggestProgressionPath({ query: q, - max_steps: draft.slots.length || draft.maxSteps || 5, + 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(draft), + evaluate_steps: slotsToEvaluateSteps(synced), 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 : []) + applyApiResponse(res) setDraft((prev) => (prev ? { ...prev, lastFindings: res?.path_qa || null } : prev)) } catch (e) { setActionErr(e.message || 'Bewertung fehlgeschlagen') @@ -348,14 +398,136 @@ export default function ProgressionGraphEditor({ graphId }) { } } - 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.') + 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) => applyGapOfferToSlot(prev, idx, offer)) + 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 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) : '') + 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('') + try { + const planningContext = buildPathGapPlanningContextForAi({ + offer, + ...gapContextParams, + stageLearningGoalOverride: stageGoal, + gapTrainerSupplements: supplements, + }) + 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, + } + setDraft((prev) => { + const next = applyGapOfferToDraft(prev, enrichedOffer, { slotIndex }) + return { ...next, dirty: true } + }) + setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id)) + setGapPrepOpen(false) + setActiveOffer(null) + } catch (e) { + setGapPrepError(e.message || 'KI-Anlage fehlgeschlagen') + } finally { + setGapAiBusy(false) + setGeneratingOfferId(null) + } + } + + 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) { @@ -385,7 +557,7 @@ export default function ProgressionGraphEditor({ graphId }) { {graphMeta?.name || draft.graphName || `Graph #${graphId}`}

    - Slot-Editor · Roadmap = Struktur · ein Primärpfad + Schwestern + Slots verschieben, ergänzen · KI-Angebote zuordnen · dynamisch erweitern (max. {SLOT_MAX})

    @@ -403,7 +575,7 @@ export default function ProgressionGraphEditor({ graphId }) { className="progression-graph-editor-grid" style={{ display: 'grid', - gridTemplateColumns: 'minmax(0, 1fr) minmax(260px, 320px)', + gridTemplateColumns: 'minmax(0, 1fr) minmax(280px, 340px)', gap: '14px', alignItems: 'start', }} @@ -472,22 +644,34 @@ export default function ProgressionGraphEditor({ graphId }) {

    Slots ({draft.slots.length})

    -
    {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} /> ))} @@ -495,10 +679,16 @@ export default function ProgressionGraphEditor({ graphId }) { @@ -511,6 +701,29 @@ export default function ProgressionGraphEditor({ graphId }) { /> ) : null} + { + 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} + /> +