0d3cb498a3
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
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) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
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
582 lines
17 KiB
TypeScript
582 lines
17 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import { handleSimilar } from '../../src/assertions/similar';
|
|
import { matchesSimilarity } from '../../src/matchers/similarity';
|
|
import { createMockProvider } from '../factories/provider';
|
|
|
|
vi.mock('../../src/matchers/similarity', () => ({
|
|
matchesSimilarity: vi.fn().mockImplementation(async (expected, output, _threshold, inverse) => {
|
|
if (inverse) {
|
|
return {
|
|
pass: expected !== output,
|
|
score: expected === output ? 0 : 1,
|
|
};
|
|
}
|
|
return {
|
|
pass: expected === output,
|
|
score: expected === output ? 1 : 0,
|
|
};
|
|
}),
|
|
}));
|
|
|
|
describe('handleSimilar', () => {
|
|
it('should handle string similarity assertion', async () => {
|
|
const result = await handleSimilar({
|
|
assertion: {
|
|
type: 'similar',
|
|
value: 'hello world',
|
|
},
|
|
baseType: 'similar' as any,
|
|
renderedValue: 'hello world',
|
|
outputString: 'hello world',
|
|
inverse: false,
|
|
test: {
|
|
description: 'test',
|
|
vars: {},
|
|
assert: [],
|
|
options: {},
|
|
},
|
|
assertionValueContext: {
|
|
prompt: 'test prompt',
|
|
vars: {},
|
|
test: {
|
|
description: 'test',
|
|
vars: {},
|
|
assert: [],
|
|
options: {},
|
|
},
|
|
logProbs: undefined,
|
|
// @ts-ignore
|
|
provider: createMockProvider({ response: {} }),
|
|
providerResponse: { output: 'hello world' },
|
|
},
|
|
output: 'hello world',
|
|
providerResponse: { output: 'hello world' },
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
expect(result.score).toBe(1);
|
|
});
|
|
|
|
it('should handle array of strings similarity assertion', async () => {
|
|
const result = await handleSimilar({
|
|
assertion: {
|
|
type: 'similar',
|
|
value: ['hello world', 'hi world'],
|
|
},
|
|
baseType: 'similar' as any,
|
|
renderedValue: ['hello world', 'hi world'],
|
|
outputString: 'hello world',
|
|
inverse: false,
|
|
test: {
|
|
description: 'test',
|
|
vars: {},
|
|
assert: [],
|
|
options: {},
|
|
},
|
|
assertionValueContext: {
|
|
prompt: 'test prompt',
|
|
vars: {},
|
|
test: {
|
|
description: 'test',
|
|
vars: {},
|
|
assert: [],
|
|
options: {},
|
|
},
|
|
logProbs: undefined,
|
|
// @ts-ignore
|
|
provider: createMockProvider({ response: {} }),
|
|
providerResponse: { output: 'hello world' },
|
|
},
|
|
output: 'hello world',
|
|
providerResponse: { output: 'hello world' },
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
expect(result.score).toBe(1);
|
|
});
|
|
|
|
it('should handle custom threshold', async () => {
|
|
const result = await handleSimilar({
|
|
assertion: {
|
|
type: 'similar',
|
|
value: 'hello world',
|
|
threshold: 0.5,
|
|
},
|
|
baseType: 'similar' as any,
|
|
renderedValue: 'hello world',
|
|
outputString: 'hello world',
|
|
inverse: false,
|
|
test: {
|
|
description: 'test',
|
|
vars: {},
|
|
assert: [],
|
|
options: {},
|
|
},
|
|
assertionValueContext: {
|
|
prompt: 'test prompt',
|
|
vars: {},
|
|
test: {
|
|
description: 'test',
|
|
vars: {},
|
|
assert: [],
|
|
options: {},
|
|
},
|
|
logProbs: undefined,
|
|
// @ts-ignore
|
|
provider: createMockProvider({ response: {} }),
|
|
providerResponse: { output: 'hello world' },
|
|
},
|
|
output: 'hello world',
|
|
providerResponse: { output: 'hello world' },
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
});
|
|
|
|
it('should handle inverse similarity assertion', async () => {
|
|
const result = await handleSimilar({
|
|
assertion: {
|
|
type: 'similar',
|
|
value: 'hello world',
|
|
},
|
|
baseType: 'similar' as any,
|
|
renderedValue: 'hello world',
|
|
outputString: 'completely different',
|
|
inverse: true,
|
|
test: {
|
|
description: 'test',
|
|
vars: {},
|
|
assert: [],
|
|
options: {},
|
|
},
|
|
assertionValueContext: {
|
|
prompt: 'test prompt',
|
|
vars: {},
|
|
test: {
|
|
description: 'test',
|
|
vars: {},
|
|
assert: [],
|
|
options: {},
|
|
},
|
|
logProbs: undefined,
|
|
// @ts-ignore
|
|
provider: createMockProvider({ response: {} }),
|
|
providerResponse: { output: 'completely different' },
|
|
},
|
|
output: 'completely different',
|
|
providerResponse: { output: 'completely different' },
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
});
|
|
|
|
it('should fail when no array values meet threshold', async () => {
|
|
const result = await handleSimilar({
|
|
assertion: {
|
|
type: 'similar',
|
|
value: ['hello world', 'hi world'],
|
|
threshold: 0.9,
|
|
},
|
|
baseType: 'similar' as any,
|
|
renderedValue: ['hello world', 'hi world'],
|
|
outputString: 'completely different',
|
|
inverse: false,
|
|
test: {
|
|
description: 'test',
|
|
vars: {},
|
|
assert: [],
|
|
options: {},
|
|
},
|
|
assertionValueContext: {
|
|
prompt: 'test prompt',
|
|
vars: {},
|
|
test: {
|
|
description: 'test',
|
|
vars: {},
|
|
assert: [],
|
|
options: {},
|
|
},
|
|
logProbs: undefined,
|
|
// @ts-ignore
|
|
provider: createMockProvider({ response: {} }),
|
|
providerResponse: { output: 'completely different' },
|
|
},
|
|
output: 'completely different',
|
|
providerResponse: { output: 'completely different' },
|
|
});
|
|
|
|
expect(result.pass).toBe(false);
|
|
expect(result.reason).toBe('None of the provided values met the similarity threshold');
|
|
expect(result.score).toBe(0);
|
|
});
|
|
|
|
it('should report the best (highest) score when no array values meet threshold', async () => {
|
|
// Neither value passes, but the output is closer to the second one.
|
|
vi.mocked(matchesSimilarity)
|
|
.mockResolvedValueOnce({ pass: false, score: 0.2 } as any)
|
|
.mockResolvedValueOnce({ pass: false, score: 0.6 } as any);
|
|
|
|
const result = await handleSimilar({
|
|
assertion: {
|
|
type: 'similar',
|
|
value: ['far value', 'closer value'],
|
|
threshold: 0.9,
|
|
},
|
|
baseType: 'similar' as any,
|
|
renderedValue: ['far value', 'closer value'],
|
|
outputString: 'some output',
|
|
inverse: false,
|
|
test: {
|
|
description: 'test',
|
|
vars: {},
|
|
assert: [],
|
|
options: {},
|
|
},
|
|
assertionValueContext: {
|
|
prompt: 'test prompt',
|
|
vars: {},
|
|
test: {
|
|
description: 'test',
|
|
vars: {},
|
|
assert: [],
|
|
options: {},
|
|
},
|
|
logProbs: undefined,
|
|
// @ts-ignore
|
|
provider: createMockProvider({ response: {} }),
|
|
providerResponse: { output: 'some output' },
|
|
},
|
|
output: 'some output',
|
|
providerResponse: { output: 'some output' },
|
|
});
|
|
|
|
expect(result.pass).toBe(false);
|
|
// Report how close the output got to its closest value (0.6), not the worst (0.2).
|
|
expect(result.score).toBe(0.6);
|
|
});
|
|
|
|
it('should FAIL not-similar with an array when the output is similar to ANY value', async () => {
|
|
// not-similar means "dissimilar to ALL". The output is too similar to the
|
|
// first value, so the inverse check fails there and the assertion returns
|
|
// immediately — it must fail rather than continue and pass on a later value.
|
|
// Only the first value is evaluated, so we queue a single result (queuing a
|
|
// second would leak into the next test, which runs in random order).
|
|
vi.mocked(matchesSimilarity).mockResolvedValueOnce({
|
|
pass: false,
|
|
score: 0.05,
|
|
reason: 'too similar',
|
|
} as any);
|
|
|
|
const result = await handleSimilar({
|
|
assertion: {
|
|
type: 'similar',
|
|
value: ['forbidden answer A', 'unrelated answer B'],
|
|
threshold: 0.75,
|
|
},
|
|
baseType: 'similar' as any,
|
|
renderedValue: ['forbidden answer A', 'unrelated answer B'],
|
|
outputString: 'forbidden answer A',
|
|
inverse: true,
|
|
test: {
|
|
description: 'test',
|
|
vars: {},
|
|
assert: [],
|
|
options: {},
|
|
},
|
|
assertionValueContext: {
|
|
prompt: 'test prompt',
|
|
vars: {},
|
|
test: { description: 'test', vars: {}, assert: [], options: {} },
|
|
logProbs: undefined,
|
|
// @ts-ignore
|
|
provider: createMockProvider({ response: {} }),
|
|
providerResponse: { output: 'forbidden answer A' },
|
|
},
|
|
output: 'forbidden answer A',
|
|
providerResponse: { output: 'forbidden answer A' },
|
|
});
|
|
|
|
// Before the fix this returned pass: true (dissimilar to the second value
|
|
// short-circuited the loop). The output is identical to value A, so it must fail.
|
|
expect(result.pass).toBe(false);
|
|
expect(result.score).toBe(0.05);
|
|
});
|
|
|
|
it('should PASS not-similar with an array only when dissimilar to ALL values', async () => {
|
|
// Dissimilar to every value → not-similar passes; report the lowest score
|
|
// (the value it came closest to / tightest margin).
|
|
vi.mocked(matchesSimilarity)
|
|
.mockResolvedValueOnce({ pass: true, score: 0.8, reason: 'dissimilar' } as any)
|
|
.mockResolvedValueOnce({ pass: true, score: 0.95, reason: 'dissimilar' } as any);
|
|
|
|
const result = await handleSimilar({
|
|
assertion: {
|
|
type: 'similar',
|
|
value: ['forbidden answer A', 'forbidden answer B'],
|
|
threshold: 0.75,
|
|
},
|
|
baseType: 'similar' as any,
|
|
renderedValue: ['forbidden answer A', 'forbidden answer B'],
|
|
outputString: 'a totally different response',
|
|
inverse: true,
|
|
test: {
|
|
description: 'test',
|
|
vars: {},
|
|
assert: [],
|
|
options: {},
|
|
},
|
|
assertionValueContext: {
|
|
prompt: 'test prompt',
|
|
vars: {},
|
|
test: { description: 'test', vars: {}, assert: [], options: {} },
|
|
logProbs: undefined,
|
|
// @ts-ignore
|
|
provider: createMockProvider({ response: {} }),
|
|
providerResponse: { output: 'a totally different response' },
|
|
},
|
|
output: 'a totally different response',
|
|
providerResponse: { output: 'a totally different response' },
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
expect(result.score).toBe(0.8);
|
|
});
|
|
|
|
it('should report the closest (lowest) score across 3+ values when dissimilar to ALL (not-similar)', async () => {
|
|
// Dissimilar to all three; the lowest score is in the MIDDLE, proving the
|
|
// min-selection picks the tightest margin regardless of position (not just
|
|
// first/last). The full closest result (score + reason) is propagated.
|
|
vi.mocked(matchesSimilarity)
|
|
.mockResolvedValueOnce({ pass: true, score: 0.9, reason: 'dissimilar to A' } as any)
|
|
.mockResolvedValueOnce({ pass: true, score: 0.78, reason: 'closest to B' } as any)
|
|
.mockResolvedValueOnce({ pass: true, score: 0.95, reason: 'dissimilar to C' } as any);
|
|
|
|
const result = await handleSimilar({
|
|
assertion: { type: 'similar', value: ['A', 'B', 'C'], threshold: 0.75 },
|
|
renderedValue: ['A', 'B', 'C'],
|
|
outputString: 'a totally different response',
|
|
inverse: true,
|
|
test: { description: 'test', vars: {}, assert: [], options: {} },
|
|
} as any);
|
|
|
|
expect(result.pass).toBe(true);
|
|
expect(result.score).toBe(0.78);
|
|
expect(result.reason).toBe('closest to B');
|
|
});
|
|
|
|
it('should throw error for invalid renderedValue type', async () => {
|
|
await expect(
|
|
handleSimilar({
|
|
assertion: {
|
|
type: 'similar',
|
|
value: 'test',
|
|
},
|
|
baseType: 'similar' as any,
|
|
renderedValue: 123 as any,
|
|
outputString: 'test',
|
|
inverse: false,
|
|
test: {
|
|
description: 'test',
|
|
vars: {},
|
|
assert: [],
|
|
options: {},
|
|
},
|
|
assertionValueContext: {
|
|
prompt: 'test prompt',
|
|
vars: {},
|
|
test: {
|
|
description: 'test',
|
|
vars: {},
|
|
assert: [],
|
|
options: {},
|
|
},
|
|
logProbs: undefined,
|
|
// @ts-ignore
|
|
provider: createMockProvider({ response: {} }),
|
|
providerResponse: { output: 'test' },
|
|
},
|
|
output: 'test',
|
|
providerResponse: { output: 'test' },
|
|
}),
|
|
).rejects.toThrow('Similarity assertion type must have a string or array of strings value');
|
|
});
|
|
|
|
it('should throw error for empty array value', async () => {
|
|
await expect(
|
|
handleSimilar({
|
|
assertion: {
|
|
type: 'similar',
|
|
value: [],
|
|
},
|
|
baseType: 'similar' as any,
|
|
renderedValue: [],
|
|
outputString: 'test',
|
|
inverse: false,
|
|
test: {
|
|
description: 'test',
|
|
vars: {},
|
|
assert: [],
|
|
options: {},
|
|
},
|
|
assertionValueContext: {
|
|
prompt: 'test prompt',
|
|
vars: {},
|
|
test: {
|
|
description: 'test',
|
|
vars: {},
|
|
assert: [],
|
|
options: {},
|
|
},
|
|
logProbs: undefined,
|
|
// @ts-ignore
|
|
provider: createMockProvider({ response: {} }),
|
|
providerResponse: { output: 'test' },
|
|
},
|
|
output: 'test',
|
|
providerResponse: { output: 'test' },
|
|
}),
|
|
).rejects.toThrow('Similarity assertion must have at least one value to compare against');
|
|
});
|
|
|
|
it('should use dot_product metric when specified', async () => {
|
|
const mockMatchesSimilarity = vi.mocked(matchesSimilarity);
|
|
|
|
await handleSimilar({
|
|
assertion: {
|
|
type: 'similar:dot',
|
|
value: 'hello world',
|
|
},
|
|
baseType: 'similar' as any,
|
|
renderedValue: 'hello world',
|
|
outputString: 'hello world',
|
|
inverse: false,
|
|
test: {
|
|
description: 'test',
|
|
vars: {},
|
|
assert: [],
|
|
options: {},
|
|
},
|
|
assertionValueContext: {
|
|
prompt: 'test prompt',
|
|
vars: {},
|
|
test: {
|
|
description: 'test',
|
|
vars: {},
|
|
assert: [],
|
|
options: {},
|
|
},
|
|
logProbs: undefined,
|
|
// @ts-ignore
|
|
provider: createMockProvider({ response: {} }),
|
|
providerResponse: { output: 'hello world' },
|
|
},
|
|
output: 'hello world',
|
|
providerResponse: { output: 'hello world' },
|
|
});
|
|
|
|
// Verify that matchesSimilarity was called with dot_product metric
|
|
expect(mockMatchesSimilarity).toHaveBeenCalledWith(
|
|
'hello world',
|
|
'hello world',
|
|
expect.any(Number),
|
|
false,
|
|
expect.any(Object),
|
|
'dot_product',
|
|
);
|
|
});
|
|
|
|
it('should use euclidean metric when specified', async () => {
|
|
const mockMatchesSimilarity = vi.mocked(matchesSimilarity);
|
|
|
|
await handleSimilar({
|
|
assertion: {
|
|
type: 'similar:euclidean',
|
|
value: 'hello world',
|
|
},
|
|
baseType: 'similar' as any,
|
|
renderedValue: 'hello world',
|
|
outputString: 'hello world',
|
|
inverse: false,
|
|
test: {
|
|
description: 'test',
|
|
vars: {},
|
|
assert: [],
|
|
options: {},
|
|
},
|
|
assertionValueContext: {
|
|
prompt: 'test prompt',
|
|
vars: {},
|
|
test: {
|
|
description: 'test',
|
|
vars: {},
|
|
assert: [],
|
|
options: {},
|
|
},
|
|
logProbs: undefined,
|
|
// @ts-ignore
|
|
provider: createMockProvider({ response: {} }),
|
|
providerResponse: { output: 'hello world' },
|
|
},
|
|
output: 'hello world',
|
|
providerResponse: { output: 'hello world' },
|
|
});
|
|
|
|
// Verify that matchesSimilarity was called with euclidean metric
|
|
expect(mockMatchesSimilarity).toHaveBeenCalledWith(
|
|
'hello world',
|
|
'hello world',
|
|
expect.any(Number),
|
|
false,
|
|
expect.any(Object),
|
|
'euclidean',
|
|
);
|
|
});
|
|
|
|
it('should default to cosine metric when not specified', async () => {
|
|
const mockMatchesSimilarity = vi.mocked(matchesSimilarity);
|
|
|
|
await handleSimilar({
|
|
assertion: {
|
|
type: 'similar',
|
|
value: 'hello world',
|
|
},
|
|
baseType: 'similar' as any,
|
|
renderedValue: 'hello world',
|
|
outputString: 'hello world',
|
|
inverse: false,
|
|
test: {
|
|
description: 'test',
|
|
vars: {},
|
|
assert: [],
|
|
options: {},
|
|
},
|
|
assertionValueContext: {
|
|
prompt: 'test prompt',
|
|
vars: {},
|
|
test: {
|
|
description: 'test',
|
|
vars: {},
|
|
assert: [],
|
|
options: {},
|
|
},
|
|
logProbs: undefined,
|
|
// @ts-ignore
|
|
provider: createMockProvider({ response: {} }),
|
|
providerResponse: { output: 'hello world' },
|
|
},
|
|
output: 'hello world',
|
|
providerResponse: { output: 'hello world' },
|
|
});
|
|
|
|
// Verify that matchesSimilarity was called with cosine metric (default)
|
|
expect(mockMatchesSimilarity).toHaveBeenCalledWith(
|
|
'hello world',
|
|
'hello world',
|
|
expect.any(Number),
|
|
false,
|
|
expect.any(Object),
|
|
'cosine',
|
|
);
|
|
});
|
|
});
|