feat: add evidence-checked document exports #411
@@ -0,0 +1,52 @@
|
||||
import { validateStructuredOutput, type FieldEvidence, type OutputFieldContract, type TextSource, type ValidationIssue } from './structured-output-validation';
|
||||
|
||||
export type ValidatedExport =
|
||||
| { status: 'ready'; mediaType: 'application/json' | 'text/csv'; content: string }
|
||||
| { status: 'needs_review'; issues: ValidationIssue[] };
|
||||
|
||||
/**
|
||||
* Serialize a single, flat record after rechecking its evidence against the
|
||||
* supplied source text. This is an internal serializer, not an authorization,
|
||||
* completeness, semantic-correctness or source-authenticity check.
|
||||
* CSV rejects formula-like text rather than silently changing customer data;
|
||||
* JSON preserves it exactly. CSV also rejects absent optional fields so that
|
||||
* absence is never silently converted into an empty string.
|
||||
*/
|
||||
export function exportValidatedOutput(input: {
|
||||
format: 'json' | 'csv';
|
||||
fields: readonly OutputFieldContract[];
|
||||
value: unknown;
|
||||
sources: readonly TextSource[];
|
||||
evidence: readonly FieldEvidence[];
|
||||
}): ValidatedExport {
|
||||
if (input.format !== 'json' && input.format !== 'csv') {
|
||||
return { status: 'needs_review', issues: [{ field: '', code: 'unsupported_export_format' }] };
|
||||
}
|
||||
const validation = validateStructuredOutput(input);
|
||||
if (validation.status !== 'mechanically_validated') return validation;
|
||||
|
||||
const value = input.value as Record<string, string | number | boolean>;
|
||||
const ordered: Record<string, string | number | boolean> = Object.create(null) as Record<string, string | number | boolean>;
|
||||
for (const field of input.fields) {
|
||||
if (Object.prototype.hasOwnProperty.call(value, field.name)) ordered[field.name] = value[field.name]!;
|
||||
else if (input.format === 'csv') {
|
||||
return { status: 'needs_review', issues: [{ field: field.name, code: 'csv_missing_value' }] };
|
||||
}
|
||||
}
|
||||
if (input.format === 'json') {
|
||||
return { status: 'ready', mediaType: 'application/json', content: `${JSON.stringify(ordered, null, 2)}\n` };
|
||||
}
|
||||
|
||||
// Spreadsheet applications may interpret these prefixes as formulas even
|
||||
// in quoted CSV cells. Treat leading control characters as unsafe as well.
|
||||
const unsafe = (cell: string): boolean => /^[\t\r\n]/u.test(cell) || /^\s*[=+@-]/u.test(cell);
|
||||
for (const field of input.fields) {
|
||||
if (unsafe(field.name) || (field.type === 'string' && unsafe(ordered[field.name] as string))) {
|
||||
return { status: 'needs_review', issues: [{ field: field.name, code: 'csv_unsafe_cell' }] };
|
||||
}
|
||||
}
|
||||
const quote = (cell: string): string => `"${cell.replaceAll('"', '""')}"`;
|
||||
const header = input.fields.map((field) => quote(field.name)).join(',');
|
||||
const row = input.fields.map((field) => quote(String(ordered[field.name]))).join(',');
|
||||
return { status: 'ready', mediaType: 'text/csv', content: `${header}\r\n${row}\r\n` };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { exportValidatedOutput } from '../../src/document-workbench/export-validated-output';
|
||||
|
||||
const fields = [
|
||||
{ name: 'name', type: 'string' as const, required: true },
|
||||
{ name: 'count', type: 'integer' as const, required: true },
|
||||
];
|
||||
const sources = [{ id: 'source', text: 'Acme, "North"\n-2' }];
|
||||
const evidence = [
|
||||
{ field: 'name', sourceId: 'source', start: 0, end: 13, transform: 'exact' as const },
|
||||
{ field: 'count', sourceId: 'source', start: 14, end: 16, transform: 'integer' as const },
|
||||
];
|
||||
const input = { fields, sources, evidence, value: { name: 'Acme, "North"', count: -2 } };
|
||||
|
||||
describe('validated document exports', () => {
|
||||
it('preserves JSON values and produces correctly escaped, ordered CSV', () => {
|
||||
const json = exportValidatedOutput({ ...input, format: 'json' });
|
||||
expect(json.status).toBe('ready');
|
||||
if (json.status === 'ready') expect(JSON.parse(json.content)).toEqual(input.value);
|
||||
expect(exportValidatedOutput({ ...input, format: 'csv' })).toEqual({
|
||||
status: 'ready', mediaType: 'text/csv', content: '"name","count"\r\n"Acme, ""North""","-2"\r\n',
|
||||
});
|
||||
});
|
||||
|
||||
it('revalidates evidence and refuses unsupported or absent data', () => {
|
||||
expect(exportValidatedOutput({ ...input, value: { ...input.value, name: 'invented' }, format: 'json' }).status).toBe('needs_review');
|
||||
expect(exportValidatedOutput({ ...input, evidence: [], format: 'csv' }).status).toBe('needs_review');
|
||||
const optional = { ...input, fields: [...fields, { name: 'note', type: 'string' as const, required: false }] };
|
||||
expect(exportValidatedOutput({ ...optional, format: 'csv' })).toMatchObject({ status: 'needs_review', issues: [{ field: 'note', code: 'csv_missing_value' }] });
|
||||
expect(exportValidatedOutput({ ...optional, format: 'json' }).status).toBe('ready');
|
||||
expect(exportValidatedOutput({ ...input, format: 'xml' as 'json' })).toMatchObject({ status: 'needs_review', issues: [{ code: 'unsupported_export_format' }] });
|
||||
});
|
||||
|
||||
it('rejects formula-like CSV values and headers without changing JSON', () => {
|
||||
const bad = { fields: [{ name: 'cell', type: 'string' as const, required: true }], sources: [{ id: 's', text: ' =1+1' }], evidence: [{ field: 'cell', sourceId: 's', start: 0, end: 6, transform: 'exact' as const }], value: { cell: ' =1+1' } };
|
||||
expect(exportValidatedOutput({ ...bad, format: 'csv' })).toMatchObject({ status: 'needs_review', issues: [{ code: 'csv_unsafe_cell' }] });
|
||||
expect(exportValidatedOutput({ ...bad, format: 'json' }).status).toBe('ready');
|
||||
const header = { fields: [{ name: '=header', type: 'string' as const, required: true }], sources: [{ id: 's', text: 'safe' }], evidence: [{ field: '=header', sourceId: 's', start: 0, end: 4, transform: 'exact' as const }], value: { '=header': 'safe' } };
|
||||
expect(exportValidatedOutput({ ...header, format: 'csv' })).toMatchObject({ status: 'needs_review', issues: [{ code: 'csv_unsafe_cell' }] });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user