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
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.
140 lines
5.7 KiB
TypeScript
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');
|
|
});
|
|
});
|