All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 43s
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
- Updated `PROJECT_STATUS.md` to reflect the implementation of F15 features, including the unified slot review and handling of `findings_stale`. - Enhanced `PROGRESSION_GRAPH_SLOT_EDITOR_SPEC.md` with detailed descriptions of new functionalities related to the match dialog and path quality assessments. - Introduced new functions in `exercise_progression_graphs.py` to validate exercise visibility against progression graph settings, ensuring proper governance. - Improved frontend components to support new governance parameters (visibility and club_id) in exercise creation workflows. - Updated documentation in `HANDOVER.md` and `PLANNING_KI_ROADMAP.md` to outline the latest developments and validation results for the F15 features. - Enhanced utility functions for exercise creation to incorporate governance settings, improving the overall user experience in the path builder and editor.
341 lines
11 KiB
JavaScript
341 lines
11 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 })),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sichtbarkeit/club_id für Schnellanlage (z. B. aus Progressionsgraph).
|
|
* @param {{ visibility?: string, clubId?: number|null }} [governance]
|
|
*/
|
|
function resolveQuickCreateGovernance(governance) {
|
|
const rawVis = (governance?.visibility || 'private').trim().toLowerCase()
|
|
const vis = rawVis === 'club' || rawVis === 'official' ? rawVis : 'private'
|
|
let clubId = null
|
|
if (vis === 'club' && governance?.clubId != null && governance.clubId !== '') {
|
|
const n = Number(governance.clubId)
|
|
if (Number.isFinite(n) && n > 0) clubId = n
|
|
}
|
|
return { visibility: vis, club_id: clubId }
|
|
}
|
|
|
|
/**
|
|
* createExercise-Payload aus bearbeitetem Entwurf.
|
|
* @param {{ visibility?: string, clubId?: number|null }} [governance]
|
|
* @throws {Error}
|
|
*/
|
|
export function buildQuickCreateExercisePayloadFromDraft(draft, governance) {
|
|
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)
|
|
const { visibility, club_id: clubId } = resolveQuickCreateGovernance(governance)
|
|
|
|
return {
|
|
title,
|
|
summary,
|
|
goal,
|
|
execution,
|
|
preparation: prep,
|
|
trainer_notes: trainerNotes,
|
|
visibility,
|
|
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: clubId,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* createExercise-Payload aus bestätigter Vorschau (Checkbox-Modus).
|
|
* @param {{ visibility?: string, clubId?: number|null }} [governance]
|
|
* @throws {Error}
|
|
*/
|
|
export function buildQuickCreateExercisePayloadFromPreview(preview, { title, focusAreaId, sketchPlain, ...governance } = {}) {
|
|
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 { visibility, club_id: clubId } = resolveQuickCreateGovernance(governance)
|
|
|
|
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,
|
|
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: clubId,
|
|
}
|
|
}
|
|
|
|
/** @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 })
|
|
}
|