mitai-jinkendo/frontend/src/utils/workflowValidation.js
Lars ba773e677b
Some checks failed
Deploy Development / deploy (push) Successful in 1m1s
Build Test / pytest-backend (push) Failing after 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
fix(workflow): Test-Suite Fixes - Issues #5, #8, #9, #11, #12
Addressed test results from Test_status_Wkf.md:

**Issue #5: End-Node Überschriften**
- Fixed aggregate_results to show node labels instead of "Node 10"
- Added graph lookup to get node.data.label from node objects
- Modified backend/workflow_executor.py (2 locations)

**Issue #8: Löschen-Taste funktioniert nicht**
- Added Delete key support to WorkflowCanvas
- Set deleteKeyCode={['Backspace', 'Delete']}
- Frontend: WorkflowCanvas.jsx

**Issue #9: Mehrere End-Nodes verhindern**
- Added validation error when multiple End-Nodes exist
- Backend supports only 1 End-Node (aggregate_results takes last)
- Frontend: workflowValidation.js

**Issue #11: Export Fehler "Internal Server Error"**
- Added missing fields to export-all endpoint:
  - graph_data (workflow node graph)
  - question_augmentations (analysis prompts)
- Added missing fields to import endpoint
- Proper JSON serialization for all JSONB fields
- Backend: routers/prompts.py

**Issue #12: Workflow duplizieren funktioniert nicht**
- Fixed duplicate endpoint to include all prompt fields:
  - type, stages, output_format, output_schema
  - question_augmentations, graph_data (critical for workflows!)
- Backend: routers/prompts.py

Files changed:
- backend/workflow_executor.py: Node label lookup in aggregate_results
- backend/routers/prompts.py: Export/import/duplicate fixes
- frontend/src/components/workflow/WorkflowCanvas.jsx: Delete key
- frontend/src/utils/workflowValidation.js: Max 1 End-Node validation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 14:15:57 +02:00

247 lines
6.6 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'
})
} 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
}