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")
|
||||
|
||||
|
||||
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(
|
||||
*,
|
||||
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")
|
||||
if stage_goal:
|
||||
parts.append(f"Lernziel dieser Roadmap-Stufe: {stage_goal}")
|
||||
parts.extend(
|
||||
[
|
||||
f"Entwicklungsphase dieser Übung: {snap.get('stage_phase') or phase}",
|
||||
f"Erwarteter Entwicklungsbogen: {arc}",
|
||||
f"Einordnung: didaktische Zwischenstufe zwischen „{from_title}“ und „{to_title}“.",
|
||||
]
|
||||
)
|
||||
parts.append(f"Entwicklungsphase dieser Übung: {snap.get('stage_phase') or phase}")
|
||||
parts.append(f"Erwarteter Entwicklungsbogen: {arc}")
|
||||
if spec.get("source") == "roadmap_unfilled":
|
||||
parts.append(
|
||||
"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"):
|
||||
parts.append(f"Belastungsschwerpunkte: {', '.join(snap['stage_load_profile'])}")
|
||||
if snap.get("stage_success_criteria"):
|
||||
|
|
@ -346,10 +491,20 @@ def build_gap_fill_offer(
|
|||
proposal: Optional[Mapping[str, Any]] = None,
|
||||
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
source = spec.get("source")
|
||||
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]}"
|
||||
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 = ""
|
||||
if brief and goal_query:
|
||||
goal_for_ai = build_gap_fill_goal_text(
|
||||
|
|
@ -411,6 +566,38 @@ def apply_gap_fill_after_qa(
|
|||
offers: List[Dict[str, Any]] = []
|
||||
|
||||
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)
|
||||
if idx < 0 or idx >= len(out) - 1:
|
||||
continue
|
||||
|
|
@ -432,7 +619,7 @@ def apply_gap_fill_after_qa(
|
|||
if not gap.get("expected_phase"):
|
||||
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:
|
||||
proposal = try_suggest_ai_bridge_step(
|
||||
cur,
|
||||
|
|
@ -508,4 +695,5 @@ __all__ = [
|
|||
"collect_gap_fill_specs",
|
||||
"insert_ai_proposals_for_gaps",
|
||||
"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
|
||||
|
||||
|
||||
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():
|
||||
brief = build_semantic_brief("Kumite Beinarbeit")
|
||||
offer = build_gap_fill_offer(
|
||||
|
|
|
|||
|
|
@ -11,17 +11,20 @@ import ProgressionFindingsPanel from './ProgressionFindingsPanel'
|
|||
import {
|
||||
aiPreviewToQuickCreateDraft,
|
||||
buildQuickCreateAiPreview,
|
||||
buildQuickCreateExercisePayloadFromDraft,
|
||||
} from '../utils/exerciseAiQuickCreate'
|
||||
import {
|
||||
buildPathGapPlanningContextForAi,
|
||||
gapOfferContextDisplayLines,
|
||||
initialStageLearningGoalFromOffer,
|
||||
} from '../utils/planningContextForExerciseAi'
|
||||
import ExerciseAiQuickCreateModal from './exercises/ExerciseAiQuickCreateModal'
|
||||
import {
|
||||
addSlotToDraft,
|
||||
applyEvaluateResponseToDraft,
|
||||
applyGapOfferToDraft,
|
||||
applyMatchStepsToSlots,
|
||||
collectGapOffersFromApiResponse,
|
||||
applyMatchResponseToDraft,
|
||||
buildPlanningArtifactFromDraft,
|
||||
hydrateProgressionGraphDraft,
|
||||
insertSlotInDraft,
|
||||
librarySlotExercise,
|
||||
|
|
@ -30,10 +33,12 @@ import {
|
|||
patchSlotInDraft,
|
||||
removeSlotFromDraft,
|
||||
saveProgressionGraphDraft,
|
||||
setSlotPrimaryLibrary,
|
||||
SLOT_MAX,
|
||||
slotsAsPathStepRows,
|
||||
slotsToEvaluateSteps,
|
||||
syncProgressionRoadmapFromSlots,
|
||||
syncSlotPhasesFromRoadmap,
|
||||
} from '../utils/progressionGraphDraft'
|
||||
|
||||
function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) {
|
||||
|
|
@ -83,6 +88,12 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
const [gapPrepError, setGapPrepError] = useState('')
|
||||
const [generatingOfferId, setGeneratingOfferId] = useState(null)
|
||||
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 () => {
|
||||
if (!graphId) return
|
||||
|
|
@ -93,11 +104,13 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
api.getExerciseProgressionGraph(Number(graphId)),
|
||||
api.listExerciseProgressionEdges(Number(graphId)),
|
||||
])
|
||||
const edgeList = Array.isArray(edges) ? edges : []
|
||||
setCurrentEdges(edgeList)
|
||||
setGraphMeta(graph)
|
||||
setDraft(
|
||||
hydrateProgressionGraphDraft({
|
||||
artifact: graph?.planning_roadmap,
|
||||
edges: Array.isArray(edges) ? edges : [],
|
||||
edges: edgeList,
|
||||
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)
|
||||
}, [draft?.slots])
|
||||
|
||||
const applyApiResponse = (res) => {
|
||||
setSemanticBrief(res?.semantic_brief_summary || null)
|
||||
setTargetSummary(res?.target_profile_summary || null)
|
||||
setPathQa(res?.path_qa || null)
|
||||
setGapFillOffers(collectGapOffersFromApiResponse(res))
|
||||
}
|
||||
|
||||
const runRoadmapGenerate = async () => {
|
||||
const q = (draft?.goalQuery || '').trim()
|
||||
if (q.length < 3) {
|
||||
|
|
@ -280,8 +286,10 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
})
|
||||
const roadmap = res?.progression_roadmap
|
||||
if (!roadmap) throw new Error('Keine Roadmap in der Antwort')
|
||||
const preservedArtifact = buildPlanningArtifactFromDraft(draft) || {}
|
||||
const hydrated = hydrateProgressionGraphDraft({
|
||||
artifact: {
|
||||
...preservedArtifact,
|
||||
goal_query: q,
|
||||
progression_roadmap: roadmap,
|
||||
start_situation: draft.startSituation,
|
||||
|
|
@ -289,10 +297,11 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
roadmap_notes: draft.roadmapNotes,
|
||||
max_steps: (roadmap?.roadmap?.major_steps || []).length || draft.maxSteps,
|
||||
},
|
||||
edges: [],
|
||||
edges: currentEdges,
|
||||
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)
|
||||
} catch (e) {
|
||||
setActionErr(e.message || 'Roadmap-Generierung fehlgeschlagen')
|
||||
|
|
@ -331,16 +340,19 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
progression_graph_id: Number(graphId),
|
||||
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
|
||||
})
|
||||
const next = applyMatchStepsToSlots(
|
||||
const { draft: matched, remainingOffers } = applyMatchResponseToDraft(
|
||||
{
|
||||
...synced,
|
||||
progressionRoadmap: res?.progression_roadmap || synced.progressionRoadmap,
|
||||
pathSkillExpectations: res?.path_skill_expectations || synced.pathSkillExpectations,
|
||||
},
|
||||
res?.steps,
|
||||
res,
|
||||
)
|
||||
setDraft(next)
|
||||
applyApiResponse(res)
|
||||
setDraft(matched)
|
||||
setSemanticBrief(res?.semantic_brief_summary || null)
|
||||
setTargetSummary(res?.target_profile_summary || null)
|
||||
setPathQa(res?.path_qa || null)
|
||||
setGapFillOffers(remainingOffers)
|
||||
} catch (e) {
|
||||
setActionErr(e.message || 'Übungs-Match fehlgeschlagen')
|
||||
} finally {
|
||||
|
|
@ -374,8 +386,11 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
progression_graph_id: Number(graphId),
|
||||
...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes),
|
||||
})
|
||||
applyApiResponse(res)
|
||||
setDraft((prev) => (prev ? { ...prev, lastFindings: res?.path_qa || null } : prev))
|
||||
setSemanticBrief(res?.semantic_brief_summary || null)
|
||||
setPathQa(res?.path_qa || null)
|
||||
const { draft: evaluated, remainingOffers } = applyEvaluateResponseToDraft(synced, res)
|
||||
setDraft({ ...evaluated, lastFindings: res?.path_qa || null })
|
||||
setGapFillOffers(remainingOffers)
|
||||
} catch (e) {
|
||||
setActionErr(e.message || 'Bewertung fehlgeschlagen')
|
||||
} finally {
|
||||
|
|
@ -492,10 +507,12 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
ai_suggestion: aiDraft,
|
||||
has_ai_payload: true,
|
||||
}
|
||||
setDraft((prev) => {
|
||||
const next = applyGapOfferToDraft(prev, enrichedOffer, { slotIndex })
|
||||
return { ...next, dirty: true }
|
||||
})
|
||||
setDraft((prev) => applyGapOfferToDraft(prev, enrichedOffer, { slotIndex }))
|
||||
if (slotIndex != null && Number.isFinite(slotIndex)) {
|
||||
setSlotQuickCreateIndex(slotIndex)
|
||||
setSlotQuickCreateDraft(aiDraft)
|
||||
setSlotQuickCreateOpen(true)
|
||||
}
|
||||
setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id))
|
||||
setGapPrepOpen(false)
|
||||
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 title = (gapPrepTitle || '').trim()
|
||||
if (title.length < 3) {
|
||||
|
|
@ -675,6 +736,7 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
onMoveDown={(i) => handleMoveSlot(i, 1)}
|
||||
onRemoveSlot={handleRemoveSlot}
|
||||
onInsertAfter={handleInsertAfter}
|
||||
onCreateFromProposal={openSlotQuickCreate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -704,6 +766,22 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa
|
|||
/>
|
||||
) : 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
|
||||
open={gapPrepOpen}
|
||||
offer={activeOffer}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export default function ProgressionSlotCard({
|
|||
onMoveDown,
|
||||
onRemoveSlot,
|
||||
onInsertAfter,
|
||||
onCreateFromProposal,
|
||||
disabled = false,
|
||||
}) {
|
||||
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>
|
||||
) : null}
|
||||
</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' }}>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -143,6 +150,17 @@ export default function ProgressionSlotCard({
|
|||
>
|
||||
{primary.kind === 'empty' ? 'Übung wählen' : 'Übung ändern'}
|
||||
</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' ? (
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -161,21 +161,76 @@ export function offerCanExpandSlots(draft, offer) {
|
|||
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) {
|
||||
const top = Array.isArray(res?.gap_fill_offers) ? res.gap_fill_offers : []
|
||||
if (top.length) return top
|
||||
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 Array.isArray(qa?.gap_fill_offers) ? qa.gap_fill_offers : []
|
||||
}
|
||||
|
||||
/** Maximal ein Angebot pro Slot — Roadmap-Lücken vor Brücken/QS. */
|
||||
export function dedupeGapOffersBySlot(offers, draft) {
|
||||
const bySlot = new Map()
|
||||
for (const offer of offers || []) {
|
||||
const idx = resolveOfferSlotIndex(draft, offer)
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
return (draft.slots || []).map((slot) => ({
|
||||
exerciseId: slot.primary?.exerciseId ?? null,
|
||||
|
|
@ -644,19 +699,23 @@ export function slotsToEvaluateSteps(draft) {
|
|||
|
||||
export function applyMatchStepsToSlots(draft, 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) {
|
||||
const idx =
|
||||
step.roadmap_major_step_index != null
|
||||
? Number(step.roadmap_major_step_index)
|
||||
: steps.indexOf(step)
|
||||
if (!Number.isFinite(idx) || idx < 0 || idx >= nextSlots.length) continue
|
||||
if (step.roadmap_major_step_index == null || !Number.isFinite(Number(step.roadmap_major_step_index))) {
|
||||
continue
|
||||
}
|
||||
const idx = Number(step.roadmap_major_step_index)
|
||||
if (idx < 0 || idx >= nextSlots.length) continue
|
||||
|
||||
const isProposal = Boolean(step.is_ai_proposal) || step.exercise_id == null
|
||||
if (isProposal) {
|
||||
nextSlots[idx].primary = proposalSlotExercise({
|
||||
title: step.title,
|
||||
title: step.title || nextSlots[idx].learning_goal,
|
||||
proposalKey: step.proposal_key,
|
||||
aiSuggestion: step.ai_suggestion,
|
||||
})
|
||||
|
|
@ -672,6 +731,72 @@ export function applyMatchStepsToSlots(draft, apiSteps) {
|
|||
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) {
|
||||
const nextSlots = (draft.slots || []).map((s) => ({ ...s, primary: { ...s.primary }, siblings: [...(s.siblings || [])] }))
|
||||
if (slotIndex < 0 || slotIndex >= nextSlots.length) return draft
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user