Ermöglicht Analysis Nodes zwischen zwei Prompt-Modi zu wählen: - Reference Mode: Basis-Prompt aus DB referenzieren (bestehend) - Inline Mode: Template direkt im Node editieren (NEU) Frontend: - InlineTemplateEditor Component (~80 Zeilen) - Radio Buttons in WorkflowEditorPage für Mode-Auswahl - Placeholder Picker für beide Modi (End Node + Inline Template) - Cursor-Position Tracking mit textareaRef - Conditional Rendering basierend auf promptSource - Validation: Entweder prompt_slug ODER inline_template Backend: - load_prompt_template() akzeptiert ganzen WorkflowNode (statt nur slug) - Unterstützt inline_template (Mode 1) und prompt_slug (Mode 2) - WorkflowNode.inline_template Feld hinzugefügt - Validation: HTTPException wenn weder slug noch template Serialization: - inline_template in graph_data speichern/laden - Backward-compatible mit bestehenden Workflows Version: 0.9q Module: workflow 0.7.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
239 lines
6.3 KiB
JavaScript
239 lines
6.3 KiB
JavaScript
/**
|
|
* Workflow Validation Utilities
|
|
*
|
|
* Validiert Workflow-Graphen (Struktur + Logik).
|
|
*/
|
|
|
|
export function validateWorkflowGraph(nodes, edges) {
|
|
const errors = []
|
|
const warnings = []
|
|
|
|
// 1. Strukturelle Validierung
|
|
validateStructure(nodes, edges, errors, warnings)
|
|
|
|
// 2. Logische Validierung
|
|
validateLogic(nodes, edges, errors, warnings)
|
|
|
|
return {
|
|
errors,
|
|
warnings,
|
|
isValid: errors.length === 0
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Strukturelle Validierung (DAG, START/END, Zyklen)
|
|
*/
|
|
function validateStructure(nodes, edges, errors, warnings) {
|
|
// START Node
|
|
const startNodes = nodes.filter(n => n.type === 'start')
|
|
if (startNodes.length === 0) {
|
|
errors.push({
|
|
type: 'structure',
|
|
message: 'Kein START-Node vorhanden',
|
|
severity: 'error'
|
|
})
|
|
} else if (startNodes.length > 1) {
|
|
errors.push({
|
|
type: 'structure',
|
|
message: `${startNodes.length} START-Nodes gefunden (max. 1 erlaubt)`,
|
|
severity: 'error'
|
|
})
|
|
}
|
|
|
|
// END Node
|
|
const endNodes = nodes.filter(n => n.type === 'end')
|
|
if (endNodes.length === 0) {
|
|
errors.push({
|
|
type: 'structure',
|
|
message: 'Kein END-Node vorhanden',
|
|
severity: 'error'
|
|
})
|
|
}
|
|
|
|
// Zyklen-Erkennung
|
|
if (detectCycles(nodes, edges)) {
|
|
errors.push({
|
|
type: 'structure',
|
|
message: 'Workflow enthält Zyklen (nicht erlaubt)',
|
|
severity: 'error'
|
|
})
|
|
}
|
|
|
|
// Isolierte Nodes
|
|
nodes.forEach(node => {
|
|
if (node.type === 'start' || node.type === 'end') return
|
|
|
|
const hasIncoming = edges.some(e => e.target === node.id)
|
|
const hasOutgoing = edges.some(e => e.source === node.id)
|
|
|
|
if (!hasIncoming || !hasOutgoing) {
|
|
warnings.push({
|
|
type: 'isolation',
|
|
message: `Node "${node.data.label}" ist isoliert (keine/fehlende Verbindungen)`,
|
|
nodeId: node.id,
|
|
severity: 'warning'
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Logische Validierung (Node-Konfiguration)
|
|
*/
|
|
function validateLogic(nodes, edges, errors, warnings) {
|
|
nodes.forEach(node => {
|
|
// Analysis Nodes
|
|
if (node.type === 'analysis') {
|
|
const questions = node.data.questions || []
|
|
const hasPromptSlug = node.data.prompt_slug != null && node.data.prompt_slug !== ''
|
|
const hasInlineTemplate = node.data.inline_template != null && node.data.inline_template.trim() !== ''
|
|
|
|
// Part 3: Validation - Entweder prompt_slug ODER inline_template
|
|
if (!hasPromptSlug && !hasInlineTemplate) {
|
|
errors.push({
|
|
type: 'config',
|
|
message: `Analysis-Node "${node.data.label}" benötigt entweder Basis-Prompt oder Inline-Template`,
|
|
nodeId: node.id,
|
|
severity: 'error'
|
|
})
|
|
}
|
|
|
|
// Warning wenn beide gesetzt (sollte nicht passieren, aber zur Sicherheit)
|
|
if (hasPromptSlug && hasInlineTemplate) {
|
|
warnings.push({
|
|
type: 'config',
|
|
message: `Analysis-Node "${node.data.label}" hat sowohl Basis-Prompt als auch Inline-Template - Inline hat Vorrang`,
|
|
nodeId: node.id,
|
|
severity: 'warning'
|
|
})
|
|
}
|
|
|
|
// Fragen validieren
|
|
questions.forEach((q, idx) => {
|
|
if (!q.question?.trim()) {
|
|
errors.push({
|
|
type: 'config',
|
|
message: `Frage ${idx + 1} in "${node.data.label}" hat keinen Text`,
|
|
nodeId: node.id,
|
|
severity: 'error'
|
|
})
|
|
}
|
|
|
|
if (!q.answer_spectrum || q.answer_spectrum.length < 2) {
|
|
errors.push({
|
|
type: 'config',
|
|
message: `Frage ${idx + 1} in "${node.data.label}" braucht mind. 2 Antworten`,
|
|
nodeId: node.id,
|
|
severity: 'error'
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
// Logic Nodes
|
|
if (node.type === 'logic') {
|
|
const condition = node.data.condition
|
|
|
|
if (!condition || !condition.operator) {
|
|
errors.push({
|
|
type: 'config',
|
|
message: `Logic-Node "${node.data.label}" hat keine Bedingung`,
|
|
nodeId: node.id,
|
|
severity: 'error'
|
|
})
|
|
} else {
|
|
// Bedingung vollständig?
|
|
const incomplete = findIncompleteConditions(condition)
|
|
if (incomplete.length > 0) {
|
|
errors.push({
|
|
type: 'config',
|
|
message: `Logic-Node "${node.data.label}" hat ${incomplete.length} unvollständige Bedingung(en)`,
|
|
nodeId: node.id,
|
|
severity: 'error'
|
|
})
|
|
}
|
|
}
|
|
|
|
// Mind. 2 Outgoing Edges (true/false Pfade)
|
|
const outgoing = edges.filter(e => e.source === node.id)
|
|
if (outgoing.length < 2) {
|
|
warnings.push({
|
|
type: 'config',
|
|
message: `Logic-Node "${node.data.label}" hat nur ${outgoing.length} Ausgang (sollte mind. 2 haben)`,
|
|
nodeId: node.id,
|
|
severity: 'warning'
|
|
})
|
|
}
|
|
}
|
|
|
|
// Join Nodes
|
|
if (node.type === 'join') {
|
|
const incoming = edges.filter(e => e.target === node.id)
|
|
|
|
if (incoming.length < 2) {
|
|
warnings.push({
|
|
type: 'config',
|
|
message: `Join-Node "${node.data.label}" hat nur ${incoming.length} eingehende Kante (sollte mind. 2 haben)`,
|
|
nodeId: node.id,
|
|
severity: 'warning'
|
|
})
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Zyklen-Erkennung (DFS-basiert)
|
|
*/
|
|
function detectCycles(nodes, edges) {
|
|
const visited = new Set()
|
|
const recStack = new Set()
|
|
|
|
function dfs(nodeId) {
|
|
visited.add(nodeId)
|
|
recStack.add(nodeId)
|
|
|
|
const outgoing = edges.filter(e => e.source === nodeId)
|
|
for (const edge of outgoing) {
|
|
if (!visited.has(edge.target)) {
|
|
if (dfs(edge.target)) return true
|
|
} else if (recStack.has(edge.target)) {
|
|
return true // Cycle detected
|
|
}
|
|
}
|
|
|
|
recStack.delete(nodeId)
|
|
return false
|
|
}
|
|
|
|
for (const node of nodes) {
|
|
if (!visited.has(node.id)) {
|
|
if (dfs(node.id)) return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Unvollständige Bedingungen finden (rekursiv)
|
|
*/
|
|
function findIncompleteConditions(condition) {
|
|
const incomplete = []
|
|
|
|
// Verschachtelte Gruppe?
|
|
if (condition.operands && Array.isArray(condition.operands)) {
|
|
for (const op of condition.operands) {
|
|
incomplete.push(...findIncompleteConditions(op))
|
|
}
|
|
} else {
|
|
// Einfache Bedingung: ref, operator, value müssen gesetzt sein
|
|
if (!condition.ref || !condition.operator || condition.value === undefined || condition.value === '') {
|
|
incomplete.push(condition)
|
|
}
|
|
}
|
|
|
|
return incomplete
|
|
}
|