mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-19 16:55:02 +02:00
181 lines
4.9 KiB
TypeScript
181 lines
4.9 KiB
TypeScript
import { filterLlmMessages } from './message';
|
|
import { AgentRuntime } from '../runtime/loop/agent-runtime';
|
|
import type { BuiltEval, CheckFn, EvalInput, EvalScore, JudgeFn, JudgeHandlerFn } from '../types';
|
|
import type { ModelConfig } from '../types/sdk/agent';
|
|
import type { AgentMessage } from '../types/sdk/message';
|
|
|
|
/** Extract text content from LLM messages (custom messages are skipped). */
|
|
function extractText(messages: AgentMessage[]): string {
|
|
return filterLlmMessages(messages)
|
|
.flatMap((m) => m.content)
|
|
.filter((c) => c.type === 'text')
|
|
.map((c) => (c as { text: string }).text)
|
|
.join('');
|
|
}
|
|
|
|
/**
|
|
* Builder for creating evaluations with a fluent API.
|
|
*
|
|
* Two modes:
|
|
* - **Deterministic**: `.check(fn)` — pure function scoring
|
|
* - **LLM-as-judge**: `.model()` + `.credential()` + `.judge(fn)` — LLM-powered scoring
|
|
*
|
|
* Usage:
|
|
* ```typescript
|
|
* // Deterministic
|
|
* const jsonCheck = new Eval('json-check')
|
|
* .description('Verify output is valid JSON')
|
|
* .check(({ output }) => {
|
|
* try { JSON.parse(output); return { score: 1, reasoning: 'Valid JSON' }; }
|
|
* catch { return { score: 0, reasoning: 'Invalid JSON' }; }
|
|
* });
|
|
*
|
|
* // LLM-as-judge
|
|
* const correctness = new Eval('correctness')
|
|
* .description('Judge factual correctness')
|
|
* .model('anthropic/claude-haiku-4-5')
|
|
* .credential('anthropic')
|
|
* .judge(async ({ input, output, expected, llm }) => {
|
|
* const result = await llm(`Is "${output}" correct for "${input}"? Expected: ${expected}`);
|
|
* const score = parseFloat(result.text.match(/[\d.]+/)?.[0] ?? '0');
|
|
* return { score: Math.min(1, Math.max(0, score)), reasoning: result.text };
|
|
* });
|
|
* ```
|
|
*/
|
|
export class Eval {
|
|
private evalName: string;
|
|
|
|
private desc?: string;
|
|
|
|
private checkFn?: CheckFn;
|
|
|
|
private judgeFn?: JudgeHandlerFn;
|
|
|
|
private modelId?: string;
|
|
|
|
private credentialName?: string;
|
|
|
|
constructor(name: string) {
|
|
this.evalName = name;
|
|
}
|
|
|
|
/** Human-readable description of what this eval measures. */
|
|
description(desc: string): this {
|
|
this.desc = desc;
|
|
return this;
|
|
}
|
|
|
|
/** Set the judge model (LLM-as-judge mode). */
|
|
model(modelId: string): this {
|
|
// TODO: support full model config
|
|
this.modelId = modelId;
|
|
return this;
|
|
}
|
|
|
|
/** Declare a credential for the judge model. */
|
|
credential(name: string): this {
|
|
this.credentialName = name;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Set a deterministic check function.
|
|
* Mutually exclusive with `.judge()`.
|
|
*/
|
|
check(fn: CheckFn): this {
|
|
if (this.judgeFn) {
|
|
throw new Error(`Eval "${this.evalName}": cannot use both .check() and .judge()`);
|
|
}
|
|
this.checkFn = fn;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Set an LLM-as-judge handler. Requires `.model()` and `.credential()`.
|
|
* The handler receives `{ input, output, expected, llm }` where `llm`
|
|
* is a callable function bound to the judge model.
|
|
* Mutually exclusive with `.check()`.
|
|
*/
|
|
judge(fn: JudgeHandlerFn): this {
|
|
if (this.checkFn) {
|
|
throw new Error(`Eval "${this.evalName}": cannot use both .check() and .judge()`);
|
|
}
|
|
this.judgeFn = fn;
|
|
return this;
|
|
}
|
|
|
|
/** The eval name. */
|
|
get name(): string {
|
|
return this.evalName;
|
|
}
|
|
|
|
/** @internal Build the eval into a runnable form. */
|
|
protected build(): BuiltEval {
|
|
if (!this.checkFn && !this.judgeFn) {
|
|
throw new Error(`Eval "${this.evalName}" requires either .check() or .judge()`);
|
|
}
|
|
|
|
if (this.judgeFn && !this.modelId) {
|
|
throw new Error(`Eval "${this.evalName}" uses .judge() but no .model() was set`);
|
|
}
|
|
|
|
const name = this.evalName;
|
|
const desc = this.desc;
|
|
|
|
if (this.checkFn) {
|
|
const checkFn = this.checkFn;
|
|
return {
|
|
name,
|
|
description: desc,
|
|
evalType: 'check' as const,
|
|
modelId: this.modelId ?? null,
|
|
credentialName: this.credentialName ?? null,
|
|
_run: async (input: EvalInput) => await checkFn(input),
|
|
};
|
|
}
|
|
|
|
// LLM-as-judge mode
|
|
const judgeFn = this.judgeFn!;
|
|
if (!this.modelId) {
|
|
throw new Error(`Eval "${this.evalName}" uses .judge() but no .model() was set`);
|
|
}
|
|
const modelConfig: ModelConfig = this.modelId;
|
|
|
|
const runtime = new AgentRuntime({
|
|
name: `${name}-judge`,
|
|
model: modelConfig,
|
|
instructions: 'You are an evaluation judge. Respond precisely as instructed.',
|
|
});
|
|
|
|
const llm: JudgeFn = async (prompt: string) => {
|
|
const result = await runtime.generate([
|
|
{ role: 'user', content: [{ type: 'text', text: prompt }] },
|
|
]);
|
|
return { text: extractText(result.messages) };
|
|
};
|
|
|
|
return {
|
|
name,
|
|
description: desc,
|
|
evalType: 'judge' as const,
|
|
modelId: this.modelId ?? null,
|
|
credentialName: this.credentialName ?? null,
|
|
_run: async (input: EvalInput) => await judgeFn({ ...input, llm }),
|
|
};
|
|
}
|
|
|
|
/** @internal Ensure the eval is built (lazy). */
|
|
private _built?: BuiltEval;
|
|
|
|
/** @internal */
|
|
ensureBuilt(): BuiltEval {
|
|
this._built ??= this.build();
|
|
return this._built;
|
|
}
|
|
|
|
/** Run this eval against a single input. Lazy-builds on first call. */
|
|
async run(input: EvalInput): Promise<EvalScore> {
|
|
return await this.ensureBuilt()._run(input);
|
|
}
|
|
}
|