From e5291256d0d153c67b1b09c1ce8705fd94267ce3 Mon Sep 17 00:00:00 2001
From: Lars
Date: Fri, 22 May 2026 09:21:44 +0200
Subject: [PATCH] Enhance AI Exercise Suggestion Functionality and UX
- Updated the AI Exercise Implementation Plan to include a detailed description of the new suggestion dialog for AI proposals, allowing users to preview and selectively adopt AI-generated summaries and skills.
- Implemented a new preview feature in the ExerciseFormPageRoot component, enabling users to review AI suggestions before applying them to the form.
- Enhanced the skill management process by normalizing AI-suggested skills and integrating them into the exercise form, improving user interaction and data handling.
---
.../AI_EXERCISE_IMPLEMENTATION_PLAN.md | 7 +-
.../exercises/ExerciseFormPageRoot.jsx | 441 ++++++++++++++++--
2 files changed, 404 insertions(+), 44 deletions(-)
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 (
+ discardExerciseAiSuggestionPreview()}
+ onKeyDown={(e) => e.key === 'Escape' && discardExerciseAiSuggestionPreview()}
+ >
+
e.stopPropagation()}
+ >
+
KI-Vorschlag übernehmen
+
+ Vergleichen und nur die gewünschten Teile übernehmen. Es werden keine Daten automatisch gespeichert.
+
+
+ {p.hasSummaryProposal ? (
+
+
+ Kurzfassung
+
+
+
+
+
+ Aktuell (ohne Formatierung)
+
+
{p.summaryBeforePlain || '(leer)'}
+
+
+
KI-Vorschlag
+
+ {p.summaryAfterPlain || '(leer)'}
+
+
+
+
+ ) : null}
+
+ {p.skillsRequested ? (
+
+
+
+ Fähigkeiten ({p.skillChoices.length}
+ {p.skillChoices.length === 1 ? ' Vorschlag' : ' Vorschläge'})
+
+ {p.skillChoices.length > 0 ? (
+
+
+
+
+ ) : null}
+
+ {p.skillChoices.length === 0 ? (
+
+ Keine passenden Fähigkeiten — der Katalog-Vorschlag war leer oder enthielt nur ungültige IDs.
+
+ ) : (
+
+ {p.skillChoices.map((c) => (
+ -
+
+
+ ))}
+
+ )}
+
+ ) : null}
+
+
+
+
+
+
+
+ )
+ })()}
{mediaPreview && (
KI-Unterstützung: OpenRouter gestützte Vorschläge für Kurzfassung und Fähigkeitenzuordnung
- (suggestExerciseAi / regenerateExerciseAi). Übernahme nur im Formular; Speichern
+ (suggestExerciseAi / regenerateExerciseAi). Übernahme im Dialog ins Formular; Speichern
wie gewohnt.