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

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -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');
});
});
@@ -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(', ')}` }
: {}),
};
},
};
@@ -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 };
},
};
}
@@ -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(', ')}` }
: {}),
};
},
};
@@ -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' };
},
};
@@ -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,
];
@@ -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(', ')}` }
: {}),
};
},
};
@@ -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(', ')}` }
: {}),
};
},
};
@@ -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(', ')}` }
: {}),
};
},
};
@@ -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}`,
};
});
},
};
}
@@ -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');
});
});
@@ -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?`,
});
@@ -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 };
},
};
}
@@ -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?`,
});
@@ -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.`,
});
@@ -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?`,
});
@@ -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,
];
@@ -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>;
@@ -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>;
@@ -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;
}
@@ -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,
};
}
@@ -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);
}
@@ -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);
}
@@ -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;
}
@@ -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);
}
@@ -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);
}
@@ -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';
@@ -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;
}
@@ -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),
];
},
};
}
@@ -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();
});
});
});
@@ -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;
},
};
}
@@ -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,
};
}
@@ -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);
},
};
}
@@ -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;
},
};
}