Enhance AI Quick Create Functionality in ExercisePickerModal
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m20s

- Updated the quick create process to include a preview feature for AI-generated exercises, allowing users to review goals, execution, preparation, and trainer notes.
- Introduced new constants for instruction fields and refactored the payload building function to utilize the preview data.
- Improved error handling to ensure at least one of the goal or execution fields is populated.
- Deprecated the previous payload building function in favor of the new preview-based approach, streamlining the exercise creation workflow.
This commit is contained in:
Lars 2026-05-22 19:10:16 +02:00
parent 4725eaa90b
commit 675cfa85f0
4 changed files with 581 additions and 46 deletions

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.164" APP_VERSION = "0.8.165"
BUILD_DATE = "2026-05-31" BUILD_DATE = "2026-05-31"
DB_SCHEMA_VERSION = "20260531071" DB_SCHEMA_VERSION = "20260531071"
@ -42,6 +42,13 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.165",
"date": "2026-05-31",
"changes": [
"Übungspicker Schnellanlage: KI-Vorschau-Dialog vor Speichern; Live-Bibliothekssuche (Titel+Skizze) mit Übernahme bestehender Übung.",
],
},
{ {
"version": "0.8.164", "version": "0.8.164",
"date": "2026-05-31", "date": "2026-05-31",

View File

@ -0,0 +1,294 @@
import React, { useEffect } from 'react'
import { describeAiSkillRowForPreview } from '../utils/exerciseAiQuickCreate'
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',
}
/**
* Modal: KI-Vorschlag prüfen (Formular + Schnellanlage).
*/
export default function ExerciseAiSuggestPreviewModal({
preview,
onPreviewChange,
onDiscard,
onApply,
skillsCatalog = [],
dialogTitle = 'KI-Vorschlag prüfen',
hint = 'Vergleichen und nur die gewünschten Teile übernehmen.',
applyLabel = 'Ausgewähltes übernehmen',
applyDisabled = false,
zIndex = 2000,
}) {
useEffect(() => {
if (!preview) return undefined
const onKey = (e) => {
if (e.key === 'Escape') {
e.preventDefault()
e.stopPropagation()
onDiscard()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [preview, onDiscard])
if (!preview) return null
const p = preview
const canApplySomething =
!applyDisabled &&
((p.applySummary && p.summaryAfterHtml) ||
(p.skillChoices || []).some((c) => c.include) ||
(p.instructionChoices || []).some((c) => c.include && c.afterHtml))
return (
<div
role="dialog"
aria-modal="true"
aria-label={dialogTitle}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.55)',
zIndex,
overflow: 'auto',
padding: '16px',
}}
onClick={() => onDiscard()}
>
<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' }}>{dialogTitle}</h3>
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0, marginBottom: '16px' }}>{hint}</p>
{p.hasInstructionChoices ? (
<section style={{ marginBottom: '20px' }} aria-labelledby="ai-preview-instructions-heading">
<div
id="ai-preview-instructions-heading"
style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: '10px' }}
>
Anleitung ({p.instructionChoices.length}{' '}
{p.instructionChoices.length === 1 ? 'Feld' : 'Felder'})
</div>
{p.instructionChoices.map((c) => (
<div
key={c.key}
style={{
border: '1px solid var(--border)',
borderRadius: '8px',
padding: '12px',
marginBottom: '12px',
background: 'var(--surface)',
}}
>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '10px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 600,
}}
>
<input
type="checkbox"
checked={c.include}
onChange={(e) =>
onPreviewChange((prev) =>
prev
? {
...prev,
instructionChoices: prev.instructionChoices.map((x) =>
x.key === c.key ? { ...x, include: e.target.checked } : x,
),
}
: prev,
)
}
/>
{c.label} übernehmen
</label>
<div
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)',
gap: '12px',
}}
>
<div>
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>
Ausgang (Plaintext)
</div>
<div style={summaryBoxSx}>{c.beforePlain || '(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))',
}}
>
{c.afterPlain || '(leer)'}
</div>
</div>
</div>
</div>
))}
</section>
) : null}
{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) =>
onPreviewChange((prev) => (prev ? { ...prev, applySummary: e.target.checked } : prev))
}
/>
Kurzfassung übernehmen
</label>
<div
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)',
gap: '12px',
}}
>
<div>
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>Ausgang</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 !== false && p.hasSkillChoices ? (
<section aria-labelledby="ai-preview-skills-heading">
<div
id="ai-preview-skills-heading"
style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: '10px' }}
>
Fähigkeiten ({p.skillChoices.length})
</div>
<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',
alignItems: 'flex-start',
gap: '8px',
cursor: 'pointer',
fontSize: '14px',
}}
>
<input
type="checkbox"
checked={c.include}
style={{ marginTop: 3 }}
onChange={(e) =>
onPreviewChange((prev) =>
prev
? {
...prev,
skillChoices: prev.skillChoices.map((x) =>
x.key === c.key ? { ...x, include: e.target.checked } : x,
),
}
: prev,
)
}
/>
<span>{describeAiSkillRowForPreview(c.after, skillsCatalog)}</span>
</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={() => onDiscard()}>
Abbrechen
</button>
<button
type="button"
className="btn btn-primary"
disabled={!canApplySomething}
onClick={() => onApply()}
>
{applyLabel}
</button>
</div>
</div>
</div>
)
}

View File

@ -17,7 +17,11 @@ import {
import SkillTreeMultiSelect from './SkillTreeMultiSelect' import SkillTreeMultiSelect from './SkillTreeMultiSelect'
import ExerciseFocusRulePicker from './ExerciseFocusRulePicker' import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
import CatalogRulePicker from './CatalogRulePicker' import CatalogRulePicker from './CatalogRulePicker'
import { buildQuickCreateExercisePayload } from '../utils/exerciseAiQuickCreate' import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
import {
buildQuickCreateAiPreview,
buildQuickCreateExercisePayloadFromPreview,
} from '../utils/exerciseAiQuickCreate'
const PAGE_SIZE = 100 const PAGE_SIZE = 100
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null) const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
@ -60,6 +64,10 @@ export default function ExercisePickerModal({
const [quickFocusAreaId, setQuickFocusAreaId] = useState('') const [quickFocusAreaId, setQuickFocusAreaId] = useState('')
const [quickSaving, setQuickSaving] = useState(false) const [quickSaving, setQuickSaving] = useState(false)
const [quickAiError, setQuickAiError] = useState('') const [quickAiError, setQuickAiError] = useState('')
const [quickCreatePreview, setQuickCreatePreview] = useState(null)
const [quickLibraryHits, setQuickLibraryHits] = useState([])
const [quickLibraryLoading, setQuickLibraryLoading] = useState(false)
const [debouncedQuickLibraryQ, setDebouncedQuickLibraryQ] = useState('')
const pickerScrollRef = useRef(null) const pickerScrollRef = useRef(null)
const toggleMultiPick = (ex) => { const toggleMultiPick = (ex) => {
@ -78,6 +86,48 @@ export default function ExercisePickerModal({
return () => clearTimeout(t) return () => clearTimeout(t)
}, [aiSearchInput]) }, [aiSearchInput])
const quickLibraryQuery = useMemo(() => {
const t = (quickTitle || '').trim()
const s = (quickSketch || '').trim()
return [t, s].filter(Boolean).join(' ').trim()
}, [quickTitle, quickSketch])
useEffect(() => {
const t = setTimeout(() => setDebouncedQuickLibraryQ(quickLibraryQuery), 400)
return () => clearTimeout(t)
}, [quickLibraryQuery])
useEffect(() => {
if (!open || !quickOpen || !catalogsReady) {
setQuickLibraryHits([])
return
}
if (debouncedQuickLibraryQ.length < 3) {
setQuickLibraryHits([])
return
}
let cancelled = false
;(async () => {
setQuickLibraryLoading(true)
try {
const q = { search: debouncedQuickLibraryQ, include_archived: true, limit: 8, offset: 0 }
if (Array.isArray(exerciseKindAny) && exerciseKindAny.length > 0) {
q.exercise_kind_any = exerciseKindAny
}
const batch = await api.listExercises(q)
if (!cancelled) setQuickLibraryHits(Array.isArray(batch) ? batch : [])
} catch (e) {
console.error(e)
if (!cancelled) setQuickLibraryHits([])
} finally {
if (!cancelled) setQuickLibraryLoading(false)
}
})()
return () => {
cancelled = true
}
}, [open, quickOpen, catalogsReady, debouncedQuickLibraryQ, exerciseKindAny])
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
let cancelled = false let cancelled = false
@ -127,6 +177,9 @@ export default function ExercisePickerModal({
setQuickFocusAreaId('') setQuickFocusAreaId('')
setQuickSaving(false) setQuickSaving(false)
setQuickAiError('') setQuickAiError('')
setQuickCreatePreview(null)
setQuickLibraryHits([])
setDebouncedQuickLibraryQ('')
return return
} }
setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs)) setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs))
@ -286,7 +339,17 @@ export default function ExercisePickerModal({
const resetFilters = () => setFilters({ ...INITIAL_FILTERS }) const resetFilters = () => setFilters({ ...INITIAL_FILTERS })
const submitQuickCreate = async () => { const adoptExistingExercise = async (ex) => {
if (!ex?.id) return
if (multiSelect && typeof onSelectExercises === 'function') {
await Promise.resolve(onSelectExercises([ex]))
} else if (typeof onSelectExercise === 'function') {
await Promise.resolve(onSelectExercise(ex))
}
onClose()
}
const runQuickCreateAiSuggest = async () => {
const title = (quickTitle || '').trim() const title = (quickTitle || '').trim()
if (title.length < 3) { if (title.length < 3) {
alert('Titel: mindestens 3 Zeichen.') alert('Titel: mindestens 3 Zeichen.')
@ -294,12 +357,12 @@ export default function ExercisePickerModal({
} }
const sketch = (quickSketch || '').trim() const sketch = (quickSketch || '').trim()
if (!sketch) { if (!sketch) {
alert('Bitte eine kurze Skizze / Idee eingeben — die KI erzeugt daraus die Anleitung.') alert('Bitte eine kurze Skizze / Idee eingeben.')
return return
} }
const focusId = parseInt(String(quickFocusAreaId).trim(), 10) const focusId = parseInt(String(quickFocusAreaId).trim(), 10)
if (!Number.isFinite(focusId) || focusId < 1) { if (!Number.isFinite(focusId) || focusId < 1) {
alert('Bitte einen Fokusbereich wählen (für Fähigkeiten-Vorschläge).') alert('Bitte einen Fokusbereich wählen.')
return return
} }
@ -307,6 +370,7 @@ export default function ExercisePickerModal({
const focusHint = (focusRow?.name || '').trim() const focusHint = (focusRow?.name || '').trim()
setQuickAiError('') setQuickAiError('')
setQuickCreatePreview(null)
setQuickSaving(true) setQuickSaving(true)
try { try {
const aiRes = await api.suggestExerciseAi({ const aiRes = await api.suggestExerciseAi({
@ -322,23 +386,38 @@ export default function ExercisePickerModal({
include_instructions: true, include_instructions: true,
}) })
const payload = buildQuickCreateExercisePayload({ const preview = buildQuickCreateAiPreview(aiRes, { sketchPlain: sketch })
if (!preview.hasSummaryProposal && !preview.hasInstructionChoices && !preview.hasSkillChoices) {
throw new Error('Die KI lieferte keinen verwertbaren Vorschlag.')
}
setQuickCreatePreview(preview)
} catch (e) {
console.error(e)
const msg = e?.message || String(e)
setQuickAiError(msg)
alert(msg || 'KI-Vorschlag fehlgeschlagen')
} finally {
setQuickSaving(false)
}
}
const applyQuickCreatePreview = async () => {
const title = (quickTitle || '').trim()
const sketch = (quickSketch || '').trim()
const focusId = parseInt(String(quickFocusAreaId).trim(), 10)
if (!quickCreatePreview) return
setQuickSaving(true)
setQuickAiError('')
try {
const payload = buildQuickCreateExercisePayloadFromPreview(quickCreatePreview, {
title, title,
focusAreaId: focusId, focusAreaId: focusId,
sketchPlain: sketch, sketchPlain: sketch,
apiRes: aiRes,
}) })
const created = await api.createExercise(payload) const created = await api.createExercise(payload)
if (!created?.id) { if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
throw new Error('Anlegen fehlgeschlagen') await adoptExistingExercise(created)
}
if (multiSelect && typeof onSelectExercises === 'function') {
await Promise.resolve(onSelectExercises([created]))
} else if (typeof onSelectExercise === 'function') {
await Promise.resolve(onSelectExercise(created))
}
onClose()
} catch (e) { } catch (e) {
console.error(e) console.error(e)
const msg = e?.message || String(e) const msg = e?.message || String(e)
@ -391,14 +470,14 @@ export default function ExercisePickerModal({
onClick={() => setQuickOpen((v) => !v)} onClick={() => setQuickOpen((v) => !v)}
aria-expanded={quickOpen} aria-expanded={quickOpen}
> >
{quickOpen ? 'Neue Übung ausblenden' : 'Neue Übung mit KI anlegen'} {quickOpen ? 'Neue Übung ausblenden' : 'Bibliothek prüfen / neu anlegen'}
</button> </button>
{quickOpen ? ( {quickOpen ? (
<div style={{ marginTop: '12px', display: 'grid', gap: '10px' }}> <div style={{ marginTop: '12px', display: 'grid', gap: '10px' }}>
<p style={{ margin: 0, fontSize: '13px', color: 'var(--text2)', lineHeight: 1.45 }}> <p style={{ margin: 0, fontSize: '13px', color: 'var(--text2)', lineHeight: 1.45 }}>
Die KI erzeugt aus Titel und Skizze <strong>Anleitung</strong> (Ziel, Durchführung, Vorbereitung, Zuerst prüft die Suche die <strong>Bibliothek</strong> (Titel + Skizze). Passt eine Übung, direkt
Trainer-Hinweise), eine <strong>Kurzbeschreibung</strong> und <strong>Fähigkeiten</strong>. Gespeichert übernehmen. Sonst erzeugt die KI einen Vorschlag Anleitung, Kurzbeschreibung, Fähigkeiten den du
als Entwurf (privat) und direkt in den Ablauf übernommen. Benötigt OpenRouter auf dem Server. im Dialog prüfst, bevor gespeichert wird.
</p> </p>
<div> <div>
<label className="form-label" htmlFor="ex-picker-quick-title"> <label className="form-label" htmlFor="ex-picker-quick-title">
@ -448,6 +527,54 @@ export default function ExercisePickerModal({
placeholder="Kurze Beschreibung: Ablauf, Material, Ziel der Übung …" placeholder="Kurze Beschreibung: Ablauf, Material, Ziel der Übung …"
/> />
</div> </div>
<div
style={{
borderTop: '1px solid var(--border)',
paddingTop: '12px',
marginTop: '4px',
}}
>
<strong style={{ fontSize: '13px' }}>Passende Übungen in der Bibliothek</strong>
{quickLibraryQuery.length < 3 ? (
<p style={{ margin: '8px 0 0', fontSize: '12px', color: 'var(--text3)' }}>
Titel oder Skizze (mind. 3 Zeichen) dann erscheinen Treffer.
</p>
) : quickLibraryLoading ? (
<p style={{ margin: '8px 0 0', fontSize: '12px', color: 'var(--text3)' }}>Suche läuft</p>
) : quickLibraryHits.length === 0 ? (
<p style={{ margin: '8px 0 0', fontSize: '12px', color: 'var(--text3)' }}>
Keine Treffer unten KI-Vorschlag erzeugen.
</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: '10px 0 0' }}>
{quickLibraryHits.map((ex) => (
<li key={ex.id} style={{ marginBottom: '8px' }}>
<button
type="button"
className="btn btn-secondary"
style={{
width: '100%',
textAlign: 'left',
padding: '10px 12px',
display: 'block',
}}
onClick={() => adoptExistingExercise(ex)}
>
<strong style={{ display: 'block' }}>{ex.title}</strong>
{(ex.summary || '').trim() ? (
<span style={{ fontSize: '12px', color: 'var(--text2)' }}>
{(ex.summary || '').trim().slice(0, 100)}
{(ex.summary || '').trim().length > 100 ? '…' : ''}
</span>
) : null}
</button>
</li>
))}
</ul>
)}
</div>
{quickAiError ? ( {quickAiError ? (
<p style={{ margin: 0, fontSize: '13px', color: 'var(--danger)' }}>{quickAiError}</p> <p style={{ margin: 0, fontSize: '13px', color: 'var(--danger)' }}>{quickAiError}</p>
) : null} ) : null}
@ -461,9 +588,9 @@ export default function ExercisePickerModal({
!(quickSketch || '').trim() || !(quickSketch || '').trim() ||
!quickFocusAreaId !quickFocusAreaId
} }
onClick={submitQuickCreate} onClick={runQuickCreateAiSuggest}
> >
{quickSaving ? 'KI erzeugt Übung…' : 'Mit KI anlegen und übernehmen'} {quickSaving ? 'KI erzeugt Vorschlag…' : 'KI-Vorschlag erzeugen'}
</button> </button>
</div> </div>
</div> </div>
@ -819,6 +946,19 @@ export default function ExercisePickerModal({
)} )}
</div> </div>
</div> </div>
<ExerciseAiSuggestPreviewModal
preview={quickCreatePreview}
onPreviewChange={setQuickCreatePreview}
onDiscard={() => setQuickCreatePreview(null)}
onApply={applyQuickCreatePreview}
skillsCatalog={catalogs.skills}
dialogTitle="Neue Übung — KI-Vorschlag prüfen"
hint="Felder und Fähigkeiten per Checkbox wählen. Erst „Anlegen und übernehmen“ speichert die Übung (Entwurf, privat) und übernimmt sie in den Ablauf."
applyLabel={quickSaving ? 'Wird angelegt…' : 'Anlegen und übernehmen'}
applyDisabled={quickSaving}
zIndex={2100}
/>
</div> </div>
) )
} }

View File

@ -1,13 +1,21 @@
/** /**
* KI-gestützte Schnellanlage einer Übung (Planung / ExercisePickerModal). * KI-gestützte Schnellanlage / Vorschau (Planung / ExercisePickerModal).
*/ */
import { stripHtmlToText } from './htmlUtils' import { stripHtmlToText } from './htmlUtils'
import { normalizeSkillLevelSlug } from '../constants/skillLevels' import { normalizeSkillLevelSlug, formatSkillLevelSlug } from '../constants/skillLevels'
import { import {
EXERCISE_SKILL_INTENSITY_DEFAULT, EXERCISE_SKILL_INTENSITY_DEFAULT,
normalizeExerciseSkillIntensity, normalizeExerciseSkillIntensity,
formatExerciseSkillIntensityLabel,
} from '../constants/exerciseSkillIntensity' } from '../constants/exerciseSkillIntensity'
export const INSTRUCTION_AI_FIELD_DEFS = [
{ key: 'goal', label: 'Ziel' },
{ key: 'execution', label: 'Durchführung' },
{ key: 'preparation', label: 'Vorbereitung / Aufbau' },
{ key: 'trainer_notes', label: 'Hinweise für Trainer' },
]
function escapeHtmlText(s) { function escapeHtmlText(s) {
return String(s) return String(s)
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
@ -41,38 +49,118 @@ export function normalizeAiSkillRowFromApi(sug) {
} }
} }
/** /** Vorschau für Schnellanlage: Kurzfassung + Anleitung + Fähigkeiten. */
* Baut createExercise-Payload aus suggestExerciseAi-Antwort. export function buildQuickCreateAiPreview(apiRes, { sketchPlain = '' } = {}) {
* @throws {Error} wenn Ziel/Durchführung nach KI leer wären
*/
export function buildQuickCreateExercisePayload({ title, focusAreaId, sketchPlain, apiRes }) {
const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain) const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain)
const fields = apiRes?.instructions?.fields || {} const snapshotInstructions = {
goal: sketchHtml,
execution: '',
preparation: '',
trainer_notes: '',
}
let goal = (fields.goal || '').trim() || sketchHtml let summaryAfterHtml = null
let execution = (fields.execution || '').trim() let summaryAfterPlain = ''
const prep = (fields.preparation || '').trim() || null if (apiRes?.summary?.text) {
const trainerNotes = (fields.trainer_notes || '').trim() || null summaryAfterPlain = String(apiRes.summary.text).trim()
if (summaryAfterPlain) {
summaryAfterHtml = aiPlainTextToMinimalHtml(apiRes.summary.text)
}
}
const skillChoices = []
if (Array.isArray(apiRes?.skills)) {
for (const sug of apiRes.skills) {
const after = normalizeAiSkillRowFromApi(sug)
if (!after) continue
skillChoices.push({
key: String(after.skill_id),
skill_id: after.skill_id,
kind: 'add',
before: null,
after,
include: true,
})
}
}
const instructionChoices = []
const fields = apiRes?.instructions?.fields || {}
for (const def of INSTRUCTION_AI_FIELD_DEFS) {
const afterHtml = fields[def.key]
if (!afterHtml || !String(afterHtml).trim()) continue
const beforeHtml = snapshotInstructions[def.key] || ''
instructionChoices.push({
key: def.key,
field: def.key,
label: def.label,
beforePlain: stripHtmlToText(beforeHtml).trim(),
afterHtml: String(afterHtml),
afterPlain: stripHtmlToText(afterHtml).trim(),
include: true,
})
}
const hasSummaryProposal = !!summaryAfterHtml
const hasSkillChoices = skillChoices.length > 0
const hasInstructionChoices = instructionChoices.length > 0
return {
mode: 'quick_create',
applySummary: hasSummaryProposal,
summaryBeforePlain: stripHtmlToText(sketchPlain).trim(),
summaryAfterPlain,
summaryAfterHtml,
skillChoices,
instructionChoices,
hasSummaryProposal,
hasSkillChoices,
hasInstructionChoices,
summaryRequested: true,
skillsRequested: true,
instructionsRequested: true,
}
}
export function describeAiSkillRowForPreview(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}`
}
/**
* createExercise-Payload aus bestätigter Vorschau.
* @throws {Error}
*/
export function buildQuickCreateExercisePayloadFromPreview(preview, { title, focusAreaId, sketchPlain }) {
const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain)
const fieldMap = {}
for (const c of preview?.instructionChoices || []) {
if (c.include && c.afterHtml) fieldMap[c.field] = c.afterHtml
}
let goal = (fieldMap.goal || '').trim() || sketchHtml
let execution = (fieldMap.execution || '').trim()
const prep = (fieldMap.preparation || '').trim() || null
const trainerNotes = (fieldMap.trainer_notes || '').trim() || null
if (!stripHtmlToText(goal).trim() && !stripHtmlToText(execution).trim()) { if (!stripHtmlToText(goal).trim() && !stripHtmlToText(execution).trim()) {
throw new Error('KI lieferte keine verwertbare Anleitung (Ziel/Durchführung leer).') throw new Error('Mindestens Ziel oder Durchführung muss übernommen werden.')
} }
if (!stripHtmlToText(goal).trim()) goal = execution if (!stripHtmlToText(goal).trim()) goal = execution
if (!stripHtmlToText(execution).trim()) execution = goal if (!stripHtmlToText(execution).trim()) execution = goal
let summary = null let summary = null
if (apiRes?.summary?.text) { if (preview?.applySummary && preview?.summaryAfterHtml) {
const sumHtml = aiPlainTextToMinimalHtml(apiRes.summary.text) summary = preview.summaryAfterHtml
if (sumHtml) summary = sumHtml
} }
const skills = [] const skills = (preview?.skillChoices || []).filter((c) => c.include).map((c) => c.after)
if (Array.isArray(apiRes?.skills)) {
for (const sug of apiRes.skills) {
const row = normalizeAiSkillRowFromApi(sug)
if (row) skills.push(row)
}
}
const fid = Number(focusAreaId) const fid = Number(focusAreaId)
if (!Number.isFinite(fid) || fid < 1) { if (!Number.isFinite(fid) || fid < 1) {
@ -98,3 +186,9 @@ export function buildQuickCreateExercisePayload({ title, focusAreaId, sketchPlai
club_id: null, club_id: null,
} }
} }
/** @deprecated Direkt aus API — nutze Preview + buildQuickCreateExercisePayloadFromPreview */
export function buildQuickCreateExercisePayload({ title, focusAreaId, sketchPlain, apiRes }) {
const preview = buildQuickCreateAiPreview(apiRes, { sketchPlain })
return buildQuickCreateExercisePayloadFromPreview(preview, { title, focusAreaId, sketchPlain })
}