pediatric-ai-scribe-v3/test/image-fallback-chain.test.js
Daniel 03621752e8
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 55s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 2m5s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
feat: image fallback chains for every workflow, and a library worth looking at
**Fallbacks.** One image model meant a refusal, a rate limit or a model the
gateway had since dropped ended as a missing picture. Every workflow now tries
its model, then each fallback in order, stopping at the first that produces an
image. Primary plus two, capped: each hop is a paid request, and a chain long
enough to need a cap is long enough to surprise someone.

My Resources previously had no fallback at all — only the Clinical Assistant
did, and only one. That is backwards: a missing figure is most visible in a
deck, where it leaves a hole in a slide.

The retry rule is now a classifier that says *why*, rather than a boolean.
Transient faults, a 404 for a model the gateway does not have, and a content
refusal all move to the next model — a refusal because policy is a vendor
decision, not a fact about the request. 401/403 stop immediately (one gateway,
one set of credentials, the next model fails identically), as do 413 and any
other 4xx, which are malformed everywhere. Refusals are recognised from the
message: no provider sends a machine-readable reason and the status varies.

Each hop re-leases the job, so a chain cannot outlive its claim and let a second
worker repeat the same paid work, and the row records the model actually being
paid for so a picture made by the third model is not attributed to the first.

The old singular `fallback_image_model` is still read, so an existing
configuration keeps working without anyone re-entering it.

**Library.** Documents/Images tabs in My Resources, with a real grid: fixed
aspect tiles so the rows line up whatever shape the pictures are, a source badge
on the picture, two-line prompt, hover lift, shimmer skeletons while thumbnails
land, and a lightbox that closes on Escape or the backdrop and restores focus.
Actions are hidden on hover only behind `@media (hover:hover)` — hiding delete
behind :hover would put it out of reach on touch and keyboard.

Downloads go through privateImageBlob rather than a bare `<a download href>`: a
mobile client's session is a bearer token an anchor cannot send, and these
assets are served no-store on purpose.

The gallery lives in My Resources only. Assistant images appear in it, which was
the point; the assistant page does not grow a gallery of its own, and a test
asserts no assistant module lists the endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 14:53:04 +02:00

100 lines
5 KiB
JavaScript

// An image model that fails should hand the job to the next one — for every
// feature, not only the Clinical Assistant, and for the failures where another
// model genuinely has a chance.
const test = require('node:test');
const assert = require('node:assert/strict');
const { classifyImageFailure, isContentRefusal, MAX_IMAGE_MODELS,
shouldRetryImageFallback } = require('../src/utils/generatedImages');
const httpError = (status, body) => Object.assign(new Error(typeof body === 'string' ? body : 'request failed'),
{ response: { status, data: body } });
test('the provider saying "not now" is always worth another model', () => {
// 408 is Request Timeout, 429 is Too Many Requests. Both are a server saying
// not now, never not ever.
for (const status of [408, 429, 500, 502, 503, 504]) {
assert.equal(classifyImageFailure(httpError(status), null, false).retry, true, 'status ' + status);
}
// No response at all: a socket hang-up or a DNS failure.
assert.equal(classifyImageFailure(new Error('socket hang up'), null, false).retry, true);
});
test('bad credentials stop the chain, because every model shares them', () => {
for (const status of [401, 403]) {
const verdict = classifyImageFailure(httpError(status), null, false);
assert.equal(verdict.retry, false, 'status ' + status);
assert.match(verdict.reason, /credentials/);
}
});
test('a model the gateway does not have moves on rather than failing the job', () => {
// This is a configuration mistake, and the next model is exactly the thing
// that rescues it.
const verdict = classifyImageFailure(httpError(404), null, false);
assert.equal(verdict.retry, true);
assert.match(verdict.reason, /does not have that model/);
});
test('a content refusal tries the next model — policy is a vendor decision', () => {
const refusals = [
httpError(400, { error: { message: 'Your request was rejected by our safety system' } }),
httpError(400, 'content_policy_violation'),
httpError(422, { message: 'The prompt was flagged as sensitive' }),
httpError(400, { error: 'This request was blocked by moderation' })
];
for (const err of refusals) {
assert.equal(isContentRefusal(err), true, err.message);
assert.equal(classifyImageFailure(err, null, false).retry, true);
}
});
test('a malformed request is not retried, because it is malformed everywhere', () => {
const verdict = classifyImageFailure(httpError(400, { error: 'size must be one of 1024x1024' }), null, false);
assert.equal(verdict.retry, false);
assert.match(verdict.reason, /rejected the request/);
assert.equal(classifyImageFailure(httpError(413), null, false).retry, false, 'too large everywhere too');
});
test('a cancelled request and a shutdown never start more paid work', () => {
assert.equal(classifyImageFailure(new Error('x'), { aborted: true }, false).retry, false);
assert.equal(classifyImageFailure(Object.assign(new Error('x'), { name: 'AbortError' }), null, false).retry, false);
assert.equal(classifyImageFailure(httpError(503), null, true).retry, false, 'shutting down');
});
test('every workflow gets fallbacks now, not the Clinical Assistant alone', () => {
// A My Resources figure that failed used to have no second chance at all,
// which is the case where a missing picture is most visible — a slide with a
// hole in it.
for (const workflow of ['clinical_assistant', 'my_resources', 'learning_hub']) {
assert.equal(shouldRetryImageFallback({
workflow, fallback: 'model-b', jobModel: 'model-a', error: httpError(503)
}), true, workflow);
}
});
test('a fallback identical to the model that just failed is not a fallback', () => {
assert.equal(shouldRetryImageFallback({
workflow: 'my_resources', fallback: 'model-a', jobModel: 'model-a', error: httpError(503)
}), false);
assert.equal(shouldRetryImageFallback({
workflow: 'my_resources', fallback: '', jobModel: 'model-a', error: httpError(503)
}), false);
});
test('the chain is capped, because every hop is a paid request', () => {
assert.equal(MAX_IMAGE_MODELS, 3);
const lib = require('fs').readFileSync(require('path').join(__dirname, '..', 'src/utils/generatedImages.js'), 'utf8');
assert.match(lib, /chain\.slice\(0, MAX_IMAGE_MODELS\)/);
// Duplicates collapse: naming the primary again as a fallback would pay twice
// for the same refusal.
assert.match(lib, /if \(chain\.indexOf\(id\) === -1\) chain\.push\(id\)/);
});
test('each hop re-leases, and records which model is actually being paid for', () => {
const lib = require('fs').readFileSync(require('path').join(__dirname, '..', 'src/utils/generatedImages.js'), 'utf8');
const fn = lib.slice(lib.indexOf('async function generateWithFallback'));
assert.match(fn.slice(0, 1600), /lease_until=NOW\(\)\+interval '3 minutes'/,
'a chain must not outlive its claim and let a second worker repeat the work');
assert.match(fn.slice(0, 1600), /UPDATE generated_image_jobs SET model=\$3/,
'a picture made by the third model must not be attributed to the first');
});