Add Gap Offer Handling and UI Enhancements in Progression Graph Components
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m18s
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m18s
- 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.
This commit is contained in:
parent
97efe66306
commit
c1bf9279ad
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<li
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface2)',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
<span className="exercise-tag" style={{ marginBottom: '6px', display: 'inline-block' }}>
|
||||
{offerSourceLabel(offer.source)}
|
||||
{offer.phase ? ` · ${offer.phase}` : ''}
|
||||
{offer.has_ai_payload ? ' · KI-Entwurf bereit' : ''}
|
||||
</span>
|
||||
<div style={{ fontWeight: 600 }}>{offer.title_hint || offer.proposal_title || 'Übungsvorschlag'}</div>
|
||||
{offer.rationale ? (
|
||||
<p style={{ margin: '4px 0 0', color: 'var(--text2)' }}>{offer.rationale}</p>
|
||||
) : null}
|
||||
{offer.from_title && offer.to_title ? (
|
||||
<p style={{ margin: '4px 0 0', color: 'var(--text3)', fontSize: '11px' }}>
|
||||
Zwischen „{offer.from_title}“ und „{offer.to_title}“
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div style={{ marginTop: '8px', display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
|
||||
<label style={{ fontSize: '11px', color: 'var(--text3)' }}>
|
||||
Ziel-Slot
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ marginLeft: '6px', padding: '4px 8px', fontSize: '12px', minWidth: '100px' }}
|
||||
value={slotPick}
|
||||
onChange={(e) => setSlotPick(e.target.value)}
|
||||
>
|
||||
<option value="">— wählen —</option>
|
||||
{slotOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '8px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{offer.has_ai_payload ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: '11px', padding: '4px 8px' }}
|
||||
onClick={() => applyToSlot()}
|
||||
>
|
||||
Entwurf in Slot
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: '11px', padding: '4px 8px' }}
|
||||
disabled={aiBusy}
|
||||
onClick={() => onGenerateAi(offer, slotPick !== '' ? Number(slotPick) : defaultSlot)}
|
||||
>
|
||||
{generatingOfferId === offer.offer_id ? 'KI erstellt…' : 'KI anlegen'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '11px', padding: '4px 8px' }}
|
||||
onClick={() => applyToSlot()}
|
||||
>
|
||||
Platzhalter in Slot
|
||||
</button>
|
||||
{needsInsert ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '11px', padding: '4px 8px' }}
|
||||
disabled={!canInsert}
|
||||
title={!canInsert ? `Maximal ${slotCount} Slots` : 'Neuen Slot zwischen zwei Stufen einfügen'}
|
||||
onClick={() => onInsertSlot(offer)}
|
||||
>
|
||||
Neuen Slot einfügen
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="card" style={{ position: 'sticky', top: '12px' }}>
|
||||
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Graph-Bewertung</h3>
|
||||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<button
|
||||
|
|
@ -85,57 +221,39 @@ export default function ProgressionFindingsPanel({
|
|||
{pathQa.off_topic_count} Schritt(e) ohne Bezug zum Pfad-Thema.
|
||||
</p>
|
||||
) : null}
|
||||
{pathQa.roadmap_qa_mode === 'roadmap_first_lite' ? (
|
||||
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>
|
||||
QS an Roadmap gekoppelt (keine Brücken zwischen Major Steps).
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>
|
||||
Noch keine Bewertung. Slots befüllen und „Graph bewerten“ ausführen.
|
||||
Noch keine Bewertung. Roadmap anlegen, dann „Graph bewerten“ oder „Übungen matchen“.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{Array.isArray(gapFillOffers) && gapFillOffers.length > 0 ? (
|
||||
<div style={{ marginTop: '14px' }}>
|
||||
<h4 style={{ margin: '0 0 8px', fontSize: '0.9rem' }}>Lücken-Angebote</h4>
|
||||
<div style={{ marginTop: '14px' }}>
|
||||
<h4 style={{ margin: '0 0 8px', fontSize: '0.9rem' }}>
|
||||
KI-Angebote {gapFillOffers.length > 0 ? `(${gapFillOffers.length})` : ''}
|
||||
</h4>
|
||||
{gapFillOffers.length === 0 ? (
|
||||
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>
|
||||
Keine offenen Angebote. Nach Match oder Bewertung erscheinen Vorschläge für leere Slots und Lücken.
|
||||
</p>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{gapFillOffers.slice(0, 6).map((offer) => (
|
||||
<li
|
||||
key={offer.offer_id || offer.title_hint}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface2)',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
{offer.title_hint || 'Übungsvorschlag'}
|
||||
{offer.roadmap_major_step_index != null
|
||||
? ` · Slot ${Number(offer.roadmap_major_step_index) + 1}`
|
||||
: ''}
|
||||
</div>
|
||||
{offer.rationale ? (
|
||||
<p style={{ margin: '4px 0 0', color: 'var(--text2)' }}>{offer.rationale}</p>
|
||||
) : null}
|
||||
{typeof onApplyGapOffer === 'function' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ marginTop: '6px', fontSize: '11px', padding: '4px 8px' }}
|
||||
onClick={() => onApplyGapOffer(offer)}
|
||||
>
|
||||
Als Entwurf in Slot
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
{gapFillOffers.map((offer) => (
|
||||
<GapOfferCard
|
||||
key={offer.offer_id || `${offer.source}-${offer.title_hint}`}
|
||||
offer={offer}
|
||||
slotCount={slotCount}
|
||||
draft={draft}
|
||||
generatingOfferId={generatingOfferId}
|
||||
aiBusy={aiBusy}
|
||||
onApplyDraft={(o, idx) => onApplyGapOffer(o, idx)}
|
||||
onInsertSlot={onInsertGapSlot}
|
||||
onGenerateAi={onGenerateGapAi}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
</h2>
|
||||
<p style={{ margin: '4px 0 0', fontSize: '12px', color: 'var(--text3)' }}>
|
||||
Slot-Editor · Roadmap = Struktur · ein Primärpfad + Schwestern
|
||||
Slots verschieben, ergänzen · KI-Angebote zuordnen · dynamisch erweitern (max. {SLOT_MAX})
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/exercises" className="btn btn-secondary" style={{ fontSize: '12px' }}>
|
||||
|
|
@ -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 }) {
|
|||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '1rem' }}>Slots ({draft.slots.length})</h3>
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: '12px' }} disabled={busy || draft.slots.length >= 10} onClick={handleAddSlot}>
|
||||
Slot hinzufügen
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '12px' }}
|
||||
disabled={busy || draft.slots.length >= SLOT_MAX}
|
||||
onClick={handleAddSlot}
|
||||
>
|
||||
Slot am Ende
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{draft.slots.map((slot, idx) => (
|
||||
<ProgressionSlotCard
|
||||
key={`slot-${slot.majorStepIndex}-${idx}`}
|
||||
key={`slot-${idx}-${slot.learning_goal?.slice(0, 12) || 'x'}`}
|
||||
slot={slot}
|
||||
slotIndex={idx}
|
||||
slotCount={draft.slots.length}
|
||||
disabled={busy}
|
||||
onPickPrimary={(i) => 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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -495,10 +679,16 @@ export default function ProgressionGraphEditor({ graphId }) {
|
|||
<ProgressionFindingsPanel
|
||||
pathQa={pathQa}
|
||||
gapFillOffers={gapFillOffers}
|
||||
draft={draft}
|
||||
slotCount={draft.slots.length}
|
||||
loading={evaluating}
|
||||
error={evaluating ? '' : ''}
|
||||
error=""
|
||||
onEvaluate={runEvaluate}
|
||||
onApplyGapOffer={handleApplyGapOffer}
|
||||
onInsertGapSlot={handleInsertGapSlot}
|
||||
onGenerateGapAi={openGapFillPrep}
|
||||
generatingOfferId={generatingOfferId}
|
||||
aiBusy={gapAiBusy}
|
||||
evaluateDisabled={busy || !draft.goalQuery?.trim()}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -511,6 +701,29 @@ export default function ProgressionGraphEditor({ graphId }) {
|
|||
/>
|
||||
) : null}
|
||||
|
||||
<ExerciseGapFillPrepModal
|
||||
open={gapPrepOpen}
|
||||
offer={activeOffer}
|
||||
onClose={() => {
|
||||
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}
|
||||
/>
|
||||
|
||||
<style>{`
|
||||
@media (max-width: 900px) {
|
||||
.progression-graph-editor-grid {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
*/
|
||||
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 —'
|
||||
|
|
@ -13,11 +14,17 @@ function exerciseLabel(entry) {
|
|||
export default function ProgressionSlotCard({
|
||||
slot,
|
||||
slotIndex,
|
||||
slotCount = 1,
|
||||
onPickPrimary,
|
||||
onPickSibling,
|
||||
onClearPrimary,
|
||||
onRemoveSibling,
|
||||
onPatchLearningGoal,
|
||||
onPatchPhase,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onRemoveSlot,
|
||||
onInsertAfter,
|
||||
disabled = false,
|
||||
}) {
|
||||
const { primary, siblings = [], phase, learning_goal: learningGoal } = slot
|
||||
|
|
@ -34,31 +41,77 @@ export default function ProgressionSlotCard({
|
|||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<h4 style={{ margin: 0, fontSize: '0.95rem' }}>
|
||||
Slot {slotIndex + 1}
|
||||
{phase ? <span style={{ color: 'var(--text3)', fontWeight: 400 }}>{` · ${phase}`}</span> : null}
|
||||
</h4>
|
||||
<h4 style={{ margin: 0, fontSize: '0.95rem' }}>Slot {slotIndex + 1}</h4>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<span
|
||||
className="exercise-tag"
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
borderColor: primary.kind === 'proposal' ? 'var(--danger)' : undefined,
|
||||
}}
|
||||
>
|
||||
{primary.kind === 'empty' ? 'leer' : primary.kind === 'proposal' ? 'KI-Entwurf' : 'Bibliothek'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ fontSize: '11px', padding: '2px 6px' }}
|
||||
disabled={disabled || slotIndex === 0}
|
||||
onClick={() => onMoveUp(slotIndex)}
|
||||
title="Nach oben"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ fontSize: '11px', padding: '2px 6px' }}
|
||||
disabled={disabled || slotIndex >= slotCount - 1}
|
||||
onClick={() => onMoveDown(slotIndex)}
|
||||
title="Nach unten"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ fontSize: '11px', padding: '2px 6px' }}
|
||||
disabled={disabled || slotCount <= 2}
|
||||
onClick={() => onRemoveSlot(slotIndex)}
|
||||
title="Slot entfernen"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
className="exercise-tag"
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
borderColor: primary.kind === 'proposal' ? 'var(--danger)' : undefined,
|
||||
}}
|
||||
>
|
||||
{primary.kind === 'empty' ? 'leer' : primary.kind === 'proposal' ? 'KI-Entwurf' : 'Bibliothek'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="form-row" style={{ marginTop: '10px', marginBottom: '8px' }}>
|
||||
<label className="form-label">Lernziel (Major Step)</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={learningGoal || ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onPatchLearningGoal(slotIndex, e.target.value)}
|
||||
placeholder="Was soll in dieser Stufe erreicht werden?"
|
||||
/>
|
||||
<div className="form-row" style={{ marginTop: '10px', marginBottom: '8px', display: 'grid', gridTemplateColumns: '1fr 140px', gap: '8px' }}>
|
||||
<div>
|
||||
<label className="form-label">Lernziel (Major Step)</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={learningGoal || ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onPatchLearningGoal(slotIndex, e.target.value)}
|
||||
placeholder="Was soll in dieser Stufe erreicht werden?"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Phase</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={phase || 'vertiefung'}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onPatchPhase(slotIndex, e.target.value)}
|
||||
>
|
||||
{ROADMAP_PHASES.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
@ -144,16 +197,27 @@ export default function ProgressionSlotCard({
|
|||
))}
|
||||
</ul>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '12px', padding: '4px 10px' }}
|
||||
disabled={disabled || primary.kind !== 'library'}
|
||||
onClick={() => onPickSibling(slotIndex)}
|
||||
title={primary.kind !== 'library' ? 'Zuerst eine Hauptübung wählen' : undefined}
|
||||
>
|
||||
Schwester hinzufügen
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '12px', padding: '4px 10px' }}
|
||||
disabled={disabled || primary.kind !== 'library'}
|
||||
onClick={() => onPickSibling(slotIndex)}
|
||||
title={primary.kind !== 'library' ? 'Zuerst eine Hauptübung wählen' : undefined}
|
||||
>
|
||||
Schwester hinzufügen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ fontSize: '12px', padding: '4px 10px' }}
|
||||
disabled={disabled || slotCount >= 10}
|
||||
onClick={() => onInsertAfter(slotIndex)}
|
||||
>
|
||||
Slot darunter einfügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,9 +2,191 @@
|
|||
* Progressionsgraph Slot-Editor — Draft-Hydration und Speichern (Phase B).
|
||||
*/
|
||||
|
||||
const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion']
|
||||
export const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion']
|
||||
export const SLOT_MAX = 10
|
||||
export const PLANNING_ARTIFACT_SCHEMA = 1
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function collectGapOffersFromApiResponse(res) {
|
||||
const qa = res?.path_qa || {}
|
||||
const merged = [
|
||||
...(Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : []),
|
||||
...(Array.isArray(qa?.gap_fill_offers) ? qa.gap_fill_offers : []),
|
||||
]
|
||||
const seen = new Set()
|
||||
return merged.filter((offer) => {
|
||||
const key = offer?.offer_id || `${offer?.source}-${offer?.title_hint}`
|
||||
if (!key || seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function slotsAsPathStepRows(draft) {
|
||||
return (draft.slots || []).map((slot) => ({
|
||||
exerciseId: slot.primary?.exerciseId ?? null,
|
||||
exerciseTitle: slot.primary?.exerciseTitle || '',
|
||||
roadmapMajorStepIndex: slot.majorStepIndex,
|
||||
roadmapPhase: slot.phase,
|
||||
roadmapLearningGoal: slot.learning_goal,
|
||||
isAiProposal: slot.primary?.kind === 'proposal',
|
||||
}))
|
||||
}
|
||||
|
||||
export function emptySlotExercise() {
|
||||
return {
|
||||
kind: 'empty',
|
||||
|
|
@ -487,34 +669,74 @@ export function applyMatchStepsToSlots(draft, apiSteps) {
|
|||
}
|
||||
}
|
||||
|
||||
return { ...draft, slots: nextSlots, dirty: true }
|
||||
return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, 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: offer?.title_hint || offer?.title || 'KI-Vorschlag',
|
||||
proposalKey: offer?.offer_id || offer?.proposal_key,
|
||||
title,
|
||||
proposalKey: offer?.proposal_key || offer?.offer_id || null,
|
||||
aiSuggestion: aiSuggestion || offer?.ai_suggestion || null,
|
||||
})
|
||||
return { ...draft, slots: nextSlots, dirty: true }
|
||||
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 primarySteps = draftPrimaryChainSteps(draft)
|
||||
const siblingPairs = draftSiblingEdgePairs(draft)
|
||||
const artifact = buildPlanningArtifactFromDraft(draft)
|
||||
const edgeIds = [
|
||||
...(draft.primaryChainEdgeIds || []),
|
||||
...(draft.siblingEdgeIds || []),
|
||||
].filter((id) => Number.isFinite(Number(id)))
|
||||
const synced = syncProgressionRoadmapFromSlots(draft)
|
||||
const primarySteps = draftPrimaryChainSteps(synced)
|
||||
const siblingPairs = draftSiblingEdgePairs(synced)
|
||||
const artifact = buildPlanningArtifactFromDraft(synced)
|
||||
|
||||
if (edgeIds.length > 0) {
|
||||
await api.deleteExerciseProgressionEdgesBatch(Number(graphId), edgeIds)
|
||||
}
|
||||
// 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,
|
||||
|
|
@ -523,10 +745,12 @@ export async function saveProgressionGraphDraft(api, graphId, draft) {
|
|||
segment_notes: primarySteps.slice(1).map(() => null),
|
||||
...(artifact ? { planning_roadmap: artifact } : {}),
|
||||
})
|
||||
} else if (artifact) {
|
||||
await api.updateExerciseProgressionGraph(Number(graphId), { 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,
|
||||
|
|
@ -537,5 +761,9 @@ export async function saveProgressionGraphDraft(api, graphId, draft) {
|
|||
})
|
||||
}
|
||||
|
||||
if (artifact && !artifactPersisted) {
|
||||
await api.updateExerciseProgressionGraph(Number(graphId), { planning_roadmap: artifact })
|
||||
}
|
||||
|
||||
return { primaryCount: primarySteps.length, siblingCount: siblingPairs.length }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user