Quality gates / quality (pull_request) Failing after 14s
The blank-first ordering let one blank field short-circuit the whole reply, so a response carrying an impossible value came back as `unusable_visual_fields`: the user was told the photo was unreadable when the model had in fact returned something Timmy must never apply. Blank fields still abstain and still prefill nothing. A filled-in field is a claim, and a claim outside the contract now fails closed even when the other field came back blank. The corpus earns its label too. `out-of-range-fields` drops from confidence 1.4 to 0.9 so the case reaches the field-range check instead of dying at the confidence gate, and a new `blank-plus-out-of-range` case covers the class the old ordering let through. The release demo replays that case on camera and the fail-closed 503 budget moves from one to two, so the scene cannot be dropped silently. Closes #73
134 lines
6.1 KiB
JavaScript
134 lines
6.1 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import {
|
|
buildVisionRequest,
|
|
mergeVisualSuggestion,
|
|
parseVisionResponse,
|
|
validatePhotoPayload,
|
|
} from '../src/analysis.js';
|
|
|
|
test('validates a confident visual suggestion without inventing nonvisual fields', () => {
|
|
const result = parseVisionResponse({
|
|
isStool: true,
|
|
bristolType: 4,
|
|
color: 'brown',
|
|
confidence: 0.82,
|
|
imageQuality: 'good',
|
|
observations: 'Smooth, formed appearance.',
|
|
urgency: 4,
|
|
discomfort: 3,
|
|
symptoms: { blood: true },
|
|
diagnosis: 'anything',
|
|
});
|
|
assert.deepEqual(result, {
|
|
status: 'suggestion',
|
|
isStool: true,
|
|
bristolType: 4,
|
|
color: 'brown',
|
|
confidence: 0.82,
|
|
imageQuality: 'good',
|
|
observations: 'Smooth, formed appearance.',
|
|
warning: 'Visual suggestion only. Confirm it yourself; this is not a diagnosis.',
|
|
});
|
|
assert.equal(result.urgency, undefined);
|
|
assert.equal(result.symptoms, undefined);
|
|
assert.equal(result.diagnosis, undefined);
|
|
});
|
|
|
|
test('fails closed on a non-stool or low-confidence image', () => {
|
|
// abstainedFor names which abstention happened, so callers never have to parse prose.
|
|
assert.deepEqual(parseVisionResponse({ isStool: false, confidence: 0.9 }), {
|
|
status: 'needs_user_input',
|
|
isStool: false,
|
|
abstainedFor: 'not_stool',
|
|
reason: 'The image does not clearly show stool.',
|
|
});
|
|
assert.deepEqual(parseVisionResponse({ isStool: true, bristolType: 4, color: 'brown', confidence: 0.31 }), {
|
|
status: 'needs_user_input',
|
|
isStool: true,
|
|
abstainedFor: 'low_confidence',
|
|
reason: 'The image is too uncertain to prefill safely.',
|
|
});
|
|
});
|
|
|
|
test('rejects malformed model output instead of guessing defaults', () => {
|
|
assert.throws(() => parseVisionResponse({ isStool: true, bristolType: 9, color: 'purple', confidence: 0.8 }), /invalid/i);
|
|
assert.throws(() => parseVisionResponse('not json'), /invalid/i);
|
|
});
|
|
|
|
test('merges only visual fields and preserves user-reported context', () => {
|
|
const form = { bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'user note', symptoms: { fever: true } };
|
|
const merged = mergeVisualSuggestion(form, { status: 'suggestion', bristolType: 4, color: 'brown', confidence: 0.8 });
|
|
assert.deepEqual(merged, { bristolType: 4, color: 'brown', urgency: 3, discomfort: 2, note: 'user note', symptoms: { fever: true } });
|
|
});
|
|
|
|
test('accepts bounded JPEG/PNG/WebP data URLs and rejects oversized or unsupported input', () => {
|
|
const payload = validatePhotoPayload({ imageDataUrl: 'data:image/jpeg;base64,' + 'YQ==', consent: true });
|
|
assert.equal(payload.mime, 'image/jpeg');
|
|
assert.equal(payload.bytes, 1);
|
|
assert.throws(() => validatePhotoPayload({ imageDataUrl: 'data:image/svg+xml;base64,PHN2Zz4=', consent: true }), /JPEG, PNG, or WebP/i);
|
|
assert.throws(() => validatePhotoPayload({ imageDataUrl: 'data:image/jpeg;base64,YQ==', consent: false }), /consent/i);
|
|
assert.throws(() => validatePhotoPayload({ imageDataUrl: 'data:image/jpeg;base64,' + 'A'.repeat(6_000_000), consent: true }), /too large/i);
|
|
});
|
|
|
|
test('builds a structured multimodal request that forbids diagnosis and nonvisual inference', () => {
|
|
const body = buildVisionRequest({ imageDataUrl: 'data:image/jpeg;base64,YQ==', model: 'vision-model' });
|
|
assert.equal(body.model, 'vision-model');
|
|
assert.equal(body.response_format.type, 'json_schema');
|
|
const prompt = body.messages[0].content.find(part => part.type === 'text').text;
|
|
assert.match(prompt, /do not infer urgency/i);
|
|
assert.match(prompt, /not a diagnosis/i);
|
|
assert.equal(body.messages[0].content.find(part => part.type === 'image_url').image_url.url, 'data:image/jpeg;base64,YQ==');
|
|
});
|
|
|
|
test('fails closed when a field the model did fill in is out of range while another field is blank', () => {
|
|
// Issue #73: the blank-first ordering let a blank field short-circuit the whole response, so a reply
|
|
// that carried an impossible value came back as `unusable_visual_fields` — telling the user the photo
|
|
// was unreadable when the model had in fact returned something Timmy must never apply. A blank field
|
|
// means "could not read it"; a filled-in field is a claim, and a claim outside the contract fails closed.
|
|
assert.throws(
|
|
() => parseVisionResponse({ isStool: true, bristolType: 9, color: null, confidence: 0.9, imageQuality: 'good', observations: 'Form read, colour not.' }),
|
|
(error) => {
|
|
assert.equal(error.code, 'invalid_fields', 'an out-of-range form next to a blank colour must fail closed');
|
|
return true;
|
|
},
|
|
);
|
|
assert.throws(
|
|
() => parseVisionResponse({ isStool: true, bristolType: null, color: 'purple', confidence: 0.9, imageQuality: 'good', observations: 'Colour read, form not.' }),
|
|
(error) => {
|
|
assert.equal(error.code, 'invalid_fields', 'an out-of-range colour next to a blank form must fail closed');
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
test('still abstains when the only filled-in visual field is inside the contract', () => {
|
|
// The complement of the rule above: a partial read that is entirely valid is an abstention, not a
|
|
// failure, so the glare path keeps returning 200 with nothing prefilled.
|
|
assert.deepEqual(parseVisionResponse({ isStool: true, bristolType: 4, color: null, confidence: 0.9, imageQuality: 'good', observations: 'Form read, colour not.' }), {
|
|
status: 'needs_user_input',
|
|
isStool: true,
|
|
abstainedFor: 'unusable_visual_fields',
|
|
reason: 'The photo does not show a form Timmy can read safely.',
|
|
});
|
|
});
|
|
|
|
test('abstains instead of failing when a schema-valid response carries no readable visual fields', () => {
|
|
// The declared JSON schema allows a null form and color whenever the model cannot read them,
|
|
// so a schema-valid response must come back as an abstention — never as a provider failure.
|
|
assert.deepEqual(parseVisionResponse({
|
|
isStool: true,
|
|
bristolType: null,
|
|
color: null,
|
|
confidence: 0.66,
|
|
imageQuality: 'poor',
|
|
observations: 'Large specular highlight hides the surface.',
|
|
}), {
|
|
status: 'needs_user_input',
|
|
isStool: true,
|
|
abstainedFor: 'unusable_visual_fields',
|
|
reason: 'The photo does not show a form Timmy can read safely.',
|
|
});
|
|
});
|