Skip to content

Commit e43a4d4

Browse files
authored
feat(core): support structured llm-grader context
Adds suite metadata inheritance and structured llm-grader prompt context via existing input, metadata, and rubrics variables. Supports reusable prompt files for rubric/score-range modes and Dexter-style rubric criteria aliases.
1 parent 98665e9 commit e43a4d4

13 files changed

Lines changed: 350 additions & 94 deletions

File tree

apps/web/src/content/docs/docs/graders/llm-graders.mdx

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Reference an LLM grader in your eval file:
2929
assertions:
3030
- name: semantic_check
3131
type: llm-grader
32-
prompt: ./graders/correctness.md
32+
prompt: file://graders/correctness.md
3333
target: grader_gpt_5_mini # optional: route this grader to a named LLM target
3434
```
3535
@@ -69,12 +69,44 @@ Score the response from 0.0 to 1.0 based on:
6969
| `output_text` | Last candidate response content |
7070
| `expected_output_text` | Last expected message content |
7171
| `criteria` | Test `criteria` field |
72-
| `input` | Full resolved input array, JSON-serialized |
73-
| `expected_output` | Full resolved expected array, JSON-serialized |
74-
| `output` | Full provider output array, JSON-serialized |
72+
| `input` | Resolved input text |
73+
| `expected_output` | Reference answer text |
74+
| `output` | Candidate answer text |
75+
| `metadata` | Test metadata as formatted JSON |
76+
| `metadata_json` | Test metadata as compact JSON |
77+
| `rubrics` | LLM-grader rubric items as formatted JSON |
78+
| `rubrics_json` | LLM-grader rubric items as compact JSON |
7579
| `file_changes` | Unified diff of workspace file changes (populated when `workspace` is configured) |
7680
| `tool_calls` | Formatted summary of tool calls from agent execution (tool name + key inputs per call) |
7781

82+
Use `prompt: file://path/to/prompt.md` to reuse a markdown prompt file. Bare `prompt: "..."` strings are treated as inline prompt text, not file paths.
83+
84+
Structured task input belongs in `input`. If `input` is a message whose `content` is a JSON object, `{{input}}` renders that object as formatted JSON for the grader prompt; no separate grader-only input field is required. Use `metadata` for provenance or suite-level source fields, and `rubrics_json` for rubric arrays.
85+
86+
Suite-level `metadata` is inherited by every test. When rubric items vary per test, keep the grader on each test and reuse the prompt file:
87+
88+
```yaml
89+
metadata:
90+
source_repo: https://github.com/virattt/dexter
91+
source_commit: 8d9419829f443f84b804d033bb2c3b1fbd788629
92+
source_file: src/evals/dataset/finance_agent.csv
93+
94+
tests:
95+
- id: apple-research
96+
input:
97+
company: Apple
98+
ticker: AAPL
99+
metadata:
100+
row: 1
101+
assertions:
102+
- name: dexter_semantic
103+
type: llm-grader
104+
prompt: file://prompts/dexter-grader.md
105+
rubrics:
106+
- operator: correctness
107+
criteria: Uses the provided ticker and company.
108+
```
109+
78110
## Per-Grader Target
79111

80112
By default, an `llm-grader` uses the suite target's `grader_target`. Override it per grader when you need multiple grader models in one run:
@@ -193,6 +225,7 @@ TypeScript templates receive a context object with these fields:
193225
| `expectedOutput` | `Message[]` | Full resolved expected output |
194226
| `output` | `Message[]` | Full provider output messages |
195227
| `trace` | `TraceSummary` | Execution metrics summary |
228+
| `metadata` | `object` | Test metadata after suite defaults are merged |
196229
| `config` | `object` | Custom config from YAML |
197230

198231
## Template Variable Derivation
@@ -225,9 +258,10 @@ Derived strings injected into grader prompts:
225258
| `criteria` | Passed through from the test field |
226259
| `expected_output_text` | Content of the last entry in `expected_output` |
227260
| `output_text` | Content of the last entry in `output` |
228-
| `input` | Full resolved input array, JSON-serialized |
229-
| `expected_output` | Full resolved expected array, JSON-serialized |
230-
| `output` | Full provider output array, JSON-serialized |
261+
| `input` | Resolved input text |
262+
| `expected_output` | Reference answer text |
263+
| `output` | Candidate answer text |
264+
| `metadata_json` | Test metadata, compact JSON |
231265
| `file_changes` | Unified diff of workspace file changes (populated when `workspace` is configured) |
232266
| `tool_calls` | Formatted summary of tool calls from agent execution (tool name + key inputs per call) |
233267

packages/core/src/evaluation/graders/code-grader.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ export class CodeGrader implements Grader {
168168
context.evalCase.input as readonly Record<string, unknown>[],
169169
getImageDir,
170170
),
171+
metadata: context.evalCase.metadata ?? null,
171172
trace: context.trace ?? null,
172173
tokenUsage: context.tokenUsage ?? null,
173174
costUsd: context.costUsd ?? null,

packages/core/src/evaluation/graders/llm-grader-prompt.ts

Lines changed: 87 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,44 @@ export interface LlmGraderPromptAssembly {
1818
mode: 'freeform' | 'checklist' | 'score_range';
1919
}
2020

21+
function stringifyPretty(value: unknown): string {
22+
return value === undefined ? '' : JSON.stringify(value, null, 2);
23+
}
24+
25+
function stringifyCompact(value: unknown): string {
26+
return value === undefined ? '' : JSON.stringify(value);
27+
}
28+
29+
function buildTemplateVariables(input: {
30+
evalCase: EvalTest;
31+
candidate: string;
32+
promptInputs: PromptInputs;
33+
rubrics?: readonly RubricItem[];
34+
fileChanges?: string;
35+
toolCalls?: string;
36+
}): Record<string, string> {
37+
const formattedQuestion =
38+
input.promptInputs.question && input.promptInputs.question.trim().length > 0
39+
? input.promptInputs.question
40+
: input.evalCase.question;
41+
42+
return {
43+
[TEMPLATE_VARIABLES.INPUT]: formattedQuestion.trim(),
44+
[TEMPLATE_VARIABLES.OUTPUT]: input.candidate.trim(),
45+
[TEMPLATE_VARIABLES.EXPECTED_OUTPUT]: (input.evalCase.reference_answer ?? '').trim(),
46+
[TEMPLATE_VARIABLES.CRITERIA]: input.evalCase.criteria.trim(),
47+
[TEMPLATE_VARIABLES.METADATA]: stringifyPretty(input.evalCase.metadata),
48+
[TEMPLATE_VARIABLES.METADATA_JSON]: stringifyCompact(input.evalCase.metadata),
49+
[TEMPLATE_VARIABLES.RUBRICS]: stringifyPretty(input.rubrics),
50+
[TEMPLATE_VARIABLES.RUBRICS_JSON]: stringifyCompact(input.rubrics),
51+
[TEMPLATE_VARIABLES.FILE_CHANGES]: input.fileChanges ?? '',
52+
[TEMPLATE_VARIABLES.TOOL_CALLS]: input.toolCalls ?? '',
53+
[TEMPLATE_VARIABLES.INPUT_TEXT]: formattedQuestion.trim(),
54+
[TEMPLATE_VARIABLES.OUTPUT_TEXT]: input.candidate.trim(),
55+
[TEMPLATE_VARIABLES.EXPECTED_OUTPUT_TEXT]: (input.evalCase.reference_answer ?? '').trim(),
56+
};
57+
}
58+
2159
export function assembleLlmGraderPrompt(input: {
2260
evalCase: EvalTest;
2361
candidate: string;
@@ -42,6 +80,17 @@ export function assembleLlmGraderPrompt(input: {
4280

4381
// Detect mode
4482
if (rubrics && rubrics.length > 0) {
83+
if (graderTemplateOverride) {
84+
return assembleCustom(
85+
evalCase,
86+
candidate,
87+
promptInputs,
88+
rubrics,
89+
fileChanges,
90+
toolCalls,
91+
graderTemplateOverride,
92+
);
93+
}
4594
const hasScoreRanges = rubrics.some((r) => r.score_ranges && r.score_ranges.length > 0);
4695
if (hasScoreRanges) {
4796
return assembleScoreRange(evalCase, candidate, promptInputs, rubrics, fileChanges, toolCalls);
@@ -67,23 +116,13 @@ function assembleFreeform(
67116
toolCalls?: string,
68117
graderTemplateOverride?: string,
69118
): LlmGraderPromptAssembly {
70-
const formattedQuestion =
71-
promptInputs.question && promptInputs.question.trim().length > 0
72-
? promptInputs.question
73-
: evalCase.question;
74-
75-
const variables = {
76-
[TEMPLATE_VARIABLES.INPUT]: formattedQuestion.trim(),
77-
[TEMPLATE_VARIABLES.OUTPUT]: candidate.trim(),
78-
[TEMPLATE_VARIABLES.EXPECTED_OUTPUT]: (evalCase.reference_answer ?? '').trim(),
79-
[TEMPLATE_VARIABLES.CRITERIA]: evalCase.criteria.trim(),
80-
[TEMPLATE_VARIABLES.FILE_CHANGES]: fileChanges ?? '',
81-
[TEMPLATE_VARIABLES.TOOL_CALLS]: toolCalls ?? '',
82-
// Deprecated aliases
83-
[TEMPLATE_VARIABLES.INPUT_TEXT]: formattedQuestion.trim(),
84-
[TEMPLATE_VARIABLES.OUTPUT_TEXT]: candidate.trim(),
85-
[TEMPLATE_VARIABLES.EXPECTED_OUTPUT_TEXT]: (evalCase.reference_answer ?? '').trim(),
86-
};
119+
const variables = buildTemplateVariables({
120+
evalCase,
121+
candidate,
122+
promptInputs,
123+
fileChanges,
124+
toolCalls,
125+
});
87126

88127
const systemPrompt = buildOutputSchema();
89128
const template = graderTemplateOverride ?? DEFAULT_GRADER_TEMPLATE;
@@ -105,6 +144,37 @@ function assembleFreeform(
105144
};
106145
}
107146

147+
function assembleCustom(
148+
evalCase: EvalTest,
149+
candidate: string,
150+
promptInputs: PromptInputs,
151+
rubrics: readonly RubricItem[],
152+
fileChanges: string | undefined,
153+
toolCalls: string | undefined,
154+
graderTemplateOverride: string,
155+
): LlmGraderPromptAssembly {
156+
const hasScoreRanges = rubrics.some((r) => r.score_ranges && r.score_ranges.length > 0);
157+
const systemPrompt = hasScoreRanges ? buildScoreRangeOutputSchema() : buildRubricOutputSchema();
158+
const userPrompt = substituteVariables(
159+
graderTemplateOverride,
160+
buildTemplateVariables({
161+
evalCase,
162+
candidate,
163+
promptInputs,
164+
rubrics,
165+
fileChanges,
166+
toolCalls,
167+
}),
168+
);
169+
170+
return {
171+
systemPrompt,
172+
userPrompt,
173+
responseSchema: systemPrompt,
174+
mode: hasScoreRanges ? 'score_range' : 'checklist',
175+
};
176+
}
177+
108178
function assembleChecklist(
109179
evalCase: EvalTest,
110180
candidate: string,

packages/core/src/evaluation/graders/llm-grader.ts

Lines changed: 58 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,39 @@ interface StructuredGenerationResult {
155155
readonly tokenUsage?: TokenUsage;
156156
}
157157

158+
function stringifyPretty(value: unknown): string {
159+
return value === undefined ? '' : JSON.stringify(value, null, 2);
160+
}
161+
162+
function stringifyCompact(value: unknown): string {
163+
return value === undefined ? '' : JSON.stringify(value);
164+
}
165+
166+
function buildTemplateVariables(context: EvaluationContext): Record<string, string> {
167+
const formattedQuestion =
168+
context.promptInputs.question && context.promptInputs.question.trim().length > 0
169+
? context.promptInputs.question
170+
: context.evalCase.question;
171+
const rubrics = context.evaluator?.type === 'llm-grader' ? context.evaluator.rubrics : undefined;
172+
173+
return {
174+
[TEMPLATE_VARIABLES.INPUT]: formattedQuestion.trim(),
175+
[TEMPLATE_VARIABLES.OUTPUT]: context.candidate.trim(),
176+
[TEMPLATE_VARIABLES.EXPECTED_OUTPUT]: (context.evalCase.reference_answer ?? '').trim(),
177+
[TEMPLATE_VARIABLES.CRITERIA]: context.evalCase.criteria.trim(),
178+
[TEMPLATE_VARIABLES.METADATA]: stringifyPretty(context.evalCase.metadata),
179+
[TEMPLATE_VARIABLES.METADATA_JSON]: stringifyCompact(context.evalCase.metadata),
180+
[TEMPLATE_VARIABLES.RUBRICS]: stringifyPretty(rubrics),
181+
[TEMPLATE_VARIABLES.RUBRICS_JSON]: stringifyCompact(rubrics),
182+
[TEMPLATE_VARIABLES.FILE_CHANGES]: context.fileChanges ?? '',
183+
[TEMPLATE_VARIABLES.TOOL_CALLS]: context.toolCalls ?? '',
184+
// Deprecated aliases — same values as the primary variables above
185+
[TEMPLATE_VARIABLES.INPUT_TEXT]: formattedQuestion.trim(),
186+
[TEMPLATE_VARIABLES.OUTPUT_TEXT]: context.candidate.trim(),
187+
[TEMPLATE_VARIABLES.EXPECTED_OUTPUT_TEXT]: (context.evalCase.reference_answer ?? '').trim(),
188+
};
189+
}
190+
158191
function resolveContentBasePath(context: EvaluationContext): string | undefined {
159192
if (context.workspacePath) {
160193
return context.workspacePath;
@@ -259,25 +292,7 @@ export class LlmGrader implements Grader {
259292
context: EvaluationContext,
260293
graderProvider: Provider,
261294
): Promise<EvaluationScore> {
262-
const formattedQuestion =
263-
context.promptInputs.question && context.promptInputs.question.trim().length > 0
264-
? context.promptInputs.question
265-
: context.evalCase.question;
266-
267-
// Prepare template variables for substitution.
268-
// Primary variables resolve to human-readable text; deprecated _text aliases map to the same values.
269-
const variables = {
270-
[TEMPLATE_VARIABLES.INPUT]: formattedQuestion.trim(),
271-
[TEMPLATE_VARIABLES.OUTPUT]: context.candidate.trim(),
272-
[TEMPLATE_VARIABLES.EXPECTED_OUTPUT]: (context.evalCase.reference_answer ?? '').trim(),
273-
[TEMPLATE_VARIABLES.CRITERIA]: context.evalCase.criteria.trim(),
274-
[TEMPLATE_VARIABLES.FILE_CHANGES]: context.fileChanges ?? '',
275-
[TEMPLATE_VARIABLES.TOOL_CALLS]: context.toolCalls ?? '',
276-
// Deprecated aliases — same values as the primary variables above
277-
[TEMPLATE_VARIABLES.INPUT_TEXT]: formattedQuestion.trim(),
278-
[TEMPLATE_VARIABLES.OUTPUT_TEXT]: context.candidate.trim(),
279-
[TEMPLATE_VARIABLES.EXPECTED_OUTPUT_TEXT]: (context.evalCase.reference_answer ?? '').trim(),
280-
};
295+
const variables = buildTemplateVariables(context);
281296

282297
// Build system prompt (only the mandatory output schema)
283298
const systemPrompt = buildOutputSchema();
@@ -367,7 +382,10 @@ export class LlmGrader implements Grader {
367382
return this.evaluateWithScoreRanges(context, graderProvider, rubrics);
368383
}
369384

370-
const prompt = this.buildRubricPrompt(context, rubrics);
385+
const prompt =
386+
context.graderTemplateOverride || this.graderTemplate
387+
? this.buildCustomPrompt(context)
388+
: this.buildRubricPrompt(context, rubrics);
371389
const systemPrompt = buildRubricOutputSchema();
372390

373391
const graderRawRequest: JsonObject = {
@@ -423,7 +441,10 @@ export class LlmGrader implements Grader {
423441
graderProvider: Provider,
424442
rubrics: readonly RubricItem[],
425443
): Promise<EvaluationScore> {
426-
const prompt = this.buildScoreRangePrompt(context, rubrics);
444+
const prompt =
445+
context.graderTemplateOverride || this.graderTemplate
446+
? this.buildCustomPrompt(context)
447+
: this.buildScoreRangePrompt(context, rubrics);
427448
const systemPrompt = buildScoreRangeOutputSchema();
428449

429450
const graderRawRequest: JsonObject = {
@@ -688,22 +709,12 @@ export class LlmGrader implements Grader {
688709
? context.promptInputs.question
689710
: context.evalCase.question;
690711

691-
const variables: Record<string, string> = {
692-
[TEMPLATE_VARIABLES.CRITERIA]: context.evalCase.criteria.trim(),
693-
[TEMPLATE_VARIABLES.INPUT]: formattedQuestion.trim(),
694-
[TEMPLATE_VARIABLES.OUTPUT]: context.candidate.trim(),
695-
[TEMPLATE_VARIABLES.EXPECTED_OUTPUT]: (context.evalCase.reference_answer ?? '').trim(),
696-
[TEMPLATE_VARIABLES.FILE_CHANGES]: context.fileChanges ?? '',
697-
[TEMPLATE_VARIABLES.TOOL_CALLS]: context.toolCalls ?? '',
698-
// Deprecated aliases
699-
[TEMPLATE_VARIABLES.INPUT_TEXT]: formattedQuestion.trim(),
700-
[TEMPLATE_VARIABLES.OUTPUT_TEXT]: context.candidate.trim(),
701-
[TEMPLATE_VARIABLES.EXPECTED_OUTPUT_TEXT]: (context.evalCase.reference_answer ?? '').trim(),
702-
};
712+
const variables = buildTemplateVariables(context);
703713

704-
if (this.graderTemplate) {
705-
warnDeprecatedTemplateVars(this.graderTemplate);
706-
return substituteVariables(this.graderTemplate, variables);
714+
const template = context.graderTemplateOverride ?? this.graderTemplate;
715+
if (template) {
716+
warnDeprecatedTemplateVars(template);
717+
return substituteVariables(template, variables);
707718
}
708719

709720
const config = context.evaluator;
@@ -767,21 +778,11 @@ export class LlmGrader implements Grader {
767778
const config = context.evaluator;
768779
const rubrics = config?.type === 'llm-grader' ? config.rubrics : undefined;
769780

770-
if (this.graderTemplate) {
771-
const variables: Record<string, string> = {
772-
[TEMPLATE_VARIABLES.CRITERIA]: context.evalCase.criteria.trim(),
773-
[TEMPLATE_VARIABLES.INPUT]: formattedQuestion.trim(),
774-
[TEMPLATE_VARIABLES.OUTPUT]: context.candidate.trim(),
775-
[TEMPLATE_VARIABLES.EXPECTED_OUTPUT]: (context.evalCase.reference_answer ?? '').trim(),
776-
[TEMPLATE_VARIABLES.FILE_CHANGES]: context.fileChanges ?? '',
777-
[TEMPLATE_VARIABLES.TOOL_CALLS]: context.toolCalls ?? '',
778-
// Deprecated aliases
779-
[TEMPLATE_VARIABLES.INPUT_TEXT]: formattedQuestion.trim(),
780-
[TEMPLATE_VARIABLES.OUTPUT_TEXT]: context.candidate.trim(),
781-
[TEMPLATE_VARIABLES.EXPECTED_OUTPUT_TEXT]: (context.evalCase.reference_answer ?? '').trim(),
782-
};
783-
warnDeprecatedTemplateVars(this.graderTemplate);
784-
const customPrompt = substituteVariables(this.graderTemplate, variables);
781+
const template = context.graderTemplateOverride ?? this.graderTemplate;
782+
if (template) {
783+
const variables = buildTemplateVariables(context);
784+
warnDeprecatedTemplateVars(template);
785+
const customPrompt = substituteVariables(template, variables);
785786

786787
const outputSchema =
787788
rubrics && rubrics.length > 0 ? buildRubricOutputSchema() : buildOutputSchema();
@@ -984,6 +985,12 @@ export class LlmGrader implements Grader {
984985
return parts.join('\n');
985986
}
986987

988+
private buildCustomPrompt(context: EvaluationContext): string {
989+
const template = context.graderTemplateOverride ?? this.graderTemplate ?? '';
990+
warnDeprecatedTemplateVars(template);
991+
return substituteVariables(template, buildTemplateVariables(context));
992+
}
993+
987994
private buildRubricPrompt(context: EvaluationContext, rubrics: readonly RubricItem[]): string {
988995
const formattedQuestion =
989996
context.promptInputs.question && context.promptInputs.question.trim().length > 0

packages/core/src/evaluation/graders/prompt-resolution.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ async function executePromptTemplate(
103103
output: context.output ?? null,
104104
inputFiles: context.evalCase.file_paths,
105105
input: context.evalCase.input,
106+
metadata: context.evalCase.metadata ?? null,
106107
trace: context.trace ?? null,
107108
fileChanges: context.fileChanges ?? null,
108109
workspacePath: context.workspacePath ?? null,

packages/core/src/evaluation/loaders/grader-parser.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2143,7 +2143,7 @@ function parseRubricItems(
21432143
}
21442144

21452145
const id = asString(rawRubric.id) ?? `rubric-${index + 1}`;
2146-
const expectedOutcome = asString(rawRubric.outcome) ?? '';
2146+
const expectedOutcome = asString(rawRubric.outcome) ?? asString(rawRubric.criteria) ?? '';
21472147
const operator = parseRubricOperator(rawRubric.operator, id, evaluatorName, evalId);
21482148
const weight = typeof rawRubric.weight === 'number' ? rawRubric.weight : 1.0;
21492149

0 commit comments

Comments
 (0)