diff --git a/backend/version.py b/backend/version.py index 2a799c3..cbdf9da 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.163" +APP_VERSION = "0.8.164" BUILD_DATE = "2026-05-31" DB_SCHEMA_VERSION = "20260531071" @@ -42,6 +42,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.164", + "date": "2026-05-31", + "changes": [ + "Planung/Übungspicker: Schnellanlage nutzt suggestExerciseAi (Anleitung, Kurzbeschreibung, Fähigkeiten); Fokusbereich Pflichtfeld.", + ], + }, { "version": "0.8.163", "date": "2026-05-31", diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx index c869498..b9c5589 100644 --- a/frontend/src/components/ExercisePickerModal.jsx +++ b/frontend/src/components/ExercisePickerModal.jsx @@ -17,16 +17,13 @@ import { import SkillTreeMultiSelect from './SkillTreeMultiSelect' import ExerciseFocusRulePicker from './ExerciseFocusRulePicker' import CatalogRulePicker from './CatalogRulePicker' +import { buildQuickCreateExercisePayload } from '../utils/exerciseAiQuickCreate' const PAGE_SIZE = 100 const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null) const INITIAL_FILTERS = { ...INITIAL_EXERCISE_LIST_FILTERS } -/** Stub-Ziel für API-Validator (mind. Ziel oder Durchführung); Nutzer ergänzt Details in der Übungsbearbeitung. */ -const QUICK_CREATE_GOAL_PLACEHOLDER = - 'Aus der Trainingsplanung angelegt — bitte Ziel und Durchführung in der Übungsbearbeitung ergänzen.' - export default function ExercisePickerModal({ open, onClose, @@ -59,8 +56,10 @@ export default function ExercisePickerModal({ const [multiPicked, setMultiPicked] = useState([]) const [quickOpen, setQuickOpen] = useState(false) const [quickTitle, setQuickTitle] = useState('') - const [quickSummary, setQuickSummary] = useState('') + const [quickSketch, setQuickSketch] = useState('') + const [quickFocusAreaId, setQuickFocusAreaId] = useState('') const [quickSaving, setQuickSaving] = useState(false) + const [quickAiError, setQuickAiError] = useState('') const pickerScrollRef = useRef(null) const toggleMultiPick = (ex) => { @@ -124,8 +123,10 @@ export default function ExercisePickerModal({ setMultiPicked([]) setQuickOpen(false) setQuickTitle('') - setQuickSummary('') + setQuickSketch('') + setQuickFocusAreaId('') setQuickSaving(false) + setQuickAiError('') return } setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs)) @@ -291,25 +292,44 @@ export default function ExercisePickerModal({ alert('Titel: mindestens 3 Zeichen.') return } - const summaryRaw = (quickSummary || '').trim() + const sketch = (quickSketch || '').trim() + if (!sketch) { + alert('Bitte eine kurze Skizze / Idee eingeben — die KI erzeugt daraus die Anleitung.') + return + } + const focusId = parseInt(String(quickFocusAreaId).trim(), 10) + if (!Number.isFinite(focusId) || focusId < 1) { + alert('Bitte einen Fokusbereich wählen (für Fähigkeiten-Vorschläge).') + return + } + + const focusRow = (catalogs.focusAreas || []).find((x) => Number(x.id) === focusId) + const focusHint = (focusRow?.name || '').trim() + + setQuickAiError('') setQuickSaving(true) try { - const created = await api.createExercise({ + const aiRes = await api.suggestExerciseAi({ title, - summary: summaryRaw || null, - goal: QUICK_CREATE_GOAL_PLACEHOLDER, - execution: null, - visibility: 'private', - status: 'draft', - equipment: [], - focus_areas_multi: [], - training_styles_multi: [], - training_types_multi: [], - target_groups_multi: [], - age_groups: [], - skills: [], - club_id: null, + goal: sketch, + execution: '', + preparation: '', + trainer_notes: '', + focus_area_hint: focusHint || undefined, + focus_areas_context: [{ focus_area_id: focusId, is_primary: true }], + include_summary: true, + include_skills: true, + include_instructions: true, }) + + const payload = buildQuickCreateExercisePayload({ + title, + focusAreaId: focusId, + sketchPlain: sketch, + apiRes: aiRes, + }) + + const created = await api.createExercise(payload) if (!created?.id) { throw new Error('Anlegen fehlgeschlagen') } @@ -321,7 +341,9 @@ export default function ExercisePickerModal({ onClose() } catch (e) { console.error(e) - alert(e.message || 'Übung konnte nicht angelegt werden') + const msg = e?.message || String(e) + setQuickAiError(msg) + alert(msg || 'Übung konnte nicht angelegt werden') } finally { setQuickSaving(false) } @@ -369,18 +391,18 @@ export default function ExercisePickerModal({ onClick={() => setQuickOpen((v) => !v)} aria-expanded={quickOpen} > - {quickOpen ? 'Neue Übung ausblenden' : 'Neue Übung anlegen (Entwurf, privat)'} + {quickOpen ? 'Neue Übung ausblenden' : 'Neue Übung mit KI anlegen'} - {quickOpen ? ( + {quickOpen ? (
- Wird mit Freigabelevel privat und Status Entwurf gespeichert und - erscheint auf dem Dashboard zum Weiterbearbeiten. Nach dem Speichern wird die Übung direkt in den - Ablauf übernommen. + Die KI erzeugt aus Titel und Skizze Anleitung (Ziel, Durchführung, Vorbereitung, + Trainer-Hinweise), eine Kurzbeschreibung und Fähigkeiten. Gespeichert + als Entwurf (privat) und direkt in den Ablauf übernommen. Benötigt OpenRouter auf dem Server.
{quickAiError}
+ ) : null}${escapeHtmlText(p)}
`).join('') +} + +export function normalizeAiSkillRowFromApi(sug) { + const sid = Number(sug?.skill_id) + if (!Number.isFinite(sid) || sid < 1) return null + return { + skill_id: sid, + intensity: normalizeExerciseSkillIntensity(sug.intensity) || EXERCISE_SKILL_INTENSITY_DEFAULT, + 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, + } +} + +/** + * Baut createExercise-Payload aus suggestExerciseAi-Antwort. + * @throws {Error} wenn Ziel/Durchführung nach KI leer wären + */ +export function buildQuickCreateExercisePayload({ title, focusAreaId, sketchPlain, apiRes }) { + const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain) + const fields = apiRes?.instructions?.fields || {} + + let goal = (fields.goal || '').trim() || sketchHtml + let execution = (fields.execution || '').trim() + const prep = (fields.preparation || '').trim() || null + const trainerNotes = (fields.trainer_notes || '').trim() || null + + if (!stripHtmlToText(goal).trim() && !stripHtmlToText(execution).trim()) { + throw new Error('KI lieferte keine verwertbare Anleitung (Ziel/Durchführung leer).') + } + if (!stripHtmlToText(goal).trim()) goal = execution + if (!stripHtmlToText(execution).trim()) execution = goal + + let summary = null + if (apiRes?.summary?.text) { + const sumHtml = aiPlainTextToMinimalHtml(apiRes.summary.text) + if (sumHtml) summary = sumHtml + } + + const skills = [] + if (Array.isArray(apiRes?.skills)) { + for (const sug of apiRes.skills) { + const row = normalizeAiSkillRowFromApi(sug) + if (row) skills.push(row) + } + } + + const fid = Number(focusAreaId) + if (!Number.isFinite(fid) || fid < 1) { + throw new Error('Fokusbereich ist erforderlich.') + } + + return { + title: (title || '').trim(), + summary, + goal, + execution, + preparation: prep, + trainer_notes: trainerNotes, + visibility: 'private', + status: 'draft', + equipment: [], + focus_areas_multi: [{ focus_area_id: fid, is_primary: true }], + training_styles_multi: [], + training_types_multi: [], + target_groups_multi: [], + age_groups: [], + skills, + club_id: null, + } +}