Add AI Suggestion Handling for Roadmap Gaps and Enhance Progression Graph Components
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 45s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m17s
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 45s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m17s
- Implemented functions to resolve neighboring steps based on major indices and build AI context for unfilled roadmap stages. - Enhanced `try_suggest_ai_stage_step` to generate AI proposals for empty roadmap stages, improving user experience in gap filling. - Updated `build_gap_fill_offer` to utilize major step neighbors for better context in offers related to unfilled slots. - Added tests to ensure correct functionality of AI suggestion handling in the context of roadmap gaps. - Incremented application version to reflect these updates.
This commit is contained in:
parent
ee22b22970
commit
e0ddfa6ce5
|
|
@ -18,6 +18,144 @@ from planning_exercise_semantics import PlanningSemanticBrief, brief_to_summary_
|
||||||
_logger = logging.getLogger("shinkan.planning_exercise_path_ai_fill")
|
_logger = logging.getLogger("shinkan.planning_exercise_path_ai_fill")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_neighbor_steps_by_major_index(
|
||||||
|
steps: Sequence[Mapping[str, Any]],
|
||||||
|
major_idx: int,
|
||||||
|
) -> Tuple[Optional[Mapping[str, Any]], Optional[Mapping[str, Any]]]:
|
||||||
|
"""Nachbarn im Pfad anhand roadmap_major_step_index (nicht Array-Position)."""
|
||||||
|
step_before: Optional[Mapping[str, Any]] = None
|
||||||
|
step_after: Optional[Mapping[str, Any]] = None
|
||||||
|
for step in steps:
|
||||||
|
raw = step.get("roadmap_major_step_index")
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
mi = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if mi < major_idx:
|
||||||
|
step_before = step
|
||||||
|
elif mi > major_idx and step_after is None:
|
||||||
|
step_after = step
|
||||||
|
return step_before, step_after
|
||||||
|
|
||||||
|
|
||||||
|
def _build_stage_ai_context(
|
||||||
|
*,
|
||||||
|
goal_query: str,
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
spec: Mapping[str, Any],
|
||||||
|
step_before: Optional[Mapping[str, Any]] = None,
|
||||||
|
step_after: Optional[Mapping[str, Any]] = None,
|
||||||
|
) -> ExerciseFormAiPromptContext:
|
||||||
|
"""KI-Kontext für unbesetzte Roadmap-Stufe (keine Brücke zwischen falschen Array-Indizes)."""
|
||||||
|
gap = dict(spec.get("gap") or {})
|
||||||
|
phase = spec.get("phase") or gap.get("expected_phase") or "vertiefung"
|
||||||
|
topic = (brief.primary_topic or "Technik").strip()
|
||||||
|
learning_goal = (
|
||||||
|
gap.get("learning_goal")
|
||||||
|
or spec.get("title_hint")
|
||||||
|
or spec.get("sketch")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
title = (spec.get("title_hint") or f"{topic} — {phase}").strip()[:280]
|
||||||
|
goal_parts = [
|
||||||
|
f"Planungsziel: {goal_query}",
|
||||||
|
f"Roadmap-Stufe ({phase}): {learning_goal}",
|
||||||
|
"Erstelle eine Übung, die dieses Stufen-Lernziel erfüllt — keine generische Brücken-Übung.",
|
||||||
|
]
|
||||||
|
if step_before:
|
||||||
|
goal_parts.append(
|
||||||
|
f"Vorherige Stufe im Pfad: „{(step_before.get('title') or '').strip()}“"
|
||||||
|
)
|
||||||
|
if step_after:
|
||||||
|
goal_parts.append(
|
||||||
|
f"Nächste Stufe im Pfad: „{(step_after.get('title') or '').strip()}“"
|
||||||
|
)
|
||||||
|
sketch = (spec.get("sketch") or "").strip()
|
||||||
|
if sketch and sketch != learning_goal:
|
||||||
|
goal_parts.extend(["", f"Kontext: {sketch}"])
|
||||||
|
goal = "\n".join(goal_parts)
|
||||||
|
|
||||||
|
focus_hint = topic if brief.topic_type == "technique" else None
|
||||||
|
if brief.must_phrases:
|
||||||
|
focus_hint = ", ".join(brief.must_phrases[:2])
|
||||||
|
|
||||||
|
return ExerciseFormAiPromptContext(
|
||||||
|
title=title[:280],
|
||||||
|
goal=goal[:8000],
|
||||||
|
execution=None,
|
||||||
|
focus_hint=focus_hint,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def try_suggest_ai_stage_step(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
goal_query: str,
|
||||||
|
brief: PlanningSemanticBrief,
|
||||||
|
spec: Mapping[str, Any],
|
||||||
|
steps: Sequence[Mapping[str, Any]],
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""KI-Vorschlag für leere Roadmap-Stufe."""
|
||||||
|
major_idx = spec.get("roadmap_major_step_index")
|
||||||
|
if major_idx is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
mi = int(major_idx)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
step_before, step_after = _resolve_neighbor_steps_by_major_index(steps, mi)
|
||||||
|
gap = dict(spec.get("gap") or {})
|
||||||
|
if not gap.get("expected_phase"):
|
||||||
|
gap["expected_phase"] = spec.get("phase") or "vertiefung"
|
||||||
|
gap["roadmap_major_step_index"] = mi
|
||||||
|
if not gap.get("learning_goal"):
|
||||||
|
gap["learning_goal"] = spec.get("title_hint") or spec.get("sketch")
|
||||||
|
|
||||||
|
ctx = _build_stage_ai_context(
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=brief,
|
||||||
|
spec=spec,
|
||||||
|
step_before=step_before,
|
||||||
|
step_after=step_after,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
ai_payload = run_exercise_form_ai_suggestion(cur, ctx=ctx)
|
||||||
|
except Exception:
|
||||||
|
_logger.exception("roadmap_unfilled AI suggest failed")
|
||||||
|
return None
|
||||||
|
if not ai_payload:
|
||||||
|
return None
|
||||||
|
|
||||||
|
summary_text = ""
|
||||||
|
summary_obj = ai_payload.get("summary")
|
||||||
|
if isinstance(summary_obj, dict):
|
||||||
|
summary_text = str(summary_obj.get("text") or "").strip()
|
||||||
|
elif isinstance(summary_obj, str):
|
||||||
|
summary_text = summary_obj.strip()
|
||||||
|
|
||||||
|
proposal_key = f"ai-{uuid.uuid4().hex[:10]}"
|
||||||
|
title = (ctx.title or spec.get("title_hint") or "KI-Vorschlag").strip()
|
||||||
|
return {
|
||||||
|
"exercise_id": None,
|
||||||
|
"proposal_key": proposal_key,
|
||||||
|
"variant_id": None,
|
||||||
|
"title": title,
|
||||||
|
"summary": summary_text or None,
|
||||||
|
"score": None,
|
||||||
|
"semantic_score": None,
|
||||||
|
"reasons": ["KI-Neuanlage für Roadmap-Stufe ohne Bibliothekstreffer"],
|
||||||
|
"variants": [],
|
||||||
|
"is_bridge": False,
|
||||||
|
"is_ai_proposal": True,
|
||||||
|
"ai_suggestion": dict(ai_payload),
|
||||||
|
"roadmap_major_step_index": mi,
|
||||||
|
"roadmap_phase": gap.get("expected_phase"),
|
||||||
|
"roadmap_learning_goal": gap.get("learning_goal"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_gap_ai_context(
|
def _build_gap_ai_context(
|
||||||
*,
|
*,
|
||||||
goal_query: str,
|
goal_query: str,
|
||||||
|
|
@ -291,13 +429,20 @@ def build_gap_fill_goal_text(
|
||||||
stage_goal = snap.get("stage_learning_goal") or spec.get("title_hint")
|
stage_goal = snap.get("stage_learning_goal") or spec.get("title_hint")
|
||||||
if stage_goal:
|
if stage_goal:
|
||||||
parts.append(f"Lernziel dieser Roadmap-Stufe: {stage_goal}")
|
parts.append(f"Lernziel dieser Roadmap-Stufe: {stage_goal}")
|
||||||
parts.extend(
|
parts.append(f"Entwicklungsphase dieser Übung: {snap.get('stage_phase') or phase}")
|
||||||
[
|
parts.append(f"Erwarteter Entwicklungsbogen: {arc}")
|
||||||
f"Entwicklungsphase dieser Übung: {snap.get('stage_phase') or phase}",
|
if spec.get("source") == "roadmap_unfilled":
|
||||||
f"Erwarteter Entwicklungsbogen: {arc}",
|
parts.append(
|
||||||
f"Einordnung: didaktische Zwischenstufe zwischen „{from_title}“ und „{to_title}“.",
|
"Einordnung: Übung für diese Roadmap-Stufe — das Stufen-Lernziel steht im Vordergrund."
|
||||||
]
|
)
|
||||||
)
|
if step_a:
|
||||||
|
parts.append(f"Vorherige Stufe: „{from_title}“")
|
||||||
|
if step_b:
|
||||||
|
parts.append(f"Nächste Stufe: „{to_title}“")
|
||||||
|
else:
|
||||||
|
parts.append(
|
||||||
|
f"Einordnung: didaktische Zwischenstufe zwischen „{from_title}“ und „{to_title}“."
|
||||||
|
)
|
||||||
if snap.get("stage_load_profile"):
|
if snap.get("stage_load_profile"):
|
||||||
parts.append(f"Belastungsschwerpunkte: {', '.join(snap['stage_load_profile'])}")
|
parts.append(f"Belastungsschwerpunkte: {', '.join(snap['stage_load_profile'])}")
|
||||||
if snap.get("stage_success_criteria"):
|
if snap.get("stage_success_criteria"):
|
||||||
|
|
@ -346,10 +491,20 @@ def build_gap_fill_offer(
|
||||||
proposal: Optional[Mapping[str, Any]] = None,
|
proposal: Optional[Mapping[str, Any]] = None,
|
||||||
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
|
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
|
source = spec.get("source")
|
||||||
idx = int(spec.get("insert_after_index") or 0)
|
idx = int(spec.get("insert_after_index") or 0)
|
||||||
|
major_idx = spec.get("roadmap_major_step_index")
|
||||||
|
if source == "roadmap_unfilled" and major_idx is not None:
|
||||||
|
try:
|
||||||
|
mi = int(major_idx)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
mi = idx
|
||||||
|
step_a, step_b = _resolve_neighbor_steps_by_major_index(steps, mi)
|
||||||
|
idx = mi
|
||||||
|
else:
|
||||||
|
step_a = steps[idx] if idx < len(steps) else None
|
||||||
|
step_b = steps[idx + 1] if idx + 1 < len(steps) else None
|
||||||
offer_id = f"{spec.get('source')}-{idx}-{uuid.uuid4().hex[:8]}"
|
offer_id = f"{spec.get('source')}-{idx}-{uuid.uuid4().hex[:8]}"
|
||||||
step_a = steps[idx] if idx < len(steps) else None
|
|
||||||
step_b = steps[idx + 1] if idx + 1 < len(steps) else None
|
|
||||||
goal_for_ai = ""
|
goal_for_ai = ""
|
||||||
if brief and goal_query:
|
if brief and goal_query:
|
||||||
goal_for_ai = build_gap_fill_goal_text(
|
goal_for_ai = build_gap_fill_goal_text(
|
||||||
|
|
@ -411,6 +566,38 @@ def apply_gap_fill_after_qa(
|
||||||
offers: List[Dict[str, Any]] = []
|
offers: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
for spec in specs:
|
for spec in specs:
|
||||||
|
source = spec.get("source")
|
||||||
|
|
||||||
|
if source == "roadmap_unfilled":
|
||||||
|
proposal: Optional[Dict[str, Any]] = None
|
||||||
|
if include_ai_calls and len(proposals) < max_ai_proposals:
|
||||||
|
proposal = try_suggest_ai_stage_step(
|
||||||
|
cur,
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=brief,
|
||||||
|
spec=spec,
|
||||||
|
steps=out,
|
||||||
|
)
|
||||||
|
offer = build_gap_fill_offer(
|
||||||
|
spec=spec,
|
||||||
|
steps=out,
|
||||||
|
goal_query=goal_query,
|
||||||
|
brief=brief,
|
||||||
|
proposal=proposal,
|
||||||
|
roadmap_snapshot=roadmap_snapshot,
|
||||||
|
)
|
||||||
|
offers.append(offer)
|
||||||
|
if proposal and auto_insert_proposals:
|
||||||
|
proposals.append(
|
||||||
|
{
|
||||||
|
"roadmap_major_step_index": spec.get("roadmap_major_step_index"),
|
||||||
|
"proposal_key": proposal.get("proposal_key"),
|
||||||
|
"proposal_title": proposal.get("title"),
|
||||||
|
"offer_id": offer.get("offer_id"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
idx = int(spec.get("insert_after_index") or 0)
|
idx = int(spec.get("insert_after_index") or 0)
|
||||||
if idx < 0 or idx >= len(out) - 1:
|
if idx < 0 or idx >= len(out) - 1:
|
||||||
continue
|
continue
|
||||||
|
|
@ -432,7 +619,7 @@ def apply_gap_fill_after_qa(
|
||||||
if not gap.get("expected_phase"):
|
if not gap.get("expected_phase"):
|
||||||
gap["expected_phase"] = spec.get("phase") or "vertiefung"
|
gap["expected_phase"] = spec.get("phase") or "vertiefung"
|
||||||
|
|
||||||
proposal: Optional[Dict[str, Any]] = None
|
proposal = None
|
||||||
if include_ai_calls and len(proposals) < max_ai_proposals:
|
if include_ai_calls and len(proposals) < max_ai_proposals:
|
||||||
proposal = try_suggest_ai_bridge_step(
|
proposal = try_suggest_ai_bridge_step(
|
||||||
cur,
|
cur,
|
||||||
|
|
@ -508,4 +695,5 @@ __all__ = [
|
||||||
"collect_gap_fill_specs",
|
"collect_gap_fill_specs",
|
||||||
"insert_ai_proposals_for_gaps",
|
"insert_ai_proposals_for_gaps",
|
||||||
"try_suggest_ai_bridge_step",
|
"try_suggest_ai_bridge_step",
|
||||||
|
"try_suggest_ai_stage_step",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,39 @@ def test_build_gap_fill_goal_text_includes_expected_skills():
|
||||||
assert "Timing" in text
|
assert "Timing" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_gap_fill_offer_roadmap_unfilled_uses_major_step_neighbors():
|
||||||
|
"""Leere Stufe 2 zwischen Stufe 1 und 3 — Nachbarn per roadmap_major_step_index."""
|
||||||
|
brief = build_semantic_brief("Kumite Beinarbeit")
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
"title": "Explosive Angriffe",
|
||||||
|
"exercise_id": 10,
|
||||||
|
"roadmap_major_step_index": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Kumite-Anwendung",
|
||||||
|
"exercise_id": 30,
|
||||||
|
"roadmap_major_step_index": 2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
offer = build_gap_fill_offer(
|
||||||
|
spec={
|
||||||
|
"source": "roadmap_unfilled",
|
||||||
|
"roadmap_major_step_index": 1,
|
||||||
|
"phase": "grundlage",
|
||||||
|
"title_hint": "Grundlegende Kumite-Steppbewegungen",
|
||||||
|
"gap": {"learning_goal": "Grundlegende Kumite-Steppbewegungen", "expected_phase": "grundlage"},
|
||||||
|
},
|
||||||
|
steps=steps,
|
||||||
|
goal_query="Kumite Beinarbeit",
|
||||||
|
brief=brief,
|
||||||
|
)
|
||||||
|
assert offer["roadmap_major_step_index"] == 1
|
||||||
|
assert "Explosive Angriffe" in offer["from_title"]
|
||||||
|
assert "Kumite-Anwendung" in offer["to_title"]
|
||||||
|
assert "Stufen-Lernziel" in offer["goal_for_ai"] or "Roadmap-Stufe" in offer["goal_for_ai"]
|
||||||
|
|
||||||
|
|
||||||
def test_build_gap_fill_offer_exposes_context_preview():
|
def test_build_gap_fill_offer_exposes_context_preview():
|
||||||
brief = build_semantic_brief("Kumite Beinarbeit")
|
brief = build_semantic_brief("Kumite Beinarbeit")
|
||||||
offer = build_gap_fill_offer(
|
offer = build_gap_fill_offer(
|
||||||
|
|
|
||||||
|
|
@ -11,17 +11,20 @@ import ProgressionFindingsPanel from './ProgressionFindingsPanel'
|
||||||
import {
|
import {
|
||||||
aiPreviewToQuickCreateDraft,
|
aiPreviewToQuickCreateDraft,
|
||||||
buildQuickCreateAiPreview,
|
buildQuickCreateAiPreview,
|
||||||
|
buildQuickCreateExercisePayloadFromDraft,
|
||||||
} from '../utils/exerciseAiQuickCreate'
|
} from '../utils/exerciseAiQuickCreate'
|
||||||
import {
|
import {
|
||||||
buildPathGapPlanningContextForAi,
|
buildPathGapPlanningContextForAi,
|
||||||
gapOfferContextDisplayLines,
|
gapOfferContextDisplayLines,
|
||||||
initialStageLearningGoalFromOffer,
|
initialStageLearningGoalFromOffer,
|
||||||
} from '../utils/planningContextForExerciseAi'
|
} from '../utils/planningContextForExerciseAi'
|
||||||
|
import ExerciseAiQuickCreateModal from './exercises/ExerciseAiQuickCreateModal'
|
||||||
import {
|
import {
|
||||||
addSlotToDraft,
|
addSlotToDraft,
|
||||||
|
applyEvaluateResponseToDraft,
|
||||||
applyGapOfferToDraft,
|
applyGapOfferToDraft,
|
||||||
applyMatchStepsToSlots,
|
applyMatchResponseToDraft,
|
||||||
collectGapOffersFromApiResponse,
|
buildPlanningArtifactFromDraft,
|
||||||
hydrateProgressionGraphDraft,
|
hydrateProgressionGraphDraft,
|
||||||
insertSlotInDraft,
|
insertSlotInDraft,
|
||||||
librarySlotExercise,
|
librarySlotExercise,
|
||||||
|
|
@ -30,10 +33,12 @@ import {
|
||||||
patchSlotInDraft,
|
patchSlotInDraft,
|
||||||
removeSlotFromDraft,
|
removeSlotFromDraft,
|
||||||
saveProgressionGraphDraft,
|
saveProgressionGraphDraft,
|
||||||
|
setSlotPrimaryLibrary,
|
||||||
SLOT_MAX,
|
SLOT_MAX,
|
||||||
slotsAsPathStepRows,
|
slotsAsPathStepRows,
|
||||||
slotsToEvaluateSteps,
|
slotsToEvaluateSteps,
|
||||||
syncProgressionRoadmapFromSlots,
|
syncProgressionRoadmapFromSlots,
|
||||||
|
syncSlotPhasesFromRoadmap,
|
||||||
} from '../utils/progressionGraphDraft'
|
} from '../utils/progressionGraphDraft'
|
||||||
|
|
||||||
function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) {
|
function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) {
|
||||||
|
|
@ -83,6 +88,12 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
const [gapPrepError, setGapPrepError] = useState('')
|
const [gapPrepError, setGapPrepError] = useState('')
|
||||||
const [generatingOfferId, setGeneratingOfferId] = useState(null)
|
const [generatingOfferId, setGeneratingOfferId] = useState(null)
|
||||||
const [gapAiBusy, setGapAiBusy] = useState(false)
|
const [gapAiBusy, setGapAiBusy] = useState(false)
|
||||||
|
const [currentEdges, setCurrentEdges] = useState([])
|
||||||
|
const [slotQuickCreateOpen, setSlotQuickCreateOpen] = useState(false)
|
||||||
|
const [slotQuickCreateIndex, setSlotQuickCreateIndex] = useState(null)
|
||||||
|
const [slotQuickCreateDraft, setSlotQuickCreateDraft] = useState(null)
|
||||||
|
const [slotQuickSaving, setSlotQuickSaving] = useState(false)
|
||||||
|
const [slotQuickError, setSlotQuickError] = useState('')
|
||||||
|
|
||||||
const loadGraph = useCallback(async () => {
|
const loadGraph = useCallback(async () => {
|
||||||
if (!graphId) return
|
if (!graphId) return
|
||||||
|
|
@ -93,11 +104,13 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
api.getExerciseProgressionGraph(Number(graphId)),
|
api.getExerciseProgressionGraph(Number(graphId)),
|
||||||
api.listExerciseProgressionEdges(Number(graphId)),
|
api.listExerciseProgressionEdges(Number(graphId)),
|
||||||
])
|
])
|
||||||
|
const edgeList = Array.isArray(edges) ? edges : []
|
||||||
|
setCurrentEdges(edgeList)
|
||||||
setGraphMeta(graph)
|
setGraphMeta(graph)
|
||||||
setDraft(
|
setDraft(
|
||||||
hydrateProgressionGraphDraft({
|
hydrateProgressionGraphDraft({
|
||||||
artifact: graph?.planning_roadmap,
|
artifact: graph?.planning_roadmap,
|
||||||
edges: Array.isArray(edges) ? edges : [],
|
edges: edgeList,
|
||||||
graphName: graph?.name,
|
graphName: graph?.name,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -248,13 +261,6 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
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) {
|
||||||
|
|
@ -280,8 +286,10 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
})
|
})
|
||||||
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 preservedArtifact = buildPlanningArtifactFromDraft(draft) || {}
|
||||||
const hydrated = hydrateProgressionGraphDraft({
|
const hydrated = hydrateProgressionGraphDraft({
|
||||||
artifact: {
|
artifact: {
|
||||||
|
...preservedArtifact,
|
||||||
goal_query: q,
|
goal_query: q,
|
||||||
progression_roadmap: roadmap,
|
progression_roadmap: roadmap,
|
||||||
start_situation: draft.startSituation,
|
start_situation: draft.startSituation,
|
||||||
|
|
@ -289,10 +297,11 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
roadmap_notes: draft.roadmapNotes,
|
roadmap_notes: draft.roadmapNotes,
|
||||||
max_steps: (roadmap?.roadmap?.major_steps || []).length || draft.maxSteps,
|
max_steps: (roadmap?.roadmap?.major_steps || []).length || draft.maxSteps,
|
||||||
},
|
},
|
||||||
edges: [],
|
edges: currentEdges,
|
||||||
graphName: draft.graphName,
|
graphName: draft.graphName,
|
||||||
})
|
})
|
||||||
setDraft({ ...hydrated, goalQuery: q, dirty: true })
|
const withPhases = syncSlotPhasesFromRoadmap(hydrated, roadmap)
|
||||||
|
setDraft({ ...withPhases, goalQuery: q, dirty: true })
|
||||||
setSemanticBrief(res?.semantic_brief_summary || null)
|
setSemanticBrief(res?.semantic_brief_summary || null)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setActionErr(e.message || 'Roadmap-Generierung fehlgeschlagen')
|
setActionErr(e.message || 'Roadmap-Generierung fehlgeschlagen')
|
||||||
|
|
@ -331,16 +340,19 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
progression_graph_id: Number(graphId),
|
progression_graph_id: Number(graphId),
|
||||||
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
|
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
|
||||||
})
|
})
|
||||||
const next = applyMatchStepsToSlots(
|
const { draft: matched, remainingOffers } = applyMatchResponseToDraft(
|
||||||
{
|
{
|
||||||
...synced,
|
...synced,
|
||||||
progressionRoadmap: res?.progression_roadmap || synced.progressionRoadmap,
|
progressionRoadmap: res?.progression_roadmap || synced.progressionRoadmap,
|
||||||
pathSkillExpectations: res?.path_skill_expectations || synced.pathSkillExpectations,
|
pathSkillExpectations: res?.path_skill_expectations || synced.pathSkillExpectations,
|
||||||
},
|
},
|
||||||
res?.steps,
|
res,
|
||||||
)
|
)
|
||||||
setDraft(next)
|
setDraft(matched)
|
||||||
applyApiResponse(res)
|
setSemanticBrief(res?.semantic_brief_summary || null)
|
||||||
|
setTargetSummary(res?.target_profile_summary || null)
|
||||||
|
setPathQa(res?.path_qa || null)
|
||||||
|
setGapFillOffers(remainingOffers)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setActionErr(e.message || 'Übungs-Match fehlgeschlagen')
|
setActionErr(e.message || 'Übungs-Match fehlgeschlagen')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -374,8 +386,11 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
progression_graph_id: Number(graphId),
|
progression_graph_id: Number(graphId),
|
||||||
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
|
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
|
||||||
})
|
})
|
||||||
applyApiResponse(res)
|
setSemanticBrief(res?.semantic_brief_summary || null)
|
||||||
setDraft((prev) => (prev ? { ...prev, lastFindings: res?.path_qa || null } : prev))
|
setPathQa(res?.path_qa || null)
|
||||||
|
const { draft: evaluated, remainingOffers } = applyEvaluateResponseToDraft(synced, res)
|
||||||
|
setDraft({ ...evaluated, lastFindings: res?.path_qa || null })
|
||||||
|
setGapFillOffers(remainingOffers)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setActionErr(e.message || 'Bewertung fehlgeschlagen')
|
setActionErr(e.message || 'Bewertung fehlgeschlagen')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -492,10 +507,12 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
ai_suggestion: aiDraft,
|
ai_suggestion: aiDraft,
|
||||||
has_ai_payload: true,
|
has_ai_payload: true,
|
||||||
}
|
}
|
||||||
setDraft((prev) => {
|
setDraft((prev) => applyGapOfferToDraft(prev, enrichedOffer, { slotIndex }))
|
||||||
const next = applyGapOfferToDraft(prev, enrichedOffer, { slotIndex })
|
if (slotIndex != null && Number.isFinite(slotIndex)) {
|
||||||
return { ...next, dirty: true }
|
setSlotQuickCreateIndex(slotIndex)
|
||||||
})
|
setSlotQuickCreateDraft(aiDraft)
|
||||||
|
setSlotQuickCreateOpen(true)
|
||||||
|
}
|
||||||
setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
|
setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
|
||||||
setGapPrepOpen(false)
|
setGapPrepOpen(false)
|
||||||
setActiveOffer(null)
|
setActiveOffer(null)
|
||||||
|
|
@ -507,6 +524,50 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openSlotQuickCreate = (slotIndex) => {
|
||||||
|
const slot = draft?.slots?.[slotIndex]
|
||||||
|
if (!slot) return
|
||||||
|
const primary = slot.primary
|
||||||
|
setSlotQuickCreateIndex(slotIndex)
|
||||||
|
setSlotQuickError('')
|
||||||
|
if (primary?.kind === 'proposal' && primary.aiSuggestion) {
|
||||||
|
setSlotQuickCreateDraft(primary.aiSuggestion)
|
||||||
|
setSlotQuickCreateOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setActiveOfferSlotIndex(slotIndex)
|
||||||
|
openGapFillPrep(
|
||||||
|
{
|
||||||
|
offer_id: `slot-${slotIndex}`,
|
||||||
|
title_hint: primary?.exerciseTitle || slot.learning_goal,
|
||||||
|
roadmap_major_step_index: slot.majorStepIndex,
|
||||||
|
phase: slot.phase,
|
||||||
|
source: 'roadmap_unfilled',
|
||||||
|
goal_for_ai: slot.learning_goal,
|
||||||
|
},
|
||||||
|
slotIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const applySlotQuickCreate = async () => {
|
||||||
|
if (slotQuickCreateIndex == null || !slotQuickCreateDraft) return
|
||||||
|
setSlotQuickSaving(true)
|
||||||
|
setSlotQuickError('')
|
||||||
|
try {
|
||||||
|
const payload = buildQuickCreateExercisePayloadFromDraft(slotQuickCreateDraft)
|
||||||
|
const created = await api.createExercise(payload)
|
||||||
|
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
|
||||||
|
setDraft((prev) => setSlotPrimaryLibrary(prev, slotQuickCreateIndex, created))
|
||||||
|
setSlotQuickCreateOpen(false)
|
||||||
|
setSlotQuickCreateDraft(null)
|
||||||
|
setSlotQuickCreateIndex(null)
|
||||||
|
} catch (e) {
|
||||||
|
setSlotQuickError(e.message || 'Übung konnte nicht angelegt werden')
|
||||||
|
} finally {
|
||||||
|
setSlotQuickSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const submitGapFillPrep = async () => {
|
const submitGapFillPrep = async () => {
|
||||||
const title = (gapPrepTitle || '').trim()
|
const title = (gapPrepTitle || '').trim()
|
||||||
if (title.length < 3) {
|
if (title.length < 3) {
|
||||||
|
|
@ -675,6 +736,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
onMoveDown={(i) => handleMoveSlot(i, 1)}
|
onMoveDown={(i) => handleMoveSlot(i, 1)}
|
||||||
onRemoveSlot={handleRemoveSlot}
|
onRemoveSlot={handleRemoveSlot}
|
||||||
onInsertAfter={handleInsertAfter}
|
onInsertAfter={handleInsertAfter}
|
||||||
|
onCreateFromProposal={openSlotQuickCreate}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -704,6 +766,22 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<ExerciseAiQuickCreateModal
|
||||||
|
open={slotQuickCreateOpen}
|
||||||
|
onClose={() => {
|
||||||
|
if (slotQuickSaving) return
|
||||||
|
setSlotQuickCreateOpen(false)
|
||||||
|
setSlotQuickCreateDraft(null)
|
||||||
|
setSlotQuickError('')
|
||||||
|
}}
|
||||||
|
title={draft?.slots?.[slotQuickCreateIndex]?.primary?.exerciseTitle || ''}
|
||||||
|
draft={slotQuickCreateDraft}
|
||||||
|
onDraftChange={setSlotQuickCreateDraft}
|
||||||
|
busy={slotQuickSaving}
|
||||||
|
error={slotQuickError}
|
||||||
|
onSubmit={applySlotQuickCreate}
|
||||||
|
/>
|
||||||
|
|
||||||
<ExerciseGapFillPrepModal
|
<ExerciseGapFillPrepModal
|
||||||
open={gapPrepOpen}
|
open={gapPrepOpen}
|
||||||
offer={activeOffer}
|
offer={activeOffer}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ export default function ProgressionSlotCard({
|
||||||
onMoveDown,
|
onMoveDown,
|
||||||
onRemoveSlot,
|
onRemoveSlot,
|
||||||
onInsertAfter,
|
onInsertAfter,
|
||||||
|
onCreateFromProposal,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) {
|
}) {
|
||||||
const { primary, siblings = [], phase, learning_goal: learningGoal } = slot
|
const { primary, siblings = [], phase, learning_goal: learningGoal } = slot
|
||||||
|
|
@ -133,6 +134,12 @@ export default function ProgressionSlotCard({
|
||||||
<span style={{ color: 'var(--text3)', fontWeight: 400 }}>{` · ${primary.variantName}`}</span>
|
<span style={{ color: 'var(--text3)', fontWeight: 400 }}>{` · ${primary.variantName}`}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
{primary.kind === 'proposal' && primary.aiSuggestion?.summary?.text ? (
|
||||||
|
<p style={{ margin: '6px 0 0', fontSize: '12px', color: 'var(--text2)', lineHeight: 1.4 }}>
|
||||||
|
{primary.aiSuggestion.summary.text.slice(0, 220)}
|
||||||
|
{primary.aiSuggestion.summary.text.length > 220 ? '…' : ''}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', marginTop: '8px' }}>
|
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', marginTop: '8px' }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -143,6 +150,17 @@ export default function ProgressionSlotCard({
|
||||||
>
|
>
|
||||||
{primary.kind === 'empty' ? 'Übung wählen' : 'Übung ändern'}
|
{primary.kind === 'empty' ? 'Übung wählen' : 'Übung ändern'}
|
||||||
</button>
|
</button>
|
||||||
|
{primary.kind === 'proposal' && typeof onCreateFromProposal === 'function' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ fontSize: '12px', padding: '4px 10px' }}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onCreateFromProposal(slotIndex)}
|
||||||
|
>
|
||||||
|
{primary.aiSuggestion ? 'Als Übung anlegen' : 'Mit KI anlegen'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
{primary.kind !== 'empty' ? (
|
{primary.kind !== 'empty' ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -161,21 +161,76 @@ export function offerCanExpandSlots(draft, offer) {
|
||||||
return (draft.slots?.length || 0) < SLOT_MAX
|
return (draft.slots?.length || 0) < SLOT_MAX
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GAP_OFFER_SOURCE_PRIORITY = {
|
||||||
|
roadmap_unfilled: 0,
|
||||||
|
unfilled_gap: 1,
|
||||||
|
llm_suggested: 2,
|
||||||
|
off_topic: 3,
|
||||||
|
}
|
||||||
|
|
||||||
export function collectGapOffersFromApiResponse(res) {
|
export function collectGapOffersFromApiResponse(res) {
|
||||||
|
const top = Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : []
|
||||||
|
if (top.length) return top
|
||||||
const qa = res?.path_qa || {}
|
const qa = res?.path_qa || {}
|
||||||
const merged = [
|
return Array.isArray(qa?.gap_fill_offers) ? qa.gap_fill_offers : []
|
||||||
...(Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : []),
|
}
|
||||||
...(Array.isArray(qa?.gap_fill_offers) ? qa.gap_fill_offers : []),
|
|
||||||
]
|
/** Maximal ein Angebot pro Slot — Roadmap-Lücken vor Brücken/QS. */
|
||||||
const seen = new Set()
|
export function dedupeGapOffersBySlot(offers, draft) {
|
||||||
return merged.filter((offer) => {
|
const bySlot = new Map()
|
||||||
const key = offer?.offer_id || `${offer?.source}-${offer?.title_hint}`
|
for (const offer of offers || []) {
|
||||||
if (!key || seen.has(key)) return false
|
const idx = resolveOfferSlotIndex(draft, offer)
|
||||||
seen.add(key)
|
if (idx == null || !Number.isFinite(idx) || idx < 0) continue
|
||||||
|
const existing = bySlot.get(idx)
|
||||||
|
const prio = GAP_OFFER_SOURCE_PRIORITY[offer?.source] ?? 9
|
||||||
|
const existingPrio = existing ? (GAP_OFFER_SOURCE_PRIORITY[existing?.source] ?? 9) : 99
|
||||||
|
if (!existing || prio < existingPrio) {
|
||||||
|
bySlot.set(idx, offer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(bySlot.entries())
|
||||||
|
.sort(([a], [b]) => a - b)
|
||||||
|
.map(([, offer]) => offer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Angebote nur für Slots ohne belegte Primary (Bibliothek oder KI-Entwurf). */
|
||||||
|
export function filterGapOffersForUnfilledSlots(draft, offers) {
|
||||||
|
return (offers || []).filter((offer) => {
|
||||||
|
const idx = resolveOfferSlotIndex(draft, offer)
|
||||||
|
if (idx == null || idx < 0 || idx >= (draft?.slots?.length || 0)) return true
|
||||||
|
const p = draft.slots[idx]?.primary
|
||||||
|
if (p?.kind === 'library' && p.exerciseId != null) return false
|
||||||
|
if (p?.kind === 'proposal') return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function syncSlotPhasesFromRoadmap(draft, progressionRoadmap) {
|
||||||
|
if (!progressionRoadmap) return draft
|
||||||
|
const majors = mapMajorStepsFromApi(progressionRoadmap)
|
||||||
|
if (!majors.length) return draft
|
||||||
|
const slots = (draft.slots || []).map((slot, i) => {
|
||||||
|
const m = majors[i]
|
||||||
|
if (!m) return slot
|
||||||
|
return {
|
||||||
|
...slot,
|
||||||
|
phase: m.phase || slot.phase,
|
||||||
|
learning_goal: m.learning_goal || slot.learning_goal,
|
||||||
|
load_profile: m.load_profile?.length ? m.load_profile : slot.load_profile,
|
||||||
|
success_criteria: m.success_criteria?.length ? m.success_criteria : slot.success_criteria,
|
||||||
|
anti_patterns: m.anti_patterns?.length ? m.anti_patterns : slot.anti_patterns,
|
||||||
|
exercise_type: m.exercise_type || slot.exercise_type,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return syncProgressionRoadmapFromSlots({
|
||||||
|
...draft,
|
||||||
|
slots,
|
||||||
|
progressionRoadmap,
|
||||||
|
majorSteps: majors,
|
||||||
|
maxSteps: Math.max(slots.length, majors.length),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function slotsAsPathStepRows(draft) {
|
export function slotsAsPathStepRows(draft) {
|
||||||
return (draft.slots || []).map((slot) => ({
|
return (draft.slots || []).map((slot) => ({
|
||||||
exerciseId: slot.primary?.exerciseId ?? null,
|
exerciseId: slot.primary?.exerciseId ?? null,
|
||||||
|
|
@ -644,19 +699,23 @@ export function slotsToEvaluateSteps(draft) {
|
||||||
|
|
||||||
export function applyMatchStepsToSlots(draft, apiSteps) {
|
export function applyMatchStepsToSlots(draft, apiSteps) {
|
||||||
const steps = Array.isArray(apiSteps) ? apiSteps : []
|
const steps = Array.isArray(apiSteps) ? apiSteps : []
|
||||||
const nextSlots = (draft.slots || []).map((slot) => ({ ...slot, primary: { ...slot.primary }, siblings: [...(slot.siblings || [])] }))
|
const nextSlots = (draft.slots || []).map((slot) => ({
|
||||||
|
...slot,
|
||||||
|
primary: { ...slot.primary },
|
||||||
|
siblings: [...(slot.siblings || [])],
|
||||||
|
}))
|
||||||
|
|
||||||
for (const step of steps) {
|
for (const step of steps) {
|
||||||
const idx =
|
if (step.roadmap_major_step_index == null || !Number.isFinite(Number(step.roadmap_major_step_index))) {
|
||||||
step.roadmap_major_step_index != null
|
continue
|
||||||
? Number(step.roadmap_major_step_index)
|
}
|
||||||
: steps.indexOf(step)
|
const idx = Number(step.roadmap_major_step_index)
|
||||||
if (!Number.isFinite(idx) || idx < 0 || idx >= nextSlots.length) continue
|
if (idx < 0 || idx >= nextSlots.length) continue
|
||||||
|
|
||||||
const isProposal = Boolean(step.is_ai_proposal) || step.exercise_id == null
|
const isProposal = Boolean(step.is_ai_proposal) || step.exercise_id == null
|
||||||
if (isProposal) {
|
if (isProposal) {
|
||||||
nextSlots[idx].primary = proposalSlotExercise({
|
nextSlots[idx].primary = proposalSlotExercise({
|
||||||
title: step.title,
|
title: step.title || nextSlots[idx].learning_goal,
|
||||||
proposalKey: step.proposal_key,
|
proposalKey: step.proposal_key,
|
||||||
aiSuggestion: step.ai_suggestion,
|
aiSuggestion: step.ai_suggestion,
|
||||||
})
|
})
|
||||||
|
|
@ -672,6 +731,72 @@ export function applyMatchStepsToSlots(draft, apiSteps) {
|
||||||
return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true })
|
return syncProgressionRoadmapFromSlots({ ...draft, slots: nextSlots, dirty: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lücken-Angebote in leere Slots legen; Panel nur für verbleibende Lücken. */
|
||||||
|
export function applyGapOffersFromResponse(draft, res) {
|
||||||
|
let next = draft
|
||||||
|
if (res?.progression_roadmap) {
|
||||||
|
next = syncSlotPhasesFromRoadmap(next, res.progression_roadmap)
|
||||||
|
}
|
||||||
|
|
||||||
|
const offers = dedupeGapOffersBySlot(collectGapOffersFromApiResponse(res), next)
|
||||||
|
const placedIds = new Set()
|
||||||
|
|
||||||
|
for (const offer of offers) {
|
||||||
|
const idx = resolveOfferSlotIndex(next, offer)
|
||||||
|
if (idx == null || idx < 0 || idx >= (next.slots?.length || 0)) continue
|
||||||
|
const primary = next.slots[idx]?.primary
|
||||||
|
if (primary?.kind === 'library' && primary.exerciseId != null) continue
|
||||||
|
if (primary?.kind === 'proposal') continue
|
||||||
|
|
||||||
|
next = applyGapOfferToSlot(next, idx, offer)
|
||||||
|
if (offer?.offer_id) placedIds.add(offer.offer_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingOffers = filterGapOffersForUnfilledSlots(
|
||||||
|
next,
|
||||||
|
dedupeGapOffersBySlot(
|
||||||
|
collectGapOffersFromApiResponse(res).filter((o) => !placedIds.has(o?.offer_id)),
|
||||||
|
next,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return { draft: { ...next, dirty: true }, remainingOffers }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Match-Antwort: Schritte + Lücken-Angebote direkt in Slots (wie früher im Pfad-Wizard sichtbar). */
|
||||||
|
export function applyMatchResponseToDraft(draft, res) {
|
||||||
|
let next = applyMatchStepsToSlots(draft, res?.steps)
|
||||||
|
if (res?.progression_roadmap) {
|
||||||
|
next = {
|
||||||
|
...syncSlotPhasesFromRoadmap(next, res.progression_roadmap),
|
||||||
|
pathSkillExpectations: res?.path_skill_expectations || next.pathSkillExpectations,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { draft: withOffers, remainingOffers } = applyGapOffersFromResponse(next, res)
|
||||||
|
return { draft: withOffers, remainingOffers }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Evaluate-Antwort: KI-Angebote in leere Slots (ohne Schritte neu zu matchen). */
|
||||||
|
export function applyEvaluateResponseToDraft(draft, res) {
|
||||||
|
return applyGapOffersFromResponse(draft, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSlotPrimaryLibrary(draft, slotIndex, exercise) {
|
||||||
|
if (!exercise?.id) return draft
|
||||||
|
const slots = (draft.slots || []).map((s, i) =>
|
||||||
|
i === slotIndex
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
primary: librarySlotExercise({
|
||||||
|
exerciseId: exercise.id,
|
||||||
|
exerciseTitle: exercise.title || `Übung #${exercise.id}`,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: s,
|
||||||
|
)
|
||||||
|
return syncProgressionRoadmapFromSlots({ ...draft, slots, 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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user