first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
+57
@@ -0,0 +1,57 @@
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import { createBinaryChecksEvaluator } from '../index';
|
||||
|
||||
const mockNodeTypes: INodeTypeDescription[] = [];
|
||||
|
||||
describe('createBinaryChecksEvaluator', () => {
|
||||
it('creates evaluator with correct name', () => {
|
||||
const evaluator = createBinaryChecksEvaluator({ nodeTypes: mockNodeTypes });
|
||||
expect(evaluator.name).toBe('binary-checks');
|
||||
});
|
||||
|
||||
it('runs deterministic checks without LLM', async () => {
|
||||
const evaluator = createBinaryChecksEvaluator({ nodeTypes: mockNodeTypes });
|
||||
const workflow = { name: 'test', nodes: [], connections: {} };
|
||||
const feedback = await evaluator.evaluate(workflow, { prompt: 'test' });
|
||||
expect(feedback.length).toBe(17); // 17 deterministic checks
|
||||
expect(feedback.every((f) => f.evaluator === 'binary-checks')).toBe(true);
|
||||
expect(feedback.every((f) => f.kind === 'metric')).toBe(true);
|
||||
expect(feedback.every((f) => f.score === 0 || f.score === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('filters checks with --checks option', async () => {
|
||||
const evaluator = createBinaryChecksEvaluator({
|
||||
nodeTypes: mockNodeTypes,
|
||||
checks: ['has_nodes', 'has_trigger'],
|
||||
});
|
||||
const workflow = { name: 'test', nodes: [], connections: {} };
|
||||
const feedback = await evaluator.evaluate(workflow, { prompt: 'test' });
|
||||
expect(feedback.length).toBe(2);
|
||||
expect(feedback.map((f) => f.metric).sort()).toEqual(['has_nodes', 'has_trigger']);
|
||||
});
|
||||
|
||||
it('throws when all check names in filter are invalid', () => {
|
||||
expect(() =>
|
||||
createBinaryChecksEvaluator({
|
||||
nodeTypes: mockNodeTypes,
|
||||
checks: ['nonexistent_check'],
|
||||
}),
|
||||
).toThrow('No valid checks after filtering');
|
||||
});
|
||||
|
||||
it('warns but continues when some check names are unrecognized', async () => {
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
const evaluator = createBinaryChecksEvaluator({
|
||||
nodeTypes: mockNodeTypes,
|
||||
checks: ['has_nodes', 'nonexistent'],
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('nonexistent'));
|
||||
warnSpy.mockRestore();
|
||||
|
||||
const workflow = { name: 'test', nodes: [], connections: {} };
|
||||
const feedback = await evaluator.evaluate(workflow, { prompt: 'test' });
|
||||
expect(feedback.length).toBe(1);
|
||||
expect(feedback[0].metric).toBe('has_nodes');
|
||||
});
|
||||
});
|
||||
+1047
File diff suppressed because it is too large
Load Diff
+50
@@ -0,0 +1,50 @@
|
||||
import { getChildNodes, mapConnectionsByDestination } from 'n8n-workflow';
|
||||
|
||||
import type { BinaryCheck, SimpleWorkflow } from '../types';
|
||||
|
||||
const STICKY_NOTE_TYPE = 'n8n-nodes-base.stickyNote';
|
||||
|
||||
export const allNodesConnected: BinaryCheck = {
|
||||
name: 'all_nodes_connected',
|
||||
kind: 'deterministic',
|
||||
async run(workflow: SimpleWorkflow) {
|
||||
// Filter out sticky notes — they are visual annotations, not part of the workflow graph
|
||||
const activeNodes = (workflow.nodes ?? []).filter((n) => n.type !== STICKY_NOTE_TYPE);
|
||||
|
||||
if (activeNodes.length === 0) {
|
||||
return { pass: true };
|
||||
}
|
||||
|
||||
const connections = workflow.connections ?? {};
|
||||
|
||||
// Use n8n-workflow graph utilities to determine connectivity
|
||||
const connectionsByDest = mapConnectionsByDestination(connections);
|
||||
|
||||
// Build full reachability set: nodes that appear as source or target in any connection,
|
||||
// plus all nodes transitively reachable via getChildNodes (ALL connection types)
|
||||
const connected = new Set<string>();
|
||||
for (const sourceName of Object.keys(connections)) {
|
||||
connected.add(sourceName);
|
||||
for (const child of getChildNodes(connections, sourceName, 'ALL', -1)) {
|
||||
connected.add(child);
|
||||
}
|
||||
}
|
||||
for (const destName of Object.keys(connectionsByDest)) {
|
||||
connected.add(destName);
|
||||
}
|
||||
|
||||
const disconnected: string[] = [];
|
||||
for (const node of activeNodes) {
|
||||
if (!connected.has(node.name)) {
|
||||
disconnected.push(node.name);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pass: disconnected.length === 0,
|
||||
...(disconnected.length > 0
|
||||
? { comment: `Disconnected nodes: ${disconnected.join(', ')}` }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import type { ProgrammaticViolation } from '@/validation/types';
|
||||
|
||||
import type { BinaryCheck, BinaryCheckContext, SimpleWorkflow } from '../types';
|
||||
|
||||
type ValidateFn = (
|
||||
workflow: SimpleWorkflow,
|
||||
nodeTypes: INodeTypeDescription[],
|
||||
) => ProgrammaticViolation[];
|
||||
|
||||
interface ValidationCheckConfig {
|
||||
/** Check name in snake_case */
|
||||
name: string;
|
||||
/** Validation function to call. nodeTypes is always passed but can be ignored. */
|
||||
validate: ValidateFn;
|
||||
/** Optional filter to select specific violations. If omitted, all violations count. */
|
||||
filter?: (violation: ProgrammaticViolation) => boolean;
|
||||
/** Static comment when check fails. If omitted, violation descriptions are joined with '; '. */
|
||||
failComment?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for binary checks that wrap existing validation functions.
|
||||
*
|
||||
* Eliminates boilerplate: call validator → filter violations → format comment.
|
||||
*/
|
||||
export function createValidationCheck(config: ValidationCheckConfig): BinaryCheck {
|
||||
return {
|
||||
name: config.name,
|
||||
kind: 'deterministic',
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
async run(workflow: SimpleWorkflow, ctx: BinaryCheckContext) {
|
||||
const all = config.validate(workflow, ctx.nodeTypes);
|
||||
const violations = config.filter ? all.filter(config.filter) : all;
|
||||
if (violations.length === 0) return { pass: true };
|
||||
const comment = config.failComment ?? violations.map((v) => v.description).join('; ');
|
||||
return { pass: false, comment };
|
||||
},
|
||||
};
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import type { BinaryCheck, SimpleWorkflow } from '../types';
|
||||
|
||||
/**
|
||||
* Regex patterns to extract node names from n8n expression syntaxes.
|
||||
* Mirrors ACCESS_PATTERNS from packages/workflow/src/node-reference-parser-utils.ts.
|
||||
*
|
||||
* Quoted patterns capture at group index 2; dot-notation captures at group index 1.
|
||||
*/
|
||||
const QUOTED_NODE_REFS: RegExp[] = [
|
||||
/\$\(\s*(['"`])((?:\\.|(?!\1)[^\\])*)\1\s*\)/g, // $('Node Name')
|
||||
/\$node\[\s*(['"])((?:\\.|(?!\1)[^\\])*)\1\s*\]/g, // $node["Node Name"]
|
||||
/\$items\(\s*(['"])((?:\\.|(?!\1)[^\\])*)\1\s*[,)]/g, // $items("Node Name") or $items("Node Name", 0)
|
||||
];
|
||||
|
||||
/** Legacy dot-notation: $node.NodeName.json... — name is a JS identifier after $node. */
|
||||
const DOT_NODE_REF = /\$node\.(\w+)\./g;
|
||||
|
||||
/** Remove backslash escapes from a captured node name (e.g. `Node\'s` → `Node's`). */
|
||||
function unescapeNodeName(raw: string): string {
|
||||
return raw.replace(/\\(.)/g, '$1');
|
||||
}
|
||||
|
||||
/** Collect all matches from a global regex, returning captured groups at the given index. */
|
||||
function collectMatches(pattern: RegExp, text: string, groupIndex: number): string[] {
|
||||
return Array.from(text.matchAll(pattern), (m) => m[groupIndex]);
|
||||
}
|
||||
|
||||
/** Extract all referenced node names from an expression string. */
|
||||
function extractNodeNamesFromExpression(expression: string): string[] {
|
||||
const names: string[] = [];
|
||||
|
||||
for (const pattern of QUOTED_NODE_REFS) {
|
||||
for (const raw of collectMatches(pattern, expression, 2)) {
|
||||
names.push(unescapeNodeName(raw));
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy $node.Name dot-notation
|
||||
for (const name of collectMatches(DOT_NODE_REF, expression, 1)) {
|
||||
names.push(name);
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Recursively extract all expression strings from node parameters. */
|
||||
function extractExpressionsFromParams(value: unknown, key?: string): string[] {
|
||||
if (typeof value === 'string') {
|
||||
// Expressions start with '=', jsCode fields are also expressions
|
||||
if (value.charAt(0) === '=' || key === 'jsCode') {
|
||||
return [value];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((item) => extractExpressionsFromParams(item));
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return Object.entries(value).flatMap(([k, v]) => extractExpressionsFromParams(v, k));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export const expressionsReferenceExistingNodes: BinaryCheck = {
|
||||
name: 'expressions_reference_existing_nodes',
|
||||
kind: 'deterministic',
|
||||
async run(workflow: SimpleWorkflow) {
|
||||
if (!workflow.nodes || workflow.nodes.length === 0) {
|
||||
return { pass: true };
|
||||
}
|
||||
|
||||
const existingNodeNames = new Set(workflow.nodes.map((n) => n.name));
|
||||
const invalid: string[] = [];
|
||||
|
||||
for (const node of workflow.nodes) {
|
||||
if (!node.parameters) continue;
|
||||
|
||||
const expressions = extractExpressionsFromParams(node.parameters);
|
||||
for (const expr of expressions) {
|
||||
const referencedNames = extractNodeNamesFromExpression(expr);
|
||||
for (const refName of referencedNames) {
|
||||
if (!existingNodeNames.has(refName)) {
|
||||
invalid.push(`"${refName}" (in node "${node.name}")`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
const unique = [...new Set(invalid)];
|
||||
|
||||
return {
|
||||
pass: unique.length === 0,
|
||||
...(unique.length > 0
|
||||
? { comment: `Expressions reference non-existent nodes: ${unique.join(', ')}` }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { getChildNodes, NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import { createNodeTypeMaps, getNodeTypeForNode } from '@/validation/utils/node-type-map';
|
||||
|
||||
import type { BinaryCheck, BinaryCheckContext, SimpleWorkflow } from '../types';
|
||||
|
||||
export const hasStartNode: BinaryCheck = {
|
||||
name: 'has_start_node',
|
||||
kind: 'deterministic',
|
||||
async run(workflow: SimpleWorkflow, ctx: BinaryCheckContext) {
|
||||
if (!workflow.nodes || workflow.nodes.length === 0) {
|
||||
return { pass: false, comment: 'Workflow has no nodes' };
|
||||
}
|
||||
|
||||
const connections = workflow.connections ?? {};
|
||||
const { nodeTypeMap, nodeTypesByName } = createNodeTypeMaps(ctx.nodeTypes);
|
||||
|
||||
for (const node of workflow.nodes) {
|
||||
const nodeType = getNodeTypeForNode(node, nodeTypeMap, nodeTypesByName);
|
||||
if (!nodeType?.group.includes('trigger')) continue;
|
||||
|
||||
const children = getChildNodes(connections, node.name, NodeConnectionTypes.Main, 1);
|
||||
if (children.length > 0) {
|
||||
return { pass: true };
|
||||
}
|
||||
}
|
||||
|
||||
return { pass: false, comment: 'No trigger has a downstream node connected' };
|
||||
},
|
||||
};
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import type { BinaryCheck } from '../types';
|
||||
import { allNodesConnected } from './all-nodes-connected';
|
||||
import { expressionsReferenceExistingNodes } from './expressions-reference-existing-nodes';
|
||||
import { hasStartNode } from './has-start-node';
|
||||
import { noEmptySetNodes } from './no-empty-set-nodes';
|
||||
import { noUnnecessaryCodeNodes } from './no-unnecessary-code-nodes';
|
||||
import { noUnreachableNodes } from './no-unreachable-nodes';
|
||||
import {
|
||||
agentHasDynamicPrompt,
|
||||
agentHasLanguageModel,
|
||||
hasNodes,
|
||||
hasTrigger,
|
||||
memoryProperlyConnected,
|
||||
noHardcodedCredentials,
|
||||
noInvalidFromAi,
|
||||
toolsHaveParameters,
|
||||
validOptionsValues,
|
||||
validRequiredParameters,
|
||||
vectorStoreHasEmbeddings,
|
||||
} from './validation-checks';
|
||||
|
||||
export const DETERMINISTIC_CHECKS: BinaryCheck[] = [
|
||||
hasNodes,
|
||||
allNodesConnected,
|
||||
noUnreachableNodes,
|
||||
hasTrigger,
|
||||
noEmptySetNodes,
|
||||
agentHasDynamicPrompt,
|
||||
agentHasLanguageModel,
|
||||
memoryProperlyConnected,
|
||||
vectorStoreHasEmbeddings,
|
||||
hasStartNode,
|
||||
noHardcodedCredentials,
|
||||
noUnnecessaryCodeNodes,
|
||||
expressionsReferenceExistingNodes,
|
||||
validRequiredParameters,
|
||||
validOptionsValues,
|
||||
noInvalidFromAi,
|
||||
toolsHaveParameters,
|
||||
];
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import type { BinaryCheck, SimpleWorkflow } from '../types';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a Set node has values configured in either format:
|
||||
* - v3.x+: parameters.assignments.assignments (array)
|
||||
* - v2.x: parameters.fields.values (array)
|
||||
*/
|
||||
function hasSetNodeValues(parameters: unknown): boolean {
|
||||
if (!isRecord(parameters)) return false;
|
||||
|
||||
// v3.x+ format: assignments.assignments
|
||||
const assignments = parameters.assignments;
|
||||
if (isRecord(assignments)) {
|
||||
if (Array.isArray(assignments.assignments) && assignments.assignments.length > 0) return true;
|
||||
}
|
||||
|
||||
// v2.x format: fields.values
|
||||
const fields = parameters.fields;
|
||||
if (isRecord(fields)) {
|
||||
if (Array.isArray(fields.values) && fields.values.length > 0) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export const noEmptySetNodes: BinaryCheck = {
|
||||
name: 'no_empty_set_nodes',
|
||||
kind: 'deterministic',
|
||||
async run(workflow: SimpleWorkflow) {
|
||||
if (!workflow.nodes || workflow.nodes.length === 0) {
|
||||
return { pass: true };
|
||||
}
|
||||
|
||||
const emptySetNodes: string[] = [];
|
||||
for (const node of workflow.nodes) {
|
||||
if (node.type !== 'n8n-nodes-base.set') continue;
|
||||
|
||||
if (!hasSetNodeValues(node.parameters)) {
|
||||
emptySetNodes.push(node.name);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pass: emptySetNodes.length === 0,
|
||||
...(emptySetNodes.length > 0
|
||||
? { comment: `Empty Set nodes (no assignments): ${emptySetNodes.join(', ')}` }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import type { BinaryCheck, BinaryCheckContext, SimpleWorkflow } from '../types';
|
||||
|
||||
export const noUnnecessaryCodeNodes: BinaryCheck = {
|
||||
name: 'no_unnecessary_code_nodes',
|
||||
kind: 'deterministic',
|
||||
async run(workflow: SimpleWorkflow, ctx: BinaryCheckContext) {
|
||||
if (ctx.annotations?.code_necessary === true) {
|
||||
return { pass: true, comment: 'Code marked as necessary by annotation' };
|
||||
}
|
||||
|
||||
if (!workflow.nodes || workflow.nodes.length === 0) {
|
||||
return { pass: true };
|
||||
}
|
||||
|
||||
const codeNodes = workflow.nodes.filter((n) => n.type === 'n8n-nodes-base.code');
|
||||
return {
|
||||
pass: codeNodes.length === 0,
|
||||
...(codeNodes.length > 0
|
||||
? { comment: `Unnecessary code nodes: ${codeNodes.map((n) => n.name).join(', ')}` }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import { getChildNodes } from 'n8n-workflow';
|
||||
|
||||
import { createNodeTypeMaps, getNodeTypeForNode } from '@/validation/utils/node-type-map';
|
||||
|
||||
import type { BinaryCheck, BinaryCheckContext, SimpleWorkflow } from '../types';
|
||||
|
||||
const STICKY_NOTE_TYPE = 'n8n-nodes-base.stickyNote';
|
||||
|
||||
function findTriggerNames(workflow: SimpleWorkflow, nodeTypes: INodeTypeDescription[]): string[] {
|
||||
const { nodeTypeMap, nodeTypesByName } = createNodeTypeMaps(nodeTypes);
|
||||
const triggers: string[] = [];
|
||||
for (const node of workflow.nodes) {
|
||||
const nodeType = getNodeTypeForNode(node, nodeTypeMap, nodeTypesByName);
|
||||
if (nodeType?.group.includes('trigger')) {
|
||||
triggers.push(node.name);
|
||||
}
|
||||
}
|
||||
return triggers;
|
||||
}
|
||||
|
||||
/** Check if a node has an ai_* output connection to any node in the reachable set. */
|
||||
function connectsToReachableViaAi(
|
||||
nodeConns: SimpleWorkflow['connections'][string],
|
||||
reachable: Set<string>,
|
||||
): boolean {
|
||||
return Object.entries(nodeConns).some(
|
||||
([connType, connGroups]) =>
|
||||
connType.startsWith('ai_') &&
|
||||
connGroups.some((group) => group?.some((conn) => conn?.node && reachable.has(conn.node))),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find sub-nodes that connect TO already-reachable nodes via ai_* outputs.
|
||||
* Repeats until no new nodes are discovered (fixpoint), so deeply nested
|
||||
* sub-nodes (e.g., Tool → AgentTool → Agent) are handled.
|
||||
*
|
||||
* Returns a new Set containing the original reachable nodes plus discovered sub-nodes.
|
||||
*/
|
||||
function expandReachableWithSubNodes(
|
||||
reachable: ReadonlySet<string>,
|
||||
connections: SimpleWorkflow['connections'],
|
||||
allNodeNames: string[],
|
||||
): Set<string> {
|
||||
const expanded = new Set(reachable);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const nodeName of allNodeNames) {
|
||||
if (expanded.has(nodeName)) continue;
|
||||
|
||||
const nodeConns = connections[nodeName];
|
||||
if (!nodeConns) continue;
|
||||
|
||||
if (connectsToReachableViaAi(nodeConns, expanded)) {
|
||||
expanded.add(nodeName);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return expanded;
|
||||
}
|
||||
|
||||
export const noUnreachableNodes: BinaryCheck = {
|
||||
name: 'no_unreachable_nodes',
|
||||
kind: 'deterministic',
|
||||
async run(workflow: SimpleWorkflow, ctx: BinaryCheckContext) {
|
||||
// Filter out sticky notes — they are visual annotations, not part of the workflow graph
|
||||
const activeNodes = (workflow.nodes ?? []).filter((n) => n.type !== STICKY_NOTE_TYPE);
|
||||
|
||||
if (activeNodes.length === 0) {
|
||||
return { pass: true };
|
||||
}
|
||||
|
||||
const connections = workflow.connections ?? {};
|
||||
const triggers = findTriggerNames(workflow, ctx.nodeTypes);
|
||||
|
||||
// No triggers means all nodes are unreachable — fail explicitly
|
||||
if (triggers.length === 0) {
|
||||
const nodeNames = activeNodes.map((n) => n.name);
|
||||
return {
|
||||
pass: false,
|
||||
comment: `No trigger found — all nodes unreachable: ${nodeNames.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Forward BFS from triggers using n8n-workflow's getChildNodes (ALL connection types)
|
||||
const reachable = new Set<string>(triggers);
|
||||
for (const trigger of triggers) {
|
||||
for (const child of getChildNodes(connections, trigger, 'ALL', -1)) {
|
||||
reachable.add(child);
|
||||
}
|
||||
}
|
||||
|
||||
// Iteratively expand: sub-nodes connect TO parents via ai_* outputs.
|
||||
// Repeat until stable so nested sub-nodes (Tool → AgentTool → Agent) are found.
|
||||
const allNodeNames = activeNodes.map((n) => n.name);
|
||||
const expanded = expandReachableWithSubNodes(reachable, connections, allNodeNames);
|
||||
|
||||
const unreachable = activeNodes.filter((n) => !expanded.has(n.name)).map((n) => n.name);
|
||||
|
||||
return {
|
||||
pass: unreachable.length === 0,
|
||||
...(unreachable.length > 0
|
||||
? { comment: `Unreachable nodes: ${unreachable.join(', ')}` }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Binary checks that wrap existing validation functions.
|
||||
*
|
||||
* Each check calls a validator from @/validation/checks, optionally filters
|
||||
* the violations, and returns a binary pass/fail result.
|
||||
*/
|
||||
|
||||
import {
|
||||
validateAgentPrompt,
|
||||
validateConnections,
|
||||
validateCredentials,
|
||||
validateFromAi,
|
||||
validateNodes,
|
||||
validateParameters,
|
||||
validateTools,
|
||||
validateTrigger,
|
||||
} from '@/validation/checks';
|
||||
|
||||
import { createValidationCheck } from './create-validation-check';
|
||||
|
||||
export const hasNodes = createValidationCheck({
|
||||
name: 'has_nodes',
|
||||
validate: validateNodes,
|
||||
filter: (v) => v.name === 'workflow-has-no-nodes',
|
||||
failComment: 'Workflow has no nodes',
|
||||
});
|
||||
|
||||
export const hasTrigger = createValidationCheck({
|
||||
name: 'has_trigger',
|
||||
validate: validateTrigger,
|
||||
filter: (v) => v.name === 'workflow-has-no-trigger',
|
||||
failComment: 'Workflow has no trigger node',
|
||||
});
|
||||
|
||||
export const agentHasDynamicPrompt = createValidationCheck({
|
||||
name: 'agent_has_dynamic_prompt',
|
||||
validate: (workflow) => validateAgentPrompt(workflow),
|
||||
filter: (v) => v.name === 'agent-static-prompt',
|
||||
});
|
||||
|
||||
export const agentHasLanguageModel = createValidationCheck({
|
||||
name: 'agent_has_language_model',
|
||||
validate: validateConnections,
|
||||
filter: (v) =>
|
||||
v.name === 'node-missing-required-input' &&
|
||||
v.metadata?.missingType === 'ai_languageModel' &&
|
||||
(v.metadata?.nodeType?.includes('langchain.agent') ?? false),
|
||||
failComment: 'Agent node missing required language model connection',
|
||||
});
|
||||
|
||||
export const memoryProperlyConnected = createValidationCheck({
|
||||
name: 'memory_properly_connected',
|
||||
validate: validateConnections,
|
||||
filter: (v) => v.name === 'sub-node-not-connected' && v.metadata?.outputType === 'ai_memory',
|
||||
failComment: 'Memory node not properly connected to parent node',
|
||||
});
|
||||
|
||||
export const vectorStoreHasEmbeddings = createValidationCheck({
|
||||
name: 'vector_store_has_embeddings',
|
||||
validate: validateConnections,
|
||||
filter: (v) =>
|
||||
v.name === 'node-missing-required-input' &&
|
||||
v.metadata?.missingType === 'ai_embedding' &&
|
||||
(v.metadata?.nodeType?.includes('vectorStore') ?? false),
|
||||
failComment: 'Vector store node missing required embeddings connection',
|
||||
});
|
||||
|
||||
export const noHardcodedCredentials = createValidationCheck({
|
||||
name: 'no_hardcoded_credentials',
|
||||
validate: (workflow) => validateCredentials(workflow),
|
||||
});
|
||||
|
||||
export const validRequiredParameters = createValidationCheck({
|
||||
name: 'valid_required_parameters',
|
||||
validate: validateParameters,
|
||||
filter: (v) => v.name === 'node-missing-required-parameter',
|
||||
});
|
||||
|
||||
export const validOptionsValues = createValidationCheck({
|
||||
name: 'valid_options_values',
|
||||
validate: validateParameters,
|
||||
filter: (v) => v.name === 'node-invalid-options-value',
|
||||
});
|
||||
|
||||
export const noInvalidFromAi = createValidationCheck({
|
||||
name: 'no_invalid_from_ai',
|
||||
validate: validateFromAi,
|
||||
});
|
||||
|
||||
export const toolsHaveParameters = createValidationCheck({
|
||||
name: 'tools_have_parameters',
|
||||
validate: validateTools,
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import type { SimpleWorkflow } from '@/types/workflow';
|
||||
|
||||
import { DETERMINISTIC_CHECKS } from './checks';
|
||||
import { LLM_CHECKS } from './llm-checks';
|
||||
import type { BinaryCheck, BinaryCheckContext } from './types';
|
||||
import type { EvaluationContext, Evaluator, Feedback } from '../../harness/harness-types';
|
||||
|
||||
export interface BinaryChecksEvaluatorOptions {
|
||||
nodeTypes: INodeTypeDescription[];
|
||||
llm?: BaseChatModel;
|
||||
checks?: string[];
|
||||
}
|
||||
|
||||
const EVALUATOR_NAME = 'binary-checks';
|
||||
|
||||
/**
|
||||
* Create a binary-checks evaluator that runs deterministic and optional LLM checks.
|
||||
*
|
||||
* Each check emits one Feedback with score 0 or 1.
|
||||
*/
|
||||
export function createBinaryChecksEvaluator(
|
||||
options: BinaryChecksEvaluatorOptions,
|
||||
): Evaluator<EvaluationContext> {
|
||||
const allChecks: BinaryCheck[] = [...DETERMINISTIC_CHECKS, ...(options.llm ? LLM_CHECKS : [])];
|
||||
|
||||
const allCheckNames = allChecks.map((c) => c.name);
|
||||
|
||||
let selectedChecks: BinaryCheck[];
|
||||
|
||||
if (options.checks && options.checks.length > 0) {
|
||||
const validNames = new Set(allCheckNames);
|
||||
const unrecognized = options.checks.filter((name) => !validNames.has(name));
|
||||
|
||||
for (const name of unrecognized) {
|
||||
console.warn(`Warning: unrecognized check name "${name}" in --checks filter`);
|
||||
}
|
||||
|
||||
selectedChecks = allChecks.filter((c) => options.checks!.includes(c.name));
|
||||
|
||||
if (selectedChecks.length === 0) {
|
||||
throw new Error(
|
||||
`No valid checks after filtering. Requested: ${options.checks.join(', ')}. Available: ${allCheckNames.join(', ')}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
selectedChecks = allChecks;
|
||||
}
|
||||
|
||||
return {
|
||||
name: EVALUATOR_NAME,
|
||||
|
||||
async evaluate(workflow: SimpleWorkflow, ctx: EvaluationContext): Promise<Feedback[]> {
|
||||
const checkCtx: BinaryCheckContext = {
|
||||
prompt: ctx.prompt,
|
||||
nodeTypes: options.nodeTypes,
|
||||
annotations: ctx.annotations,
|
||||
llm: options.llm,
|
||||
// Intentionally omit llmCallLimiter: binary-checks LLM judges are small,
|
||||
// cheap calls that should run in parallel, not throttled by the shared limiter.
|
||||
timeoutMs: ctx.timeoutMs,
|
||||
};
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
selectedChecks.map(async (check) => {
|
||||
try {
|
||||
const result = await check.run(workflow, checkCtx);
|
||||
return { check, result };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { check, result: { pass: false, comment: `Error: ${message}` } };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return results.map((settled, i) => {
|
||||
if (settled.status === 'fulfilled') {
|
||||
const { check, result } = settled.value;
|
||||
return {
|
||||
evaluator: EVALUATOR_NAME,
|
||||
metric: check.name,
|
||||
score: result.pass ? 1 : 0,
|
||||
kind: 'metric' as const,
|
||||
...(result.comment ? { comment: result.comment } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Should not happen since we catch inside, but handle gracefully
|
||||
const check = selectedChecks[i];
|
||||
const reason =
|
||||
settled.reason instanceof Error ? settled.reason.message : String(settled.reason);
|
||||
return {
|
||||
evaluator: EVALUATOR_NAME,
|
||||
metric: check.name,
|
||||
score: 0,
|
||||
kind: 'metric' as const,
|
||||
comment: `Unexpected error: ${reason}`,
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { createLlmCheck } from '../create-llm-check';
|
||||
|
||||
describe('createLlmCheck', () => {
|
||||
it('returns pass: true with "Skipped" comment when no LLM provided', async () => {
|
||||
const check = createLlmCheck({
|
||||
name: 'test_check',
|
||||
systemPrompt: 'test',
|
||||
humanTemplate: 'test {userPrompt} {generatedWorkflow} {referenceSection}',
|
||||
});
|
||||
|
||||
expect(check.name).toBe('test_check');
|
||||
expect(check.kind).toBe('llm');
|
||||
|
||||
const result = await check.run(
|
||||
{ name: 'test', nodes: [], connections: {} },
|
||||
{ prompt: 'test', nodeTypes: [] },
|
||||
);
|
||||
expect(result.pass).toBe(true);
|
||||
expect(result.comment).toBe('Skipped: no LLM');
|
||||
});
|
||||
});
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { createLlmCheck } from './create-llm-check';
|
||||
|
||||
export const correctNodeOperations = createLlmCheck({
|
||||
name: 'correct_node_operations',
|
||||
systemPrompt: `You are an evaluator checking whether n8n workflow nodes use the correct resource and operation settings.
|
||||
|
||||
For each node that has resource/operation parameters, verify:
|
||||
1. The resource matches what the node SHOULD operate on given its name and the workflow's purpose
|
||||
2. The operation matches the intended action (get, getAll, create, update, delete, etc.)
|
||||
3. Two nodes that should do different things are NOT configured identically
|
||||
|
||||
Common mistakes to catch:
|
||||
- A node named "Get Captions" but configured with resource: "video" instead of resource: "caption"
|
||||
- A node that should create records but uses operation: "get"
|
||||
- Two nodes configured identically when they should fetch different resources
|
||||
|
||||
Nodes without resource/operation parameters (triggers, Set, Merge, AI agents, LLM models) should be skipped.
|
||||
|
||||
Respond with pass: true ONLY if all resource/operation combinations are correct for the workflow's intent.`,
|
||||
humanTemplate: `User Request: {userPrompt}
|
||||
|
||||
Generated Workflow:
|
||||
{generatedWorkflow}
|
||||
|
||||
{referenceSection}
|
||||
|
||||
Check each node's resource and operation parameters. Are they all correct for this workflow's purpose?`,
|
||||
});
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { runWithOptionalLimiter, withTimeout } from '../../../harness/evaluation-helpers';
|
||||
import { createEvaluatorChain, invokeEvaluatorChain } from '../../llm-judge/evaluators/base';
|
||||
import type { BinaryCheck, BinaryCheckContext, SimpleWorkflow } from '../types';
|
||||
import { binaryJudgeResultSchema } from './schemas';
|
||||
|
||||
const REASONING_FIRST_SUFFIX = `
|
||||
|
||||
IMPORTANT: Write your full reasoning FIRST. Only AFTER completing your analysis, decide on pass or fail based on what you wrote. Do not decide pass/fail before reasoning.`;
|
||||
|
||||
export function createLlmCheck(options: {
|
||||
name: string;
|
||||
systemPrompt: string;
|
||||
humanTemplate: string;
|
||||
}): BinaryCheck {
|
||||
const systemPrompt = options.systemPrompt + REASONING_FIRST_SUFFIX;
|
||||
|
||||
return {
|
||||
name: options.name,
|
||||
kind: 'llm',
|
||||
async run(workflow: SimpleWorkflow, ctx: BinaryCheckContext) {
|
||||
if (!ctx.llm) {
|
||||
return { pass: true, comment: 'Skipped: no LLM' };
|
||||
}
|
||||
|
||||
const chain = createEvaluatorChain(
|
||||
ctx.llm,
|
||||
binaryJudgeResultSchema,
|
||||
systemPrompt,
|
||||
options.humanTemplate,
|
||||
);
|
||||
|
||||
const result = await runWithOptionalLimiter(async () => {
|
||||
return await withTimeout({
|
||||
promise: invokeEvaluatorChain(chain, {
|
||||
userPrompt: ctx.prompt,
|
||||
generatedWorkflow: workflow,
|
||||
}),
|
||||
timeoutMs: ctx.timeoutMs,
|
||||
label: `binary-checks:${options.name}`,
|
||||
});
|
||||
}, ctx.llmCallLimiter);
|
||||
|
||||
return { pass: result.pass, comment: result.reasoning };
|
||||
},
|
||||
};
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { createLlmCheck } from './create-llm-check';
|
||||
|
||||
export const descriptiveNodeNames = createLlmCheck({
|
||||
name: 'descriptive_node_names',
|
||||
systemPrompt: `You are an evaluator checking whether n8n workflow nodes have meaningful, descriptive names.
|
||||
Check:
|
||||
- Are node names descriptive of their purpose (e.g., "Send Welcome Email" vs "HTTP Request")?
|
||||
- Do names avoid default/generic names like "HTTP Request", "Code", "Set", "IF"?
|
||||
- Are names specific enough to understand the workflow at a glance?
|
||||
|
||||
Note: Trigger nodes commonly keep their default names (e.g., "When clicking 'Test workflow'"), which is acceptable.
|
||||
A few default names in a simple workflow is acceptable; the check is about overall naming quality.
|
||||
|
||||
Respond with pass: true if node names are generally descriptive and meaningful, false otherwise.
|
||||
Provide clear reasoning for your decision.`,
|
||||
humanTemplate: `User Request: {userPrompt}
|
||||
|
||||
Generated Workflow:
|
||||
{generatedWorkflow}
|
||||
|
||||
{referenceSection}
|
||||
|
||||
Do the nodes in this workflow have descriptive, meaningful names?`,
|
||||
});
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { createLlmCheck } from './create-llm-check';
|
||||
|
||||
export const fulfillsUserRequest = createLlmCheck({
|
||||
name: 'fulfills_user_request',
|
||||
systemPrompt: `You are a strict evaluator checking whether an n8n workflow fulfills a user's request.
|
||||
|
||||
For each feature the user explicitly asked for, check:
|
||||
1. Is there a node of the correct TYPE for that feature? (e.g., YouTube node for YouTube operations)
|
||||
2. Is that node configured with the correct RESOURCE and OPERATION? (e.g., resource: "caption" for fetching captions, not resource: "video")
|
||||
3. Is the node actually CONNECTED in the workflow flow?
|
||||
|
||||
A node that exists but is misconfigured does NOT count as fulfilling the requirement.
|
||||
For example, a YouTube node with resource: "video" does NOT fulfill a request to "fetch captions" — captions require resource: "caption".
|
||||
|
||||
Be binary: pass ONLY if every explicitly requested feature has a correctly-typed AND correctly-configured node.
|
||||
Do NOT pass just because a node with the right name exists — verify its actual parameters.`,
|
||||
humanTemplate: `User Request: {userPrompt}
|
||||
|
||||
Generated Workflow:
|
||||
{generatedWorkflow}
|
||||
|
||||
{referenceSection}
|
||||
|
||||
For each feature the user requested, is there a correctly configured node? List each requirement and whether it's met.`,
|
||||
});
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { createLlmCheck } from './create-llm-check';
|
||||
|
||||
export const handlesMultipleItems = createLlmCheck({
|
||||
name: 'handles_multiple_items',
|
||||
systemPrompt: `You are an evaluator checking whether an n8n workflow handles multiple items correctly.
|
||||
|
||||
Important n8n context:
|
||||
- Most n8n nodes automatically process ALL incoming items one by one — this is correct default behavior
|
||||
- A workflow designed for single-item processing (manual trigger + one record) does NOT need batch handling
|
||||
- Merge nodes with "combineByPosition" are correct for merging parallel single-item branches
|
||||
- AI Agent nodes process one item at a time, which is normal
|
||||
|
||||
Only FAIL if there is a clear structural problem:
|
||||
- A node configured with multipleFiles/multiple inputs but no downstream handling for arrays
|
||||
- A splitInBatches that's clearly needed but missing (e.g., sending individual API calls for each item in a large list)
|
||||
- An aggregate node producing an array that downstream nodes don't handle
|
||||
|
||||
Do NOT fail for:
|
||||
- Single-item workflows (manual trigger processing one record)
|
||||
- Workflows where n8n's automatic item-by-item processing is sufficient
|
||||
- Chatbot/agent workflows that process one message at a time
|
||||
|
||||
Respond with pass: true if the workflow handles items correctly for its use case.`,
|
||||
humanTemplate: `User Request: {userPrompt}
|
||||
|
||||
Generated Workflow:
|
||||
{generatedWorkflow}
|
||||
|
||||
{referenceSection}
|
||||
|
||||
Does this workflow handle multiple items correctly for its intended use case?`,
|
||||
});
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import type { BinaryCheck } from '../types';
|
||||
import { correctNodeOperations } from './correct-node-operations';
|
||||
import { descriptiveNodeNames } from './descriptive-node-names';
|
||||
import { fulfillsUserRequest } from './fulfills-user-request';
|
||||
import { handlesMultipleItems } from './handles-multiple-items';
|
||||
import { validDataFlow } from './valid-data-flow';
|
||||
|
||||
export const LLM_CHECKS: BinaryCheck[] = [
|
||||
fulfillsUserRequest,
|
||||
correctNodeOperations,
|
||||
validDataFlow,
|
||||
handlesMultipleItems,
|
||||
descriptiveNodeNames,
|
||||
];
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const binaryJudgeResultSchema = z.object({
|
||||
reasoning: z
|
||||
.string()
|
||||
.describe('Step-by-step analysis. Write this FIRST, BEFORE deciding pass/fail.'),
|
||||
pass: z
|
||||
.boolean()
|
||||
.describe(
|
||||
'Final verdict derived from the reasoning above. true = all criteria met, false = at least one issue found.',
|
||||
),
|
||||
});
|
||||
|
||||
export type BinaryJudgeResult = z.infer<typeof binaryJudgeResultSchema>;
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { createLlmCheck } from './create-llm-check';
|
||||
|
||||
export const validDataFlow = createLlmCheck({
|
||||
name: 'valid_data_flow',
|
||||
systemPrompt: `You are an evaluator checking whether expressions in an n8n workflow reference fields that actually exist upstream.
|
||||
|
||||
For each expression in node parameters, check:
|
||||
1. \`{{ $json.fieldName }}\` — does the immediately upstream node output this field?
|
||||
2. \`$('NodeName').item.json.field\` — does that node exist, and does it output that field?
|
||||
3. Cross-references between nodes are consistent (field set in one node matches what's read in another)
|
||||
|
||||
Important n8n context:
|
||||
- Manual Trigger and Schedule Trigger nodes output an empty object — they do NOT provide custom fields unless a Set node is placed after them
|
||||
- YouTube video.get returns \`snippet.title\`, \`snippet.description\`, etc. — NOT \`caption\` or \`transcript\`
|
||||
- Set nodes output exactly the fields defined in their assignments
|
||||
- AI Agent nodes output \`{ output: string }\`
|
||||
- Merge nodes combine fields from all inputs
|
||||
|
||||
Focus on CRITICAL issues only:
|
||||
- Expressions referencing fields that clearly don't exist upstream (e.g., \`$json.transcript\` when no node produces a transcript field)
|
||||
- Expressions referencing nodes that don't exist in the workflow
|
||||
|
||||
Do NOT fail for:
|
||||
- Minor field name case differences
|
||||
- Fields that might be available through n8n's built-in variables (\`$execution\`, \`$workflow\`, etc.)
|
||||
|
||||
Respond with pass: true if there are no critical data flow issues.`,
|
||||
humanTemplate: `User Request: {userPrompt}
|
||||
|
||||
Generated Workflow:
|
||||
{generatedWorkflow}
|
||||
|
||||
{referenceSection}
|
||||
|
||||
Check each expression in the workflow. Do they reference fields that exist upstream?`,
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import type { SimpleWorkflow } from '@/types';
|
||||
|
||||
import type { LlmCallLimiter } from '../../harness/harness-types';
|
||||
|
||||
export interface BinaryCheckResult {
|
||||
pass: boolean;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface BinaryCheckContext {
|
||||
prompt: string;
|
||||
nodeTypes: INodeTypeDescription[];
|
||||
annotations?: Record<string, unknown>;
|
||||
llm?: BaseChatModel;
|
||||
llmCallLimiter?: LlmCallLimiter;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface BinaryCheck {
|
||||
name: string;
|
||||
kind: 'deterministic' | 'llm';
|
||||
run(workflow: SimpleWorkflow, ctx: BinaryCheckContext): Promise<BinaryCheckResult>;
|
||||
}
|
||||
|
||||
// Re-export for convenience
|
||||
export type { SimpleWorkflow };
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Execution evaluator for the v2 evaluation harness.
|
||||
*
|
||||
* Executes generated workflows with pin data to verify they run without errors.
|
||||
* Service nodes use pin data (skipping real API calls); utility nodes execute
|
||||
* using their real compiled dist implementations.
|
||||
*/
|
||||
|
||||
import type { SimpleWorkflow } from '@/types/workflow';
|
||||
|
||||
import type { EvaluationContext, Evaluator, Feedback } from '../../harness/harness-types';
|
||||
import { executeWorkflowWithPinData } from '../../support/workflow-executor';
|
||||
|
||||
/**
|
||||
* Create an execution evaluator that runs the workflow with pin data.
|
||||
*
|
||||
* Node implementations are loaded lazily from the compiled dist/ files in
|
||||
* nodes-base and nodes-langchain — no DI or package-loading setup required.
|
||||
*/
|
||||
export function createExecutionEvaluator(): Evaluator<EvaluationContext> {
|
||||
return {
|
||||
name: 'execution',
|
||||
|
||||
async evaluate(workflow: SimpleWorkflow, ctx: EvaluationContext): Promise<Feedback[]> {
|
||||
const pinData = ctx.pinData ?? {};
|
||||
|
||||
const result = await executeWorkflowWithPinData(workflow, pinData);
|
||||
|
||||
return [
|
||||
{
|
||||
evaluator: 'execution',
|
||||
metric: 'executionSuccess',
|
||||
score: result.success ? 1 : 0,
|
||||
kind: 'score',
|
||||
comment: result.success
|
||||
? `Workflow executed successfully (${result.executedNodes.length} nodes)`
|
||||
: `Execution failed: ${result.error}${result.errorNode ? ` (at node: ${result.errorNode})` : ''}`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Evaluator factories for the v2 evaluation harness.
|
||||
*
|
||||
* Each factory creates an Evaluator that wraps existing evaluation logic.
|
||||
* All evaluators are independent and can run in parallel.
|
||||
*/
|
||||
|
||||
export { createLLMJudgeEvaluator } from './llm-judge';
|
||||
export { createProgrammaticEvaluator } from './programmatic';
|
||||
export {
|
||||
createPairwiseEvaluator,
|
||||
type PairwiseEvaluatorOptions,
|
||||
} from './pairwise';
|
||||
export {
|
||||
createSimilarityEvaluator,
|
||||
type SimilarityEvaluatorOptions,
|
||||
} from './similarity';
|
||||
export {
|
||||
createResponderEvaluator,
|
||||
type ResponderEvaluationContext,
|
||||
} from './responder';
|
||||
export { createExecutionEvaluator } from './execution';
|
||||
export {
|
||||
createBinaryChecksEvaluator,
|
||||
type BinaryChecksEvaluatorOptions,
|
||||
} from './binary-checks';
|
||||
@@ -0,0 +1,97 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { SimpleWorkflow } from '../../../src/types/workflow';
|
||||
|
||||
// Violation schema
|
||||
const violationSchema = z.object({
|
||||
type: z.enum(['critical', 'major', 'minor']),
|
||||
description: z.string(),
|
||||
pointsDeducted: z.number().min(0),
|
||||
});
|
||||
|
||||
// Category score schema
|
||||
const categoryScoreSchema = z.object({
|
||||
violations: z.array(violationSchema),
|
||||
score: z.number().min(0).max(1),
|
||||
});
|
||||
|
||||
// Structural similarity schema (with applicable flag)
|
||||
const structuralSimilaritySchema = z.object({
|
||||
violations: z.array(violationSchema),
|
||||
score: z.number().min(0).max(1),
|
||||
applicable: z
|
||||
.boolean()
|
||||
.describe('Whether this category was evaluated (based on reference workflow availability)'),
|
||||
});
|
||||
|
||||
const efficiencyScoreSchema = categoryScoreSchema.extend({
|
||||
redundancyScore: z.number().min(0).max(1).describe('Score for avoiding redundant operations'),
|
||||
pathOptimization: z.number().min(0).max(1).describe('Score for optimal execution paths'),
|
||||
nodeCountEfficiency: z.number().min(0).max(1).describe('Score for using minimal nodes'),
|
||||
});
|
||||
|
||||
const maintainabilityScoreSchema = categoryScoreSchema.extend({
|
||||
nodeNamingQuality: z.number().min(0).max(1).describe('Score for descriptive node naming'),
|
||||
workflowOrganization: z.number().min(0).max(1).describe('Score for logical workflow structure'),
|
||||
modularity: z.number().min(0).max(1).describe('Score for reusable and modular components'),
|
||||
});
|
||||
|
||||
const bestPracticesScoreSchema = categoryScoreSchema.extend({
|
||||
techniques: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
'Workflow techniques identified for this evaluation (e.g., chatbot, content-generation)',
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// Main evaluation result schema
|
||||
export const evaluationResultSchema = z.object({
|
||||
overallScore: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(1)
|
||||
.describe('Weighted average score across all categories (0-1)'),
|
||||
functionality: categoryScoreSchema,
|
||||
connections: categoryScoreSchema,
|
||||
expressions: categoryScoreSchema,
|
||||
nodeConfiguration: categoryScoreSchema,
|
||||
structuralSimilarity: structuralSimilaritySchema,
|
||||
efficiency: efficiencyScoreSchema,
|
||||
dataFlow: categoryScoreSchema,
|
||||
maintainability: maintainabilityScoreSchema,
|
||||
bestPractices: bestPracticesScoreSchema,
|
||||
summary: z.string().describe('2-3 sentences summarizing main strengths and weaknesses'),
|
||||
criticalIssues: z
|
||||
.array(z.string())
|
||||
.describe('List of issues that would prevent the workflow from functioning')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// Type exports
|
||||
export type Violation = z.infer<typeof violationSchema>;
|
||||
export type CategoryScore = z.infer<typeof categoryScoreSchema>;
|
||||
export type EfficiencyScore = z.infer<typeof efficiencyScoreSchema>;
|
||||
export type MaintainabilityScore = z.infer<typeof maintainabilityScoreSchema>;
|
||||
export type BestPracticesScore = z.infer<typeof bestPracticesScoreSchema>;
|
||||
export type EvaluationResult = z.infer<typeof evaluationResultSchema>;
|
||||
|
||||
// Test case schema for evaluation
|
||||
export const testCaseSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
prompt: z.string(),
|
||||
referenceWorkflows: z.array(z.custom<SimpleWorkflow>()).optional(),
|
||||
});
|
||||
|
||||
export type TestCase = z.infer<typeof testCaseSchema>;
|
||||
|
||||
// Evaluation input schema
|
||||
export const evaluationInputSchema = z.object({
|
||||
userPrompt: z.string(),
|
||||
generatedWorkflow: z.custom<SimpleWorkflow>(),
|
||||
referenceWorkflows: z.array(z.custom<SimpleWorkflow>()).optional(),
|
||||
preset: z.enum(['strict', 'standard', 'lenient']).optional(),
|
||||
});
|
||||
|
||||
export type EvaluationInput = z.infer<typeof evaluationInputSchema>;
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { SystemMessage } from '@langchain/core/messages';
|
||||
import { ChatPromptTemplate, HumanMessagePromptTemplate } from '@langchain/core/prompts';
|
||||
import type { Runnable, RunnableConfig } from '@langchain/core/runnables';
|
||||
import { RunnableSequence } from '@langchain/core/runnables';
|
||||
import { OperationalError } from 'n8n-workflow';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import type { EvaluationInput } from '../evaluation';
|
||||
|
||||
type EvaluatorChainInput = {
|
||||
userPrompt: string;
|
||||
generatedWorkflow: string;
|
||||
referenceSection: string;
|
||||
};
|
||||
|
||||
export function createEvaluatorChain<TResult extends Record<string, unknown>>(
|
||||
llm: BaseChatModel,
|
||||
schema: z.ZodType<TResult>,
|
||||
systemPrompt: string,
|
||||
humanTemplate: string,
|
||||
): RunnableSequence<EvaluatorChainInput, TResult> {
|
||||
if (!llm.bindTools) {
|
||||
throw new OperationalError("LLM doesn't support binding tools");
|
||||
}
|
||||
|
||||
const prompt = ChatPromptTemplate.fromMessages([
|
||||
new SystemMessage(systemPrompt),
|
||||
HumanMessagePromptTemplate.fromTemplate(humanTemplate),
|
||||
]);
|
||||
|
||||
const llmWithStructuredOutput = llm.withStructuredOutput<TResult>(schema);
|
||||
|
||||
return RunnableSequence.from<EvaluatorChainInput, TResult>([prompt, llmWithStructuredOutput]);
|
||||
}
|
||||
|
||||
export async function invokeEvaluatorChain<TResult>(
|
||||
chain: Runnable<EvaluatorChainInput, TResult>,
|
||||
input: EvaluationInput,
|
||||
config?: RunnableConfig,
|
||||
): Promise<TResult> {
|
||||
const referenceSection =
|
||||
input.referenceWorkflows && input.referenceWorkflows.length > 0
|
||||
? `<reference_workflows>\n${JSON.stringify(input.referenceWorkflows, null, 2)}\n</reference_workflows>`
|
||||
: '';
|
||||
|
||||
const result = await chain.invoke(
|
||||
{
|
||||
userPrompt: input.userPrompt,
|
||||
generatedWorkflow: JSON.stringify(input.generatedWorkflow, null, 2),
|
||||
referenceSection,
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { promptCategorizationChain } from '@/chains/prompt-categorization';
|
||||
import { documentation } from '@/tools/best-practices';
|
||||
|
||||
import { createEvaluatorChain } from './base';
|
||||
import type { EvaluationInput } from '../evaluation';
|
||||
|
||||
// Schema for best practices evaluation result
|
||||
const bestPracticesResultSchema = z.object({
|
||||
score: z.number().min(0).max(1),
|
||||
violations: z.array(
|
||||
z.object({
|
||||
type: z.enum(['major', 'minor']),
|
||||
description: z.string(),
|
||||
pointsDeducted: z.number().min(0),
|
||||
}),
|
||||
),
|
||||
techniques: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
'Workflow techniques identified for this evaluation (e.g., chatbot, content-generation)',
|
||||
),
|
||||
});
|
||||
|
||||
export type BestPracticesResult = z.infer<typeof bestPracticesResultSchema>;
|
||||
|
||||
const systemPrompt = `You are an expert n8n workflow evaluator focusing specifically on BEST PRACTICES ADHERENCE.
|
||||
Your task is to evaluate whether a generated workflow follows the documented best practices for its workflow type(s).
|
||||
|
||||
## Your Role
|
||||
Evaluate ONLY adherence to the provided best practices documentation. Focus on whether the workflow follows recommended patterns, avoids common pitfalls, and uses nodes correctly.
|
||||
|
||||
## Context-Aware Evaluation Philosophy
|
||||
|
||||
**CRITICAL**: Always consider what the user actually requested in their prompt. Do not penalize workflows for missing features or safeguards that were not part of the user's requirements.
|
||||
|
||||
- If the user asked for a simple workflow without mentioning production readiness, error handling, or rate limiting, these should NOT be critical violations
|
||||
- Only mark something as critical if it would prevent the workflow from fulfilling the user's specific request
|
||||
- Consider the scope and complexity implied by the user's prompt
|
||||
|
||||
## Evaluation Criteria
|
||||
|
||||
### Understanding Workflow Connections
|
||||
|
||||
n8n workflows can have multiple triggers and execution paths. When evaluating whether components are "connected," understand that n8n supports multiple connection methods beyond direct node-to-node data flow.
|
||||
|
||||
Valid Connection Methods:
|
||||
|
||||
1. **Direct Data Flow Connections**: Traditional node-to-node connections where data flows from source output to target input
|
||||
- Example: HTTP Request → Set → Database
|
||||
|
||||
2. **AI-Specific Connections**: Special connection types for AI nodes, denoted with brackets like [ai_memory], [ai_tool], [ai_embedding]
|
||||
- Memory nodes connected to multiple agents via [ai_memory] - enables agents to share conversation history
|
||||
- Tools connected to agents via [ai_tool] - provides capabilities to agents
|
||||
- Vector stores use [ai_embedding] and [ai_document] - for AI-powered data retrieval
|
||||
|
||||
**CRITICAL: AI sub-nodes are the SOURCE of ai_* connections, NOT the target.**
|
||||
- Document Loader connects TO Vector Store (Document Loader is source, Vector Store is target)
|
||||
- Embeddings connects TO Vector Store (Embeddings is source, Vector Store is target)
|
||||
- Chat Model connects TO AI Agent (Chat Model is source, AI Agent is target)
|
||||
|
||||
In the connections JSON, this appears as:
|
||||
\`\`\`json
|
||||
"Document Loader": { "ai_document": [[{ "node": "Vector Store", ... }]] }
|
||||
\`\`\`
|
||||
This means the connection EXISTS - Document Loader provides ai_document capability TO Vector Store.
|
||||
|
||||
**NEVER flag "Vector Store missing Document Loader" if Document Loader has ai_document → Vector Store.**
|
||||
|
||||
3. **Shared Memory**: Multiple agents/workflows sharing the same memory node for context/data persistence
|
||||
- Same Window Buffer Memory connected to both a scheduled agent AND a chat agent
|
||||
- Both agents can access shared conversation history and context
|
||||
|
||||
4. **Vector Store Sharing**: Multiple workflows accessing the same vector store for data storage/retrieval
|
||||
- Scheduled workflow writes documents to Vector Store
|
||||
- Chat workflow queries the same Vector Store for information
|
||||
- Both workflows effectively share data through the vector store
|
||||
|
||||
5. **Data Storage Sharing**: Multiple workflows reading/writing to the same persistent storage
|
||||
- Database nodes (PostgreSQL, MongoDB, MySQL)
|
||||
- Spreadsheet services (Google Sheets, Airtable)
|
||||
- Data Tables (n8n's built-in storage)
|
||||
- One workflow writes data, another workflow reads it
|
||||
|
||||
6. **Tool-based Connections**: Agents connected through tool nodes
|
||||
- Agent Tool nodes allow one agent to invoke another agent
|
||||
- Tools provide indirect connections between workflow components
|
||||
|
||||
7. **Loop Patterns (Split In Batches)**: Intentional cycles for batch processing
|
||||
- Output 0 ("loop"): Fires for EACH batch - connect batch processing here
|
||||
- Output 1 ("done"): Fires once after ALL iterations complete - connect final processing here
|
||||
- Processing nodes loop BACK to Split In Batches input to continue the loop
|
||||
- This circular connection is CORRECT and INTENTIONAL - it creates the batch processing loop
|
||||
|
||||
**NEVER flag as incorrect if:**
|
||||
- Output 1 connects to processing nodes
|
||||
- Processing nodes connect back to Split In Batches input (index 0)
|
||||
- Output 0 connects to aggregation/final step
|
||||
|
||||
This is the standard n8n pattern for processing large datasets in batches.
|
||||
|
||||
8. **Shared Destination Pattern**: Multiple branches connecting to same node
|
||||
- Multiple Switch/IF outputs can ALL connect to the same downstream node
|
||||
- This is correct when all branches need the same final processing (e.g., save to database)
|
||||
- Do NOT use Merge for this - Merge waits for all inputs, but only one branch executes per item
|
||||
|
||||
9. **Chat Trigger Auto-Response**: Chat Trigger handles responses automatically
|
||||
- Chat Trigger (@n8n/n8n-nodes-langchain.chatTrigger) is BIDIRECTIONAL
|
||||
- AI Agent output is automatically sent back to the chat interface
|
||||
- There is NO main connection back to Chat Trigger - this is correct behavior
|
||||
- **NEVER flag "AI Agent has no connection back to Chat Trigger"** - responses are built-in
|
||||
|
||||
10. **Document Loader Input Pattern**: Document Loaders read from context, not main connections
|
||||
- Document Loaders have NO main input connections by design
|
||||
- They read binary data/URLs from workflow context based on their configuration
|
||||
- They OUTPUT via ai_document to Vector Store or other consumers
|
||||
- **NEVER flag "Document Loader has no main input"** or "Trigger not connected to Document Loader"
|
||||
|
||||
Before assessing there is a missing connection as per best practices documentation (for example a chatbot
|
||||
should be connected to data from other triggered components of the workflow) make sure that there is no
|
||||
possible connection, check all possible connections, ESPECIALLY agent nodes (memory and tools could
|
||||
create the necessary connections).
|
||||
|
||||
Critical Evaluation Rule:
|
||||
Before marking components as "disconnected," verify they have NO connection method - not just no direct data flow connection.
|
||||
|
||||
### Evaluating Configuration and Fields
|
||||
|
||||
If a best practice states that certain configuration should be applied, for example disabling n8n attribution
|
||||
check to see if that has been specified as part of the generated workflows configuration or its additional fields.
|
||||
If a node of the correct type has these settings present, then it is likely NOT in violation of the practice.
|
||||
|
||||
## Violation Criteria
|
||||
|
||||
**Major (-20 to -40 points):**
|
||||
- Not following recommended approaches that significantly impact reliability or performance FOR THE REQUESTED USE CASE
|
||||
- Using non-recommended nodes when better alternatives are documented and relevant
|
||||
- Missing important safeguards that the documentation warns about IF they're relevant to the user's request
|
||||
- Ignoring service-specific considerations that would impact the user's stated goals
|
||||
|
||||
**Minor (-5 to -20 points):**
|
||||
- Using less optimal patterns that are documented as pitfalls but don't break functionality
|
||||
- Missing optional best practices that would improve the workflow (like error handling when not requested)
|
||||
- Missing production-ready features when the user asked for a basic/simple workflow
|
||||
- Small deviations from recommended approaches that don't impact the user's goals
|
||||
- Missing rate limiting, memory management, or advanced error handling when not requested
|
||||
|
||||
## Scoring Instructions
|
||||
1. Start with 100 points
|
||||
2. Read the user prompt carefully to understand what they actually requested
|
||||
3. Deduct points for each violation found based on severity AND relevance to the user's request
|
||||
4. Score cannot go below 0
|
||||
5. Convert to 0-1 scale by dividing by 100
|
||||
|
||||
## Important Context
|
||||
- You will be provided with best practices documentation relevant to the workflow type(s)
|
||||
- Focus on whether the workflow follows the documented recommendations RELEVANT to the user's request
|
||||
- Consider the specific nodes used and their documented pitfalls
|
||||
- Evaluate against common mistakes mentioned in the documentation
|
||||
- DO NOT penalize for missing best practices that aren't relevant to what the user asked for
|
||||
- DO NOT create arbitrary best practices - only evaluate against what's documented
|
||||
- DO NOT mark optional features as critical violations when they weren't requested
|
||||
`;
|
||||
|
||||
const humanTemplate = `Evaluate how well this workflow follows n8n best practices in the context of what the user requested.
|
||||
|
||||
<user_prompt>
|
||||
{userPrompt}
|
||||
</user_prompt>
|
||||
|
||||
<generated_workflow>
|
||||
{generatedWorkflow}
|
||||
</generated_workflow>
|
||||
|
||||
<best_practices_documentation>
|
||||
{bestPractices}
|
||||
</best_practices_documentation>
|
||||
|
||||
{referenceSection}
|
||||
|
||||
IMPORTANT: First, analyze what the user actually requested in their prompt. Then evaluate the workflow against best practices that are relevant to that request.
|
||||
|
||||
- If the user requested a simple/basic workflow, do NOT mark missing error handling or rate limiting as critical
|
||||
- Only mark violations as critical if they would prevent the core requested functionality from working
|
||||
- Consider whether advanced features (error handling, rate limiting, memory management) were part of the user's requirements
|
||||
|
||||
Provide a best practices evaluation with score, violations (citing specific best practices and explaining why they matter for THIS use case), and brief analysis.`;
|
||||
|
||||
export function createBestPracticesEvaluatorChain(llm: BaseChatModel) {
|
||||
return createEvaluatorChain(llm, bestPracticesResultSchema, systemPrompt, humanTemplate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load relevant best practices documentation for the given user prompt
|
||||
* Returns both the documentation string and the identified techniques
|
||||
*/
|
||||
async function loadRelevantBestPractices(
|
||||
llm: BaseChatModel,
|
||||
userPrompt: string,
|
||||
): Promise<{ documentation: string; techniques: string[] }> {
|
||||
try {
|
||||
// Categorize the prompt to determine which techniques apply
|
||||
const categorization = await promptCategorizationChain(llm, userPrompt);
|
||||
|
||||
// Load best practices for identified techniques
|
||||
const relevantDocs: string[] = [];
|
||||
|
||||
for (const technique of categorization.techniques) {
|
||||
const bestPractice = documentation[technique];
|
||||
if (bestPractice) {
|
||||
relevantDocs.push(
|
||||
`## Best Practices for ${technique}\n\n${bestPractice.getDocumentation()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (relevantDocs.length === 0) {
|
||||
return {
|
||||
documentation:
|
||||
'No specific best practices documentation available for this workflow type. Evaluate based on general n8n workflow principles.',
|
||||
techniques: categorization.techniques,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
documentation: relevantDocs.join('\n\n---\n\n'),
|
||||
techniques: categorization.techniques,
|
||||
};
|
||||
} catch (error) {
|
||||
// If categorization fails, return a message indicating no specific best practices
|
||||
return {
|
||||
documentation:
|
||||
'Unable to load specific best practices. Evaluate based on general n8n workflow principles.',
|
||||
techniques: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type BestPracticesChainInput = {
|
||||
userPrompt: string;
|
||||
generatedWorkflow: string;
|
||||
bestPractices: string;
|
||||
referenceSection: string;
|
||||
};
|
||||
|
||||
export async function evaluateBestPractices(
|
||||
llm: BaseChatModel,
|
||||
input: EvaluationInput,
|
||||
): Promise<BestPracticesResult> {
|
||||
// Load relevant best practices documentation and identify techniques
|
||||
const { documentation: bestPracticesDoc, techniques } = await loadRelevantBestPractices(
|
||||
llm,
|
||||
input.userPrompt,
|
||||
);
|
||||
|
||||
// Prepare the reference section
|
||||
const referenceSection =
|
||||
input.referenceWorkflows && input.referenceWorkflows.length > 0
|
||||
? `<reference_workflows>\n${JSON.stringify(input.referenceWorkflows, null, 2)}\n</reference_workflows>`
|
||||
: '';
|
||||
|
||||
// Invoke the evaluator chain with best practices
|
||||
const chain = createBestPracticesEvaluatorChain(llm);
|
||||
const chainInput: BestPracticesChainInput = {
|
||||
userPrompt: input.userPrompt,
|
||||
generatedWorkflow: JSON.stringify(input.generatedWorkflow, null, 2),
|
||||
bestPractices: bestPracticesDoc,
|
||||
referenceSection,
|
||||
};
|
||||
|
||||
const result = await chain.invoke(chainInput);
|
||||
|
||||
// Add the identified techniques to the result
|
||||
return {
|
||||
...result,
|
||||
techniques,
|
||||
};
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { createEvaluatorChain, invokeEvaluatorChain } from './base';
|
||||
import type { EvaluationInput } from '../evaluation';
|
||||
|
||||
// Schema for connections evaluation result
|
||||
const connectionsResultSchema = z.object({
|
||||
score: z.number().min(0).max(1),
|
||||
violations: z.array(
|
||||
z.object({
|
||||
type: z.enum(['critical', 'major', 'minor']),
|
||||
description: z.string(),
|
||||
pointsDeducted: z.number().min(0),
|
||||
}),
|
||||
),
|
||||
analysis: z.string().describe('Brief analysis of node connections and data flow'),
|
||||
});
|
||||
|
||||
export type ConnectionsResult = z.infer<typeof connectionsResultSchema>;
|
||||
|
||||
const systemPrompt = `You are an expert n8n workflow evaluator focusing on node connections and data flow. Verify that connections follow n8n's sourcing rules, support the requested behaviour, and respect AI capability patterns.
|
||||
|
||||
<reading_n8n_connection_json>
|
||||
The workflow JSON structure uses the outer key as the SOURCE node. This is critical for correct analysis.
|
||||
|
||||
Structure:
|
||||
"connections": {
|
||||
"SOURCE_NODE": {
|
||||
"connection_type": [[{ "node": "TARGET_NODE", "type": "connection_type", "index": 0 }]]
|
||||
}
|
||||
}
|
||||
|
||||
Reading this JSON: SOURCE_NODE outputs to TARGET_NODE via connection_type.
|
||||
|
||||
Example:
|
||||
"Form Trigger": {
|
||||
"main": [[{ "node": "Set Node", "type": "main", "index": 0 }]]
|
||||
}
|
||||
|
||||
This means Form Trigger connects TO Set Node (Form Trigger is source, Set Node is target). Data flows from Form Trigger to Set Node.
|
||||
|
||||
For AI capability connections:
|
||||
"Text Splitter": {
|
||||
"ai_textSplitter": [[{ "node": "Document Loader", "type": "ai_textSplitter", "index": 0 }]]
|
||||
}
|
||||
|
||||
This means Text Splitter provides its capability TO Document Loader. Text Splitter is the source, Document Loader is the target. This is the correct direction for ai_* connections because sub-nodes provide capabilities to parent nodes.
|
||||
</reading_n8n_connection_json>
|
||||
|
||||
<connection_model>
|
||||
Main connections (type: main) carry runtime data between workflow nodes. They flow from data producers to data consumers, forming the primary execution path from trigger through processing to outputs.
|
||||
|
||||
AI capability connections (types: ai_languageModel, ai_memory, ai_tool, ai_document, ai_embedding, ai_textSplitter, ai_outputParser) let sub-nodes provide capabilities to parent nodes. The sub-node is always the source. For example, a Chat Model provides ai_languageModel capability to an AI Agent, so the connection goes Chat Model to AI Agent.
|
||||
|
||||
Capability-only nodes like Document Loader, Text Splitter, Embeddings, LLMs, Output Parsers, and Tool nodes exist purely to provide ai_* capabilities. They have no main inputs or outputs by design. Document Loader appearing without main connections is correct architecture, not a problem.
|
||||
|
||||
Hybrid nodes like Vector Store and AI Agent participate in both main data flow and ai_* capability networks simultaneously. A Vector Store in insert mode receives main data and also receives ai_document from Document Loader and ai_embedding from Embeddings.
|
||||
</connection_model>
|
||||
|
||||
<rag_pipeline_architecture>
|
||||
In RAG workflows, data flows through main connections while capabilities flow through ai_* connections:
|
||||
|
||||
Data Source connects to Vector Store via main (triggers the insert operation).
|
||||
Document Loader connects to Vector Store via ai_document (provides document processing).
|
||||
Text Splitter connects to Document Loader via ai_textSplitter (provides chunking).
|
||||
Embeddings connects to Vector Store via ai_embedding (provides vectorization).
|
||||
|
||||
The Document Loader reads from workflow context based on its configuration. It does not receive data through main connections. This is intentional design.
|
||||
|
||||
Valid connection directions for RAG:
|
||||
Text Splitter to Document Loader via ai_textSplitter
|
||||
Document Loader to Vector Store via ai_document
|
||||
Embeddings to Vector Store via ai_embedding
|
||||
Language Model to AI Agent via ai_languageModel
|
||||
Tool nodes to AI Agent via ai_tool
|
||||
Memory nodes to AI Agent via ai_memory
|
||||
</rag_pipeline_architecture>
|
||||
|
||||
<loop_and_multi_output_patterns>
|
||||
## Split In Batches (Loop Node)
|
||||
Split In Batches has TWO outputs with specific semantics:
|
||||
- Output 0 ("done"): Fires ONCE after ALL batches complete. Connect aggregation/final processing here.
|
||||
- Output 1 ("loop"): Fires for EACH batch. Connect processing nodes here.
|
||||
|
||||
Correct loop pattern:
|
||||
1. Split In Batches output 1 → Processing Node(s) → Split In Batches INPUT (index 0)
|
||||
2. Split In Batches output 0 → Next workflow step
|
||||
|
||||
CRITICAL: The loop-back connection goes to the node's INPUT (index 0), creating a cycle. This is CORRECT behavior.
|
||||
Do NOT flag "Split In Batches connects to itself" or "circular connection" as an error - this IS the loop pattern.
|
||||
|
||||
Example correct connections JSON:
|
||||
"Split In Batches": {
|
||||
"main": [
|
||||
[{ "node": "Aggregate", "type": "main", "index": 0 }], // Output 0 (done) → final step
|
||||
[{ "node": "HTTP Request", "type": "main", "index": 0 }] // Output 1 (loop) → processing
|
||||
]
|
||||
},
|
||||
"HTTP Request": {
|
||||
"main": [
|
||||
[{ "node": "Split In Batches", "type": "main", "index": 0 }] // Loop back to INPUT - CORRECT
|
||||
]
|
||||
}
|
||||
|
||||
## Switch and IF Nodes (Multi-Output)
|
||||
Switch and IF nodes route data to different outputs:
|
||||
- IF: Output 0 = true branch, Output 1 = false branch
|
||||
- Switch: Outputs 0 to N-1 = case branches
|
||||
|
||||
SHARED DESTINATION PATTERN:
|
||||
Multiple outputs can ALL connect to the same downstream node. This is valid when different branches need the same final processing:
|
||||
Switch output 0 → Database
|
||||
Switch output 1 → Database
|
||||
Switch output 2 → Database
|
||||
|
||||
Do NOT flag multiple connections to the same target as redundant - it's the correct pattern for routing different cases to a shared destination without using Merge (which would wait forever since only one branch executes per item).
|
||||
</loop_and_multi_output_patterns>
|
||||
|
||||
<chat_trigger_patterns>
|
||||
## Chat Trigger and Chat Interface Nodes
|
||||
Chat Trigger (@n8n/n8n-nodes-langchain.chatTrigger) is a BIDIRECTIONAL node that handles both input AND output automatically.
|
||||
|
||||
CRITICAL: Chat Trigger does NOT need a return connection from downstream nodes.
|
||||
- Chat Trigger receives user messages and starts the workflow
|
||||
- AI Agent or other nodes process the message
|
||||
- The response is automatically sent back through Chat Trigger's built-in response mechanism
|
||||
- There is NO "main" connection back to Chat Trigger - this is correct behavior
|
||||
|
||||
Valid chat workflow pattern:
|
||||
Chat Trigger → AI Agent (with Chat Model via ai_languageModel)
|
||||
|
||||
The AI Agent's output is automatically routed back to the chat interface. Do NOT flag "AI Agent has no connection back to Chat Trigger" as an error.
|
||||
|
||||
## Node Positioning
|
||||
Node positions (x, y coordinates) in the workflow JSON are for VISUAL LAYOUT ONLY.
|
||||
- Position does NOT affect execution order
|
||||
- Execution order is determined by connections, not positions
|
||||
- A trigger at position [250, 450] executes before a node at [250, 300] if connected that way
|
||||
- Do NOT flag node positioning as a connection or execution flow issue
|
||||
</chat_trigger_patterns>
|
||||
|
||||
<document_loader_patterns>
|
||||
## Document Loader Connection Rules
|
||||
Document Loader nodes (@n8n/n8n-nodes-langchain.documentLoader*) are CAPABILITY-ONLY nodes.
|
||||
|
||||
CRITICAL rules for Document Loaders:
|
||||
1. Document Loaders have NO main input connections - this is correct by design
|
||||
2. Document Loaders provide ai_document capability to Vector Store or other consumers
|
||||
3. Document Loaders read data from workflow context (binary data, URLs) based on their configuration
|
||||
4. The data source is configured in the Document Loader's parameters, NOT passed via main connection
|
||||
|
||||
Valid pattern:
|
||||
Form Trigger → Vector Store (main connection for triggering insert)
|
||||
Document Loader → Vector Store (ai_document capability)
|
||||
|
||||
The Form Trigger does NOT connect to Document Loader. The Document Loader reads the binary data from workflow context automatically.
|
||||
|
||||
Do NOT flag these as errors:
|
||||
- "Document Loader has no main input connection" - correct, it uses ai_document output only
|
||||
- "Missing connection from Trigger to Document Loader" - incorrect expectation
|
||||
- "Document Loader is disconnected" - check for ai_document connection instead
|
||||
</document_loader_patterns>
|
||||
|
||||
<validation_process>
|
||||
Work through these steps in your analysis:
|
||||
|
||||
1. Parse all connections from the JSON. For each entry, identify the source node (the JSON key) and the target node (the "node" field inside). Write each as: Source to Target via connection_type.
|
||||
|
||||
2. Identify capability-only nodes (Document Loader, Text Splitter, Embeddings, LLMs, Output Parsers, Tools, Memory). These nodes correctly have no main connections.
|
||||
|
||||
3. Verify the main execution path flows from trigger through processing nodes. Each non-capability node that processes data should have appropriate main connections.
|
||||
|
||||
4. Verify ai_* connections point from sub-nodes to parent nodes. The sub-node providing the capability should be the source (the JSON key).
|
||||
|
||||
5. For hybrid nodes, confirm they have both their required main connections and ai_* capability connections based on their mode.
|
||||
</validation_process>
|
||||
|
||||
<scoring>
|
||||
Start with 100 points and deduct for violations:
|
||||
|
||||
Critical violations (40-50 points): Breaks in main execution path where trigger or data source has no downstream connection. Missing mandatory main inputs for data processing nodes.
|
||||
|
||||
Major violations (15-25 points): Wrong connection type used. Hybrid nodes missing required connections for their configured mode. Data dependencies out of order.
|
||||
|
||||
Minor violations (5-10 points): Branches that should merge but remain isolated. Unused conditional branches without clear termination.
|
||||
|
||||
IMPORTANT - These are NOT violations:
|
||||
- Capability-only nodes without main connections (correct design)
|
||||
- Split In Batches loop-back connections (correct loop pattern)
|
||||
- Multiple Switch/IF outputs connecting to the same destination (shared destination pattern)
|
||||
- Chat Trigger with no return connection from AI Agent (auto-response is built-in)
|
||||
- Document Loader with no main input (reads from workflow context, outputs via ai_document)
|
||||
- Node positions not matching visual execution flow (positions are layout only)
|
||||
|
||||
Convert final score to 0-1 scale by dividing by 100.
|
||||
</scoring>`;
|
||||
|
||||
const humanTemplate = `Evaluate the connections and data flow of this workflow:
|
||||
|
||||
<user_prompt>
|
||||
{userPrompt}
|
||||
</user_prompt>
|
||||
|
||||
<generated_workflow>
|
||||
{generatedWorkflow}
|
||||
</generated_workflow>
|
||||
|
||||
{referenceSection}
|
||||
|
||||
Conduct your analysis in <analysis> tags, systematically parsing the connection JSON (remember: the JSON key is the SOURCE node, the "node" field is the TARGET). Then provide your evaluation with score, violations array, and brief analysis.`;
|
||||
|
||||
export function createConnectionsEvaluatorChain(llm: BaseChatModel) {
|
||||
return createEvaluatorChain(llm, connectionsResultSchema, systemPrompt, humanTemplate);
|
||||
}
|
||||
|
||||
export async function evaluateConnections(
|
||||
llm: BaseChatModel,
|
||||
input: EvaluationInput,
|
||||
): Promise<ConnectionsResult> {
|
||||
return await invokeEvaluatorChain(createConnectionsEvaluatorChain(llm), input);
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { createEvaluatorChain, invokeEvaluatorChain } from './base';
|
||||
import type { EvaluationInput } from '../evaluation';
|
||||
|
||||
// Schema for data flow evaluation result
|
||||
const dataFlowResultSchema = z.object({
|
||||
score: z.number().min(0).max(1),
|
||||
violations: z.array(
|
||||
z.object({
|
||||
type: z.enum(['critical', 'major', 'minor']),
|
||||
description: z.string(),
|
||||
pointsDeducted: z.number().min(0),
|
||||
}),
|
||||
),
|
||||
analysis: z.string().describe('Brief analysis of data flow and transformations'),
|
||||
});
|
||||
|
||||
export type DataFlowResult = z.infer<typeof dataFlowResultSchema>;
|
||||
|
||||
const systemPrompt = `You are an expert n8n workflow evaluator focusing specifically on DATA FLOW and TRANSFORMATION ACCURACY.
|
||||
Your task is to evaluate how accurately data is transformed and passed through the workflow.
|
||||
|
||||
## CRITICAL: Understanding n8n Data Flow Patterns
|
||||
- **AI agents with tools handle data internally** - not visible in main flow
|
||||
- **Vector stores are referenced by ID**, not direct connections
|
||||
- **Memory nodes connect via ai_memory**, not main connections
|
||||
- **Document loaders may process data via AI connections**
|
||||
- **Focus on actual data corruption/loss, not architectural patterns**
|
||||
|
||||
## Data Transformation Accuracy (0-1)
|
||||
|
||||
### Evaluation Criteria:
|
||||
|
||||
**Score 1.0 - Perfect Transformations:**
|
||||
- All data transformations preserve data integrity
|
||||
- Field mappings are correct and complete
|
||||
- Data types are properly handled (strings, numbers, arrays, objects)
|
||||
- No data loss during transformations
|
||||
- Proper handling of nested data structures
|
||||
|
||||
**Score 0.75 - Good Transformations:**
|
||||
- Most transformations are correct
|
||||
- Minor field naming inconsistencies that don't affect functionality
|
||||
- Slight inefficiencies in data handling
|
||||
|
||||
**Score 0.5 - Adequate Transformations:**
|
||||
- Core data transformations work but with issues
|
||||
- Some data might be lost or incorrectly mapped
|
||||
- Type conversions might have problems
|
||||
|
||||
**Score 0.25 - Poor Transformations:**
|
||||
- Significant data transformation errors
|
||||
- Important fields missing or incorrectly mapped
|
||||
- Data structure problems
|
||||
|
||||
**Score 0.0 - Failed Transformations:**
|
||||
- Critical data loss
|
||||
- Completely incorrect transformations
|
||||
- Would cause workflow to fail
|
||||
|
||||
## Common Transformation Patterns to Check:
|
||||
|
||||
### 1. JSON Data Handling
|
||||
- Correct field extraction from nested objects
|
||||
- Array manipulation (map, filter, reduce operations)
|
||||
- Merging data from multiple sources
|
||||
|
||||
### 2. Type Conversions
|
||||
- String to number conversions where needed
|
||||
- Date formatting and parsing
|
||||
- Boolean logic handling
|
||||
- Array/object conversions
|
||||
|
||||
### 3. Data Aggregation
|
||||
- Combining data from multiple nodes
|
||||
- Proper use of Merge nodes
|
||||
- Maintaining data relationships
|
||||
- Handling one-to-many relationships
|
||||
|
||||
### 4. Data Filtering
|
||||
- IF nodes with correct conditions
|
||||
- Switch nodes with proper case handling
|
||||
- Filter nodes for items filtering
|
||||
- Filter operations on arrays
|
||||
- Conditional data routing
|
||||
|
||||
### 5. Loop and Batch Processing (Split In Batches)
|
||||
Split In Batches creates intentional loops for batch processing:
|
||||
- Output 0 ("done"): Fires ONCE after all batches complete
|
||||
- Output 1 ("loop"): Fires for EACH batch
|
||||
|
||||
Correct data flow pattern:
|
||||
Split In Batches (output 1) → Processing nodes → Split In Batches (input) [LOOP BACK]
|
||||
Split In Batches (output 0) → Aggregate/Final step [COMPLETION]
|
||||
|
||||
CRITICAL: The loop-back connection (Processing → Split In Batches input) is INTENTIONAL.
|
||||
- Data accumulates across iterations
|
||||
- Aggregate node on output 0 collects all processed items after loop completes
|
||||
- Do NOT flag loop-back connections as "circular references" or "infinite loops"
|
||||
|
||||
## Violations to Identify:
|
||||
|
||||
**Critical (-30 to -40 points):**
|
||||
- Complete data loss in transformations
|
||||
- Wrong data types causing failures (e.g., string where number expected)
|
||||
- Missing required data fields for downstream nodes
|
||||
- **DO NOT penalize AI agent tool usage patterns**
|
||||
- **DO NOT penalize Split In Batches loop-back connections** - these are intentional loops, not circular references
|
||||
|
||||
**Major (-10 to -20 points):**
|
||||
- Partial data loss
|
||||
- Incorrect field mappings affecting functionality
|
||||
- Wrong assumptions about data structure
|
||||
- Missing data validation
|
||||
|
||||
**Minor (-2 to -5 points):**
|
||||
- Inefficient data transformations
|
||||
- Unnecessary data duplication
|
||||
- Minor field naming inconsistencies
|
||||
- Missing optional data enrichment
|
||||
|
||||
## Special Considerations:
|
||||
|
||||
### DO NOT penalize for:
|
||||
- Different but valid transformation approaches
|
||||
- Field renaming that maintains data integrity
|
||||
- Intermediate transformation steps for clarity
|
||||
- Placeholder values where user didn't provide data
|
||||
- Chat Trigger without return data flow (responses are automatic via built-in mechanism)
|
||||
- Document Loader without main input (reads from workflow context, not main connections)
|
||||
- Node positions not matching visual flow (positions are for layout only, not execution order)
|
||||
|
||||
### Context Awareness:
|
||||
- Consider the user's intent for data transformation
|
||||
- Some data loss might be intentional (filtering)
|
||||
- Transformation complexity should match task requirements
|
||||
- AI nodes might transform data implicitly
|
||||
|
||||
## Scoring Instructions
|
||||
1. Evaluate transformation accuracy (0-1)
|
||||
2. Identify specific violations
|
||||
3. Overall score = transformation accuracy score
|
||||
4. Provide examples of good/bad transformations in analysis
|
||||
|
||||
Focus on whether data flows correctly through the workflow and reaches its destination in the expected format.`;
|
||||
|
||||
const humanTemplate = `Evaluate the data flow and transformations in this workflow:
|
||||
|
||||
<user_prompt>
|
||||
{userPrompt}
|
||||
</user_prompt>
|
||||
|
||||
<generated_workflow>
|
||||
{generatedWorkflow}
|
||||
</generated_workflow>
|
||||
|
||||
{referenceSection}
|
||||
|
||||
Provide a data flow evaluation with transformation accuracy score, violations, and analysis.`;
|
||||
|
||||
export function createDataFlowEvaluatorChain(llm: BaseChatModel) {
|
||||
return createEvaluatorChain(llm, dataFlowResultSchema, systemPrompt, humanTemplate);
|
||||
}
|
||||
|
||||
export async function evaluateDataFlow(
|
||||
llm: BaseChatModel,
|
||||
input: EvaluationInput,
|
||||
): Promise<DataFlowResult> {
|
||||
return await invokeEvaluatorChain(createDataFlowEvaluatorChain(llm), input);
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { createEvaluatorChain, invokeEvaluatorChain } from './base';
|
||||
import type { EvaluationInput } from '../evaluation';
|
||||
|
||||
// Schema for efficiency evaluation result
|
||||
const efficiencyResultSchema = z.object({
|
||||
score: z.number().min(0).max(1),
|
||||
violations: z.array(
|
||||
z.object({
|
||||
type: z.enum(['critical', 'major', 'minor']),
|
||||
description: z.string(),
|
||||
pointsDeducted: z.number().min(0),
|
||||
}),
|
||||
),
|
||||
redundancyScore: z.number().min(0).max(1).describe('Score for avoiding redundant operations'),
|
||||
pathOptimization: z.number().min(0).max(1).describe('Score for optimal execution paths'),
|
||||
nodeCountEfficiency: z.number().min(0).max(1).describe('Score for using minimal nodes'),
|
||||
analysis: z.string().describe('Brief analysis of workflow efficiency'),
|
||||
});
|
||||
|
||||
export type EfficiencyResult = z.infer<typeof efficiencyResultSchema>;
|
||||
|
||||
const systemPrompt = `You are an expert n8n workflow evaluator focusing specifically on WORKFLOW EFFICIENCY.
|
||||
Your task is to evaluate the efficiency of the workflow across three key metrics.
|
||||
|
||||
## CRITICAL: Understanding n8n Efficiency Patterns
|
||||
- **AI agents with tools + separate nodes is NOT always duplication**
|
||||
- Agent tools are for AI-driven operations
|
||||
- Separate nodes may handle different data or validation
|
||||
- **Backup/fallback paths are intentional redundancy for reliability**
|
||||
- **Some "redundancy" improves maintainability and debugging**
|
||||
- **Focus on actual inefficiencies, not architectural choices**
|
||||
|
||||
## Efficiency Metrics
|
||||
|
||||
### 1. Redundancy Score (0-1)
|
||||
Evaluate if the workflow avoids redundant operations:
|
||||
- Check for duplicate operations that could be consolidated
|
||||
- Look for or unnecessary data transformations
|
||||
- Find redundant Set nodes that could be combined
|
||||
- Score 1.0 = No redundancy, 0.0 = Highly redundant
|
||||
|
||||
**Violations for redundancy:**
|
||||
- Critical: Same operation performed 3+ times unnecessarily
|
||||
- Major: Clear duplication of logic or operations
|
||||
- Minor: Small inefficiencies that could be optimized
|
||||
|
||||
### 2. Path Optimization (0-1)
|
||||
Evaluate if the workflow uses optimal execution paths:
|
||||
- Check if operations are in the most efficient order
|
||||
- Identify paths that could be shortened or simplified
|
||||
- Verify conditional logic doesn't create inefficient branches
|
||||
- Score 1.0 = Optimal paths, 0.0 = Very inefficient paths
|
||||
|
||||
### 3. Node Count Efficiency (0-1)
|
||||
Evaluate if the workflow uses the minimal number of nodes needed:
|
||||
- Check if multiple operations could be combined into single nodes
|
||||
- Look for unnecessary intermediate nodes
|
||||
- Identify if simpler node types could achieve the same result
|
||||
- Consider if the task complexity justifies the node count
|
||||
- Score 1.0 = Minimal nodes for task, 0.0 = Excessive nodes
|
||||
|
||||
**Guidelines for node count:**
|
||||
- Simple tasks (1-3 operations): 2-5 nodes expected
|
||||
- Medium tasks (4-7 operations): 5-10 nodes expected
|
||||
- Complex tasks (8+ operations): 10+ nodes acceptable
|
||||
- Each node should have a clear purpose
|
||||
|
||||
**Violations for node count:**
|
||||
- Critical: 2x+ more nodes than necessary
|
||||
- Major: 50% more nodes than optimal
|
||||
- Minor: A few extra nodes that could be consolidated
|
||||
|
||||
## Important Considerations
|
||||
|
||||
### DO NOT penalize for:
|
||||
- Nodes required for proper error handling
|
||||
- Necessary data validation steps
|
||||
- Required authentication/setup nodes
|
||||
- Legitimate use of multiple nodes for clarity/maintainability
|
||||
- AI sub-nodes (they provide capabilities, not redundancy)
|
||||
|
||||
### Context Awareness:
|
||||
- Consider the complexity of the user's request
|
||||
- Some redundancy may be acceptable for reliability
|
||||
- Clear separation of concerns can justify more nodes
|
||||
|
||||
## Scoring Instructions
|
||||
1. Calculate each metric score (0-1)
|
||||
2. Identify violations with point deductions
|
||||
3. Overall score = average of the three metrics
|
||||
4. Provide specific examples in the analysis
|
||||
|
||||
Focus on identifying clear inefficiencies, not micro-optimizations.`;
|
||||
|
||||
const humanTemplate = `Evaluate the efficiency of this workflow:
|
||||
|
||||
<user_prompt>
|
||||
{userPrompt}
|
||||
</user_prompt>
|
||||
|
||||
<generated_workflow>
|
||||
{generatedWorkflow}
|
||||
</generated_workflow>
|
||||
|
||||
{referenceSection}
|
||||
|
||||
Provide an efficiency evaluation with individual metric scores, overall score, violations, and analysis.`;
|
||||
|
||||
export function createEfficiencyEvaluatorChain(llm: BaseChatModel) {
|
||||
return createEvaluatorChain(llm, efficiencyResultSchema, systemPrompt, humanTemplate);
|
||||
}
|
||||
|
||||
export async function evaluateEfficiency(
|
||||
llm: BaseChatModel,
|
||||
input: EvaluationInput,
|
||||
): Promise<EfficiencyResult> {
|
||||
const result = await invokeEvaluatorChain(createEfficiencyEvaluatorChain(llm), input);
|
||||
|
||||
// Ensure overall score is calculated as average of metrics
|
||||
const avgScore =
|
||||
(result.redundancyScore + result.pathOptimization + result.nodeCountEfficiency) / 3;
|
||||
result.score = avgScore;
|
||||
|
||||
return result;
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { createEvaluatorChain, invokeEvaluatorChain } from './base';
|
||||
import type { EvaluationInput } from '../evaluation';
|
||||
|
||||
// Schema for expressions evaluation result
|
||||
const expressionsResultSchema = z.object({
|
||||
score: z.number().min(0).max(1),
|
||||
violations: z.array(
|
||||
z.object({
|
||||
type: z.enum(['critical', 'major', 'minor']),
|
||||
description: z.string(),
|
||||
pointsDeducted: z.number().min(0),
|
||||
}),
|
||||
),
|
||||
analysis: z.string().describe('Brief analysis of expression syntax and usage'),
|
||||
});
|
||||
|
||||
export type ExpressionsResult = z.infer<typeof expressionsResultSchema>;
|
||||
|
||||
const systemPrompt = `You are an expert n8n workflow evaluator focusing specifically on EXPRESSION SYNTAX and CORRECTNESS.
|
||||
Your task is to evaluate whether expressions correctly reference nodes and data using proper n8n syntax.
|
||||
|
||||
## Correct n8n Expression Syntax
|
||||
|
||||
### Modern Syntax (Preferred)
|
||||
The correct n8n expression syntax uses \`{{ $('Node Name').item.json.field }}\` format
|
||||
|
||||
**Valid patterns:**
|
||||
- Single item: \`={{ $('Node Name').item.json.fieldName }}\`
|
||||
- All items: \`={{ $('Node Name').all() }}\`
|
||||
- First/last item: \`={{ $('Node Name').first().json.field }}\` or \`={{ $('Node Name').last().json.field }}\`
|
||||
- Array index: \`={{ $('Node Name').all()[0].json.fieldName }}\`
|
||||
- Previous node: \`={{ $json.fieldName }}\` or \`={{ $input.item.json.field }}\`
|
||||
- String with text: \`="Text prefix {{ expression }} text suffix"\`
|
||||
- String with date: \`="Report - {{ $now.format('MMMM d, yyyy') }}"\`
|
||||
|
||||
### Special Tool Node Pattern
|
||||
- Tool nodes (ending with "Tool") with ai_tool connections support $fromAI
|
||||
- Format: \`={{ $fromAI('parameterName', 'description', 'type', defaultValue) }}\`
|
||||
- This allows AI Agents to dynamically populate parameters
|
||||
|
||||
### Valid JavaScript in Expressions
|
||||
- Array methods: \`={{ $json.items.map(item => item.name).join(', ') }}\`
|
||||
- String operations: \`={{ $json.text.split(',').filter(x => x) }}\`
|
||||
- Math operations: \`={{ Math.round($json.price * 1.2) }}\`
|
||||
- Conditional logic: \`={{ $json.status === 'active' ? 'Yes' : 'No' }}\`
|
||||
|
||||
### Special n8n Variables
|
||||
- **Item access helpers**: \`$json\`, \`$binary\`, \`$input.item\`, \`$input.all()\`, \`$input.first()\`, \`$input.last()\`, \`$input.params\`, \`$input.context.noItemsLeft\`
|
||||
- **Cross-node helpers**: \`$('Node Name').item\`, \`.all(branchIndex?, runIndex?)\`, \`.first(...)\`, \`.last(...)\`, \`.params\`, \`.context\`, \`.itemMatching(currentNodeInputIndex)\`, \`$('Node Name').isExecuted\`
|
||||
- **Execution metadata**: \`$workflow.id\`, \`$workflow.name\`, \`$workflow.active\`, \`$execution.id\`, \`$execution.mode\`, \`$execution.resumeUrl\`, \`$execution.customData\`, \`$runIndex\`, \`$prevNode.name\`, \`$prevNode.outputIndex\`, \`$prevNode.runIndex\`, \`$itemIndex\`, \`$nodeVersion\`, \`$version\`
|
||||
- **Environment and variables**: \`$env\`, \`$vars\`, \`$secrets\`, \`$getWorkflowStaticData(type)\`
|
||||
- **Utility helpers**: \`$evaluateExpression(expression, itemIndex?)\`, \`$ifEmpty(value, defaultValue)\`
|
||||
- **Date and time**: \`$now\`, \`$today\`
|
||||
- **HTTP node only**: \`$pageCount\`, \`$request\`, \`$response\`
|
||||
- **Context awareness**: Some helpers exist only in specific nodes (Loop Over Items, HTTP Request, etc.); do not assume they should appear everywhere
|
||||
|
||||
## Important: The = Prefix
|
||||
- REQUIRED for expressions: \`={{ expression }}\`
|
||||
- REQUIRED for mixed text/expressions: \`="Text {{ expression }}"\`
|
||||
- Optional for pure static text: \`"Hello World"\` or \`="Hello World"\`
|
||||
|
||||
## Evaluation Criteria
|
||||
|
||||
### DO NOT penalize:
|
||||
- Alternative but functionally equivalent syntax variations
|
||||
- Expression syntax that would work even if not optimal
|
||||
- String concatenation in any valid form
|
||||
- Simple = prefix for strings
|
||||
- Any working expression format
|
||||
|
||||
### Check for these violations:
|
||||
|
||||
**Critical (-40 to -50 points):**
|
||||
- Invalid JavaScript syntax causing runtime errors
|
||||
- Referencing non-existent nodes or fields or npm modules
|
||||
- Using $fromAI in non-tool nodes
|
||||
- Unclosed brackets, syntax errors, malformed JSON
|
||||
|
||||
**Major (-20 to -25 points):**
|
||||
- Missing required = prefix for expressions
|
||||
- Referencing undefined variables or functions
|
||||
- Wrong data paths preventing execution
|
||||
|
||||
**Minor (-5 to -10 points):**
|
||||
- Inefficient but working expressions
|
||||
- Outdated syntax (e.g., \`$node["NodeName"]\` instead of \`$('NodeName')\`)
|
||||
- Style preferences that don't affect functionality
|
||||
|
||||
## Context Understanding
|
||||
Consider the data flow context:
|
||||
- Field names may differ between nodes
|
||||
- Check if referenced fields exist in source nodes
|
||||
- Consider field name transformations
|
||||
- Minor naming mismatches are less severe if types match
|
||||
|
||||
## Scoring Instructions
|
||||
1. Start with 100 points
|
||||
2. Deduct points for each violation found based on severity
|
||||
3. Score cannot go below 0
|
||||
4. Convert to 0-1 scale by dividing by 100
|
||||
|
||||
Focus on whether expressions would execute successfully, not style preferences.`;
|
||||
|
||||
const humanTemplate = `Evaluate the expression syntax of this workflow:
|
||||
|
||||
<user_prompt>
|
||||
{userPrompt}
|
||||
</user_prompt>
|
||||
|
||||
<generated_workflow>
|
||||
{generatedWorkflow}
|
||||
</generated_workflow>
|
||||
|
||||
{referenceSection}
|
||||
|
||||
Provide an expressions evaluation with score, violations, and brief analysis.`;
|
||||
|
||||
export function createExpressionsEvaluatorChain(llm: BaseChatModel) {
|
||||
return createEvaluatorChain(llm, expressionsResultSchema, systemPrompt, humanTemplate);
|
||||
}
|
||||
|
||||
export async function evaluateExpressions(
|
||||
llm: BaseChatModel,
|
||||
input: EvaluationInput,
|
||||
): Promise<ExpressionsResult> {
|
||||
return await invokeEvaluatorChain(createExpressionsEvaluatorChain(llm), input);
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { createEvaluatorChain, invokeEvaluatorChain } from './base';
|
||||
import type { EvaluationInput } from '../evaluation';
|
||||
|
||||
// Schema for functionality evaluation result
|
||||
const functionalityResultSchema = z.object({
|
||||
score: z.number().min(0).max(1),
|
||||
violations: z.array(
|
||||
z.object({
|
||||
type: z.enum(['critical', 'major', 'minor']),
|
||||
description: z.string(),
|
||||
pointsDeducted: z.number().min(0),
|
||||
}),
|
||||
),
|
||||
analysis: z.string().describe('Brief analysis of functionality implementation'),
|
||||
});
|
||||
|
||||
export type FunctionalityResult = z.infer<typeof functionalityResultSchema>;
|
||||
|
||||
const systemPrompt = `You are an expert n8n workflow evaluator focusing specifically on FUNCTIONAL CORRECTNESS.
|
||||
Your task is to evaluate whether a generated workflow correctly implements what the user EXPLICITLY requested.
|
||||
|
||||
## Your Role
|
||||
Evaluate ONLY the functional aspects - whether the workflow achieves the intended goal and performs the requested operations.
|
||||
|
||||
## Evaluation Criteria
|
||||
|
||||
### DO NOT penalize for:
|
||||
- Missing optimizations not requested by user
|
||||
- Missing features that would be "nice to have" but weren't specified
|
||||
- Alternative valid approaches to solve the same problem
|
||||
- Style preferences or minor inefficiencies
|
||||
|
||||
### Check for these violations:
|
||||
|
||||
**Critical (-40 to -50 points):**
|
||||
- Missing core functionality explicitly requested
|
||||
- Incorrect operation logic that prevents the workflow from working
|
||||
- Workflows missing a trigger node when they need to start automatically or by some external event
|
||||
- Complete failure to address the user's main request
|
||||
|
||||
**Major (-15 to -25 points):**
|
||||
- Missing explicitly required data transformations
|
||||
- Incomplete implementation of requested features
|
||||
- Using completely wrong node type for the task (e.g., Set node when IF node is clearly needed)
|
||||
- Workflows that would fail immediately on first execution due to structural issues
|
||||
- Missing important steps that were clearly specified
|
||||
|
||||
**Minor (-5 to -10 points):**
|
||||
- Missing optional features explicitly mentioned by user
|
||||
- Using less optimal but functional node choices
|
||||
- Minor deviations from requested behavior that don't break functionality
|
||||
|
||||
## Scoring Instructions
|
||||
1. Start with 100 points
|
||||
2. Deduct points for each violation found based on severity
|
||||
3. Score cannot go below 0
|
||||
4. Convert to 0-1 scale by dividing by 100
|
||||
|
||||
## Important Context
|
||||
- Focus on whether the workflow performs all EXPLICITLY requested operations
|
||||
- Check if operations are in the correct logical sequence
|
||||
- Verify it handles all scenarios mentioned in the user prompt
|
||||
- Ensure data transformations are implemented as requested
|
||||
- Remember: functional correctness is about meeting requirements, not perfection
|
||||
|
||||
## n8n RAG Pipeline Pattern (CRITICAL - Do Not Misunderstand)
|
||||
|
||||
**Document Loader is a CAPABILITY-ONLY sub-node. It NEVER receives main data flow.**
|
||||
|
||||
The Document Loader node:
|
||||
- Has NO main input - it cannot and should not receive data via main connections
|
||||
- ONLY connects via ai_document TO a Vector Store (Document Loader → Vector Store)
|
||||
- Reads data from the workflow context (binary files, JSON) based on its dataType configuration
|
||||
- Is a capability provider that tells Vector Store HOW to process documents
|
||||
|
||||
**CORRECT RAG Pipeline:**
|
||||
\`\`\`
|
||||
Data Source (Extract From File, HTTP Request, etc.)
|
||||
│
|
||||
│ [main]
|
||||
▼
|
||||
Vector Store (insert mode) ◄──[ai_document]── Document Loader ◄──[ai_textSplitter]── Text Splitter
|
||||
▲
|
||||
└──[ai_embedding]── Embeddings
|
||||
\`\`\`
|
||||
|
||||
**THE FOLLOWING ARE ALL CORRECT - NEVER FLAG AS VIOLATIONS:**
|
||||
- Document Loader has NO main connections - THIS IS CORRECT BY DESIGN
|
||||
- Document Loader connects TO Vector Store via ai_document - THIS IS THE ONLY WAY TO USE IT
|
||||
- Extract From File connects directly to Vector Store via main - THIS IS CORRECT
|
||||
- Document Loader appears "isolated" from the main data path - THIS IS CORRECT
|
||||
|
||||
**INVALID VIOLATION EXAMPLES - DO NOT OUTPUT THESE:**
|
||||
- ❌ "Document ingestion pipeline is broken because data bypasses Document Loader" - WRONG ANALYSIS
|
||||
- ❌ "Extract From File connects directly to Vector Store, bypassing Document Loader" - This IS the correct pattern
|
||||
- ❌ "Document Loader is disconnected from main data flow" - CORRECT behavior, not an error
|
||||
- ❌ "Document Loader needs to receive the extracted data" - WRONG, it reads from workflow context
|
||||
- ❌ "Document Loader is completely disconnected from the main data flow" - WRONG, it connects via ai_document
|
||||
- ❌ "Vector Store is missing required Document Loader connection via ai_document port" when Document Loader IS connected via ai_document - CHECK THE CONNECTIONS CAREFULLY
|
||||
- ❌ Any violation claiming Document Loader should receive main data - ALWAYS WRONG
|
||||
|
||||
The main connection triggers the Vector Store insert operation. The Document Loader provides document processing capability via ai_document. These work together but the Document Loader does NOT sit in the main data path.
|
||||
|
||||
## ABSOLUTE RULE: Embedding Nodes MUST Use Embedding Models (NEVER A VIOLATION)
|
||||
|
||||
**THIS IS THE MOST IMPORTANT RULE IN THIS EVALUATOR. READ CAREFULLY.**
|
||||
|
||||
### The Technical Reality
|
||||
- **Embedding models** (text-embedding-3-small, text-embedding-3-large, text-embedding-ada-002): Convert text to vectors
|
||||
- **Chat models** (gpt-4, gpt-4o, gpt-4o-mini, gpt-4.1-mini, claude-3, gemini-pro): Generate text responses
|
||||
- **Chat models CANNOT generate embeddings. It is technically impossible.**
|
||||
|
||||
### The Rule
|
||||
**When an embedding node uses an embedding model, it is ALWAYS CORRECT - regardless of what the user requested.**
|
||||
|
||||
If a user says "use gpt-4o-mini for embeddings" or "use gpt-4.1-mini for embeddings":
|
||||
- The user has made a technical error (requested an impossible configuration)
|
||||
- The workflow builder CORRECTLY uses an embedding model instead
|
||||
- This is the RIGHT behavior, NOT a violation
|
||||
- **DO NOT OUTPUT ANY VIOLATION FOR THIS SCENARIO**
|
||||
|
||||
### FORBIDDEN Violations - NEVER Output These
|
||||
You must NEVER output violations like:
|
||||
- ❌ "User requested gpt-4o-mini but workflow uses text-embedding-3-small"
|
||||
- ❌ "User explicitly requested embeddings using 'gpt-4.1-mini' but workflow uses 'text-embedding-3-small'"
|
||||
- ❌ "User's explicit specification was not followed" (when user specified a chat model for embeddings)
|
||||
- ❌ "The workflow does not use the model specified by the user" (for embedding nodes)
|
||||
- ❌ Any violation mentioning that an embedding node should use a chat model
|
||||
|
||||
### Why This Rule Exists
|
||||
This is like a user asking to "cut wood with a hammer" - using a saw instead is correct, not a violation. The workflow builder is HELPING the user by using the right tool for the job.
|
||||
|
||||
### Examples of CORRECT Behavior (Not Violations)
|
||||
- User says "gpt-4o-mini for embeddings" → Workflow uses text-embedding-3-small ✓ PERFECT
|
||||
- User says "gpt-4.1-mini for embeddings" → Workflow uses text-embedding-3-small ✓ PERFECT
|
||||
- User says "gpt-4 for vector store" → Workflow uses text-embedding-3-large ✓ PERFECT
|
||||
- User mentions ANY chat model for embedding tasks → Workflow uses ANY embedding model ✓ PERFECT
|
||||
|
||||
## Model Selection: ALWAYS Minor Severity at Most
|
||||
|
||||
**Model selection differences are NEVER critical or major violations.**
|
||||
|
||||
When evaluating model choices:
|
||||
1. **Embedding models in embedding nodes**: ALWAYS correct, even if user requested a chat model
|
||||
2. **Same family, different model**: Minor at most (e.g., user says gpt-4, workflow uses gpt-4o-mini)
|
||||
3. **Same provider, different model**: Minor at most (e.g., user says claude-3-opus, workflow uses claude-3-sonnet)
|
||||
4. **Different provider entirely**: Minor at most, unless user explicitly required a specific provider for a business reason
|
||||
|
||||
**Examples of CORRECT behavior (not violations):**
|
||||
- User requests "gpt-4o-mini" → Workflow uses "gpt-4o" or "gpt-4" ✓
|
||||
- User requests "claude" → Workflow uses any Anthropic model ✓
|
||||
- User requests "OpenAI" → Workflow uses any OpenAI model ✓
|
||||
- User mentions any model → Workflow uses a different but capable model ✓
|
||||
|
||||
**The workflow builder selects appropriate models. Model choice is a preference, not a functional requirement.**`;
|
||||
|
||||
const humanTemplate = `Evaluate the functional correctness of this workflow:
|
||||
|
||||
<user_prompt>
|
||||
{userPrompt}
|
||||
</user_prompt>
|
||||
|
||||
<generated_workflow>
|
||||
{generatedWorkflow}
|
||||
</generated_workflow>
|
||||
|
||||
{referenceSection}
|
||||
|
||||
Provide a functionality evaluation with score, violations, and brief analysis.`;
|
||||
|
||||
export function createFunctionalityEvaluatorChain(llm: BaseChatModel) {
|
||||
return createEvaluatorChain(llm, functionalityResultSchema, systemPrompt, humanTemplate);
|
||||
}
|
||||
|
||||
export async function evaluateFunctionality(
|
||||
llm: BaseChatModel,
|
||||
input: EvaluationInput,
|
||||
): Promise<FunctionalityResult> {
|
||||
return await invokeEvaluatorChain(createFunctionalityEvaluatorChain(llm), input);
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
// Export all evaluator functions and types
|
||||
export { evaluateFunctionality, type FunctionalityResult } from './functionality-evaluator';
|
||||
export { evaluateConnections, type ConnectionsResult } from './connections-evaluator';
|
||||
export { evaluateExpressions, type ExpressionsResult } from './expressions-evaluator';
|
||||
export {
|
||||
evaluateNodeConfiguration,
|
||||
type NodeConfigurationResult,
|
||||
} from './node-configuration-evaluator';
|
||||
export { evaluateEfficiency, type EfficiencyResult } from './efficiency-evaluator';
|
||||
export { evaluateDataFlow, type DataFlowResult } from './data-flow-evaluator';
|
||||
export { evaluateMaintainability, type MaintainabilityResult } from './maintainability-evaluator';
|
||||
export {
|
||||
evaluateBestPractices,
|
||||
type BestPracticesResult,
|
||||
} from './best-practices-evaluator';
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { createEvaluatorChain, invokeEvaluatorChain } from './base';
|
||||
import type { EvaluationInput } from '../evaluation';
|
||||
|
||||
// Schema for maintainability evaluation result
|
||||
const maintainabilityResultSchema = z.object({
|
||||
score: z.number().min(0).max(1),
|
||||
violations: z.array(
|
||||
z.object({
|
||||
type: z.enum(['critical', 'major', 'minor']),
|
||||
description: z.string(),
|
||||
pointsDeducted: z.number().min(0),
|
||||
}),
|
||||
),
|
||||
nodeNamingQuality: z.number().min(0).max(1).describe('Score for descriptive node naming'),
|
||||
workflowOrganization: z.number().min(0).max(1).describe('Score for logical workflow structure'),
|
||||
modularity: z.number().min(0).max(1).describe('Score for reusable and modular components'),
|
||||
analysis: z.string().describe('Brief analysis of workflow maintainability'),
|
||||
});
|
||||
|
||||
export type MaintainabilityResult = z.infer<typeof maintainabilityResultSchema>;
|
||||
|
||||
const systemPrompt = `You are an expert n8n workflow evaluator focusing specifically on WORKFLOW MAINTAINABILITY.
|
||||
Your task is to evaluate how maintainable and well-organized the workflow is.
|
||||
|
||||
## Maintainability Metrics
|
||||
|
||||
### 1. Node Naming Quality (0-1)
|
||||
Evaluate the descriptiveness and consistency of node names:
|
||||
|
||||
**Score 1.0 - Excellent Naming:**
|
||||
- All nodes have descriptive, clear names
|
||||
- Names indicate the node's purpose/function
|
||||
- Consistent naming convention throughout
|
||||
- No generic names like "Set", "HTTP Request"
|
||||
- Names help understand workflow at a glance
|
||||
|
||||
**Score 0.75 - Good Naming:**
|
||||
- Most nodes well-named
|
||||
- Some generic names but context is clear
|
||||
- Generally consistent naming
|
||||
|
||||
**Score 0.5 - Adequate Naming:**
|
||||
- Mix of good and poor names
|
||||
- Some nodes hard to understand from names
|
||||
- Inconsistent naming patterns
|
||||
|
||||
**Score 0.25 - Poor Naming:**
|
||||
- Many generic or unclear names
|
||||
- Difficult to understand node purposes
|
||||
- No clear naming strategy
|
||||
|
||||
**Score 0.0 - Very Poor Naming:**
|
||||
- All or most nodes have generic names
|
||||
- Impossible to understand workflow from names
|
||||
- Random or meaningless names
|
||||
|
||||
**Good Naming Examples:**
|
||||
- "Fetch Customer Data from CRM"
|
||||
- "Transform Order to Invoice Format"
|
||||
- "Send Confirmation Email"
|
||||
- "Validate User Input"
|
||||
- "Check Inventory Availability"
|
||||
|
||||
**Poor Naming Examples:**
|
||||
- "Set"
|
||||
- "HTTP Request"
|
||||
- "Node1"
|
||||
- "Process Data"
|
||||
- "Do Something"
|
||||
|
||||
### 2. Workflow Organization (0-1)
|
||||
Evaluate the logical structure and layout:
|
||||
|
||||
**Score 1.0 - Excellent Organization:**
|
||||
- Clear logical flow from start to finish
|
||||
- Related nodes grouped together
|
||||
- Proper separation of concerns
|
||||
- Easy to follow data flow
|
||||
- Clear section boundaries
|
||||
|
||||
**Score 0.75 - Good Organization:**
|
||||
- Generally well-organized
|
||||
- Most sections clear
|
||||
- Minor improvements possible
|
||||
|
||||
**Score 0.5 - Adequate Organization:**
|
||||
- Basic organization present
|
||||
- Some confusion in flow
|
||||
- Mixed concerns in places
|
||||
|
||||
**Score 0.25 - Poor Organization:**
|
||||
- Confusing layout
|
||||
- Hard to follow flow
|
||||
- Mixed responsibilities
|
||||
- No clear structure
|
||||
|
||||
**Score 0.0 - No Organization:**
|
||||
- Chaotic structure
|
||||
- Random node placement
|
||||
- Impossible to follow
|
||||
- No logical grouping
|
||||
|
||||
**Organization Patterns to Look For:**
|
||||
- Input validation at the beginning
|
||||
- Data transformation in the middle
|
||||
- Output/notification at the end
|
||||
- Error handling grouped together
|
||||
- Related operations near each other
|
||||
|
||||
### 3. Modularity (0-1)
|
||||
Evaluate reusability and component separation:
|
||||
|
||||
**Score 1.0 - Highly Modular:**
|
||||
- Clear separation of concerns
|
||||
- Reusable components/patterns
|
||||
- Each node has single responsibility
|
||||
- Could easily extract parts for reuse
|
||||
- Workflow sections could be sub-workflows
|
||||
|
||||
**Score 0.75 - Good Modularity:**
|
||||
- Most components well-separated
|
||||
- Some reusable patterns
|
||||
- Generally follows single responsibility
|
||||
|
||||
**Score 0.5 - Adequate Modularity:**
|
||||
- Some modularity present
|
||||
- Mixed responsibilities in places
|
||||
- Limited reusability
|
||||
|
||||
**Score 0.25 - Poor Modularity:**
|
||||
- Little separation of concerns
|
||||
- Nodes doing too many things
|
||||
- Hard to extract reusable parts
|
||||
- Tightly coupled components
|
||||
|
||||
**Score 0.0 - No Modularity:**
|
||||
- Everything mixed together
|
||||
- No clear component boundaries
|
||||
- Impossible to reuse parts
|
||||
- Monolithic approach
|
||||
|
||||
**Modularity Indicators:**
|
||||
- Each node does one thing well
|
||||
- Data transformation separated from business logic
|
||||
- Authentication separated from main flow
|
||||
- Error handling is modular
|
||||
- Could extract sections as sub-workflows
|
||||
|
||||
## Violations to Identify:
|
||||
|
||||
**Critical (-40 to -50 points):**
|
||||
- Completely generic node naming throughout
|
||||
- Chaotic organization making workflow unmaintainable
|
||||
- No modularity - everything in single complex nodes
|
||||
- Workflow would be impossible for another developer to understand
|
||||
|
||||
**Major (-15 to -25 points):**
|
||||
- Many poorly named nodes
|
||||
- Confusing organization in critical sections
|
||||
- Poor separation of concerns
|
||||
- Difficult to modify or extend
|
||||
|
||||
**Minor (-5 to -10 points):**
|
||||
- Some generic node names
|
||||
- Minor organization improvements needed
|
||||
- Could be more modular
|
||||
- Small maintainability issues
|
||||
|
||||
## Important Considerations:
|
||||
|
||||
### DO NOT penalize for:
|
||||
- Simple workflows that don't need complex organization
|
||||
- AI-generated placeholder names that are still descriptive
|
||||
- Different but valid organizational approaches
|
||||
- Prototypes or POC workflows
|
||||
|
||||
### Context Awareness:
|
||||
- Simple workflows (2-5 nodes) need less organization
|
||||
- Complex workflows (15+ nodes) need clear structure
|
||||
- Template workflows should be extra maintainable
|
||||
- Consider the workflow's purpose and audience
|
||||
|
||||
### Workflow Complexity vs Maintainability:
|
||||
- Simple: Basic naming and organization acceptable
|
||||
- Medium: Should have clear names and sections
|
||||
- Complex: Must have excellent maintainability
|
||||
|
||||
## Scoring Instructions
|
||||
1. Calculate node naming quality (0-1)
|
||||
2. Calculate workflow organization (0-1)
|
||||
3. Calculate modularity score (0-1)
|
||||
4. Overall score = average of three metrics
|
||||
5. Identify specific violations
|
||||
6. Suggest improvements where applicable
|
||||
|
||||
Focus on aspects that would make the workflow easier to understand, modify, and maintain by other developers.`;
|
||||
|
||||
const humanTemplate = `Evaluate the maintainability of this workflow:
|
||||
|
||||
<user_prompt>
|
||||
{userPrompt}
|
||||
</user_prompt>
|
||||
|
||||
<generated_workflow>
|
||||
{generatedWorkflow}
|
||||
</generated_workflow>
|
||||
|
||||
{referenceSection}
|
||||
|
||||
Provide a maintainability evaluation with naming, organization, and modularity scores, violations, and analysis.`;
|
||||
|
||||
export function createMaintainabilityEvaluatorChain(llm: BaseChatModel) {
|
||||
return createEvaluatorChain(llm, maintainabilityResultSchema, systemPrompt, humanTemplate);
|
||||
}
|
||||
|
||||
export async function evaluateMaintainability(
|
||||
llm: BaseChatModel,
|
||||
input: EvaluationInput,
|
||||
): Promise<MaintainabilityResult> {
|
||||
const result = await invokeEvaluatorChain(createMaintainabilityEvaluatorChain(llm), input);
|
||||
|
||||
// Ensure overall score is calculated as average of metrics
|
||||
const avgScore = (result.nodeNamingQuality + result.workflowOrganization + result.modularity) / 3;
|
||||
result.score = avgScore;
|
||||
|
||||
return result;
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { createEvaluatorChain, invokeEvaluatorChain } from './base';
|
||||
import type { EvaluationInput } from '../evaluation';
|
||||
|
||||
// Schema for node configuration evaluation result
|
||||
const nodeConfigurationResultSchema = z.object({
|
||||
score: z.number().min(0).max(1),
|
||||
violations: z.array(
|
||||
z.object({
|
||||
type: z.enum(['critical', 'major', 'minor']),
|
||||
description: z.string(),
|
||||
pointsDeducted: z.number().min(0),
|
||||
}),
|
||||
),
|
||||
analysis: z.string().describe('Brief analysis of node parameter configuration'),
|
||||
});
|
||||
|
||||
export type NodeConfigurationResult = z.infer<typeof nodeConfigurationResultSchema>;
|
||||
|
||||
const systemPrompt = `You are an expert n8n workflow evaluator focusing specifically on NODE CONFIGURATION and PARAMETERS.
|
||||
Your task is to evaluate whether nodes are configured with correct parameters and settings.
|
||||
|
||||
## SCOPE: ONLY Evaluate Node Parameters
|
||||
|
||||
**YOUR SCOPE IS LIMITED TO:**
|
||||
- Node parameter values (the "parameters" object inside each node)
|
||||
- Whether parameter values match what the user requested
|
||||
- Whether required parameters are present
|
||||
- Whether parameter values are valid (correct types, valid JSON, etc.)
|
||||
|
||||
**DO NOT EVALUATE (these are handled by other evaluators):**
|
||||
- Node connections (handled by Connections Evaluator)
|
||||
- Whether nodes are connected to each other
|
||||
- Missing ai_document, ai_embedding, ai_tool, ai_memory, or any other connection types
|
||||
- Data flow between nodes
|
||||
|
||||
**NEVER OUTPUT VIOLATIONS ABOUT:**
|
||||
- ❌ "missing Document Loader connection"
|
||||
- ❌ "missing ai_document connection"
|
||||
- ❌ "missing ai_embedding connection"
|
||||
- ❌ "missing required connection"
|
||||
- ❌ Any violation mentioning "connection" - that's not your job
|
||||
|
||||
If you see something that looks like a connection issue, IGNORE IT. Focus only on the parameters object.
|
||||
|
||||
## CRITICAL: Understanding n8n Credentials and Configuration
|
||||
- **NEVER penalize nodes for missing credentials**
|
||||
- **Credentials are ALWAYS configured at runtime through the n8n UI**
|
||||
- **Empty "credentials": {} fields are NORMAL and EXPECTED**
|
||||
- **Focus on actual parameter misconfiguration, not missing credentials**
|
||||
|
||||
## Valid Placeholder Patterns
|
||||
|
||||
### DO NOT penalize these patterns:
|
||||
- \`<UNKNOWN>\` values when user didn't specify concrete values
|
||||
- Empty strings ("") in configuration fields when not provided by user
|
||||
- Empty strings in resource selectors (base/table/document IDs)
|
||||
- Placeholder API keys like "YOUR_API_KEY" or similar patterns
|
||||
- These are ALL valid user configuration points, not errors
|
||||
|
||||
**Important**: Empty string ("") and \`<UNKNOWN>\` are BOTH valid placeholders
|
||||
|
||||
### Special Tool Node Handling
|
||||
- $fromAI expressions are VALID in ANY tool node (nodes ending with "Tool")
|
||||
- Tool nodes connected via ai_tool allow AI Agents to populate parameters dynamically
|
||||
- Format: \`{{ $fromAI('parameter', 'description') }}\` is correct and expected
|
||||
- DO NOT penalize $fromAI in TOOL NODE parameters
|
||||
|
||||
## ABSOLUTE RULE: Embedding Nodes MUST Use Embedding Models (NEVER A VIOLATION)
|
||||
|
||||
**THIS IS THE MOST IMPORTANT RULE IN THIS EVALUATOR. READ CAREFULLY.**
|
||||
|
||||
### The Technical Reality
|
||||
- **Embedding models** (text-embedding-3-small, text-embedding-3-large, text-embedding-ada-002): Convert text to vectors
|
||||
- **Chat models** (gpt-4, gpt-4o, gpt-4o-mini, gpt-4.1-mini, claude-3, etc.): Generate text
|
||||
- **Chat models CANNOT generate embeddings. It is technically impossible.**
|
||||
|
||||
### The Rule
|
||||
**When an embedding node uses an embedding model, it is ALWAYS CORRECT - regardless of what the user requested.**
|
||||
|
||||
If a user says "use gpt-4o-mini for embeddings" or "use gpt-4.1-mini for embeddings":
|
||||
- The user has made a technical error (requested an impossible configuration)
|
||||
- The workflow builder CORRECTLY uses an embedding model instead
|
||||
- This is the RIGHT behavior, NOT a violation
|
||||
- **DO NOT OUTPUT ANY VIOLATION FOR THIS SCENARIO**
|
||||
|
||||
### FORBIDDEN Violations - NEVER Output These
|
||||
You must NEVER output violations like:
|
||||
- ❌ "User requested gpt-4o-mini but workflow uses text-embedding-3-small"
|
||||
- ❌ "User explicitly requested embeddings using 'gpt-4.1-mini' but workflow uses 'text-embedding-3-small'"
|
||||
- ❌ "User's explicit specification was not followed" (when user specified a chat model for embeddings)
|
||||
- ❌ "Embedding node uses wrong model"
|
||||
- ❌ Any violation about embedding nodes not using chat models
|
||||
|
||||
### Examples of CORRECT Behavior (Not Violations)
|
||||
- User says "gpt-4o-mini for embeddings" → Workflow uses text-embedding-3-small ✓ PERFECT
|
||||
- User says "gpt-4.1-mini for embeddings" → Workflow uses text-embedding-3-small ✓ PERFECT
|
||||
- User mentions ANY chat model for embedding tasks → Workflow uses ANY embedding model ✓ PERFECT
|
||||
|
||||
## General Model Selection Rules
|
||||
|
||||
**Model selection differences are NEVER critical or major violations. At most MINOR.**
|
||||
|
||||
Model choices are preferences, not requirements:
|
||||
- Same provider, different model = MINOR at most (gpt-4 vs gpt-4o-mini)
|
||||
- Different provider = MINOR at most (OpenAI vs Anthropic)
|
||||
- Model selection is NEVER critical or major
|
||||
|
||||
**Examples of CORRECT behavior (not violations):**
|
||||
- User says "gpt-4" → Workflow uses gpt-4o-mini ✓
|
||||
- User says "claude" → Workflow uses any Anthropic model ✓
|
||||
- User mentions model X → Workflow uses capable model Y ✓
|
||||
|
||||
## Evaluation Criteria
|
||||
|
||||
### Check for these violations:
|
||||
|
||||
**Critical (-30 to -40 points):** ONLY for actual breaking issues:
|
||||
- Truly required parameters completely absent (not empty/placeholder):
|
||||
- HTTP Request without URL (unless using $fromAI)
|
||||
- Database operations without operation type specified
|
||||
- Code node without any code
|
||||
- Parameters with invalid values that would crash:
|
||||
- Invalid JSON in JSON fields
|
||||
- Non-numeric values in number-only fields
|
||||
- Configuration that would cause runtime crash
|
||||
- **NEVER penalize for missing credentials or API keys**
|
||||
- **NEVER penalize for model selection choices**
|
||||
|
||||
**Major (-10 to -20 points):**
|
||||
- Wrong operation mode when explicitly specified by user
|
||||
- Significant deviation from requested behavior (NOT model choices)
|
||||
- Missing resource/operation selection that prevents node from functioning
|
||||
- **NOT model selection - model differences are minor at most**
|
||||
|
||||
**Minor (-2 to -5 points):**
|
||||
- Suboptimal but working configurations
|
||||
- Style preferences or minor inefficiencies
|
||||
- Missing optional parameters that could improve functionality
|
||||
- Model selection differences (if any - usually not worth flagging)
|
||||
|
||||
## Context-Aware Evaluation
|
||||
|
||||
### Compare Against User Request
|
||||
- Only penalize incorrect values if user explicitly provided them
|
||||
- If user didn't provide specific values, placeholders are expected
|
||||
- Focus on structural correctness, not specific values
|
||||
|
||||
### Severity Guidelines:
|
||||
- If user didn't provide email addresses, \`<UNKNOWN>\` is expected
|
||||
- If user didn't specify API keys, placeholder values are valid
|
||||
- If user didn't provide specific IDs or credentials, empty/placeholder values are correct
|
||||
|
||||
## Scoring Instructions
|
||||
1. Start with 100 points
|
||||
2. Deduct points for each violation found based on severity
|
||||
3. Score cannot go below 0
|
||||
4. Convert to 0-1 scale by dividing by 100
|
||||
|
||||
Focus on whether parameters are set correctly based on what the user actually specified.`;
|
||||
|
||||
const humanTemplate = `Evaluate the node configuration of this workflow:
|
||||
|
||||
<user_prompt>
|
||||
{userPrompt}
|
||||
</user_prompt>
|
||||
|
||||
<generated_workflow>
|
||||
{generatedWorkflow}
|
||||
</generated_workflow>
|
||||
|
||||
{referenceSection}
|
||||
|
||||
Provide a node configuration evaluation with score, violations, and brief analysis.`;
|
||||
|
||||
export function createNodeConfigurationEvaluatorChain(llm: BaseChatModel) {
|
||||
return createEvaluatorChain(llm, nodeConfigurationResultSchema, systemPrompt, humanTemplate);
|
||||
}
|
||||
|
||||
export async function evaluateNodeConfiguration(
|
||||
llm: BaseChatModel,
|
||||
input: EvaluationInput,
|
||||
): Promise<NodeConfigurationResult> {
|
||||
return await invokeEvaluatorChain(createNodeConfigurationEvaluatorChain(llm), input);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import type { SimpleWorkflow } from '@/types/workflow';
|
||||
|
||||
import type { EvaluationInput } from './evaluation';
|
||||
import { evaluateWorkflow } from './workflow-evaluator';
|
||||
import { runWithOptionalLimiter, withTimeout } from '../../harness/evaluation-helpers';
|
||||
import type { EvaluationContext, Evaluator, Feedback } from '../../harness/harness-types';
|
||||
|
||||
const EVALUATOR_NAME = 'llm-judge';
|
||||
|
||||
/**
|
||||
* Violation type from evaluation results.
|
||||
*/
|
||||
interface Violation {
|
||||
type: string;
|
||||
description: string;
|
||||
pointsDeducted: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format violations as a comment string.
|
||||
*/
|
||||
function formatViolations(violations: Violation[]): string {
|
||||
if (!violations || violations.length === 0) return '';
|
||||
return violations.map((v) => `[${v.type}] ${v.description}`).join('; ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an LLM-as-judge evaluator that uses the existing evaluateWorkflow chain.
|
||||
*
|
||||
* @param llm - The LLM to use for evaluation
|
||||
* @param _nodeTypes - Node type descriptions (unused but kept for interface compatibility)
|
||||
* @returns An evaluator that produces feedback from LLM evaluation
|
||||
*/
|
||||
export function createLLMJudgeEvaluator(
|
||||
llm: BaseChatModel,
|
||||
_nodeTypes: INodeTypeDescription[],
|
||||
): Evaluator<EvaluationContext> {
|
||||
const fb = (
|
||||
metric: string,
|
||||
score: number,
|
||||
kind: Feedback['kind'],
|
||||
comment?: string,
|
||||
): Feedback => ({
|
||||
evaluator: EVALUATOR_NAME,
|
||||
metric,
|
||||
score,
|
||||
kind,
|
||||
...(comment ? { comment } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
name: EVALUATOR_NAME,
|
||||
|
||||
async evaluate(workflow: SimpleWorkflow, ctx: EvaluationContext): Promise<Feedback[]> {
|
||||
const input: EvaluationInput = {
|
||||
userPrompt: ctx.prompt,
|
||||
generatedWorkflow: workflow,
|
||||
};
|
||||
|
||||
const result = await runWithOptionalLimiter(async () => {
|
||||
return await withTimeout({
|
||||
promise: evaluateWorkflow(llm, input),
|
||||
timeoutMs: ctx.timeoutMs,
|
||||
label: 'llm-judge:evaluateWorkflow',
|
||||
});
|
||||
}, ctx.llmCallLimiter);
|
||||
|
||||
return [
|
||||
// Core category scores
|
||||
fb(
|
||||
'functionality',
|
||||
result.functionality.score,
|
||||
'metric',
|
||||
formatViolations(result.functionality.violations),
|
||||
),
|
||||
fb(
|
||||
'connections',
|
||||
result.connections.score,
|
||||
'metric',
|
||||
formatViolations(result.connections.violations),
|
||||
),
|
||||
fb(
|
||||
'expressions',
|
||||
result.expressions.score,
|
||||
'metric',
|
||||
formatViolations(result.expressions.violations),
|
||||
),
|
||||
fb(
|
||||
'nodeConfiguration',
|
||||
result.nodeConfiguration.score,
|
||||
'metric',
|
||||
formatViolations(result.nodeConfiguration.violations),
|
||||
),
|
||||
|
||||
// Efficiency with sub-metrics
|
||||
fb(
|
||||
'efficiency',
|
||||
result.efficiency.score,
|
||||
'metric',
|
||||
formatViolations(result.efficiency.violations),
|
||||
),
|
||||
fb('efficiency.redundancyScore', result.efficiency.redundancyScore, 'detail'),
|
||||
fb('efficiency.pathOptimization', result.efficiency.pathOptimization, 'detail'),
|
||||
fb('efficiency.nodeCountEfficiency', result.efficiency.nodeCountEfficiency, 'detail'),
|
||||
|
||||
// Data flow
|
||||
fb(
|
||||
'dataFlow',
|
||||
result.dataFlow.score,
|
||||
'metric',
|
||||
formatViolations(result.dataFlow.violations),
|
||||
),
|
||||
|
||||
// Maintainability with sub-metrics
|
||||
fb(
|
||||
'maintainability',
|
||||
result.maintainability.score,
|
||||
'metric',
|
||||
formatViolations(result.maintainability.violations),
|
||||
),
|
||||
fb('maintainability.nodeNamingQuality', result.maintainability.nodeNamingQuality, 'detail'),
|
||||
fb(
|
||||
'maintainability.workflowOrganization',
|
||||
result.maintainability.workflowOrganization,
|
||||
'detail',
|
||||
),
|
||||
fb('maintainability.modularity', result.maintainability.modularity, 'detail'),
|
||||
|
||||
// Best practices adherence
|
||||
fb(
|
||||
'bestPractices',
|
||||
result.bestPractices.score,
|
||||
'metric',
|
||||
formatViolations(result.bestPractices.violations),
|
||||
),
|
||||
|
||||
// Overall score
|
||||
fb('overallScore', result.overallScore, 'score', result.summary),
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
import type { EvaluationResult, CategoryScore } from './evaluation';
|
||||
import {
|
||||
calculateWeightedScore,
|
||||
generateEvaluationSummary,
|
||||
identifyCriticalIssues,
|
||||
LLM_JUDGE_CATEGORY_WEIGHTS,
|
||||
TOTAL_WEIGHT_WITHOUT_STRUCTURAL,
|
||||
TOTAL_WEIGHT_WITH_STRUCTURAL,
|
||||
} from './workflow-evaluator';
|
||||
|
||||
/**
|
||||
* Creates a minimal category score for testing.
|
||||
*/
|
||||
function createCategoryScore(
|
||||
score: number,
|
||||
violations: CategoryScore['violations'] = [],
|
||||
): CategoryScore {
|
||||
return { score, violations };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a complete evaluation result with all scores set to the same value.
|
||||
*/
|
||||
function createUniformResult(score: number): EvaluationResult {
|
||||
return {
|
||||
overallScore: 0,
|
||||
functionality: createCategoryScore(score),
|
||||
connections: createCategoryScore(score),
|
||||
expressions: createCategoryScore(score),
|
||||
nodeConfiguration: createCategoryScore(score),
|
||||
efficiency: {
|
||||
...createCategoryScore(score),
|
||||
redundancyScore: score,
|
||||
pathOptimization: score,
|
||||
nodeCountEfficiency: score,
|
||||
},
|
||||
dataFlow: createCategoryScore(score),
|
||||
maintainability: {
|
||||
...createCategoryScore(score),
|
||||
nodeNamingQuality: score,
|
||||
workflowOrganization: score,
|
||||
modularity: score,
|
||||
},
|
||||
bestPractices: createCategoryScore(score),
|
||||
structuralSimilarity: {
|
||||
score: 0,
|
||||
violations: [],
|
||||
applicable: false,
|
||||
},
|
||||
summary: '',
|
||||
};
|
||||
}
|
||||
|
||||
describe('workflow-evaluator', () => {
|
||||
describe('calculateWeightedScore', () => {
|
||||
it('should return 1.0 when all scores are perfect', () => {
|
||||
const result = createUniformResult(1.0);
|
||||
expect(calculateWeightedScore(result)).toBeCloseTo(1.0, 5);
|
||||
});
|
||||
|
||||
it('should return 0 when all scores are zero', () => {
|
||||
const result = createUniformResult(0);
|
||||
expect(calculateWeightedScore(result)).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 0.5 when all scores are 0.5', () => {
|
||||
const result = createUniformResult(0.5);
|
||||
expect(calculateWeightedScore(result)).toBeCloseTo(0.5, 5);
|
||||
});
|
||||
|
||||
it('should weight functionality at 25%', () => {
|
||||
const result = createUniformResult(0);
|
||||
result.functionality.score = 1.0;
|
||||
const expected = LLM_JUDGE_CATEGORY_WEIGHTS.functionality / TOTAL_WEIGHT_WITHOUT_STRUCTURAL;
|
||||
expect(calculateWeightedScore(result)).toBeCloseTo(expected, 5);
|
||||
});
|
||||
|
||||
it('should weight connections at 15%', () => {
|
||||
const result = createUniformResult(0);
|
||||
result.connections.score = 1.0;
|
||||
const expected = LLM_JUDGE_CATEGORY_WEIGHTS.connections / TOTAL_WEIGHT_WITHOUT_STRUCTURAL;
|
||||
expect(calculateWeightedScore(result)).toBeCloseTo(expected, 5);
|
||||
});
|
||||
|
||||
it('should include structural similarity when applicable', () => {
|
||||
const result = createUniformResult(1.0);
|
||||
result.structuralSimilarity = {
|
||||
score: 0,
|
||||
violations: [],
|
||||
applicable: true,
|
||||
};
|
||||
// With structural similarity at 0, weighted sum = TOTAL_WEIGHT_WITHOUT_STRUCTURAL
|
||||
const expected = TOTAL_WEIGHT_WITHOUT_STRUCTURAL / TOTAL_WEIGHT_WITH_STRUCTURAL;
|
||||
expect(calculateWeightedScore(result)).toBeCloseTo(expected, 5);
|
||||
});
|
||||
|
||||
it('should not include structural similarity when not applicable', () => {
|
||||
const result = createUniformResult(1.0);
|
||||
result.structuralSimilarity = {
|
||||
score: 0.5,
|
||||
violations: [],
|
||||
applicable: false,
|
||||
};
|
||||
// Should still be 1.0 since structural similarity is not counted
|
||||
expect(calculateWeightedScore(result)).toBeCloseTo(1.0, 5);
|
||||
});
|
||||
|
||||
it('should handle mixed scores correctly', () => {
|
||||
const result = createUniformResult(0);
|
||||
result.functionality.score = 1.0;
|
||||
result.connections.score = 0.8;
|
||||
result.expressions.score = 0.6;
|
||||
result.nodeConfiguration.score = 0.4;
|
||||
result.efficiency.score = 0.2;
|
||||
result.dataFlow.score = 0.0;
|
||||
result.maintainability.score = 1.0;
|
||||
result.bestPractices.score = 0.5;
|
||||
|
||||
const w = LLM_JUDGE_CATEGORY_WEIGHTS;
|
||||
const weightedSum =
|
||||
1.0 * w.functionality +
|
||||
0.8 * w.connections +
|
||||
0.6 * w.expressions +
|
||||
0.4 * w.nodeConfiguration +
|
||||
0.2 * w.efficiency +
|
||||
0.0 * w.dataFlow +
|
||||
1.0 * w.maintainability +
|
||||
0.5 * w.bestPractices;
|
||||
const expected = weightedSum / TOTAL_WEIGHT_WITHOUT_STRUCTURAL;
|
||||
|
||||
expect(calculateWeightedScore(result)).toBeCloseTo(expected, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateEvaluationSummary', () => {
|
||||
it('should list strengths for scores >= 0.8', () => {
|
||||
const result = createUniformResult(0.9);
|
||||
const summary = generateEvaluationSummary(result);
|
||||
|
||||
expect(summary).toContain('strong functional implementation');
|
||||
expect(summary).toContain('well-connected nodes');
|
||||
expect(summary).toContain('correct expression syntax');
|
||||
expect(summary).toContain('well-configured nodes');
|
||||
expect(summary).toContain('proper data flow');
|
||||
expect(summary).toContain('efficient design');
|
||||
expect(summary).toContain('maintainable structure');
|
||||
expect(summary).toContain('follows best practices');
|
||||
});
|
||||
|
||||
it('should list weaknesses for scores < 0.5', () => {
|
||||
const result = createUniformResult(0.3);
|
||||
const summary = generateEvaluationSummary(result);
|
||||
|
||||
expect(summary).toContain('functional gaps');
|
||||
expect(summary).toContain('connection issues');
|
||||
expect(summary).toContain('expression errors');
|
||||
expect(summary).toContain('node configuration issues');
|
||||
expect(summary).toContain('data flow problems');
|
||||
expect(summary).toContain('inefficiencies');
|
||||
expect(summary).toContain('poor maintainability');
|
||||
expect(summary).toContain('deviates from best practices');
|
||||
});
|
||||
|
||||
it('should not list scores between 0.5 and 0.8 as strengths or weaknesses', () => {
|
||||
const result = createUniformResult(0.65);
|
||||
const summary = generateEvaluationSummary(result);
|
||||
|
||||
// Should return the default message since no strengths or weaknesses
|
||||
expect(summary).toBe(
|
||||
'The workflow shows adequate implementation across all evaluated metrics.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle mixed scores', () => {
|
||||
const result = createUniformResult(0.65);
|
||||
result.functionality.score = 0.9; // strength
|
||||
result.connections.score = 0.3; // weakness
|
||||
|
||||
const summary = generateEvaluationSummary(result);
|
||||
|
||||
expect(summary).toContain('strong functional implementation');
|
||||
expect(summary).toContain('connection issues');
|
||||
expect(summary).not.toContain('adequate implementation');
|
||||
});
|
||||
|
||||
it('should format summary with proper grammar', () => {
|
||||
const result = createUniformResult(0.65);
|
||||
result.functionality.score = 0.9;
|
||||
result.connections.score = 0.9;
|
||||
|
||||
const summary = generateEvaluationSummary(result);
|
||||
|
||||
expect(summary).toMatch(/^The workflow demonstrates .+\.$/);
|
||||
expect(summary).toContain(', '); // Multiple strengths should be comma-separated
|
||||
});
|
||||
|
||||
it('should include "Key areas for improvement" for weaknesses', () => {
|
||||
const result = createUniformResult(0.65);
|
||||
result.functionality.score = 0.3;
|
||||
|
||||
const summary = generateEvaluationSummary(result);
|
||||
|
||||
expect(summary).toContain('Key areas for improvement include');
|
||||
});
|
||||
});
|
||||
|
||||
describe('identifyCriticalIssues', () => {
|
||||
it('should return undefined when no critical violations exist', () => {
|
||||
const result = createUniformResult(0.5);
|
||||
result.functionality.violations = [
|
||||
{ type: 'major', description: 'Some major issue', pointsDeducted: 20 },
|
||||
{ type: 'minor', description: 'Some minor issue', pointsDeducted: 5 },
|
||||
];
|
||||
|
||||
expect(identifyCriticalIssues(result)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should extract critical violations from all categories', () => {
|
||||
const result = createUniformResult(0.5);
|
||||
result.functionality.violations = [
|
||||
{ type: 'critical', description: 'Missing trigger', pointsDeducted: 50 },
|
||||
];
|
||||
result.connections.violations = [
|
||||
{ type: 'critical', description: 'Disconnected node', pointsDeducted: 40 },
|
||||
];
|
||||
|
||||
const issues = identifyCriticalIssues(result);
|
||||
|
||||
expect(issues).toHaveLength(2);
|
||||
expect(issues).toContain('[functionality] Missing trigger');
|
||||
expect(issues).toContain('[connections] Disconnected node');
|
||||
});
|
||||
|
||||
it('should only include critical violations, not major or minor', () => {
|
||||
const result = createUniformResult(0.5);
|
||||
result.functionality.violations = [
|
||||
{ type: 'critical', description: 'Critical issue', pointsDeducted: 50 },
|
||||
{ type: 'major', description: 'Major issue', pointsDeducted: 20 },
|
||||
{ type: 'minor', description: 'Minor issue', pointsDeducted: 5 },
|
||||
];
|
||||
|
||||
const issues = identifyCriticalIssues(result);
|
||||
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues).toContain('[functionality] Critical issue');
|
||||
});
|
||||
|
||||
it('should handle multiple critical violations in same category', () => {
|
||||
const result = createUniformResult(0.5);
|
||||
result.functionality.violations = [
|
||||
{ type: 'critical', description: 'First critical', pointsDeducted: 50 },
|
||||
{ type: 'critical', description: 'Second critical', pointsDeducted: 40 },
|
||||
];
|
||||
|
||||
const issues = identifyCriticalIssues(result);
|
||||
|
||||
expect(issues).toHaveLength(2);
|
||||
expect(issues).toContain('[functionality] First critical');
|
||||
expect(issues).toContain('[functionality] Second critical');
|
||||
});
|
||||
|
||||
it('should check all eight evaluation categories', () => {
|
||||
const result = createUniformResult(0.5);
|
||||
|
||||
// Add a critical violation to each category
|
||||
result.functionality.violations = [
|
||||
{ type: 'critical', description: 'func issue', pointsDeducted: 50 },
|
||||
];
|
||||
result.connections.violations = [
|
||||
{ type: 'critical', description: 'conn issue', pointsDeducted: 50 },
|
||||
];
|
||||
result.expressions.violations = [
|
||||
{ type: 'critical', description: 'expr issue', pointsDeducted: 50 },
|
||||
];
|
||||
result.nodeConfiguration.violations = [
|
||||
{ type: 'critical', description: 'config issue', pointsDeducted: 50 },
|
||||
];
|
||||
result.efficiency.violations = [
|
||||
{ type: 'critical', description: 'eff issue', pointsDeducted: 50 },
|
||||
];
|
||||
result.dataFlow.violations = [
|
||||
{ type: 'critical', description: 'flow issue', pointsDeducted: 50 },
|
||||
];
|
||||
result.maintainability.violations = [
|
||||
{ type: 'critical', description: 'maint issue', pointsDeducted: 50 },
|
||||
];
|
||||
result.bestPractices.violations = [
|
||||
{ type: 'critical', description: 'bp issue', pointsDeducted: 50 },
|
||||
];
|
||||
|
||||
const issues = identifyCriticalIssues(result);
|
||||
|
||||
expect(issues).toHaveLength(8);
|
||||
expect(issues).toContain('[functionality] func issue');
|
||||
expect(issues).toContain('[connections] conn issue');
|
||||
expect(issues).toContain('[expressions] expr issue');
|
||||
expect(issues).toContain('[nodeConfiguration] config issue');
|
||||
expect(issues).toContain('[efficiency] eff issue');
|
||||
expect(issues).toContain('[dataFlow] flow issue');
|
||||
expect(issues).toContain('[maintainability] maint issue');
|
||||
expect(issues).toContain('[bestPractices] bp issue');
|
||||
});
|
||||
|
||||
it('should return undefined for empty violations arrays', () => {
|
||||
const result = createUniformResult(1.0);
|
||||
expect(identifyCriticalIssues(result)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
|
||||
import type { EvaluationInput, EvaluationResult } from './evaluation';
|
||||
import {
|
||||
evaluateFunctionality,
|
||||
evaluateConnections,
|
||||
evaluateExpressions,
|
||||
evaluateNodeConfiguration,
|
||||
evaluateEfficiency,
|
||||
evaluateDataFlow,
|
||||
evaluateMaintainability,
|
||||
evaluateBestPractices,
|
||||
} from './evaluators';
|
||||
|
||||
/**
|
||||
* Weights for each LLM-judge evaluation category used in overall score calculation.
|
||||
*
|
||||
* This is evaluator-internal weighting, and is independent from the harness-level
|
||||
* cross-evaluator weighting in `evaluations/score-calculator.ts`.
|
||||
* Exported for use in tests.
|
||||
*/
|
||||
export const LLM_JUDGE_CATEGORY_WEIGHTS = {
|
||||
functionality: 0.25,
|
||||
connections: 0.15,
|
||||
expressions: 0.15,
|
||||
nodeConfiguration: 0.15,
|
||||
efficiency: 0.1,
|
||||
dataFlow: 0.1,
|
||||
maintainability: 0.05,
|
||||
bestPractices: 0.1,
|
||||
structuralSimilarity: 0.05,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* @deprecated Use `LLM_JUDGE_CATEGORY_WEIGHTS` (kept for backwards compatibility within the package).
|
||||
*/
|
||||
export const EVALUATION_WEIGHTS = LLM_JUDGE_CATEGORY_WEIGHTS;
|
||||
|
||||
/**
|
||||
* Total weight when structural similarity is not applicable.
|
||||
*/
|
||||
export const TOTAL_WEIGHT_WITHOUT_STRUCTURAL =
|
||||
LLM_JUDGE_CATEGORY_WEIGHTS.functionality +
|
||||
LLM_JUDGE_CATEGORY_WEIGHTS.connections +
|
||||
LLM_JUDGE_CATEGORY_WEIGHTS.expressions +
|
||||
LLM_JUDGE_CATEGORY_WEIGHTS.nodeConfiguration +
|
||||
LLM_JUDGE_CATEGORY_WEIGHTS.efficiency +
|
||||
LLM_JUDGE_CATEGORY_WEIGHTS.dataFlow +
|
||||
LLM_JUDGE_CATEGORY_WEIGHTS.maintainability +
|
||||
LLM_JUDGE_CATEGORY_WEIGHTS.bestPractices;
|
||||
|
||||
/**
|
||||
* Total weight when structural similarity is applicable.
|
||||
*/
|
||||
export const TOTAL_WEIGHT_WITH_STRUCTURAL =
|
||||
TOTAL_WEIGHT_WITHOUT_STRUCTURAL + LLM_JUDGE_CATEGORY_WEIGHTS.structuralSimilarity;
|
||||
|
||||
/**
|
||||
* Calculate weighted score for the overall evaluation
|
||||
* @param result - Evaluation result with all category scores
|
||||
* @returns Weighted overall score
|
||||
*/
|
||||
export function calculateWeightedScore(result: EvaluationResult): number {
|
||||
const w = LLM_JUDGE_CATEGORY_WEIGHTS;
|
||||
|
||||
// Calculate weighted sum for all categories
|
||||
const weightedSum =
|
||||
result.functionality.score * w.functionality +
|
||||
result.connections.score * w.connections +
|
||||
result.expressions.score * w.expressions +
|
||||
result.nodeConfiguration.score * w.nodeConfiguration +
|
||||
result.efficiency.score * w.efficiency +
|
||||
result.dataFlow.score * w.dataFlow +
|
||||
result.maintainability.score * w.maintainability +
|
||||
result.bestPractices.score * w.bestPractices +
|
||||
(result.structuralSimilarity?.applicable
|
||||
? result.structuralSimilarity.score * w.structuralSimilarity
|
||||
: 0);
|
||||
|
||||
const totalWeight = result.structuralSimilarity?.applicable
|
||||
? TOTAL_WEIGHT_WITH_STRUCTURAL
|
||||
: TOTAL_WEIGHT_WITHOUT_STRUCTURAL;
|
||||
|
||||
return totalWeight > 0 ? weightedSum / totalWeight : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a summary of the evaluation results
|
||||
* @param result - Complete evaluation result
|
||||
* @returns Summary string describing strengths and weaknesses
|
||||
*/
|
||||
export function generateEvaluationSummary(result: EvaluationResult): string {
|
||||
const strengths: string[] = [];
|
||||
const weaknesses: string[] = [];
|
||||
|
||||
// Analyze core metrics
|
||||
if (result.functionality.score >= 0.8) strengths.push('strong functional implementation');
|
||||
else if (result.functionality.score < 0.5) weaknesses.push('functional gaps');
|
||||
|
||||
if (result.connections.score >= 0.8) strengths.push('well-connected nodes');
|
||||
else if (result.connections.score < 0.5) weaknesses.push('connection issues');
|
||||
|
||||
if (result.expressions.score >= 0.8) strengths.push('correct expression syntax');
|
||||
else if (result.expressions.score < 0.5) weaknesses.push('expression errors');
|
||||
|
||||
if (result.nodeConfiguration.score >= 0.8) strengths.push('well-configured nodes');
|
||||
else if (result.nodeConfiguration.score < 0.5) weaknesses.push('node configuration issues');
|
||||
|
||||
if (result.dataFlow.score >= 0.8) strengths.push('proper data flow');
|
||||
else if (result.dataFlow.score < 0.5) weaknesses.push('data flow problems');
|
||||
|
||||
// Analyze new metrics
|
||||
if (result.efficiency.score >= 0.8) strengths.push('efficient design');
|
||||
else if (result.efficiency.score < 0.5) weaknesses.push('inefficiencies');
|
||||
|
||||
if (result.maintainability.score >= 0.8) strengths.push('maintainable structure');
|
||||
else if (result.maintainability.score < 0.5) weaknesses.push('poor maintainability');
|
||||
|
||||
if (result.bestPractices.score >= 0.8) strengths.push('follows best practices');
|
||||
else if (result.bestPractices.score < 0.5) weaknesses.push('deviates from best practices');
|
||||
|
||||
// Create summary
|
||||
let summary = '';
|
||||
if (strengths.length > 0) {
|
||||
summary += `The workflow demonstrates ${strengths.join(', ')}.`;
|
||||
}
|
||||
if (weaknesses.length > 0) {
|
||||
summary += ` Key areas for improvement include ${weaknesses.join(', ')}.`;
|
||||
}
|
||||
if (summary === '') {
|
||||
summary = 'The workflow shows adequate implementation across all evaluated metrics.';
|
||||
}
|
||||
|
||||
return summary.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies critical issues from all evaluation categories
|
||||
* @param result - Complete evaluation result
|
||||
* @returns Array of critical issues if any
|
||||
*/
|
||||
export function identifyCriticalIssues(result: EvaluationResult): string[] | undefined {
|
||||
const criticalIssues: string[] = [];
|
||||
|
||||
// Check all categories for critical violations
|
||||
const categories = [
|
||||
{ name: 'functionality', data: result.functionality },
|
||||
{ name: 'connections', data: result.connections },
|
||||
{ name: 'expressions', data: result.expressions },
|
||||
{ name: 'nodeConfiguration', data: result.nodeConfiguration },
|
||||
{ name: 'efficiency', data: result.efficiency },
|
||||
{ name: 'dataFlow', data: result.dataFlow },
|
||||
{ name: 'maintainability', data: result.maintainability },
|
||||
{ name: 'bestPractices', data: result.bestPractices },
|
||||
];
|
||||
|
||||
for (const category of categories) {
|
||||
if (category.data) {
|
||||
const criticalViolations = category.data.violations.filter((v) => v.type === 'critical');
|
||||
criticalViolations.forEach((v) => {
|
||||
criticalIssues.push(`[${category.name}] ${v.description}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return criticalIssues.length > 0 ? criticalIssues : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main workflow evaluation function that orchestrates all evaluators
|
||||
* Runs all evaluations in parallel for optimal performance
|
||||
* @param llm - Language model to use for evaluation
|
||||
* @param input - Evaluation input containing workflow and prompt
|
||||
* @returns Complete evaluation result with all metrics
|
||||
*/
|
||||
export async function evaluateWorkflow(
|
||||
llm: BaseChatModel,
|
||||
input: EvaluationInput,
|
||||
): Promise<EvaluationResult> {
|
||||
// Run all evaluations in parallel
|
||||
const [
|
||||
functionality,
|
||||
connections,
|
||||
expressions,
|
||||
nodeConfiguration,
|
||||
efficiency,
|
||||
dataFlow,
|
||||
maintainability,
|
||||
bestPractices,
|
||||
] = await Promise.all([
|
||||
// Core evaluations
|
||||
evaluateFunctionality(llm, input),
|
||||
evaluateConnections(llm, input),
|
||||
evaluateExpressions(llm, input),
|
||||
evaluateNodeConfiguration(llm, input),
|
||||
evaluateEfficiency(llm, input),
|
||||
evaluateDataFlow(llm, input),
|
||||
evaluateMaintainability(llm, input),
|
||||
evaluateBestPractices(llm, input),
|
||||
]);
|
||||
|
||||
// Build the evaluation result
|
||||
const evaluationResult: EvaluationResult = {
|
||||
overallScore: 0, // Will be calculated below
|
||||
functionality,
|
||||
connections,
|
||||
expressions,
|
||||
nodeConfiguration,
|
||||
efficiency,
|
||||
dataFlow,
|
||||
maintainability,
|
||||
bestPractices,
|
||||
structuralSimilarity: {
|
||||
violations: [],
|
||||
score: 0,
|
||||
applicable: false, // TODO: Implement structural similarity if reference workflow provided
|
||||
},
|
||||
summary: '', // Will be generated below
|
||||
};
|
||||
|
||||
// Calculate overall score
|
||||
evaluationResult.overallScore = calculateWeightedScore(evaluationResult);
|
||||
|
||||
// Generate summary
|
||||
evaluationResult.summary = generateEvaluationSummary(evaluationResult);
|
||||
|
||||
// Identify critical issues
|
||||
evaluationResult.criticalIssues = identifyCriticalIssues(evaluationResult);
|
||||
|
||||
return evaluationResult;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
|
||||
import type { SimpleWorkflow } from '@/types/workflow';
|
||||
|
||||
import { runJudgePanel, type EvalCriteria } from './judge-panel';
|
||||
import { PAIRWISE_METRICS } from './metrics';
|
||||
import type {
|
||||
DisplayLine,
|
||||
EvaluationContext,
|
||||
Evaluator,
|
||||
Feedback,
|
||||
} from '../../harness/harness-types';
|
||||
|
||||
/**
|
||||
* Options for creating a pairwise evaluator.
|
||||
*/
|
||||
export interface PairwiseEvaluatorOptions {
|
||||
/** Number of judges to run (default: 3) */
|
||||
numJudges?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pairwise evaluator that uses a panel of judges.
|
||||
* Each judge evaluates the workflow against dos/donts criteria.
|
||||
*
|
||||
* @param llm - Language model for evaluation
|
||||
* @param options - Configuration options
|
||||
* @returns An evaluator that produces feedback from pairwise evaluation
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const evaluator = createPairwiseEvaluator(llm, { numJudges: 3 });
|
||||
* const feedback = await evaluator.evaluate(workflow, { dos, donts });
|
||||
* ```
|
||||
*/
|
||||
export function createPairwiseEvaluator(
|
||||
llm: BaseChatModel,
|
||||
options?: PairwiseEvaluatorOptions,
|
||||
): Evaluator<EvaluationContext> {
|
||||
const numJudges = options?.numJudges ?? 3;
|
||||
|
||||
return {
|
||||
name: 'pairwise',
|
||||
|
||||
async evaluate(workflow: SimpleWorkflow, ctx: EvaluationContext): Promise<Feedback[]> {
|
||||
const evalCriteria: EvalCriteria = {
|
||||
dos: ctx?.dos,
|
||||
donts: ctx?.donts,
|
||||
};
|
||||
|
||||
const result = await runJudgePanel(llm, workflow, evalCriteria, numJudges, {
|
||||
llmCallLimiter: ctx.llmCallLimiter,
|
||||
timeoutMs: ctx.timeoutMs,
|
||||
});
|
||||
|
||||
const feedback: Feedback[] = [];
|
||||
|
||||
const totalViolations = result.judgeResults.reduce((sum, r) => sum + r.violations.length, 0);
|
||||
const totalPasses = result.judgeResults.reduce((sum, r) => sum + r.passes.length, 0);
|
||||
|
||||
// Primary metrics
|
||||
feedback.push({
|
||||
evaluator: 'pairwise',
|
||||
metric: PAIRWISE_METRICS.PAIRWISE_PRIMARY,
|
||||
score: result.majorityPass ? 1 : 0,
|
||||
kind: 'score',
|
||||
comment: `${result.primaryPasses}/${numJudges} judges passed`,
|
||||
});
|
||||
feedback.push({
|
||||
evaluator: 'pairwise',
|
||||
metric: PAIRWISE_METRICS.PAIRWISE_DIAGNOSTIC,
|
||||
score: result.avgDiagnosticScore,
|
||||
kind: 'metric',
|
||||
});
|
||||
feedback.push({
|
||||
evaluator: 'pairwise',
|
||||
metric: PAIRWISE_METRICS.PAIRWISE_JUDGES_PASSED,
|
||||
score: result.primaryPasses,
|
||||
kind: 'detail',
|
||||
});
|
||||
feedback.push({
|
||||
evaluator: 'pairwise',
|
||||
metric: PAIRWISE_METRICS.PAIRWISE_TOTAL_PASSES,
|
||||
score: totalPasses,
|
||||
kind: 'detail',
|
||||
});
|
||||
feedback.push({
|
||||
evaluator: 'pairwise',
|
||||
metric: PAIRWISE_METRICS.PAIRWISE_TOTAL_VIOLATIONS,
|
||||
score: totalViolations,
|
||||
kind: 'detail',
|
||||
});
|
||||
|
||||
// Individual judge results
|
||||
for (let i = 0; i < result.judgeResults.length; i++) {
|
||||
const judge = result.judgeResults[i];
|
||||
const violationSummary =
|
||||
judge.violations.length > 0
|
||||
? judge.violations.map((v) => `[${v.rule}] ${v.justification}`).join('; ')
|
||||
: undefined;
|
||||
|
||||
// Pre-format display lines for verbose logging
|
||||
const displayLines: DisplayLine[] = [];
|
||||
if (judge.violations.length > 0) {
|
||||
for (const v of judge.violations) {
|
||||
displayLines.push({ text: `[${v.rule}]`, color: 'yellow' });
|
||||
displayLines.push({ text: v.justification, color: 'red' });
|
||||
}
|
||||
}
|
||||
|
||||
feedback.push({
|
||||
evaluator: 'pairwise',
|
||||
metric: `judge${i + 1}`,
|
||||
score: judge.primaryPass ? 1 : 0,
|
||||
kind: 'detail',
|
||||
comment: violationSummary,
|
||||
details: displayLines.length > 0 ? { displayLines } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return feedback;
|
||||
},
|
||||
};
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import type { SimpleWorkflow } from '@/types/workflow';
|
||||
|
||||
import { evaluateWorkflowPairwise, type PairwiseEvaluationInput } from './judge-chain';
|
||||
import * as baseEvaluator from '../llm-judge/evaluators/base';
|
||||
|
||||
// Mock the base evaluator module
|
||||
jest.mock('../llm-judge/evaluators/base', () => ({
|
||||
createEvaluatorChain: jest.fn(),
|
||||
invokeEvaluatorChain: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('evaluateWorkflowPairwise', () => {
|
||||
const mockLlm = mock<BaseChatModel>();
|
||||
|
||||
const mockWorkflow: SimpleWorkflow = {
|
||||
nodes: [],
|
||||
connections: {},
|
||||
name: 'Test Workflow',
|
||||
};
|
||||
|
||||
const input: PairwiseEvaluationInput = {
|
||||
evalCriteria: {
|
||||
dos: 'Do this',
|
||||
donts: "Don't do that",
|
||||
},
|
||||
workflowJSON: mockWorkflow,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return structured result from invokeEvaluatorChain', async () => {
|
||||
const mockResult = {
|
||||
violations: [],
|
||||
passes: [
|
||||
{ rule: 'Do this', justification: 'Done' },
|
||||
{ rule: "Don't do that", justification: 'Not done' },
|
||||
],
|
||||
};
|
||||
|
||||
jest.mocked(baseEvaluator.invokeEvaluatorChain).mockResolvedValue(mockResult);
|
||||
|
||||
const result = await evaluateWorkflowPairwise(mockLlm, input);
|
||||
|
||||
expect(result).toEqual({
|
||||
...mockResult,
|
||||
primaryPass: true,
|
||||
diagnosticScore: 1,
|
||||
});
|
||||
expect(baseEvaluator.createEvaluatorChain).toHaveBeenCalledWith(
|
||||
mockLlm,
|
||||
expect.anything(), // schema
|
||||
expect.stringContaining('expert n8n workflow auditor'), // system prompt
|
||||
expect.stringContaining('<task_context>'), // human template
|
||||
);
|
||||
expect(baseEvaluator.invokeEvaluatorChain).toHaveBeenCalledWith(
|
||||
undefined, // The chain (undefined because createEvaluatorChain mock returns undefined)
|
||||
expect.objectContaining({
|
||||
userPrompt: expect.stringContaining('<do>'),
|
||||
generatedWorkflow: input.workflowJSON,
|
||||
}),
|
||||
undefined, // config parameter (not passed in this test)
|
||||
);
|
||||
});
|
||||
|
||||
it('should calculate diagnosticScore correctly with violations', async () => {
|
||||
const mockResult = {
|
||||
violations: [{ rule: "Don't do that", justification: 'Did it' }],
|
||||
passes: [{ rule: 'Do this', justification: 'Done' }],
|
||||
};
|
||||
|
||||
jest.mocked(baseEvaluator.invokeEvaluatorChain).mockResolvedValue(mockResult);
|
||||
|
||||
const result = await evaluateWorkflowPairwise(mockLlm, input);
|
||||
|
||||
expect(result.primaryPass).toBe(false);
|
||||
expect(result.diagnosticScore).toBe(0.5);
|
||||
});
|
||||
|
||||
it('should return diagnosticScore 0 when no rules evaluated', async () => {
|
||||
const mockResult = {
|
||||
violations: [],
|
||||
passes: [],
|
||||
};
|
||||
|
||||
jest.mocked(baseEvaluator.invokeEvaluatorChain).mockResolvedValue(mockResult);
|
||||
|
||||
const result = await evaluateWorkflowPairwise(mockLlm, input);
|
||||
|
||||
expect(result.primaryPass).toBe(true);
|
||||
expect(result.diagnosticScore).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { RunnableConfig } from '@langchain/core/runnables';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { EvalCriteria } from './judge-panel';
|
||||
import { prompt } from '../../../src/prompts/builder';
|
||||
import type { SimpleWorkflow } from '../../../src/types/workflow';
|
||||
import { createEvaluatorChain, invokeEvaluatorChain } from '../llm-judge/evaluators/base';
|
||||
|
||||
export interface PairwiseEvaluationInput {
|
||||
evalCriteria: EvalCriteria;
|
||||
workflowJSON: SimpleWorkflow;
|
||||
}
|
||||
|
||||
const pairwiseEvaluationLLMResultSchema = z.object({
|
||||
violations: z
|
||||
.array(
|
||||
z.object({
|
||||
rule: z.string(),
|
||||
justification: z.string(),
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
'List of criteria that were violated, this must be passed as a JSON array not a string.',
|
||||
),
|
||||
passes: z
|
||||
.array(
|
||||
z.object({
|
||||
rule: z.string(),
|
||||
justification: z.string(),
|
||||
}),
|
||||
)
|
||||
.describe('The criterion that was passed, this must be passed as a JSON array not a string.'),
|
||||
});
|
||||
|
||||
export type PairwiseEvaluationResult = z.infer<typeof pairwiseEvaluationLLMResultSchema> & {
|
||||
/** True only if ALL criteria passed (no violations) */
|
||||
primaryPass: boolean;
|
||||
/** Ratio of passed criteria to total criteria (0-1) */
|
||||
diagnosticScore: number;
|
||||
};
|
||||
|
||||
const EVALUATOR_SYSTEM_PROMPT = prompt()
|
||||
.section(
|
||||
'role',
|
||||
'You are an expert n8n workflow auditor. Your task is to strictly evaluate a candidate workflow against a provided set of requirements.',
|
||||
)
|
||||
.section(
|
||||
'role_definition',
|
||||
`- You are objective, precise, and evidence-based.
|
||||
- You do not assume functionality that is not explicitly configured in the JSON.
|
||||
- You verify every claim against the actual node configurations, connections, and parameters.`,
|
||||
)
|
||||
.section(
|
||||
'clarifications',
|
||||
`When evaluating criteria about "provider-specific nodes" or "using a specific AI provider":
|
||||
|
||||
- Provider-specific nodes (e.g., n8n-nodes-langchain.openAi, n8n-nodes-langchain.anthropic) are standalone nodes that directly call a provider's API.
|
||||
- Chat model sub-nodes (e.g., @n8n/n8n-nodes-langchain.lmChatAnthropic, @n8n/n8n-nodes-langchain.lmChatOpenAi) are NOT provider-specific nodes. They are required infrastructure for connecting generic nodes like the AI Agent to a language model.
|
||||
|
||||
If a criterion says "do not use provider-specific nodes" or similar, the presence of lmChat* sub-nodes should NOT count as a violation - these are necessary connectors, not provider-specific workflow nodes.
|
||||
|
||||
When evaluating whether a specific node type has been used:
|
||||
- The "@n8n/" prefix in node types is OPTIONAL - ignore it when comparing
|
||||
- "@n8n/n8n-nodes-langchain.chatTrigger" and "n8n-nodes-langchain.chatTrigger" are the SAME node type
|
||||
- This applies regardless of which form appears in the criteria or the workflow`,
|
||||
)
|
||||
.section(
|
||||
'constraints',
|
||||
`- Judge ONLY against the provided evaluation criteria. Do not apply external "best practices" unless explicitly asked.
|
||||
- If a criterion is "not verifiable" from the JSON alone (e.g., requires runtime data), mark it as a violation and explain why.
|
||||
- For every pass or violation, you MUST cite the specific node name or parameter that serves as evidence.
|
||||
- Do not hallucinate nodes or parameters.`,
|
||||
)
|
||||
.build();
|
||||
|
||||
const humanTemplate = prompt()
|
||||
.section(
|
||||
'task_context',
|
||||
'Analyze the following n8n workflow against the provided checklist of criteria.',
|
||||
)
|
||||
.section('evaluation_criteria', '{userPrompt}')
|
||||
.section('workflow_candidate', '{generatedWorkflow}')
|
||||
.section(
|
||||
'instructions',
|
||||
`1. Read the <evaluation_criteria> carefully. It contains <do> and <dont> criteria.
|
||||
2. For each criterion:
|
||||
- Search for evidence in the <workflow_candidate>.
|
||||
- Classify as PASS or VIOLATION using the rules below.
|
||||
- Provide a clear 'justification' citing the evidence (e.g., "Node 'HTTP Request' has method set to 'GET'").
|
||||
3. Output the result as a structured JSON with 'violations' and 'passes'.`,
|
||||
)
|
||||
.section(
|
||||
'classification_rules',
|
||||
`CRITICAL: Understand how to classify each criterion correctly:
|
||||
|
||||
For <do> criteria (positive requirements like "Use X" or "Include Y"):
|
||||
- PASS: The required element IS present in the workflow
|
||||
- VIOLATION: The required element is NOT present in the workflow
|
||||
|
||||
For <dont> criteria (anti-patterns to avoid):
|
||||
- PASS: The forbidden element is NOT present (the anti-pattern was avoided)
|
||||
- VIOLATION: The forbidden element IS present (the anti-pattern was used)
|
||||
|
||||
Example: <dont>Use code node to organize data</dont>
|
||||
- If NO code node exists for organizing data → PASS (anti-pattern avoided)
|
||||
- If a code node IS used for organizing data → VIOLATION (anti-pattern present)`,
|
||||
)
|
||||
.build();
|
||||
|
||||
export async function evaluateWorkflowPairwise(
|
||||
llm: BaseChatModel,
|
||||
input: PairwiseEvaluationInput,
|
||||
config?: RunnableConfig,
|
||||
): Promise<PairwiseEvaluationResult> {
|
||||
const dos = input.evalCriteria?.dos ?? '';
|
||||
const donts = input.evalCriteria?.donts ?? '';
|
||||
|
||||
const doLines = dos.split('\n').filter((line) => line.trim().length > 0);
|
||||
const dontLines = donts.split('\n').filter((line) => line.trim().length > 0);
|
||||
|
||||
const criteriaBuilder = prompt({ format: 'xml' });
|
||||
for (const line of doLines) {
|
||||
criteriaBuilder.section('do', line.trim());
|
||||
}
|
||||
for (const line of dontLines) {
|
||||
criteriaBuilder.section('dont', line.trim());
|
||||
}
|
||||
const criteriaList = criteriaBuilder.build();
|
||||
|
||||
const chain = createEvaluatorChain(
|
||||
llm,
|
||||
pairwiseEvaluationLLMResultSchema,
|
||||
EVALUATOR_SYSTEM_PROMPT,
|
||||
humanTemplate,
|
||||
);
|
||||
|
||||
const result = await invokeEvaluatorChain(
|
||||
chain,
|
||||
{
|
||||
userPrompt: criteriaList,
|
||||
generatedWorkflow: input.workflowJSON,
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
const totalRules = result.passes.length + result.violations.length;
|
||||
const diagnosticScore = totalRules > 0 ? result.passes.length / totalRules : 0;
|
||||
const primaryPass = result.violations.length === 0;
|
||||
|
||||
return {
|
||||
...result,
|
||||
primaryPass,
|
||||
diagnosticScore,
|
||||
};
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import pLimit from 'p-limit';
|
||||
|
||||
import type { SimpleWorkflow } from '@/types/workflow';
|
||||
|
||||
import { runJudgePanel } from './judge-panel';
|
||||
|
||||
const mockEvaluateWorkflowPairwise = jest.fn();
|
||||
|
||||
jest.mock('./judge-chain', () => ({
|
||||
evaluateWorkflowPairwise: (...args: unknown[]): unknown => mockEvaluateWorkflowPairwise(...args),
|
||||
}));
|
||||
|
||||
function createMockWorkflow(name = 'Test Workflow'): SimpleWorkflow {
|
||||
return { name, nodes: [], connections: {} };
|
||||
}
|
||||
|
||||
describe('runJudgePanel()', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should respect llmCallLimiter concurrency', async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
|
||||
mockEvaluateWorkflowPairwise.mockImplementation(async () => {
|
||||
active++;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
active--;
|
||||
return { violations: [], passes: [], primaryPass: true, diagnosticScore: 1 };
|
||||
});
|
||||
|
||||
const llm = mock<BaseChatModel>();
|
||||
const workflow = createMockWorkflow();
|
||||
|
||||
await runJudgePanel(llm, workflow, { dos: 'Do X', donts: 'Do not Y' }, 5, {
|
||||
llmCallLimiter: pLimit(2),
|
||||
});
|
||||
|
||||
expect(maxActive).toBeLessThanOrEqual(2);
|
||||
expect(mockEvaluateWorkflowPairwise).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { RunnableConfig } from '@langchain/core/runnables';
|
||||
|
||||
import { evaluateWorkflowPairwise, type PairwiseEvaluationResult } from './judge-chain';
|
||||
import type { SimpleWorkflow } from '../../../src/types/workflow';
|
||||
import {
|
||||
getTracingCallbacks,
|
||||
runWithOptionalLimiter,
|
||||
withTimeout,
|
||||
} from '../../harness/evaluation-helpers';
|
||||
import type { EvaluationContext } from '../../harness/harness-types';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
/** Evaluation criteria - at least one of dos or donts should be provided */
|
||||
export interface EvalCriteria {
|
||||
dos?: string;
|
||||
donts?: string;
|
||||
}
|
||||
|
||||
export interface JudgePanelTiming {
|
||||
/** Total time for all judges in milliseconds */
|
||||
totalMs: number;
|
||||
/** Time per judge in milliseconds */
|
||||
perJudgeMs: number[];
|
||||
}
|
||||
|
||||
export interface JudgePanelResult {
|
||||
judgeResults: PairwiseEvaluationResult[];
|
||||
primaryPasses: number;
|
||||
majorityPass: boolean;
|
||||
avgDiagnosticScore: number;
|
||||
/** Timing information (only populated when timing is tracked) */
|
||||
timing?: JudgePanelTiming;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Calculate minimum judges needed for majority (e.g., 2 for 3 judges, 3 for 5 judges)
|
||||
* @param numJudges - Number of judges (must be >= 1)
|
||||
* @throws Error if numJudges < 1
|
||||
*/
|
||||
export function getMajorityThreshold(numJudges: number): number {
|
||||
if (numJudges < 1) {
|
||||
throw new Error(`getMajorityThreshold requires numJudges >= 1, got ${numJudges}`);
|
||||
}
|
||||
return Math.ceil(numJudges / 2);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Judge Panel Execution
|
||||
// ============================================================================
|
||||
|
||||
export interface JudgePanelOptions {
|
||||
/** Experiment name for metadata */
|
||||
experimentName?: string;
|
||||
/** Optional limiter for LLM calls (shared across harness) */
|
||||
llmCallLimiter?: EvaluationContext['llmCallLimiter'];
|
||||
/** Optional timeout for each judge call */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a panel of judges on a workflow.
|
||||
* Executes judges in parallel and aggregates their results.
|
||||
*
|
||||
* @param llm - Language model for evaluation
|
||||
* @param workflow - Workflow to evaluate
|
||||
* @param evalCriteria - Evaluation criteria (dos/donts)
|
||||
* @param numJudges - Number of judges to run
|
||||
* @param options - Optional metadata for tracing
|
||||
* @returns Aggregated judge panel results
|
||||
*/
|
||||
export async function runJudgePanel(
|
||||
llm: BaseChatModel,
|
||||
workflow: SimpleWorkflow,
|
||||
evalCriteria: EvalCriteria,
|
||||
numJudges: number,
|
||||
options?: JudgePanelOptions,
|
||||
): Promise<JudgePanelResult> {
|
||||
const { experimentName, llmCallLimiter, timeoutMs } = options ?? {};
|
||||
const panelStartTime = Date.now();
|
||||
|
||||
// Bridge LangSmith traceable context to LangChain callbacks
|
||||
const callbacks = await getTracingCallbacks();
|
||||
|
||||
// Run all judges in parallel, tracking timing for each
|
||||
const judgeTimings: number[] = [];
|
||||
const judgeResults = await Promise.all(
|
||||
Array.from({ length: numJudges }, async (_, judgeIndex) => {
|
||||
const runJudge = async (): Promise<PairwiseEvaluationResult> => {
|
||||
const judgeStartTime = Date.now();
|
||||
|
||||
// Build config with callbacks for proper trace context propagation
|
||||
const config: RunnableConfig = {
|
||||
runName: `judge_${judgeIndex + 1}`,
|
||||
metadata: {
|
||||
...(experimentName && { experiment_name: experimentName }),
|
||||
},
|
||||
callbacks,
|
||||
};
|
||||
|
||||
const result = await withTimeout({
|
||||
promise: evaluateWorkflowPairwise(llm, { workflowJSON: workflow, evalCriteria }, config),
|
||||
timeoutMs,
|
||||
label: `pairwise:judge${judgeIndex + 1}`,
|
||||
});
|
||||
judgeTimings[judgeIndex] = Date.now() - judgeStartTime;
|
||||
return result;
|
||||
};
|
||||
|
||||
return await runWithOptionalLimiter(runJudge, llmCallLimiter);
|
||||
}),
|
||||
);
|
||||
|
||||
const totalMs = Date.now() - panelStartTime;
|
||||
const aggregated = aggregateJudgeResults(judgeResults, numJudges);
|
||||
|
||||
return {
|
||||
...aggregated,
|
||||
timing: {
|
||||
totalMs,
|
||||
perJudgeMs: judgeTimings,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate results from multiple judges into summary metrics.
|
||||
*/
|
||||
export function aggregateJudgeResults(
|
||||
judgeResults: PairwiseEvaluationResult[],
|
||||
numJudges: number,
|
||||
): JudgePanelResult {
|
||||
const primaryPasses = judgeResults.filter((r) => r.primaryPass).length;
|
||||
const majorityPass = primaryPasses >= getMajorityThreshold(numJudges);
|
||||
const avgDiagnosticScore =
|
||||
judgeResults.reduce((sum, r) => sum + r.diagnosticScore, 0) / numJudges;
|
||||
|
||||
return {
|
||||
judgeResults,
|
||||
primaryPasses,
|
||||
majorityPass,
|
||||
avgDiagnosticScore,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const PAIRWISE_METRICS = {
|
||||
PAIRWISE_DIAGNOSTIC: 'pairwise_diagnostic',
|
||||
PAIRWISE_JUDGES_PASSED: 'pairwise_judges_passed',
|
||||
PAIRWISE_PRIMARY: 'pairwise_primary',
|
||||
PAIRWISE_TOTAL_PASSES: 'pairwise_total_passes',
|
||||
PAIRWISE_TOTAL_VIOLATIONS: 'pairwise_total_violations',
|
||||
} as const;
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import type { SimpleWorkflow } from '@/types/workflow';
|
||||
|
||||
import type { EvaluationContext, Evaluator, Feedback } from '../../harness/harness-types';
|
||||
import { programmaticEvaluation } from '../../programmatic/programmatic-evaluation';
|
||||
|
||||
/**
|
||||
* Format violations as a comment string.
|
||||
*/
|
||||
function formatViolations(
|
||||
violations: Array<{ type: string; description: string }>,
|
||||
): string | undefined {
|
||||
if (!violations || violations.length === 0) return undefined;
|
||||
return violations.map((v) => `[${v.type}] ${v.description}`).join('; ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a programmatic evaluator that runs rule-based checks.
|
||||
* This doesn't require an LLM - it uses static analysis.
|
||||
*
|
||||
* @param nodeTypes - Node type descriptions for validation
|
||||
* @returns An evaluator that produces feedback from programmatic checks
|
||||
*/
|
||||
export function createProgrammaticEvaluator(
|
||||
nodeTypes: INodeTypeDescription[],
|
||||
): Evaluator<EvaluationContext> {
|
||||
const fb = (
|
||||
metric: string,
|
||||
score: number,
|
||||
kind: Feedback['kind'],
|
||||
comment?: string,
|
||||
): Feedback => ({
|
||||
evaluator: 'programmatic',
|
||||
metric,
|
||||
score,
|
||||
kind,
|
||||
...(comment ? { comment } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
name: 'programmatic',
|
||||
|
||||
async evaluate(workflow: SimpleWorkflow, ctx: EvaluationContext): Promise<Feedback[]> {
|
||||
const result = await programmaticEvaluation(
|
||||
{
|
||||
userPrompt: ctx.prompt,
|
||||
generatedWorkflow: workflow,
|
||||
referenceWorkflows: ctx.referenceWorkflows,
|
||||
generatedCode: ctx.generatedCode,
|
||||
},
|
||||
nodeTypes,
|
||||
);
|
||||
|
||||
const feedback: Feedback[] = [
|
||||
// Overall programmatic score (scoring)
|
||||
fb('overall', result.overallScore, 'score'),
|
||||
// Stable category metrics (dashboard)
|
||||
fb(
|
||||
'connections',
|
||||
result.connections.score,
|
||||
'metric',
|
||||
formatViolations(result.connections.violations),
|
||||
),
|
||||
fb('nodes', result.nodes.score, 'metric', formatViolations(result.nodes.violations)),
|
||||
fb('trigger', result.trigger.score, 'metric', formatViolations(result.trigger.violations)),
|
||||
fb(
|
||||
'agentPrompt',
|
||||
result.agentPrompt.score,
|
||||
'metric',
|
||||
formatViolations(result.agentPrompt.violations),
|
||||
),
|
||||
fb('tools', result.tools.score, 'metric', formatViolations(result.tools.violations)),
|
||||
fb('fromAi', result.fromAi.score, 'metric', formatViolations(result.fromAi.violations)),
|
||||
fb(
|
||||
'credentials',
|
||||
result.credentials.score,
|
||||
'metric',
|
||||
formatViolations(result.credentials.violations),
|
||||
),
|
||||
fb(
|
||||
'graphValidation',
|
||||
result.graphValidation.score,
|
||||
'metric',
|
||||
formatViolations(result.graphValidation.violations),
|
||||
),
|
||||
fb(
|
||||
'parameters',
|
||||
result.parameters.score,
|
||||
'metric',
|
||||
formatViolations(result.parameters.violations),
|
||||
),
|
||||
];
|
||||
|
||||
// Similarity check (if reference workflow provided)
|
||||
if (result.similarity !== null && result.similarity !== undefined) {
|
||||
feedback.push(
|
||||
fb(
|
||||
'similarity',
|
||||
result.similarity.score,
|
||||
'metric',
|
||||
formatViolations(result.similarity.violations),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return feedback;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { HumanMessage } from '@langchain/core/messages';
|
||||
|
||||
import type { ResponderEvalCriteria } from './responder-judge.prompt';
|
||||
import { buildResponderJudgePrompt } from './responder-judge.prompt';
|
||||
import { runWithOptionalLimiter, withTimeout } from '../../harness/evaluation-helpers';
|
||||
import type { EvaluationContext, Evaluator, Feedback } from '../../harness/harness-types';
|
||||
import { DEFAULTS } from '../../support/constants';
|
||||
|
||||
const EVALUATOR_NAME = 'responder-judge';
|
||||
|
||||
export interface ResponderEvaluatorOptions {
|
||||
/** Number of judges to run in parallel (default: DEFAULTS.NUM_JUDGES) */
|
||||
numJudges?: number;
|
||||
}
|
||||
|
||||
interface ResponderJudgeDimension {
|
||||
score: number;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
interface ResponderJudgeResult {
|
||||
relevance: ResponderJudgeDimension;
|
||||
accuracy: ResponderJudgeDimension;
|
||||
completeness: ResponderJudgeDimension;
|
||||
clarity: ResponderJudgeDimension;
|
||||
tone: ResponderJudgeDimension;
|
||||
criteriaMatch: ResponderJudgeDimension;
|
||||
forbiddenPhrases: ResponderJudgeDimension;
|
||||
overallScore: number;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for responder evaluation, extends standard EvaluationContext
|
||||
* with the responder output and per-example criteria.
|
||||
*/
|
||||
export interface ResponderEvaluationContext extends EvaluationContext {
|
||||
/** The text output from the responder agent */
|
||||
responderOutput: string;
|
||||
/** Per-example evaluation criteria from the dataset */
|
||||
responderEvals: ResponderEvalCriteria;
|
||||
/** The actual workflow JSON for accuracy verification */
|
||||
workflowJSON?: unknown;
|
||||
}
|
||||
|
||||
function isResponderContext(ctx: EvaluationContext): ctx is ResponderEvaluationContext {
|
||||
return (
|
||||
'responderOutput' in ctx &&
|
||||
typeof (ctx as ResponderEvaluationContext).responderOutput === 'string' &&
|
||||
'responderEvals' in ctx &&
|
||||
typeof (ctx as ResponderEvaluationContext).responderEvals === 'object'
|
||||
);
|
||||
}
|
||||
|
||||
function parseJudgeResponse(content: string): ResponderJudgeResult {
|
||||
// Extract JSON from markdown code block if present
|
||||
const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||
const jsonStr = jsonMatch ? jsonMatch[1].trim() : content.trim();
|
||||
try {
|
||||
return JSON.parse(jsonStr) as ResponderJudgeResult;
|
||||
} catch {
|
||||
throw new Error(`Failed to parse judge response as JSON: ${jsonStr.slice(0, 100)}...`);
|
||||
}
|
||||
}
|
||||
|
||||
const DIMENSION_KEYS = [
|
||||
'relevance',
|
||||
'accuracy',
|
||||
'completeness',
|
||||
'clarity',
|
||||
'tone',
|
||||
'criteriaMatch',
|
||||
'forbiddenPhrases',
|
||||
] as const;
|
||||
|
||||
const fb = (metric: string, score: number, kind: Feedback['kind'], comment?: string): Feedback => ({
|
||||
evaluator: EVALUATOR_NAME,
|
||||
metric,
|
||||
score,
|
||||
kind,
|
||||
...(comment ? { comment } : {}),
|
||||
});
|
||||
|
||||
/** Run a single judge invocation and return the parsed result. */
|
||||
async function runSingleJudge(
|
||||
llm: BaseChatModel,
|
||||
ctx: ResponderEvaluationContext,
|
||||
judgeIndex: number,
|
||||
): Promise<ResponderJudgeResult> {
|
||||
const judgePrompt = buildResponderJudgePrompt({
|
||||
userPrompt: ctx.prompt,
|
||||
responderOutput: ctx.responderOutput,
|
||||
evalCriteria: ctx.responderEvals,
|
||||
workflowJSON: ctx.workflowJSON,
|
||||
});
|
||||
|
||||
return await runWithOptionalLimiter(async () => {
|
||||
const response = await withTimeout({
|
||||
promise: llm.invoke([new HumanMessage(judgePrompt)], {
|
||||
runName: `responder_judge_${judgeIndex + 1}`,
|
||||
}),
|
||||
timeoutMs: ctx.timeoutMs,
|
||||
label: `responder-judge:evaluate:judge_${judgeIndex + 1}`,
|
||||
});
|
||||
|
||||
const content =
|
||||
typeof response.content === 'string' ? response.content : JSON.stringify(response.content);
|
||||
|
||||
return parseJudgeResponse(content);
|
||||
}, ctx.llmCallLimiter);
|
||||
}
|
||||
|
||||
/** Aggregate results from multiple judges into feedback items. */
|
||||
function aggregateResults(results: ResponderJudgeResult[], numJudges: number): Feedback[] {
|
||||
const feedback: Feedback[] = [];
|
||||
|
||||
// Per-dimension averaged metrics
|
||||
for (const key of DIMENSION_KEYS) {
|
||||
const avgScore =
|
||||
results.reduce((sum, r) => {
|
||||
const dimension = r[key];
|
||||
return sum + (dimension?.score ?? 0);
|
||||
}, 0) / numJudges;
|
||||
const comments = results
|
||||
.map((r, i) => {
|
||||
const dimension = r[key];
|
||||
return `[Judge ${i + 1}] ${dimension?.comment ?? 'No comment'}`;
|
||||
})
|
||||
.join(' | ');
|
||||
feedback.push(fb(key, avgScore, 'metric', comments));
|
||||
}
|
||||
|
||||
// Aggregated overall score
|
||||
const avgOverall = results.reduce((sum, r) => sum + r.overallScore, 0) / numJudges;
|
||||
feedback.push(
|
||||
fb(
|
||||
'overallScore',
|
||||
avgOverall,
|
||||
'score',
|
||||
`${numJudges}/${numJudges} judges averaged ${avgOverall.toFixed(2)}`,
|
||||
),
|
||||
);
|
||||
|
||||
// Per-judge detail items
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const r = results[i];
|
||||
feedback.push(fb(`judge${i + 1}`, r.overallScore, 'detail', `Judge ${i + 1}: ${r.summary}`));
|
||||
}
|
||||
|
||||
return feedback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a responder LLM-judge evaluator.
|
||||
*
|
||||
* Uses an LLM to evaluate responder output against per-example criteria
|
||||
* from the dataset. The evaluator expects a ResponderEvaluationContext
|
||||
* with `responderOutput` and `responderEvals` fields.
|
||||
*
|
||||
* When `numJudges > 1`, runs multiple judge calls in parallel and aggregates
|
||||
* dimension scores (averaged) and per-judge detail feedback.
|
||||
*
|
||||
* @param llm - The LLM to use for judging
|
||||
* @param options - Optional configuration (e.g. numJudges)
|
||||
* @returns An evaluator that produces feedback for responder output
|
||||
*/
|
||||
export function createResponderEvaluator(
|
||||
llm: BaseChatModel,
|
||||
options?: ResponderEvaluatorOptions,
|
||||
): Evaluator<EvaluationContext> {
|
||||
const numJudges = options?.numJudges ?? DEFAULTS.NUM_JUDGES;
|
||||
|
||||
return {
|
||||
name: EVALUATOR_NAME,
|
||||
|
||||
async evaluate(_workflow, ctx: EvaluationContext): Promise<Feedback[]> {
|
||||
if (!isResponderContext(ctx)) {
|
||||
return [
|
||||
fb(
|
||||
'error',
|
||||
0,
|
||||
'score',
|
||||
'Missing responderOutput or responderEvals in evaluation context',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: numJudges }, async (_, i) => await runSingleJudge(llm, ctx, i)),
|
||||
);
|
||||
|
||||
return aggregateResults(results, numJudges);
|
||||
},
|
||||
};
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import { prompt } from '@/prompts/builder';
|
||||
|
||||
/**
|
||||
* Responder evaluation types that map to different evaluation strategies.
|
||||
*
|
||||
* Currently all responses happen after a full workflow generation.
|
||||
* Plan mode types can be added later when that feature is implemented.
|
||||
*/
|
||||
export type ResponderEvalType = 'workflow_summary' | 'datatable_instructions' | 'general_response';
|
||||
|
||||
export interface ResponderEvalCriteria {
|
||||
type: ResponderEvalType;
|
||||
criteria: string;
|
||||
}
|
||||
|
||||
const FORBIDDEN_PHRASES = [
|
||||
'activate workflow',
|
||||
'activate the workflow',
|
||||
'click the activate button',
|
||||
];
|
||||
|
||||
function buildForbiddenPhrasesSection(): string {
|
||||
return FORBIDDEN_PHRASES.map((p) => `- "${p}"`).join('\n');
|
||||
}
|
||||
|
||||
function buildTypeSpecificGuidance(evalType: ResponderEvalType): string {
|
||||
switch (evalType) {
|
||||
case 'workflow_summary':
|
||||
return `
|
||||
Additionally evaluate:
|
||||
- Does the response accurately describe the workflow that was built?
|
||||
- Are all key nodes and their purposes mentioned?
|
||||
- Is the explanation of the workflow flow logical and complete?
|
||||
- Does it explain how the workflow addresses the user request?
|
||||
- Are setup instructions (credentials, placeholders) clearly provided?
|
||||
`;
|
||||
|
||||
case 'datatable_instructions':
|
||||
return `
|
||||
Additionally evaluate:
|
||||
- Are the data table creation instructions clear and actionable?
|
||||
- Do the column names/types match what the workflow expects?
|
||||
- Is the user told exactly what to create manually?
|
||||
`;
|
||||
|
||||
case 'general_response':
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function buildWorkflowSummary(workflowJSON: unknown): string {
|
||||
if (!workflowJSON || typeof workflowJSON !== 'object') {
|
||||
return 'No workflow data available';
|
||||
}
|
||||
|
||||
const workflow = workflowJSON as { nodes?: Array<{ name?: string; type?: string }> };
|
||||
if (!Array.isArray(workflow.nodes) || workflow.nodes.length === 0) {
|
||||
return 'Empty workflow (no nodes)';
|
||||
}
|
||||
|
||||
const nodeList = workflow.nodes
|
||||
.map((node: { name?: string; type?: string }) => {
|
||||
const name = node.name ?? 'unnamed';
|
||||
const type = node.type ?? 'unknown';
|
||||
return `- ${name} (${type})`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `Workflow contains ${workflow.nodes.length} nodes:\n${nodeList}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the LLM judge prompt for evaluating a responder output.
|
||||
*/
|
||||
export function buildResponderJudgePrompt(args: {
|
||||
userPrompt: string;
|
||||
responderOutput: string;
|
||||
evalCriteria: ResponderEvalCriteria;
|
||||
workflowJSON?: unknown;
|
||||
}): string {
|
||||
const { userPrompt, responderOutput, evalCriteria, workflowJSON } = args;
|
||||
const typeGuidance = buildTypeSpecificGuidance(evalCriteria.type);
|
||||
const hasWorkflow = workflowJSON !== undefined;
|
||||
|
||||
return prompt()
|
||||
.section(
|
||||
'role',
|
||||
'You are an expert evaluator assessing the quality of an AI assistant response in a workflow automation context.',
|
||||
)
|
||||
.section(
|
||||
'task',
|
||||
`
|
||||
Evaluate the responder output against the provided criteria.
|
||||
Score each dimension from 0.0 to 1.0.
|
||||
|
||||
Return your evaluation as JSON with this exact structure:
|
||||
\`\`\`json
|
||||
{
|
||||
"relevance": { "score": 0.0, "comment": "..." },
|
||||
"accuracy": { "score": 0.0, "comment": "..." },',
|
||||
"completeness": { "score": 0.0, "comment": "..." },
|
||||
"clarity": { "score": 0.0, "comment": "..." },
|
||||
"tone": { "score": 0.0, "comment": "..." },',
|
||||
"criteriaMatch": { "score": 0.0, "comment": "..." },
|
||||
"forbiddenPhrases": { "score": 0.0, "comment": "..." },
|
||||
"overallScore": 0.0,',
|
||||
"summary": "..."
|
||||
}
|
||||
\`\`\`
|
||||
`,
|
||||
)
|
||||
.section(
|
||||
'dimensions',
|
||||
`
|
||||
**relevance** (0-1): Does the response address the user request?'
|
||||
**accuracy** (0-1): Is the information factually correct? If a workflow is provided, verify that the responder's claims about the workflow (nodes, integrations, actions) match what was actually built."
|
||||
**completeness** (0-1): Does it cover everything needed?
|
||||
**clarity** (0-1): Is the response well-structured and easy to understand?
|
||||
**tone** (0-1): Is the tone professional and helpful?',
|
||||
**criteriaMatch** (0-1): Does it satisfy the specific evaluation criteria below?
|
||||
**forbiddenPhrases** (0-1): 1.0 if no forbidden phrases are present, 0.0 if any are found.
|
||||
`,
|
||||
)
|
||||
.section('forbiddenPhrases', buildForbiddenPhrasesSection())
|
||||
.section('userPrompt', userPrompt)
|
||||
.section('responderOutput', responderOutput)
|
||||
.sectionIf(hasWorkflow, 'actualWorkflow', () => buildWorkflowSummary(workflowJSON))
|
||||
.section('evaluationCriteria', evalCriteria.criteria)
|
||||
.sectionIf(typeGuidance.length > 0, 'typeSpecificGuidance', typeGuidance)
|
||||
.build();
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { SimpleWorkflow } from '@/types/workflow';
|
||||
|
||||
import type { EvaluationContext, Evaluator, Feedback } from '../../harness/harness-types';
|
||||
import {
|
||||
evaluateWorkflowSimilarity,
|
||||
evaluateWorkflowSimilarityMultiple,
|
||||
} from '../../programmatic/evaluators/workflow-similarity';
|
||||
|
||||
/**
|
||||
* Options for creating a similarity evaluator.
|
||||
*/
|
||||
export interface SimilarityEvaluatorOptions {
|
||||
/** Comparison preset: 'strict' | 'standard' | 'lenient' (default: 'standard') */
|
||||
preset?: 'strict' | 'standard' | 'lenient';
|
||||
/** Optional path to custom configuration file */
|
||||
customConfigPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format violations as a comment string.
|
||||
*/
|
||||
function formatViolations(
|
||||
violations: Array<{ name: string; type: string; description: string; pointsDeducted: number }>,
|
||||
): string | undefined {
|
||||
if (!violations || violations.length === 0) return undefined;
|
||||
return violations.map((v) => `[${v.type}] ${v.description}`).join('; ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a similarity evaluator that compares workflows using graph edit distance.
|
||||
*
|
||||
* This evaluator uses a Python script to calculate similarity between the generated
|
||||
* workflow and reference workflow(s). It requires `uvx` to be installed.
|
||||
*
|
||||
* @param options - Configuration options
|
||||
* @returns An evaluator that produces feedback from similarity comparison
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const evaluator = createSimilarityEvaluator({ preset: 'standard' });
|
||||
*
|
||||
* // With single reference workflow
|
||||
* const feedback = await evaluator.evaluate(workflow, {
|
||||
* referenceWorkflows: [referenceWorkflow]
|
||||
* });
|
||||
*
|
||||
* // With multiple reference workflows (best match wins)
|
||||
* const feedback = await evaluator.evaluate(workflow, {
|
||||
* referenceWorkflows: [ref1, ref2, ref3]
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createSimilarityEvaluator(
|
||||
options?: SimilarityEvaluatorOptions,
|
||||
): Evaluator<EvaluationContext> {
|
||||
const preset = options?.preset ?? 'standard';
|
||||
const customConfigPath = options?.customConfigPath;
|
||||
|
||||
return {
|
||||
name: 'similarity',
|
||||
|
||||
async evaluate(workflow: SimpleWorkflow, ctx: EvaluationContext): Promise<Feedback[]> {
|
||||
const feedback: Feedback[] = [];
|
||||
|
||||
const referenceWorkflows = ctx.referenceWorkflows;
|
||||
|
||||
// No reference workflows provided - treat as configuration error
|
||||
if (!referenceWorkflows?.length) {
|
||||
feedback.push({
|
||||
evaluator: 'similarity',
|
||||
metric: 'error',
|
||||
score: 0,
|
||||
kind: 'score',
|
||||
comment: 'No reference workflow provided for comparison',
|
||||
});
|
||||
return feedback;
|
||||
}
|
||||
|
||||
try {
|
||||
let result: {
|
||||
violations: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
pointsDeducted: number;
|
||||
}>;
|
||||
score: number;
|
||||
};
|
||||
|
||||
if (referenceWorkflows.length === 1) {
|
||||
result = await evaluateWorkflowSimilarity(
|
||||
workflow,
|
||||
referenceWorkflows[0],
|
||||
preset,
|
||||
customConfigPath,
|
||||
);
|
||||
} else {
|
||||
result = await evaluateWorkflowSimilarityMultiple(
|
||||
workflow,
|
||||
referenceWorkflows,
|
||||
preset,
|
||||
customConfigPath,
|
||||
);
|
||||
}
|
||||
|
||||
// Overall similarity score
|
||||
feedback.push({
|
||||
evaluator: 'similarity',
|
||||
metric: 'score',
|
||||
score: result.score,
|
||||
kind: 'score',
|
||||
comment: formatViolations(result.violations),
|
||||
});
|
||||
|
||||
// Count violations by type
|
||||
const violationsByType: Record<string, number> = {};
|
||||
for (const v of result.violations) {
|
||||
const type = v.name.replace('workflow-similarity-', '');
|
||||
violationsByType[type] = (violationsByType[type] || 0) + 1;
|
||||
}
|
||||
|
||||
// Add individual violation counts as feedback
|
||||
for (const [type, count] of Object.entries(violationsByType)) {
|
||||
feedback.push({
|
||||
evaluator: 'similarity',
|
||||
metric: type,
|
||||
score: Math.max(0, 1 - count * 0.1), // Penalty per violation
|
||||
kind: 'detail',
|
||||
comment: `${count} ${type} edit(s)`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Return error feedback
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
feedback.push({
|
||||
evaluator: 'similarity',
|
||||
metric: 'error',
|
||||
score: 0,
|
||||
kind: 'score',
|
||||
comment: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
return feedback;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user