tpmjs/apps/web/src/lib/scenarios/evaluate.ts
Ajax Davis 2df4b53354 feat(scenarios): complete scenario evaluation system with JSON Schema validation
- Add JSON Schema validation using Ajv for structured output assertions
- Add extractJsonFromOutput() to parse JSON from various formats (direct, markdown, embedded)
- Add validateJsonSchema() for proper schema validation with error messages
- Implement structured error handling with ScenarioExecutionError class
- Add 8 error categories: COLLECTION_NOT_FOUND, NO_COLLECTION, NO_TOOLS, etc.
- Add comprehensive unit tests for execute.ts (16 tests)
- Expand evaluate.test.ts with JSON Schema validation tests (36 tests total)
- Refactor ExpandedRunDetails.tsx into smaller components for assertions and conversation display
2026-01-27 05:22:34 +10:00

256 lines
7.1 KiB
TypeScript

/**
* Scenario Evaluation Service
*
* Uses LLM judgment to evaluate if a scenario execution was successful.
* Supports both regex pattern matching and JSON Schema validation for assertions.
*/
import { anthropic } from '@ai-sdk/anthropic';
import { openai } from '@ai-sdk/openai';
import { generateObject } from 'ai';
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import { z } from 'zod';
// Initialize Ajv with common formats (email, uri, date-time, etc.)
const ajv = new Ajv({ allErrors: true, strict: false });
addFormats(ajv);
const EvaluationSchema = z.object({
verdict: z.enum(['pass', 'fail']).describe('Whether the scenario was completed successfully'),
reason: z.string().describe('Brief explanation of why the scenario passed or failed'),
confidence: z.number().min(0).max(1).describe('Confidence level in the evaluation (0-1)'),
});
export type EvaluationResult = z.infer<typeof EvaluationSchema>;
/**
* Supported evaluator model IDs
*/
export type EvaluatorModelId =
| 'claude-3-5-sonnet-latest'
| 'claude-3-5-haiku-latest'
| 'gpt-4o'
| 'gpt-4o-mini'
| 'gpt-4.1-mini';
/**
* Get the model instance for an evaluator model ID
*/
function getEvaluatorModel(modelId: EvaluatorModelId) {
switch (modelId) {
case 'claude-3-5-sonnet-latest':
return anthropic('claude-3-5-sonnet-latest');
case 'claude-3-5-haiku-latest':
return anthropic('claude-3-5-haiku-latest');
case 'gpt-4o':
return openai('gpt-4o');
case 'gpt-4o-mini':
return openai('gpt-4o-mini');
case 'gpt-4.1-mini':
return openai('gpt-4.1-mini');
default:
return openai('gpt-4.1-mini');
}
}
const DEFAULT_EVALUATOR: EvaluatorModelId = 'gpt-4.1-mini';
/**
* Evaluate if a scenario execution was successful
*
* @param scenarioPrompt The original scenario prompt/task
* @param agentOutput The output produced by the agent
* @param conversation Optional conversation history for context
* @param modelId Which model to use for evaluation
*/
export async function evaluateScenarioRun(
scenarioPrompt: string,
agentOutput: string,
conversation?: unknown[],
modelId: EvaluatorModelId = DEFAULT_EVALUATOR
): Promise<EvaluationResult> {
const model = getEvaluatorModel(modelId);
const conversationContext = conversation
? `\n\nConversation history:\n${JSON.stringify(conversation, null, 2)}`
: '';
const { object } = await generateObject({
model,
schema: EvaluationSchema,
prompt: `You are evaluating whether an AI agent successfully completed a task.
## Task
${scenarioPrompt}
## Agent Output
${agentOutput}
${conversationContext}
## Instructions
Evaluate if the agent successfully completed the task described above.
- A "pass" means the core objective was achieved, even if some minor aspects weren't perfect
- A "fail" means the agent failed to accomplish the main goal
- Consider partial success as a pass if the primary task was completed
- Be fair but rigorous in your evaluation
Provide your verdict, a brief reason, and your confidence level.`,
});
return object;
}
/**
* Extract JSON from text output
*
* Attempts to find and parse JSON from various formats:
* - Direct JSON
* - JSON wrapped in markdown code blocks
* - JSON embedded in text
*
* @param output The text to extract JSON from
* @returns Parsed JSON or null if not found
*/
export function extractJsonFromOutput(output: string): unknown | null {
// Try direct parse first
try {
return JSON.parse(output.trim());
} catch {
// Continue to other strategies
}
// Try extracting from markdown code blocks (```json ... ``` or ``` ... ```)
const codeBlockMatch = output.match(/```(?:json)?\s*([\s\S]*?)```/);
if (codeBlockMatch?.[1]) {
try {
return JSON.parse(codeBlockMatch[1].trim());
} catch {
// Continue to other strategies
}
}
// Try finding JSON object or array in the text
const jsonMatch = output.match(/(\{[\s\S]*\}|\[[\s\S]*\])/);
if (jsonMatch?.[1]) {
try {
return JSON.parse(jsonMatch[1]);
} catch {
// Could not parse as JSON
}
}
return null;
}
/**
* Validate data against a JSON Schema
*
* @param data The data to validate
* @param schema The JSON Schema to validate against
* @returns Validation result with errors if any
*/
export function validateJsonSchema(
data: unknown,
schema: Record<string, unknown>
): { valid: boolean; errors: string[] } {
try {
const validate = ajv.compile(schema);
const valid = validate(data);
if (valid) {
return { valid: true, errors: [] };
}
// Format errors into readable strings
const errors = (validate.errors || []).map((err) => {
const path = err.instancePath || 'root';
const message = err.message || 'Unknown error';
return `${path}: ${message}`;
});
return { valid: false, errors };
} catch (err) {
return {
valid: false,
errors: [`Schema compilation error: ${err instanceof Error ? err.message : 'Unknown error'}`],
};
}
}
/**
* Run assertions against the output
*
* Supports two types of assertions:
* - regex: Array of regex patterns that must match the output
* - schema: JSON Schema that the output (parsed as JSON) must validate against
*
* @param output The agent output to check
* @param assertions The assertions to run
*/
export function runAssertions(
output: string,
assertions: { regex?: string[]; schema?: Record<string, unknown> }
): { passed: string[]; failed: string[] } {
const passed: string[] = [];
const failed: string[] = [];
// Check regex assertions
if (assertions.regex) {
for (const pattern of assertions.regex) {
try {
const regex = new RegExp(pattern, 'i');
if (regex.test(output)) {
passed.push(`regex:${pattern}`);
} else {
failed.push(`regex:${pattern}`);
}
} catch {
failed.push(`regex:${pattern} (invalid pattern)`);
}
}
}
// Validate against JSON Schema if provided
if (assertions.schema && Object.keys(assertions.schema).length > 0) {
const extractedJson = extractJsonFromOutput(output);
if (extractedJson === null) {
failed.push('schema: Output does not contain valid JSON');
} else {
const validation = validateJsonSchema(extractedJson, assertions.schema);
if (validation.valid) {
passed.push('schema: JSON validates against schema');
} else {
// Include first 3 errors for clarity
const errorSummary = validation.errors.slice(0, 3).join('; ');
const moreErrors =
validation.errors.length > 3 ? ` (+${validation.errors.length - 3} more)` : '';
failed.push(`schema: ${errorSummary}${moreErrors}`);
}
}
}
return { passed, failed };
}
/**
* Combine LLM evaluation and assertions into final verdict
*/
export function determineFinalVerdict(
evaluation: EvaluationResult,
assertions?: { passed: string[]; failed: string[] } | null
): 'pass' | 'fail' {
// If LLM says fail, it fails
if (evaluation.verdict === 'fail') {
return 'fail';
}
// If there are failed assertions, it fails
if (assertions && assertions.failed.length > 0) {
return 'fail';
}
return 'pass';
}