Files
windmill/ai_evals/core/runSuite.ts
centdix f1e84cb088 chore: add backend preview validation to ai evals (#8827)
* feat: add backend preview validation to ai evals

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: refresh shared preview workspace assets

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: harden shared backend preview validation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-15 15:11:25 +00:00

302 lines
9.2 KiB
TypeScript

import { judgeOutput, DEFAULT_JUDGE_MODEL } from "./judge";
import type {
BenchmarkAttemptResult,
BenchmarkCaseResult,
BenchmarkCheck,
EvalCase,
FrontendBenchmarkProgressEvent,
ModeRunner,
} from "./types";
export async function runSuite<TInitial, TExpected, TActual>(input: {
modeRunner: ModeRunner<TInitial, TExpected, TActual>;
cases: EvalCase[];
runs: number;
runModel: string | null;
judgeModel?: string | null;
concurrency?: number;
verbose?: boolean;
onProgress?: (event: FrontendBenchmarkProgressEvent) => void;
}): Promise<BenchmarkCaseResult[]> {
const judgeModel = input.judgeModel ?? DEFAULT_JUDGE_MODEL;
const concurrency = Math.max(1, input.concurrency ?? input.modeRunner.concurrency);
const results = new Array<BenchmarkCaseResult>(input.cases.length);
let cursor = 0;
if (input.modeRunner.mode !== "cli") {
input.onProgress?.({
type: "run-start",
surface: input.modeRunner.mode,
totalCases: input.cases.length,
runs: input.runs,
concurrency,
});
}
async function worker(): Promise<void> {
while (true) {
const caseIndex = cursor++;
if (caseIndex >= input.cases.length) {
return;
}
const evalCase = input.cases[caseIndex];
results[caseIndex] = {
id: evalCase.id,
prompt: evalCase.prompt,
initialPath: evalCase.initialPath,
expectedPath: evalCase.expectedPath,
attempts: await runCaseAttempts({
caseIndex,
evalCase,
runs: input.runs,
judgeModel,
judgeThreshold: input.modeRunner.judgeThreshold ?? 80,
modeRunner: input.modeRunner,
totalCases: input.cases.length,
verbose: input.verbose ?? false,
onProgress: input.onProgress,
}),
};
}
}
await Promise.all(
Array.from({ length: Math.min(concurrency, input.cases.length) }, () => worker())
);
return results;
}
async function runCaseAttempts<TInitial, TExpected, TActual>(input: {
caseIndex: number;
evalCase: EvalCase;
runs: number;
judgeModel: string;
judgeThreshold: number;
modeRunner: ModeRunner<TInitial, TExpected, TActual>;
totalCases: number;
verbose: boolean;
onProgress?: (event: FrontendBenchmarkProgressEvent) => void;
}): Promise<BenchmarkAttemptResult[]> {
const attempts: BenchmarkAttemptResult[] = [];
const surface = input.modeRunner.mode === "cli" ? null : input.modeRunner.mode;
for (let attempt = 1; attempt <= input.runs; attempt += 1) {
if (surface) {
input.onProgress?.({
type: "attempt-start",
surface,
caseId: input.evalCase.id,
caseNumber: input.caseIndex + 1,
totalCases: input.totalCases,
attempt,
runs: input.runs,
});
}
const startedAt = Date.now();
try {
const initial = await input.modeRunner.loadInitial(input.evalCase.initialPath);
const expected = await input.modeRunner.loadExpected(input.evalCase.expectedPath);
const run = await input.modeRunner.run(input.evalCase.prompt, initial, {
caseId: input.evalCase.id,
caseNumber: input.caseIndex + 1,
totalCases: input.totalCases,
attempt,
runs: input.runs,
verbose: input.verbose,
onAssistantMessageStart: input.verbose && surface
? () =>
input.onProgress?.({
type: "assistant-message-start",
surface,
caseId: input.evalCase.id,
caseNumber: input.caseIndex + 1,
totalCases: input.totalCases,
attempt,
runs: input.runs,
})
: undefined,
onAssistantChunk: input.verbose && surface
? (chunk: string) =>
input.onProgress?.({
type: "assistant-chunk",
surface,
caseId: input.evalCase.id,
caseNumber: input.caseIndex + 1,
totalCases: input.totalCases,
attempt,
runs: input.runs,
chunk,
})
: undefined,
onAssistantMessageEnd: input.verbose && surface
? () =>
input.onProgress?.({
type: "assistant-message-end",
surface,
caseId: input.evalCase.id,
caseNumber: input.caseIndex + 1,
totalCases: input.totalCases,
attempt,
runs: input.runs,
})
: undefined,
});
const checks: BenchmarkCheck[] = [
buildCheck("run succeeded", run.success, run.error),
...input.modeRunner.validate({
evalCase: input.evalCase,
prompt: input.evalCase.prompt,
initial,
expected,
actual: run.actual,
run,
}),
];
const artifactFiles = input.modeRunner.buildArtifacts?.(run.actual) ?? [];
if (run.success && input.modeRunner.backendValidate) {
try {
const backendValidation = await input.modeRunner.backendValidate({
evalCase: input.evalCase,
prompt: input.evalCase.prompt,
initial,
expected,
actual: run.actual,
run,
context: {
caseId: input.evalCase.id,
caseNumber: input.caseIndex + 1,
totalCases: input.totalCases,
attempt,
runs: input.runs,
verbose: input.verbose,
onAssistantMessageStart: undefined,
onAssistantChunk: undefined,
onAssistantMessageEnd: undefined,
},
});
if (backendValidation) {
checks.push(...backendValidation.checks);
artifactFiles.push(...(backendValidation.artifactFiles ?? []));
}
} catch (error) {
checks.push(
buildCheck(
"backend validation succeeded",
false,
error instanceof Error ? error.message : String(error)
)
);
}
}
let judgeScore: number | null = null;
let judgeSummary: string | null = null;
if (run.success) {
const judge = await judgeOutput({
mode: input.modeRunner.mode,
prompt: input.evalCase.prompt,
checklist: input.evalCase.judgeChecklist,
initial,
expected: input.modeRunner.mode === "cli" ? undefined : expected,
actual: run.actual,
model: input.judgeModel,
});
judgeScore = judge.success ? judge.score : null;
judgeSummary = judge.summary;
checks.push(buildCheck("judge succeeded", judge.success, judge.error));
checks.push(
buildCheck(
`judge score >= ${input.judgeThreshold}`,
(judgeScore ?? 0) >= input.judgeThreshold,
judge.success ? `score=${judgeScore}` : judge.error
)
);
}
const attemptResult: BenchmarkAttemptResult = {
attempt,
passed: checks.every((check) => check.passed),
durationMs: Date.now() - startedAt,
assistantMessageCount: run.assistantMessageCount,
toolCallCount: run.toolCallCount,
toolsUsed: uniqueStrings(run.toolsUsed),
skillsInvoked: uniqueStrings(run.skillsInvoked),
checks,
judgeScore,
judgeSummary,
error: run.error ?? null,
tokenUsage: run.tokenUsage ?? null,
artifactsPath: null,
artifactFiles,
};
if (surface) {
input.onProgress?.({
type: "attempt-finish",
surface,
caseId: input.evalCase.id,
caseNumber: input.caseIndex + 1,
totalCases: input.totalCases,
attempt,
runs: input.runs,
passed: attemptResult.passed,
durationMs: attemptResult.durationMs,
judgeScore: attemptResult.judgeScore,
error: attemptResult.error,
});
}
attempts.push(attemptResult);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const failedAttempt: BenchmarkAttemptResult = {
attempt,
passed: false,
durationMs: Date.now() - startedAt,
assistantMessageCount: 0,
toolCallCount: 0,
toolsUsed: [],
skillsInvoked: [],
checks: [buildCheck("run crashed", false, message)],
judgeScore: null,
judgeSummary: null,
error: message,
tokenUsage: null,
};
if (surface) {
input.onProgress?.({
type: "attempt-finish",
surface,
caseId: input.evalCase.id,
caseNumber: input.caseIndex + 1,
totalCases: input.totalCases,
attempt,
runs: input.runs,
passed: false,
durationMs: failedAttempt.durationMs,
judgeScore: null,
error: message,
});
}
attempts.push(failedAttempt);
}
}
return attempts;
}
function buildCheck(name: string, passed: boolean, details?: string): BenchmarkCheck {
return details ? { name, passed, details } : { name, passed };
}
function uniqueStrings(values: string[]): string[] {
return [...new Set(values)];
}