n8n
Version:
n8n Workflow Automation Tool
285 lines • 12.6 kB
JavaScript
;
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.WorkflowCompilerService = void 0;
const di_1 = require("@n8n/di");
const n8n_workflow_1 = require("n8n-workflow");
const nanoid_1 = require("nanoid");
const evaluation_config_validator_1 = require("../evaluation-config-validator");
const llm_judge_provider_registry_1 = require("../llm-judge-provider-registry");
const RESERVED_PREFIX = '__eval_';
const TRIGGER_NAME = '__eval_trigger';
const NODE_STEP_X = 220;
const MODEL_OFFSET_Y = 220;
const METRIC_COLUMN_GAP = 440;
const EXPRESSION_ROW_HEIGHT = 140;
const LLM_JUDGE_ROW_HEIGHT = 380;
let WorkflowCompilerService = class WorkflowCompilerService {
constructor(providerRegistry) {
this.providerRegistry = providerRegistry;
}
compile(workflow, config) {
this.assertNoReservedNames(workflow);
const entryNodeName = this.resolveEntryNode(workflow, config);
const entryPos = this.positionOf(workflow, entryNodeName) ?? [0, 0];
const endPos = this.positionOf(workflow, config.endNodeName) ?? [
entryPos[0] + NODE_STEP_X,
entryPos[1],
];
const replacedNodeName = this.findReplacedUpstreamNode(workflow, entryNodeName);
const userTriggerEdge = this.findUserTriggerEdgeTo(workflow.connections, entryNodeName);
const rightmostX = workflow.nodes.reduce((max, n) => Math.max(max, n.position?.[0] ?? 0), endPos[0]);
const metricColumnX = rightmostX + METRIC_COLUMN_GAP;
const metricRowYs = this.computeMetricRowYs(config.metrics, endPos[1]);
const triggerNode = this.buildEvaluationTriggerNode(config, entryPos);
const metricNodes = config.metrics.map((m, i) => this.buildMetricNode(m, metricColumnX, metricRowYs[i]));
const chatModelNodes = config.metrics.flatMap((m, i) => this.buildChatModelNodeIfNeeded(m, metricColumnX, metricRowYs[i]));
const userNodesOut = replacedNodeName
? workflow.nodes.map((n) => this.rewriteExpressionsOnNode(n, replacedNodeName))
: workflow.nodes;
const metricNodesOut = replacedNodeName
? metricNodes.map((n) => this.rewriteExpressionsOnNode(n, replacedNodeName))
: metricNodes;
const nodes = [...userNodesOut, triggerNode, ...metricNodesOut, ...chatModelNodes];
const connections = this.rewireConnections({
original: workflow.connections,
userTriggerEdge,
entryNodeName,
metrics: config.metrics,
endNodeName: config.endNodeName,
});
return { ...workflow, nodes, connections };
}
findReplacedUpstreamNode(workflow, entryNodeName) {
const upstreamNames = new Set();
for (const [fromNode, conn] of Object.entries(workflow.connections)) {
for (const bucket of conn.main ?? []) {
for (const edge of bucket ?? []) {
if (edge?.node === entryNodeName)
upstreamNames.add(fromNode);
}
}
}
if (upstreamNames.size !== 1)
return null;
const [name] = upstreamNames;
return name;
}
computeMetricRowYs(metrics, baseY) {
const ys = [];
let y = baseY;
for (const m of metrics) {
ys.push(y);
y += m.type === 'llm_judge' ? LLM_JUDGE_ROW_HEIGHT : EXPRESSION_ROW_HEIGHT;
}
return ys;
}
rewriteExpressionsOnNode(node, fromName) {
return {
...node,
parameters: rewriteExpressionRefs(node.parameters, fromName),
};
}
assertNoReservedNames(workflow) {
const offender = workflow.nodes.find((n) => n.name.startsWith(RESERVED_PREFIX));
if (offender) {
throw new n8n_workflow_1.UserError(`Node name "${offender.name}" uses the reserved "${RESERVED_PREFIX}" prefix`);
}
}
resolveEntryNode(workflow, config) {
if (config.startNodeName) {
return config.startNodeName;
}
const triggerNames = workflow.nodes.filter((n) => this.isTrigger(n)).map((n) => n.name);
const downstream = new Set();
for (const tName of triggerNames) {
for (const bucket of workflow.connections[tName]?.main ?? []) {
for (const edge of bucket ?? [])
downstream.add(edge.node);
}
}
if (downstream.size === 0) {
throw new n8n_workflow_1.UserError('Cannot determine entry node: workflow has no trigger with a downstream connection');
}
if (downstream.size > 1) {
throw new n8n_workflow_1.UserError('Cannot auto-determine entry node: workflow trigger has multiple downstream nodes; set startNodeName explicitly');
}
return [...downstream][0];
}
isTrigger(node) {
return /trigger|webhook|manual/i.test(node.type);
}
findUserTriggerEdgeTo(connections, entryNodeName) {
for (const [fromNode, conn] of Object.entries(connections)) {
const buckets = conn.main ?? [];
for (let bIdx = 0; bIdx < buckets.length; bIdx++) {
const edges = buckets[bIdx] ?? [];
for (let eIdx = 0; eIdx < edges.length; eIdx++) {
if (edges[eIdx]?.node === entryNodeName) {
return { fromNode, fromBucketIndex: bIdx, edgeIndex: eIdx };
}
}
}
}
throw new n8n_workflow_1.UserError(`No incoming connection to entry node "${entryNodeName}"; cannot inject evaluation trigger`);
}
positionOf(workflow, nodeName) {
const node = workflow.nodes.find((n) => n.name === nodeName);
return node?.position;
}
buildEvaluationTriggerNode(config, entryPos) {
const datasetRef = config.datasetSource === 'data_table'
? config.datasetRef
: undefined;
return {
id: (0, nanoid_1.nanoid)(),
name: TRIGGER_NAME,
type: n8n_workflow_1.EVALUATION_TRIGGER_NODE_TYPE,
typeVersion: 4.7,
position: [entryPos[0] - NODE_STEP_X, entryPos[1]],
parameters: {
source: 'dataTable',
dataTableId: datasetRef?.dataTableId ?? '',
},
};
}
buildMetricNode(metric, x, y) {
return {
id: (0, nanoid_1.nanoid)(),
name: `__eval_metric_${metric.id}`,
type: n8n_workflow_1.EVALUATION_NODE_TYPE,
typeVersion: 4.7,
position: [x, y],
parameters: this.buildMetricNodeParameters(metric),
};
}
buildMetricNodeParameters(metric) {
if (metric.type === 'expression') {
if (metric.config.outputType === 'boolean' &&
!(0, evaluation_config_validator_1.isCoercibleBooleanExpression)(metric.config.expression)) {
throw new n8n_workflow_1.UserError(`Metric "${metric.name}" expression cannot be coerced into a boolean (multi-segment templates are not supported)`);
}
const value = metric.config.outputType === 'boolean'
? coerceBooleanExpression(metric.config.expression)
: metric.config.expression;
return {
operation: 'setMetrics',
metric: 'customMetrics',
metrics: {
assignments: [{ id: (0, nanoid_1.nanoid)(), name: metric.name, value, type: 'number' }],
},
};
}
const { preset, prompt, inputs } = metric.config;
return {
operation: 'setMetrics',
metric: preset,
prompt,
actualAnswer: inputs.actualAnswer,
...(preset === 'correctness' ? { expectedAnswer: inputs.expectedAnswer ?? '' } : {}),
...(preset === 'helpfulness' ? { userQuery: inputs.userQuery ?? '' } : {}),
options: {
metricName: metric.name,
},
};
}
buildChatModelNodeIfNeeded(metric, metricX, metricY) {
if (metric.type !== 'llm_judge')
return [];
return [
{
id: (0, nanoid_1.nanoid)(),
name: `__eval_model_${metric.id}`,
type: metric.config.provider,
typeVersion: 1,
position: [metricX, metricY + MODEL_OFFSET_Y],
credentials: this.credentialsForProvider(metric.config.provider, metric.config.credentialId),
parameters: { model: metric.config.model },
},
];
}
credentialsForProvider(provider, credentialId) {
const entry = this.providerRegistry.get(provider);
if (!entry || entry.credentialTypes.length === 0) {
throw new n8n_workflow_1.UserError(`Unsupported LLM judge provider "${provider}"`);
}
const credentialType = entry.credentialTypes[0].name;
return { [credentialType]: { id: credentialId, name: '' } };
}
rewireConnections(args) {
const out = (0, n8n_workflow_1.deepCopy)(args.original);
const { fromNode, fromBucketIndex, edgeIndex } = args.userTriggerEdge;
const buckets = out[fromNode]?.main;
if (buckets?.[fromBucketIndex]) {
buckets[fromBucketIndex].splice(edgeIndex, 1);
if (buckets.every((b) => (b ?? []).length === 0)) {
delete out[fromNode];
}
}
out[TRIGGER_NAME] = {
main: [[{ node: args.entryNodeName, type: 'main', index: 0 }]],
};
const metricEdges = args.metrics.map((m) => ({
node: `__eval_metric_${m.id}`,
type: 'main',
index: 0,
}));
const existingEnd = out[args.endNodeName] ?? {};
const existingMain = existingEnd.main ?? [];
const bucketCount = Math.max(existingMain.length, 1);
out[args.endNodeName] = {
...existingEnd,
main: Array.from({ length: bucketCount }, () => [...metricEdges]),
};
for (const m of args.metrics) {
if (m.type !== 'llm_judge')
continue;
const modelNode = `__eval_model_${m.id}`;
const metricNode = `__eval_metric_${m.id}`;
out[modelNode] = {
[n8n_workflow_1.NodeConnectionTypes.AiLanguageModel]: [
[{ node: metricNode, type: n8n_workflow_1.NodeConnectionTypes.AiLanguageModel, index: 0 }],
],
};
}
return out;
}
};
exports.WorkflowCompilerService = WorkflowCompilerService;
exports.WorkflowCompilerService = WorkflowCompilerService = __decorate([
(0, di_1.Service)(),
__metadata("design:paramtypes", [llm_judge_provider_registry_1.LlmJudgeProviderRegistry])
], WorkflowCompilerService);
function coerceBooleanExpression(expression) {
const match = expression.match(/^=\{\{([\s\S]*)\}\}$/);
const inner = match ? match[1].trim() : JSON.stringify(expression);
return `={{ (${inner}) ? 1 : 0 }}`;
}
function rewriteExpressionRefs(value, fromName) {
const escaped = fromName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`\\$\\(\\s*(['"])${escaped}\\1\\s*\\)`, 'g');
const replacement = `$("${TRIGGER_NAME}")`;
const walk = (v) => {
if (typeof v === 'string')
return v.replace(pattern, replacement);
if (Array.isArray(v))
return v.map(walk);
if (v && typeof v === 'object') {
const out = {};
for (const [k, child] of Object.entries(v))
out[k] = walk(child);
return out;
}
return v;
};
return walk(value);
}
//# sourceMappingURL=workflow-compiler.service.js.map