pediatric-ai-scribe-v3/test/deck-review.test.js
Daniel d2a06b0fcf feat: modifying a resource is a job, the same as generating one
Modify held the request open for a library search, a PubMed search, a web
search and a restating model call. That is minutes, and a browser gives up
first — Firefox abandons a non-streaming fetch at five minutes, the same
failure generating was moved off the request to fix in ef574edd. The server
carried on and saved the result while the person watched an error, and closing
the tab killed the work outright.

POST /my-resources/:id/refine now records the request and answers 202 with the
job, exactly as /generate does. The writing moved into refineResource(), which
the job runner dispatches to by kind; the job list, the five-second polling,
the restart recovery and the three-in-flight cap are all the work they already
did, unchanged. Ownership is checked again inside refineResource because the
resource can be deleted while the job waits.

The page follows the job instead of the response. Reporting is unchanged — the
unchanged reply, what was seen and what was searched — it is only said from the
job list now, so it still reaches the person who asked for it after a reload.
2026-09-16 23:28:42 +02:00

198 lines
11 KiB
JavaScript

// ============================================================
// DECK REVIEW
// ============================================================
// The model that writes a deck never sees it, so overflow, a figure on the
// wrong slide and a nine-item list that wants two columns are invisible to it.
// The review renders the deck and looks.
//
// The thing that has to hold: it may move content, never change it.
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const review = require('../src/utils/deckReview');
const read = p => fs.readFileSync(path.join(__dirname, '..', p), 'utf8');
const BEFORE = { slides: [
{ type: 'bullets', heading: 'Management', bullets: [{ text: 'One' }, { text: 'Two' }, { text: 'Three' }, { text: 'Four' }] },
{ type: 'figure', heading: 'Anatomy', bullets: [{ text: 'Five' }], image_job: 'job-a' },
]};
const verdict = after => review.movedOnly(review.fingerprint(BEFORE), review.fingerprint(after));
test('a review may move content between slides and layouts', () => {
assert.equal(verdict(BEFORE), null, 'unchanged');
// Splitting an overfull slide is the most valuable fix there is, and it
// necessarily repeats the heading.
assert.equal(verdict({ slides: [
{ type: 'bullets', heading: 'Management', bullets: [{ text: 'One' }, { text: 'Two' }] },
{ type: 'bullets', heading: 'Management (continued)', bullets: [{ text: 'Three' }, { text: 'Four' }] },
{ type: 'figure', heading: 'Anatomy', bullets: [{ text: 'Five' }], image_job: 'job-a' },
]}), null, 'split into two');
// Regrouping into labelled columns moves text between fields; the words stay.
// Column labels are structure, not content, so they may be new.
assert.equal(verdict({ slides: [
{ type: 'compare', heading: 'Management', columns: [
{ label: 'EARLY', bullets: [{ text: 'One' }, { text: 'Two' }] },
{ label: 'LATE', bullets: [{ text: 'Three' }, { text: 'Four' }] }] },
{ type: 'figure', heading: 'Anatomy', bullets: [{ text: 'Five' }], image_job: 'job-a' },
]}), null, 'bullets to a comparison');
// Reordering slides.
assert.equal(verdict({ slides: [BEFORE.slides[1], BEFORE.slides[0]] }), null, 'reordered');
});
test('a review that changes the words is discarded', () => {
// A model told not to rewrite will still occasionally improve a sentence, and
// a silent edit to clinical text is the one thing this must never introduce.
assert.match(verdict({ slides: [
{ type: 'bullets', heading: 'Management', bullets: [{ text: 'One thing' }, { text: 'Two' }, { text: 'Three' }, { text: 'Four' }] },
BEFORE.slides[1],
]}), /wording changed/);
// Dropping a bullet is a change too, not a layout decision.
assert.match(verdict({ slides: [
{ type: 'bullets', heading: 'Management', bullets: [{ text: 'One' }, { text: 'Two' }] },
BEFORE.slides[1],
]}), /wording changed/);
// Figures are drawn already; a review places them but cannot invent or lose one.
assert.match(verdict({ slides: [BEFORE.slides[0],
{ type: 'bullets', heading: 'Anatomy', bullets: [{ text: 'Five' }] }] }), /figures changed/);
// A heading may be reused or extended, never invented.
assert.match(verdict({ slides: [
{ type: 'bullets', heading: 'Key points', bullets: [{ text: 'One' }, { text: 'Two' }, { text: 'Three' }, { text: 'Four' }] },
BEFORE.slides[1],
]}), /heading was invented/);
});
test('the reviewer names slides, and the server moves the text', () => {
// Asking for the whole deck back put the reply in proportion to the deck
// rather than to the number of problems: a fourteen-slide deck came back cut
// off mid-object every time. A patch is small, and the words never pass
// through the model at all — which is a stronger guarantee than asking it not
// to rewrite them and checking afterwards.
const deck = { title: 'T', slides: [
{ type: 'bullets', heading: 'Management', bullets: [{ text: 'A' }, { text: 'B' }, { text: 'C' }, { text: 'D' }, { text: 'E' }, { text: 'F' }] },
{ type: 'figure', heading: 'Anatomy', bullets: [{ text: 'K' }], image_job: 'job-a' },
]};
const intact = out => review.movedOnly(review.fingerprint(deck), review.fingerprint(out.deck));
const two = review.applyChanges(deck, [{ slide: 0, action: 'two' }]);
assert.equal(two.applied, 1);
assert.equal(two.deck.slides[0].type, 'two');
assert.equal(intact(two), null);
const split = review.applyChanges(deck, [{ slide: 0, action: 'split', after: 2, heading: 'Management (continued)' }]);
assert.equal(split.deck.slides.length, 3);
assert.equal(intact(split), null);
const compare = review.applyChanges(deck, [{ slide: 0, action: 'compare', at: 3, labels: ['EARLY', 'LATE'] }]);
assert.equal(compare.deck.slides[0].columns.length, 2);
assert.equal(intact(compare), null);
// A continuation heading is the reviewer's only piece of text, so it is
// replaced rather than trusted when it does not continue anything.
const invented = review.applyChanges(deck, [{ slide: 0, action: 'split', after: 2, heading: 'Totally New' }]);
assert.equal(invented.deck.slides[1].heading, 'Management (continued)');
assert.equal(intact(invented), null);
// Nonsense is ignored rather than applied badly.
for (const bad of [[{ slide: 9, action: 'two' }],
[{ slide: 0, action: 'split', after: 5 }],
[{ slide: 0, action: 'compare', at: 3, labels: ['only'] }],
[{ slide: 0, action: 'nonsense' }]]) {
assert.equal(review.applyChanges(deck, bad).applied, 0, JSON.stringify(bad));
}
// Two splits at once must not renumber each other.
const both = review.applyChanges(deck, [{ slide: 0, action: 'split', after: 1 }, { slide: 1, action: 'two' }]);
assert.equal(intact(both), null);
});
test('nothing about the review can fail a generation', async () => {
const src = read('src/utils/deckReview.js');
// Off unless an administrator names a reviewer.
const off = await review.review(BEFORE, { model: '' });
assert.equal(off.reviewed, false);
assert.equal(off.deck, BEFORE, 'the deck comes back untouched');
// A reviewer that throws, returns nothing, or returns prose gives the deck back.
const broken = await review.review(BEFORE, {
model: 'some-model', gotenberg: 'http://127.0.0.1:1', mime: 'application/x',
pptx: Buffer.from(''), extractJson: () => null,
callAI: async () => { throw new Error('provider unreachable'); },
});
assert.equal(broken.reviewed, false);
assert.equal(broken.deck, BEFORE);
assert.match(src, /catch \(err\) \{\s*\n\s*console\.warn\('\[deck-review\] skipped:'/);
// A deck longer than the pass can look at is left alone rather than truncated.
const long = await review.review({ slides: new Array(review.MAX_SLIDES + 1).fill({ type: 'section', heading: 'x' }) },
{ model: 'some-model' });
assert.equal(long.reviewed, false);
assert.match(long.reason, /too long/);
});
test('the reviewer is admin-chosen, off by default, and runs once per change', () => {
const route = read('src/routes/myResources.js');
assert.match(route, /db\.getSetting\('my_resources\.review_model', ''\)/);
assert.match(route, /if \(reviewModel\) \{/, 'nothing happens without one');
// Modifying reviews too, which generation-only used to forbid. The reasoning
// changed with the evidence: an edit is made against how the deck looked
// *before* it, so a slide that gains two bullets only overflows once it is
// rendered again — exactly the class of fault the reviewer exists for. The
// old rule assumed refining was a text edit; it is a layout edit as often as
// not.
const refine = route.slice(route.indexOf("async function refineResource("));
assert.match(refine, /deckReview\.review\(revisedDeck/);
assert.match(refine, /if \(revisedDeck && visionModel\)/, 'and only when one is configured');
// Still one pass. The verification runs on the result, never in a loop.
assert.equal((refine.match(/deckReview\.review\(/g) || []).length, 1);
// The key has to be writable, or saving it silently does nothing.
assert.match(read('src/routes/adminConfig.js'), /'clinical_assistant\.', 'my_resources\.', 'nextcloud\.'\]/);
// And the image can actually rasterise a deck.
assert.match(read('Dockerfile'), /poppler-utils/);
assert.match(read('src/utils/deckReview.js'), /'pdftoppm', \['-png', '-r', String\(RENDER_DPI\)/);
});
test('the review is asked without thinking when the caller says so', () => {
// One real deck: the writing reasoned its 16,000-token budget away and
// returned nothing, and the reviews of it did the same on four 2,000-token
// budgets. Thinking off is DeepSeek's own field, sent by the model wrapper,
// so all this file has to do is carry the caller's rule to the model call —
// and a task that does want reasoning must still be able to ask for it.
const src = read('src/utils/deckReview.js');
const call = src.slice(src.indexOf('var reviewOptions = {'), src.indexOf('// A list of changes'));
assert.match(call, /if \(options\.reasoningEffort\) reviewOptions\.reasoningEffort = options\.reasoningEffort;/);
assert.match(call, /options\.callAI\(\s*\[[\s\S]*?\],\s*reviewOptions\s*\)/, 'the rule reaches the model call');
const route = read('src/routes/myResources.js');
const gen = route.slice(route.indexOf('async function generateResource('), route.indexOf('async function runResourceJob('));
assert.match(gen, /reasoningEffort: options\.reasoningEffort,/, 'generation\u2019s review inherits the writing\u2019s rule');
const refine = route.slice(route.indexOf("async function refineResource("));
assert.match(refine, /reasoningEffort: 'none'/, 'a revision is writing');
assert.equal((refine.match(/reasoningEffort: options\.reasoningEffort,/g) || []).length, 1,
'and its review is asked the same way');
});
test('a deck whose slide was closed before its notes still parses', () => {
// A saved reply from production's fallback path: the model wrote
// `"rows":[[…]]}` and then `,"notes":"…"}` — the slide closed early and the
// whole deck was thrown away for it, three generations in a row.
const deckBuild = require('../src/utils/deckBuild');
const reply = fs.readFileSync(path.join(__dirname, 'fixtures/deck-reply-early-close.json'), 'utf8');
assert.throws(() => JSON.parse(reply), 'the fixture really is broken');
const deck = deckBuild.parse(reply, []);
assert.ok(deck && deck.slides.length >= 6, 'parsed after repair');
assert.ok(deck.slides.some(s => s.type === 'table'), 'the table slide survived');
// The repair is narrow: a brace that legitimately closes an inner object
// before the next one begins is left alone.
const fine = '{"title":"t","slides":[{"type":"compare","heading":"h","columns":[{"label":"A","bullets":[{"text":"x"}]},{"label":"B","bullets":[{"text":"y"}]}],"notes":"n"}]}';
assert.equal(deckBuild.repairEarlyClose(fine), fine);
assert.equal(deckBuild.parse(fine, []).slides[0].type, 'compare');
});