All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m21s
- Introduced skills catalog management in the `ProgressionGraphEditor`, allowing for improved context in AI suggestions. - Updated the loading mechanism to fetch both focus areas and skills catalog concurrently, enhancing performance. - Implemented `ensureQuickCreateDraftFromAiSuggestion` utility to streamline the creation of drafts from AI suggestions. - Enhanced slot management by integrating AI context into the gap fill preparation process, improving user experience. - Incremented application version to reflect these updates.
322 lines
10 KiB
JavaScript
322 lines
10 KiB
JavaScript
/**
|
|
* KI-gestützte Schnellanlage / Vorschau (Planung / ExercisePickerModal).
|
|
*/
|
|
import { stripHtmlToText } from './htmlUtils'
|
|
import { normalizeSkillLevelSlug, formatSkillLevelSlug } from '../constants/skillLevels'
|
|
import {
|
|
EXERCISE_SKILL_INTENSITY_DEFAULT,
|
|
normalizeExerciseSkillIntensity,
|
|
formatExerciseSkillIntensityLabel,
|
|
} 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) {
|
|
return String(s)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
}
|
|
|
|
/** Suchstring → Titel + Skizze für Schnellanlage (erste Zeile / Satz als Titel). */
|
|
export function parseSearchQueryForQuickCreate(searchText) {
|
|
const raw = String(searchText || '').trim()
|
|
if (!raw) return { title: '', sketch: '' }
|
|
|
|
const lines = raw.split(/\n/).map((l) => l.trim()).filter(Boolean)
|
|
if (lines.length >= 2) {
|
|
return {
|
|
title: lines[0].slice(0, 300),
|
|
sketch: lines.slice(1).join('\n'),
|
|
}
|
|
}
|
|
|
|
const single = lines[0] || raw
|
|
if (single.length <= 80) {
|
|
return { title: single.slice(0, 300), sketch: single }
|
|
}
|
|
|
|
const sentenceMatch = single.match(/^(.{10,120}?[.!?;:])\s+(.+)$/s)
|
|
if (sentenceMatch) {
|
|
return {
|
|
title: sentenceMatch[1].trim().slice(0, 300),
|
|
sketch: single,
|
|
}
|
|
}
|
|
|
|
const words = single.split(/\s+/).filter(Boolean)
|
|
const titleWordCount = Math.min(8, Math.max(3, Math.ceil(words.length / 3)))
|
|
const title = words.slice(0, titleWordCount).join(' ').slice(0, 120)
|
|
return { title, sketch: single }
|
|
}
|
|
|
|
/** Plaintext → Absätze für RichTextEditor / API. */
|
|
export function aiPlainTextToMinimalHtml(text) {
|
|
const raw = String(text || '').trim()
|
|
if (!raw) return ''
|
|
const parts = raw.split(/\n+/).map((p) => p.trim()).filter(Boolean)
|
|
const paras = parts.length ? parts : [raw]
|
|
return paras.map((p) => `<p>${escapeHtmlText(p)}</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,
|
|
}
|
|
}
|
|
|
|
/** Vorschau für Schnellanlage: Kurzfassung + Anleitung + Fähigkeiten. */
|
|
export function buildQuickCreateAiPreview(apiRes, { sketchPlain = '' } = {}) {
|
|
const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain)
|
|
const snapshotInstructions = {
|
|
goal: sketchHtml,
|
|
execution: '',
|
|
preparation: '',
|
|
trainer_notes: '',
|
|
}
|
|
|
|
let summaryAfterHtml = null
|
|
let summaryAfterPlain = ''
|
|
if (apiRes?.summary?.text) {
|
|
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}`
|
|
}
|
|
|
|
/** KI-Vorschau → bearbeitbarer Entwurf (Rich-Text-Felder). */
|
|
/** Rohes API-ai_suggestion oder bereits bearbeiteter Entwurf → Preview-Entwurf. */
|
|
export function ensureQuickCreateDraftFromAiSuggestion(
|
|
aiSuggestion,
|
|
{ title = '', focusAreaId = '', sketchPlain = '' } = {},
|
|
) {
|
|
if (!aiSuggestion || typeof aiSuggestion !== 'object') return null
|
|
if (aiSuggestion.instructionFields) return aiSuggestion
|
|
const preview = buildQuickCreateAiPreview(aiSuggestion, { sketchPlain })
|
|
if (
|
|
!preview.hasSummaryProposal &&
|
|
!preview.hasInstructionChoices &&
|
|
!preview.hasSkillChoices
|
|
) {
|
|
return null
|
|
}
|
|
return aiPreviewToQuickCreateDraft(preview, { title, focusAreaId, sketchPlain })
|
|
}
|
|
|
|
export function aiPreviewToQuickCreateDraft(preview, { title, focusAreaId, sketchPlain }) {
|
|
const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain)
|
|
const instructionFields = {
|
|
goal: sketchHtml,
|
|
execution: '',
|
|
preparation: '',
|
|
trainer_notes: '',
|
|
}
|
|
for (const c of preview?.instructionChoices || []) {
|
|
if (c.field && c.include !== false && c.afterHtml) {
|
|
instructionFields[c.field] = String(c.afterHtml)
|
|
}
|
|
}
|
|
|
|
return {
|
|
title: (title || '').trim(),
|
|
focusAreaId: focusAreaId != null && focusAreaId !== '' ? String(focusAreaId) : '',
|
|
summaryHtml:
|
|
preview?.applySummary !== false && preview?.summaryAfterHtml ? String(preview.summaryAfterHtml) : '',
|
|
instructionFields,
|
|
skillChoices: (preview?.skillChoices || []).map((c) => ({ ...c })),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* createExercise-Payload aus bearbeitetem Entwurf.
|
|
* @throws {Error}
|
|
*/
|
|
export function buildQuickCreateExercisePayloadFromDraft(draft) {
|
|
const title = (draft?.title || '').trim()
|
|
if (title.length < 3) {
|
|
throw new Error('Titel: mindestens 3 Zeichen.')
|
|
}
|
|
|
|
const fid = Number(draft?.focusAreaId)
|
|
if (!Number.isFinite(fid) || fid < 1) {
|
|
throw new Error('Fokusbereich ist erforderlich.')
|
|
}
|
|
|
|
const fields = draft?.instructionFields || {}
|
|
let goal = (fields.goal || '').trim()
|
|
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('Mindestens Ziel oder Durchführung ausfüllen.')
|
|
}
|
|
if (!stripHtmlToText(goal).trim()) goal = execution
|
|
if (!stripHtmlToText(execution).trim()) execution = goal
|
|
|
|
let summary = (draft?.summaryHtml || '').trim() || null
|
|
if (summary && !stripHtmlToText(summary).trim()) summary = null
|
|
|
|
const skills = (draft?.skillChoices || []).filter((c) => c.include).map((c) => c.after)
|
|
|
|
return {
|
|
title,
|
|
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,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* createExercise-Payload aus bestätigter Vorschau (Checkbox-Modus).
|
|
* @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()) {
|
|
throw new Error('Mindestens Ziel oder Durchführung muss übernommen werden.')
|
|
}
|
|
if (!stripHtmlToText(goal).trim()) goal = execution
|
|
if (!stripHtmlToText(execution).trim()) execution = goal
|
|
|
|
let summary = null
|
|
if (preview?.applySummary && preview?.summaryAfterHtml) {
|
|
summary = preview.summaryAfterHtml
|
|
}
|
|
|
|
const skills = (preview?.skillChoices || []).filter((c) => c.include).map((c) => c.after)
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
/** @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 })
|
|
}
|