Files
0pt/tests/document-workbench/structured-output-validation.test.ts
admin 6c4f1998dc
CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 10s
CI (SIAX Cloud) / security (push) Successful in 20s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 20s
CI (SIAX Cloud) / security (pull_request) Successful in 21s
Security Scan (SIAX Cloud) / security (push) Successful in 28s
CI (SIAX Cloud) / integration (pull_request) Successful in 38s
CI (SIAX Cloud) / quality (push) Successful in 48s
CI (SIAX Cloud) / quality (pull_request) Successful in 47s
feat(workbench): locale-aware decimal/date evidence without an AI dependency
A source span now supports a decimal ('12 500,00' -> '12500.00') or a date
('14/10/2026' -> '2026-10-14') when the reading is unambiguous. Ambiguous
formats (01/02/2026, 1,234) still go to review. Evidence derivation scans the
source for typed candidates, so the local decision-server model only has to
return values, not UTF-16 offsets.
2026-11-02 17:39:31 +01:00

140 lines
5.7 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { validateStructuredOutput } from '../../src/document-workbench/structured-output-validation';
const sample = {
fields: [
{ name: 'name', type: 'string' as const, required: true },
{ name: 'count', type: 'integer' as const, required: true },
],
value: { name: 'Acme', count: 12 },
sources: [{ id: 'page-1', text: 'Name: Acme\nCount: 12' }],
evidence: [
{ field: 'name', sourceId: 'page-1', start: 6, end: 10, transform: 'exact' as const },
{ field: 'count', sourceId: 'page-1', start: 18, end: 20, transform: 'integer' as const },
],
};
describe('structured output mechanical validation', () => {
it('accepts typed fields with matching source ranges', () => {
expect(validateStructuredOutput(sample).status).toBe('mechanically_validated');
});
it('does not accept invented, mistyped or extra values', () => {
const result = validateStructuredOutput({ ...sample, value: { name: 'Other', count: '12', extra: 1 } });
expect(result).toMatchObject({
status: 'needs_review',
issues: expect.arrayContaining([
{ field: 'name', code: 'evidence_mismatch' },
{ field: 'count', code: 'invalid_type' },
{ field: 'extra', code: 'unexpected_field' },
]),
});
});
it('rejects absent, duplicate or out-of-range evidence and ambiguous sources', () => {
expect(validateStructuredOutput({ ...sample, evidence: [] }).status).toBe('needs_review');
expect(
validateStructuredOutput({ ...sample, evidence: [...sample.evidence, sample.evidence[0]!] }).status,
).toBe('needs_review');
expect(
validateStructuredOutput({
...sample,
evidence: [{ ...sample.evidence[0]!, end: 999 }, sample.evidence[1]!],
}).status,
).toBe('needs_review');
expect(
validateStructuredOutput({ ...sample, sources: [...sample.sources, sample.sources[0]!] }).status,
).toBe('needs_review');
});
it('refuses unsafe integers and absent required fields', () => {
expect(
validateStructuredOutput({ ...sample, value: { name: 'Acme', count: Number.MAX_SAFE_INTEGER + 1 } })
.status,
).toBe('needs_review');
expect(
validateStructuredOutput({ ...sample, value: { name: 'Acme' }, evidence: [sample.evidence[0]!] })
.status,
).toBe('needs_review');
});
it('preserves exact decimal precision and accepts real ISO calendar dates', () => {
const text = '9007199254740993.20 2024-02-29';
const input = {
fields: [
{ name: 'amount', type: 'decimal' as const, required: true },
{ name: 'date', type: 'date' as const, required: true },
],
value: { amount: '9007199254740993.20', date: '2024-02-29' },
sources: [{ id: 'p', text }],
evidence: [
{ field: 'amount', sourceId: 'p', start: 0, end: 19, transform: 'decimal' as const },
{ field: 'date', sourceId: 'p', start: 20, end: 30, transform: 'date' as const },
],
};
expect(validateStructuredOutput(input).status).toBe('mechanically_validated');
expect(
validateStructuredOutput({ ...input, value: { ...input.value, amount: 9007199254740994 } }).status,
).toBe('needs_review');
expect(
validateStructuredOutput({ ...input, value: { ...input.value, amount: '9007199254740993.2' } }).status,
).toBe('needs_review');
});
it.each(['2023-02-29', '1900-02-29', '2024-13-01', '2024-04-31', '0000-01-01'])(
'rejects invalid date %s even if present in the source',
(text) => {
expect(
validateStructuredOutput({
fields: [{ name: 'date', type: 'date', required: true }],
value: { date: text },
sources: [{ id: 'p', text }],
evidence: [{ field: 'date', sourceId: 'p', start: 0, end: text.length, transform: 'date' }],
}).status,
).toBe('needs_review');
},
);
it.each(['01.0', '1e3', '1,25', '+1.0', '.5'])(
'rejects ambiguous decimal %s rather than guessing',
(text) => {
expect(
validateStructuredOutput({
fields: [{ name: 'amount', type: 'decimal', required: true }],
value: { amount: text },
sources: [{ id: 'p', text }],
evidence: [{ field: 'amount', sourceId: 'p', start: 0, end: text.length, transform: 'decimal' }],
}).status,
).toBe('needs_review');
},
);
});
describe('locale-aware evidence (decimal + date)', () => {
const fields = [
{ name: 'amount', type: 'decimal' as const, required: true },
{ name: 'due', type: 'date' as const, required: true },
];
const sources = [{ id: 's1', text: 'Belopp: 12 500,00 SEK\nFörfallodatum: 14/10/2026' }];
const span = (needle: string) => {
const start = sources[0]!.text.indexOf(needle);
return { start, end: start + needle.length };
};
it('accepts an unambiguous locale amount and day-first date', () => {
const out = validateStructuredOutput({
fields,
sources,
value: { amount: '12500.00', due: '2026-10-14' },
evidence: [
{ field: 'amount', sourceId: 's1', ...span('12 500,00'), transform: 'decimal' },
{ field: 'due', sourceId: 's1', ...span('14/10/2026'), transform: 'date' },
],
});
expect(out.status).toBe('mechanically_validated');
});
it('refuses a date that is ambiguous between day-first and month-first', () => {
const ambiguous = [{ id: 's1', text: 'Datum: 01/02/2026' }];
const start = ambiguous[0]!.text.indexOf('01/02/2026');
const out = validateStructuredOutput({
fields: [{ name: 'due', type: 'date', required: true }],
sources: ambiguous,
value: { due: '2026-02-01' },
evidence: [{ field: 'due', sourceId: 's1', start, end: start + 10, transform: 'date' }],
});
expect(out.status).toBe('needs_review');
});
});