diff --git a/backend/planning_exercise_form_context.py b/backend/planning_exercise_form_context.py index 3394261..6972c93 100644 --- a/backend/planning_exercise_form_context.py +++ b/backend/planning_exercise_form_context.py @@ -168,6 +168,8 @@ def build_progression_path_gap_planning_context( resolved_structured: Optional[Mapping[str, Any]] = None, stage_spec: Optional[Mapping[str, Any]] = None, semantic_brief: Optional[Mapping[str, Any]] = None, + stage_learning_goal_override: Optional[str] = None, + gap_trainer_supplements: Optional[str] = None, ) -> Dict[str, Any]: """Kontext für KI-Neuanlage aus Progressionsgraph-Pfad-Lücke.""" offer = offer or {} @@ -205,6 +207,11 @@ def build_progression_path_gap_planning_context( semantic_brief=semantic_brief, ) ctx.update(snap) + if stage_learning_goal_override and stage_learning_goal_override.strip(): + ctx["stage_learning_goal"] = _trim_str(stage_learning_goal_override, limit=1200) + ctx["roadmap_learning_goal"] = ctx["stage_learning_goal"] + if gap_trainer_supplements and gap_trainer_supplements.strip(): + ctx["gap_trainer_supplements"] = _trim_str(gap_trainer_supplements, limit=2000) return sanitize_planning_context_for_ai(ctx) diff --git a/backend/tests/test_planning_exercise_form_context.py b/backend/tests/test_planning_exercise_form_context.py index 772e2ff..3079065 100644 --- a/backend/tests/test_planning_exercise_form_context.py +++ b/backend/tests/test_planning_exercise_form_context.py @@ -78,3 +78,14 @@ def test_gap_planning_context_carries_snapshot_fields(): ) assert ctx["start_situation"] == "Start" assert ctx["stage_learning_goal"] == "Stufenziel" + + +def test_gap_planning_context_trainer_supplements_and_stage_override(): + ctx = build_progression_path_gap_planning_context( + goal_query="Kumite", + stage_spec={"learning_goal": "Original"}, + stage_learning_goal_override="Angepasstes Stufenziel", + gap_trainer_supplements="Nur Partnerübung, Kindergruppe", + ) + assert ctx["stage_learning_goal"] == "Angepasstes Stufenziel" + assert ctx["gap_trainer_supplements"] == "Nur Partnerübung, Kindergruppe" diff --git a/backend/version.py b/backend/version.py index f3eec53..e55663f 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.213" +APP_VERSION = "0.8.214" BUILD_DATE = "2026-06-07" DB_SCHEMA_VERSION = "20260607087" diff --git a/frontend/src/components/ExerciseProgressionPathBuilder.jsx b/frontend/src/components/ExerciseProgressionPathBuilder.jsx index 6b3f535..7288ec8 100644 --- a/frontend/src/components/ExerciseProgressionPathBuilder.jsx +++ b/frontend/src/components/ExerciseProgressionPathBuilder.jsx @@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useState } from 'react' import api from '../utils/api' import ExerciseAiQuickCreateModal from './exercises/ExerciseAiQuickCreateModal' +import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal' import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal' import { aiPreviewToQuickCreateDraft, @@ -13,6 +14,7 @@ import { import { buildPathGapPlanningContextForAi, gapOfferContextDisplayLines, + initialStageLearningGoalFromOffer, } from '../utils/planningContextForExerciseAi' function applyResolvedStructuredFromRoadmap(progressionRoadmap, setters) { @@ -252,6 +254,12 @@ export default function ExerciseProgressionPathBuilder({ const [quickSaving, setQuickSaving] = useState(false) const [quickAiError, setQuickAiError] = useState('') const [activePlanningContextLines, setActivePlanningContextLines] = useState([]) + 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('') useEffect(() => { let cancelled = false @@ -391,13 +399,6 @@ export default function ExerciseProgressionPathBuilder({ setQuickAiError('') } - const handleGapFillClick = async (offer) => { - if (!confirmPathExpansionIfNeeded(offer, pathSteps.length, maxSteps, setMaxSteps)) { - return - } - await runGapFillAiSuggest(offer) - } - const gapContextFallbackParams = { goalQuery, semanticBrief, @@ -410,21 +411,70 @@ export default function ExerciseProgressionPathBuilder({ roadmapNotes, } - const runGapFillAiSuggest = async (offer) => { - const title = (offer?.title_hint || '').trim() - if (title.length < 3) { - alert('Titel-Hinweis fehlt — bitte Pfad erneut vorschlagen.') + const closeGapFillPrep = () => { + if (quickSaving) return + setGapPrepOpen(false) + setGapPrepError('') + } + + const openGapFillPrep = (offer) => { + const defaultFocus = resolveDefaultFocusAreaId(targetSummary, focusAreas) + setActiveOffer(offer) + setGapPrepTitle((offer?.title_hint || '').trim()) + setGapPrepStageGoal(initialStageLearningGoalFromOffer(offer, gapContextFallbackParams)) + setGapPrepSupplements('') + setGapPrepFocusAreaId(defaultFocus ? String(defaultFocus) : '') + setActivePlanningContextLines(gapOfferContextDisplayLines(offer, gapContextFallbackParams)) + setGapPrepError('') + setGapPrepOpen(true) + } + + const handleGapFillClick = (offer) => { + if (!confirmPathExpansionIfNeeded(offer, pathSteps.length, maxSteps, setMaxSteps)) { return } - const goalText = (offer?.goal_for_ai || offer?.sketch || '').trim() - const focusId = resolveDefaultFocusAreaId(targetSummary, focusAreas) + openGapFillPrep(offer) + } + + 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 + setGapPrepError('') + await runGapFillAiSuggest(activeOffer, { + title, + stageLearningGoal: (gapPrepStageGoal || '').trim(), + supplements: (gapPrepSupplements || '').trim(), + focusAreaId: focusId, + }) + } + + const runGapFillAiSuggest = async (offer, prep = null) => { + 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 für diese Übung:\n${supplements}`.trim() + } + const focusId = + prep?.focusAreaId != null && Number.isFinite(Number(prep.focusAreaId)) + ? Number(prep.focusAreaId) + : resolveDefaultFocusAreaId(targetSummary, focusAreas) if (!focusId) { - alert('Kein Fokusbereich verfügbar — bitte Kataloge laden oder manuell wählen.') - setQuickTitle(title) - setQuickSketch(goalText) - setQuickFocusAreaId('') - setActiveOffer(offer) - setQuickCreateOpen(true) + alert('Kein Fokusbereich verfügbar — bitte im Vorbereitungsdialog wählen.') return } const focusRow = (focusAreas || []).find((x) => Number(x.id) === focusId) @@ -438,11 +488,16 @@ export default function ExerciseProgressionPathBuilder({ setQuickCreateDraft(null) setQuickSaving(true) setGeneratingOfferId(offer?.offer_id || null) - const contextLines = gapOfferContextDisplayLines(offer, gapContextFallbackParams) + const contextParams = { + ...gapContextFallbackParams, + stageLearningGoalOverride: stageGoal, + gapTrainerSupplements: supplements, + } + const contextLines = gapOfferContextDisplayLines(offer, contextParams) setActivePlanningContextLines(contextLines) const planningContext = buildPathGapPlanningContextForAi({ offer, - ...gapContextFallbackParams, + ...contextParams, }) try { const aiRes = await api.suggestExerciseAi({ @@ -450,7 +505,7 @@ export default function ExerciseProgressionPathBuilder({ goal: goalText || undefined, execution: '', preparation: '', - trainer_notes: '', + trainer_notes: supplements || '', focus_area_hint: focusHint || undefined, focus_areas_context: [{ focus_area_id: focusId, is_primary: true }], planning_context: planningContext || undefined, @@ -469,12 +524,13 @@ export default function ExerciseProgressionPathBuilder({ sketchPlain: goalText, }), ) + setGapPrepOpen(false) setQuickCreateOpen(false) } catch (e) { console.error(e) const msg = e?.message || String(e) + setGapPrepError(msg) setQuickAiError(msg) - setQuickCreateOpen(true) } finally { setQuickSaving(false) setGeneratingOfferId(null) @@ -1205,7 +1261,7 @@ export default function ExerciseProgressionPathBuilder({ Fehlende oder zu ersetzende Schritte ({pathSteps.length}/{maxSteps} im Pfad). {pathSteps.length >= maxSteps ? ' Der Pfad ist voll — beim Einfügen können Sie die Pfadlänge dynamisch vergrößern (ohne neuen Vorschlag); Ersatz-Angebote ersetzen einen Schritt.' - : ' „Mit KI anlegen“ erzeugt einen vollständigen Entwurf und fügt die Übung ein.'} + : ' Zuerst Kontext prüfen und ergänzen, dann KI-Entwurf erstellen und einfügen.'}

{gapFillOffers.map((offer) => ( @@ -1255,12 +1311,12 @@ export default function ExerciseProgressionPathBuilder({ : 'Pfad voll — Klick fragt, ob die Pfadlänge vergrößert werden soll' : offer.replace_step_index != null ? 'Ersetzt den themenfremden Schritt im Pfad' - : 'KI-Entwurf mit Pfad-Kontext generieren' + : 'Kontext prüfen, Ergänzungen mitgeben, dann KI-Entwurf' } > {generatingOfferId === offer.offer_id ? 'KI erstellt Entwurf …' - : 'Mit KI anlegen'} + : 'Vorbereiten & KI anlegen'}
@@ -1406,6 +1462,25 @@ export default function ExerciseProgressionPathBuilder({ ) : null} + + { setQuickCreateDraft(null) - setActivePlanningContextLines([]) - if (activeOffer) setQuickCreateOpen(true) + if (activeOffer) { + setGapPrepOpen(true) + } else { + setActivePlanningContextLines([]) + } }} planningContextLines={activePlanningContextLines} onApply={applyQuickCreateDraft} diff --git a/frontend/src/components/exercises/ExerciseGapFillPrepModal.jsx b/frontend/src/components/exercises/ExerciseGapFillPrepModal.jsx new file mode 100644 index 0000000..196982a --- /dev/null +++ b/frontend/src/components/exercises/ExerciseGapFillPrepModal.jsx @@ -0,0 +1,186 @@ +import React, { useEffect } from 'react' + +/** + * Vorbereitung vor KI-Übungsanlage aus Pfad-Lücke: Kontext prüfen, Ergänzungen mitgeben. + */ +export default function ExerciseGapFillPrepModal({ + open, + onClose, + offer = null, + contextLines = [], + title = '', + onTitleChange, + stageLearningGoal = '', + onStageLearningGoalChange, + supplements = '', + onSupplementsChange, + focusAreaId = '', + onFocusAreaChange, + focusAreas = [], + busy = false, + error = '', + onSubmit, +}) { + useEffect(() => { + if (!open) return undefined + const onKey = (e) => { + if (e.key === 'Escape' && !busy) { + e.preventDefault() + onClose() + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [open, busy, onClose]) + + if (!open || !offer) return null + + const readOnlyLines = (contextLines || []).filter( + (line) => line.label !== 'Stufen-Lernziel', + ) + + return ( +
{ + if (e.target === e.currentTarget && !busy) onClose() + }} + > +
e.stopPropagation()} + style={{ maxWidth: '680px' }} + > +
+

+ Übung mit KI vorbereiten +

+ +
+
+

+ Prüfen und anpassen, was an die KI geht. Ergänzungen fließen in Ziel, Trainerhinweise und + Planungskontext ein — erst danach wird der Entwurf erzeugt. +

+ + {readOnlyLines.length > 0 ? ( +
+ Pfad-Kontext (aus Roadmap) +
+ {readOnlyLines.map(({ label, value }) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+ ) : null} + +
+
+ + onTitleChange(e.target.value)} + disabled={busy} + maxLength={280} + /> +
+
+ +