Merge pull request 'fix(workbench): tolerera platt/inkapslad JSON från lokal modell' (#442) from fix/model-output-tolerance into main
masterplan-lock / check (push) Successful in 5s
CI (SIAX Cloud) / security (push) Successful in 20s
Security Scan (SIAX Cloud) / security (push) Successful in 26s
CI (SIAX Cloud) / integration (push) Successful in 40s
CI (SIAX Cloud) / quality (push) Successful in 43s
CI (SIAX Cloud) / sonar (push) Successful in 1m5s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m20s
Deploy to Coolify / deploy (push) Successful in 1m38s

This commit was merged in pull request #442.
This commit is contained in:
2026-11-02 16:21:08 +00:00
2 changed files with 49 additions and 10 deletions
+35 -10
View File
@@ -133,31 +133,54 @@ export async function structureDocument(input: {
try { try {
parsed = JSON.parse(content); parsed = JSON.parse(content);
} catch { } catch {
return { status: 'needs_review', issues: [{ field: '', code: 'invalid_model_json' }], model }; // Small models often wrap JSON in a markdown fence or add prose.
const fenced = /```(?:json)?\s*([\s\S]*?)```/i.exec(content);
let inner = fenced ? fenced[1]!.trim() : '';
if (!inner) {
const start = content.indexOf('{');
const end = content.lastIndexOf('}');
inner = start >= 0 && end > start ? content.slice(start, end + 1) : '';
}
try {
parsed = inner ? JSON.parse(inner) : undefined;
} catch {
parsed = undefined;
}
if (parsed === undefined) {
return { status: 'needs_review', issues: [{ field: '', code: 'invalid_model_json' }], model };
}
} }
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return { status: 'needs_review', issues: [{ field: '', code: 'invalid_model_json' }], model }; return { status: 'needs_review', issues: [{ field: '', code: 'invalid_model_json' }], model };
} }
const candidate = parsed as { value?: unknown; evidence?: unknown }; const candidate = parsed as Record<string, unknown>;
const asObject = const fieldNames = new Set(snapshot.fields.map((field) => field.name));
candidate.value && typeof candidate.value === 'object' && !Array.isArray(candidate.value) // Accept the documented {value, evidence} envelope, a flat object of the
? (candidate.value as Record<string, unknown>) // requested fields, or {value: {...}} with extra prose keys.
: undefined; let asObject: Record<string, unknown> | undefined;
if (candidate.value && typeof candidate.value === 'object' && !Array.isArray(candidate.value)) {
asObject = candidate.value as Record<string, unknown>;
} else if (
Object.keys(candidate).length > 0 &&
Object.keys(candidate).every((key) => fieldNames.has(key))
) {
asObject = candidate;
}
if (!asObject) return { status: 'needs_review', issues: [{ field: '', code: 'invalid_output' }], model }; if (!asObject) return { status: 'needs_review', issues: [{ field: '', code: 'invalid_output' }], model };
// 1. Trust model-supplied evidence only if it validates as-is. // 1. Trust model-supplied evidence only if it validates as-is.
if (Array.isArray(candidate.evidence)) { if (Array.isArray((candidate as { evidence?: unknown }).evidence)) {
const supplied = validateStructuredOutput({ const supplied = validateStructuredOutput({
fields: snapshot.fields, fields: snapshot.fields,
sources: snapshot.sources, sources: snapshot.sources,
value: asObject, value: asObject,
evidence: candidate.evidence as FieldEvidence[], evidence: (candidate as { evidence?: unknown }).evidence as FieldEvidence[],
}); });
if (supplied.status === 'mechanically_validated') { if (supplied.status === 'mechanically_validated') {
return { return {
status: 'mechanically_validated', status: 'mechanically_validated',
value: asObject, value: asObject,
evidence: candidate.evidence as FieldEvidence[], evidence: (candidate as { evidence?: unknown }).evidence as FieldEvidence[],
model, model,
}; };
} }
@@ -169,7 +192,9 @@ export async function structureDocument(input: {
fields: snapshot.fields, fields: snapshot.fields,
sources: snapshot.sources, sources: snapshot.sources,
value: asObject, value: asObject,
evidence: Array.isArray(candidate.evidence) ? (candidate.evidence as FieldEvidence[]) : [], evidence: Array.isArray((candidate as { evidence?: unknown }).evidence)
? ((candidate as { evidence?: unknown }).evidence as FieldEvidence[])
: [],
}); });
return { status: 'needs_review', issues: checked.issues, model }; return { status: 'needs_review', issues: checked.issues, model };
} }
@@ -122,6 +122,20 @@ describe('structure document workflow', () => {
{ field: 'name', sourceId: 'page1', start: 6, end: 10, transform: 'exact' }, { field: 'name', sourceId: 'page1', start: 6, end: 10, transform: 'exact' },
]); ]);
}); });
it('accepts a flat object or a fenced JSON object from a small local model', async () => {
const flat = await structureDocument({
...base,
gateway: { complete: vi.fn(async () => ({ ...response, content: JSON.stringify({ name: 'Acme' }) })) },
});
expect(flat).toMatchObject({ status: 'mechanically_validated', value: { name: 'Acme' } });
const fenced = await structureDocument({
...base,
gateway: {
complete: vi.fn(async () => ({ ...response, content: '```json\n{"value":{"name":"Acme"}}\n```' })),
},
});
expect(fenced).toMatchObject({ status: 'mechanically_validated', value: { name: 'Acme' } });
});
it('refuses ambiguous or absent values when computing evidence itself', async () => { it('refuses ambiguous or absent values when computing evidence itself', async () => {
const twice = await structureDocument({ const twice = await structureDocument({
...base, ...base,