Files
alighasami 3d5eaf9445
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
first commit
2026-03-17 16:22:57 +03:30

682 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Evaluations (v2 harness)
Internal evaluation harness for the AI Workflow Builder. Supports local CLI runs and LangSmith-backed runs, using the same evaluators.
## Quick Start
Run from the package directory:
```bash
pushd packages/@n8n/ai-workflow-builder.ee
# Local: run against default prompts (fixtures/default-prompts.csv)
pnpm eval --verbose
# Local: single prompt
pnpm eval --prompt "Create a workflow that..." --verbose
# Local: custom CSV file
pnpm eval --prompts-csv path/to/prompts.csv --verbose
# Local: pairwise + programmatic
pnpm eval:pairwise --prompt "Create a workflow that..." --dos "Must use Slack" --donts "No HTTP Request node" --verbose
# LangSmith: LLM-judge + programmatic
pnpm eval:langsmith --dataset "workflow-builder-canvas-prompts" --name "my-exp" --concurrency 10 --max-examples 20 --verbose
# LangSmith: pairwise + programmatic
pnpm eval:pairwise:langsmith --dataset "notion-pairwise-workflows" --name "pairwise-exp" --filter "technique:content_generation" --max-examples 50 --verbose
popd
```
## Prerequisites
- **LLM key** (required for generation and any LLM-based evaluators):
- `N8N_AI_ANTHROPIC_KEY` (see `evaluations/support/environment.ts`)
- **Node definitions** (required for workflow generation, and used by evaluators):
- export using `pnpm export:nodes` in this package.
- `evaluations/.data/nodes.json` (see `evaluations/support/load-nodes.ts`)
- Optional: `N8N_EVALS_DISABLED_NODES="n8n-nodes-base.httpRequest,..."` to exclude specific nodes from generation.
- **LangSmith** (only for `--backend langsmith` runs):
- `LANGSMITH_API_KEY`
- `LANGSMITH_TRACING=true` (the harness sets this in LangSmith mode, but exporting it is fine)
- Optional: `LANGSMITH_MINIMAL_TRACING=false` to disable trace filtering (useful when debugging traces; default is filtered)
## Mental Model
```mermaid
flowchart TB
subgraph Config["runEvaluation(config)"]
direction LR
C1["mode: 'local' | 'langsmith'"]
C2["dataset: TestCase[] | string"]
C3["generateWorkflow: (prompt) => workflow"]
C4["evaluators: Evaluator[]"]
end
Config --> Loop
subgraph Loop["For each test case"]
G["1. generateWorkflow(prompt)"]
E["2. evaluateWithPlugins (parallel)"]
A["3. Aggregate feedback"]
G --> E --> A
end
Loop --> Evaluators
subgraph Evaluators["Evaluators (run in parallel)"]
direction LR
LLM["LLM-Judge"]
Pair["Pairwise"]
Prog["Programmatic"]
Bin["Binary-Checks"]
end
Evaluators --> Feedback
subgraph Feedback["Feedback[]"]
F1["evaluator: string"]
F2["metric: string"]
F3["score: 0-1"]
F4["kind: 'score' | 'metric' | 'detail'"]
F5["comment?: string"]
end
```
## Key Concepts
### Evaluator
A function that takes a workflow and returns feedback:
```typescript
interface Evaluator<TContext = EvaluationContext> {
name: string;
evaluate(workflow: SimpleWorkflow, ctx: TContext): Promise<Feedback[]>;
}
```
Evaluators are:
- **Independent** - no dependencies between evaluators
- **Parallel** - all evaluators run concurrently
- **Error-tolerant** - if one fails, others continue
### Feedback
The universal output format from all evaluators:
```typescript
interface Feedback {
evaluator: string; // e.g., "llm-judge", "pairwise"
metric: string; // e.g., "functionality", "judge1", "efficiency.nodeCountEfficiency"
score: number; // 0.0 to 1.0
comment?: string; // Optional explanation/violations
kind: 'score' | 'metric' | 'detail';
}
```
`kind` is used by the harness scoring logic:
- `score`: the evaluators single overall score (preferred for scoring)
- `metric`: stable per-category metrics (useful to show, but not necessarily used for scoring if a `score` exists)
- `detail`: verbose/unstable metrics and details (never used for scoring when a `score` is present)
### Lifecycle Hooks
Centralized logging via hooks (not per-evaluator logging):
```typescript
interface EvaluationLifecycle {
onStart(config): void;
onExampleStart(index, total, prompt): void;
onWorkflowGenerated(workflow, durationMs): void;
onEvaluatorComplete(name, feedback): void;
onEvaluatorError(name, error): void;
onExampleComplete(index, result): void;
onEnd(summary): void;
}
```
### Context
Evaluators receive context from multiple sources:
```
globalContext (from RunConfig.context)
+
testCase.context (per-test-case overrides)
+
prompt (always included)
=
Final context passed to evaluators
```
## Local vs LangSmith Mode
### Local Mode
```typescript
import { createLogger } from './harness/logger';
const logger = createLogger(true); // verbose output
const config: RunConfig = {
mode: 'local',
dataset: [
{ prompt: 'Create a workflow...', context: { dos: '...' } },
],
generateWorkflow,
evaluators: [llmJudge, programmatic],
lifecycle: createConsoleLifecycle({ verbose: true, logger }),
logger,
};
await runEvaluation(config);
```
- Processes test cases sequentially (examples), but LLM-bound work is capped via `llmCallLimiter` (see `evaluations/harness/runner.ts`)
- Results logged to console via lifecycle hooks
- The harness returns a `RunSummary`; the CLI decides exit codes
### LangSmith Mode
```typescript
import { createLogger } from './harness/logger';
const logger = createLogger(false); // non-verbose output
const config: RunConfig = {
mode: 'langsmith',
dataset: 'my-dataset-name', // LangSmith dataset
generateWorkflow,
evaluators: [llmJudge, programmatic],
logger,
langsmithOptions: {
experimentName: 'experiment-1',
repetitions: 1,
concurrency: 4,
},
};
await runEvaluation(config);
```
If you want *no output* (e.g. unit tests), use `createQuietLifecycle()` (or pass a stub logger) instead of relying on a "silent logger".
**Architecture:** The target function does ALL work (generation + evaluation). The LangSmith evaluator just extracts pre-computed feedback.
The runner flushes pending trace batches before returning, so traces/results reliably show up in LangSmith.
```typescript
// Inside runLangsmith():
// IMPORTANT: Create traceable wrapper ONCE outside the target function
// to avoid context leaking in concurrent scenarios. Pass params explicitly.
const traceableGenerateWorkflow = traceable(
async (args: { prompt: string; genFn: Function }) => {
return await args.genFn(args.prompt);
},
{ name: 'workflow_generation', run_type: 'chain', client: lsClient }
);
const target = async (inputs) => {
const { prompt } = inputs;
// Call the pre-created wrapper with explicit params (no closures)
const workflow = await traceableGenerateWorkflow({
prompt,
genFn: generateWorkflow,
});
const feedback = await evaluateWithPlugins(workflow, evaluators);
return { workflow, prompt, feedback }; // Pre-computed!
};
// LangSmith evaluator converts internal `{ evaluator, metric }` into `{ key, score, comment? }`:
const feedbackExtractor = (run) => run.outputs.feedback.map(toLangsmithEvaluationResult);
```
## LangSmith Tracing
- **Do not** wrap the `target` function with `traceable()``evaluate()` handles that automatically
- **Do** create `traceable` wrappers **once** outside the target function (not inside concurrent code)
- **Do** pass all parameters explicitly to avoid closure-based context leaking
- **Do** use `getTracingCallbacks()` to bridge traceable context to LangChain calls (pass callbacks to `agent.chat()` or chain's `invoke()`)
## Available Evaluators
### LLM-Judge
Uses an LLM to evaluate workflow quality across multiple dimensions:
```typescript
import { createLLMJudgeEvaluator } from './evaluators';
const evaluator = createLLMJudgeEvaluator(llm, nodeTypes);
```
**Evaluator:** `llm-judge`
**Metrics:** `functionality`, `connections`, `expressions`, `nodeConfiguration`, `efficiency`, `dataFlow`, `maintainability`, `overallScore`
**Context required:** `{ prompt: string }`
### Pairwise
Uses a panel of judges to evaluate against dos/donts criteria:
```typescript
import { createPairwiseEvaluator } from './evaluators';
const evaluator = createPairwiseEvaluator(llm, { numJudges: 3 });
```
**Evaluator:** `pairwise`
**Metrics:**
`pairwise_primary`, `pairwise_diagnostic`, `pairwise_judges_passed`, `pairwise_total_passes`, `pairwise_total_violations`
Additional per-judge details may also be emitted (e.g. `judge1`, `judge2`).
**Context required:** `{ dos?: string, donts?: string }`
### Binary-Checks
Per-check binary pass/fail evaluation — 17 deterministic checks (fast, no LLM) plus 5 LLM-judge checks (parallel):
```typescript
import { createBinaryChecksEvaluator } from './evaluators';
const evaluator = createBinaryChecksEvaluator({ nodeTypes, llm });
```
**Evaluator:** `binary-checks`
**Deterministic checks:** `has_nodes`, `all_nodes_connected`, `no_unreachable_nodes`, `has_trigger`, `no_empty_set_nodes`, `agent_has_dynamic_prompt`, `agent_has_language_model`, `memory_properly_connected`, `vector_store_has_embeddings`, `has_start_node`, `no_hardcoded_credentials`, `no_unnecessary_code_nodes`, `expressions_reference_existing_nodes`, `valid_required_parameters`, `valid_options_values`, `no_invalid_from_ai`, `tools_have_parameters`
**LLM checks** (require `llm` option): `fulfills_user_request`, `correct_node_operations`, `valid_data_flow`, `handles_multiple_items`, `descriptive_node_names`
**Context required:** `{ prompt: string }`, optional `{ annotations?: Record<string, unknown> }`
**CLI:**
```bash
# Run all checks
pnpm eval --suite binary-checks --prompt "Create a Slack workflow"
# Run specific checks only
pnpm eval --suite binary-checks --checks has_nodes,has_trigger --prompt "..."
# LangSmith
pnpm eval --suite binary-checks --langsmith --dataset "binary-checks-spec-prompts"
```
### Programmatic
Rule-based checks without LLM calls:
```typescript
import { createProgrammaticEvaluator } from './evaluators';
const evaluator = createProgrammaticEvaluator(nodeTypes);
```
**Evaluator:** `programmatic`
**Metrics:** `overall`, `connections`, `trigger`, `agentPrompt`, `tools`, `fromAi` (optional: `similarity`)
**Context required:** None
## Metric Naming (LangSmith compatibility)
LangSmith metric keys are derived from `Feedback` in `evaluations/harness/feedback.ts`:
- `llm-judge`: **unprefixed** (e.g. `overallScore`, `maintainability.workflowOrganization`)
- `programmatic`: **prefixed** (e.g. `programmatic.trigger`)
- `pairwise`: v1-compatible keys stay **unprefixed** (e.g. `pairwise_primary`); non-v1 details are namespaced (e.g. `pairwise.judge1`)
## CLI Usage
### NPM Scripts
```bash
# Local mode with LLM-judge evaluator
pnpm eval --prompt "Create a workflow..." --verbose
# LangSmith mode (results in LangSmith dashboard)
pnpm eval:langsmith --name "my-experiment" --verbose
# Pairwise mode (local)
pnpm eval:pairwise --prompt "..." --dos "Must use Slack" --donts "No HTTP"
# Pairwise mode with LangSmith
pnpm eval:pairwise:langsmith --name "pairwise-exp" --verbose
```
Notes:
- In `--backend langsmith` mode, the CLI requires `--dataset` and rejects `--prompt`, `--prompts-csv`, and `--test-case`.
- `--output-dir` only applies to local mode (it writes artifacts to disk).
### Common Flags
```bash
--suite <llm-judge|pairwise|programmatic|similarity|binary-checks>
--backend <local|langsmith> # Or `--langsmith` as a shortcut
--verbose, -v # Enable verbose output
--name <name> # Experiment name (LangSmith mode)
--dataset <name> # LangSmith dataset name
--max-examples <n> # Limit number of examples to evaluate
--concurrency <n> # Max concurrent evaluations (default: 5)
--repetitions <n> # Number of repetitions per example
--test-case <id> # Run a predefined test case (local)
--prompts-csv <path># Load prompts from CSV (local)
--prompt <text> # Single prompt for local testing
--dos <text> # Pairwise: things the workflow should do
--donts <text> # Pairwise: things the workflow should not do
--checks <names> # Comma-separated binary check names (binary-checks suite only)
--output-dir <dir> # Local mode: write artifacts (one folder per example + summary.json)
--template-examples # Enable template examples feature flag
--webhook-url <url> # Send results to webhook URL on completion (HTTPS only)
--webhook-secret <s> # HMAC-SHA256 secret for webhook authentication (min 16 chars)
```
### CSV Format
`--prompts-csv` supports optional headers. Recognized columns:
- `prompt` (required)
- `id` (optional)
- `dos` / `do` (optional)
- `donts` / `dont` (optional)
Example:
```csv
id,prompt,dos,donts
pw-001,"Create a workflow to sync Gmail to Notion","Must use Notion","No HTTP Request node"
```
### Direct Usage
```bash
# Local mode (default)
tsx evaluations/cli/index.ts --prompt "Create a workflow..." --verbose
# LangSmith mode
tsx evaluations/cli/index.ts --backend langsmith --name "my-experiment" --verbose
# Pairwise mode
tsx evaluations/cli/index.ts --suite pairwise --prompt "..." --dos "Must use Slack"
```
## Components & Where Things Live
This directory is intentionally split by responsibility:
- `evaluations/cli/`: CLI entrypoint and input parsing (`cli/index.ts`, `cli/argument-parser.ts`, `cli/csv-prompt-loader.ts`, `cli/webhook.ts`)
- `evaluations/harness/`: orchestration, scoring, logging, and artifact writing (`harness/runner.ts`, `harness/lifecycle.ts`, `harness/score-calculator.ts`, `harness/output.ts`)
- `evaluations/evaluators/`: evaluator factories used by the harness (LLM-judge, pairwise, programmatic, similarity, binary-checks)
- `evaluations/judge/`: the LLM-judge “engine” (schemas + category evaluators + `judge/workflow-evaluator.ts`)
- `evaluations/langsmith/`: LangSmith-specific helpers (`langsmith/trace-filters.ts`, `langsmith/types.ts`)
- `evaluations/support/`: environment setup, node loading, report generation, and test-case generation
- `evaluations/programmatic/`: programmatic evaluator implementation (TypeScript) + `programmatic/python/` (kept separate)
## Extending
### Adding a new evaluator
Add an evaluator by implementing the `Evaluator` interface and returning `Feedback[]`:
- Put evaluator factories under `evaluations/evaluators/<name>/`
- Make sure you emit at least one `kind: 'score'` item (the harness scoring prefers this)
- If you need custom context, extend via `Evaluator<MyContext>` and validate required fields at runtime (keep the base context cast-free)
- If you want stable LangSmith keys, update `evaluations/harness/feedback.ts`
### Adding a new “runner” (backend)
The harness runner is `evaluations/harness/runner.ts`. Today it supports:
- `mode: 'local'` (local dataset array + optional artifacts)
- `mode: 'langsmith'` (LangSmith dataset or preloaded examples)
To add a new backend, keep evaluators backend-agnostic and extend the runner with a new `RunConfig['mode']` branch.
## File Structure
```
evaluations/
├── __tests__/ # Unit tests
├── cli/ # CLI entry + arg parsing + CSV loader
├── evaluators/ # Evaluator factories
│ ├── binary-checks/ # Binary pass/fail checks (deterministic + LLM)
│ ├── llm-judge/
│ ├── pairwise/
│ ├── programmatic/
│ └── similarity/
├── harness/ # Runner + lifecycle + scoring + artifacts
├── fixtures/ # Local fixtures (tracked)
│ └── reference-workflows/
├── judge/ # LLM-judge internals (schemas + judge evaluators)
├── langsmith/ # LangSmith-specific helpers (types + trace filters)
├── programmatic/ # Programmatic evaluation logic
├── support/ # Environment + node loading + reports + test case gen
├── index.ts # Public exports
└── README.md # This file
```
## Error Handling
The harness uses "skip and continue" error handling:
- If an evaluator throws, it returns error feedback and continues
- If workflow generation fails, the example is marked as error and continues
- Other evaluators still run even if one fails
```typescript
// Error feedback format:
{ evaluator: 'evaluator-name', metric: 'error', score: 0, kind: 'score', comment: 'Error message' }
```
## Testing
From `packages/@n8n/ai-workflow-builder.ee`:
```bash
pnpm test:eval
```
## CI Integration
### Automated Eval Runs
Evaluations run automatically via GitHub Actions:
| Trigger | Reps | Judges | Dataset | When |
|---------|------|--------|---------|------|
| Push to master | 1 | 1 | `workflow-builder-canvas-prompts` | On changes to `ai-workflow-builder.ee/` |
| Scheduled | 3 | 3 | `prompts-v2` | Saturdays 22:00 UTC |
| Minor release | 2 | 3 | `workflow-builder-canvas-prompts` | On `vX.Y.0` releases |
| Manual dispatch | Configurable | Configurable | Configurable | Via GitHub Actions UI |
### Skipping Evals on Merge
To skip eval runs when merging a PR that doesn't affect prompts/AI behavior, use any of:
- **PR label**: Add `no-prompt-changes` label to the PR
- **PR title**: Include `(no-prompt-changes)` in the PR title
- **Commit message**: Include `(no-prompt-changes)` in the merge commit message
### Experiment Naming Convention
LangSmith experiments follow this naming pattern:
| Source | Format | Example |
|--------|--------|---------|
| Branch with ticket | `{TICKET-ID}_{YYYY_MM_DD}` | `AI-1234_2026_01_20` |
| Branch without ticket | `CI_{branch}_{YYYY_MM_DD}` | `CI_master_2026_01_20` |
| Scheduled run | `CI_scheduled_{YYYY_MM_DD}` | `CI_scheduled_2026_01_20` |
| Minor release | `CI_vX.Y_{YYYY_MM_DD}` | `CI_v1.70_2026_01_20` |
| Manual dispatch | `CI_manual_{YYYY_MM_DD}` | `CI_manual_2026_01_20` |
### CI Metadata
All LangSmith experiments include metadata to distinguish CI runs from local development:
```json
{
"source": "ci",
"trigger": "push",
"commitSha": "abc123...",
"branch": "master",
"runId": "12345678"
}
```
Local runs show `"source": "local"` with no other CI fields.
### Webhook Notifications
The CLI supports sending evaluation results to a webhook URL when evaluations complete. This enables integrations with Slack, Discord, or custom notification systems.
```bash
pnpm eval:langsmith --dataset "my-dataset" --webhook-url "https://hooks.slack.com/services/..."
```
**Why custom webhooks?**
LangSmith's `evaluate()` function does not provide native webhook support for experiment run notifications. LangSmith offers webhooks via:
- **Trace Rules** — triggered on individual traces, not experiment completions
- **API endpoint webhooks** — for specific API events, but not for `evaluate()` completions
- **API polling** — requires external orchestration to detect when experiments finish
Since none of these approaches support the "notify on experiment completion" use case for the `evaluate()` SDK function, we implemented a custom webhook system that fires after all evaluations complete, sending a summary payload with experiment metadata.
**Payload format:**
```json
{
"suite": "llm-judge",
"summary": {
"totalExamples": 50,
"passed": 45,
"failed": 5,
"errors": 0,
"averageScore": 0.87
},
"evaluatorAverages": {
"llm-judge": 0.85,
"programmatic": 0.92
},
"totalDurationMs": 120000,
"metadata": {
"source": "ci",
"trigger": "push",
"runId": "12345678"
},
"langsmith": {
"experimentName": "AI-1234_2026_01_20",
"experimentId": "48660e0e-0ed5-4e32-9e04-88803d7c161f",
"datasetId": "b04d1ce8-8e3f-455a-818c-ee2c7e14c458",
"datasetName": "workflow-builder-canvas-prompts"
}
}
```
The `langsmith` object (only present in LangSmith mode) contains IDs and names for constructing comparison URLs.
**Security:**
- Only HTTPS URLs are allowed
- Localhost and private/internal IPs are blocked (SSRF prevention)
- DNS resolution validates that hostnames don't resolve to private IPs
- Webhook URLs are masked in logs to protect embedded tokens
- HMAC-SHA256 signature for request authentication (optional but recommended)
#### Webhook Authentication (HMAC Signature)
For production use, authenticate webhook requests using HMAC-SHA256 signatures:
```bash
# Generate a secret (run once, store securely)
openssl rand -hex 32
# Use with the CLI
pnpm eval:langsmith --dataset "my-dataset" \
--webhook-url "https://your.endpoint/webhook" \
--webhook-secret "your-64-char-hex-secret"
```
When a secret is provided, requests include:
- `X-Signature-256`: HMAC-SHA256 signature (`sha256=<hex>`)
- `X-Timestamp`: Unix timestamp in milliseconds
**How it works:**
```
Sender:
1. payload = JSON.stringify(body)
2. signatureInput = `${timestamp}.${payload}`
3. signature = HMAC-SHA256(signatureInput, secret)
4. Send with headers: X-Signature-256, X-Timestamp
Receiver:
1. Extract X-Signature-256 and X-Timestamp headers
2. Check timestamp is recent (< 5 minutes old)
3. Recreate: signatureInput = `${timestamp}.${rawBody}`
4. Compute expected = HMAC-SHA256(signatureInput, secret)
5. Compare signatures (timing-safe)
```
**Verifying in an n8n workflow:**
Use a Code node after the Webhook trigger:
```javascript
const crypto = require('crypto');
// Get from webhook input (adjust based on your webhook node config)
const signature = $input.first().json.headers['x-signature-256'];
const timestamp = $input.first().json.headers['x-timestamp'];
const rawBody = $input.first().json.rawBody ?? $input.first().json.body;
const body = typeof rawBody === 'string' ? rawBody : JSON.stringify(rawBody);
// Your secret (use n8n credentials or environment variable)
const secret = $env.WEBHOOK_SECRET;
// Verify timestamp (reject requests older than 5 minutes)
const MAX_AGE_MS = 5 * 60 * 1000;
const age = Date.now() - parseInt(timestamp, 10);
if (!signature || !timestamp) throw new Error('Missing signature headers');
if (age > MAX_AGE_MS) throw new Error('Request too old');
// Compute and compare signature
const payload = `${timestamp}.${body}`;
const expected = 'sha256=' + crypto.createHmac('sha256', secret)
.update(payload, 'utf8').digest('hex');
if (signature.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
throw new Error('Invalid signature');
}
// Valid! Return parsed payload
return [{ json: JSON.parse(body) }];
```
**CI Configuration:**
Add secrets to GitHub:
- `EVALS_WEBHOOK_URL`: Your webhook endpoint
- `EVALS_WEBHOOK_SECRET`: The HMAC secret (64-char hex string)
### Debug Dataset
For faster iteration during development, use a minimal dataset:
```bash
# Use the debug dataset with a single example
pnpm eval:langsmith --dataset "workflow-builder-debug-single" --name "debug-run" --verbose
```
To create your own debug dataset in LangSmith:
1. Go to LangSmith → Datasets
2. Create a new dataset with 1-3 representative examples
3. Use it with `--dataset "your-debug-dataset"`
This is useful for:
- Testing workflow changes quickly
- Debugging evaluator issues
- Validating CI workflow changes locally