@artemiskit/core 0.3.0 → 0.4.1
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 +17 -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 +52 -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 +20281 -13144
- 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/local.d.ts +1 -1
- package/dist/storage/local.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 +90 -19
- package/src/artifacts/manifest.ts +18 -5
- package/src/artifacts/types.ts +133 -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 +374 -0
- package/src/runner/executor.ts +349 -22
- package/src/runner/release-validation.test.ts +169 -0
- package/src/runner/runner.ts +7 -1
- package/src/runner/types.ts +4 -0
- package/src/scenario/schema.ts +56 -1
- package/src/storage/local.test.ts +24 -0
- package/src/storage/local.ts +13 -2
- package/src/storage/supabase.test.ts +111 -1
- package/src/storage/supabase.ts +26 -3
- 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
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { describe, expect, test } from 'bun:test';
|
|
6
6
|
import { createRunManifest } from './manifest';
|
|
7
|
+
import { assertRunManifestIntegrity } from './types';
|
|
7
8
|
import type { CaseResult } from './types';
|
|
8
9
|
|
|
9
10
|
describe('createRunManifest', () => {
|
|
@@ -12,35 +13,29 @@ describe('createRunManifest', () => {
|
|
|
12
13
|
id: 'case-1',
|
|
13
14
|
name: 'Test Case 1',
|
|
14
15
|
ok: true,
|
|
16
|
+
status: 'passed',
|
|
15
17
|
latencyMs: 100,
|
|
16
18
|
prompt: 'Hello',
|
|
17
19
|
response: 'Hi there!',
|
|
18
|
-
tokens: { prompt: 10, completion: 5 },
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
score: 1,
|
|
24
|
-
reason: 'Contains expected value',
|
|
25
|
-
},
|
|
26
|
-
],
|
|
20
|
+
tokens: { prompt: 10, completion: 5, total: 15 },
|
|
21
|
+
score: 1,
|
|
22
|
+
matcherType: 'contains',
|
|
23
|
+
expected: { type: 'contains', values: ['Hi'], mode: 'any' },
|
|
24
|
+
tags: [],
|
|
27
25
|
},
|
|
28
26
|
{
|
|
29
27
|
id: 'case-2',
|
|
30
28
|
name: 'Test Case 2',
|
|
31
29
|
ok: false,
|
|
30
|
+
status: 'failed',
|
|
32
31
|
latencyMs: 200,
|
|
33
32
|
prompt: 'Goodbye',
|
|
34
33
|
response: 'See you!',
|
|
35
|
-
tokens: { prompt: 8, completion: 4 },
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
score: 0,
|
|
41
|
-
reason: 'Missing expected value',
|
|
42
|
-
},
|
|
43
|
-
],
|
|
34
|
+
tokens: { prompt: 8, completion: 4, total: 12 },
|
|
35
|
+
score: 0,
|
|
36
|
+
matcherType: 'contains',
|
|
37
|
+
expected: { type: 'contains', values: ['Bye'], mode: 'any' },
|
|
38
|
+
tags: [],
|
|
44
39
|
},
|
|
45
40
|
];
|
|
46
41
|
|
|
@@ -60,7 +55,7 @@ describe('createRunManifest', () => {
|
|
|
60
55
|
endTime,
|
|
61
56
|
});
|
|
62
57
|
|
|
63
|
-
expect(manifest.version).toBe('1.
|
|
58
|
+
expect(manifest.version).toBe('1.1');
|
|
64
59
|
expect(manifest.project).toBe('test-project');
|
|
65
60
|
expect(manifest.run_id).toBeTruthy();
|
|
66
61
|
expect(manifest.run_id.length).toBe(12);
|
|
@@ -86,6 +81,10 @@ describe('createRunManifest', () => {
|
|
|
86
81
|
expect(manifest.metrics.passed_cases).toBe(1);
|
|
87
82
|
expect(manifest.metrics.failed_cases).toBe(1);
|
|
88
83
|
expect(manifest.metrics.success_rate).toBe(0.5);
|
|
84
|
+
expect(manifest.metrics.total_attempts).toBe(2);
|
|
85
|
+
expect(manifest.metrics.valid_evaluations).toBe(2);
|
|
86
|
+
expect(manifest.metrics.invalid_evaluations).toBe(0);
|
|
87
|
+
expect(manifest.metrics.outcome_rate_denominator).toBe(2);
|
|
89
88
|
expect(manifest.metrics.total_tokens).toBe(27); // (10+5) + (8+4)
|
|
90
89
|
expect(manifest.metrics.total_prompt_tokens).toBe(18); // 10 + 8
|
|
91
90
|
expect(manifest.metrics.total_completion_tokens).toBe(9); // 5 + 4
|
|
@@ -129,6 +128,10 @@ describe('createRunManifest', () => {
|
|
|
129
128
|
expect(manifest.metrics.passed_cases).toBe(0);
|
|
130
129
|
expect(manifest.metrics.failed_cases).toBe(0);
|
|
131
130
|
expect(manifest.metrics.success_rate).toBe(0);
|
|
131
|
+
expect(manifest.metrics.total_attempts).toBe(0);
|
|
132
|
+
expect(manifest.metrics.valid_evaluations).toBe(0);
|
|
133
|
+
expect(manifest.metrics.invalid_evaluations).toBe(0);
|
|
134
|
+
expect(manifest.metrics.outcome_rate_denominator).toBe(0);
|
|
132
135
|
expect(manifest.metrics.median_latency_ms).toBe(0);
|
|
133
136
|
expect(manifest.metrics.p95_latency_ms).toBe(0);
|
|
134
137
|
});
|
|
@@ -203,4 +206,72 @@ describe('createRunManifest', () => {
|
|
|
203
206
|
expect(manifest.metrics.median_latency_ms).toBe(300);
|
|
204
207
|
expect(manifest.metrics.p95_latency_ms).toBe(500);
|
|
205
208
|
});
|
|
209
|
+
|
|
210
|
+
test('excludes invalid and target-error measurements from the success-rate denominator', () => {
|
|
211
|
+
const manifest = createRunManifest({
|
|
212
|
+
project: 'test-project',
|
|
213
|
+
config: { scenario: 'test-scenario', provider: 'openai' },
|
|
214
|
+
cases: [
|
|
215
|
+
{ ...mockCases[0], attempts: 2 },
|
|
216
|
+
{ ...mockCases[1], status: 'invalid', error: undefined },
|
|
217
|
+
{ ...mockCases[1], id: 'case-3', status: 'error', error: 'provider unavailable' },
|
|
218
|
+
],
|
|
219
|
+
startTime: new Date(),
|
|
220
|
+
endTime: new Date(),
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
expect(manifest.metrics).toMatchObject({
|
|
224
|
+
total_attempts: 4,
|
|
225
|
+
total_cases: 3,
|
|
226
|
+
valid_evaluations: 1,
|
|
227
|
+
invalid_evaluations: 2,
|
|
228
|
+
outcome_rate_denominator: 1,
|
|
229
|
+
passed_cases: 1,
|
|
230
|
+
failed_cases: 0,
|
|
231
|
+
success_rate: 1,
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('maps historical manifests without a status using the documented legacy mapping', () => {
|
|
236
|
+
const manifest = createRunManifest({
|
|
237
|
+
project: 'test-project',
|
|
238
|
+
config: { scenario: 'legacy', provider: 'openai' },
|
|
239
|
+
cases: [
|
|
240
|
+
{ ...mockCases[0], status: undefined },
|
|
241
|
+
{ ...mockCases[1], status: undefined, error: 'legacy execution failure' },
|
|
242
|
+
],
|
|
243
|
+
startTime: new Date(),
|
|
244
|
+
endTime: new Date(),
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
expect(manifest.metrics).toMatchObject({
|
|
248
|
+
valid_evaluations: 1,
|
|
249
|
+
invalid_evaluations: 1,
|
|
250
|
+
outcome_rate_denominator: 1,
|
|
251
|
+
success_rate: 1,
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('accepts historical cases without integrity fields but rejects malformed new evidence', () => {
|
|
256
|
+
const historical = createRunManifest({
|
|
257
|
+
project: 'test-project',
|
|
258
|
+
config: { scenario: 'legacy', provider: 'openai' },
|
|
259
|
+
cases: [{ ...mockCases[0], status: undefined, evidence: undefined }],
|
|
260
|
+
startTime: new Date(),
|
|
261
|
+
endTime: new Date(),
|
|
262
|
+
});
|
|
263
|
+
expect(() => assertRunManifestIntegrity(historical)).not.toThrow();
|
|
264
|
+
|
|
265
|
+
const malformed = {
|
|
266
|
+
...historical,
|
|
267
|
+
cases: [
|
|
268
|
+
{
|
|
269
|
+
...mockCases[0],
|
|
270
|
+
status: 'passed',
|
|
271
|
+
evidence: { evaluator: 'custom', validation: { status: 'unknown' } },
|
|
272
|
+
},
|
|
273
|
+
],
|
|
274
|
+
};
|
|
275
|
+
expect(() => assertRunManifestIntegrity(malformed)).toThrow('invalid evidence validation');
|
|
276
|
+
});
|
|
206
277
|
});
|
|
@@ -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
|
@@ -16,6 +16,8 @@ export interface CaseRedactionInfo {
|
|
|
16
16
|
promptRedacted: boolean;
|
|
17
17
|
/** Whether response was redacted */
|
|
18
18
|
responseRedacted: boolean;
|
|
19
|
+
/** Whether evaluator reason text was redacted */
|
|
20
|
+
reasonRedacted?: boolean;
|
|
19
21
|
/** Number of redactions in this case */
|
|
20
22
|
redactionCount: number;
|
|
21
23
|
}
|
|
@@ -34,6 +36,8 @@ export interface ManifestRedactionInfo {
|
|
|
34
36
|
summary: {
|
|
35
37
|
promptsRedacted: number;
|
|
36
38
|
responsesRedacted: number;
|
|
39
|
+
/** Cases whose evaluator reason text was redacted. */
|
|
40
|
+
reasonsRedacted?: number;
|
|
37
41
|
totalRedactions: number;
|
|
38
42
|
};
|
|
39
43
|
}
|
|
@@ -42,6 +46,26 @@ export interface ManifestRedactionInfo {
|
|
|
42
46
|
// Case Result Types
|
|
43
47
|
// ============================================================================
|
|
44
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Terminal status of a case measurement.
|
|
51
|
+
*
|
|
52
|
+
* `invalid` means a target response was available but could not be evaluated
|
|
53
|
+
* reliably. `error` means execution did not produce a usable target response.
|
|
54
|
+
*/
|
|
55
|
+
export type CaseEvaluationStatus = 'passed' | 'failed' | 'invalid' | 'error';
|
|
56
|
+
|
|
57
|
+
/** Reviewed, bounded evaluator evidence retained in a run artifact. */
|
|
58
|
+
export interface CaseEvaluationEvidence {
|
|
59
|
+
evaluator: string;
|
|
60
|
+
score?: number;
|
|
61
|
+
threshold?: number;
|
|
62
|
+
model?: string;
|
|
63
|
+
validation?: {
|
|
64
|
+
status: 'valid' | 'invalid';
|
|
65
|
+
code?: string;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
45
69
|
/**
|
|
46
70
|
* Individual test case result
|
|
47
71
|
*/
|
|
@@ -49,6 +73,10 @@ export interface CaseResult {
|
|
|
49
73
|
id: string;
|
|
50
74
|
name?: string;
|
|
51
75
|
ok: boolean;
|
|
76
|
+
/** Present in manifest v1.1+. Missing values use the documented legacy mapping. */
|
|
77
|
+
status?: CaseEvaluationStatus;
|
|
78
|
+
/** Number of execution attempts represented by this terminal result. */
|
|
79
|
+
attempts?: number;
|
|
52
80
|
score: number;
|
|
53
81
|
matcherType: string;
|
|
54
82
|
reason?: string;
|
|
@@ -63,8 +91,14 @@ export interface CaseResult {
|
|
|
63
91
|
expected: object;
|
|
64
92
|
tags: string[];
|
|
65
93
|
error?: string;
|
|
94
|
+
/** Sanitized evaluator evidence; arbitrary evaluator details are never stored here. */
|
|
95
|
+
evidence?: CaseEvaluationEvidence;
|
|
66
96
|
/** Redaction information for this case */
|
|
67
97
|
redaction?: CaseRedactionInfo;
|
|
98
|
+
/** Ordered tool activity captured for an enabled tool loop. */
|
|
99
|
+
toolTrace?: import('../tools').ToolTraceEntry[];
|
|
100
|
+
/** Terminal status for an enabled tool loop. */
|
|
101
|
+
toolLoop?: import('../tools').ToolLoopSummary;
|
|
68
102
|
}
|
|
69
103
|
|
|
70
104
|
/**
|
|
@@ -91,7 +125,15 @@ export interface CostEstimateInfo {
|
|
|
91
125
|
*/
|
|
92
126
|
export interface RunMetrics {
|
|
93
127
|
success_rate: number;
|
|
128
|
+
/** Number of execution attempts, including retries. */
|
|
129
|
+
total_attempts?: number;
|
|
94
130
|
total_cases: number;
|
|
131
|
+
/** Case results with a valid evaluator outcome (passed or failed). */
|
|
132
|
+
valid_evaluations?: number;
|
|
133
|
+
/** Case results excluded from outcome rates (invalid or error). */
|
|
134
|
+
invalid_evaluations?: number;
|
|
135
|
+
/** Denominator used for success_rate; zero produces a success_rate of zero. */
|
|
136
|
+
outcome_rate_denominator?: number;
|
|
95
137
|
passed_cases: number;
|
|
96
138
|
failed_cases: number;
|
|
97
139
|
median_latency_ms: number;
|
|
@@ -240,6 +282,97 @@ export interface RunManifest {
|
|
|
240
282
|
redaction?: ManifestRedactionInfo;
|
|
241
283
|
}
|
|
242
284
|
|
|
285
|
+
/**
|
|
286
|
+
* Read a case status from both v1.1 artifacts and historical v1.0 artifacts.
|
|
287
|
+
* Historical records cannot distinguish evaluator failures from ordinary failed
|
|
288
|
+
* criteria unless they set the legacy `error` field.
|
|
289
|
+
*/
|
|
290
|
+
export function getCaseEvaluationStatus(caseResult: CaseResult): CaseEvaluationStatus {
|
|
291
|
+
if (
|
|
292
|
+
caseResult.status === 'passed' ||
|
|
293
|
+
caseResult.status === 'failed' ||
|
|
294
|
+
caseResult.status === 'invalid' ||
|
|
295
|
+
caseResult.status === 'error'
|
|
296
|
+
) {
|
|
297
|
+
return caseResult.status;
|
|
298
|
+
}
|
|
299
|
+
if (caseResult.ok) return 'passed';
|
|
300
|
+
return caseResult.error ? 'error' : 'failed';
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Reject untrusted integrity-bearing fields before a run manifest is persisted
|
|
305
|
+
* or treated as a standard run. Historical manifests remain supported because
|
|
306
|
+
* status and evidence are optional in the v1.0 contract.
|
|
307
|
+
*/
|
|
308
|
+
export function assertRunManifestIntegrity(manifest: unknown): asserts manifest is RunManifest {
|
|
309
|
+
if (!isRecord(manifest) || !Array.isArray(manifest.cases)) {
|
|
310
|
+
throw new Error('Invalid run manifest: expected an object with a cases array');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
for (const [index, caseResult] of manifest.cases.entries()) {
|
|
314
|
+
if (!isRecord(caseResult)) {
|
|
315
|
+
throw new Error(`Invalid run manifest: case ${index} is not an object`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (
|
|
319
|
+
caseResult.status !== undefined &&
|
|
320
|
+
caseResult.status !== 'passed' &&
|
|
321
|
+
caseResult.status !== 'failed' &&
|
|
322
|
+
caseResult.status !== 'invalid' &&
|
|
323
|
+
caseResult.status !== 'error'
|
|
324
|
+
) {
|
|
325
|
+
throw new Error(`Invalid run manifest: case ${index} has an unknown status`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (caseResult.evidence !== undefined) {
|
|
329
|
+
assertCaseEvaluationEvidence(caseResult.evidence, index);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function assertCaseEvaluationEvidence(evidence: unknown, caseIndex: number): void {
|
|
335
|
+
if (
|
|
336
|
+
!isRecord(evidence) ||
|
|
337
|
+
typeof evidence.evaluator !== 'string' ||
|
|
338
|
+
evidence.evaluator.length > 100
|
|
339
|
+
) {
|
|
340
|
+
throw new Error(`Invalid run manifest: case ${caseIndex} has malformed evaluator evidence`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (evidence.score !== undefined && !isUnitIntervalNumber(evidence.score)) {
|
|
344
|
+
throw new Error(`Invalid run manifest: case ${caseIndex} has an invalid evidence score`);
|
|
345
|
+
}
|
|
346
|
+
if (evidence.threshold !== undefined && !isUnitIntervalNumber(evidence.threshold)) {
|
|
347
|
+
throw new Error(`Invalid run manifest: case ${caseIndex} has an invalid evidence threshold`);
|
|
348
|
+
}
|
|
349
|
+
if (
|
|
350
|
+
evidence.model !== undefined &&
|
|
351
|
+
(typeof evidence.model !== 'string' || evidence.model.length > 200)
|
|
352
|
+
) {
|
|
353
|
+
throw new Error(`Invalid run manifest: case ${caseIndex} has an invalid evidence model`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (evidence.validation !== undefined) {
|
|
357
|
+
if (
|
|
358
|
+
!isRecord(evidence.validation) ||
|
|
359
|
+
(evidence.validation.status !== 'valid' && evidence.validation.status !== 'invalid') ||
|
|
360
|
+
(evidence.validation.code !== undefined &&
|
|
361
|
+
(typeof evidence.validation.code !== 'string' || evidence.validation.code.length > 100))
|
|
362
|
+
) {
|
|
363
|
+
throw new Error(`Invalid run manifest: case ${caseIndex} has invalid evidence validation`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function isUnitIntervalNumber(value: unknown): value is number {
|
|
369
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
373
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
374
|
+
}
|
|
375
|
+
|
|
243
376
|
// ============================================================================
|
|
244
377
|
// Red Team Manifest Types
|
|
245
378
|
// ============================================================================
|
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
|
}
|