mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-29 19:10:43 +08:00
fix(ai-builder): Remove pairwise multi-gen evals and improve logs (no-changelog) (#24270)
Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
This commit is contained in:
@@ -207,28 +207,37 @@ The runner flushes pending trace batches before returning, so traces/results rel
|
||||
|
||||
```typescript
|
||||
// Inside runLangsmith():
|
||||
// IMPORTANT: Do NOT wrap target with traceable() - see "LangSmith Tracing" section below
|
||||
|
||||
// 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) => {
|
||||
// Wrap inner operations with traceable() for child traces
|
||||
const traceableGenerate = traceable(
|
||||
async () => await generateWorkflow(prompt),
|
||||
{ name: 'workflow_generation', run_type: 'chain', client: lsClient }
|
||||
);
|
||||
const workflow = await traceableGenerate();
|
||||
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 evaluator converts internal `{ evaluator, metric }` into `{ key, score, comment? }`:
|
||||
const feedbackExtractor = (run) => run.outputs.feedback.map(toLangsmithEvaluationResult);
|
||||
```
|
||||
|
||||
## LangSmith Tracing (Critical)
|
||||
## LangSmith Tracing
|
||||
|
||||
Do this in LangSmith mode:
|
||||
|
||||
1. **Do not** wrap the top-level `target` with `traceable()` — `evaluate()` will wrap it and wire up the run correctly.
|
||||
2. **Do** wrap inner operations with `traceable()` for child traces, and pass `client: lsClient` so child runs attach to the same project/run tree.
|
||||
- **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
|
||||
|
||||
@@ -260,11 +269,10 @@ const evaluator = createPairwiseEvaluator(llm, { numJudges: 3 });
|
||||
|
||||
**Evaluator:** `pairwise`
|
||||
|
||||
**Metrics (v1-compatible):**
|
||||
- Single-gen: `pairwise_primary`, `pairwise_diagnostic`, `pairwise_judges_passed`, `pairwise_total_passes`, `pairwise_total_violations`
|
||||
- Multi-gen: `pairwise_generation_correctness`, `pairwise_aggregated_diagnostic`, `pairwise_generations_passed`, `pairwise_total_judge_calls`
|
||||
**Metrics:**
|
||||
`pairwise_primary`, `pairwise_diagnostic`, `pairwise_judges_passed`, `pairwise_total_passes`, `pairwise_total_violations`
|
||||
|
||||
Additional per-judge/per-generation details may also be emitted (e.g. `judge1`, `gen1.majorityPass`).
|
||||
Additional per-judge details may also be emitted (e.g. `judge1`, `judge2`).
|
||||
|
||||
**Context required:** `{ dos?: string, donts?: string }`
|
||||
|
||||
|
||||
@@ -88,7 +88,6 @@ function createMockArgs(overrides: Record<string, unknown> = {}) {
|
||||
dos: undefined,
|
||||
donts: undefined,
|
||||
numJudges: 3,
|
||||
numGenerations: 1,
|
||||
experimentName: undefined,
|
||||
repetitions: 1,
|
||||
concurrency: 4,
|
||||
@@ -276,9 +275,9 @@ describe('CLI', () => {
|
||||
// Verify numJudges was passed correctly
|
||||
const callArgs = mockCreatePairwiseEvaluator.mock.calls[0] as [
|
||||
unknown,
|
||||
{ numJudges: number; numGenerations: number },
|
||||
{ numJudges: number },
|
||||
];
|
||||
expect(callArgs[1]).toEqual({ numJudges: 5, numGenerations: 1 });
|
||||
expect(callArgs[1]).toEqual({ numJudges: 5 });
|
||||
expect(mockCreateProgrammaticEvaluator).toHaveBeenCalled();
|
||||
expect(mockCreateLLMJudgeEvaluator).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -388,7 +387,7 @@ describe('CLI', () => {
|
||||
});
|
||||
|
||||
describe('exit codes', () => {
|
||||
it('should exit with 0 when pass rate >= 70%', async () => {
|
||||
it('should always exit with 0 on successful completion (pass/fail is informational)', async () => {
|
||||
mockRunEvaluation.mockResolvedValue(createMockSummary({ totalExamples: 10, passed: 7 }));
|
||||
|
||||
const { runV2Evaluation } = await import('../cli');
|
||||
@@ -396,28 +395,20 @@ describe('CLI', () => {
|
||||
await expect(runV2Evaluation()).rejects.toThrow('process.exit(0)');
|
||||
});
|
||||
|
||||
it('should exit with 1 when pass rate < 70%', async () => {
|
||||
it('should exit with 0 even when pass rate is low', async () => {
|
||||
mockRunEvaluation.mockResolvedValue(createMockSummary({ totalExamples: 10, passed: 5 }));
|
||||
|
||||
const { runV2Evaluation } = await import('../cli');
|
||||
|
||||
await expect(runV2Evaluation()).rejects.toThrow('process.exit(1)');
|
||||
});
|
||||
|
||||
it('should exit with 0 when pass rate is exactly 70%', async () => {
|
||||
mockRunEvaluation.mockResolvedValue(createMockSummary({ totalExamples: 10, passed: 7 }));
|
||||
|
||||
const { runV2Evaluation } = await import('../cli');
|
||||
|
||||
await expect(runV2Evaluation()).rejects.toThrow('process.exit(0)');
|
||||
});
|
||||
|
||||
it('should exit with 1 when no examples', async () => {
|
||||
it('should exit with 0 even when no examples', async () => {
|
||||
mockRunEvaluation.mockResolvedValue(createMockSummary({ totalExamples: 0, passed: 0 }));
|
||||
|
||||
const { runV2Evaluation } = await import('../cli');
|
||||
|
||||
await expect(runV2Evaluation()).rejects.toThrow('process.exit(1)');
|
||||
await expect(runV2Evaluation()).rejects.toThrow('process.exit(0)');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
-250
@@ -7,7 +7,6 @@
|
||||
|
||||
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';
|
||||
|
||||
@@ -288,253 +287,4 @@ describe('Pairwise Evaluator', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPairwiseEvaluator() multi-gen', () => {
|
||||
it('should use single-gen behavior when numGenerations is 1', async () => {
|
||||
mockRunJudgePanel.mockResolvedValue(createMockPanelResult());
|
||||
|
||||
const { createPairwiseEvaluator } = await import('../../evaluators/pairwise');
|
||||
const evaluator = createPairwiseEvaluator(mockLlm, { numGenerations: 1 });
|
||||
|
||||
const workflow = createMockWorkflow();
|
||||
const feedback = await evaluator.evaluate(workflow, { prompt: 'Test prompt' });
|
||||
|
||||
// Should have single-gen feedback keys
|
||||
expect(findFeedback(feedback, PAIRWISE_METRICS.PAIRWISE_PRIMARY)).toBeDefined();
|
||||
expect(findFeedback(feedback, PAIRWISE_METRICS.PAIRWISE_DIAGNOSTIC)).toBeDefined();
|
||||
// Should NOT have multi-gen keys
|
||||
expect(
|
||||
findFeedback(feedback, PAIRWISE_METRICS.PAIRWISE_GENERATION_CORRECTNESS),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should generate multiple workflows when numGenerations > 1', async () => {
|
||||
mockRunJudgePanel.mockResolvedValue(createMockPanelResult());
|
||||
|
||||
const { createPairwiseEvaluator } = await import('../../evaluators/pairwise');
|
||||
const evaluator = createPairwiseEvaluator(mockLlm, { numJudges: 3, numGenerations: 3 });
|
||||
|
||||
const workflow = createMockWorkflow();
|
||||
const mockGenerateWorkflow = jest.fn().mockResolvedValue(createMockWorkflow());
|
||||
|
||||
await evaluator.evaluate(workflow, {
|
||||
dos: 'Test',
|
||||
donts: 'Test',
|
||||
prompt: 'Create a workflow',
|
||||
generateWorkflow: mockGenerateWorkflow,
|
||||
});
|
||||
|
||||
// Should have called generateWorkflow 3 times
|
||||
expect(mockGenerateWorkflow).toHaveBeenCalledTimes(3);
|
||||
// Should have called runJudgePanel 3 times (once per generation)
|
||||
expect(mockRunJudgePanel).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should limit generation concurrency when llmCallLimiter is provided', async () => {
|
||||
mockRunJudgePanel.mockResolvedValue(createMockPanelResult());
|
||||
|
||||
const { createPairwiseEvaluator } = await import('../../evaluators/pairwise');
|
||||
const evaluator = createPairwiseEvaluator(mockLlm, { numJudges: 3, numGenerations: 3 });
|
||||
|
||||
const workflow = createMockWorkflow();
|
||||
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
const mockGenerateWorkflow = jest.fn().mockImplementation(async () => {
|
||||
active++;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
active--;
|
||||
return createMockWorkflow();
|
||||
});
|
||||
|
||||
await evaluator.evaluate(workflow, {
|
||||
prompt: 'Create a workflow',
|
||||
generateWorkflow: mockGenerateWorkflow,
|
||||
llmCallLimiter: pLimit(1),
|
||||
});
|
||||
|
||||
expect(mockGenerateWorkflow).toHaveBeenCalledTimes(3);
|
||||
expect(maxActive).toBe(1);
|
||||
});
|
||||
|
||||
it('should aggregate results across generations', async () => {
|
||||
// First two generations pass, third fails
|
||||
mockRunJudgePanel
|
||||
.mockResolvedValueOnce(
|
||||
createMockPanelResult({ majorityPass: true, avgDiagnosticScore: 0.9 }),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createMockPanelResult({ majorityPass: true, avgDiagnosticScore: 0.8 }),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createMockPanelResult({ majorityPass: false, avgDiagnosticScore: 0.5 }),
|
||||
);
|
||||
|
||||
const { createPairwiseEvaluator } = await import('../../evaluators/pairwise');
|
||||
const evaluator = createPairwiseEvaluator(mockLlm, { numJudges: 3, numGenerations: 3 });
|
||||
|
||||
const workflow = createMockWorkflow();
|
||||
const mockGenerateWorkflow = jest.fn().mockResolvedValue(createMockWorkflow());
|
||||
|
||||
const feedback = await evaluator.evaluate(workflow, {
|
||||
prompt: 'Create a workflow',
|
||||
generateWorkflow: mockGenerateWorkflow,
|
||||
});
|
||||
|
||||
// Should have multi-gen feedback
|
||||
const correctness = findFeedback(feedback, PAIRWISE_METRICS.PAIRWISE_GENERATION_CORRECTNESS);
|
||||
expect(correctness?.score).toBeCloseTo(2 / 3); // 2 of 3 passed
|
||||
expect(correctness?.comment).toBe('2/3 generations passed');
|
||||
|
||||
const diagnostic = findFeedback(feedback, PAIRWISE_METRICS.PAIRWISE_AGGREGATED_DIAGNOSTIC);
|
||||
expect(diagnostic?.score).toBeCloseTo((0.9 + 0.8 + 0.5) / 3);
|
||||
});
|
||||
|
||||
it('should return per-generation feedback', async () => {
|
||||
mockRunJudgePanel
|
||||
.mockResolvedValueOnce(
|
||||
createMockPanelResult({
|
||||
majorityPass: true,
|
||||
avgDiagnosticScore: 0.9,
|
||||
primaryPasses: 3,
|
||||
judgeResults: [
|
||||
{
|
||||
primaryPass: true,
|
||||
diagnosticScore: 0.9,
|
||||
violations: [],
|
||||
passes: [{ rule: 'Has trigger', justification: 'OK' }],
|
||||
},
|
||||
{
|
||||
primaryPass: true,
|
||||
diagnosticScore: 0.9,
|
||||
violations: [],
|
||||
passes: [{ rule: 'Has trigger', justification: 'OK' }],
|
||||
},
|
||||
{
|
||||
primaryPass: true,
|
||||
diagnosticScore: 0.9,
|
||||
violations: [],
|
||||
passes: [{ rule: 'Has trigger', justification: 'OK' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createMockPanelResult({
|
||||
majorityPass: false,
|
||||
avgDiagnosticScore: 0.4,
|
||||
primaryPasses: 1,
|
||||
judgeResults: [
|
||||
{
|
||||
primaryPass: true,
|
||||
diagnosticScore: 0.5,
|
||||
violations: [],
|
||||
passes: [{ rule: 'Has trigger', justification: 'OK' }],
|
||||
},
|
||||
{
|
||||
primaryPass: false,
|
||||
diagnosticScore: 0.3,
|
||||
violations: [{ rule: 'Missing Notion', justification: 'No Notion node found' }],
|
||||
passes: [],
|
||||
},
|
||||
{
|
||||
primaryPass: false,
|
||||
diagnosticScore: 0.4,
|
||||
violations: [{ rule: 'No HTTP', justification: 'Contains HTTP Request node' }],
|
||||
passes: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const { createPairwiseEvaluator } = await import('../../evaluators/pairwise');
|
||||
const evaluator = createPairwiseEvaluator(mockLlm, { numJudges: 3, numGenerations: 2 });
|
||||
|
||||
const workflow = createMockWorkflow();
|
||||
const mockGenerateWorkflow = jest.fn().mockResolvedValue(createMockWorkflow());
|
||||
|
||||
const feedback = await evaluator.evaluate(workflow, {
|
||||
prompt: 'Create a workflow',
|
||||
generateWorkflow: mockGenerateWorkflow,
|
||||
});
|
||||
|
||||
// Gen 1 feedback
|
||||
const gen1Pass = findFeedback(feedback, 'gen1.majorityPass');
|
||||
expect(gen1Pass?.score).toBe(1);
|
||||
expect(gen1Pass?.comment).toBe('3/3 judges');
|
||||
|
||||
const gen1Diag = findFeedback(feedback, 'gen1.diagnosticScore');
|
||||
expect(gen1Diag?.score).toBe(0.9);
|
||||
|
||||
// Gen 2 feedback
|
||||
const gen2Pass = findFeedback(feedback, 'gen2.majorityPass');
|
||||
expect(gen2Pass?.score).toBe(0);
|
||||
expect(gen2Pass?.comment).toBe('1/3 judges');
|
||||
|
||||
const gen2Diag = findFeedback(feedback, 'gen2.diagnosticScore');
|
||||
expect(gen2Diag?.score).toBe(0.4);
|
||||
|
||||
const gen2Judge2 = findFeedback(feedback, 'gen2.judge2');
|
||||
expect(gen2Judge2?.score).toBe(0);
|
||||
expect(gen2Judge2?.comment).toContain('[Missing Notion] No Notion node found');
|
||||
|
||||
const gen2Judge3 = findFeedback(feedback, 'gen2.judge3');
|
||||
expect(gen2Judge3?.score).toBe(0);
|
||||
expect(gen2Judge3?.comment).toContain('[No HTTP] Contains HTTP Request node');
|
||||
});
|
||||
|
||||
it('should throw if generateWorkflow missing in context for multi-gen', async () => {
|
||||
const { createPairwiseEvaluator } = await import('../../evaluators/pairwise');
|
||||
const evaluator = createPairwiseEvaluator(mockLlm, { numGenerations: 3 });
|
||||
|
||||
const workflow = createMockWorkflow();
|
||||
|
||||
await expect(
|
||||
evaluator.evaluate(workflow, {
|
||||
prompt: 'Create a workflow',
|
||||
// Missing generateWorkflow
|
||||
}),
|
||||
).rejects.toThrow('Multi-gen requires generateWorkflow and prompt in context');
|
||||
});
|
||||
|
||||
it('should throw if prompt missing in context for multi-gen', async () => {
|
||||
const { createPairwiseEvaluator } = await import('../../evaluators/pairwise');
|
||||
const evaluator = createPairwiseEvaluator(mockLlm, { numGenerations: 3 });
|
||||
|
||||
const workflow = createMockWorkflow();
|
||||
const mockGenerateWorkflow = jest.fn().mockResolvedValue(createMockWorkflow());
|
||||
|
||||
await expect(
|
||||
// @ts-expect-error Intentionally missing required prompt to verify runtime validation
|
||||
evaluator.evaluate(workflow, {
|
||||
generateWorkflow: mockGenerateWorkflow,
|
||||
// Missing prompt
|
||||
}),
|
||||
).rejects.toThrow('Multi-gen requires generateWorkflow and prompt in context');
|
||||
});
|
||||
|
||||
it('should run all generations in parallel', async () => {
|
||||
// Track call order with timestamps
|
||||
const callTimes: number[] = [];
|
||||
mockRunJudgePanel.mockImplementation(async () => {
|
||||
callTimes.push(Date.now());
|
||||
await new Promise((r) => setTimeout(r, 50)); // Simulate async work
|
||||
return createMockPanelResult();
|
||||
});
|
||||
|
||||
const { createPairwiseEvaluator } = await import('../../evaluators/pairwise');
|
||||
const evaluator = createPairwiseEvaluator(mockLlm, { numJudges: 3, numGenerations: 3 });
|
||||
|
||||
const workflow = createMockWorkflow();
|
||||
const mockGenerateWorkflow = jest.fn().mockResolvedValue(createMockWorkflow());
|
||||
|
||||
await evaluator.evaluate(workflow, {
|
||||
prompt: 'Create a workflow',
|
||||
generateWorkflow: mockGenerateWorkflow,
|
||||
});
|
||||
|
||||
expect(callTimes).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -276,14 +276,14 @@ describe('Console Lifecycle', () => {
|
||||
feedback: [
|
||||
{
|
||||
evaluator: 'pairwise',
|
||||
metric: 'pairwise_generation_correctness',
|
||||
score: 0.5,
|
||||
comment: '1/2 generations passed',
|
||||
metric: 'pairwise_primary',
|
||||
score: 0,
|
||||
comment: '1/3 judges passed',
|
||||
kind: 'score',
|
||||
},
|
||||
{
|
||||
evaluator: 'pairwise',
|
||||
metric: 'gen2.judge2',
|
||||
metric: 'judge2',
|
||||
score: 0,
|
||||
comment: '[No HTTP] Contains HTTP Request node',
|
||||
kind: 'detail',
|
||||
@@ -297,7 +297,7 @@ describe('Console Lifecycle', () => {
|
||||
expect(mockConsole.log).toHaveBeenCalled();
|
||||
const logOutput = mockConsole.log.mock.calls.flat().join(' ');
|
||||
expect(logOutput).toContain('issues');
|
||||
expect(logOutput).toContain('gen2.judge2');
|
||||
expect(logOutput).toContain('judge2');
|
||||
expect(logOutput).toContain('Contains HTTP Request node');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
/**
|
||||
* Tests for multi-generation utilities.
|
||||
*
|
||||
* These utilities support aggregating results across multiple workflow
|
||||
* generations in pairwise evaluation.
|
||||
*/
|
||||
|
||||
import type { SimpleWorkflow } from '@/types/workflow';
|
||||
|
||||
import {
|
||||
getMajorityThreshold,
|
||||
aggregateGenerations,
|
||||
type GenerationDetail,
|
||||
} from '../harness/multi-gen';
|
||||
|
||||
/** Helper to create a mock workflow */
|
||||
function createMockWorkflow(name = 'Test Workflow'): SimpleWorkflow {
|
||||
return { name, nodes: [], connections: {} };
|
||||
}
|
||||
|
||||
/** Helper to create a generation detail */
|
||||
function createGenerationDetail(overrides: Partial<GenerationDetail> = {}): GenerationDetail {
|
||||
return {
|
||||
workflow: createMockWorkflow(),
|
||||
majorityPass: true,
|
||||
diagnosticScore: 0.8,
|
||||
primaryPasses: 2,
|
||||
numJudges: 3,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Multi-Generation Utilities', () => {
|
||||
describe('getMajorityThreshold()', () => {
|
||||
it('should throw for 0 judges', () => {
|
||||
expect(() => getMajorityThreshold(0)).toThrow('numJudges must be >= 1');
|
||||
});
|
||||
|
||||
it('should return 1 for 1 judge', () => {
|
||||
expect(getMajorityThreshold(1)).toBe(1);
|
||||
});
|
||||
|
||||
it('should return 2 for 3 judges', () => {
|
||||
expect(getMajorityThreshold(3)).toBe(2);
|
||||
});
|
||||
|
||||
it('should return 3 for 5 judges', () => {
|
||||
expect(getMajorityThreshold(5)).toBe(3);
|
||||
});
|
||||
|
||||
it('should return 1 for 2 judges (tie-goes-to-pass)', () => {
|
||||
expect(getMajorityThreshold(2)).toBe(1);
|
||||
});
|
||||
|
||||
it('should return 2 for 4 judges (tie-goes-to-pass)', () => {
|
||||
expect(getMajorityThreshold(4)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateGenerations()', () => {
|
||||
it('should calculate generation correctness', () => {
|
||||
const details = [
|
||||
createGenerationDetail({ majorityPass: true }),
|
||||
createGenerationDetail({ majorityPass: true }),
|
||||
createGenerationDetail({ majorityPass: false }),
|
||||
];
|
||||
|
||||
const result = aggregateGenerations(details);
|
||||
|
||||
expect(result.generationCorrectness).toBeCloseTo(2 / 3);
|
||||
expect(result.passingGenerations).toBe(2);
|
||||
expect(result.totalGenerations).toBe(3);
|
||||
});
|
||||
|
||||
it('should calculate aggregated diagnostic score', () => {
|
||||
const details = [
|
||||
createGenerationDetail({ diagnosticScore: 0.9 }),
|
||||
createGenerationDetail({ diagnosticScore: 0.8 }),
|
||||
createGenerationDetail({ diagnosticScore: 0.7 }),
|
||||
];
|
||||
|
||||
const result = aggregateGenerations(details);
|
||||
|
||||
expect(result.aggregatedDiagnosticScore).toBeCloseTo(0.8);
|
||||
});
|
||||
|
||||
it('should count passing generations', () => {
|
||||
const details = [
|
||||
createGenerationDetail({ majorityPass: true }),
|
||||
createGenerationDetail({ majorityPass: false }),
|
||||
createGenerationDetail({ majorityPass: true }),
|
||||
createGenerationDetail({ majorityPass: true }),
|
||||
createGenerationDetail({ majorityPass: false }),
|
||||
];
|
||||
|
||||
const result = aggregateGenerations(details);
|
||||
|
||||
expect(result.passingGenerations).toBe(3);
|
||||
expect(result.totalGenerations).toBe(5);
|
||||
});
|
||||
|
||||
it('should handle all passing', () => {
|
||||
const details = [
|
||||
createGenerationDetail({ majorityPass: true, diagnosticScore: 1.0 }),
|
||||
createGenerationDetail({ majorityPass: true, diagnosticScore: 1.0 }),
|
||||
createGenerationDetail({ majorityPass: true, diagnosticScore: 1.0 }),
|
||||
];
|
||||
|
||||
const result = aggregateGenerations(details);
|
||||
|
||||
expect(result.generationCorrectness).toBe(1);
|
||||
expect(result.aggregatedDiagnosticScore).toBe(1);
|
||||
expect(result.passingGenerations).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle all failing', () => {
|
||||
const details = [
|
||||
createGenerationDetail({ majorityPass: false, diagnosticScore: 0.3 }),
|
||||
createGenerationDetail({ majorityPass: false, diagnosticScore: 0.2 }),
|
||||
createGenerationDetail({ majorityPass: false, diagnosticScore: 0.1 }),
|
||||
];
|
||||
|
||||
const result = aggregateGenerations(details);
|
||||
|
||||
expect(result.generationCorrectness).toBe(0);
|
||||
expect(result.aggregatedDiagnosticScore).toBeCloseTo(0.2);
|
||||
expect(result.passingGenerations).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle single generation', () => {
|
||||
const details = [createGenerationDetail({ majorityPass: true, diagnosticScore: 0.95 })];
|
||||
|
||||
const result = aggregateGenerations(details);
|
||||
|
||||
expect(result.generationCorrectness).toBe(1);
|
||||
expect(result.aggregatedDiagnosticScore).toBe(0.95);
|
||||
expect(result.passingGenerations).toBe(1);
|
||||
expect(result.totalGenerations).toBe(1);
|
||||
});
|
||||
|
||||
it('should preserve generation details', () => {
|
||||
const workflow1 = createMockWorkflow('Workflow 1');
|
||||
const workflow2 = createMockWorkflow('Workflow 2');
|
||||
|
||||
const details = [
|
||||
createGenerationDetail({ workflow: workflow1, primaryPasses: 3 }),
|
||||
createGenerationDetail({ workflow: workflow2, primaryPasses: 1 }),
|
||||
];
|
||||
|
||||
const result = aggregateGenerations(details);
|
||||
|
||||
expect(result.generationDetails).toHaveLength(2);
|
||||
expect(result.generationDetails[0].workflow.name).toBe('Workflow 1');
|
||||
expect(result.generationDetails[0].primaryPasses).toBe(3);
|
||||
expect(result.generationDetails[1].workflow.name).toBe('Workflow 2');
|
||||
expect(result.generationDetails[1].primaryPasses).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,6 @@ export interface EvaluationArgs {
|
||||
donts?: string;
|
||||
|
||||
numJudges: number;
|
||||
numGenerations: number;
|
||||
|
||||
featureFlags?: BuilderFeatureFlags;
|
||||
}
|
||||
@@ -63,7 +62,6 @@ const cliSchema = z
|
||||
donts: z.string().min(1).optional(),
|
||||
|
||||
numJudges: z.coerce.number().int().positive().default(DEFAULTS.NUM_JUDGES),
|
||||
numGenerations: z.coerce.number().int().positive().max(10).default(DEFAULTS.NUM_GENERATIONS),
|
||||
|
||||
langsmith: z.boolean().optional(),
|
||||
templateExamples: z.boolean().default(false),
|
||||
@@ -189,12 +187,6 @@ const FLAG_DEFS: Record<string, FlagDef> = {
|
||||
|
||||
// Advanced
|
||||
'--judges': { key: 'numJudges', kind: 'string', group: 'advanced', desc: 'Number of LLM judges' },
|
||||
'--generations': {
|
||||
key: 'numGenerations',
|
||||
kind: 'string',
|
||||
group: 'advanced',
|
||||
desc: 'Workflow generations per prompt',
|
||||
},
|
||||
};
|
||||
|
||||
// Aliases (not shown in help)
|
||||
@@ -438,7 +430,6 @@ export function parseEvaluationArgs(argv: string[] = process.argv.slice(2)): Eva
|
||||
dos: parsed.dos,
|
||||
donts: parsed.donts,
|
||||
numJudges: parsed.numJudges,
|
||||
numGenerations: parsed.numGenerations,
|
||||
featureFlags,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,7 +34,11 @@ import {
|
||||
loadDefaultTestCases,
|
||||
getDefaultTestCaseIds,
|
||||
} from './csv-prompt-loader';
|
||||
import { consumeGenerator, getChatPayload } from '../harness/evaluation-helpers';
|
||||
import {
|
||||
consumeGenerator,
|
||||
getChatPayload,
|
||||
getTracingCallbacks,
|
||||
} from '../harness/evaluation-helpers';
|
||||
import { createLogger } from '../harness/logger';
|
||||
import { generateRunId, isWorkflowStateValues } from '../langsmith/types';
|
||||
import { EVAL_TYPES, EVAL_USERS } from '../support/constants';
|
||||
@@ -42,7 +46,8 @@ import { setupTestEnvironment, createAgent } from '../support/environment';
|
||||
|
||||
/**
|
||||
* Create a workflow generator function.
|
||||
* NOTE: Don't pass a tracer - LangSmith tracing is handled via traceable() in the runner.
|
||||
* LangSmith tracing is handled via traceable() in the runner.
|
||||
* We bridge the trace context to LangChain via getTracingCallbacks().
|
||||
*/
|
||||
function createWorkflowGenerator(
|
||||
parsedNodeTypes: INodeTypeDescription[],
|
||||
@@ -51,6 +56,7 @@ function createWorkflowGenerator(
|
||||
): (prompt: string) => Promise<SimpleWorkflow> {
|
||||
return async (prompt: string): Promise<SimpleWorkflow> => {
|
||||
const runId = generateRunId();
|
||||
const callbacks = await getTracingCallbacks();
|
||||
|
||||
const agent = createAgent({
|
||||
parsedNodeTypes,
|
||||
@@ -67,6 +73,8 @@ function createWorkflowGenerator(
|
||||
featureFlags,
|
||||
}),
|
||||
EVAL_USERS.LANGSMITH,
|
||||
undefined, // abortSignal
|
||||
callbacks,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -166,7 +174,6 @@ export async function runV2Evaluation(): Promise<void> {
|
||||
evaluators.push(
|
||||
createPairwiseEvaluator(env.llm, {
|
||||
numJudges: args.numJudges,
|
||||
numGenerations: args.numGenerations,
|
||||
}),
|
||||
);
|
||||
evaluators.push(createProgrammaticEvaluator(env.parsedNodeTypes));
|
||||
@@ -179,8 +186,6 @@ export async function runV2Evaluation(): Promise<void> {
|
||||
break;
|
||||
}
|
||||
|
||||
// Build context - include generateWorkflow for multi-gen pairwise
|
||||
const isMultiGen = args.suite === 'pairwise' && args.numGenerations > 1;
|
||||
const llmCallLimiter = pLimit(args.concurrency);
|
||||
|
||||
const baseConfig = {
|
||||
@@ -190,7 +195,7 @@ export async function runV2Evaluation(): Promise<void> {
|
||||
logger,
|
||||
outputDir: args.outputDir,
|
||||
timeoutMs: args.timeoutMs,
|
||||
context: isMultiGen ? { generateWorkflow, llmCallLimiter } : { llmCallLimiter },
|
||||
context: { llmCallLimiter },
|
||||
};
|
||||
|
||||
const config: RunConfig =
|
||||
@@ -210,9 +215,7 @@ export async function runV2Evaluation(): Promise<void> {
|
||||
args.suite === 'pairwise'
|
||||
? {
|
||||
numJudges: args.numJudges,
|
||||
numGenerations: args.numGenerations,
|
||||
scoringMethod:
|
||||
args.numGenerations > 1 ? 'hierarchical-multi-generation' : 'hierarchical',
|
||||
scoringMethod: 'hierarchical',
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
@@ -224,12 +227,10 @@ export async function runV2Evaluation(): Promise<void> {
|
||||
};
|
||||
|
||||
// Run evaluation
|
||||
const summary = await runEvaluation(config);
|
||||
await runEvaluation(config);
|
||||
|
||||
// Exit with appropriate code
|
||||
// Check pass rate
|
||||
const passRate = summary.totalExamples > 0 ? summary.passed / summary.totalExamples : 0;
|
||||
process.exit(passRate >= 0.7 ? 0 : 1);
|
||||
// Always exit 0 on successful completion - pass/fail is informational, not an error
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
|
||||
@@ -4,19 +4,12 @@ import type { SimpleWorkflow } from '@/types/workflow';
|
||||
|
||||
import { runJudgePanel, type EvalCriteria } from './judge-panel';
|
||||
import { PAIRWISE_METRICS } from './metrics';
|
||||
import { runWithOptionalLimiter, withTimeout } from '../../harness/evaluation-helpers';
|
||||
import type { EvaluationContext, Evaluator, Feedback } from '../../harness/harness-types';
|
||||
import { aggregateGenerations, type GenerationDetail } from '../../harness/multi-gen';
|
||||
|
||||
type MultiGenContext = EvaluationContext & {
|
||||
generateWorkflow: (prompt: string) => Promise<SimpleWorkflow>;
|
||||
};
|
||||
|
||||
function assertMultiGenContext(ctx: EvaluationContext): asserts ctx is MultiGenContext {
|
||||
if (!ctx.generateWorkflow || !ctx.prompt) {
|
||||
throw new Error('Multi-gen requires generateWorkflow and prompt in context');
|
||||
}
|
||||
}
|
||||
import type {
|
||||
DisplayLine,
|
||||
EvaluationContext,
|
||||
Evaluator,
|
||||
Feedback,
|
||||
} from '../../harness/harness-types';
|
||||
|
||||
/**
|
||||
* Options for creating a pairwise evaluator.
|
||||
@@ -24,247 +17,108 @@ function assertMultiGenContext(ctx: EvaluationContext): asserts ctx is MultiGenC
|
||||
export interface PairwiseEvaluatorOptions {
|
||||
/** Number of judges to run (default: 3) */
|
||||
numJudges?: number;
|
||||
/** Number of workflow generations (default: 1, no multi-gen) */
|
||||
numGenerations?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a single workflow with a judge panel.
|
||||
* Returns feedback with majority pass, diagnostic score, and per-judge results.
|
||||
*/
|
||||
async function evaluateSingleGeneration(
|
||||
llm: BaseChatModel,
|
||||
workflow: SimpleWorkflow,
|
||||
ctx: EvaluationContext,
|
||||
numJudges: number,
|
||||
): 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);
|
||||
|
||||
// v1-compatible single-generation 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];
|
||||
// Build comment from violations
|
||||
const violationSummary =
|
||||
judge.violations.length > 0
|
||||
? judge.violations.map((v) => `[${v.rule}] ${v.justification}`).join('; ')
|
||||
: undefined;
|
||||
feedback.push({
|
||||
evaluator: 'pairwise',
|
||||
metric: `judge${i + 1}`,
|
||||
score: judge.primaryPass ? 1 : 0,
|
||||
kind: 'detail',
|
||||
comment: violationSummary,
|
||||
});
|
||||
}
|
||||
|
||||
return feedback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate multiple workflow generations with a judge panel each.
|
||||
* Returns aggregated feedback with generation correctness and per-generation details.
|
||||
*/
|
||||
async function evaluateMultiGeneration(
|
||||
llm: BaseChatModel,
|
||||
ctx: MultiGenContext,
|
||||
numJudges: number,
|
||||
numGenerations: number,
|
||||
): Promise<Feedback[]> {
|
||||
const evalCriteria: EvalCriteria = {
|
||||
dos: ctx?.dos,
|
||||
donts: ctx?.donts,
|
||||
};
|
||||
|
||||
// Generate all workflows and evaluate in parallel
|
||||
const generationRuns = await Promise.all(
|
||||
Array.from({ length: numGenerations }, async (_, i) => {
|
||||
const workflow = await runWithOptionalLimiter(async () => {
|
||||
return await withTimeout({
|
||||
promise: ctx.generateWorkflow(ctx.prompt),
|
||||
timeoutMs: ctx.timeoutMs,
|
||||
label: 'pairwise:workflow_generation',
|
||||
});
|
||||
}, ctx.llmCallLimiter);
|
||||
const result = await runJudgePanel(llm, workflow, evalCriteria, numJudges, {
|
||||
generationIndex: i + 1,
|
||||
llmCallLimiter: ctx.llmCallLimiter,
|
||||
timeoutMs: ctx.timeoutMs,
|
||||
});
|
||||
return { workflow, result };
|
||||
}),
|
||||
);
|
||||
|
||||
const generationDetails: GenerationDetail[] = generationRuns.map(({ workflow, result }) => ({
|
||||
workflow,
|
||||
majorityPass: result.majorityPass,
|
||||
diagnosticScore: result.avgDiagnosticScore,
|
||||
primaryPasses: result.primaryPasses,
|
||||
numJudges,
|
||||
}));
|
||||
|
||||
// Aggregate results
|
||||
const aggregation = aggregateGenerations(generationDetails);
|
||||
|
||||
// Build feedback
|
||||
const feedback: Feedback[] = [
|
||||
{
|
||||
evaluator: 'pairwise',
|
||||
metric: PAIRWISE_METRICS.PAIRWISE_GENERATION_CORRECTNESS,
|
||||
score: aggregation.generationCorrectness,
|
||||
kind: 'score',
|
||||
comment: `${aggregation.passingGenerations}/${aggregation.totalGenerations} generations passed`,
|
||||
},
|
||||
{
|
||||
evaluator: 'pairwise',
|
||||
metric: PAIRWISE_METRICS.PAIRWISE_AGGREGATED_DIAGNOSTIC,
|
||||
score: aggregation.aggregatedDiagnosticScore,
|
||||
kind: 'metric',
|
||||
},
|
||||
{
|
||||
evaluator: 'pairwise',
|
||||
metric: PAIRWISE_METRICS.PAIRWISE_GENERATIONS_PASSED,
|
||||
score: aggregation.passingGenerations,
|
||||
kind: 'detail',
|
||||
},
|
||||
{
|
||||
evaluator: 'pairwise',
|
||||
metric: PAIRWISE_METRICS.PAIRWISE_TOTAL_JUDGE_CALLS,
|
||||
score: aggregation.totalGenerations * numJudges,
|
||||
kind: 'detail',
|
||||
},
|
||||
];
|
||||
|
||||
// Per-judge details (one set per generation)
|
||||
generationRuns.forEach(({ result }, genIndex) => {
|
||||
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;
|
||||
|
||||
feedback.push({
|
||||
evaluator: 'pairwise',
|
||||
metric: `gen${genIndex + 1}.judge${i + 1}`,
|
||||
score: judge.primaryPass ? 1 : 0,
|
||||
kind: 'detail',
|
||||
comment: violationSummary,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Per-generation details
|
||||
aggregation.generationDetails.forEach((gen, i) => {
|
||||
feedback.push({
|
||||
evaluator: 'pairwise',
|
||||
metric: `gen${i + 1}.majorityPass`,
|
||||
score: gen.majorityPass ? 1 : 0,
|
||||
kind: 'detail',
|
||||
comment: `${gen.primaryPasses}/${gen.numJudges} judges`,
|
||||
});
|
||||
feedback.push({
|
||||
evaluator: 'pairwise',
|
||||
metric: `gen${i + 1}.diagnosticScore`,
|
||||
score: gen.diagnosticScore,
|
||||
kind: 'detail',
|
||||
});
|
||||
});
|
||||
|
||||
return feedback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pairwise evaluator that uses a panel of judges.
|
||||
* Each judge evaluates the workflow against dos/donts criteria.
|
||||
*
|
||||
* For single generation (default): evaluates the provided workflow.
|
||||
* For multi-generation: generates N workflows and aggregates results.
|
||||
*
|
||||
* @param llm - Language model for evaluation
|
||||
* @param options - Configuration options
|
||||
* @returns An evaluator that produces feedback from pairwise evaluation
|
||||
*
|
||||
* @example Single generation
|
||||
* @example
|
||||
* ```typescript
|
||||
* const evaluator = createPairwiseEvaluator(llm, { numJudges: 3 });
|
||||
* const feedback = await evaluator.evaluate(workflow, { dos, donts });
|
||||
* ```
|
||||
*
|
||||
* @example Multi-generation
|
||||
* ```typescript
|
||||
* const evaluator = createPairwiseEvaluator(llm, { numJudges: 3, numGenerations: 5 });
|
||||
* const feedback = await evaluator.evaluate(workflow, {
|
||||
* dos, donts,
|
||||
* prompt: "Create a workflow...",
|
||||
* generateWorkflow: async (p) => agent.generate(p),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createPairwiseEvaluator(
|
||||
llm: BaseChatModel,
|
||||
options?: PairwiseEvaluatorOptions,
|
||||
): Evaluator<EvaluationContext> {
|
||||
const numJudges = options?.numJudges ?? 3;
|
||||
const numGenerations = options?.numGenerations ?? 1;
|
||||
|
||||
return {
|
||||
name: 'pairwise',
|
||||
|
||||
async evaluate(workflow: SimpleWorkflow, ctx: EvaluationContext): Promise<Feedback[]> {
|
||||
// Single generation (existing behavior)
|
||||
if (numGenerations === 1) {
|
||||
return await evaluateSingleGeneration(llm, workflow, ctx, numJudges);
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// Multi-generation - validate required context
|
||||
assertMultiGenContext(ctx);
|
||||
return await evaluateMultiGeneration(llm, ctx, numJudges, numGenerations);
|
||||
return feedback;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+21
-57
@@ -1,8 +1,13 @@
|
||||
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 { runWithOptionalLimiter, withTimeout } from '../../harness/evaluation-helpers';
|
||||
import {
|
||||
getTracingCallbacks,
|
||||
runWithOptionalLimiter,
|
||||
withTimeout,
|
||||
} from '../../harness/evaluation-helpers';
|
||||
import type { EvaluationContext } from '../../harness/harness-types';
|
||||
|
||||
// ============================================================================
|
||||
@@ -31,23 +36,6 @@ export interface JudgePanelResult {
|
||||
timing?: JudgePanelTiming;
|
||||
}
|
||||
|
||||
export interface GenerationResult extends JudgePanelResult {
|
||||
workflow: SimpleWorkflow;
|
||||
}
|
||||
|
||||
export interface MultiGenerationAggregation {
|
||||
/** Generation correctness: (# passing generations) / total generations */
|
||||
generationCorrectness: number;
|
||||
/** Average diagnostic score across all generations */
|
||||
aggregatedDiagnosticScore: number;
|
||||
/** Number of generations that passed majority vote */
|
||||
passingGenerations: number;
|
||||
/** Total number of generations run */
|
||||
totalGenerations: number;
|
||||
/** Detailed results for each generation */
|
||||
generationDetails: GenerationResult[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
@@ -69,8 +57,6 @@ export function getMajorityThreshold(numJudges: number): number {
|
||||
// ============================================================================
|
||||
|
||||
export interface JudgePanelOptions {
|
||||
/** Generation index (1-based) for metadata */
|
||||
generationIndex?: number;
|
||||
/** Experiment name for metadata */
|
||||
experimentName?: string;
|
||||
/** Optional limiter for LLM calls (shared across harness) */
|
||||
@@ -97,27 +83,30 @@ export async function runJudgePanel(
|
||||
numJudges: number,
|
||||
options?: JudgePanelOptions,
|
||||
): Promise<JudgePanelResult> {
|
||||
const { generationIndex, experimentName, llmCallLimiter, timeoutMs } = options ?? {};
|
||||
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 },
|
||||
{
|
||||
runName: `judge_${judgeIndex + 1}`,
|
||||
metadata: {
|
||||
...(experimentName && { experiment_name: experimentName }),
|
||||
...(generationIndex && { evaluating_generation: `generation_${generationIndex}` }),
|
||||
},
|
||||
},
|
||||
),
|
||||
promise: evaluateWorkflowPairwise(llm, { workflowJSON: workflow, evalCriteria }, config),
|
||||
timeoutMs,
|
||||
label: `pairwise:judge${judgeIndex + 1}`,
|
||||
});
|
||||
@@ -160,28 +149,3 @@ export function aggregateJudgeResults(
|
||||
avgDiagnosticScore,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Multi-Generation Aggregation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Aggregate results across multiple generations.
|
||||
*/
|
||||
export function aggregateGenerations(
|
||||
generationResults: GenerationResult[],
|
||||
): MultiGenerationAggregation {
|
||||
const totalGenerations = generationResults.length;
|
||||
const passingGenerations = generationResults.filter((g) => g.majorityPass).length;
|
||||
const generationCorrectness = passingGenerations / totalGenerations;
|
||||
const aggregatedDiagnosticScore =
|
||||
generationResults.reduce((sum, g) => sum + g.avgDiagnosticScore, 0) / totalGenerations;
|
||||
|
||||
return {
|
||||
generationCorrectness,
|
||||
aggregatedDiagnosticScore,
|
||||
passingGenerations,
|
||||
totalGenerations,
|
||||
generationDetails: generationResults,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
export const PAIRWISE_METRICS = {
|
||||
// Single generation 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',
|
||||
|
||||
// Multi-generation metrics
|
||||
PAIRWISE_AGGREGATED_DIAGNOSTIC: 'pairwise_aggregated_diagnostic',
|
||||
PAIRWISE_GENERATION_CORRECTNESS: 'pairwise_generation_correctness',
|
||||
PAIRWISE_GENERATIONS_PASSED: 'pairwise_generations_passed',
|
||||
PAIRWISE_TOTAL_JUDGE_CALLS: 'pairwise_total_judge_calls',
|
||||
} as const;
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import type { Callbacks } from '@langchain/core/callbacks/manager';
|
||||
import { getLangchainCallbacks } from 'langsmith/langchain';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import type { LlmCallLimiter } from './harness-types';
|
||||
import type { BuilderFeatureFlags, ChatPayload } from '../../src/workflow-builder-agent';
|
||||
import { DEFAULTS } from '../support/constants';
|
||||
|
||||
/**
|
||||
* Get LangChain callbacks that bridge the current traceable context.
|
||||
* Returns undefined if not in a traceable context.
|
||||
*/
|
||||
export async function getTracingCallbacks(): Promise<Callbacks | undefined> {
|
||||
try {
|
||||
return await getLangchainCallbacks();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function consumeGenerator<T>(gen: AsyncGenerator<T>) {
|
||||
for await (const _ of gen) {
|
||||
/* consume all */
|
||||
|
||||
@@ -21,11 +21,6 @@ export interface EvaluationContext {
|
||||
donts?: string;
|
||||
/** Optional reference workflows for similarity-based checks (best match wins) */
|
||||
referenceWorkflows?: SimpleWorkflow[];
|
||||
/**
|
||||
* Optional generator for multi-generation evaluations.
|
||||
* When present, pairwise evaluator can generate multiple workflows from the same prompt.
|
||||
*/
|
||||
generateWorkflow?: (prompt: string) => Promise<SimpleWorkflow>;
|
||||
/**
|
||||
* Optional limiter for LLM-bound work (generation + evaluators).
|
||||
* When provided, treat it as the global knob for overall parallel LLM calls.
|
||||
@@ -44,6 +39,15 @@ export type TestCaseContext = Omit<Partial<EvaluationContext>, 'prompt'>;
|
||||
/** Global context attached to a run (prompt is provided per test case). */
|
||||
export type GlobalRunContext = Omit<Partial<EvaluationContext>, 'prompt'>;
|
||||
|
||||
/**
|
||||
* A styled line for verbose display output.
|
||||
* Evaluators can provide these in `details.displayLines` for custom formatting.
|
||||
*/
|
||||
export interface DisplayLine {
|
||||
text: string;
|
||||
color?: 'yellow' | 'red' | 'dim';
|
||||
}
|
||||
|
||||
/**
|
||||
* What evaluators return - a single piece of feedback.
|
||||
*/
|
||||
@@ -62,6 +66,12 @@ export interface Feedback {
|
||||
* - `detail`: unstable/verbose metrics that should not affect scoring
|
||||
*/
|
||||
kind: 'score' | 'metric' | 'detail';
|
||||
/**
|
||||
* Optional structured details for rich display.
|
||||
* Evaluators can provide structured data here for better logging.
|
||||
* The `comment` field remains the primary text for LangSmith.
|
||||
*/
|
||||
details?: { displayLines?: DisplayLine[] } & Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import pc from 'picocolors';
|
||||
|
||||
import type {
|
||||
DisplayLine,
|
||||
EvaluationLifecycle,
|
||||
RunConfig,
|
||||
Feedback,
|
||||
@@ -64,10 +65,6 @@ const DISPLAY_METRICS_BY_EVALUATOR: Record<string, string[]> = {
|
||||
'pairwise_judges_passed',
|
||||
'pairwise_total_passes',
|
||||
'pairwise_total_violations',
|
||||
'pairwise_generation_correctness',
|
||||
'pairwise_aggregated_diagnostic',
|
||||
'pairwise_generations_passed',
|
||||
'pairwise_total_judge_calls',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -75,10 +72,36 @@ const PAIRWISE_COUNT_METRICS = new Set([
|
||||
'pairwise_judges_passed',
|
||||
'pairwise_total_passes',
|
||||
'pairwise_total_violations',
|
||||
'pairwise_generations_passed',
|
||||
'pairwise_total_judge_calls',
|
||||
]);
|
||||
|
||||
const PAIRWISE_DISPLAY_NAMES: Record<string, string> = {
|
||||
pairwise_primary: 'primary',
|
||||
pairwise_diagnostic: 'diagnostic',
|
||||
pairwise_judges_passed: 'judges_passed',
|
||||
pairwise_total_passes: 'total_passes',
|
||||
pairwise_total_violations: 'total_violations',
|
||||
};
|
||||
|
||||
function getDisplayMetricName(evaluator: string, metric: string): string {
|
||||
if (evaluator === 'pairwise') {
|
||||
return PAIRWISE_DISPLAY_NAMES[metric] ?? metric;
|
||||
}
|
||||
return metric;
|
||||
}
|
||||
|
||||
function isDisplayLine(item: unknown): item is DisplayLine {
|
||||
if (typeof item !== 'object' || item === null) return false;
|
||||
if (!('text' in item)) return false;
|
||||
return typeof item.text === 'string';
|
||||
}
|
||||
|
||||
function getDisplayLines(details?: Feedback['details']): DisplayLine[] | undefined {
|
||||
if (!details?.displayLines || !Array.isArray(details.displayLines)) return undefined;
|
||||
// Validate each item matches DisplayLine shape
|
||||
if (!details.displayLines.every(isDisplayLine)) return undefined;
|
||||
return details.displayLines;
|
||||
}
|
||||
|
||||
function formatMetricValue(evaluator: string, metric: string, score: number): string {
|
||||
if (evaluator === 'pairwise' && PAIRWISE_COUNT_METRICS.has(metric)) {
|
||||
if (!Number.isFinite(score)) return 'N/A';
|
||||
@@ -198,7 +221,8 @@ function formatEvaluatorLines(args: {
|
||||
const metricsLine = picked
|
||||
.map((f) => {
|
||||
const color = scoreColor(f.score);
|
||||
return `${f.metric}: ${color(formatMetricValue(evaluatorName, f.metric, f.score))}`;
|
||||
const displayName = getDisplayMetricName(evaluatorName, f.metric);
|
||||
return `${displayName}: ${color(formatMetricValue(evaluatorName, f.metric, f.score))}`;
|
||||
})
|
||||
.join(pc.dim(' | '));
|
||||
lines.push(pc.dim(' ') + metricsLine);
|
||||
@@ -221,8 +245,20 @@ function formatEvaluatorLines(args: {
|
||||
const top = issues.slice(0, 3);
|
||||
lines.push(pc.dim(` issues(top=${top.length}):`));
|
||||
for (const issue of top) {
|
||||
const comment = truncateForSingleLine(issue.comment ?? '', 320);
|
||||
lines.push(pc.dim(` - [${issue.metric}] `) + pc.red(comment));
|
||||
const displayMetric = getDisplayMetricName(evaluatorName, issue.metric);
|
||||
const displayLines = getDisplayLines(issue.details);
|
||||
if (displayLines && displayLines.length > 0) {
|
||||
// Evaluator provided custom display lines with optional color
|
||||
lines.push(pc.dim(` - [${displayMetric}]`));
|
||||
for (const dl of displayLines) {
|
||||
const truncated = truncateForSingleLine(dl.text, 300);
|
||||
const colorFn = dl.color === 'yellow' ? pc.yellow : dl.color === 'dim' ? pc.dim : pc.red;
|
||||
lines.push(pc.dim(' ') + colorFn(truncated));
|
||||
}
|
||||
} else {
|
||||
const comment = truncateForSingleLine(issue.comment ?? '', 320);
|
||||
lines.push(pc.dim(` - [${displayMetric}] `) + pc.red(comment));
|
||||
}
|
||||
}
|
||||
if (issues.length > top.length) {
|
||||
lines.push(pc.dim(` ... and ${issues.length - top.length} more`));
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* Multi-generation utilities for pairwise evaluation.
|
||||
*
|
||||
* These utilities support generating multiple workflows from a single prompt
|
||||
* and aggregating evaluation results across all generations.
|
||||
*/
|
||||
|
||||
import type { SimpleWorkflow } from '@/types/workflow';
|
||||
|
||||
/**
|
||||
* Details for a single generation in multi-gen evaluation.
|
||||
*/
|
||||
export interface GenerationDetail {
|
||||
/** The generated workflow */
|
||||
workflow: SimpleWorkflow;
|
||||
/** Whether majority of judges passed this generation */
|
||||
majorityPass: boolean;
|
||||
/** Average diagnostic score across judges */
|
||||
diagnosticScore: number;
|
||||
/** Number of judges that passed */
|
||||
primaryPasses: number;
|
||||
/** Total number of judges */
|
||||
numJudges: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregated result across multiple generations.
|
||||
*/
|
||||
export interface MultiGenerationResult {
|
||||
/** Ratio of passing generations (0-1) */
|
||||
generationCorrectness: number;
|
||||
/** Average diagnostic score across all generations */
|
||||
aggregatedDiagnosticScore: number;
|
||||
/** Count of generations that passed */
|
||||
passingGenerations: number;
|
||||
/** Total number of generations */
|
||||
totalGenerations: number;
|
||||
/** Detailed results for each generation */
|
||||
generationDetails: GenerationDetail[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the majority threshold for a given number of judges.
|
||||
*
|
||||
* @param numJudges - Number of judges in the panel
|
||||
* @returns Minimum number of passes needed for majority
|
||||
*
|
||||
* @example
|
||||
* getMajorityThreshold(3) // returns 2
|
||||
* getMajorityThreshold(5) // returns 3
|
||||
*/
|
||||
export function getMajorityThreshold(numJudges: number): number {
|
||||
if (!Number.isFinite(numJudges) || numJudges < 1) {
|
||||
throw new Error(`numJudges must be >= 1 (received ${String(numJudges)})`);
|
||||
}
|
||||
return Math.ceil(numJudges / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate results across multiple workflow generations.
|
||||
*
|
||||
* Calculates:
|
||||
* - Generation correctness: passing generations / total generations
|
||||
* - Aggregated diagnostic: average diagnostic score across all generations
|
||||
*
|
||||
* @param details - Array of generation details to aggregate
|
||||
* @returns Aggregated multi-generation result
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = aggregateGenerations([
|
||||
* { majorityPass: true, diagnosticScore: 0.9, ... },
|
||||
* { majorityPass: false, diagnosticScore: 0.6, ... },
|
||||
* { majorityPass: true, diagnosticScore: 0.85, ... },
|
||||
* ]);
|
||||
* // result.generationCorrectness = 0.67 (2/3)
|
||||
* // result.aggregatedDiagnosticScore = 0.78
|
||||
* ```
|
||||
*/
|
||||
export function aggregateGenerations(details: GenerationDetail[]): MultiGenerationResult {
|
||||
const totalGenerations = details.length;
|
||||
const passingGenerations = details.filter((d) => d.majorityPass).length;
|
||||
const generationCorrectness = totalGenerations > 0 ? passingGenerations / totalGenerations : 0;
|
||||
|
||||
const aggregatedDiagnosticScore =
|
||||
totalGenerations > 0
|
||||
? details.reduce((sum, d) => sum + d.diagnosticScore, 0) / totalGenerations
|
||||
: 0;
|
||||
|
||||
return {
|
||||
generationCorrectness,
|
||||
aggregatedDiagnosticScore,
|
||||
passingGenerations,
|
||||
totalGenerations,
|
||||
generationDetails: details,
|
||||
};
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
RunSummary,
|
||||
EvaluationLifecycle,
|
||||
LangsmithExampleFilters,
|
||||
LlmCallLimiter,
|
||||
} from './harness-types.js';
|
||||
import type { EvalLogger } from './logger';
|
||||
import { createArtifactSaver, type ArtifactSaver } from './output';
|
||||
@@ -745,7 +746,6 @@ async function runLangsmithEvaluateAndFlush(params: {
|
||||
|
||||
/**
|
||||
* Run evaluation in LangSmith mode.
|
||||
* This wraps generation + evaluation in a traceable function.
|
||||
*/
|
||||
async function runLangsmith(config: LangsmithRunConfig): Promise<RunSummary> {
|
||||
const {
|
||||
@@ -776,10 +776,32 @@ async function runLangsmith(config: LangsmithRunConfig): Promise<RunSummary> {
|
||||
const artifactSaver = createArtifactSaverIfRequested({ outputDir, logger });
|
||||
const capturedResults: ExampleResult[] | null = artifactSaver ? [] : null;
|
||||
|
||||
// Create traceable wrapper ONCE outside target function to avoid context leaking
|
||||
// when running concurrent evaluations. Pass all parameters explicitly (no closures).
|
||||
const traceableGenerateWorkflow = traceable(
|
||||
async (args: {
|
||||
prompt: string;
|
||||
genFn: (prompt: string) => Promise<SimpleWorkflow>;
|
||||
limiter?: LlmCallLimiter;
|
||||
genTimeoutMs?: number;
|
||||
}): Promise<SimpleWorkflow> => {
|
||||
return await runWithOptionalLimiter(async () => {
|
||||
return await withTimeout({
|
||||
promise: args.genFn(args.prompt),
|
||||
timeoutMs: args.genTimeoutMs,
|
||||
label: 'workflow_generation',
|
||||
});
|
||||
}, args.limiter);
|
||||
},
|
||||
{
|
||||
name: 'workflow_generation',
|
||||
run_type: 'chain',
|
||||
client: lsClient,
|
||||
},
|
||||
);
|
||||
|
||||
// Create target function that does ALL work (generation + evaluation)
|
||||
// NOTE: Do NOT wrap target with traceable() - evaluate() handles that automatically
|
||||
// and applies critical options (on_end callback, reference_example_id, client).
|
||||
// Only wrap inner operations with traceable() for child traces.
|
||||
// NOTE: Do NOT wrap target with traceable() - evaluate() handles tracing automatically.
|
||||
let targetCallCount = 0;
|
||||
let totalExamples = 0;
|
||||
const stats = {
|
||||
@@ -802,25 +824,12 @@ async function runLangsmith(config: LangsmithRunConfig): Promise<RunSummary> {
|
||||
const genStart = Date.now();
|
||||
|
||||
try {
|
||||
const limiter = effectiveGlobalContext.llmCallLimiter;
|
||||
|
||||
// Generate workflow - wrapped in traceable for proper child trace visibility
|
||||
const traceableGenerate = traceable(
|
||||
async () =>
|
||||
await runWithOptionalLimiter(async () => {
|
||||
return await withTimeout({
|
||||
promise: generateWorkflow(prompt),
|
||||
timeoutMs,
|
||||
label: 'workflow_generation',
|
||||
});
|
||||
}, limiter),
|
||||
{
|
||||
name: 'workflow_generation',
|
||||
run_type: 'chain',
|
||||
client: lsClient,
|
||||
},
|
||||
);
|
||||
const workflow = await traceableGenerate();
|
||||
const workflow = await traceableGenerateWorkflow({
|
||||
prompt,
|
||||
genFn: generateWorkflow,
|
||||
limiter: effectiveGlobalContext.llmCallLimiter,
|
||||
genTimeoutMs: timeoutMs,
|
||||
});
|
||||
const genDurationMs = Date.now() - genStart;
|
||||
lifecycle?.onWorkflowGenerated?.(workflow, genDurationMs);
|
||||
|
||||
|
||||
@@ -54,14 +54,6 @@ export {
|
||||
type ArtifactSaverOptions,
|
||||
} from './harness/output';
|
||||
|
||||
// Multi-generation utilities
|
||||
export {
|
||||
getMajorityThreshold,
|
||||
aggregateGenerations,
|
||||
type GenerationDetail,
|
||||
type MultiGenerationResult,
|
||||
} from './harness/multi-gen';
|
||||
|
||||
// Trace filtering (re-exported from v1 for convenience)
|
||||
export {
|
||||
createTraceFilters,
|
||||
|
||||
@@ -24,7 +24,6 @@ export const TRACEABLE_NAMES = {
|
||||
|
||||
export const DEFAULTS = {
|
||||
NUM_JUDGES: 3,
|
||||
NUM_GENERATIONS: 1,
|
||||
EXPERIMENT_NAME: 'pairwise-evals',
|
||||
LLM_JUDGE_EXPERIMENT_NAME: 'workflow-builder-evaluation',
|
||||
CONCURRENCY: 5,
|
||||
|
||||
Reference in New Issue
Block a user