Quality gates / quality (pull_request) Successful in 13m29s
Confidence was the only thing standing between a model answer and a prefilled journal entry, so a confident reading of a blurry photo could still be offered as fact. Quality is now part of the policy: `poor` abstains however confident the reply looked, with its own fixed copy. The confidence floor, accepted quality levels, and refusal copy move into src/abstention-policy.js so the parser, the corpus, and the browser cannot drift into three explanations of the same refusal. parseVisionResponse takes an optional policy, which is how the tests prove the parser carries no hidden limits of its own. evaluatePrefillGate turns the #29 calibration report into one release decision: GO plus a validation-selected threshold scored on the held-out test split, or prefill is blocked with each reason named. A missing report is a block, never a default allow. scripts/abstention_policy_receipt.mjs exposes that as a receipt (exit 0 / 3 / 2). The committed synthetic predictions currently report BLOCKED and the receipt says so. No candidate exists until #31, and the builder does not hard-gate on the receipt yet; the on-camera quality gate is the evidence this release carries. The corpus gains a case that replays a 0.94-confidence answer about the synthetic motion-blur fixture, and the release demo asserts in the browser that nothing is prefilled before captioning it. Verified: 226 unit tests, calibration harness (19), corpus manifest (8), vision-refusal, photo-first, and provisional acceptance, check:syntax, check:diff, and a full real-browser demo recording whose caption receipt confirms the new scene painted on screen.
171 lines
8.2 KiB
JavaScript
171 lines
8.2 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { ABSTENTION_COPY, ABSTENTION_POLICY } from '../src/abstention-policy.js';
|
|
|
|
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.',
|
|
});
|
|
});
|
|
|
|
test('a confident answer about a low-quality photo still abstains', () => {
|
|
// Issue #32: confidence is not the only gate. A blurry or badly lit photo can produce a
|
|
// confident-looking form and colour, and prefilling it would show the user a claim the photo
|
|
// cannot support. Quality is part of the policy, so this abstains instead of prefilling.
|
|
assert.deepEqual(parseVisionResponse({
|
|
isStool: true,
|
|
bristolType: 4,
|
|
color: 'brown',
|
|
confidence: 0.94,
|
|
imageQuality: 'poor',
|
|
observations: 'Form barely visible through motion blur.',
|
|
}), {
|
|
status: 'needs_user_input',
|
|
isStool: true,
|
|
abstainedFor: 'low_quality',
|
|
reason: 'The photo is too blurry or poorly lit to suggest a form safely.',
|
|
});
|
|
});
|
|
|
|
test('the parse path reads the shipped policy instead of hardcoding its own limits', () => {
|
|
// Issue #32: policy numbers and copy live in one module. A caller may tighten the policy for a
|
|
// release without editing the parser, and a photo that the default policy would accept has to
|
|
// abstain under the stricter one — otherwise the parser is carrying its own hidden policy.
|
|
const fields = { isStool: true, bristolType: 4, color: 'brown', confidence: 0.8, imageQuality: 'fair', observations: 'Form visible but soft.' };
|
|
assert.equal(parseVisionResponse(fields).status, 'suggestion', 'fair quality may prefill under the shipped policy');
|
|
const strict = { ...ABSTENTION_POLICY, minConfidence: 0.9, acceptedImageQuality: ['good'] };
|
|
assert.equal(parseVisionResponse(fields, strict).abstainedFor, 'low_confidence');
|
|
assert.equal(parseVisionResponse({ ...fields, confidence: 0.95 }, strict).abstainedFor, 'low_quality');
|
|
assert.equal(parseVisionResponse({ ...fields, confidence: 0.95, imageQuality: 'good' }, strict).status, 'suggestion');
|
|
assert.equal(
|
|
parseVisionResponse({ ...fields, imageQuality: 'poor' }).reason,
|
|
ABSTENTION_COPY.low_quality,
|
|
'the refusal copy must come from the policy module, not a second copy in the parser',
|
|
);
|
|
});
|