@artemiskit/core 0.1.5 → 0.2.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 +90 -0
- package/README.md +1 -0
- package/dist/adapters/types.d.ts +3 -1
- package/dist/adapters/types.d.ts.map +1 -1
- package/dist/artifacts/types.d.ts +39 -0
- package/dist/artifacts/types.d.ts.map +1 -1
- package/dist/cost/index.d.ts +5 -0
- package/dist/cost/index.d.ts.map +1 -0
- package/dist/cost/pricing.d.ts +66 -0
- package/dist/cost/pricing.d.ts.map +1 -0
- package/dist/evaluators/combined.d.ts +10 -0
- package/dist/evaluators/combined.d.ts.map +1 -0
- package/dist/evaluators/index.d.ts +4 -0
- package/dist/evaluators/index.d.ts.map +1 -1
- package/dist/evaluators/inline.d.ts +22 -0
- package/dist/evaluators/inline.d.ts.map +1 -0
- package/dist/evaluators/not-contains.d.ts +10 -0
- package/dist/evaluators/not-contains.d.ts.map +1 -0
- package/dist/evaluators/similarity.d.ts +16 -0
- package/dist/evaluators/similarity.d.ts.map +1 -0
- package/dist/events/emitter.d.ts +111 -0
- package/dist/events/emitter.d.ts.map +1 -0
- package/dist/events/index.d.ts +6 -0
- package/dist/events/index.d.ts.map +1 -0
- package/dist/events/types.d.ts +177 -0
- package/dist/events/types.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16904 -18362
- package/dist/scenario/discovery.d.ts +72 -0
- package/dist/scenario/discovery.d.ts.map +1 -0
- package/dist/scenario/index.d.ts +1 -0
- package/dist/scenario/index.d.ts.map +1 -1
- package/dist/scenario/schema.d.ts +1245 -9
- package/dist/scenario/schema.d.ts.map +1 -1
- package/dist/utils/logger.d.ts.map +1 -1
- package/package.json +5 -6
- package/src/adapters/types.ts +3 -1
- package/src/artifacts/types.ts +39 -0
- package/src/cost/index.ts +14 -0
- package/src/cost/pricing.ts +273 -0
- package/src/evaluators/combined.test.ts +172 -0
- package/src/evaluators/combined.ts +95 -0
- package/src/evaluators/index.ts +12 -0
- package/src/evaluators/inline.test.ts +409 -0
- package/src/evaluators/inline.ts +393 -0
- package/src/evaluators/not-contains.test.ts +105 -0
- package/src/evaluators/not-contains.ts +45 -0
- package/src/evaluators/similarity.test.ts +333 -0
- package/src/evaluators/similarity.ts +258 -0
- package/src/index.ts +3 -0
- package/src/scenario/discovery.test.ts +153 -0
- package/src/scenario/discovery.ts +277 -0
- package/src/scenario/index.ts +1 -0
- package/src/scenario/schema.ts +43 -2
- package/src/utils/logger.ts +45 -16
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for scenario discovery
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
6
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { discoverScenarios, matchScenarioGlob, resolveScenarioPaths } from './discovery';
|
|
10
|
+
|
|
11
|
+
describe('Scenario Discovery', () => {
|
|
12
|
+
const testDir = join(tmpdir(), `artemis-discovery-test-${Date.now()}`);
|
|
13
|
+
|
|
14
|
+
beforeAll(async () => {
|
|
15
|
+
// Create test directory structure
|
|
16
|
+
await mkdir(testDir, { recursive: true });
|
|
17
|
+
await mkdir(join(testDir, 'scenarios'), { recursive: true });
|
|
18
|
+
await mkdir(join(testDir, 'scenarios', 'auth'), { recursive: true });
|
|
19
|
+
await mkdir(join(testDir, 'scenarios', 'api'), { recursive: true });
|
|
20
|
+
await mkdir(join(testDir, 'node_modules'), { recursive: true });
|
|
21
|
+
await mkdir(join(testDir, 'drafts'), { recursive: true });
|
|
22
|
+
|
|
23
|
+
// Create test scenario files
|
|
24
|
+
const scenarioContent =
|
|
25
|
+
'name: test\ncases:\n - id: t1\n prompt: test\n expected:\n type: exact\n value: test';
|
|
26
|
+
|
|
27
|
+
await writeFile(join(testDir, 'scenarios', 'basic.yaml'), scenarioContent);
|
|
28
|
+
await writeFile(join(testDir, 'scenarios', 'advanced.yml'), scenarioContent);
|
|
29
|
+
await writeFile(join(testDir, 'scenarios', 'auth', 'login.yaml'), scenarioContent);
|
|
30
|
+
await writeFile(join(testDir, 'scenarios', 'auth', 'logout.yaml'), scenarioContent);
|
|
31
|
+
await writeFile(join(testDir, 'scenarios', 'api', 'users.yaml'), scenarioContent);
|
|
32
|
+
await writeFile(join(testDir, 'scenarios', 'api', 'posts.yml'), scenarioContent);
|
|
33
|
+
await writeFile(join(testDir, 'node_modules', 'ignored.yaml'), scenarioContent);
|
|
34
|
+
await writeFile(join(testDir, 'drafts', 'draft.yaml'), scenarioContent);
|
|
35
|
+
await writeFile(join(testDir, 'scenarios', 'skip.draft.yaml'), scenarioContent);
|
|
36
|
+
await writeFile(join(testDir, 'scenarios', 'readme.md'), '# Not a scenario');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(async () => {
|
|
40
|
+
// Cleanup test directory
|
|
41
|
+
await rm(testDir, { recursive: true, force: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('discoverScenarios', () => {
|
|
45
|
+
test('finds all yaml and yml files in directory', async () => {
|
|
46
|
+
const files = await discoverScenarios(join(testDir, 'scenarios'));
|
|
47
|
+
expect(files.length).toBe(7); // 2 root + 2 auth + 2 api + 1 draft
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('excludes node_modules by default', async () => {
|
|
51
|
+
const files = await discoverScenarios(testDir);
|
|
52
|
+
const nodeModulesFiles = files.filter((f) => f.includes('node_modules'));
|
|
53
|
+
expect(nodeModulesFiles.length).toBe(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('respects custom exclude patterns', async () => {
|
|
57
|
+
const files = await discoverScenarios(join(testDir, 'scenarios'), {
|
|
58
|
+
exclude: ['*.draft.yaml'],
|
|
59
|
+
});
|
|
60
|
+
const draftFiles = files.filter((f) => f.includes('.draft.yaml'));
|
|
61
|
+
expect(draftFiles.length).toBe(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('respects custom extensions', async () => {
|
|
65
|
+
const files = await discoverScenarios(join(testDir, 'scenarios'), {
|
|
66
|
+
extensions: ['.yaml'],
|
|
67
|
+
});
|
|
68
|
+
const ymlFiles = files.filter((f) => f.endsWith('.yml'));
|
|
69
|
+
expect(ymlFiles.length).toBe(0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('respects maxDepth option', async () => {
|
|
73
|
+
const files = await discoverScenarios(join(testDir, 'scenarios'), {
|
|
74
|
+
maxDepth: 0,
|
|
75
|
+
});
|
|
76
|
+
// Should only find files in the root scenarios directory
|
|
77
|
+
expect(files.length).toBe(3); // basic.yaml, advanced.yml, skip.draft.yaml
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('throws error for non-directory path', async () => {
|
|
81
|
+
await expect(discoverScenarios(join(testDir, 'scenarios', 'basic.yaml'))).rejects.toThrow(
|
|
82
|
+
'not a directory'
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('returns sorted results', async () => {
|
|
87
|
+
const files = await discoverScenarios(join(testDir, 'scenarios'));
|
|
88
|
+
const sorted = [...files].sort();
|
|
89
|
+
expect(files).toEqual(sorted);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('matchScenarioGlob', () => {
|
|
94
|
+
test('matches simple wildcard pattern', async () => {
|
|
95
|
+
const files = await matchScenarioGlob('scenarios/*.yaml', testDir);
|
|
96
|
+
expect(files.length).toBe(2); // basic.yaml, skip.draft.yaml
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('matches recursive pattern with **', async () => {
|
|
100
|
+
const files = await matchScenarioGlob('scenarios/**/*.yaml', testDir);
|
|
101
|
+
expect(files.length).toBeGreaterThan(2);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('matches specific prefix pattern', async () => {
|
|
105
|
+
const files = await matchScenarioGlob('scenarios/auth/*.yaml', testDir);
|
|
106
|
+
expect(files.length).toBe(2); // login.yaml, logout.yaml
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('returns single file for non-glob file path', async () => {
|
|
110
|
+
const files = await matchScenarioGlob('scenarios/basic.yaml', testDir);
|
|
111
|
+
expect(files.length).toBe(1);
|
|
112
|
+
expect(files[0]).toContain('basic.yaml');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('returns all files for non-glob directory path', async () => {
|
|
116
|
+
const files = await matchScenarioGlob('scenarios/auth', testDir);
|
|
117
|
+
expect(files.length).toBe(2);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('returns empty array for non-existent path', async () => {
|
|
121
|
+
const files = await matchScenarioGlob('nonexistent/*.yaml', testDir);
|
|
122
|
+
expect(files.length).toBe(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('matches single character with ?', async () => {
|
|
126
|
+
const files = await matchScenarioGlob('scenarios/api/?????.yaml', testDir);
|
|
127
|
+
expect(files.length).toBe(1); // users.yaml (5 chars)
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('resolveScenarioPaths', () => {
|
|
132
|
+
test('resolves single file', async () => {
|
|
133
|
+
const files = await resolveScenarioPaths(join(testDir, 'scenarios', 'basic.yaml'));
|
|
134
|
+
expect(files.length).toBe(1);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('resolves directory', async () => {
|
|
138
|
+
const files = await resolveScenarioPaths(join(testDir, 'scenarios', 'auth'));
|
|
139
|
+
expect(files.length).toBe(2);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('resolves glob pattern', async () => {
|
|
143
|
+
const files = await resolveScenarioPaths('scenarios/*.yaml', testDir);
|
|
144
|
+
expect(files.length).toBe(2);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('throws for non-existent path', async () => {
|
|
148
|
+
await expect(resolveScenarioPaths(join(testDir, 'nonexistent.yaml'))).rejects.toThrow(
|
|
149
|
+
'not found'
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scenario discovery - find scenario files by directory scanning and glob patterns
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
6
|
+
import { join, resolve } from 'node:path';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Options for scenario discovery
|
|
10
|
+
*/
|
|
11
|
+
export interface DiscoveryOptions {
|
|
12
|
+
/** File extensions to include (default: ['.yaml', '.yml']) */
|
|
13
|
+
extensions?: string[];
|
|
14
|
+
/** Maximum directory depth to scan (default: 10) */
|
|
15
|
+
maxDepth?: number;
|
|
16
|
+
/** Patterns to exclude (glob-like, e.g., 'node_modules', '*.draft.yaml') */
|
|
17
|
+
exclude?: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_EXTENSIONS = ['.yaml', '.yml'];
|
|
21
|
+
const DEFAULT_MAX_DEPTH = 10;
|
|
22
|
+
const DEFAULT_EXCLUDE = ['node_modules', '.git', 'dist', 'build', 'coverage'];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check if a path matches any of the exclude patterns
|
|
26
|
+
*/
|
|
27
|
+
function matchesExcludePattern(name: string, patterns: string[]): boolean {
|
|
28
|
+
for (const pattern of patterns) {
|
|
29
|
+
// Simple wildcard matching
|
|
30
|
+
if (pattern.includes('*')) {
|
|
31
|
+
const regex = new RegExp(`^${pattern.replace(/\./g, '\\.').replace(/\*/g, '.*')}$`);
|
|
32
|
+
if (regex.test(name)) return true;
|
|
33
|
+
} else {
|
|
34
|
+
// Exact match
|
|
35
|
+
if (name === pattern) return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Check if a filename has a valid scenario extension
|
|
43
|
+
*/
|
|
44
|
+
function hasValidExtension(filename: string, extensions: string[]): boolean {
|
|
45
|
+
return extensions.some((ext) => filename.endsWith(ext));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Recursively scan a directory for scenario files
|
|
50
|
+
*/
|
|
51
|
+
async function scanDirectoryRecursive(
|
|
52
|
+
dirPath: string,
|
|
53
|
+
options: Required<DiscoveryOptions>,
|
|
54
|
+
currentDepth: number
|
|
55
|
+
): Promise<string[]> {
|
|
56
|
+
if (currentDepth > options.maxDepth) {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const results: string[] = [];
|
|
61
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
62
|
+
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
const fullPath = join(dirPath, entry.name);
|
|
65
|
+
|
|
66
|
+
// Skip excluded patterns
|
|
67
|
+
if (matchesExcludePattern(entry.name, options.exclude)) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (entry.isDirectory()) {
|
|
72
|
+
// Recursively scan subdirectories
|
|
73
|
+
const subResults = await scanDirectoryRecursive(fullPath, options, currentDepth + 1);
|
|
74
|
+
results.push(...subResults);
|
|
75
|
+
} else if (entry.isFile() && hasValidExtension(entry.name, options.extensions)) {
|
|
76
|
+
results.push(fullPath);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return results;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Discover scenario files in a directory
|
|
85
|
+
*
|
|
86
|
+
* @param dirPath - Path to the directory to scan
|
|
87
|
+
* @param options - Discovery options
|
|
88
|
+
* @returns Array of absolute paths to scenario files
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ```ts
|
|
92
|
+
* // Scan a directory for all .yaml and .yml files
|
|
93
|
+
* const files = await discoverScenarios('./scenarios');
|
|
94
|
+
*
|
|
95
|
+
* // Scan with custom options
|
|
96
|
+
* const files = await discoverScenarios('./tests', {
|
|
97
|
+
* extensions: ['.yaml'],
|
|
98
|
+
* maxDepth: 3,
|
|
99
|
+
* exclude: ['drafts', '*.skip.yaml']
|
|
100
|
+
* });
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
export async function discoverScenarios(
|
|
104
|
+
dirPath: string,
|
|
105
|
+
options: DiscoveryOptions = {}
|
|
106
|
+
): Promise<string[]> {
|
|
107
|
+
const resolvedPath = resolve(dirPath);
|
|
108
|
+
const pathStat = await stat(resolvedPath);
|
|
109
|
+
|
|
110
|
+
if (!pathStat.isDirectory()) {
|
|
111
|
+
throw new Error(`Path is not a directory: ${dirPath}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const fullOptions: Required<DiscoveryOptions> = {
|
|
115
|
+
extensions: options.extensions ?? DEFAULT_EXTENSIONS,
|
|
116
|
+
maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH,
|
|
117
|
+
exclude: [...DEFAULT_EXCLUDE, ...(options.exclude ?? [])],
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const files = await scanDirectoryRecursive(resolvedPath, fullOptions, 0);
|
|
121
|
+
|
|
122
|
+
// Sort for consistent ordering
|
|
123
|
+
return files.sort();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Match scenario files using glob-like patterns
|
|
128
|
+
*
|
|
129
|
+
* Supports basic glob patterns:
|
|
130
|
+
* - `*` matches any characters except path separator
|
|
131
|
+
* - `**` matches any characters including path separator (recursive)
|
|
132
|
+
* - `?` matches single character
|
|
133
|
+
*
|
|
134
|
+
* @param pattern - Glob pattern to match
|
|
135
|
+
* @param basePath - Base path to resolve relative patterns (default: cwd)
|
|
136
|
+
* @returns Array of absolute paths to matching scenario files
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* ```ts
|
|
140
|
+
* // Match all yaml files in scenarios directory
|
|
141
|
+
* const files = await matchScenarioGlob('scenarios/*.yaml');
|
|
142
|
+
*
|
|
143
|
+
* // Match recursively
|
|
144
|
+
* const files = await matchScenarioGlob('tests/**\/*.yaml');
|
|
145
|
+
*
|
|
146
|
+
* // Match specific patterns
|
|
147
|
+
* const files = await matchScenarioGlob('scenarios/auth-*.yaml');
|
|
148
|
+
* ```
|
|
149
|
+
*/
|
|
150
|
+
export async function matchScenarioGlob(
|
|
151
|
+
pattern: string,
|
|
152
|
+
basePath: string = process.cwd()
|
|
153
|
+
): Promise<string[]> {
|
|
154
|
+
const resolvedBase = resolve(basePath);
|
|
155
|
+
|
|
156
|
+
// Check if the pattern contains glob characters
|
|
157
|
+
const hasGlob = /[*?]/.test(pattern);
|
|
158
|
+
|
|
159
|
+
if (!hasGlob) {
|
|
160
|
+
// Not a glob pattern - check if it's a file or directory
|
|
161
|
+
const fullPath = resolve(resolvedBase, pattern);
|
|
162
|
+
try {
|
|
163
|
+
const pathStat = await stat(fullPath);
|
|
164
|
+
if (pathStat.isFile()) {
|
|
165
|
+
return [fullPath];
|
|
166
|
+
}
|
|
167
|
+
if (pathStat.isDirectory()) {
|
|
168
|
+
return discoverScenarios(fullPath);
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
// Path doesn't exist
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Convert glob pattern to regex
|
|
178
|
+
const globToRegex = (glob: string): RegExp => {
|
|
179
|
+
const regexStr = glob
|
|
180
|
+
// Escape special regex characters except * and ?
|
|
181
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
182
|
+
// Convert ** to match any path
|
|
183
|
+
.replace(/\*\*/g, '{{GLOBSTAR}}')
|
|
184
|
+
// Convert * to match any characters except /
|
|
185
|
+
.replace(/\*/g, '[^/]*')
|
|
186
|
+
// Convert ? to match single character
|
|
187
|
+
.replace(/\?/g, '.')
|
|
188
|
+
// Restore ** as match-all including /
|
|
189
|
+
.replace(/\{\{GLOBSTAR\}\}/g, '.*');
|
|
190
|
+
|
|
191
|
+
return new RegExp(`^${regexStr}$`);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// Check if pattern is an absolute path
|
|
195
|
+
const isAbsolute = pattern.startsWith('/');
|
|
196
|
+
|
|
197
|
+
// Extract the base directory (non-glob prefix)
|
|
198
|
+
const patternParts = pattern.split('/');
|
|
199
|
+
let baseDir = isAbsolute ? '/' : resolvedBase;
|
|
200
|
+
let globPart = pattern;
|
|
201
|
+
|
|
202
|
+
// Start from index 1 if absolute path (skip empty string from leading /)
|
|
203
|
+
const startIndex = isAbsolute ? 1 : 0;
|
|
204
|
+
|
|
205
|
+
for (let i = startIndex; i < patternParts.length; i++) {
|
|
206
|
+
const part = patternParts[i];
|
|
207
|
+
if (/[*?]/.test(part)) {
|
|
208
|
+
// Found first glob character - everything before is base
|
|
209
|
+
globPart = patternParts.slice(i).join('/');
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
baseDir = join(baseDir, part);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Check if base directory exists
|
|
216
|
+
try {
|
|
217
|
+
const baseStat = await stat(baseDir);
|
|
218
|
+
if (!baseStat.isDirectory()) {
|
|
219
|
+
return [];
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
return [];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Scan from base directory and filter by pattern
|
|
226
|
+
const allFiles = await discoverScenarios(baseDir, { maxDepth: 20 });
|
|
227
|
+
const regex = globToRegex(globPart);
|
|
228
|
+
|
|
229
|
+
const matchedFiles = allFiles.filter((filePath) => {
|
|
230
|
+
// Get relative path from base directory
|
|
231
|
+
const relativePath = filePath.slice(baseDir.length + 1);
|
|
232
|
+
return regex.test(relativePath);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
return matchedFiles.sort();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Resolve a scenario path argument which can be:
|
|
240
|
+
* - A single file path
|
|
241
|
+
* - A directory path
|
|
242
|
+
* - A glob pattern
|
|
243
|
+
*
|
|
244
|
+
* @param pathArg - Path argument from CLI
|
|
245
|
+
* @param basePath - Base path for relative resolution
|
|
246
|
+
* @returns Array of resolved scenario file paths
|
|
247
|
+
*/
|
|
248
|
+
export async function resolveScenarioPaths(
|
|
249
|
+
pathArg: string,
|
|
250
|
+
basePath: string = process.cwd()
|
|
251
|
+
): Promise<string[]> {
|
|
252
|
+
const resolvedBase = resolve(basePath);
|
|
253
|
+
const hasGlob = /[*?]/.test(pathArg);
|
|
254
|
+
|
|
255
|
+
if (hasGlob) {
|
|
256
|
+
return matchScenarioGlob(pathArg, resolvedBase);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const fullPath = resolve(resolvedBase, pathArg);
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
const pathStat = await stat(fullPath);
|
|
263
|
+
|
|
264
|
+
if (pathStat.isFile()) {
|
|
265
|
+
return [fullPath];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (pathStat.isDirectory()) {
|
|
269
|
+
return discoverScenarios(fullPath);
|
|
270
|
+
}
|
|
271
|
+
} catch {
|
|
272
|
+
// Path doesn't exist
|
|
273
|
+
throw new Error(`Scenario path not found: ${pathArg}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return [];
|
|
277
|
+
}
|
package/src/scenario/index.ts
CHANGED
package/src/scenario/schema.ts
CHANGED
|
@@ -42,6 +42,7 @@ export const ProviderConfigSchema = z
|
|
|
42
42
|
resourceName: z.string().optional(),
|
|
43
43
|
deploymentName: z.string().optional(),
|
|
44
44
|
apiVersion: z.string().optional(),
|
|
45
|
+
embeddingDeploymentName: z.string().optional(),
|
|
45
46
|
|
|
46
47
|
// Vercel AI specific
|
|
47
48
|
underlyingProvider: z.enum(['openai', 'azure', 'anthropic', 'google', 'mistral']).optional(),
|
|
@@ -49,9 +50,9 @@ export const ProviderConfigSchema = z
|
|
|
49
50
|
.optional();
|
|
50
51
|
|
|
51
52
|
/**
|
|
52
|
-
*
|
|
53
|
+
* Base expected types (non-recursive)
|
|
53
54
|
*/
|
|
54
|
-
|
|
55
|
+
const BaseExpectedSchema = z.discriminatedUnion('type', [
|
|
55
56
|
z.object({
|
|
56
57
|
type: z.literal('exact'),
|
|
57
58
|
value: z.string(),
|
|
@@ -84,6 +85,12 @@ export const ExpectedSchema = z.discriminatedUnion('type', [
|
|
|
84
85
|
mode: z.enum(['all', 'any']).default('all'),
|
|
85
86
|
}),
|
|
86
87
|
|
|
88
|
+
z.object({
|
|
89
|
+
type: z.literal('not_contains'),
|
|
90
|
+
values: z.array(z.string()),
|
|
91
|
+
mode: z.enum(['all', 'any']).default('all'),
|
|
92
|
+
}),
|
|
93
|
+
|
|
87
94
|
z.object({
|
|
88
95
|
type: z.literal('json_schema'),
|
|
89
96
|
schema: z.record(z.unknown()),
|
|
@@ -94,8 +101,42 @@ export const ExpectedSchema = z.discriminatedUnion('type', [
|
|
|
94
101
|
evaluator: z.string(),
|
|
95
102
|
config: z.record(z.unknown()).optional(),
|
|
96
103
|
}),
|
|
104
|
+
|
|
105
|
+
z.object({
|
|
106
|
+
type: z.literal('similarity'),
|
|
107
|
+
value: z.string(),
|
|
108
|
+
threshold: z.number().min(0).max(1).default(0.75),
|
|
109
|
+
/** Mode for similarity evaluation: 'embedding' uses vector embeddings, 'llm' uses LLM-based comparison */
|
|
110
|
+
mode: z.enum(['embedding', 'llm']).optional(),
|
|
111
|
+
/** Model for LLM-based similarity comparison (required when mode is 'llm') */
|
|
112
|
+
model: z.string().optional(),
|
|
113
|
+
/** Embedding model to use for vector similarity (required when mode is 'embedding') */
|
|
114
|
+
embeddingModel: z.string().optional(),
|
|
115
|
+
}),
|
|
116
|
+
|
|
117
|
+
z.object({
|
|
118
|
+
type: z.literal('inline'),
|
|
119
|
+
expression: z.string(),
|
|
120
|
+
value: z.string().optional(),
|
|
121
|
+
}),
|
|
97
122
|
]);
|
|
98
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Combined expectation schema - allows combining multiple expectations with and/or logic
|
|
126
|
+
* Note: Combined expectations can only contain base expectations (no nested combined)
|
|
127
|
+
*/
|
|
128
|
+
const CombinedExpectedSchema = z.object({
|
|
129
|
+
type: z.literal('combined'),
|
|
130
|
+
operator: z.enum(['and', 'or']),
|
|
131
|
+
expectations: z.array(BaseExpectedSchema).min(1),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Expected result types - how to evaluate responses
|
|
136
|
+
* Includes base types and combined type for logical grouping
|
|
137
|
+
*/
|
|
138
|
+
export const ExpectedSchema = z.union([BaseExpectedSchema, CombinedExpectedSchema]);
|
|
139
|
+
|
|
99
140
|
/**
|
|
100
141
|
* Chat message schema
|
|
101
142
|
*/
|
package/src/utils/logger.ts
CHANGED
|
@@ -2,53 +2,82 @@
|
|
|
2
2
|
* Logger utility for Artemis
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import
|
|
5
|
+
import { type ConsolaInstance, LogLevels, createConsola } from 'consola';
|
|
6
6
|
|
|
7
7
|
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
8
8
|
|
|
9
|
-
const
|
|
9
|
+
const LOG_LEVEL_MAP: Record<LogLevel, number> = {
|
|
10
|
+
debug: LogLevels.debug,
|
|
11
|
+
info: LogLevels.info,
|
|
12
|
+
warn: LogLevels.warn,
|
|
13
|
+
error: LogLevels.error,
|
|
14
|
+
};
|
|
10
15
|
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
const level = (process.env.ARTEMIS_LOG_LEVEL as LogLevel) || 'info';
|
|
17
|
+
|
|
18
|
+
const baseLogger = createConsola({
|
|
19
|
+
level: LOG_LEVEL_MAP[level] ?? LogLevels.info,
|
|
20
|
+
formatOptions: {
|
|
21
|
+
colors: true,
|
|
22
|
+
date: true,
|
|
23
|
+
},
|
|
17
24
|
});
|
|
18
25
|
|
|
19
26
|
/**
|
|
20
27
|
* Logger class for consistent logging across Artemis
|
|
21
28
|
*/
|
|
22
29
|
export class Logger {
|
|
23
|
-
private logger:
|
|
30
|
+
private logger: ConsolaInstance;
|
|
24
31
|
|
|
25
32
|
constructor(name: string) {
|
|
26
|
-
this.logger = baseLogger.
|
|
33
|
+
this.logger = baseLogger.withTag(name);
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
debug(message: string, data?: Record<string, unknown>): void {
|
|
30
|
-
|
|
37
|
+
if (data) {
|
|
38
|
+
this.logger.debug(message, data);
|
|
39
|
+
} else {
|
|
40
|
+
this.logger.debug(message);
|
|
41
|
+
}
|
|
31
42
|
}
|
|
32
43
|
|
|
33
44
|
info(message: string, data?: Record<string, unknown>): void {
|
|
34
|
-
|
|
45
|
+
if (data) {
|
|
46
|
+
this.logger.info(message, data);
|
|
47
|
+
} else {
|
|
48
|
+
this.logger.info(message);
|
|
49
|
+
}
|
|
35
50
|
}
|
|
36
51
|
|
|
37
52
|
warn(message: string, data?: Record<string, unknown>): void {
|
|
38
|
-
|
|
53
|
+
if (data) {
|
|
54
|
+
this.logger.warn(message, data);
|
|
55
|
+
} else {
|
|
56
|
+
this.logger.warn(message);
|
|
57
|
+
}
|
|
39
58
|
}
|
|
40
59
|
|
|
41
60
|
error(message: string, error?: Error | unknown, data?: Record<string, unknown>): void {
|
|
42
61
|
const errorData =
|
|
43
62
|
error instanceof Error
|
|
44
63
|
? { error: { message: error.message, stack: error.stack, name: error.name } }
|
|
45
|
-
:
|
|
46
|
-
|
|
64
|
+
: error
|
|
65
|
+
? { error }
|
|
66
|
+
: undefined;
|
|
67
|
+
|
|
68
|
+
const mergedData = errorData || data ? { ...data, ...errorData } : undefined;
|
|
69
|
+
|
|
70
|
+
if (mergedData) {
|
|
71
|
+
this.logger.error(message, mergedData);
|
|
72
|
+
} else {
|
|
73
|
+
this.logger.error(message);
|
|
74
|
+
}
|
|
47
75
|
}
|
|
48
76
|
|
|
49
77
|
child(bindings: Record<string, unknown>): Logger {
|
|
50
78
|
const childLogger = new Logger('');
|
|
51
|
-
|
|
79
|
+
const tag = bindings.name ? String(bindings.name) : '';
|
|
80
|
+
childLogger.logger = this.logger.withTag(tag);
|
|
52
81
|
return childLogger;
|
|
53
82
|
}
|
|
54
83
|
}
|