pediatric-ai-scribe-v3/test/my-resources-refine.test.js
Daniel e244ee5240
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 1m0s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m13s
Forgejo Docker Build / Build Docker image (push) Successful in 24s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
feat: modifying a deck can see it
The model editing a deck could not see it, which made most of what people
actually ask for unanswerable: "that slide is too crowded", "the diagram is in
the wrong place", "this one looks empty" are facts about the rendered page, not
about the JSON.

When a vision model is configured, modifying now renders the current deck —
with its figures, unlike the review pass, which runs while they are still being
drawn — and hands the model one image per slide alongside the JSON. Same
pipeline as review, reused rather than reimplemented: pptx, Gotenberg, PDF,
pdftoppm, capped at MAX_SLIDES.

The vision model then does the editing, which is a second and separately
measured benefit. On a real 20-slide deck, ds-deepseek-v4-flash returned the
deck unchanged for "make it better" — the echo reported yesterday — while
openrouter-gemini-3.8-flash applied it. So the stronger model fixes the echo
even without sight. A model the author picks explicitly still wins over both.

The result is rendered and reviewed again. Generation-only was the old rule, on
the reasoning that refining is a text edit; it is not. The edit is made against
how the deck looked before it, so a slide that gains two bullets only overflows
once it is rendered again. The reviewer may reposition but is held to the same
words, so a verification pass cannot quietly undo what was just asked for.

Whether an instruction achieved anything is judged on the model's edit, before
the reviewer runs, or a reviewer nudging a slide into two columns would mask an
instruction that did nothing.

Sight is an upgrade, never a dependency: no vision model, Gotenberg down, a
render that fails — each falls through to editing blind, and a test covers each
of those paths. Verified against two mutations: keeping the text model when
images are attached, and dropping the verification pass, each fail exactly one
test.

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

319 lines
16 KiB
JavaScript

// Behavioural cover for modifying a resource. The rest of my-resources.test.js
// reads the source; these run the handler, because the three faults they pin
// were all invisible to source reading — the code said the right thing and did
// the wrong one.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
// Normalised, because that is the shape the route stores. Comparing a
// hand-written deck with a parsed reply would compare normalisation, not content.
const deckBuild = require('../src/utils/deckBuild');
const DECK = Object.assign(
{ title: 'Croup', subtitle: 'Teaching deck', date: '2026-01-01' },
deckBuild.parse(JSON.stringify({
slides: [
{ type: 'title', title: 'Croup' },
{ type: 'bullets', title: 'Features', bullets: ['Barking cough', 'Stridor'] }
]
}))
);
function router(t, overrides = {}) {
const module = { exports: {} };
const aiCalls = [];
const replies = overrides.replies ? overrides.replies.slice() : null;
const reviewCalls = [];
const updates = [];
const row = Object.assign({
id: 5, user_id: 7, title: 'Croup', kind: 'presentation', topic: 'croup',
markdown: '# Croup\n\n- Barking cough\n- Stridor\n', deck: JSON.stringify(DECK)
}, overrides.row || {});
const quiet = { log() {}, error() {}, warn() {}, info() {} };
const mocks = {
express: require('express'),
os: require('os'),
path: require('path'),
'fs/promises': require('fs/promises'),
'../db/database': {
get: async (sql, params) => {
if (/^UPDATE user_resources/.test(sql)) {
updates.push({ sql, params });
return { id: row.id, title: row.title, updated_at: '2026-01-02T00:00:00Z' };
}
return row;
},
all: async () => [],
run: async () => ({}),
getSetting: async key => {
if (key === 'clinical_assistant.image_model') return 'synthetic-image';
if (key === 'my_resources.review_model') return overrides.visionModel || '';
return '';
}
},
'../middleware/auth': { authMiddleware: (req, res, next) => next() },
'../utils/ai': {
callAI: async (messages, options) => {
aiCalls.push({ messages, options });
// A scripted sequence when the test needs the model to answer
// differently on each attempt; the last reply repeats if it runs out.
const content = replies ? (replies.length > 1 ? replies.shift() : replies[0]) : overrides.reply;
return { content: content, model: 'synthetic' };
},
discoverModels: async () => []
},
'../utils/deckBuild': deckBuild,
'../utils/deckSchema': require('../src/utils/deckSchema'),
'../utils/deckReview': {
slideImages: async () => (overrides.slideImages || []),
review: async (deck) => { reviewCalls.push(deck); return { deck: deck, reviewed: true, reason: 'ok' }; },
MAX_SLIDES: 20
},
'../utils/documentExport': { FORMATS: { pptx: { mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' } }, GOTENBERG: 'http://gotenberg:3000', isSupported: () => false, filename: () => 'x', mimeFor: () => '', render: async () => ({}), renderDeck: async () => Buffer.from('pptx') },
'../utils/generatedImages': { service: () => ({ get: async () => null, asset: async () => null }), workflows: ['my_resources'] },
'../utils/learningRetrieval': { retrieve: async () => ({ context: '', sources: [], reason: null }) },
'../utils/logger': quiet,
'../utils/metrics': { resourceRefines: { inc() {} }, resourceVocabularyGaps: { inc() {} } },
'../utils/pubmedSearch': { isAvailable: async () => false, search: async () => ({ results: [] }), formatForPrompt: () => '' },
'../utils/webSearch': { isAvailable: async () => false, search: async () => ({ results: [] }), formatForPrompt: () => '' },
'../utils/resourceImages': { tools: [{ name: 'draw' }], dispatch: async x => x, guidance: () => 'GUIDANCE.', requestedCount: () => 0, MAX_IMAGES: 6 }
};
vm.runInNewContext(read('src/routes/myResources.js'), {
module, exports: module.exports, console: quiet, Buffer, JSON, Date, Math,
process: { env: {} }, setTimeout() {},
require(name) { assert.ok(Object.hasOwn(mocks, name), 'Unexpected import: ' + name); return mocks[name]; }
});
async function request(method, routePath, body) {
const layer = module.exports.stack.find(l => l.route && l.route.path === routePath && l.route.methods[method]);
assert.ok(layer, 'route missing: ' + method + ' ' + routePath);
const stack = layer.route.stack;
const handler = stack[stack.length - 1].handle;
const res = {
statusCode: 200, body: null,
status(code) { this.statusCode = code; return this; },
json(payload) { this.body = payload; return this; },
setHeader() {}, send() { return this; }
};
await handler({ body, params: { id: '5' }, query: {}, user: { id: 7 } }, res);
return res;
}
return { request, aiCalls, updates, reviewCalls };
}
test('modifying a deck asks for a deck, even when illustration is on', async () => {
// The fault: existingDeck was declared below the branch that read it, so var
// hoisting made it undefined there. Illustration on a deck therefore appended
// the markdown instruction — "Returning the markdown is still required" —
// to a prompt whose body asked for deck JSON. The model was told to produce
// two different artifacts at once and the reply parsed as neither, so every
// modification of a presentation with illustration ticked failed with 502.
const r = router(null, {
reply: JSON.stringify({ slides: [DECK.slides[0], { type: 'bullets', title: 'Features', bullets: ['Barking cough', 'Stridor', 'Hoarse voice'] }] })
});
const res = await r.request('post', '/my-resources/:id/refine', {
instructions: 'add a third feature', withImages: 'true'
});
assert.equal(res.statusCode, 200, 'a deck modification with images on must not 502');
const prompt = r.aiCalls[0].messages[0].content;
assert.match(prompt, /Revise the following slide deck/);
assert.match(prompt, /Add "image_prompt" to the slides/, 'the deck illustration instruction');
assert.doesNotMatch(prompt, /Returning the markdown is still required/,
'the markdown instruction must never reach a deck prompt');
assert.doesNotMatch(prompt, /An illustration tool is available/,
'a deck declares its figures; it is not given the tool');
assert.equal(r.aiCalls[0].options.tools, undefined, 'and no tool schema is attached');
});
test('a modification the model returned unchanged says so instead of claiming success', async () => {
// Answering "Applied" for a reply identical to the original sent people off
// to download the same file and conclude the feature was broken. It was
// logged server-side, where the person who could reword the instruction
// could not see it.
const r = router(null, { reply: JSON.stringify({ slides: DECK.slides }) });
const res = await r.request('post', '/my-resources/:id/refine', { instructions: 'make it better' });
assert.equal(res.statusCode, 200);
assert.equal(res.body.unchanged, true, 'the caller is told the deck came back identical');
});
test('a modification that did change the deck reports itself as changed', async () => {
const r = router(null, {
reply: JSON.stringify({ slides: [DECK.slides[0], { type: 'bullets', title: 'Features', bullets: ['Barking cough', 'Stridor', 'Hoarse voice'] }] })
});
const res = await r.request('post', '/my-resources/:id/refine', { instructions: 'add a third feature' });
assert.equal(res.body.unchanged, false);
assert.equal(r.updates.length, 1, 'and the row is written');
assert.match(r.updates[0].sql, /updated_at = NOW\(\)/);
});
test('a presentation with no deck still modifies, through the markdown path', async () => {
const r = router(null, { row: { deck: null }, reply: '# Croup\n\n- Barking cough\n- Stridor\n- Hoarse voice\n' });
const res = await r.request('post', '/my-resources/:id/refine', {
instructions: 'add a third feature', withImages: 'true'
});
assert.equal(res.statusCode, 200);
assert.equal(res.body.unchanged, false);
const prompt = r.aiCalls[0].messages[0].content;
assert.match(prompt, /Revise the following Pandoc markdown/);
assert.match(prompt, /An illustration tool is available/, 'markdown has nowhere to declare a figure');
});
test('the library says which presentations carry a deck, and the row shows when it was modified', () => {
// Both halves of "modify does nothing": the list read created_at, so a
// modification that did apply left the visible timestamp untouched, and
// nothing distinguished a real deck from a flat one.
const route = read('src/routes/myResources.js');
assert.match(route, /AS has_deck/);
const ui = read('public/js/myResources.js');
assert.match(ui, /row\.updated_at \? new Date\(row\.updated_at\)/);
assert.match(ui, /edited \? 'modified '/);
assert.match(ui, /has_deck === false \? ' · plain text, no slide layout' : ''/);
assert.match(ui, /if \(data\.unchanged\) \{/);
});
// ── Generating a deck ──────────────────────────────────────────────────────
const GOOD_DECK = JSON.stringify({
slides: [
{ type: 'title', title: 'Croup' },
{ type: 'bullets', title: 'Features', bullets: ['Barking cough', 'Stridor'] }
]
});
test('a deck the model fumbles once is asked for a second time, not abandoned', async () => {
// Falling straight back to markdown after one unlucky reply produced a
// materially worse artifact: plain slides inferred from markdown instead of
// the layouts the model chose. Measured on the stored library, this happened
// once in eight generations.
const r = router(null, { replies: ['Sorry, I cannot do that.', GOOD_DECK] });
const res = await r.request('post', '/my-resources/generate', {
topic: 'croup', kind: 'presentation'
});
assert.equal(res.statusCode, 200);
assert.equal(r.aiCalls.length, 2, 'the deck is asked for twice before giving up');
assert.match(r.aiCalls[1].messages[0].content, /teaching presentation/,
'the retry is the same deck prompt, not the weaker markdown one');
assert.equal(res.body.deckFallback, null, 'and the retry succeeded, so nothing fell back');
});
test('a deck that fails twice falls back to markdown and says why', async () => {
const r = router(null, { replies: ['Sorry, I cannot help with that.', 'I am unable to comply.', '# Croup\n\n- Barking cough\n'] });
const res = await r.request('post', '/my-resources/generate', {
topic: 'croup', kind: 'presentation'
});
assert.equal(res.statusCode, 200);
assert.equal(r.aiCalls.length, 3, 'two deck attempts, then markdown');
assert.equal(res.body.deckFallback, 'the reply was not a deck',
'the caller is told it came out plain, and why');
});
test('a truncated deck reply is named as truncated, not as the wrong shape', async () => {
// The three causes want different fixes — a smaller deck, a different model,
// a reworded topic — so the message distinguishes them. Truncation is only
// claimed for a reply that began as JSON: an apology in prose does not end in
// "}" either, and naming that "cut short" points at the wrong fix.
const cut = GOOD_DECK.slice(0, GOOD_DECK.length - 30);
const r = router(null, { replies: [cut, cut, '# Croup\n'] });
const res = await r.request('post', '/my-resources/generate', { topic: 'croup', kind: 'presentation' });
assert.match(res.body.deckFallback, /cut short at \d+ characters/);
});
test('a deck that parses first time is never asked for twice', async () => {
const r = router(null, { replies: [GOOD_DECK] });
const res = await r.request('post', '/my-resources/generate', { topic: 'croup', kind: 'presentation' });
assert.equal(res.statusCode, 200);
assert.equal(r.aiCalls.length, 1, 'the retry costs a call and must only happen on failure');
assert.equal(res.body.deckFallback, null);
});
// ── Modifying with sight ───────────────────────────────────────────────────
const TWO_PNGS = [
{ mimeType: 'image/png', dataBase64: 'aW1hZ2Ux' },
{ mimeType: 'image/png', dataBase64: 'aW1hZ2Uy' }
];
const EDITED = JSON.stringify({
slides: [
{ type: 'title', title: 'Croup' },
{ type: 'bullets', title: 'Features', bullets: ['Barking cough', 'Stridor', 'Hoarse voice'] }
]
});
test('modifying a deck shows the model what the deck currently looks like', async () => {
// Most of what people ask for while modifying is about the rendered page —
// "that slide is crowded", "the diagram is in the wrong place" — and none of
// it is answerable from the JSON alone.
const r = router(null, { visionModel: 'seeing-model', slideImages: TWO_PNGS, replies: [EDITED] });
const res = await r.request('post', '/my-resources/:id/refine', {
instructions: 'slide 2 looks crowded, split it'
});
assert.equal(res.statusCode, 200);
assert.equal(r.aiCalls[0].options.images, TWO_PNGS, 'the rendered slides are attached');
assert.equal(r.aiCalls[0].options.model, 'seeing-model', 'and the model that can see does the editing');
assert.match(r.aiCalls[0].messages[0].content, /You can see the deck as it renders now: 2 images/);
assert.match(r.aiCalls[0].messages[0].content, /first image is slide index 0/,
'the images must be anchored to the JSON or a slide reference means nothing');
assert.equal(res.body.saw, 2);
});
test('the edited deck is rendered again and checked', async () => {
// The edit was made against how the deck looked *before* it. A slide that
// gained two bullets only overflows once it is rendered again.
const r = router(null, { visionModel: 'seeing-model', slideImages: TWO_PNGS, replies: [EDITED] });
const res = await r.request('post', '/my-resources/:id/refine', { instructions: 'add a feature' });
assert.equal(r.reviewCalls.length, 1, 'the result goes back past the reviewer');
assert.equal(res.body.review.applied, true);
});
test('with no vision model configured, modify still works and never renders', async () => {
// Sight is an upgrade, not a dependency. Nothing here may become a new way
// for a modification to fail.
const r = router(null, { replies: [EDITED] });
const res = await r.request('post', '/my-resources/:id/refine', { instructions: 'add a feature' });
assert.equal(res.statusCode, 200);
assert.equal(res.body.saw, 0);
assert.equal(r.aiCalls[0].options.images, undefined);
assert.equal(r.reviewCalls.length, 0, 'and nothing is rendered or reviewed');
});
test('a render that fails falls through to editing blind rather than failing', async () => {
// Gotenberg down, LibreOffice wedged, a deck too big: none of them may cost
// the author their modification.
const r = router(null, { visionModel: 'seeing-model', slideImages: [], replies: [EDITED] });
const res = await r.request('post', '/my-resources/:id/refine', { instructions: 'add a feature' });
assert.equal(res.statusCode, 200);
assert.equal(res.body.saw, 0);
assert.equal(r.aiCalls[0].options.images, undefined);
assert.notEqual(r.aiCalls[0].options.model, 'seeing-model',
'no images means no reason to pay for the vision model');
});
test('an echo is still reported as an echo, even when the reviewer moved something', async () => {
// The reviewer may reposition, which would otherwise make an instruction that
// achieved nothing look like it had worked.
const r = router(null, { visionModel: 'seeing-model', slideImages: TWO_PNGS,
replies: [JSON.stringify({ slides: DECK.slides })] });
const res = await r.request('post', '/my-resources/:id/refine', { instructions: 'make it better' });
assert.equal(res.body.unchanged, true, 'judged on the model edit, before the reviewer ran');
});