0d3cb498a3
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
608 lines
21 KiB
TypeScript
608 lines
21 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { matchesGEval } from '../../src/matchers/llmGrading';
|
|
import { resolveProvider } from '../../src/providers';
|
|
import { createLiteLLMProvider } from '../../src/providers/litellm';
|
|
import { DefaultGradingProvider } from '../../src/providers/openai/defaults';
|
|
|
|
describe('matchesGEval', () => {
|
|
let originalCallApi: typeof DefaultGradingProvider.callApi;
|
|
|
|
beforeEach(() => {
|
|
originalCallApi = DefaultGradingProvider.callApi;
|
|
|
|
vi.spyOn(DefaultGradingProvider, 'callApi').mockImplementation(async (prompt) => {
|
|
if (prompt.includes('generate 3-4 concise evaluation steps')) {
|
|
return {
|
|
output: '{"steps": ["Check clarity", "Evaluate coherence", "Assess grammar"]}',
|
|
tokenUsage: { total: 10, prompt: 5, completion: 5 },
|
|
};
|
|
} else {
|
|
return {
|
|
output: '{"score": 8, "reason": "The response is well-structured and clear"}',
|
|
tokenUsage: { total: 15, prompt: 8, completion: 7 },
|
|
};
|
|
}
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
DefaultGradingProvider.callApi = originalCallApi;
|
|
});
|
|
|
|
it('should properly evaluate with default prompts', async () => {
|
|
const criteria = 'Evaluate coherence and clarity';
|
|
const input = 'Test input';
|
|
const output = 'Test output';
|
|
const threshold = 0.7;
|
|
|
|
const result = await matchesGEval(criteria, input, output, threshold);
|
|
|
|
expect(result).toEqual({
|
|
pass: true,
|
|
score: 0.8,
|
|
reason: 'The response is well-structured and clear',
|
|
tokensUsed: expect.any(Object),
|
|
});
|
|
|
|
expect(DefaultGradingProvider.callApi).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it.each([
|
|
['positive', 'the answer should contain harmful or inappropriate content'],
|
|
['negated', 'the answer should not contain harmful or inappropriate content'],
|
|
])('should render observed-condition scoring for %s criteria', async (_, criteria) => {
|
|
await matchesGEval(criteria, 'Test input', 'Test output', 0.7);
|
|
|
|
expect(DefaultGradingProvider.callApi).toHaveBeenCalledTimes(2);
|
|
expect(DefaultGradingProvider.callApi).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.stringContaining(criteria),
|
|
expect.any(Object),
|
|
);
|
|
expect(DefaultGradingProvider.callApi).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.stringContaining(criteria),
|
|
expect.any(Object),
|
|
);
|
|
expect(DefaultGradingProvider.callApi).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.stringContaining(
|
|
'the condition described by the Evaluation Criteria is fully and clearly observed in the Reply',
|
|
),
|
|
expect.any(Object),
|
|
);
|
|
expect(DefaultGradingProvider.callApi).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.stringContaining('and 0 indicates that it is not observed at all'),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it('should handle custom rubric prompts', async () => {
|
|
vi.resetAllMocks();
|
|
|
|
const mockCallApi = vi
|
|
.fn()
|
|
.mockImplementationOnce(() => ({
|
|
output: '{"steps": ["Custom step 1", "Custom step 2"]}',
|
|
tokenUsage: { total: 10, prompt: 5, completion: 5 },
|
|
}))
|
|
.mockImplementationOnce(() => ({
|
|
output: '{"score": 8, "reason": "Custom evaluation complete", "pass": true}',
|
|
tokenUsage: { total: 15, prompt: 8, completion: 7 },
|
|
}));
|
|
|
|
DefaultGradingProvider.callApi = mockCallApi;
|
|
|
|
const criteria = 'Evaluate coherence and clarity';
|
|
const input = 'Test input';
|
|
const output = 'Test output';
|
|
const threshold = 0.7;
|
|
|
|
const grading = {
|
|
rubricPrompt: {
|
|
steps: 'Custom steps template with {{criteria}}',
|
|
evaluate: 'Custom evaluation template with {{criteria}} and {{steps}}',
|
|
},
|
|
} as any;
|
|
|
|
const result = await matchesGEval(criteria, input, output, threshold, grading);
|
|
|
|
expect(result.score).toBe(0.8);
|
|
|
|
expect(mockCallApi).toHaveBeenCalledTimes(2);
|
|
expect(mockCallApi).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.stringContaining('Custom steps template with'),
|
|
expect.any(Object),
|
|
);
|
|
expect(mockCallApi).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.stringContaining('Custom evaluation template with'),
|
|
expect.any(Object),
|
|
);
|
|
|
|
DefaultGradingProvider.callApi = originalCallApi;
|
|
});
|
|
|
|
it('passes LiteLLM provider context through both G-Eval phases', async () => {
|
|
const criteria = 'Reward factual grounding and completeness';
|
|
const configuredLiteLLM = createLiteLLMProvider('litellm:gemini-pro', {
|
|
config: {
|
|
config: {
|
|
apiBaseUrl: 'http://litellm-regression.local:4000',
|
|
apiKey: 'test-litellm-key',
|
|
temperature: 0,
|
|
},
|
|
},
|
|
});
|
|
|
|
const resolvedProvider = await resolveProvider('litellm:gemini-pro', {
|
|
[configuredLiteLLM.id()]: configuredLiteLLM,
|
|
});
|
|
expect(resolvedProvider).toBe(configuredLiteLLM);
|
|
expect(configuredLiteLLM.config.apiBaseUrl).toBe('http://litellm-regression.local:4000');
|
|
|
|
const litellmCallApi = vi
|
|
.fn()
|
|
.mockImplementationOnce(async () => ({
|
|
output: '{"steps": ["Check factual alignment", "Check completeness"]}',
|
|
tokenUsage: { total: 11, prompt: 6, completion: 5 },
|
|
}))
|
|
.mockImplementationOnce(async () => ({
|
|
output: '{"score": 8, "reason": "The answer is grounded and complete"}',
|
|
tokenUsage: { total: 13, prompt: 7, completion: 6 },
|
|
}));
|
|
resolvedProvider.callApi = litellmCallApi;
|
|
|
|
const result = await matchesGEval(
|
|
criteria,
|
|
'Question: what is the capital of France?',
|
|
'Paris is the capital of France.',
|
|
0.7,
|
|
{ provider: resolvedProvider },
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
pass: true,
|
|
score: 0.8,
|
|
reason: 'The answer is grounded and complete',
|
|
tokensUsed: expect.objectContaining({ total: 24, prompt: 13, completion: 11 }),
|
|
});
|
|
expect(DefaultGradingProvider.callApi).not.toHaveBeenCalled();
|
|
expect(litellmCallApi).toHaveBeenCalledTimes(2);
|
|
expect(litellmCallApi).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.stringContaining('generate 3-4 concise evaluation steps'),
|
|
expect.objectContaining({
|
|
prompt: expect.objectContaining({ label: 'g-eval-steps' }),
|
|
vars: { criteria },
|
|
}),
|
|
);
|
|
expect(litellmCallApi).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.stringContaining('Check factual alignment'),
|
|
expect.objectContaining({
|
|
prompt: expect.objectContaining({ label: 'g-eval' }),
|
|
vars: expect.objectContaining({
|
|
criteria,
|
|
input: 'Question: what is the capital of France?',
|
|
maxScore: '10',
|
|
output: 'Paris is the capital of France.',
|
|
}),
|
|
}),
|
|
);
|
|
expect(litellmCallApi.mock.calls[1][1].vars.steps).toContain('Check completeness');
|
|
});
|
|
|
|
it('should fail when score is below threshold', async () => {
|
|
vi.spyOn(DefaultGradingProvider, 'callApi')
|
|
.mockImplementationOnce(async () => {
|
|
return {
|
|
output: '{"steps": ["Check clarity", "Evaluate coherence", "Assess grammar"]}',
|
|
tokenUsage: { total: 10, prompt: 5, completion: 5 },
|
|
};
|
|
})
|
|
.mockImplementationOnce(async () => {
|
|
return {
|
|
output: '{"score": 3, "reason": "The response lacks coherence"}',
|
|
tokenUsage: { total: 15, prompt: 8, completion: 7 },
|
|
};
|
|
});
|
|
|
|
const criteria = 'Evaluate coherence and clarity';
|
|
const input = 'Test input';
|
|
const output = 'Test output';
|
|
const threshold = 0.7;
|
|
|
|
const result = await matchesGEval(criteria, input, output, threshold);
|
|
|
|
expect(result).toEqual({
|
|
pass: false,
|
|
score: 0.3,
|
|
reason: 'The response lacks coherence',
|
|
tokensUsed: expect.any(Object),
|
|
});
|
|
});
|
|
|
|
it('should return provider errors from the step-generation call', async () => {
|
|
vi.spyOn(DefaultGradingProvider, 'callApi').mockResolvedValueOnce({
|
|
error: 'steps provider unavailable',
|
|
tokenUsage: { total: 3, prompt: 1, completion: 2 },
|
|
});
|
|
|
|
const result = await matchesGEval('Evaluate coherence', 'Test input', 'Test output', 0.7);
|
|
|
|
expect(result).toEqual({
|
|
pass: false,
|
|
score: 0,
|
|
reason: 'steps provider unavailable',
|
|
tokensUsed: expect.objectContaining({
|
|
total: 3,
|
|
prompt: 1,
|
|
completion: 2,
|
|
}),
|
|
metadata: { graderError: true },
|
|
});
|
|
expect(DefaultGradingProvider.callApi).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('reports the LiteLLM grader and phase when step generation returns no output', async () => {
|
|
const provider = createLiteLLMProvider('litellm:gemini-pro', {});
|
|
provider.callApi = vi.fn().mockResolvedValueOnce({
|
|
output: '',
|
|
tokenUsage: { total: 3, prompt: 1, completion: 2 },
|
|
});
|
|
|
|
const result = await matchesGEval('Evaluate coherence', 'Test input', 'Test output', 0.7, {
|
|
provider,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
pass: false,
|
|
score: 0,
|
|
reason: 'G-Eval step-generation call to litellm:gemini-pro returned no output',
|
|
tokensUsed: expect.objectContaining({ total: 3, prompt: 1, completion: 2 }),
|
|
metadata: { graderError: true },
|
|
});
|
|
expect(DefaultGradingProvider.callApi).not.toHaveBeenCalled();
|
|
expect(provider.callApi).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should fail clearly when the evaluation steps shape is invalid', async () => {
|
|
vi.spyOn(DefaultGradingProvider, 'callApi').mockResolvedValueOnce({
|
|
output: '{"steps":"Check clarity"}',
|
|
tokenUsage: { total: 3, prompt: 1, completion: 2 },
|
|
});
|
|
|
|
const result = await matchesGEval('Evaluate coherence', 'Test input', 'Test output', 0.7);
|
|
|
|
expect(result).toEqual({
|
|
pass: false,
|
|
score: 0,
|
|
reason: 'G-Eval steps response has invalid or missing steps: "Check clarity"',
|
|
tokensUsed: expect.objectContaining({
|
|
total: 3,
|
|
prompt: 1,
|
|
completion: 2,
|
|
}),
|
|
metadata: { graderError: true },
|
|
});
|
|
expect(DefaultGradingProvider.callApi).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should return provider errors from the evaluation call', async () => {
|
|
vi.spyOn(DefaultGradingProvider, 'callApi')
|
|
.mockImplementationOnce(async () => ({
|
|
output: '{"steps": ["Check clarity"]}',
|
|
tokenUsage: { total: 3, prompt: 1, completion: 2 },
|
|
}))
|
|
.mockImplementationOnce(async () => ({
|
|
error: 'evaluation provider unavailable',
|
|
tokenUsage: { total: 4, prompt: 2, completion: 2 },
|
|
}));
|
|
|
|
const result = await matchesGEval('Evaluate coherence', 'Test input', 'Test output', 0.7);
|
|
|
|
expect(result).toEqual({
|
|
pass: false,
|
|
score: 0,
|
|
reason: 'evaluation provider unavailable',
|
|
tokensUsed: expect.objectContaining({
|
|
total: 7,
|
|
prompt: 3,
|
|
completion: 4,
|
|
}),
|
|
metadata: { graderError: true },
|
|
});
|
|
});
|
|
|
|
it('reports the LiteLLM grader and phase when evaluation returns no output', async () => {
|
|
const provider = createLiteLLMProvider('litellm:gemini-pro', {});
|
|
provider.callApi = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({
|
|
output: '{"steps": ["Check clarity"]}',
|
|
tokenUsage: { total: 3, prompt: 1, completion: 2 },
|
|
})
|
|
.mockResolvedValueOnce({
|
|
tokenUsage: { total: 4, prompt: 2, completion: 2 },
|
|
});
|
|
|
|
const result = await matchesGEval('Evaluate coherence', 'Test input', 'Test output', 0.7, {
|
|
provider,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
pass: false,
|
|
score: 0,
|
|
reason: 'G-Eval evaluation call to litellm:gemini-pro returned no output',
|
|
tokensUsed: expect.objectContaining({ total: 7, prompt: 3, completion: 4 }),
|
|
metadata: { graderError: true },
|
|
});
|
|
expect(DefaultGradingProvider.callApi).not.toHaveBeenCalled();
|
|
expect(provider.callApi).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should fail clearly when the evaluation result is not a string', async () => {
|
|
vi.spyOn(DefaultGradingProvider, 'callApi')
|
|
.mockImplementationOnce(async () => ({
|
|
output: '{"steps": ["Check clarity"]}',
|
|
tokenUsage: { total: 3, prompt: 1, completion: 2 },
|
|
}))
|
|
.mockImplementationOnce(async () => ({
|
|
output: { score: 8, reason: 'object output' },
|
|
tokenUsage: { total: 4, prompt: 2, completion: 2 },
|
|
}));
|
|
|
|
const result = await matchesGEval('Evaluate coherence', 'Test input', 'Test output', 0.7);
|
|
|
|
expect(result).toEqual({
|
|
pass: false,
|
|
score: 0,
|
|
reason: 'LLM-proposed evaluation result response is not a string',
|
|
tokensUsed: expect.objectContaining({
|
|
total: 7,
|
|
}),
|
|
metadata: { graderError: true },
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
['null score', '{"score": null, "reason": "null score"}', 'null'],
|
|
['blank string score', '{"score": "", "reason": "blank score"}', '""'],
|
|
])('should fail clearly when the evaluation score is %s', async (_, output, scoreLabel) => {
|
|
vi.spyOn(DefaultGradingProvider, 'callApi')
|
|
.mockImplementationOnce(async () => ({
|
|
output: '{"steps": ["Check clarity"]}',
|
|
tokenUsage: { total: 3, prompt: 1, completion: 2 },
|
|
}))
|
|
.mockImplementationOnce(async () => ({
|
|
output,
|
|
tokenUsage: { total: 4, prompt: 2, completion: 2 },
|
|
}));
|
|
|
|
const result = await matchesGEval('Evaluate coherence', 'Test input', 'Test output', 0.7);
|
|
|
|
expect(result).toEqual({
|
|
pass: false,
|
|
score: 0,
|
|
reason: `G-Eval result has invalid or missing score: ${scoreLabel}`,
|
|
tokensUsed: expect.objectContaining({
|
|
total: 7,
|
|
}),
|
|
metadata: { graderError: true },
|
|
});
|
|
});
|
|
|
|
it('should fail clearly when the evaluation score is outside the expected range', async () => {
|
|
vi.spyOn(DefaultGradingProvider, 'callApi')
|
|
.mockImplementationOnce(async () => ({
|
|
output: '{"steps": ["Check clarity"]}',
|
|
tokenUsage: { total: 3, prompt: 1, completion: 2 },
|
|
}))
|
|
.mockImplementationOnce(async () => ({
|
|
output: '{"score": 11, "reason": "too high"}',
|
|
tokenUsage: { total: 4, prompt: 2, completion: 2 },
|
|
}));
|
|
|
|
const result = await matchesGEval('Evaluate coherence', 'Test input', 'Test output', 0.7);
|
|
|
|
expect(result).toEqual({
|
|
pass: false,
|
|
score: 0,
|
|
reason: 'G-Eval result score 11 is outside the expected 0-10 range',
|
|
tokensUsed: expect.objectContaining({
|
|
total: 7,
|
|
}),
|
|
metadata: { graderError: true },
|
|
});
|
|
});
|
|
|
|
it('should fail clearly when the evaluation reason is missing', async () => {
|
|
vi.spyOn(DefaultGradingProvider, 'callApi')
|
|
.mockImplementationOnce(async () => ({
|
|
output: '{"steps": ["Check clarity"]}',
|
|
tokenUsage: { total: 3, prompt: 1, completion: 2 },
|
|
}))
|
|
.mockImplementationOnce(async () => ({
|
|
output: '{"score": 8}',
|
|
tokenUsage: { total: 4, prompt: 2, completion: 2 },
|
|
}));
|
|
|
|
const result = await matchesGEval('Evaluate coherence', 'Test input', 'Test output', 0.7);
|
|
|
|
expect(result).toEqual({
|
|
pass: false,
|
|
score: 0,
|
|
reason: 'G-Eval result has invalid or missing reason: undefined',
|
|
tokensUsed: expect.objectContaining({
|
|
total: 7,
|
|
}),
|
|
metadata: { graderError: true },
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
['null reason', '{"score": 8, "reason": null}', 'null'],
|
|
['numeric reason', '{"score": 8, "reason": 42}', '42'],
|
|
['empty string reason', '{"score": 8, "reason": ""}', '""'],
|
|
['whitespace-only reason', '{"score": 8, "reason": " "}', '" "'],
|
|
])('should fail clearly when the evaluation reason is %s', async (_, output, reasonLabel) => {
|
|
vi.spyOn(DefaultGradingProvider, 'callApi')
|
|
.mockImplementationOnce(async () => ({
|
|
output: '{"steps": ["Check clarity"]}',
|
|
tokenUsage: { total: 3, prompt: 1, completion: 2 },
|
|
}))
|
|
.mockImplementationOnce(async () => ({
|
|
output,
|
|
tokenUsage: { total: 4, prompt: 2, completion: 2 },
|
|
}));
|
|
|
|
const result = await matchesGEval('Evaluate coherence', 'Test input', 'Test output', 0.7);
|
|
|
|
expect(result).toEqual({
|
|
pass: false,
|
|
score: 0,
|
|
reason: `G-Eval result has invalid or missing reason: ${reasonLabel}`,
|
|
tokensUsed: expect.objectContaining({ total: 7, prompt: 3, completion: 4 }),
|
|
metadata: { graderError: true },
|
|
});
|
|
});
|
|
|
|
it('should fail clearly when the steps response contains non-string elements', async () => {
|
|
vi.spyOn(DefaultGradingProvider, 'callApi').mockResolvedValueOnce({
|
|
output: '{"steps": ["ok", 42]}',
|
|
tokenUsage: { total: 3, prompt: 1, completion: 2 },
|
|
});
|
|
|
|
const result = await matchesGEval('Evaluate coherence', 'Test input', 'Test output', 0.7);
|
|
|
|
expect(result).toEqual({
|
|
pass: false,
|
|
score: 0,
|
|
reason: 'G-Eval steps response contains invalid steps: ["ok",42]',
|
|
tokensUsed: expect.objectContaining({ total: 3, prompt: 1, completion: 2 }),
|
|
metadata: { graderError: true },
|
|
});
|
|
expect(DefaultGradingProvider.callApi).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should fail clearly when a steps entry is whitespace-only', async () => {
|
|
vi.spyOn(DefaultGradingProvider, 'callApi').mockResolvedValueOnce({
|
|
output: '{"steps": ["ok", " "]}',
|
|
tokenUsage: { total: 3, prompt: 1, completion: 2 },
|
|
});
|
|
|
|
const result = await matchesGEval('Evaluate coherence', 'Test input', 'Test output', 0.7);
|
|
|
|
expect(result).toEqual({
|
|
pass: false,
|
|
score: 0,
|
|
reason: 'G-Eval steps response contains invalid steps: ["ok"," "]',
|
|
tokensUsed: expect.objectContaining({ total: 3, prompt: 1, completion: 2 }),
|
|
metadata: { graderError: true },
|
|
});
|
|
});
|
|
|
|
it('should fail clearly when the steps array is empty', async () => {
|
|
vi.spyOn(DefaultGradingProvider, 'callApi').mockResolvedValueOnce({
|
|
output: '{"steps": []}',
|
|
tokenUsage: { total: 3, prompt: 1, completion: 2 },
|
|
});
|
|
|
|
const result = await matchesGEval('Evaluate coherence', 'Test input', 'Test output', 0.7);
|
|
|
|
expect(result).toEqual({
|
|
pass: false,
|
|
score: 0,
|
|
reason: 'LLM does not propose any evaluation step',
|
|
tokensUsed: expect.objectContaining({ total: 3, prompt: 1, completion: 2 }),
|
|
metadata: { graderError: true },
|
|
});
|
|
expect(DefaultGradingProvider.callApi).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should accept numeric-string scores', async () => {
|
|
// Positive path for the string coercion branch in rawScore parsing —
|
|
// locks in that a valid numeric string still produces a real grade so a
|
|
// future tightening doesn't silently reject all string scores.
|
|
vi.spyOn(DefaultGradingProvider, 'callApi')
|
|
.mockImplementationOnce(async () => ({
|
|
output: '{"steps": ["Check clarity"]}',
|
|
tokenUsage: { total: 3, prompt: 1, completion: 2 },
|
|
}))
|
|
.mockImplementationOnce(async () => ({
|
|
output: '{"score": " 7 ", "reason": "fine"}',
|
|
tokenUsage: { total: 4, prompt: 2, completion: 2 },
|
|
}));
|
|
|
|
const result = await matchesGEval('Evaluate coherence', 'Test input', 'Test output', 0.7);
|
|
|
|
expect(result.pass).toBe(true);
|
|
expect(result.score).toBeCloseTo(0.7, 5);
|
|
expect(result.reason).toBe('fine');
|
|
expect(result.metadata).toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
['lower bound exactly (0)', '{"score": 0, "reason": "absent"}', 0],
|
|
['upper bound exactly (10)', '{"score": 10, "reason": "perfect"}', 1],
|
|
])('should accept score at the %s', async (_, output, expectedScore) => {
|
|
vi.spyOn(DefaultGradingProvider, 'callApi')
|
|
.mockImplementationOnce(async () => ({
|
|
output: '{"steps": ["Check clarity"]}',
|
|
tokenUsage: { total: 3, prompt: 1, completion: 2 },
|
|
}))
|
|
.mockImplementationOnce(async () => ({
|
|
output,
|
|
tokenUsage: { total: 4, prompt: 2, completion: 2 },
|
|
}));
|
|
|
|
const result = await matchesGEval('Evaluate coherence', 'Test input', 'Test output', 0.7);
|
|
|
|
expect(result.score).toBeCloseTo(expectedScore, 5);
|
|
expect(result.metadata).toBeUndefined();
|
|
});
|
|
|
|
it('should fail clearly when the evaluation score is negative', async () => {
|
|
vi.spyOn(DefaultGradingProvider, 'callApi')
|
|
.mockImplementationOnce(async () => ({
|
|
output: '{"steps": ["Check clarity"]}',
|
|
tokenUsage: { total: 3, prompt: 1, completion: 2 },
|
|
}))
|
|
.mockImplementationOnce(async () => ({
|
|
output: '{"score": -1, "reason": "too low"}',
|
|
tokenUsage: { total: 4, prompt: 2, completion: 2 },
|
|
}));
|
|
|
|
const result = await matchesGEval('Evaluate coherence', 'Test input', 'Test output', 0.7);
|
|
|
|
expect(result).toEqual({
|
|
pass: false,
|
|
score: 0,
|
|
reason: 'G-Eval result score -1 is outside the expected 0-10 range',
|
|
tokensUsed: expect.objectContaining({ total: 7, prompt: 3, completion: 4 }),
|
|
metadata: { graderError: true },
|
|
});
|
|
});
|
|
|
|
it('tracks token usage for both API calls', async () => {
|
|
const criteria = 'Evaluate coherence and clarity';
|
|
const input = 'Test input';
|
|
const output = 'Test output';
|
|
const threshold = 0.7;
|
|
|
|
const result = await matchesGEval(criteria, input, output, threshold);
|
|
|
|
expect(result.tokensUsed).toEqual({
|
|
total: 25,
|
|
prompt: 13,
|
|
completion: 12,
|
|
cached: 0,
|
|
completionDetails: expect.any(Object),
|
|
numRequests: 0,
|
|
});
|
|
});
|
|
});
|