diff --git a/.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md b/.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md index 08d0394..9c5e042 100644 --- a/.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md +++ b/.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md @@ -25,7 +25,7 @@ | **S1** | Migration `ai_prompts` + Defaults `exercise_summary`, `exercise_skill_suggestions`; `exercises.summary_ai_generated` | Migrierte DB, App startet | | **S2** | `httpx`-Client OpenRouter; Modul lädt Prompt, ersetzt Platzhalter, parst Antwort | Unit-/Smoke: 503 ohne Key | | **S3** | `POST /api/exercises/ai/suggest`, `POST /api/exercises/{id}/ai/regenerate` | OpenAPI/Handtest mit Key | -| **S4** | Frontend: KI-Vorschlag, Teilübernahme Summary + Skills | Manuelle UX-Prüfung | +| **S4** | Frontend: KI-Vorschlag, **Änderungsdialog** (Vorschau, Kurzfassung wählbar, Fähigkeiten pro Zeile an-/abwählbar), dann Übernahme ins Formular | Manuelle UX-Prüfung | | **S5** | (später) Auto-Fallback beim Speichern laut `KI_FEATURES_SPEC` §7 | Feature-Flag / Config | | **S6** | (später) Zielausbau, Anleitung-only, Varianten, Admin-Masse laut Vision | Separate Epics | @@ -47,12 +47,15 @@ - **2026-05-22:** Initial; S1–S4 als erster Umsetzungspfad. - **2026-05-22:** S1–S4 im Code umgesetzt (Migration 067, `exercise_ai` + Router, Übungsformular); S5 weiter offen. +- **2026-05-22:** UX: Übernahmedialog für KI-Vorschläge (Vorschau, selektive Übernahme) im Übungsformular (`ExerciseFormPageRoot`). --- ## 5. Umsetzungsstand (Zwischencheckpoint) -**Erledigt (2026-05-22):** Migration **`067_ai_prompts_exercise_assistant`**, **`openrouter_chat`**, **`exercise_ai`**, **`POST /api/exercises/ai/suggest`** und **`POST /api/exercises/{id}/ai/regenerate`**, Formular-Schaltflächen (Kurzfassung / Fähigkeiten / kombiniert). +**Erledigt (2026-05-22):** Migration **`067_ai_prompts_exercise_assistant`**, **`openrouter_chat`**, **`exercise_ai`**, **`POST /api/exercises/ai/suggest`** und **`POST /api/exercises/{id}/ai/regenerate`**, Formular-Schaltflächen (Kurzfassung / Fähigkeiten / kombiniert). + +**Nacharbeit S4 UX:** Übernahmedialog **`ExerciseFormPageRoot`**: keine sofortige Überschreibung; Kurzfassung mit Vergleich + Checkbox; Fähigkeiten mit Neu/Aktualisierung, Checkboxen, „Alle auswählen/abwählen“; **`Escape`** schließt; KI-Schaltflächen blockiert solange Dialog offen. **Bewusst noch nicht:** automatische KI beim Speichern (**S5**), Setzen von `summary_ai_generated` bei manuellen UI-Änderungen, Prompt-Admin-UI, Rate-Limits. diff --git a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx index 1e7d74b..3dcbfd4 100644 --- a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx +++ b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx @@ -14,7 +14,7 @@ import { buildExerciseMediaDragPayload, } from '../../utils/exerciseInlineMediaRefs' import { autoScrollForDragNearEdges } from '../../utils/dragAutoScroll' -import { normalizeSkillLevelSlug } from '../../constants/skillLevels' +import { normalizeSkillLevelSlug, formatSkillLevelSlug } from '../../constants/skillLevels' import { stripHtmlToText } from '../../utils/htmlUtils' import ExerciseCatalogAssocEditor from './ExerciseCatalogAssocEditor' import ExerciseSkillsEditor from './ExerciseSkillsEditor' @@ -44,6 +44,7 @@ import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../../hooks/useUn import { EXERCISE_SKILL_INTENSITY_DEFAULT, normalizeExerciseSkillIntensity, + formatExerciseSkillIntensityLabel, } from '../../constants/exerciseSkillIntensity' import { EXERCISE_VISIBILITY_CLUB_FIELD_LABEL, @@ -89,6 +90,85 @@ function aiPlainSummaryToMinimalHtml(text) { return paras.map((p) => `
${escapeHtmlText(p)}
`).join('') } +function cloneExerciseSkillRows(rows) { + return Array.isArray(rows) ? rows.map((s) => ({ ...s })) : [] +} + +function buildNormalizedAiSkillRowFromApi(sug) { + const sid = Number(sug.skill_id) + if (!Number.isFinite(sid)) return null + return { + skill_id: sid, + intensity: normalizeExerciseSkillIntensity(sug.intensity), + required_level: normalizeSkillLevelSlug(sug.required_level) || 'grundlagen', + target_level: + normalizeSkillLevelSlug(sug.target_level) || + normalizeSkillLevelSlug(sug.required_level) || + 'grundlagen', + is_primary: !!sug.is_primary, + ai_suggested: true, + } +} + +function buildExerciseAiSuggestionPreview({ mode, snapshotSummaryHtml, snapshotSkills, apiRes }) { + const summaryRequested = mode !== 'skills' + const skillsRequested = mode !== 'summary' + + let summaryAfterHtml = null + let summaryAfterPlain = '' + if (summaryRequested && apiRes.summary?.text) { + summaryAfterPlain = String(apiRes.summary.text).trim() + if (summaryAfterPlain) { + summaryAfterHtml = aiPlainSummaryToMinimalHtml(apiRes.summary.text) + } + } + + const skillChoices = [] + if (skillsRequested && Array.isArray(apiRes.skills)) { + for (const sug of apiRes.skills) { + const after = buildNormalizedAiSkillRowFromApi(sug) + if (!after) continue + const ix = snapshotSkills.findIndex((s) => Number(s.skill_id) === after.skill_id) + const before = ix >= 0 ? { ...snapshotSkills[ix] } : null + skillChoices.push({ + key: String(after.skill_id), + skill_id: after.skill_id, + kind: before ? 'update' : 'add', + before, + after, + include: true, + }) + } + } + + const hasSummaryProposal = !!(summaryRequested && summaryAfterHtml) + const hasSkillChoices = skillChoices.length > 0 + + return { + mode, + applySummary: hasSummaryProposal, + summaryBeforePlain: stripHtmlToText(snapshotSummaryHtml || '').trim(), + summaryAfterPlain, + summaryAfterHtml, + skillChoices, + hasSummaryProposal, + hasSkillChoices, + summaryRequested, + skillsRequested, + } +} + +function describeExerciseSkillRowForPreview(row, skillsCatalog) { + if (!row) return '' + const sk = skillsCatalog.find((x) => Number(x.id) === Number(row.skill_id)) + const name = sk?.name || `Fähigkeit #${row.skill_id}` + const int = formatExerciseSkillIntensityLabel(row.intensity) + const from = formatSkillLevelSlug(row.required_level) || '—' + const to = formatSkillLevelSlug(row.target_level) || '—' + const prim = row.is_primary ? ' · Primär' : '' + return `${name}: Intensität ${int}, Niveau ${from} → ${to}${prim}` +} + function emptyComboSlotRow() { return { title: '', @@ -524,6 +604,7 @@ function ExerciseFormPageRoot() { const [variantSavingId, setVariantSavingId] = useState(null) const [variantBusy, setVariantBusy] = useState(false) const [aiSuggestBusy, setAiSuggestBusy] = useState(false) + const [aiSuggestionPreview, setAiSuggestionPreview] = useState(null) const [variantEditSelection, setVariantEditSelection] = useState(null) const [activeFormTab, setActiveFormTab] = useState('stammdaten') const variantsSavedSnapshotRef = useRef({}) @@ -896,6 +977,9 @@ function ExerciseFormPageRoot() { .filter(Boolean) .join(', ') + const snapshotSummaryHtml = formData.summary || '' + const snapshotSkills = cloneExerciseSkillRows(formData.skills) + setAiSuggestBusy(true) try { const res = await api.suggestExerciseAi({ @@ -907,45 +991,20 @@ function ExerciseFormPageRoot() { include_skills: skillsOn, }) - let applied = false + const preview = buildExerciseAiSuggestionPreview({ + mode, + snapshotSummaryHtml, + snapshotSkills, + apiRes: res, + }) - if (summaryOn && res.summary?.text) { - updateFormField('summary', aiPlainSummaryToMinimalHtml(res.summary.text)) - applied = true - } - - if (skillsOn && Array.isArray(res.skills) && res.skills.length) { - setFormDirty(true) - setFormData((prev) => { - const next = [...(prev.skills || [])] - for (const sug of res.skills) { - const sid = Number(sug.skill_id) - if (!Number.isFinite(sid)) continue - const row = { - skill_id: sid, - intensity: normalizeExerciseSkillIntensity(sug.intensity), - required_level: normalizeSkillLevelSlug(sug.required_level) || 'grundlagen', - target_level: - normalizeSkillLevelSlug(sug.target_level) || - normalizeSkillLevelSlug(sug.required_level) || - 'grundlagen', - is_primary: !!sug.is_primary, - ai_suggested: true, - } - const ix = next.findIndex((s) => Number(s.skill_id) === sid) - if (ix >= 0) next[ix] = { ...next[ix], ...row } - else next.push(row) - } - return { ...prev, skills: next } - }) - applied = true - } - - if (!applied) { + const hasSomething = preview.hasSummaryProposal || preview.hasSkillChoices + if (!hasSomething) { toast.info('Die KI lieferte keinen verwertbaren Vorschlag für die gewählten Bereiche.') - } else { - toast.success('KI-Vorschlag ins Formular übernommen — bitte prüfen und speichern.') + return } + + setAiSuggestionPreview(preview) } catch (err) { toast.error(err?.message || String(err)) } finally { @@ -953,6 +1012,52 @@ function ExerciseFormPageRoot() { } } + const applyExerciseAiSuggestionPreview = () => { + const p = aiSuggestionPreview + if (!p) return + const takeSummary = !!(p.applySummary && p.summaryAfterHtml) + const skillsToMerge = p.skillChoices.filter((c) => c.include).map((c) => c.after) + + if (!takeSummary && skillsToMerge.length === 0) { + toast.error('Bitte mindestens eine Kurzfassung oder eine Fähigkeit zur Übernahme auswählen.') + return + } + + if (takeSummary) { + updateFormField('summary', p.summaryAfterHtml) + } + if (skillsToMerge.length > 0) { + setFormDirty(true) + setFormData((prev) => { + const next = [...(prev.skills || [])] + for (const row of skillsToMerge) { + const sid = Number(row.skill_id) + const ix = next.findIndex((s) => Number(s.skill_id) === sid) + if (ix >= 0) next[ix] = { ...next[ix], ...row } + else next.push(row) + } + return { ...prev, skills: next } + }) + } + + toast.success('Ausgewählte KI-Vorschläge übernommen — bitte prüfen und speichern.') + setAiSuggestionPreview(null) + } + + const discardExerciseAiSuggestionPreview = () => setAiSuggestionPreview(null) + + useEffect(() => { + if (!aiSuggestionPreview) return undefined + const onKey = (e) => { + if (e.key === 'Escape') { + e.preventDefault() + setAiSuggestionPreview(null) + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [aiSuggestionPreview]) + const refreshVariants = useCallback(async () => { if (!exerciseId) return const ex = await api.getExercise(exerciseId) @@ -1423,7 +1528,7 @@ function ExerciseFormPageRoot() { type="button" className="btn btn-secondary" style={{ fontSize: '12px' }} - disabled={aiSuggestBusy} + disabled={aiSuggestBusy || !!aiSuggestionPreview} onClick={() => runExerciseAiSuggestion('summary')} > KI: Kurzfassung @@ -2098,7 +2203,7 @@ function ExerciseFormPageRoot() { type="button" className="btn btn-secondary" style={{ fontSize: '12px' }} - disabled={aiSuggestBusy} + disabled={aiSuggestBusy || !!aiSuggestionPreview} onClick={() => runExerciseAiSuggestion('skills')} > KI: Fähigkeiten @@ -2107,7 +2212,7 @@ function ExerciseFormPageRoot() { type="button" className="btn btn-secondary" style={{ fontSize: '12px' }} - disabled={aiSuggestBusy} + disabled={aiSuggestBusy || !!aiSuggestionPreview} onClick={() => runExerciseAiSuggestion('both')} > KI: Kurzfassung und Fähigkeiten @@ -2117,7 +2222,7 @@ function ExerciseFormPageRoot() { - · Vorschläge werden ins Formular übernommen und nicht automatisch gespeichert. + · Es öffnet ein Dialogfeld mit Vorschau; Übernahme wählweise pro Teil. Speichern nur über die Aktionsleiste. @@ -2615,6 +2720,258 @@ function ExerciseFormPageRoot() { )} + {aiSuggestionPreview && + (() => { + const p = aiSuggestionPreview + const summaryBoxSx = { + padding: '10px 12px', + borderRadius: '8px', + border: '1px solid var(--border)', + background: 'var(--surface2)', + fontSize: '13px', + lineHeight: 1.45, + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + minHeight: '72px', + } + const canApplySomething = + (p.applySummary && p.summaryAfterHtml) || p.skillChoices.some((c) => c.include) + return ( ++ Vergleichen und nur die gewünschten Teile übernehmen. Es werden keine Daten automatisch gespeichert. +
+ + {p.hasSummaryProposal ? ( ++ Keine passenden Fähigkeiten — der Katalog-Vorschlag war leer oder enthielt nur ungültige IDs. +
+ ) : ( +suggestExerciseAi / regenerateExerciseAi). Übernahme nur im Formular; Speichern
+ (suggestExerciseAi / regenerateExerciseAi). Übernahme im Dialog ins Formular; Speichern
wie gewohnt.