Progression optimiert Phase A #55
|
|
@ -668,6 +668,47 @@ def _evaluate_steps_from_payload(
|
||||||
return steps
|
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(
|
def _run_evaluate_only_path_qa(
|
||||||
cur,
|
cur,
|
||||||
*,
|
*,
|
||||||
|
|
@ -728,6 +769,27 @@ def _run_evaluate_only_path_qa(
|
||||||
brief=semantic_brief,
|
brief=semantic_brief,
|
||||||
goal_query=goal_query,
|
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
|
path_roadmap_snapshot = None
|
||||||
if roadmap_ctx:
|
if roadmap_ctx:
|
||||||
path_roadmap_snapshot = build_progression_gap_snapshot(
|
path_roadmap_snapshot = build_progression_gap_snapshot(
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
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():
|
def test_normalize_rejects_invalid_type():
|
||||||
with pytest.raises(ValueError, match="JSON-Objekt"):
|
with pytest.raises(ValueError, match="JSON-Objekt"):
|
||||||
normalize_planning_roadmap_payload("not-json")
|
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).
|
* 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) {
|
function severityStyle(pathQa) {
|
||||||
if (!pathQa) return {}
|
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({
|
export default function ProgressionFindingsPanel({
|
||||||
pathQa = null,
|
pathQa = null,
|
||||||
gapFillOffers = [],
|
gapFillOffers = [],
|
||||||
|
draft = null,
|
||||||
|
slotCount = 0,
|
||||||
loading = false,
|
loading = false,
|
||||||
error = '',
|
error = '',
|
||||||
onEvaluate,
|
onEvaluate,
|
||||||
onApplyGapOffer,
|
onApplyGapOffer,
|
||||||
|
onInsertGapSlot,
|
||||||
|
onGenerateGapAi,
|
||||||
|
generatingOfferId = null,
|
||||||
|
aiBusy = false,
|
||||||
evaluateDisabled = false,
|
evaluateDisabled = false,
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="card" style={{ position: 'sticky', top: '12px' }}>
|
<div className="card" style={{ position: 'sticky', top: '12px' }}>
|
||||||
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Graph-Bewertung</h3>
|
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Graph-Bewertung</h3>
|
||||||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
|
<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>
|
</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
@ -85,57 +221,39 @@ export default function ProgressionFindingsPanel({
|
||||||
{pathQa.off_topic_count} Schritt(e) ohne Bezug zum Pfad-Thema.
|
{pathQa.off_topic_count} Schritt(e) ohne Bezug zum Pfad-Thema.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{Array.isArray(gapFillOffers) && gapFillOffers.length > 0 ? (
|
<div style={{ marginTop: '14px' }}>
|
||||||
<div style={{ marginTop: '14px' }}>
|
<h4 style={{ margin: '0 0 8px', fontSize: '0.9rem' }}>
|
||||||
<h4 style={{ margin: '0 0 8px', fontSize: '0.9rem' }}>Lücken-Angebote</h4>
|
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' }}>
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
{gapFillOffers.slice(0, 6).map((offer) => (
|
{gapFillOffers.map((offer) => (
|
||||||
<li
|
<GapOfferCard
|
||||||
key={offer.offer_id || offer.title_hint}
|
key={offer.offer_id || `${offer.source}-${offer.title_hint}`}
|
||||||
style={{
|
offer={offer}
|
||||||
padding: '8px 10px',
|
slotCount={slotCount}
|
||||||
borderRadius: '8px',
|
draft={draft}
|
||||||
border: '1px solid var(--border)',
|
generatingOfferId={generatingOfferId}
|
||||||
background: 'var(--surface2)',
|
aiBusy={aiBusy}
|
||||||
fontSize: '12px',
|
onApplyDraft={(o, idx) => onApplyGapOffer(o, idx)}
|
||||||
}}
|
onInsertSlot={onInsertGapSlot}
|
||||||
>
|
onGenerateAi={onGenerateGapAi}
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
)}
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,37 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import ExercisePickerModal from './ExercisePickerModal'
|
import ExercisePickerModal from './ExercisePickerModal'
|
||||||
|
import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal'
|
||||||
import ProgressionSlotCard from './ProgressionSlotCard'
|
import ProgressionSlotCard from './ProgressionSlotCard'
|
||||||
import ProgressionFindingsPanel from './ProgressionFindingsPanel'
|
import ProgressionFindingsPanel from './ProgressionFindingsPanel'
|
||||||
import {
|
import {
|
||||||
applyGapOfferToSlot,
|
aiPreviewToQuickCreateDraft,
|
||||||
|
buildQuickCreateAiPreview,
|
||||||
|
} from '../utils/exerciseAiQuickCreate'
|
||||||
|
import {
|
||||||
|
buildPathGapPlanningContextForAi,
|
||||||
|
gapOfferContextDisplayLines,
|
||||||
|
initialStageLearningGoalFromOffer,
|
||||||
|
} from '../utils/planningContextForExerciseAi'
|
||||||
|
import {
|
||||||
|
addSlotToDraft,
|
||||||
|
applyGapOfferToDraft,
|
||||||
applyMatchStepsToSlots,
|
applyMatchStepsToSlots,
|
||||||
buildPlanningArtifactFromDraft,
|
collectGapOffersFromApiResponse,
|
||||||
hydrateProgressionGraphDraft,
|
hydrateProgressionGraphDraft,
|
||||||
|
insertSlotInDraft,
|
||||||
librarySlotExercise,
|
librarySlotExercise,
|
||||||
majorStepsToOverridePayload,
|
majorStepsToOverridePayload,
|
||||||
reindexMajorSteps,
|
moveSlotInDraft,
|
||||||
|
patchSlotInDraft,
|
||||||
|
removeSlotFromDraft,
|
||||||
saveProgressionGraphDraft,
|
saveProgressionGraphDraft,
|
||||||
|
SLOT_MAX,
|
||||||
|
slotsAsPathStepRows,
|
||||||
slotsToEvaluateSteps,
|
slotsToEvaluateSteps,
|
||||||
|
syncProgressionRoadmapFromSlots,
|
||||||
} from '../utils/progressionGraphDraft'
|
} from '../utils/progressionGraphDraft'
|
||||||
|
|
||||||
const ROADMAP_PHASES = ['einstieg', 'grundlage', 'vertiefung', 'anwendung', 'perfektion']
|
|
||||||
|
|
||||||
function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) {
|
function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) {
|
||||||
const body = {}
|
const body = {}
|
||||||
const start = (startSituation || '').trim()
|
const start = (startSituation || '').trim()
|
||||||
|
|
@ -32,6 +47,16 @@ function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) {
|
||||||
return body
|
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 }) {
|
export default function ProgressionGraphEditor({ graphId }) {
|
||||||
const [graphMeta, setGraphMeta] = useState(null)
|
const [graphMeta, setGraphMeta] = useState(null)
|
||||||
const [draft, setDraft] = useState(null)
|
const [draft, setDraft] = useState(null)
|
||||||
|
|
@ -44,6 +69,20 @@ export default function ProgressionGraphEditor({ graphId }) {
|
||||||
const [evaluating, setEvaluating] = useState(false)
|
const [evaluating, setEvaluating] = useState(false)
|
||||||
const [matching, setMatching] = useState(false)
|
const [matching, setMatching] = useState(false)
|
||||||
const [roadmapLoading, setRoadmapLoading] = 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 () => {
|
const loadGraph = useCallback(async () => {
|
||||||
if (!graphId) return
|
if (!graphId) return
|
||||||
|
|
@ -76,6 +115,21 @@ export default function ProgressionGraphEditor({ graphId }) {
|
||||||
loadGraph()
|
loadGraph()
|
||||||
}, [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) => {
|
const patchDraft = useCallback((patchFn) => {
|
||||||
setDraft((prev) => {
|
setDraft((prev) => {
|
||||||
if (!prev) return 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) => {
|
const handlePickExercise = async (exercise) => {
|
||||||
if (!pickContext || !exercise?.id) return
|
if (!pickContext || !exercise?.id) return
|
||||||
const { slotIndex, role } = pickContext
|
const { slotIndex, role } = pickContext
|
||||||
|
|
@ -99,27 +168,68 @@ export default function ProgressionGraphEditor({ graphId }) {
|
||||||
if (!siblings.some((x) => x.exerciseId === entry.exerciseId)) siblings.push(entry)
|
if (!siblings.some((x) => x.exerciseId === entry.exerciseId)) siblings.push(entry)
|
||||||
return { ...s, siblings }
|
return { ...s, siblings }
|
||||||
})
|
})
|
||||||
return { ...d, slots }
|
return syncProgressionRoadmapFromSlots({ ...d, slots })
|
||||||
})
|
})
|
||||||
setPickContext(null)
|
setPickContext(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePatchLearningGoal = (slotIndex, value) => {
|
const handlePatchLearningGoal = (slotIndex, value) => {
|
||||||
patchDraft((d) => {
|
patchDraft((d) => patchSlotInDraft(d, slotIndex, { learning_goal: value }))
|
||||||
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)),
|
const handlePatchPhase = (slotIndex, value) => {
|
||||||
)
|
patchDraft((d) => patchSlotInDraft(d, slotIndex, { phase: value }))
|
||||||
return { ...d, slots, majorSteps }
|
}
|
||||||
})
|
|
||||||
|
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) => {
|
const handleClearPrimary = (slotIndex) => {
|
||||||
patchDraft((d) => {
|
patchDraft((d) => {
|
||||||
const slots = d.slots.map((s, i) =>
|
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(() => {
|
const validMajorSteps = useMemo(() => {
|
||||||
if (!draft?.slots) return []
|
if (!draft?.slots) return []
|
||||||
return draft.slots.filter((s) => (s.learning_goal || '').trim().length >= 3)
|
return draft.slots.filter((s) => (s.learning_goal || '').trim().length >= 3)
|
||||||
}, [draft?.slots])
|
}, [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 runRoadmapGenerate = async () => {
|
||||||
const q = (draft?.goalQuery || '').trim()
|
const q = (draft?.goalQuery || '').trim()
|
||||||
if (q.length < 3) {
|
if (q.length < 3) {
|
||||||
|
|
@ -200,26 +280,20 @@ export default function ProgressionGraphEditor({ graphId }) {
|
||||||
})
|
})
|
||||||
const roadmap = res?.progression_roadmap
|
const roadmap = res?.progression_roadmap
|
||||||
if (!roadmap) throw new Error('Keine Roadmap in der Antwort')
|
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({
|
const hydrated = hydrateProgressionGraphDraft({
|
||||||
artifact: {
|
artifact: {
|
||||||
...buildPlanningArtifactFromDraft({ ...draft, progressionRoadmap: roadmap }),
|
goal_query: q,
|
||||||
progression_roadmap: roadmap,
|
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: [],
|
edges: [],
|
||||||
graphName: draft.graphName,
|
graphName: draft.graphName,
|
||||||
})
|
})
|
||||||
setDraft({ ...hydrated, goalQuery: q, dirty: true })
|
setDraft({ ...hydrated, goalQuery: q, dirty: true })
|
||||||
|
setSemanticBrief(res?.semantic_brief_summary || null)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setActionErr(e.message || 'Roadmap-Generierung fehlgeschlagen')
|
setActionErr(e.message || 'Roadmap-Generierung fehlgeschlagen')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -240,20 +314,11 @@ export default function ProgressionGraphEditor({ graphId }) {
|
||||||
setMatching(true)
|
setMatching(true)
|
||||||
setActionErr('')
|
setActionErr('')
|
||||||
try {
|
try {
|
||||||
const override = majorStepsToOverridePayload(draft.slots.map((s) => ({
|
const synced = syncProgressionRoadmapFromSlots(draft)
|
||||||
index: s.majorStepIndex,
|
const override = majorStepsToOverridePayload(synced.slots)
|
||||||
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({
|
const res = await api.suggestProgressionPath({
|
||||||
query: q,
|
query: q,
|
||||||
max_steps: validMajorSteps.length,
|
max_steps: synced.slots.length,
|
||||||
include_llm_intent: true,
|
include_llm_intent: true,
|
||||||
include_path_qa: true,
|
include_path_qa: true,
|
||||||
include_llm_path_qa: true,
|
include_llm_path_qa: true,
|
||||||
|
|
@ -268,15 +333,14 @@ export default function ProgressionGraphEditor({ graphId }) {
|
||||||
})
|
})
|
||||||
const next = applyMatchStepsToSlots(
|
const next = applyMatchStepsToSlots(
|
||||||
{
|
{
|
||||||
...draft,
|
...synced,
|
||||||
progressionRoadmap: res?.progression_roadmap || draft.progressionRoadmap,
|
progressionRoadmap: res?.progression_roadmap || synced.progressionRoadmap,
|
||||||
pathSkillExpectations: res?.path_skill_expectations || draft.pathSkillExpectations,
|
pathSkillExpectations: res?.path_skill_expectations || synced.pathSkillExpectations,
|
||||||
},
|
},
|
||||||
res?.steps,
|
res?.steps,
|
||||||
)
|
)
|
||||||
setDraft(next)
|
setDraft(next)
|
||||||
setPathQa(res?.path_qa || null)
|
applyApiResponse(res)
|
||||||
setGapFillOffers(Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : [])
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setActionErr(e.message || 'Übungs-Match fehlgeschlagen')
|
setActionErr(e.message || 'Übungs-Match fehlgeschlagen')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -293,38 +357,24 @@ export default function ProgressionGraphEditor({ graphId }) {
|
||||||
setEvaluating(true)
|
setEvaluating(true)
|
||||||
setActionErr('')
|
setActionErr('')
|
||||||
try {
|
try {
|
||||||
|
const synced = syncProgressionRoadmapFromSlots(draft)
|
||||||
const override =
|
const override =
|
||||||
validMajorSteps.length >= 2
|
validMajorSteps.length >= 2 ? majorStepsToOverridePayload(synced.slots) : undefined
|
||||||
? 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({
|
const res = await api.suggestProgressionPath({
|
||||||
query: q,
|
query: q,
|
||||||
max_steps: draft.slots.length || draft.maxSteps || 5,
|
max_steps: synced.slots.length || draft.maxSteps || 5,
|
||||||
include_path_qa: true,
|
include_path_qa: true,
|
||||||
include_llm_path_qa: true,
|
include_llm_path_qa: true,
|
||||||
include_ai_gap_fill: true,
|
include_ai_gap_fill: true,
|
||||||
include_path_reorder: false,
|
include_path_reorder: false,
|
||||||
include_llm_intent: false,
|
include_llm_intent: false,
|
||||||
evaluate_only: true,
|
evaluate_only: true,
|
||||||
evaluate_steps: slotsToEvaluateSteps(draft),
|
evaluate_steps: slotsToEvaluateSteps(synced),
|
||||||
roadmap_override: override,
|
roadmap_override: override,
|
||||||
progression_graph_id: Number(graphId),
|
progression_graph_id: Number(graphId),
|
||||||
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
|
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
|
||||||
})
|
})
|
||||||
setPathQa(res?.path_qa || null)
|
applyApiResponse(res)
|
||||||
setGapFillOffers(Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : [])
|
|
||||||
setDraft((prev) => (prev ? { ...prev, lastFindings: res?.path_qa || null } : prev))
|
setDraft((prev) => (prev ? { ...prev, lastFindings: res?.path_qa || null } : prev))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setActionErr(e.message || 'Bewertung fehlgeschlagen')
|
setActionErr(e.message || 'Bewertung fehlgeschlagen')
|
||||||
|
|
@ -348,14 +398,136 @@ export default function ProgressionGraphEditor({ graphId }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleApplyGapOffer = (offer) => {
|
const handleApplyGapOffer = (offer, slotIndex) => {
|
||||||
const idx =
|
setDraft((prev) => {
|
||||||
offer?.roadmap_major_step_index != null ? Number(offer.roadmap_major_step_index) : null
|
const next = applyGapOfferToDraft(prev, offer, { slotIndex })
|
||||||
if (idx == null || !Number.isFinite(idx)) {
|
return { ...next, dirty: true }
|
||||||
alert('Angebot ohne Slot-Zuordnung — bitte manuell zuweisen.')
|
})
|
||||||
|
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
|
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) {
|
if (loadErr) {
|
||||||
|
|
@ -385,7 +557,7 @@ export default function ProgressionGraphEditor({ graphId }) {
|
||||||
{graphMeta?.name || draft.graphName || `Graph #${graphId}`}
|
{graphMeta?.name || draft.graphName || `Graph #${graphId}`}
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ margin: '4px 0 0', fontSize: '12px', color: 'var(--text3)' }}>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link to="/exercises" className="btn btn-secondary" style={{ fontSize: '12px' }}>
|
<Link to="/exercises" className="btn btn-secondary" style={{ fontSize: '12px' }}>
|
||||||
|
|
@ -403,7 +575,7 @@ export default function ProgressionGraphEditor({ graphId }) {
|
||||||
className="progression-graph-editor-grid"
|
className="progression-graph-editor-grid"
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'minmax(0, 1fr) minmax(260px, 320px)',
|
gridTemplateColumns: 'minmax(0, 1fr) minmax(280px, 340px)',
|
||||||
gap: '14px',
|
gap: '14px',
|
||||||
alignItems: 'start',
|
alignItems: 'start',
|
||||||
}}
|
}}
|
||||||
|
|
@ -472,22 +644,34 @@ export default function ProgressionGraphEditor({ graphId }) {
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||||||
<h3 style={{ margin: 0, fontSize: '1rem' }}>Slots ({draft.slots.length})</h3>
|
<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}>
|
<button
|
||||||
Slot hinzufügen
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ fontSize: '12px' }}
|
||||||
|
disabled={busy || draft.slots.length >= SLOT_MAX}
|
||||||
|
onClick={handleAddSlot}
|
||||||
|
>
|
||||||
|
Slot am Ende
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{draft.slots.map((slot, idx) => (
|
{draft.slots.map((slot, idx) => (
|
||||||
<ProgressionSlotCard
|
<ProgressionSlotCard
|
||||||
key={`slot-${slot.majorStepIndex}-${idx}`}
|
key={`slot-${idx}-${slot.learning_goal?.slice(0, 12) || 'x'}`}
|
||||||
slot={slot}
|
slot={slot}
|
||||||
slotIndex={idx}
|
slotIndex={idx}
|
||||||
|
slotCount={draft.slots.length}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onPickPrimary={(i) => setPickContext({ slotIndex: i, role: 'primary' })}
|
onPickPrimary={(i) => setPickContext({ slotIndex: i, role: 'primary' })}
|
||||||
onPickSibling={(i) => setPickContext({ slotIndex: i, role: 'sibling' })}
|
onPickSibling={(i) => setPickContext({ slotIndex: i, role: 'sibling' })}
|
||||||
onClearPrimary={handleClearPrimary}
|
onClearPrimary={handleClearPrimary}
|
||||||
onRemoveSibling={handleRemoveSibling}
|
onRemoveSibling={handleRemoveSibling}
|
||||||
onPatchLearningGoal={handlePatchLearningGoal}
|
onPatchLearningGoal={handlePatchLearningGoal}
|
||||||
|
onPatchPhase={handlePatchPhase}
|
||||||
|
onMoveUp={(i) => handleMoveSlot(i, -1)}
|
||||||
|
onMoveDown={(i) => handleMoveSlot(i, 1)}
|
||||||
|
onRemoveSlot={handleRemoveSlot}
|
||||||
|
onInsertAfter={handleInsertAfter}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -495,10 +679,16 @@ export default function ProgressionGraphEditor({ graphId }) {
|
||||||
<ProgressionFindingsPanel
|
<ProgressionFindingsPanel
|
||||||
pathQa={pathQa}
|
pathQa={pathQa}
|
||||||
gapFillOffers={gapFillOffers}
|
gapFillOffers={gapFillOffers}
|
||||||
|
draft={draft}
|
||||||
|
slotCount={draft.slots.length}
|
||||||
loading={evaluating}
|
loading={evaluating}
|
||||||
error={evaluating ? '' : ''}
|
error=""
|
||||||
onEvaluate={runEvaluate}
|
onEvaluate={runEvaluate}
|
||||||
onApplyGapOffer={handleApplyGapOffer}
|
onApplyGapOffer={handleApplyGapOffer}
|
||||||
|
onInsertGapSlot={handleInsertGapSlot}
|
||||||
|
onGenerateGapAi={openGapFillPrep}
|
||||||
|
generatingOfferId={generatingOfferId}
|
||||||
|
aiBusy={gapAiBusy}
|
||||||
evaluateDisabled={busy || !draft.goalQuery?.trim()}
|
evaluateDisabled={busy || !draft.goalQuery?.trim()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -511,6 +701,29 @@ export default function ProgressionGraphEditor({ graphId }) {
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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>{`
|
<style>{`
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.progression-graph-editor-grid {
|
.progression-graph-editor-grid {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
*/
|
*/
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
import { ROADMAP_PHASES } from '../utils/progressionGraphDraft'
|
||||||
|
|
||||||
function exerciseLabel(entry) {
|
function exerciseLabel(entry) {
|
||||||
if (!entry || entry.kind === 'empty') return '— noch leer —'
|
if (!entry || entry.kind === 'empty') return '— noch leer —'
|
||||||
|
|
@ -13,11 +14,17 @@ function exerciseLabel(entry) {
|
||||||
export default function ProgressionSlotCard({
|
export default function ProgressionSlotCard({
|
||||||
slot,
|
slot,
|
||||||
slotIndex,
|
slotIndex,
|
||||||
|
slotCount = 1,
|
||||||
onPickPrimary,
|
onPickPrimary,
|
||||||
onPickSibling,
|
onPickSibling,
|
||||||
onClearPrimary,
|
onClearPrimary,
|
||||||
onRemoveSibling,
|
onRemoveSibling,
|
||||||
onPatchLearningGoal,
|
onPatchLearningGoal,
|
||||||
|
onPatchPhase,
|
||||||
|
onMoveUp,
|
||||||
|
onMoveDown,
|
||||||
|
onRemoveSlot,
|
||||||
|
onInsertAfter,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) {
|
}) {
|
||||||
const { primary, siblings = [], phase, learning_goal: learningGoal } = slot
|
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 style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '8px', flexWrap: 'wrap' }}>
|
||||||
<div>
|
<div>
|
||||||
<h4 style={{ margin: 0, fontSize: '0.95rem' }}>
|
<h4 style={{ margin: 0, fontSize: '0.95rem' }}>Slot {slotIndex + 1}</h4>
|
||||||
Slot {slotIndex + 1}
|
</div>
|
||||||
{phase ? <span style={{ color: 'var(--text3)', fontWeight: 400 }}>{` · ${phase}`}</span> : null}
|
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
</h4>
|
<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>
|
</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>
|
||||||
|
|
||||||
<div className="form-row" style={{ marginTop: '10px', marginBottom: '8px' }}>
|
<div className="form-row" style={{ marginTop: '10px', marginBottom: '8px', display: 'grid', gridTemplateColumns: '1fr 140px', gap: '8px' }}>
|
||||||
<label className="form-label">Lernziel (Major Step)</label>
|
<div>
|
||||||
<input
|
<label className="form-label">Lernziel (Major Step)</label>
|
||||||
className="form-input"
|
<input
|
||||||
value={learningGoal || ''}
|
className="form-input"
|
||||||
disabled={disabled}
|
value={learningGoal || ''}
|
||||||
onChange={(e) => onPatchLearningGoal(slotIndex, e.target.value)}
|
disabled={disabled}
|
||||||
placeholder="Was soll in dieser Stufe erreicht werden?"
|
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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
@ -144,16 +197,27 @@ export default function ProgressionSlotCard({
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
<button
|
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||||||
type="button"
|
<button
|
||||||
className="btn btn-secondary"
|
type="button"
|
||||||
style={{ fontSize: '12px', padding: '4px 10px' }}
|
className="btn btn-secondary"
|
||||||
disabled={disabled || primary.kind !== 'library'}
|
style={{ fontSize: '12px', padding: '4px 10px' }}
|
||||||
onClick={() => onPickSibling(slotIndex)}
|
disabled={disabled || primary.kind !== 'library'}
|
||||||
title={primary.kind !== 'library' ? 'Zuerst eine Hauptübung wählen' : undefined}
|
onClick={() => onPickSibling(slotIndex)}
|
||||||
>
|
title={primary.kind !== 'library' ? 'Zuerst eine Hauptübung wählen' : undefined}
|
||||||
Schwester hinzufügen
|
>
|
||||||
</button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,191 @@
|
||||||
* Progressionsgraph Slot-Editor — Draft-Hydration und Speichern (Phase B).
|
* 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
|
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() {
|
export function emptySlotExercise() {
|
||||||
return {
|
return {
|
||||||
kind: 'empty',
|
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) {
|
export function applyGapOfferToSlot(draft, slotIndex, offer, aiSuggestion = null) {
|
||||||
const nextSlots = (draft.slots || []).map((s) => ({ ...s, primary: { ...s.primary }, siblings: [...(s.siblings || [])] }))
|
const nextSlots = (draft.slots || []).map((s) => ({ ...s, primary: { ...s.primary }, siblings: [...(s.siblings || [])] }))
|
||||||
if (slotIndex < 0 || slotIndex >= nextSlots.length) return draft
|
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({
|
nextSlots[slotIndex].primary = proposalSlotExercise({
|
||||||
title: offer?.title_hint || offer?.title || 'KI-Vorschlag',
|
title,
|
||||||
proposalKey: offer?.offer_id || offer?.proposal_key,
|
proposalKey: offer?.proposal_key || offer?.offer_id || null,
|
||||||
aiSuggestion: aiSuggestion || offer?.ai_suggestion || 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) {
|
export async function saveProgressionGraphDraft(api, graphId, draft) {
|
||||||
const primarySteps = draftPrimaryChainSteps(draft)
|
const synced = syncProgressionRoadmapFromSlots(draft)
|
||||||
const siblingPairs = draftSiblingEdgePairs(draft)
|
const primarySteps = draftPrimaryChainSteps(synced)
|
||||||
const artifact = buildPlanningArtifactFromDraft(draft)
|
const siblingPairs = draftSiblingEdgePairs(synced)
|
||||||
const edgeIds = [
|
const artifact = buildPlanningArtifactFromDraft(synced)
|
||||||
...(draft.primaryChainEdgeIds || []),
|
|
||||||
...(draft.siblingEdgeIds || []),
|
|
||||||
].filter((id) => Number.isFinite(Number(id)))
|
|
||||||
|
|
||||||
if (edgeIds.length > 0) {
|
// Kanten frisch laden — hydrate-Arrays können nach Zwischen-Speichern veraltet sein.
|
||||||
await api.deleteExerciseProgressionEdgesBatch(Number(graphId), edgeIds)
|
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 (primarySteps.length >= 2) {
|
||||||
|
if (nextEdgeIds.length > 0) {
|
||||||
|
await api.deleteExerciseProgressionEdgesBatch(Number(graphId), nextEdgeIds)
|
||||||
|
}
|
||||||
await api.createExerciseProgressionSequence(Number(graphId), {
|
await api.createExerciseProgressionSequence(Number(graphId), {
|
||||||
steps: primarySteps.map((s) => ({
|
steps: primarySteps.map((s) => ({
|
||||||
exercise_id: s.exerciseId,
|
exercise_id: s.exerciseId,
|
||||||
|
|
@ -523,10 +745,12 @@ export async function saveProgressionGraphDraft(api, graphId, draft) {
|
||||||
segment_notes: primarySteps.slice(1).map(() => null),
|
segment_notes: primarySteps.slice(1).map(() => null),
|
||||||
...(artifact ? { planning_roadmap: artifact } : {}),
|
...(artifact ? { planning_roadmap: artifact } : {}),
|
||||||
})
|
})
|
||||||
} else if (artifact) {
|
artifactPersisted = Boolean(artifact)
|
||||||
await api.updateExerciseProgressionGraph(Number(graphId), { planning_roadmap: artifact })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (siblingEdgeIds.length > 0) {
|
||||||
|
await api.deleteExerciseProgressionEdgesBatch(Number(graphId), siblingEdgeIds)
|
||||||
|
}
|
||||||
for (const pair of siblingPairs) {
|
for (const pair of siblingPairs) {
|
||||||
await api.createExerciseProgressionEdge(Number(graphId), {
|
await api.createExerciseProgressionEdge(Number(graphId), {
|
||||||
from_exercise_id: pair.from.exerciseId,
|
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 }
|
return { primaryCount: primarySteps.length, siblingCount: siblingPairs.length }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user