elasticdash-test 0.1.20 → 0.1.21-alpha-2
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/README.md +22 -7
- package/dist/ci/api-client.d.ts +1 -1
- package/dist/ci/api-client.d.ts.map +1 -1
- package/dist/ci/api-client.js +19 -4
- package/dist/ci/api-client.js.map +1 -1
- package/dist/ci/benchmark.d.ts.map +1 -1
- package/dist/ci/benchmark.js +38 -2
- package/dist/ci/benchmark.js.map +1 -1
- package/dist/ci/executor.d.ts.map +1 -1
- package/dist/ci/executor.js +6 -3
- package/dist/ci/executor.js.map +1 -1
- package/dist/core/judge-utils.d.ts +22 -0
- package/dist/core/judge-utils.d.ts.map +1 -0
- package/dist/core/judge-utils.js +209 -0
- package/dist/core/judge-utils.js.map +1 -0
- package/dist/index.cjs +293 -13
- package/dist/matchers/index.d.ts.map +1 -1
- package/dist/matchers/index.js +6 -3
- package/dist/matchers/index.js.map +1 -1
- package/package.json +1 -1
- package/src/ci/api-client.ts +21 -5
- package/src/ci/benchmark.ts +40 -2
- package/src/ci/executor.ts +6 -3
- package/src/core/judge-utils.ts +232 -0
- package/src/matchers/index.ts +6 -3
package/src/ci/benchmark.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { callProviderLLM } from '../matchers/index.js'
|
|
11
|
+
import { prepareOutputForJudge } from '../core/judge-utils.js'
|
|
11
12
|
import type { TestMeasurement } from './measurement.js'
|
|
12
13
|
import type { TestBenchmarks } from './test-registry.js'
|
|
13
14
|
import type { EvaluatorConfig } from './api-client.js'
|
|
@@ -34,6 +35,32 @@ const PROVIDER_NAME_MAP: Record<string, string> = {
|
|
|
34
35
|
moonshot: 'kimi',
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/** Default model for each provider, used when no explicit model is set or
|
|
39
|
+
* when the evaluator config model doesn't belong to the resolved provider. */
|
|
40
|
+
const DEFAULT_PROVIDER_MODELS: Record<string, string> = {
|
|
41
|
+
openai: 'gpt-4o',
|
|
42
|
+
claude: 'claude-sonnet-4-20250514',
|
|
43
|
+
gemini: 'gemini-2.0-flash',
|
|
44
|
+
grok: 'grok-3',
|
|
45
|
+
kimi: 'moonshot-v1-auto',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Known model prefixes per provider — used to check if a model belongs to a provider. */
|
|
49
|
+
const PROVIDER_MODEL_PREFIXES: Record<string, string[]> = {
|
|
50
|
+
openai: ['gpt-', 'o1-', 'o3-', 'o4-', 'chatgpt-'],
|
|
51
|
+
claude: ['claude-'],
|
|
52
|
+
gemini: ['gemini-'],
|
|
53
|
+
grok: ['grok-'],
|
|
54
|
+
kimi: ['moonshot-'],
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Check if a model name belongs to the given provider. */
|
|
58
|
+
function isModelForProvider(model: string, provider: string): boolean {
|
|
59
|
+
const prefixes = PROVIDER_MODEL_PREFIXES[provider]
|
|
60
|
+
if (!prefixes) return false
|
|
61
|
+
return prefixes.some(p => model.toLowerCase().startsWith(p))
|
|
62
|
+
}
|
|
63
|
+
|
|
37
64
|
/** Normalize provider name from backend format to SDK format. */
|
|
38
65
|
function normalizeSdkProvider(provider: string): string {
|
|
39
66
|
return PROVIDER_NAME_MAP[provider] ?? provider
|
|
@@ -125,7 +152,17 @@ export async function compareBenchmarks(
|
|
|
125
152
|
const resolvedProvider = normalizeSdkProvider(
|
|
126
153
|
judge.judge_provider ?? evaluatorConfig?.provider ?? 'openai'
|
|
127
154
|
)
|
|
128
|
-
|
|
155
|
+
// Model resolution: judge_model > evaluatorConfig.model (if compatible) > provider default
|
|
156
|
+
let resolvedModel = judge.judge_model ?? undefined
|
|
157
|
+
if (!resolvedModel && evaluatorConfig?.model) {
|
|
158
|
+
// Only use the evaluator config model if it belongs to the resolved provider
|
|
159
|
+
if (isModelForProvider(evaluatorConfig.model, resolvedProvider)) {
|
|
160
|
+
resolvedModel = evaluatorConfig.model
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (!resolvedModel) {
|
|
164
|
+
resolvedModel = DEFAULT_PROVIDER_MODELS[resolvedProvider]
|
|
165
|
+
}
|
|
129
166
|
|
|
130
167
|
// If the backend provided an API key and we're using its provider,
|
|
131
168
|
// set it in the environment so callProviderLLM can pick it up.
|
|
@@ -149,7 +186,8 @@ export async function compareBenchmarks(
|
|
|
149
186
|
|
|
150
187
|
try {
|
|
151
188
|
console.log(` [llm_judge] provider=${resolvedProvider}, model=${resolvedModel ?? '(default)'}`)
|
|
152
|
-
const
|
|
189
|
+
const preparedOutput = prepareOutputForJudge(outputStr, judge.judge_prompt)
|
|
190
|
+
const evalPrompt = `${judge.judge_prompt}\n\n<output>\n${preparedOutput}\n</output>\n\nBased on the evaluation criteria above, score this output on a scale of 0-10. Respond with only the number.`
|
|
153
191
|
|
|
154
192
|
const result = await callProviderLLM(
|
|
155
193
|
evalPrompt,
|
package/src/ci/executor.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { executePortalTask, checkToolAvailability, checkAIAvailability } from '../portal-executor.js'
|
|
2
2
|
import { scanTools } from '../execution/tool-runner.js'
|
|
3
3
|
import { callProviderLLM } from '../matchers/index.js'
|
|
4
|
+
import { prepareOutputForJudge } from '../core/judge-utils.js'
|
|
4
5
|
import type { ToolInfo } from '../execution/tool-runner.js'
|
|
5
6
|
import type {
|
|
6
7
|
APITestGroupTest,
|
|
@@ -591,12 +592,14 @@ async function evaluateLLMJudge(exp: APIExpectation, runs: CISingleRunResult[]):
|
|
|
591
592
|
? run.output
|
|
592
593
|
: JSON.stringify(run.output ?? '')
|
|
593
594
|
|
|
595
|
+
const preparedOutput = prepareOutputForJudge(outputStr, exp.judge_prompt)
|
|
594
596
|
const evalPrompt = `${exp.judge_prompt}
|
|
595
597
|
|
|
596
|
-
|
|
597
|
-
${
|
|
598
|
+
<output>
|
|
599
|
+
${preparedOutput}
|
|
600
|
+
</output>
|
|
598
601
|
|
|
599
|
-
|
|
602
|
+
Based on the evaluation criteria above, score this output on a scale of 0-10. Respond with only the number.`
|
|
600
603
|
|
|
601
604
|
try {
|
|
602
605
|
const provider = (exp.judge_provider as 'openai' | 'claude' | 'gemini' | 'grok' | 'kimi') || 'openai'
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* judge-utils.ts
|
|
3
|
+
*
|
|
4
|
+
* Utilities for preprocessing outputs before sending to LLM-as-a-judge evaluators.
|
|
5
|
+
* Addresses two problems:
|
|
6
|
+
* 1. Large outputs cause slow inference
|
|
7
|
+
* 2. LLMs miss attributes in large JSON payloads due to attention degradation
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const DEFAULT_MAX_CHARS = 8000
|
|
11
|
+
const MAX_STRING_VALUE_LENGTH = 500
|
|
12
|
+
const MAX_ARRAY_EDGE_ITEMS = 5
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Prepare an output string for LLM judge evaluation.
|
|
16
|
+
*
|
|
17
|
+
* - Small outputs (< maxChars) pass through unchanged.
|
|
18
|
+
* - Large JSON outputs: extracts subtrees whose keys match keywords from the judge prompt.
|
|
19
|
+
* - Large non-JSON outputs: truncates with head + tail and a marker in between.
|
|
20
|
+
*
|
|
21
|
+
* @param output - The raw output string to prepare.
|
|
22
|
+
* @param judgePrompt - The judge/evaluation prompt, used to identify relevant JSON keys.
|
|
23
|
+
* @param maxChars - Maximum character budget for the prepared output. Default 8000.
|
|
24
|
+
* @returns The prepared output string, possibly trimmed.
|
|
25
|
+
*/
|
|
26
|
+
export function prepareOutputForJudge(
|
|
27
|
+
output: string,
|
|
28
|
+
judgePrompt: string,
|
|
29
|
+
maxChars: number = DEFAULT_MAX_CHARS,
|
|
30
|
+
): string {
|
|
31
|
+
if (!output) return output
|
|
32
|
+
|
|
33
|
+
// Always attempt JSON-aware processing (value truncation + key extraction).
|
|
34
|
+
// Even outputs under maxChars can contain arrays with 100+ items that
|
|
35
|
+
// drown out the fields the judge actually needs to evaluate.
|
|
36
|
+
const jsonResult = tryJsonExtract(output, judgePrompt, maxChars)
|
|
37
|
+
if (jsonResult !== null) return jsonResult
|
|
38
|
+
|
|
39
|
+
// Non-JSON: only truncate if over budget
|
|
40
|
+
if (output.length <= maxChars) return output
|
|
41
|
+
return truncateHeadTail(output, maxChars)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Attempt to parse the output as JSON and extract only the subtrees
|
|
46
|
+
* whose keys are relevant to the judge prompt.
|
|
47
|
+
* Returns null if the output is not valid JSON.
|
|
48
|
+
*/
|
|
49
|
+
function tryJsonExtract(output: string, judgePrompt: string, maxChars: number): string | null {
|
|
50
|
+
let parsed: unknown
|
|
51
|
+
try {
|
|
52
|
+
parsed = JSON.parse(output)
|
|
53
|
+
} catch {
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (typeof parsed !== 'object' || parsed === null) return null
|
|
58
|
+
|
|
59
|
+
const keywords = extractKeywords(judgePrompt)
|
|
60
|
+
if (keywords.length === 0) {
|
|
61
|
+
// No keywords to match — truncate large values then serialize
|
|
62
|
+
const trimmed = truncateJsonValues(parsed)
|
|
63
|
+
const trimmedStr = JSON.stringify(trimmed, null, 2)
|
|
64
|
+
if (trimmedStr.length <= maxChars) return trimmedStr
|
|
65
|
+
return truncateHeadTail(trimmedStr, maxChars)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const extracted = extractRelevantPaths(parsed, keywords)
|
|
69
|
+
|
|
70
|
+
// If extraction found relevant content, truncate large values then serialize
|
|
71
|
+
if (extracted !== undefined && Object.keys(extracted as Record<string, unknown>).length > 0) {
|
|
72
|
+
const trimmed = truncateJsonValues(extracted)
|
|
73
|
+
const extractedStr = JSON.stringify(trimmed, null, 2)
|
|
74
|
+
|
|
75
|
+
if (extractedStr.length <= maxChars) {
|
|
76
|
+
const omittedKeys = countOmittedKeys(parsed, extracted)
|
|
77
|
+
if (omittedKeys > 0) {
|
|
78
|
+
return `${extractedStr}\n\n[Note: ${omittedKeys} irrelevant key(s) omitted from original output for brevity. Total original size: ${output.length} chars.]`
|
|
79
|
+
}
|
|
80
|
+
return extractedStr
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Extracted content is still too large — truncate it
|
|
84
|
+
return truncateHeadTail(extractedStr, maxChars)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Extraction found nothing relevant — truncate values in full object
|
|
88
|
+
const trimmedFull = truncateJsonValues(parsed)
|
|
89
|
+
const trimmedFullStr = JSON.stringify(trimmedFull, null, 2)
|
|
90
|
+
if (trimmedFullStr.length <= maxChars) return trimmedFullStr
|
|
91
|
+
return truncateHeadTail(trimmedFullStr, maxChars)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Extract lowercase keywords from the judge prompt.
|
|
96
|
+
* Filters out common stop words and short tokens.
|
|
97
|
+
*/
|
|
98
|
+
function extractKeywords(prompt: string): string[] {
|
|
99
|
+
const stopWords = new Set([
|
|
100
|
+
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
|
|
101
|
+
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
|
|
102
|
+
'should', 'may', 'might', 'shall', 'can', 'need', 'must',
|
|
103
|
+
'and', 'or', 'but', 'if', 'then', 'else', 'when', 'where', 'how',
|
|
104
|
+
'what', 'which', 'who', 'whom', 'this', 'that', 'these', 'those',
|
|
105
|
+
'it', 'its', 'of', 'in', 'on', 'at', 'to', 'for', 'with', 'by',
|
|
106
|
+
'from', 'as', 'into', 'about', 'between', 'through', 'after', 'before',
|
|
107
|
+
'not', 'no', 'nor', 'only', 'also', 'just', 'more', 'most', 'very',
|
|
108
|
+
'all', 'each', 'every', 'any', 'some', 'such', 'than', 'too',
|
|
109
|
+
'output', 'evaluate', 'score', 'check', 'whether', 'contains',
|
|
110
|
+
'following', 'given', 'based', 'respond', 'number', 'scale',
|
|
111
|
+
'text', 'result', 'response', 'answer', 'return', 'value',
|
|
112
|
+
])
|
|
113
|
+
|
|
114
|
+
const words = prompt
|
|
115
|
+
.toLowerCase()
|
|
116
|
+
.replace(/[^a-z0-9_\s-]/g, ' ')
|
|
117
|
+
.split(/\s+/)
|
|
118
|
+
.filter(w => w.length > 2 && !stopWords.has(w))
|
|
119
|
+
|
|
120
|
+
return [...new Set(words)]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Recursively extract object entries whose keys match any of the keywords.
|
|
125
|
+
* For arrays, preserves items that contain matching keys.
|
|
126
|
+
*/
|
|
127
|
+
function extractRelevantPaths(obj: unknown, keywords: string[]): unknown {
|
|
128
|
+
if (Array.isArray(obj)) {
|
|
129
|
+
// For arrays: keep items that have relevant keys, limit to first few items
|
|
130
|
+
const relevant = obj
|
|
131
|
+
.map(item => extractRelevantPaths(item, keywords))
|
|
132
|
+
.filter(item => item !== undefined)
|
|
133
|
+
if (relevant.length === 0) return undefined
|
|
134
|
+
return relevant
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (typeof obj === 'object' && obj !== null) {
|
|
138
|
+
const result: Record<string, unknown> = {}
|
|
139
|
+
let hasMatch = false
|
|
140
|
+
|
|
141
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
142
|
+
const keyLower = key.toLowerCase()
|
|
143
|
+
const keyMatchesDirectly = keywords.some(kw =>
|
|
144
|
+
keyLower.includes(kw) || kw.includes(keyLower)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if (keyMatchesDirectly) {
|
|
148
|
+
result[key] = value
|
|
149
|
+
hasMatch = true
|
|
150
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
151
|
+
// Recurse into nested objects/arrays
|
|
152
|
+
const nested = extractRelevantPaths(value, keywords)
|
|
153
|
+
if (nested !== undefined) {
|
|
154
|
+
result[key] = nested
|
|
155
|
+
hasMatch = true
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return hasMatch ? result : undefined
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return undefined
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Recursively truncate large values inside a JSON structure.
|
|
168
|
+
* - Long strings: keep first/last portions with a marker in between.
|
|
169
|
+
* - Long arrays: keep first 5 and last 5 items, skip the rest with a marker.
|
|
170
|
+
*/
|
|
171
|
+
function truncateJsonValues(obj: unknown): unknown {
|
|
172
|
+
if (obj === null || obj === undefined) return obj
|
|
173
|
+
|
|
174
|
+
if (typeof obj === 'string') {
|
|
175
|
+
if (obj.length <= MAX_STRING_VALUE_LENGTH) return obj
|
|
176
|
+
const headLen = Math.floor(MAX_STRING_VALUE_LENGTH * 0.6)
|
|
177
|
+
const tailLen = MAX_STRING_VALUE_LENGTH - headLen
|
|
178
|
+
return `${obj.slice(0, headLen)}...[${obj.length - headLen - tailLen} chars truncated]...${obj.slice(obj.length - tailLen)}`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (Array.isArray(obj)) {
|
|
182
|
+
if (obj.length <= MAX_ARRAY_EDGE_ITEMS * 2) {
|
|
183
|
+
return obj.map(item => truncateJsonValues(item))
|
|
184
|
+
}
|
|
185
|
+
const head = obj.slice(0, MAX_ARRAY_EDGE_ITEMS).map(item => truncateJsonValues(item))
|
|
186
|
+
const tail = obj.slice(obj.length - MAX_ARRAY_EDGE_ITEMS).map(item => truncateJsonValues(item))
|
|
187
|
+
const skipped = obj.length - MAX_ARRAY_EDGE_ITEMS * 2
|
|
188
|
+
return [...head, `[...${skipped} items skipped...]`, ...tail]
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (typeof obj === 'object') {
|
|
192
|
+
const result: Record<string, unknown> = {}
|
|
193
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
194
|
+
result[key] = truncateJsonValues(value)
|
|
195
|
+
}
|
|
196
|
+
return result
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return obj
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Count how many top-level keys from the original object are not in the extracted object.
|
|
204
|
+
*/
|
|
205
|
+
function countOmittedKeys(original: unknown, extracted: unknown): number {
|
|
206
|
+
if (typeof original !== 'object' || original === null) return 0
|
|
207
|
+
if (typeof extracted !== 'object' || extracted === null) return 0
|
|
208
|
+
if (Array.isArray(original)) return 0
|
|
209
|
+
|
|
210
|
+
const origKeys = Object.keys(original)
|
|
211
|
+
const extKeys = new Set(Object.keys(extracted as Record<string, unknown>))
|
|
212
|
+
return origKeys.filter(k => !extKeys.has(k)).length
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Truncate a string by keeping the head and tail portions,
|
|
217
|
+
* inserting a marker in the middle.
|
|
218
|
+
*/
|
|
219
|
+
function truncateHeadTail(text: string, maxChars: number): string {
|
|
220
|
+
if (text.length <= maxChars) return text
|
|
221
|
+
|
|
222
|
+
// Reserve space for the marker
|
|
223
|
+
const marker = `\n\n[...truncated ${text.length - maxChars} chars out of ${text.length} total...]\n\n`
|
|
224
|
+
const available = maxChars - marker.length
|
|
225
|
+
if (available <= 0) return text.slice(0, maxChars)
|
|
226
|
+
|
|
227
|
+
// 70% head, 30% tail — head is usually more important
|
|
228
|
+
const headSize = Math.floor(available * 0.7)
|
|
229
|
+
const tailSize = available - headSize
|
|
230
|
+
|
|
231
|
+
return text.slice(0, headSize) + marker + text.slice(text.length - tailSize)
|
|
232
|
+
}
|
package/src/matchers/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { expect } from 'expect'
|
|
2
2
|
import type { TraceHandle, LLMStep, CustomStep, CustomStepKind } from '../trace-adapter/context.js'
|
|
3
|
+
import { prepareOutputForJudge } from '../core/judge-utils.js'
|
|
3
4
|
|
|
4
5
|
interface LLMStepConfig {
|
|
5
6
|
model?: string
|
|
@@ -186,7 +187,7 @@ export async function callProviderLLM(
|
|
|
186
187
|
method: 'POST',
|
|
187
188
|
headers: {
|
|
188
189
|
'x-api-key': apiKey,
|
|
189
|
-
'anthropic-version': '
|
|
190
|
+
'anthropic-version': '2023-06-01',
|
|
190
191
|
'content-type': 'application/json',
|
|
191
192
|
},
|
|
192
193
|
body: JSON.stringify({
|
|
@@ -595,14 +596,16 @@ Use: expect(ctx.trace).toEvaluateOutputMetric(...)`,
|
|
|
595
596
|
}
|
|
596
597
|
}
|
|
597
598
|
|
|
599
|
+
const preparedText = prepareOutputForJudge(targetText, config.evaluationPrompt)
|
|
598
600
|
const evalPrompt = `
|
|
599
601
|
Evaluation prompt (from user):
|
|
600
602
|
${config.evaluationPrompt}
|
|
601
603
|
|
|
602
604
|
Score the following text strictly between 0 and 1 (inclusive). Respond with only the number.
|
|
603
605
|
|
|
604
|
-
|
|
605
|
-
${
|
|
606
|
+
<output>
|
|
607
|
+
${preparedText}
|
|
608
|
+
</output>
|
|
606
609
|
`.trim()
|
|
607
610
|
|
|
608
611
|
try {
|