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.
@@ -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
- const resolvedModel = judge.judge_model ?? evaluatorConfig?.model ?? undefined
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 evalPrompt = `${judge.judge_prompt}\n\nOutput to evaluate:\n${outputStr}\n\nScore this output on a scale of 0-10. Respond with only the number.`
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,
@@ -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
- Output to evaluate:
597
- ${outputStr}
598
+ <output>
599
+ ${preparedOutput}
600
+ </output>
598
601
 
599
- Score this output on a scale of 0-10. Respond with only the number.`
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
+ }
@@ -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': '2025-01-01',
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
- Text:
605
- ${targetText}
606
+ <output>
607
+ ${preparedText}
608
+ </output>
606
609
  `.trim()
607
610
 
608
611
  try {