Enhance AI Exercise Suggestion Functionality and UX
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 38s
Test Suite / playwright-tests (push) Successful in 1m21s

- 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.
This commit is contained in:
Lars 2026-05-22 09:21:44 +02:00
parent 4d36bbf634
commit e5291256d0
2 changed files with 404 additions and 44 deletions

View File

@ -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; S1S4 als erster Umsetzungspfad.
- **2026-05-22:** S1S4 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.

View File

@ -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) => `<p>${escapeHtmlText(p)}</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() {
<button type="button" className="exercise-form-inline-tab-link" onClick={() => setActiveFormTab('anleitung')}>
Anleitung
</button>
· 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.
</span>
</div>
@ -2615,6 +2720,258 @@ function ExerciseFormPageRoot() {
</div>
</div>
)}
{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 (
<div
role="dialog"
aria-modal="true"
aria-label="KI-Vorschlag prüfen"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
zIndex: 1001,
overflow: 'auto',
padding: '16px',
}}
onClick={() => discardExerciseAiSuggestionPreview()}
onKeyDown={(e) => e.key === 'Escape' && discardExerciseAiSuggestionPreview()}
>
<div
className="card"
style={{
maxWidth: 760,
margin: '3vh auto',
maxHeight: '92vh',
overflow: 'auto',
position: 'relative',
}}
onClick={(e) => e.stopPropagation()}
>
<h3 style={{ marginTop: 0, fontSize: '1.1rem', marginBottom: '6px' }}>KI-Vorschlag übernehmen</h3>
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0, marginBottom: '16px' }}>
Vergleichen und nur die gewünschten Teile übernehmen. Es werden keine Daten automatisch gespeichert.
</p>
{p.hasSummaryProposal ? (
<section style={{ marginBottom: '20px' }} aria-labelledby="ai-preview-summary-heading">
<div
id="ai-preview-summary-heading"
style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: '10px' }}
>
Kurzfassung
</div>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '10px',
cursor: 'pointer',
fontSize: '14px',
}}
>
<input
type="checkbox"
checked={p.applySummary}
onChange={(e) =>
setAiSuggestionPreview((prev) =>
prev ? { ...prev, applySummary: e.target.checked } : prev,
)
}
/>
Kurzfassung durch Vorschlag ersetzen (bestehende Kurzbeschreibung wird überschrieben)
</label>
<div
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)',
gap: '12px',
}}
>
<div>
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>
Aktuell (ohne Formatierung)
</div>
<div style={summaryBoxSx}>{p.summaryBeforePlain || '(leer)'}</div>
</div>
<div>
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>KI-Vorschlag</div>
<div style={{ ...summaryBoxSx, borderColor: 'var(--accent-dark, rgba(29,158,117,0.45))' }}>
{p.summaryAfterPlain || '(leer)'}
</div>
</div>
</div>
</section>
) : null}
{p.skillsRequested ? (
<section aria-labelledby="ai-preview-skills-heading">
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
marginBottom: '10px',
}}
>
<div id="ai-preview-skills-heading" style={{ fontWeight: 600, fontSize: '0.95rem' }}>
Fähigkeiten ({p.skillChoices.length}
{p.skillChoices.length === 1 ? ' Vorschlag' : ' Vorschläge'})
</div>
{p.skillChoices.length > 0 ? (
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '4px 8px' }}
onClick={() =>
setAiSuggestionPreview((prev) =>
prev
? {
...prev,
skillChoices: prev.skillChoices.map((x) => ({ ...x, include: true })),
}
: prev,
)
}
>
Alle auswählen
</button>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '4px 8px' }}
onClick={() =>
setAiSuggestionPreview((prev) =>
prev
? {
...prev,
skillChoices: prev.skillChoices.map((x) => ({ ...x, include: false })),
}
: prev,
)
}
>
Alle abwählen
</button>
</div>
) : null}
</div>
{p.skillChoices.length === 0 ? (
<p style={{ fontSize: '13px', color: 'var(--text3)', margin: 0 }}>
Keine passenden Fähigkeiten der Katalog-Vorschlag war leer oder enthielt nur ungültige IDs.
</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{p.skillChoices.map((c) => (
<li
key={c.key}
style={{
border: '1px solid var(--border)',
borderRadius: '8px',
padding: '10px 12px',
marginBottom: '10px',
background: 'var(--surface)',
}}
>
<label
style={{
display: 'flex',
gap: '10px',
alignItems: 'flex-start',
cursor: 'pointer',
margin: 0,
}}
>
<input
type="checkbox"
checked={c.include}
onChange={() =>
setAiSuggestionPreview((prev) =>
prev
? {
...prev,
skillChoices: prev.skillChoices.map((x) =>
x.skill_id === c.skill_id ? { ...x, include: !x.include } : x,
),
}
: prev,
)
}
style={{ marginTop: '4px' }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: '13px', marginBottom: '6px' }}>
{c.kind === 'add' ? 'Neu hinzufügen' : 'Bestehende Zeile aktualisieren'}
</div>
{c.kind === 'update' && c.before ? (
<div style={{ fontSize: '12px', lineHeight: 1.5 }}>
<div style={{ color: 'var(--text3)', marginBottom: '2px' }}>Bisher</div>
<div style={{ marginBottom: '8px' }}>
{describeExerciseSkillRowForPreview(c.before, skillsCatalog)}
</div>
<div style={{ color: 'var(--text3)', marginBottom: '2px' }}>Nach KI-Vorschlag</div>
<div>{describeExerciseSkillRowForPreview(c.after, skillsCatalog)}</div>
</div>
) : (
<div style={{ fontSize: '13px', lineHeight: 1.5 }}>
{describeExerciseSkillRowForPreview(c.after, skillsCatalog)}
</div>
)}
</div>
</label>
</li>
))}
</ul>
)}
</section>
) : null}
<div
style={{
marginTop: '20px',
paddingTop: '14px',
borderTop: '1px solid var(--border)',
display: 'flex',
justifyContent: 'flex-end',
flexWrap: 'wrap',
gap: '10px',
}}
>
<button type="button" className="btn btn-secondary" onClick={discardExerciseAiSuggestionPreview}>
Abbrechen
</button>
<button
type="button"
className="btn btn-primary"
disabled={!canApplySomething}
onClick={() => applyExerciseAiSuggestionPreview()}
>
Ausgewähltes übernehmen
</button>
</div>
</div>
</div>
)
})()}
{mediaPreview && (
<MediaPreviewModal
title={(mediaPreview.title || '').trim() || mediaPreview.original_filename || `Medium #${mediaPreview.id}`}
@ -2660,7 +3017,7 @@ function ExerciseFormPageRoot() {
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '16px' }}>
<strong>KI-Unterstützung:</strong> OpenRouter gestützte Vorschläge für Kurzfassung und Fähigkeitenzuordnung
(<code>suggestExerciseAi</code> / <code>regenerateExerciseAi</code>). Übernahme nur im Formular; Speichern
(<code>suggestExerciseAi</code> / <code>regenerateExerciseAi</code>). Übernahme im Dialog ins Formular; Speichern
wie gewohnt.
</p>
<UnsavedChangesPrompt