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
111 lines
6.0 KiB
JavaScript
111 lines
6.0 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { analyzePhoto, visionFailureResponse } from '../src/vision-service.js';
|
|
import { loadVisionCorpus } from '../src/vision-corpus.js';
|
|
import { CORPUS_MODEL, caseFor, corpusDir, corpusPayload, startProvider, startServer, stopServer } from './helpers/vision-harness.mjs';
|
|
|
|
const FAIL_CLOSED_COPY = /no suggestion was applied/i;
|
|
|
|
test('a malformed provider response fails closed with user-safe copy instead of echoing provider detail', () => {
|
|
const failure = visionFailureResponse(Object.assign(new Error('Invalid AI response: visual fields are out of range.'), { code: 'invalid_fields' }));
|
|
|
|
assert.equal(failure.status, 503);
|
|
assert.match(failure.body.error, FAIL_CLOSED_COPY);
|
|
assert.doesNotMatch(failure.body.error, /invalid ai response|out of range/i);
|
|
|
|
const consent = visionFailureResponse(new Error('Explicit consent is required before AI analysis.'));
|
|
assert.equal(consent.status, 400);
|
|
assert.match(consent.body.error, /consent/i);
|
|
|
|
const offline = visionFailureResponse(new Error('AI analysis is temporarily unavailable. Continue manually.'));
|
|
assert.equal(offline.status, 503);
|
|
assert.match(offline.body.error, /temporarily unavailable/i);
|
|
});
|
|
|
|
test('the corpus replays through the real provider call path without inventing a suggestion', async () => {
|
|
const corpus = await loadVisionCorpus(corpusDir);
|
|
for (const entry of corpus.cases) {
|
|
const fetchImpl = async () => ({ ok: true, json: async () => ({ choices: [{ message: { content: entry.providerContent } }] }) });
|
|
const options = {
|
|
payload: { imageDataUrl: 'data:image/png;base64,YQ==', consent: true },
|
|
fetchImpl,
|
|
config: { baseUrl: 'http://127.0.0.1:9/v1', apiKey: 'corpus-key', model: CORPUS_MODEL },
|
|
};
|
|
if (entry.expectedOutcome === 'suggestion') {
|
|
const result = await analyzePhoto(options);
|
|
assert.equal(result.status, 'suggestion', `${entry.id} must return a suggestion`);
|
|
assert.equal(result.imageQuality, 'good');
|
|
} else if (entry.expectedOutcome === 'abstained') {
|
|
const result = await analyzePhoto(options);
|
|
assert.equal(result.status, 'needs_user_input', `${entry.id} must abstain`);
|
|
assert.equal(result.abstainedFor, entry.expectedReason, `${entry.id} must name its abstention`);
|
|
assert.equal('bristolType' in result, false, `${entry.id} must not prefill a form`);
|
|
} else {
|
|
await assert.rejects(() => analyzePhoto(options), (error) => {
|
|
assert.equal(visionFailureResponse(error).status, 503, `${entry.id} must fail closed`);
|
|
return true;
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
test('the running server answers every corpus case with a fail-closed status and no leaked image bytes', async () => {
|
|
const corpus = await loadVisionCorpus(corpusDir);
|
|
const provider = await startProvider();
|
|
const server = await startServer({ providerPort: provider.port });
|
|
const postAnalyze = async (payload) => {
|
|
const response = await fetch('http://127.0.0.1:4199/api/analyze', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
return { status: response.status, body: await response.json() };
|
|
};
|
|
try {
|
|
const suggestion = caseFor(corpus, 'stool-like-formed');
|
|
provider.state.content = suggestion.providerContent;
|
|
const payload = await corpusPayload(suggestion);
|
|
const ok = await postAnalyze(payload);
|
|
assert.equal(ok.status, 200, 'a schema-valid suggestion is the only case that returns 200 with fields');
|
|
assert.equal(ok.body.bristolType, 4);
|
|
|
|
const glare = caseFor(corpus, 'glare-overexposed');
|
|
provider.state.content = glare.providerContent;
|
|
const abstained = await postAnalyze(await corpusPayload(glare));
|
|
assert.equal(abstained.status, 200, 'a schema-valid response with unreadable fields abstains, it is not a server failure');
|
|
assert.equal(abstained.body.status, 'needs_user_input');
|
|
assert.equal(abstained.body.abstainedFor, 'unusable_visual_fields');
|
|
assert.equal('bristolType' in abstained.body, false);
|
|
|
|
const malformed = caseFor(corpus, 'malformed-response');
|
|
provider.state.content = malformed.providerContent;
|
|
const unparsable = await postAnalyze(await corpusPayload(malformed));
|
|
assert.equal(unparsable.status, 503, 'unparsable provider content must not be served as a suggestion');
|
|
assert.match(unparsable.body.error, FAIL_CLOSED_COPY);
|
|
assert.doesNotMatch(JSON.stringify(unparsable.body), /base64|data:image|corpus-key|Bearer/i);
|
|
|
|
const outOfRangeCase = caseFor(corpus, 'out-of-range-fields');
|
|
provider.state.content = outOfRangeCase.providerContent;
|
|
const outOfRange = await postAnalyze(await corpusPayload(outOfRangeCase));
|
|
assert.equal(outOfRange.status, 503, 'out-of-range fields must not be served as a suggestion');
|
|
assert.match(outOfRange.body.error, FAIL_CLOSED_COPY);
|
|
|
|
// Issue #73: a filled-in field that is out of range fails closed even when the other field is blank,
|
|
// so the user is never told the photo was unreadable when the model returned an impossible value.
|
|
const blankAndOutOfRange = caseFor(corpus, 'blank-plus-out-of-range');
|
|
provider.state.content = blankAndOutOfRange.providerContent;
|
|
const blankOutOfRange = await postAnalyze(await corpusPayload(blankAndOutOfRange));
|
|
assert.equal(blankOutOfRange.status, 503, 'a filled-in out-of-range field must not be served as an abstention');
|
|
assert.match(blankOutOfRange.body.error, FAIL_CLOSED_COPY);
|
|
assert.equal('abstainedFor' in blankOutOfRange.body, false, 'a contract breach must not be reported as an abstention');
|
|
assert.doesNotMatch(JSON.stringify(blankOutOfRange.body), /base64|data:image|corpus-key|Bearer/i);
|
|
|
|
const noConsent = await postAnalyze({ imageDataUrl: payload.imageDataUrl, consent: false });
|
|
assert.equal(noConsent.status, 400, 'analysis without explicit consent is a client error');
|
|
assert.doesNotMatch(JSON.stringify(noConsent.body), /base64|data:image/i);
|
|
} finally {
|
|
await stopServer(server);
|
|
await provider.close();
|
|
}
|
|
});
|