pediatric-ai-scribe-v3/test/deck-review.test.js
Daniel d893452d9a
Some checks failed
Forgejo Docker Build / End-to-end (browser) (push) Blocked by required conditions
Forgejo Docker Build / Root app tests (push) Successful in 55s
Forgejo Docker Build / Build Docker image (push) Has been cancelled
feat: the site's Nextcloud is filled in, not typed — the settings page is one button
nextcloud.url (or NEXTCLOUD_URL) names the site's Nextcloud. With it set,
the address field is hidden and the page reads "Sign in with Nextcloud",
with the app-password route underneath for whoever needs it; both routes
use the default when no address is given. Without it, nothing changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 14:33:25 +02:00

178 lines
9.4 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("router.post('/my-resources/:id/refine'"));
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('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');
});