From e0ddfa6ce591ee68d7af234905d41c2dd5ed5cdf Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 10 Jun 2026 15:56:30 +0200 Subject: [PATCH] Add AI Suggestion Handling for Roadmap Gaps and Enhance Progression Graph Components - 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. --- backend/planning_exercise_path_ai_fill.py | 208 +++++++++++++++++- .../test_planning_exercise_path_ai_fill.py | 33 +++ .../src/components/ProgressionGraphEditor.jsx | 122 ++++++++-- .../src/components/ProgressionSlotCard.jsx | 18 ++ frontend/src/utils/progressionGraphDraft.js | 157 +++++++++++-- 5 files changed, 490 insertions(+), 48 deletions(-) diff --git a/backend/planning_exercise_path_ai_fill.py b/backend/planning_exercise_path_ai_fill.py index 1018b59..ef1f5bf 100644 --- a/backend/planning_exercise_path_ai_fill.py +++ b/backend/planning_exercise_path_ai_fill.py @@ -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", ] diff --git a/backend/tests/test_planning_exercise_path_ai_fill.py b/backend/tests/test_planning_exercise_path_ai_fill.py index 98cb393..15016a9 100644 --- a/backend/tests/test_planning_exercise_path_ai_fill.py +++ b/backend/tests/test_planning_exercise_path_ai_fill.py @@ -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( diff --git a/frontend/src/components/ProgressionGraphEditor.jsx b/frontend/src/components/ProgressionGraphEditor.jsx index 5985de1..0bfbc57 100644 --- a/frontend/src/components/ProgressionGraphEditor.jsx +++ b/frontend/src/components/ProgressionGraphEditor.jsx @@ -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} /> ))} @@ -704,6 +766,22 @@ export default function ProgressionGraphEditor({ graphId, embedded = false, onSa /> ) : null} + { + if (slotQuickSaving) return + setSlotQuickCreateOpen(false) + setSlotQuickCreateDraft(null) + setSlotQuickError('') + }} + title={draft?.slots?.[slotQuickCreateIndex]?.primary?.exerciseTitle || ''} + draft={slotQuickCreateDraft} + onDraftChange={setSlotQuickCreateDraft} + busy={slotQuickSaving} + error={slotQuickError} + onSubmit={applySlotQuickCreate} + /> + {` · ${primary.variantName}`} ) : null} + {primary.kind === 'proposal' && primary.aiSuggestion?.summary?.text ? ( +

+ {primary.aiSuggestion.summary.text.slice(0, 220)} + {primary.aiSuggestion.summary.text.length > 220 ? '…' : ''} +

+ ) : null}
+ {primary.kind === 'proposal' && typeof onCreateFromProposal === 'function' ? ( + + ) : null} {primary.kind !== 'empty' ? (