/** * 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' }) } else if (endNodes.length > 1) { // Backend unterstützt aktuell nur 1 End-Node (aggregate_results nimmt letzten) // Future: Multi-Exit Support würde Backend-Anpassung erfordern errors.push({ type: 'structure', message: `${endNodes.length} END-Nodes gefunden (max. 1 erlaubt)`, 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 }