Quality gates / quality (pull_request) Successful in 2m26s
The worker contract was only proven by one happy-path fixture and a handful of inline assertions, so nothing continuously checked that a reply Timmy cannot use is refused instead of guessed at. Corpus: a CC0 synthetic corpus (stool, non-stool, blur, glare, empty bowl, malformed response) generated deterministically by scripts/build_vision_corpus.py. Every fixture records its sha256, provenance, licence, case class, the provider reply it replays, and the outcome that reply must produce. loadVisionCorpus refuses a fixture whose bytes no longer match the manifest, so the corpus cannot be edited into a different test. No real medical imagery, camera, or third-party content is involved. Contract fixes the corpus exposed: - A schema-valid reply with a null form and colour is now an abstention (abstainedFor: unusable_visual_fields) instead of a hard provider failure. The declared response schema permits those nulls, so treating the reply as an error was wrong; it now abstains calmly and still prefill nothing. - Fail-closed replies carry a machine-readable code (invalid_json, invalid_fields) instead of prose matching, and visionFailureResponse() maps them to plain, user-safe copy with no provider text, image bytes, or credentials echoed. A running server now returns that copy for unparsable and out-of-range replies. Evidence: unit corpus contract and hash integrity, corpus-wide conformance through the real analysis path, the same corpus through a running server.mjs with a stub worker (200 abstain vs 503 fail-closed, no leaked bytes), and a real browser acceptance run for the refusal UI. The release demo replays three corpus cases through the real code path so the video shows the status the server would actually send, with the on-camera caption gated by the release builder.
43 lines
2.4 KiB
JavaScript
43 lines
2.4 KiB
JavaScript
import { buildVisionRequest, parseVisionResponse, validatePhotoPayload, VISION_FAILURE_CODES } from './analysis.js';
|
|
|
|
// One place decides what a failed analysis request returns, so the browser copy is testable and a
|
|
// provider's own words never reach the user.
|
|
const FAIL_CLOSED_COPY = 'No suggestion was applied. Continue manually, or try a clearer photo.';
|
|
const INPUT_REJECTION = /consent|JPEG|PNG|WebP|empty|too large/i;
|
|
|
|
export function visionFailureResponse(error) {
|
|
const message = String(error?.message || '');
|
|
if (INPUT_REJECTION.test(message)) return { status: 400, body: { error: message } };
|
|
const schemaFailure = error?.code === VISION_FAILURE_CODES.invalidJson || error?.code === VISION_FAILURE_CODES.invalidFields;
|
|
if (schemaFailure) return { status: 503, body: { error: FAIL_CLOSED_COPY } };
|
|
return { status: 503, body: { error: message || 'AI analysis is temporarily unavailable. Continue manually.' } };
|
|
}
|
|
|
|
function providerEndpoint(baseUrl) {
|
|
let url;
|
|
try { url = new URL(baseUrl); } catch { throw new Error('Invalid AI provider URL.'); }
|
|
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Invalid AI provider URL.');
|
|
return `${url.toString().replace(/\/$/, '')}/chat/completions`;
|
|
}
|
|
|
|
export async function analyzePhoto({ payload, fetchImpl = fetch, config }) {
|
|
const photo = validatePhotoPayload(payload);
|
|
if (!config?.model) throw new Error('AI analysis is not configured.');
|
|
const endpoint = providerEndpoint(config.baseUrl);
|
|
const response = await fetchImpl(endpoint, {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
authorization: `Bearer ${config.apiKey || 'local-proxy'}`,
|
|
},
|
|
body: JSON.stringify(buildVisionRequest({ imageDataUrl: photo.imageDataUrl, model: config.model })),
|
|
signal: AbortSignal.timeout(config.requestTimeoutMs || 60_000),
|
|
}).catch(() => { throw new Error('AI analysis is temporarily unavailable. Continue manually.'); });
|
|
if (!response.ok) throw new Error('AI analysis is temporarily unavailable. Continue manually.');
|
|
let data;
|
|
try { data = await response.json(); } catch { throw new Error('Invalid response from the AI provider.'); }
|
|
const content = data?.choices?.[0]?.message?.content;
|
|
if (typeof content !== 'string' && (typeof content !== 'object' || content === null)) throw new Error('Invalid response from the AI provider.');
|
|
return parseVisionResponse(content);
|
|
}
|