@artemiskit/core 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +4 -0
- package/dist/adapters/registry.d.ts.map +1 -1
- package/dist/adapters/types.d.ts +20 -2
- package/dist/adapters/types.d.ts.map +1 -1
- package/dist/agent-evaluation/index.d.ts +3 -0
- package/dist/agent-evaluation/index.d.ts.map +1 -0
- package/dist/agent-evaluation/scorer.d.ts +35 -0
- package/dist/agent-evaluation/scorer.d.ts.map +1 -0
- package/dist/agent-evaluation/types.d.ts +37 -0
- package/dist/agent-evaluation/types.d.ts.map +1 -0
- package/dist/artifacts/manifest.d.ts.map +1 -1
- package/dist/artifacts/types.d.ts +42 -0
- package/dist/artifacts/types.d.ts.map +1 -1
- package/dist/evaluators/index.d.ts +1 -0
- package/dist/evaluators/index.d.ts.map +1 -1
- package/dist/evaluators/json-schema.d.ts +0 -1
- package/dist/evaluators/json-schema.d.ts.map +1 -1
- package/dist/evaluators/llm-grader.d.ts +2 -0
- package/dist/evaluators/llm-grader.d.ts.map +1 -1
- package/dist/evaluators/tool-trace.d.ts +7 -0
- package/dist/evaluators/tool-trace.d.ts.map +1 -0
- package/dist/evaluators/types.d.ts +20 -0
- package/dist/evaluators/types.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20169 -13138
- package/dist/runner/executor.d.ts.map +1 -1
- package/dist/runner/runner.d.ts.map +1 -1
- package/dist/runner/types.d.ts +4 -0
- package/dist/runner/types.d.ts.map +1 -1
- package/dist/scenario/schema.d.ts +721 -63
- package/dist/scenario/schema.d.ts.map +1 -1
- package/dist/storage/supabase.d.ts +1 -1
- package/dist/storage/supabase.d.ts.map +1 -1
- package/dist/storage/types.d.ts +6 -2
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/tools/fixture-executor.d.ts +10 -0
- package/dist/tools/fixture-executor.d.ts.map +1 -0
- package/dist/tools/index.d.ts +4 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/schema-validator.d.ts +10 -0
- package/dist/tools/schema-validator.d.ts.map +1 -0
- package/dist/tools/types.d.ts +50 -0
- package/dist/tools/types.d.ts.map +1 -0
- package/package.json +2 -1
- package/src/adapters/registry.ts +7 -0
- package/src/adapters/types.test.ts +21 -0
- package/src/adapters/types.ts +18 -0
- package/src/agent-evaluation/index.ts +2 -0
- package/src/agent-evaluation/scorer.test.ts +1194 -0
- package/src/agent-evaluation/scorer.ts +640 -0
- package/src/agent-evaluation/types.test.ts +27 -0
- package/src/agent-evaluation/types.ts +43 -0
- package/src/artifacts/manifest.test.ts +66 -19
- package/src/artifacts/manifest.ts +18 -5
- package/src/artifacts/types.ts +49 -0
- package/src/evaluators/index.ts +3 -0
- package/src/evaluators/json-schema.test.ts +130 -0
- package/src/evaluators/json-schema.ts +38 -63
- package/src/evaluators/llm-grader.test.ts +80 -0
- package/src/evaluators/llm-grader.ts +44 -6
- package/src/evaluators/tool-trace.test.ts +46 -0
- package/src/evaluators/tool-trace.ts +50 -0
- package/src/evaluators/types.ts +20 -0
- package/src/index.ts +6 -0
- package/src/runner/executor.test.ts +340 -0
- package/src/runner/executor.ts +289 -20
- package/src/runner/release-validation.test.ts +169 -0
- package/src/runner/runner.ts +5 -1
- package/src/runner/types.ts +4 -0
- package/src/scenario/schema.ts +56 -1
- package/src/storage/supabase.test.ts +65 -1
- package/src/storage/supabase.ts +17 -2
- package/src/storage/types.ts +12 -2
- package/src/tools/fixture-executor.test.ts +88 -0
- package/src/tools/fixture-executor.ts +112 -0
- package/src/tools/index.ts +3 -0
- package/src/tools/schema-validator.test.ts +32 -0
- package/src/tools/schema-validator.ts +56 -0
- package/src/tools/types.ts +80 -0
- package/adapters/openai/dist/index.js +0 -5626
|
@@ -12,35 +12,29 @@ describe('createRunManifest', () => {
|
|
|
12
12
|
id: 'case-1',
|
|
13
13
|
name: 'Test Case 1',
|
|
14
14
|
ok: true,
|
|
15
|
+
status: 'passed',
|
|
15
16
|
latencyMs: 100,
|
|
16
17
|
prompt: 'Hello',
|
|
17
18
|
response: 'Hi there!',
|
|
18
|
-
tokens: { prompt: 10, completion: 5 },
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
score: 1,
|
|
24
|
-
reason: 'Contains expected value',
|
|
25
|
-
},
|
|
26
|
-
],
|
|
19
|
+
tokens: { prompt: 10, completion: 5, total: 15 },
|
|
20
|
+
score: 1,
|
|
21
|
+
matcherType: 'contains',
|
|
22
|
+
expected: { type: 'contains', values: ['Hi'], mode: 'any' },
|
|
23
|
+
tags: [],
|
|
27
24
|
},
|
|
28
25
|
{
|
|
29
26
|
id: 'case-2',
|
|
30
27
|
name: 'Test Case 2',
|
|
31
28
|
ok: false,
|
|
29
|
+
status: 'failed',
|
|
32
30
|
latencyMs: 200,
|
|
33
31
|
prompt: 'Goodbye',
|
|
34
32
|
response: 'See you!',
|
|
35
|
-
tokens: { prompt: 8, completion: 4 },
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
score: 0,
|
|
41
|
-
reason: 'Missing expected value',
|
|
42
|
-
},
|
|
43
|
-
],
|
|
33
|
+
tokens: { prompt: 8, completion: 4, total: 12 },
|
|
34
|
+
score: 0,
|
|
35
|
+
matcherType: 'contains',
|
|
36
|
+
expected: { type: 'contains', values: ['Bye'], mode: 'any' },
|
|
37
|
+
tags: [],
|
|
44
38
|
},
|
|
45
39
|
];
|
|
46
40
|
|
|
@@ -60,7 +54,7 @@ describe('createRunManifest', () => {
|
|
|
60
54
|
endTime,
|
|
61
55
|
});
|
|
62
56
|
|
|
63
|
-
expect(manifest.version).toBe('1.
|
|
57
|
+
expect(manifest.version).toBe('1.1');
|
|
64
58
|
expect(manifest.project).toBe('test-project');
|
|
65
59
|
expect(manifest.run_id).toBeTruthy();
|
|
66
60
|
expect(manifest.run_id.length).toBe(12);
|
|
@@ -86,6 +80,10 @@ describe('createRunManifest', () => {
|
|
|
86
80
|
expect(manifest.metrics.passed_cases).toBe(1);
|
|
87
81
|
expect(manifest.metrics.failed_cases).toBe(1);
|
|
88
82
|
expect(manifest.metrics.success_rate).toBe(0.5);
|
|
83
|
+
expect(manifest.metrics.total_attempts).toBe(2);
|
|
84
|
+
expect(manifest.metrics.valid_evaluations).toBe(2);
|
|
85
|
+
expect(manifest.metrics.invalid_evaluations).toBe(0);
|
|
86
|
+
expect(manifest.metrics.outcome_rate_denominator).toBe(2);
|
|
89
87
|
expect(manifest.metrics.total_tokens).toBe(27); // (10+5) + (8+4)
|
|
90
88
|
expect(manifest.metrics.total_prompt_tokens).toBe(18); // 10 + 8
|
|
91
89
|
expect(manifest.metrics.total_completion_tokens).toBe(9); // 5 + 4
|
|
@@ -129,6 +127,10 @@ describe('createRunManifest', () => {
|
|
|
129
127
|
expect(manifest.metrics.passed_cases).toBe(0);
|
|
130
128
|
expect(manifest.metrics.failed_cases).toBe(0);
|
|
131
129
|
expect(manifest.metrics.success_rate).toBe(0);
|
|
130
|
+
expect(manifest.metrics.total_attempts).toBe(0);
|
|
131
|
+
expect(manifest.metrics.valid_evaluations).toBe(0);
|
|
132
|
+
expect(manifest.metrics.invalid_evaluations).toBe(0);
|
|
133
|
+
expect(manifest.metrics.outcome_rate_denominator).toBe(0);
|
|
132
134
|
expect(manifest.metrics.median_latency_ms).toBe(0);
|
|
133
135
|
expect(manifest.metrics.p95_latency_ms).toBe(0);
|
|
134
136
|
});
|
|
@@ -203,4 +205,49 @@ describe('createRunManifest', () => {
|
|
|
203
205
|
expect(manifest.metrics.median_latency_ms).toBe(300);
|
|
204
206
|
expect(manifest.metrics.p95_latency_ms).toBe(500);
|
|
205
207
|
});
|
|
208
|
+
|
|
209
|
+
test('excludes invalid and target-error measurements from the success-rate denominator', () => {
|
|
210
|
+
const manifest = createRunManifest({
|
|
211
|
+
project: 'test-project',
|
|
212
|
+
config: { scenario: 'test-scenario', provider: 'openai' },
|
|
213
|
+
cases: [
|
|
214
|
+
{ ...mockCases[0], attempts: 2 },
|
|
215
|
+
{ ...mockCases[1], status: 'invalid', error: undefined },
|
|
216
|
+
{ ...mockCases[1], id: 'case-3', status: 'error', error: 'provider unavailable' },
|
|
217
|
+
],
|
|
218
|
+
startTime: new Date(),
|
|
219
|
+
endTime: new Date(),
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(manifest.metrics).toMatchObject({
|
|
223
|
+
total_attempts: 4,
|
|
224
|
+
total_cases: 3,
|
|
225
|
+
valid_evaluations: 1,
|
|
226
|
+
invalid_evaluations: 2,
|
|
227
|
+
outcome_rate_denominator: 1,
|
|
228
|
+
passed_cases: 1,
|
|
229
|
+
failed_cases: 0,
|
|
230
|
+
success_rate: 1,
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('maps historical manifests without a status using the documented legacy mapping', () => {
|
|
235
|
+
const manifest = createRunManifest({
|
|
236
|
+
project: 'test-project',
|
|
237
|
+
config: { scenario: 'legacy', provider: 'openai' },
|
|
238
|
+
cases: [
|
|
239
|
+
{ ...mockCases[0], status: undefined },
|
|
240
|
+
{ ...mockCases[1], status: undefined, error: 'legacy execution failure' },
|
|
241
|
+
],
|
|
242
|
+
startTime: new Date(),
|
|
243
|
+
endTime: new Date(),
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(manifest.metrics).toMatchObject({
|
|
247
|
+
valid_evaluations: 1,
|
|
248
|
+
invalid_evaluations: 1,
|
|
249
|
+
outcome_rate_denominator: 1,
|
|
250
|
+
success_rate: 1,
|
|
251
|
+
});
|
|
252
|
+
});
|
|
206
253
|
});
|
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
RunManifest,
|
|
16
16
|
RunMetrics,
|
|
17
17
|
} from './types';
|
|
18
|
+
import { getCaseEvaluationStatus } from './types';
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Create a new run manifest
|
|
@@ -49,7 +50,7 @@ export function createRunManifest(options: {
|
|
|
49
50
|
const environment = getEnvironmentInfo();
|
|
50
51
|
|
|
51
52
|
return {
|
|
52
|
-
version: '1.
|
|
53
|
+
version: '1.1',
|
|
53
54
|
run_id: nanoid(12),
|
|
54
55
|
project,
|
|
55
56
|
start_time: startTime.toISOString(),
|
|
@@ -74,7 +75,11 @@ export function createRunManifest(options: {
|
|
|
74
75
|
* Calculate metrics from case results
|
|
75
76
|
*/
|
|
76
77
|
function calculateMetrics(cases: CaseResult[], model?: string): RunMetrics {
|
|
77
|
-
const passedCases = cases.filter((c) => c
|
|
78
|
+
const passedCases = cases.filter((c) => getCaseEvaluationStatus(c) === 'passed');
|
|
79
|
+
const validCases = cases.filter((c) => {
|
|
80
|
+
const status = getCaseEvaluationStatus(c);
|
|
81
|
+
return status === 'passed' || status === 'failed';
|
|
82
|
+
});
|
|
78
83
|
const latencies = cases.map((c) => c.latencyMs).sort((a, b) => a - b);
|
|
79
84
|
|
|
80
85
|
const medianLatency = latencies.length > 0 ? latencies[Math.floor(latencies.length / 2)] : 0;
|
|
@@ -87,7 +92,11 @@ function calculateMetrics(cases: CaseResult[], model?: string): RunMetrics {
|
|
|
87
92
|
|
|
88
93
|
// Calculate cost if model is provided
|
|
89
94
|
let cost: CostEstimateInfo | undefined;
|
|
90
|
-
if (
|
|
95
|
+
if (
|
|
96
|
+
model &&
|
|
97
|
+
!model.toLowerCase().includes('ling-') &&
|
|
98
|
+
(totalPromptTokens > 0 || totalCompletionTokens > 0)
|
|
99
|
+
) {
|
|
91
100
|
const costEstimate = estimateCost(totalPromptTokens, totalCompletionTokens, model);
|
|
92
101
|
const pricing = getModelPricing(model);
|
|
93
102
|
cost = {
|
|
@@ -103,10 +112,14 @@ function calculateMetrics(cases: CaseResult[], model?: string): RunMetrics {
|
|
|
103
112
|
}
|
|
104
113
|
|
|
105
114
|
return {
|
|
106
|
-
success_rate:
|
|
115
|
+
success_rate: validCases.length > 0 ? passedCases.length / validCases.length : 0,
|
|
116
|
+
total_attempts: cases.reduce((sum, c) => sum + (c.attempts ?? 1), 0),
|
|
107
117
|
total_cases: cases.length,
|
|
118
|
+
valid_evaluations: validCases.length,
|
|
119
|
+
invalid_evaluations: cases.length - validCases.length,
|
|
120
|
+
outcome_rate_denominator: validCases.length,
|
|
108
121
|
passed_cases: passedCases.length,
|
|
109
|
-
failed_cases:
|
|
122
|
+
failed_cases: validCases.length - passedCases.length,
|
|
110
123
|
median_latency_ms: medianLatency,
|
|
111
124
|
p95_latency_ms: p95Latency,
|
|
112
125
|
total_tokens: totalPromptTokens + totalCompletionTokens,
|
package/src/artifacts/types.ts
CHANGED
|
@@ -42,6 +42,26 @@ export interface ManifestRedactionInfo {
|
|
|
42
42
|
// Case Result Types
|
|
43
43
|
// ============================================================================
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Terminal status of a case measurement.
|
|
47
|
+
*
|
|
48
|
+
* `invalid` means a target response was available but could not be evaluated
|
|
49
|
+
* reliably. `error` means execution did not produce a usable target response.
|
|
50
|
+
*/
|
|
51
|
+
export type CaseEvaluationStatus = 'passed' | 'failed' | 'invalid' | 'error';
|
|
52
|
+
|
|
53
|
+
/** Reviewed, bounded evaluator evidence retained in a run artifact. */
|
|
54
|
+
export interface CaseEvaluationEvidence {
|
|
55
|
+
evaluator: string;
|
|
56
|
+
score?: number;
|
|
57
|
+
threshold?: number;
|
|
58
|
+
model?: string;
|
|
59
|
+
validation?: {
|
|
60
|
+
status: 'valid' | 'invalid';
|
|
61
|
+
code?: string;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
45
65
|
/**
|
|
46
66
|
* Individual test case result
|
|
47
67
|
*/
|
|
@@ -49,6 +69,10 @@ export interface CaseResult {
|
|
|
49
69
|
id: string;
|
|
50
70
|
name?: string;
|
|
51
71
|
ok: boolean;
|
|
72
|
+
/** Present in manifest v1.1+. Missing values use the documented legacy mapping. */
|
|
73
|
+
status?: CaseEvaluationStatus;
|
|
74
|
+
/** Number of execution attempts represented by this terminal result. */
|
|
75
|
+
attempts?: number;
|
|
52
76
|
score: number;
|
|
53
77
|
matcherType: string;
|
|
54
78
|
reason?: string;
|
|
@@ -63,8 +87,14 @@ export interface CaseResult {
|
|
|
63
87
|
expected: object;
|
|
64
88
|
tags: string[];
|
|
65
89
|
error?: string;
|
|
90
|
+
/** Sanitized evaluator evidence; arbitrary evaluator details are never stored here. */
|
|
91
|
+
evidence?: CaseEvaluationEvidence;
|
|
66
92
|
/** Redaction information for this case */
|
|
67
93
|
redaction?: CaseRedactionInfo;
|
|
94
|
+
/** Ordered tool activity captured for an enabled tool loop. */
|
|
95
|
+
toolTrace?: import('../tools').ToolTraceEntry[];
|
|
96
|
+
/** Terminal status for an enabled tool loop. */
|
|
97
|
+
toolLoop?: import('../tools').ToolLoopSummary;
|
|
68
98
|
}
|
|
69
99
|
|
|
70
100
|
/**
|
|
@@ -91,7 +121,15 @@ export interface CostEstimateInfo {
|
|
|
91
121
|
*/
|
|
92
122
|
export interface RunMetrics {
|
|
93
123
|
success_rate: number;
|
|
124
|
+
/** Number of execution attempts, including retries. */
|
|
125
|
+
total_attempts?: number;
|
|
94
126
|
total_cases: number;
|
|
127
|
+
/** Case results with a valid evaluator outcome (passed or failed). */
|
|
128
|
+
valid_evaluations?: number;
|
|
129
|
+
/** Case results excluded from outcome rates (invalid or error). */
|
|
130
|
+
invalid_evaluations?: number;
|
|
131
|
+
/** Denominator used for success_rate; zero produces a success_rate of zero. */
|
|
132
|
+
outcome_rate_denominator?: number;
|
|
95
133
|
passed_cases: number;
|
|
96
134
|
failed_cases: number;
|
|
97
135
|
median_latency_ms: number;
|
|
@@ -240,6 +278,17 @@ export interface RunManifest {
|
|
|
240
278
|
redaction?: ManifestRedactionInfo;
|
|
241
279
|
}
|
|
242
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Read a case status from both v1.1 artifacts and historical v1.0 artifacts.
|
|
283
|
+
* Historical records cannot distinguish evaluator failures from ordinary failed
|
|
284
|
+
* criteria unless they set the legacy `error` field.
|
|
285
|
+
*/
|
|
286
|
+
export function getCaseEvaluationStatus(caseResult: CaseResult): CaseEvaluationStatus {
|
|
287
|
+
if (caseResult.status) return caseResult.status;
|
|
288
|
+
if (caseResult.ok) return 'passed';
|
|
289
|
+
return caseResult.error ? 'error' : 'failed';
|
|
290
|
+
}
|
|
291
|
+
|
|
243
292
|
// ============================================================================
|
|
244
293
|
// Red Team Manifest Types
|
|
245
294
|
// ============================================================================
|
package/src/evaluators/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { LLMGraderEvaluator } from './llm-grader';
|
|
|
12
12
|
import { NotContainsEvaluator } from './not-contains';
|
|
13
13
|
import { RegexEvaluator } from './regex';
|
|
14
14
|
import { SimilarityEvaluator } from './similarity';
|
|
15
|
+
import { ToolTraceEvaluator } from './tool-trace';
|
|
15
16
|
import type { Evaluator } from './types';
|
|
16
17
|
|
|
17
18
|
const evaluators = new Map<string, Evaluator>();
|
|
@@ -25,6 +26,7 @@ evaluators.set('json_schema', new JsonSchemaEvaluator());
|
|
|
25
26
|
evaluators.set('llm_grader', new LLMGraderEvaluator());
|
|
26
27
|
evaluators.set('similarity', new SimilarityEvaluator());
|
|
27
28
|
evaluators.set('inline', new InlineEvaluator());
|
|
29
|
+
evaluators.set('tool_trace', new ToolTraceEvaluator());
|
|
28
30
|
|
|
29
31
|
/**
|
|
30
32
|
* Get an evaluator by type
|
|
@@ -63,3 +65,4 @@ export { JsonSchemaEvaluator } from './json-schema';
|
|
|
63
65
|
export { LLMGraderEvaluator } from './llm-grader';
|
|
64
66
|
export { SimilarityEvaluator } from './similarity';
|
|
65
67
|
export { InlineEvaluator, SUPPORTED_EXPRESSIONS } from './inline';
|
|
68
|
+
export { ToolTraceEvaluator } from './tool-trace';
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import type { Expected } from '../scenario/schema';
|
|
3
|
+
import { JsonSchemaEvaluator } from './json-schema';
|
|
4
|
+
|
|
5
|
+
const expected: Expected = {
|
|
6
|
+
type: 'json_schema',
|
|
7
|
+
schema: {
|
|
8
|
+
type: 'object',
|
|
9
|
+
required: ['profile', 'status', 'version'],
|
|
10
|
+
additionalProperties: false,
|
|
11
|
+
properties: {
|
|
12
|
+
profile: {
|
|
13
|
+
type: 'object',
|
|
14
|
+
required: ['roles'],
|
|
15
|
+
additionalProperties: false,
|
|
16
|
+
properties: {
|
|
17
|
+
roles: {
|
|
18
|
+
type: 'array',
|
|
19
|
+
items: { type: 'string', enum: ['admin', 'member'] },
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
status: { enum: ['active', 'disabled'] },
|
|
24
|
+
version: { const: 'v1' },
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
describe('JsonSchemaEvaluator', () => {
|
|
30
|
+
const evaluator = new JsonSchemaEvaluator();
|
|
31
|
+
|
|
32
|
+
it('accepts a response matching a nested JSON schema', async () => {
|
|
33
|
+
const result = await evaluator.evaluate(
|
|
34
|
+
JSON.stringify({ profile: { roles: ['admin'] }, status: 'active', version: 'v1' }),
|
|
35
|
+
expected
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
expect(result.passed).toBe(true);
|
|
39
|
+
expect(result.score).toBe(1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('rejects a missing nested required property', async () => {
|
|
43
|
+
const result = await evaluator.evaluate(
|
|
44
|
+
JSON.stringify({ profile: {}, status: 'active', version: 'v1' }),
|
|
45
|
+
expected
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
expect(result.passed).toBe(false);
|
|
49
|
+
expect(result.score).toBe(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('rejects values outside enum constraints', async () => {
|
|
53
|
+
const result = await evaluator.evaluate(
|
|
54
|
+
JSON.stringify({ profile: { roles: ['owner'] }, status: 'pending', version: 'v1' }),
|
|
55
|
+
expected
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
expect(result.passed).toBe(false);
|
|
59
|
+
expect(result.reason).toContain('allowed values');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('rejects values that violate const constraints', async () => {
|
|
63
|
+
const result = await evaluator.evaluate(
|
|
64
|
+
JSON.stringify({ profile: { roles: ['member'] }, status: 'active', version: 'v2' }),
|
|
65
|
+
expected
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
expect(result.passed).toBe(false);
|
|
69
|
+
expect(result.reason).toContain('constant');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('rejects additional properties in closed objects', async () => {
|
|
73
|
+
const result = await evaluator.evaluate(
|
|
74
|
+
JSON.stringify({
|
|
75
|
+
profile: { roles: ['admin'], internal: true },
|
|
76
|
+
status: 'active',
|
|
77
|
+
version: 'v1',
|
|
78
|
+
}),
|
|
79
|
+
expected
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(result.passed).toBe(false);
|
|
83
|
+
expect(result.reason).toContain('additional properties');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('reports invalid schemas without exposing response content', async () => {
|
|
87
|
+
const sensitiveResponse = 'sensitive-response-value';
|
|
88
|
+
const result = await evaluator.evaluate(sensitiveResponse, {
|
|
89
|
+
type: 'json_schema',
|
|
90
|
+
schema: { type: 'not-a-json-schema-type' },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(result).toEqual({
|
|
94
|
+
passed: false,
|
|
95
|
+
score: 0,
|
|
96
|
+
reason: 'Invalid JSON schema',
|
|
97
|
+
details: { error: 'Invalid JSON schema' },
|
|
98
|
+
});
|
|
99
|
+
expect(JSON.stringify(result)).not.toContain('sensitive-response-value');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('validates distinct schema objects with the same $id independently', async () => {
|
|
103
|
+
const firstExpected: Expected = {
|
|
104
|
+
type: 'json_schema',
|
|
105
|
+
schema: {
|
|
106
|
+
$id: 'https://schemas.example.test/shared-result',
|
|
107
|
+
type: 'object',
|
|
108
|
+
required: ['first'],
|
|
109
|
+
properties: { first: { const: true } },
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
const secondExpected: Expected = {
|
|
113
|
+
type: 'json_schema',
|
|
114
|
+
schema: {
|
|
115
|
+
$id: 'https://schemas.example.test/shared-result',
|
|
116
|
+
type: 'object',
|
|
117
|
+
required: ['second'],
|
|
118
|
+
properties: { second: { const: true } },
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const firstResult = await evaluator.evaluate('{"first":true}', firstExpected);
|
|
123
|
+
const cachedResult = await evaluator.evaluate('{"first":true}', firstExpected);
|
|
124
|
+
const distinctResult = await evaluator.evaluate('{"second":true}', secondExpected);
|
|
125
|
+
|
|
126
|
+
expect(firstResult.passed).toBe(true);
|
|
127
|
+
expect(cachedResult.passed).toBe(true);
|
|
128
|
+
expect(distinctResult.passed).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -2,10 +2,21 @@
|
|
|
2
2
|
* JSON Schema evaluator - validates response against a JSON schema
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import Ajv, { type ValidateFunction } from 'ajv';
|
|
6
6
|
import type { Expected } from '../scenario/schema';
|
|
7
7
|
import type { Evaluator, EvaluatorResult } from './types';
|
|
8
8
|
|
|
9
|
+
const validators = new WeakMap<object, ValidateFunction>();
|
|
10
|
+
|
|
11
|
+
function getValidator(schema: Record<string, unknown>): ValidateFunction {
|
|
12
|
+
const cached = validators.get(schema);
|
|
13
|
+
if (cached) return cached;
|
|
14
|
+
|
|
15
|
+
const validator = new Ajv({ allErrors: true, strict: false }).compile(schema);
|
|
16
|
+
validators.set(schema, validator);
|
|
17
|
+
return validator;
|
|
18
|
+
}
|
|
19
|
+
|
|
9
20
|
export class JsonSchemaEvaluator implements Evaluator {
|
|
10
21
|
readonly type = 'json_schema';
|
|
11
22
|
|
|
@@ -14,6 +25,18 @@ export class JsonSchemaEvaluator implements Evaluator {
|
|
|
14
25
|
throw new Error('Invalid expected type for JsonSchemaEvaluator');
|
|
15
26
|
}
|
|
16
27
|
|
|
28
|
+
let validator: ValidateFunction;
|
|
29
|
+
try {
|
|
30
|
+
validator = getValidator(expected.schema);
|
|
31
|
+
} catch {
|
|
32
|
+
return {
|
|
33
|
+
passed: false,
|
|
34
|
+
score: 0,
|
|
35
|
+
reason: 'Invalid JSON schema',
|
|
36
|
+
details: { error: 'Invalid JSON schema' },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
17
40
|
let parsed: unknown;
|
|
18
41
|
try {
|
|
19
42
|
const jsonMatch = response.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
@@ -28,71 +51,23 @@ export class JsonSchemaEvaluator implements Evaluator {
|
|
|
28
51
|
};
|
|
29
52
|
}
|
|
30
53
|
|
|
31
|
-
|
|
32
|
-
const zodSchema = this.jsonSchemaToZod(expected.schema);
|
|
33
|
-
const result = zodSchema.safeParse(parsed);
|
|
34
|
-
|
|
35
|
-
if (result.success) {
|
|
36
|
-
return {
|
|
37
|
-
passed: true,
|
|
38
|
-
score: 1,
|
|
39
|
-
reason: 'Response matches JSON schema',
|
|
40
|
-
details: { parsed },
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
const issues = result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`);
|
|
44
|
-
return {
|
|
45
|
-
passed: false,
|
|
46
|
-
score: 0,
|
|
47
|
-
reason: `Schema validation failed: ${issues.join(', ')}`,
|
|
48
|
-
details: {
|
|
49
|
-
parsed,
|
|
50
|
-
errors: issues,
|
|
51
|
-
},
|
|
52
|
-
};
|
|
53
|
-
} catch (error) {
|
|
54
|
+
if (validator(parsed)) {
|
|
54
55
|
return {
|
|
55
|
-
passed:
|
|
56
|
-
score:
|
|
57
|
-
reason:
|
|
58
|
-
details: {
|
|
56
|
+
passed: true,
|
|
57
|
+
score: 1,
|
|
58
|
+
reason: 'Response matches JSON schema',
|
|
59
|
+
details: { parsed },
|
|
59
60
|
};
|
|
60
61
|
}
|
|
61
|
-
}
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return z.number().int();
|
|
73
|
-
case 'boolean':
|
|
74
|
-
return z.boolean();
|
|
75
|
-
case 'null':
|
|
76
|
-
return z.null();
|
|
77
|
-
case 'array':
|
|
78
|
-
if (schema.items) {
|
|
79
|
-
return z.array(this.jsonSchemaToZod(schema.items as Record<string, unknown>));
|
|
80
|
-
}
|
|
81
|
-
return z.array(z.unknown());
|
|
82
|
-
case 'object':
|
|
83
|
-
if (schema.properties) {
|
|
84
|
-
const shape: Record<string, z.ZodTypeAny> = {};
|
|
85
|
-
const required = (schema.required as string[]) || [];
|
|
86
|
-
|
|
87
|
-
for (const [key, value] of Object.entries(schema.properties as Record<string, unknown>)) {
|
|
88
|
-
const fieldSchema = this.jsonSchemaToZod(value as Record<string, unknown>);
|
|
89
|
-
shape[key] = required.includes(key) ? fieldSchema : fieldSchema.optional();
|
|
90
|
-
}
|
|
91
|
-
return z.object(shape);
|
|
92
|
-
}
|
|
93
|
-
return z.record(z.unknown());
|
|
94
|
-
default:
|
|
95
|
-
return z.unknown();
|
|
96
|
-
}
|
|
63
|
+
const errors = (validator.errors ?? []).map(
|
|
64
|
+
(error) => `${error.instancePath || '/'} ${error.message ?? 'is invalid'}`
|
|
65
|
+
);
|
|
66
|
+
return {
|
|
67
|
+
passed: false,
|
|
68
|
+
score: 0,
|
|
69
|
+
reason: `Schema validation failed: ${errors.join('; ')}`,
|
|
70
|
+
details: { parsed, errors },
|
|
71
|
+
};
|
|
97
72
|
}
|
|
98
73
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { ModelClient } from '../adapters/types';
|
|
3
|
+
import { ScenarioSchema } from '../scenario/schema';
|
|
4
|
+
import { LLMGraderEvaluator } from './llm-grader';
|
|
5
|
+
|
|
6
|
+
function graderCase(strict: boolean) {
|
|
7
|
+
return ScenarioSchema.parse({
|
|
8
|
+
name: 'grader parsing',
|
|
9
|
+
cases: [
|
|
10
|
+
{
|
|
11
|
+
id: 'grade',
|
|
12
|
+
prompt: 'grade this',
|
|
13
|
+
expected: { type: 'llm_grader', rubric: 'Be correct', threshold: 0.7, strict },
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
}).cases[0].expected;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function clientWith(text: string): ModelClient {
|
|
20
|
+
return {
|
|
21
|
+
provider: 'test',
|
|
22
|
+
generate: async () => ({
|
|
23
|
+
id: 'judge-response',
|
|
24
|
+
model: 'judge-model',
|
|
25
|
+
text,
|
|
26
|
+
tokens: { prompt: 1, completion: 1, total: 2 },
|
|
27
|
+
latencyMs: 1,
|
|
28
|
+
finishReason: 'stop',
|
|
29
|
+
}),
|
|
30
|
+
capabilities: async () => ({
|
|
31
|
+
streaming: false,
|
|
32
|
+
functionCalling: false,
|
|
33
|
+
toolUse: false,
|
|
34
|
+
maxContext: 1,
|
|
35
|
+
}),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('LLMGraderEvaluator strict parsing', () => {
|
|
40
|
+
test('accepts finite numeric boundary scores in exact JSON', async () => {
|
|
41
|
+
const evaluator = new LLMGraderEvaluator();
|
|
42
|
+
const passing = await evaluator.evaluate('response', graderCase(true), {
|
|
43
|
+
client: clientWith('{"score":1,"reason":"complete"}'),
|
|
44
|
+
});
|
|
45
|
+
const failing = await evaluator.evaluate('response', graderCase(true), {
|
|
46
|
+
client: clientWith('{"score":0,"reason":"missing requirement"}'),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(passing).toMatchObject({ passed: true, score: 1, status: 'passed' });
|
|
50
|
+
expect(failing).toMatchObject({ passed: false, score: 0, status: 'failed' });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('marks malformed, coerced, and out-of-range strict judge scores invalid', async () => {
|
|
54
|
+
const evaluator = new LLMGraderEvaluator();
|
|
55
|
+
for (const text of [
|
|
56
|
+
'Score: 1',
|
|
57
|
+
'{"score":"1","reason":"coerced"}',
|
|
58
|
+
'{"score":1.01,"reason":"out of range"}',
|
|
59
|
+
'```json\n{"score":1,"reason":"wrapped"}\n```',
|
|
60
|
+
]) {
|
|
61
|
+
const result = await evaluator.evaluate('response', graderCase(true), {
|
|
62
|
+
client: clientWith(text),
|
|
63
|
+
});
|
|
64
|
+
expect(result).toMatchObject({
|
|
65
|
+
passed: false,
|
|
66
|
+
score: 0,
|
|
67
|
+
status: 'invalid',
|
|
68
|
+
evidence: { validation: { status: 'invalid', code: 'grader_failure' } },
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('keeps permissive parsing for non-strict legacy graders', async () => {
|
|
74
|
+
const result = await new LLMGraderEvaluator().evaluate('response', graderCase(false), {
|
|
75
|
+
client: clientWith('Score: 0.8'),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(result).toMatchObject({ passed: true, score: 0.8, status: 'passed' });
|
|
79
|
+
});
|
|
80
|
+
});
|