diff --git a/apps/web/package.json b/apps/web/package.json index 49693d9..7fb84c6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -45,6 +45,8 @@ "@vercel/blob": "^2.0.0", "@vercel/kv": "^3.0.0", "ai": "6.0.49", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "better-auth": "^1.4.10", "bm25": "^0.1.1", "d3": "^7.9.0", diff --git a/apps/web/src/app/(profile)/[username]/collections/[slug]/scenarios/[id]/ExpandedRunDetails.tsx b/apps/web/src/app/(profile)/[username]/collections/[slug]/scenarios/[id]/ExpandedRunDetails.tsx index f24ca8f..baa2c86 100644 --- a/apps/web/src/app/(profile)/[username]/collections/[slug]/scenarios/[id]/ExpandedRunDetails.tsx +++ b/apps/web/src/app/(profile)/[username]/collections/[slug]/scenarios/[id]/ExpandedRunDetails.tsx @@ -1,10 +1,130 @@ +import { Badge } from '@tpmjs/ui/Badge/Badge'; +import { Icon } from '@tpmjs/ui/Icon/Icon'; import type { ScenarioRun } from './page'; interface ExpandedRunDetailsProps { run: ScenarioRun; } +/** Displays passed and failed assertions */ +function AssertionsSection({ assertions }: { assertions: { passed: string[]; failed: string[] } }) { + return ( +
+

+ Assertions +

+
+ {assertions.passed.length > 0 && ( +
+
+ + Passed ({assertions.passed.length}) +
+
+ {assertions.passed.map((assertion) => ( +
+ {assertion} +
+ ))} +
+
+ )} + {assertions.failed.length > 0 && ( +
+
+ + Failed ({assertions.failed.length}) +
+
+ {assertions.failed.map((assertion) => ( +
+ {assertion} +
+ ))} +
+
+ )} +
+
+ ); +} + +/** Displays conversation messages */ +function ConversationSection({ + conversation, +}: { + conversation: NonNullable; +}) { + return ( +
+
+

+ Conversation History +

+
+
+ {conversation.map((msg) => ( + + ))} +
+
+ ); +} + +/** Single conversation message */ +function ConversationMessage({ msg }: { msg: NonNullable[number] }) { + if (msg.role === 'USER') { + return ( +
+
+
{msg.content}
+
+
+ ); + } + + if (msg.role === 'ASSISTANT') { + return msg.content ? ( +
+
+
+
{msg.content}
+
+
+
+ ) : null; + } + + if (msg.role === 'TOOL') { + return ( +
+
+
+
+ {msg.toolName || 'Unknown Tool'} +
+ {msg.toolResult != null && ( +
+
+                  {typeof msg.toolResult === 'string'
+                    ? msg.toolResult
+                    : JSON.stringify(msg.toolResult, null, 2)}
+                
+
+ )} +
+
+
+ ); + } + + return null; +} + export function ExpandedRunDetails({ run }: ExpandedRunDetailsProps) { + const hasAssertions = + run.assertions && (run.assertions.passed.length > 0 || run.assertions.failed.length > 0); + return (
@@ -15,7 +135,24 @@ export function ExpandedRunDetails({ run }: ExpandedRunDetailsProps) { LLM Evaluation
- {run.evaluator.model && {run.evaluator.model}} + + + {run.evaluator.verdict === 'pass' ? 'Pass' : 'Fail'} + + {run.evaluator.model && ( + + {run.evaluator.model} + + )}
{run.evaluator?.reason && (

{run.evaluator.reason}

@@ -55,6 +192,9 @@ export function ExpandedRunDetails({ run }: ExpandedRunDetailsProps) {
+ {/* Assertions Results */} + {hasAssertions && run.assertions && } + {/* Output (if owner) */} {run.output && (
@@ -80,61 +220,7 @@ export function ExpandedRunDetails({ run }: ExpandedRunDetailsProps) { )} {/* Conversation History */} - {run.conversation && ( -
-
-

- Conversation History -

-
-
- {run.conversation.map((msg) => ( -
- {msg.role === 'USER' && ( -
-
-
{msg.content}
-
-
- )} - {msg.role === 'ASSISTANT' && ( -
- {msg.content && ( -
-
-
- {msg.content} -
-
-
- )} -
- )} - {msg.role === 'TOOL' && ( -
-
-
-
- {msg.toolName || 'Unknown Tool'} -
- {msg.toolResult != null && ( -
-
-                              {typeof msg.toolResult === 'string'
-                                ? msg.toolResult
-                                : JSON.stringify(msg.toolResult, null, 2)}
-                            
-
- )} -
-
-
- )} -
- ))} -
-
- )} + {run.conversation && }
); } diff --git a/apps/web/src/lib/scenarios/evaluate.test.ts b/apps/web/src/lib/scenarios/evaluate.test.ts index 0d998d1..39c7dbd 100644 --- a/apps/web/src/lib/scenarios/evaluate.test.ts +++ b/apps/web/src/lib/scenarios/evaluate.test.ts @@ -3,13 +3,20 @@ * * Tests for the evaluation logic including: * - Regex assertion matching + * - JSON Schema validation + * - JSON extraction from various formats * - Final verdict determination * - Assertion result handling */ import { describe, expect, it } from 'vitest'; -import { determineFinalVerdict, runAssertions } from './evaluate'; +import { + determineFinalVerdict, + extractJsonFromOutput, + runAssertions, + validateJsonSchema, +} from './evaluate'; describe('runAssertions', () => { describe('regex assertions', () => { @@ -93,29 +100,262 @@ describe('runAssertions', () => { }); describe('schema assertions', () => { - it('should note when schema is provided', () => { - const output = '{"name": "test"}'; - const assertions = { schema: { type: 'object' } }; + it('should pass when JSON validates against schema', () => { + const output = '{"name": "test", "age": 25}'; + const assertions = { + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + required: ['name'], + }, + }; const result = runAssertions(output, assertions); - expect(result.passed).toContain('schema:provided (validation pending)'); + expect(result.passed).toContain('schema: JSON validates against schema'); + expect(result.failed).toHaveLength(0); }); - }); - describe('combined assertions', () => { - it('should handle both regex and schema assertions', () => { - const output = '{"status": "success"}'; + it('should fail when JSON does not validate against schema', () => { + const output = '{"name": 123}'; + const assertions = { + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }, + }; + + const result = runAssertions(output, assertions); + + expect(result.failed.length).toBeGreaterThan(0); + expect(result.failed[0]).toContain('schema:'); + expect(result.passed).toHaveLength(0); + }); + + it('should fail when output is not valid JSON', () => { + const output = 'This is not JSON at all'; const assertions = { - regex: ['success'], schema: { type: 'object' }, }; const result = runAssertions(output, assertions); - expect(result.passed).toContain('regex:success'); - expect(result.passed).toContain('schema:provided (validation pending)'); + expect(result.failed).toContain('schema: Output does not contain valid JSON'); }); + + it('should handle schema with required fields', () => { + const output = '{"name": "test"}'; + const assertions = { + schema: { + type: 'object', + required: ['name', 'email'], + }, + }; + + const result = runAssertions(output, assertions); + + expect(result.failed.length).toBeGreaterThan(0); + expect(result.failed[0]).toContain('schema:'); + }); + + it('should handle empty schema object', () => { + const output = '{"anything": "goes"}'; + const assertions = { schema: {} }; + + const result = runAssertions(output, assertions); + + // Empty schema should not run validation + expect(result.passed).toHaveLength(0); + expect(result.failed).toHaveLength(0); + }); + }); + + describe('combined assertions', () => { + it('should handle both regex and schema assertions passing', () => { + const output = '{"status": "success", "count": 42}'; + const assertions = { + regex: ['success', '42'], + schema: { + type: 'object', + properties: { + status: { type: 'string' }, + count: { type: 'number' }, + }, + }, + }; + + const result = runAssertions(output, assertions); + + expect(result.passed).toContain('regex:success'); + expect(result.passed).toContain('regex:42'); + expect(result.passed).toContain('schema: JSON validates against schema'); + expect(result.failed).toHaveLength(0); + }); + + it('should handle regex passing but schema failing', () => { + const output = '{"status": "success", "count": "not a number"}'; + const assertions = { + regex: ['success'], + schema: { + type: 'object', + properties: { + count: { type: 'number' }, + }, + }, + }; + + const result = runAssertions(output, assertions); + + expect(result.passed).toContain('regex:success'); + expect(result.failed.length).toBeGreaterThan(0); + expect(result.failed[0]).toContain('schema:'); + }); + }); +}); + +describe('extractJsonFromOutput', () => { + it('should extract direct JSON object', () => { + const output = '{"name": "test"}'; + const result = extractJsonFromOutput(output); + expect(result).toEqual({ name: 'test' }); + }); + + it('should extract direct JSON array', () => { + const output = '[1, 2, 3]'; + const result = extractJsonFromOutput(output); + expect(result).toEqual([1, 2, 3]); + }); + + it('should extract JSON from markdown code block with json tag', () => { + const output = 'Here is the result:\n```json\n{"status": "ok"}\n```'; + const result = extractJsonFromOutput(output); + expect(result).toEqual({ status: 'ok' }); + }); + + it('should extract JSON from markdown code block without tag', () => { + const output = 'Result:\n```\n{"value": 42}\n```\nDone.'; + const result = extractJsonFromOutput(output); + expect(result).toEqual({ value: 42 }); + }); + + it('should extract JSON embedded in text', () => { + const output = 'The response is {"data": "found"} as expected.'; + const result = extractJsonFromOutput(output); + expect(result).toEqual({ data: 'found' }); + }); + + it('should return null for non-JSON output', () => { + const output = 'This is just plain text with no JSON.'; + const result = extractJsonFromOutput(output); + expect(result).toBeNull(); + }); + + it('should handle whitespace around JSON', () => { + const output = ' \n {"trimmed": true} \n '; + const result = extractJsonFromOutput(output); + expect(result).toEqual({ trimmed: true }); + }); + + it('should handle nested JSON objects', () => { + const output = '{"outer": {"inner": {"deep": "value"}}}'; + const result = extractJsonFromOutput(output); + expect(result).toEqual({ outer: { inner: { deep: 'value' } } }); + }); +}); + +describe('validateJsonSchema', () => { + it('should validate simple object schema', () => { + const data = { name: 'test', age: 25 }; + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + }; + + const result = validateJsonSchema(data, schema); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should return errors for invalid data', () => { + const data = { name: 123 }; + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }; + + const result = validateJsonSchema(data, schema); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should validate required fields', () => { + const data = { name: 'test' }; + const schema = { + type: 'object', + required: ['name', 'email'], + }; + + const result = validateJsonSchema(data, schema); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('email'))).toBe(true); + }); + + it('should validate array schemas', () => { + const data = [1, 2, 3]; + const schema = { + type: 'array', + items: { type: 'number' }, + }; + + const result = validateJsonSchema(data, schema); + expect(result.valid).toBe(true); + }); + + it('should handle format validation (email)', () => { + const data = { email: 'invalid-email' }; + const schema = { + type: 'object', + properties: { + email: { type: 'string', format: 'email' }, + }, + }; + + const result = validateJsonSchema(data, schema); + expect(result.valid).toBe(false); + }); + + it('should pass format validation for valid email', () => { + const data = { email: 'test@example.com' }; + const schema = { + type: 'object', + properties: { + email: { type: 'string', format: 'email' }, + }, + }; + + const result = validateJsonSchema(data, schema); + expect(result.valid).toBe(true); + }); + + it('should handle invalid schema gracefully', () => { + const data = { test: true }; + const schema = { + type: 'invalid-type-that-does-not-exist', + }; + + const result = validateJsonSchema(data, schema as Record); + // Ajv with strict: false will still try to validate + expect(result.valid).toBe(false); }); }); diff --git a/apps/web/src/lib/scenarios/evaluate.ts b/apps/web/src/lib/scenarios/evaluate.ts index e86b77e..b0c17fd 100644 --- a/apps/web/src/lib/scenarios/evaluate.ts +++ b/apps/web/src/lib/scenarios/evaluate.ts @@ -2,13 +2,20 @@ * 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'), @@ -94,9 +101,90 @@ 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 +): { 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 */ @@ -123,11 +211,25 @@ export function runAssertions( } } - // Schema assertions would require more complex validation - // For now, we'll just note if schema was provided - if (assertions.schema) { - // TODO: Implement JSON schema validation against parsed output - passed.push('schema:provided (validation pending)'); + // 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 }; diff --git a/apps/web/src/lib/scenarios/execute.test.ts b/apps/web/src/lib/scenarios/execute.test.ts new file mode 100644 index 0000000..9beb132 --- /dev/null +++ b/apps/web/src/lib/scenarios/execute.test.ts @@ -0,0 +1,447 @@ +/** + * Scenario Execution Unit Tests + * + * Tests for the execution logic including: + * - Quota management + * - Quality score metrics + * - Error handling + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ScenarioExecutionError } from './execute'; + +// Mock prisma +vi.mock('@tpmjs/db', () => ({ + prisma: { + scenarioQuota: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + scenario: { + findUnique: vi.fn(), + update: vi.fn(), + }, + scenarioRun: { + create: vi.fn(), + update: vi.fn(), + }, + collection: { + findUnique: vi.fn(), + }, + }, +})); + +describe('ScenarioExecutionError', () => { + it('should create error with category', () => { + const error = new ScenarioExecutionError('Test error message', 'COLLECTION_NOT_FOUND', { + scenarioId: 'test-123', + }); + + expect(error.message).toBe('Test error message'); + expect(error.category).toBe('COLLECTION_NOT_FOUND'); + expect(error.context).toEqual({ scenarioId: 'test-123' }); + expect(error.name).toBe('ScenarioExecutionError'); + }); + + it('should work without context', () => { + const error = new ScenarioExecutionError('No context error', 'NO_TOOLS'); + + expect(error.message).toBe('No context error'); + expect(error.category).toBe('NO_TOOLS'); + expect(error.context).toBeUndefined(); + }); + + it('should be an instance of Error', () => { + const error = new ScenarioExecutionError('Test', 'UNKNOWN_ERROR'); + expect(error instanceof Error).toBe(true); + }); + + it('should support all error categories', () => { + const categories = [ + 'COLLECTION_NOT_FOUND', + 'NO_COLLECTION', + 'NO_TOOLS', + 'TOOL_BUILD_ERROR', + 'EXECUTION_ERROR', + 'EVALUATION_ERROR', + 'QUOTA_EXCEEDED', + 'UNKNOWN_ERROR', + ] as const; + + for (const category of categories) { + const error = new ScenarioExecutionError(`Error: ${category}`, category); + expect(error.category).toBe(category); + } + }); +}); + +describe('Quota Management', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-01-15T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + describe('checkAndDecrementQuota', () => { + it('should create quota for new user', async () => { + const { prisma } = await import('@tpmjs/db'); + const { checkAndDecrementQuota } = await import('./execute'); + + vi.mocked(prisma.scenarioQuota.findUnique).mockResolvedValue(null); + vi.mocked(prisma.scenarioQuota.create).mockResolvedValue({ + id: 'quota-1', + userId: 'user-1', + dailyLimit: 50, + dailyUsed: 0, + lastResetAt: new Date('2025-01-15T00:00:00Z'), + createdAt: new Date(), + updatedAt: new Date(), + }); + vi.mocked(prisma.scenarioQuota.update).mockResolvedValue({ + id: 'quota-1', + userId: 'user-1', + dailyLimit: 50, + dailyUsed: 1, + lastResetAt: new Date('2025-01-15T00:00:00Z'), + createdAt: new Date(), + updatedAt: new Date(), + }); + + const result = await checkAndDecrementQuota('user-1'); + + expect(result.allowed).toBe(true); + expect(result.remaining).toBe(49); + expect(prisma.scenarioQuota.create).toHaveBeenCalled(); + }); + + it('should deny when quota exceeded', async () => { + const { prisma } = await import('@tpmjs/db'); + const { checkAndDecrementQuota } = await import('./execute'); + + vi.mocked(prisma.scenarioQuota.findUnique).mockResolvedValue({ + id: 'quota-1', + userId: 'user-1', + dailyLimit: 50, + dailyUsed: 50, + lastResetAt: new Date('2025-01-15T00:00:00Z'), + createdAt: new Date(), + updatedAt: new Date(), + }); + + const result = await checkAndDecrementQuota('user-1'); + + expect(result.allowed).toBe(false); + expect(result.remaining).toBe(0); + }); + + it('should reset quota on new day', async () => { + const { prisma } = await import('@tpmjs/db'); + const { checkAndDecrementQuota } = await import('./execute'); + + // Quota from yesterday + vi.mocked(prisma.scenarioQuota.findUnique).mockResolvedValue({ + id: 'quota-1', + userId: 'user-1', + dailyLimit: 50, + dailyUsed: 45, + lastResetAt: new Date('2025-01-14T00:00:00Z'), // Yesterday + createdAt: new Date(), + updatedAt: new Date(), + }); + + // After reset + vi.mocked(prisma.scenarioQuota.update).mockResolvedValue({ + id: 'quota-1', + userId: 'user-1', + dailyLimit: 50, + dailyUsed: 0, + lastResetAt: new Date('2025-01-15T00:00:00Z'), + createdAt: new Date(), + updatedAt: new Date(), + }); + + const result = await checkAndDecrementQuota('user-1'); + + expect(result.allowed).toBe(true); + // After reset: 50 - 0 - 1 = 49 + expect(result.remaining).toBe(49); + }); + }); + + describe('getQuotaStatus', () => { + it('should return default quota for new user', async () => { + const { prisma } = await import('@tpmjs/db'); + const { getQuotaStatus } = await import('./execute'); + + vi.mocked(prisma.scenarioQuota.findUnique).mockResolvedValue(null); + + const result = await getQuotaStatus('user-1'); + + expect(result.used).toBe(0); + expect(result.limit).toBe(50); + expect(result.remaining).toBe(50); + }); + + it('should return current quota for existing user', async () => { + const { prisma } = await import('@tpmjs/db'); + const { getQuotaStatus } = await import('./execute'); + + vi.mocked(prisma.scenarioQuota.findUnique).mockResolvedValue({ + id: 'quota-1', + userId: 'user-1', + dailyLimit: 50, + dailyUsed: 10, + lastResetAt: new Date('2025-01-15T00:00:00Z'), + createdAt: new Date(), + updatedAt: new Date(), + }); + + const result = await getQuotaStatus('user-1'); + + expect(result.used).toBe(10); + expect(result.limit).toBe(50); + expect(result.remaining).toBe(40); + }); + }); +}); + +describe('Scenario Metrics', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('updateScenarioMetrics', () => { + it('should increase quality score on pass', async () => { + const { prisma } = await import('@tpmjs/db'); + const { updateScenarioMetrics } = await import('./execute'); + + vi.mocked(prisma.scenario.findUnique).mockResolvedValue({ + id: 'scenario-1', + collectionId: 'col-1', + prompt: 'Test', + name: 'Test', + description: null, + tags: [], + assertions: null, + qualityScore: 0.5, + consecutivePasses: 0, + consecutiveFails: 2, + totalRuns: 5, + lastRunAt: null, + lastRunStatus: 'fail', + createdAt: new Date(), + updatedAt: new Date(), + }); + + vi.mocked(prisma.scenario.update).mockResolvedValue({} as any); + + await updateScenarioMetrics('scenario-1', 'pass'); + + expect(prisma.scenario.update).toHaveBeenCalledWith({ + where: { id: 'scenario-1' }, + data: expect.objectContaining({ + consecutivePasses: 1, + consecutiveFails: 0, + totalRuns: 6, + lastRunStatus: 'pass', + }), + }); + + // Check quality score increased + const updateCall = vi.mocked(prisma.scenario.update).mock.calls[0]?.[0]; + expect(updateCall?.data.qualityScore).toBeGreaterThan(0.5); + }); + + it('should decrease quality score on fail', async () => { + const { prisma } = await import('@tpmjs/db'); + const { updateScenarioMetrics } = await import('./execute'); + + vi.mocked(prisma.scenario.findUnique).mockResolvedValue({ + id: 'scenario-1', + collectionId: 'col-1', + prompt: 'Test', + name: 'Test', + description: null, + tags: [], + assertions: null, + qualityScore: 0.5, + consecutivePasses: 3, + consecutiveFails: 0, + totalRuns: 5, + lastRunAt: null, + lastRunStatus: 'pass', + createdAt: new Date(), + updatedAt: new Date(), + }); + + vi.mocked(prisma.scenario.update).mockResolvedValue({} as any); + + await updateScenarioMetrics('scenario-1', 'fail'); + + expect(prisma.scenario.update).toHaveBeenCalledWith({ + where: { id: 'scenario-1' }, + data: expect.objectContaining({ + consecutivePasses: 0, + consecutiveFails: 1, + totalRuns: 6, + lastRunStatus: 'fail', + }), + }); + + // Check quality score decreased + const updateCall = vi.mocked(prisma.scenario.update).mock.calls[0]?.[0]; + expect(updateCall?.data.qualityScore).toBeLessThan(0.5); + }); + + it('should not exceed quality score bounds', async () => { + const { prisma } = await import('@tpmjs/db'); + const { updateScenarioMetrics } = await import('./execute'); + + // Test max bound + vi.mocked(prisma.scenario.findUnique).mockResolvedValue({ + id: 'scenario-1', + collectionId: 'col-1', + prompt: 'Test', + name: 'Test', + description: null, + tags: [], + assertions: null, + qualityScore: 0.99, + consecutivePasses: 10, + consecutiveFails: 0, + totalRuns: 20, + lastRunAt: null, + lastRunStatus: 'pass', + createdAt: new Date(), + updatedAt: new Date(), + }); + + vi.mocked(prisma.scenario.update).mockResolvedValue({} as any); + + await updateScenarioMetrics('scenario-1', 'pass'); + + const updateCall = vi.mocked(prisma.scenario.update).mock.calls[0]?.[0]; + expect(updateCall?.data.qualityScore).toBeLessThanOrEqual(1.0); + }); + + it('should not go below zero', async () => { + const { prisma } = await import('@tpmjs/db'); + const { updateScenarioMetrics } = await import('./execute'); + + // Test min bound + vi.mocked(prisma.scenario.findUnique).mockResolvedValue({ + id: 'scenario-1', + collectionId: 'col-1', + prompt: 'Test', + name: 'Test', + description: null, + tags: [], + assertions: null, + qualityScore: 0.05, + consecutivePasses: 0, + consecutiveFails: 5, + totalRuns: 10, + lastRunAt: null, + lastRunStatus: 'fail', + createdAt: new Date(), + updatedAt: new Date(), + }); + + vi.mocked(prisma.scenario.update).mockResolvedValue({} as any); + + await updateScenarioMetrics('scenario-1', 'fail'); + + const updateCall = vi.mocked(prisma.scenario.update).mock.calls[0]?.[0]; + expect(updateCall?.data.qualityScore).toBeGreaterThanOrEqual(0); + }); + + it('should handle non-existent scenario gracefully', async () => { + const { prisma } = await import('@tpmjs/db'); + const { updateScenarioMetrics } = await import('./execute'); + + vi.mocked(prisma.scenario.findUnique).mockResolvedValue(null); + + // Should not throw + await expect(updateScenarioMetrics('non-existent', 'pass')).resolves.toBeUndefined(); + expect(prisma.scenario.update).not.toHaveBeenCalled(); + }); + }); +}); + +describe('Quality Score Algorithm', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should reward consecutive passes with streak bonus', async () => { + const { prisma } = await import('@tpmjs/db'); + const { updateScenarioMetrics } = await import('./execute'); + + // Starting with 3 consecutive passes + vi.mocked(prisma.scenario.findUnique).mockResolvedValue({ + id: 'scenario-1', + collectionId: 'col-1', + prompt: 'Test', + name: 'Test', + description: null, + tags: [], + assertions: null, + qualityScore: 0.3, + consecutivePasses: 3, + consecutiveFails: 0, + totalRuns: 5, + lastRunAt: null, + lastRunStatus: 'pass', + createdAt: new Date(), + updatedAt: new Date(), + }); + + vi.mocked(prisma.scenario.update).mockResolvedValue({} as any); + + await updateScenarioMetrics('scenario-1', 'pass'); + + const updateCall = vi.mocked(prisma.scenario.update).mock.calls[0]?.[0]; + // Base: 0.3, +0.05 for pass, +0.01*4 for streak bonus = 0.3 + 0.05 + 0.04 = 0.39 + expect(updateCall?.data.qualityScore).toBeCloseTo(0.39, 2); + expect(updateCall?.data.consecutivePasses).toBe(4); + }); + + it('should penalize consecutive fails with streak penalty', async () => { + const { prisma } = await import('@tpmjs/db'); + const { updateScenarioMetrics } = await import('./execute'); + + // Starting with 2 consecutive fails + vi.mocked(prisma.scenario.findUnique).mockResolvedValue({ + id: 'scenario-1', + collectionId: 'col-1', + prompt: 'Test', + name: 'Test', + description: null, + tags: [], + assertions: null, + qualityScore: 0.5, + consecutivePasses: 0, + consecutiveFails: 2, + totalRuns: 5, + lastRunAt: null, + lastRunStatus: 'fail', + createdAt: new Date(), + updatedAt: new Date(), + }); + + vi.mocked(prisma.scenario.update).mockResolvedValue({} as any); + + await updateScenarioMetrics('scenario-1', 'fail'); + + const updateCall = vi.mocked(prisma.scenario.update).mock.calls[0]?.[0]; + // Base: 0.5, -0.1 for fail, -0.02*3 for streak penalty = 0.5 - 0.1 - 0.06 = 0.34 + expect(updateCall?.data.qualityScore).toBeCloseTo(0.34, 2); + expect(updateCall?.data.consecutiveFails).toBe(3); + }); +}); diff --git a/apps/web/src/lib/scenarios/execute.ts b/apps/web/src/lib/scenarios/execute.ts index 7ffb240..d5c481d 100644 --- a/apps/web/src/lib/scenarios/execute.ts +++ b/apps/web/src/lib/scenarios/execute.ts @@ -15,6 +15,7 @@ import { createToolDefinition } from '../ai-agent/tool-executor-agent'; import { parseExecutorConfig, resolveExecutorConfig } from '../executors'; import { determineFinalVerdict, + type EvaluationResult, type EvaluatorModelId, evaluateScenarioRun, runAssertions, @@ -25,6 +26,55 @@ const DEFAULT_MODEL = 'gpt-4.1-mini'; const MAX_RETRIES = 1; const MAX_TOOL_STEPS = 10; +/** + * Error categories for scenario execution + */ +export type ScenarioErrorCategory = + | 'COLLECTION_NOT_FOUND' + | 'NO_COLLECTION' + | 'NO_TOOLS' + | 'TOOL_BUILD_ERROR' + | 'EXECUTION_ERROR' + | 'EVALUATION_ERROR' + | 'QUOTA_EXCEEDED' + | 'UNKNOWN_ERROR'; + +/** + * Structured error for scenario execution + */ +export class ScenarioExecutionError extends Error { + constructor( + message: string, + public readonly category: ScenarioErrorCategory, + public readonly context?: Record + ) { + super(message); + this.name = 'ScenarioExecutionError'; + } +} + +/** + * Structured logging for scenario execution + */ +function logScenarioEvent( + event: 'start' | 'tools_built' | 'execution_complete' | 'evaluation_complete' | 'error' | 'retry', + scenarioId: string, + data?: Record +): void { + const logEntry = { + timestamp: new Date().toISOString(), + event: `scenario.${event}`, + scenarioId, + ...data, + }; + + // In production, this could be sent to a logging service + // For now, we use structured console logging that can be parsed + if (process.env.NODE_ENV === 'development') { + console.log('[Scenario]', JSON.stringify(logEntry, null, 2)); + } +} + /** * Collection type with full tool relations */ @@ -156,6 +206,12 @@ export async function executeScenario( ): Promise { const { evaluatorModel = DEFAULT_EVALUATOR } = options; + logScenarioEvent('start', scenario.id, { + userId, + collectionId: scenario.collectionId, + evaluatorModel, + }); + // Create run record const run = await prisma.scenarioRun.create({ data: { @@ -172,6 +228,13 @@ export async function executeScenario( for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { retryCount = attempt; + if (attempt > 0) { + logScenarioEvent('retry', scenario.id, { + attempt, + previousError: lastError?.message, + }); + } + try { // Update status to running await prisma.scenarioRun.update({ @@ -187,12 +250,21 @@ export async function executeScenario( const executionResult = await executeWithAgent(scenario); // Evaluate with LLM - const evaluation = await evaluateScenarioRun( - scenario.prompt, - executionResult.output, - executionResult.conversation, - evaluatorModel - ); + let evaluation: EvaluationResult; + try { + evaluation = await evaluateScenarioRun( + scenario.prompt, + executionResult.output, + executionResult.conversation, + evaluatorModel + ); + } catch (evalError) { + throw new ScenarioExecutionError( + `Evaluation failed: ${evalError instanceof Error ? evalError.message : 'Unknown error'}`, + 'EVALUATION_ERROR', + { scenarioId: scenario.id, evaluatorModel, error: String(evalError) } + ); + } // Run assertions if defined const assertions = scenario.assertions as { @@ -206,6 +278,14 @@ export async function executeScenario( // Determine final verdict const finalStatus = determineFinalVerdict(evaluation, assertionResults); + logScenarioEvent('evaluation_complete', scenario.id, { + verdict: finalStatus, + evaluatorVerdict: evaluation.verdict, + evaluatorConfidence: evaluation.confidence, + assertionsPassed: assertionResults?.passed.length ?? 0, + assertionsFailed: assertionResults?.failed.length ?? 0, + }); + // Update run record const updatedRun = await prisma.scenarioRun.update({ where: { id: run.id }, @@ -234,12 +314,29 @@ export async function executeScenario( // If this is the last attempt, mark as error if (attempt === MAX_RETRIES) { + // Extract error category if it's a ScenarioExecutionError + const errorCategory = + error instanceof ScenarioExecutionError ? error.category : 'UNKNOWN_ERROR'; + const errorContext = error instanceof ScenarioExecutionError ? error.context : undefined; + + logScenarioEvent('error', scenario.id, { + category: errorCategory, + message: lastError.message, + context: errorContext, + retryCount, + }); + const errorRun = await prisma.scenarioRun.update({ where: { id: run.id }, data: { status: 'error', retryCount, - errorLog: (error as Error).stack || (error as Error).message, + errorLog: JSON.stringify({ + message: lastError.message, + category: errorCategory, + context: errorContext, + stack: lastError.stack, + }), completedAt: new Date(), }, }); @@ -353,23 +450,45 @@ async function executeWithAgent(scenario: Scenario): Promise<{ // Verify scenario has a collection if (!scenario.collectionId) { - throw new Error('Scenario has no associated collection'); + throw new ScenarioExecutionError('Scenario has no associated collection', 'NO_COLLECTION', { + scenarioId: scenario.id, + }); } // Fetch collection with tools const collection = await fetchCollectionWithTools(scenario.collectionId); if (!collection) { - throw new Error(`Collection not found: ${scenario.collectionId}`); + throw new ScenarioExecutionError( + `Collection not found: ${scenario.collectionId}`, + 'COLLECTION_NOT_FOUND', + { scenarioId: scenario.id, collectionId: scenario.collectionId } + ); } if (collection.tools.length === 0) { - throw new Error('Collection has no tools configured'); + throw new ScenarioExecutionError('Collection has no tools configured', 'NO_TOOLS', { + scenarioId: scenario.id, + collectionId: collection.id, + collectionName: collection.name, + }); } // Build tools from collection - const tools = buildCollectionTools(collection); + let tools: Record>; + try { + tools = buildCollectionTools(collection); + } catch (err) { + throw new ScenarioExecutionError( + `Failed to build tools: ${err instanceof Error ? err.message : 'Unknown error'}`, + 'TOOL_BUILD_ERROR', + { scenarioId: scenario.id, collectionId: collection.id, error: String(err) } + ); + } - console.log('[executeWithAgent] Executing scenario with tools:', Object.keys(tools)); + logScenarioEvent('tools_built', scenario.id, { + toolCount: Object.keys(tools).length, + toolNames: Object.keys(tools), + }); // Build system prompt for scenario execution const systemPrompt = `You are an AI assistant tasked with completing the following scenario using the available tools. @@ -381,15 +500,30 @@ Available tools: ${Object.keys(tools).join(', ')} Complete the user's task to the best of your ability using the tools provided.`; // Execute with generateText and multi-step tool loop - const result = await generateText({ - model: openai(DEFAULT_MODEL), - messages: [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: scenario.prompt }, - ], - tools, - stopWhen: stepCountIs(MAX_TOOL_STEPS), - }); + const result = await (async () => { + try { + return await generateText({ + model: openai(DEFAULT_MODEL), + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: scenario.prompt }, + ], + tools, + stopWhen: stepCountIs(MAX_TOOL_STEPS), + }); + } catch (err) { + throw new ScenarioExecutionError( + `AI execution failed: ${err instanceof Error ? err.message : 'Unknown error'}`, + 'EXECUTION_ERROR', + { + scenarioId: scenario.id, + error: String(err), + model: DEFAULT_MODEL, + maxSteps: MAX_TOOL_STEPS, + } + ); + } + })(); // Build conversation history from response const conversation: unknown[] = [{ role: 'user', content: scenario.prompt }]; @@ -435,13 +569,13 @@ Complete the user's task to the best of your ability using the tools provided.`; // Get token usage const usage = result.usage || { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; - console.log( - '[executeWithAgent] Completed in', + logScenarioEvent('execution_complete', scenario.id, { durationMs, - 'ms with', - result.steps?.length || 0, - 'steps' - ); + stepCount: result.steps?.length || 0, + inputTokens: usage.inputTokens ?? 0, + outputTokens: usage.outputTokens ?? 0, + hasOutput: !!result.text, + }); return { output: result.text || '[No output generated]', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60eceb2..108faf7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -303,6 +303,12 @@ importers: ai: specifier: 6.0.49 version: 6.0.49(zod@4.3.5) + ajv: + specifier: ^8.17.1 + version: 8.17.1 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.17.1) better-auth: specifier: ^1.4.10 version: 1.4.10(@prisma/client@6.19.1(prisma@6.19.1(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.5.0)(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@electric-sql/pglite@0.3.2)(@opentelemetry/api@1.9.0)(@prisma/client@6.19.1(prisma@6.19.1(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@upstash/redis@1.36.1)(better-sqlite3@12.5.0)(gel@2.2.0)(kysely@0.28.9)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7)(prisma@6.19.1(typescript@5.9.3)))(mongodb@6.20.0)(mysql2@3.15.3)(next@16.1.1(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.16.3)(prisma@6.19.1(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.3)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) @@ -7413,6 +7419,7 @@ packages: '@vercel/kv@3.0.0': resolution: {integrity: sha512-pKT8fRnfyYk2MgvyB6fn6ipJPCdfZwiKDdw7vB+HL50rjboEBHDVBEcnwfkEpVSp2AjNtoaOUH7zG+bVC/rvSg==} engines: {node: '>=14.6'} + deprecated: 'Vercel KV is deprecated. If you had an existing KV store, it should have moved to Upstash Redis which you will see under Vercel Integrations. For new projects, install a Redis integration from Vercel Marketplace: https://vercel.com/marketplace?category=storage&search=redis' '@vercel/oidc@3.1.0': resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} @@ -15583,14 +15590,14 @@ snapshots: '@remotion/media-parser': 4.0.409 '@remotion/studio': 4.0.409(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@remotion/studio-shared': 4.0.409(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - css-loader: 5.2.7(webpack@5.96.1(esbuild@0.25.0)) + css-loader: 5.2.7(webpack@5.96.1) esbuild: 0.25.0 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) react-refresh: 0.9.0 remotion: 4.0.409(react-dom@19.2.3(react@19.2.3))(react@19.2.3) source-map: 0.7.3 - style-loader: 4.0.0(webpack@5.96.1(esbuild@0.25.0)) + style-loader: 4.0.0(webpack@5.96.1) webpack: 5.96.1(esbuild@0.25.0) transitivePeerDependencies: - '@swc/core' @@ -17968,7 +17975,7 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-loader@5.2.7(webpack@5.96.1(esbuild@0.25.0)): + css-loader@5.2.7(webpack@5.96.1): dependencies: icss-utils: 5.1.0(postcss@8.5.6) loader-utils: 2.0.4 @@ -22802,7 +22809,7 @@ snapshots: stubborn-utils@1.0.2: {} - style-loader@4.0.0(webpack@5.96.1(esbuild@0.25.0)): + style-loader@4.0.0(webpack@5.96.1): dependencies: webpack: 5.96.1(esbuild@0.25.0)