pediatric-ai-scribe-v3/src/utils/deckReview.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

272 lines
13 KiB
JavaScript

// ============================================================
// DECK REVIEW
// ============================================================
// Render the deck, look at it, and fix what only looking can catch.
//
// The model that writes a deck never sees it. It cannot tell that slide four
// overflowed, that a figure landed on the wrong slide, or that a nine-item list
// would read better as two columns — those are facts about the rendered page,
// not about the text. So the deck is rendered to images and a vision model is
// asked to correct the layout.
//
// It may only move things. Same words, same figures: the reviewer returns a
// deck whose body text is the same multiset it was given, or its answer is
// discarded. That is checked rather than asked for, because 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.
//
// Off unless an administrator names a model. One pass, on generation only:
// a second pass costs as much as the first and fixes much less.
var fsp = require('fs/promises');
var os = require('os');
var pathMod = require('path');
var { execFile } = require('child_process');
var MAX_SLIDES = 20; // a review pass is one image per slide
var RENDER_DPI = 70; // legible to a model, small enough to send
var CONVERT_TIMEOUT = 60000;
// The reply restates the whole deck, so it needs room for one.
var MAX_REPLY_TOKENS = 2000; // a list of fixes, not a deck
var MAX_CHANGES = 12;
function run(command, args, cwd) {
return new Promise(function (resolve, reject) {
execFile(command, args, { cwd: cwd, timeout: CONVERT_TIMEOUT, maxBuffer: 4 * 1024 * 1024 },
function (err, stdout, stderr) {
if (err) return reject(new Error(command + ': ' + (stderr || err.message).toString().slice(0, 300)));
resolve(stdout);
});
});
}
/** One PNG per slide, in order. */
async function slideImages(pptx, gotenbergUrl, mime) {
var dir = await fsp.mkdtemp(pathMod.join(os.tmpdir(), 'review-'));
try {
var form = new FormData();
form.append('files', new File([pptx], 'deck.pptx', { type: mime }));
var response = await fetch(gotenbergUrl + '/forms/libreoffice/convert', {
method: 'POST', body: form, signal: AbortSignal.timeout(90000)
});
if (!response.ok) throw new Error('PDF conversion failed (' + response.status + ')');
await fsp.writeFile(pathMod.join(dir, 'deck.pdf'), Buffer.from(await response.arrayBuffer()));
await run('pdftoppm', ['-png', '-r', String(RENDER_DPI), 'deck.pdf', 'slide'], dir);
var files = (await fsp.readdir(dir)).filter(function (f) { return /^slide-?\d+\.png$/.test(f); }).sort();
var images = [];
for (var i = 0; i < files.length && i < MAX_SLIDES; i++) {
images.push({
mimeType: 'image/png',
dataBase64: (await fsp.readFile(pathMod.join(dir, files[i]))).toString('base64')
});
}
return images;
} finally {
await fsp.rm(dir, { recursive: true, force: true }).catch(function () {});
}
}
// Everything the reviewer is forbidden to change, in a form two decks can be
// compared by. Headings are held separately because splitting an overfull slide
// legitimately repeats one.
function fingerprint(deck) {
var body = [];
var headings = [];
var figures = [];
(deck.slides || []).forEach(function (slide) {
if (slide.heading) headings.push(slide.heading.trim());
if (slide.image_job) figures.push(slide.image_job);
// Every place a bullet can live: one list, two columns, or two labelled
// columns. Missing one of them makes a legitimate re-layout look like a
// rewrite — which it did, for the two-column case.
[slide.bullets, slide.left, slide.right].forEach(function (list) {
(list || []).forEach(function (b) { body.push(String(b.text || '').trim()); });
});
(slide.columns || []).forEach(function (c) {
(c.bullets || []).forEach(function (b) { body.push(String(b.text || '').trim()); });
});
(slide.header || []).forEach(function (cell) { if (cell) body.push(String(cell).trim()); });
(slide.rows || []).forEach(function (row) {
row.forEach(function (cell) { if (cell) body.push(String(cell).trim()); });
});
if (slide.text) body.push(slide.text.trim());
if (slide.caption) body.push(slide.caption.trim());
});
return { body: body.sort(), headings: headings, figures: figures.sort() };
}
function sameMultiset(a, b) {
return a.length === b.length && a.every(function (value, i) { return value === b[i]; });
}
/**
* True when the reviewed deck moved things without changing them.
*
* Body text must be the same multiset — moving a bullet to another slide or into
* a column keeps it, rewording it does not. Figures must be the same set, so a
* review can place them but cannot invent or drop one. A heading may be reused
* or extended, which is what splitting a slide needs, but not invented.
*/
function movedOnly(before, after) {
if (!sameMultiset(before.body, after.body)) return 'the wording changed';
if (!sameMultiset(before.figures, after.figures)) return 'the figures changed';
var known = before.headings;
var invented = after.headings.filter(function (heading) {
return !known.some(function (original) {
return heading === original || heading.indexOf(original) === 0;
});
});
if (invented.length) return 'a heading was invented: ' + invented[0].slice(0, 60);
return null;
}
function instructions(slides) {
return [
'You are looking at the rendered slides of a teaching deck, in order. Slide 1',
'in the images is index 0 below. The JSON that produced them follows.',
'',
'Report layout problems only — things that are wrong about the rendered page,',
'not about the writing:',
' - text running off the bottom of a slide, or too small to read',
' - a slide carrying so much that it should be two',
' - a long single-column list that would read better in two columns',
' - two labelled groups that would read better as a side-by-side comparison',
'',
'Return ONLY a JSON object of changes, nothing else:',
'',
'{"changes":[',
' {"slide":2,"action":"two"},',
' {"slide":4,"action":"split","after":3,"heading":"Management (continued)"},',
' {"slide":6,"action":"compare","at":3,"labels":["MILD","SEVERE"]},',
' {"slide":1,"action":"one"}',
']}',
'',
' "two" — lay this slide\'s bullets out in two columns',
' "one" — put a two-column slide back into one column',
' "split" — make a second slide from the bullets after index "after"',
' (0-based); "heading" must repeat or extend the original',
' "compare" — two labelled columns, splitting the bullets at index "at";',
' "labels" are two short column headings',
'',
'You cannot change any wording, and you are not being asked to: name the slide',
'and the action, and the server rearranges the text it already has. Slide',
'indices are 0 to ' + (slides - 1) + '. Return {"changes":[]} if the deck reads well.'
].join('\n');
}
// Apply a reviewer's changes to a copy of the deck.
//
// The reviewer names slides and split points; the text is moved by this code and
// never passes through the model, so a review cannot reword, drop or invent a
// single bullet. That is a stronger guarantee than asking it not to and checking
// afterwards — which is still done, because a bug here would be just as bad.
function applyChanges(deck, changes) {
var slides = (deck.slides || []).map(function (slide) { return JSON.parse(JSON.stringify(slide)); });
var applied = 0;
// Highest index first, so splitting one slide does not renumber the next.
var ordered = changes.slice().sort(function (a, b) { return (b.slide || 0) - (a.slide || 0); });
ordered.forEach(function (change) {
var index = parseInt(change.slide, 10);
if (!(index >= 0 && index < slides.length)) return;
var slide = slides[index];
var action = String(change.action || '');
if (action === 'two' && slide.type === 'bullets' && (slide.bullets || []).length > 3) {
var half = Math.ceil(slide.bullets.length / 2);
slides[index] = { type: 'two', heading: slide.heading, notes: slide.notes,
left: slide.bullets.slice(0, half), right: slide.bullets.slice(half) };
applied++;
return;
}
if (action === 'one' && slide.type === 'two') {
slides[index] = { type: 'bullets', heading: slide.heading, notes: slide.notes,
bullets: (slide.left || []).concat(slide.right || []) };
applied++;
return;
}
if (action === 'compare' && slide.type === 'bullets') {
var at = parseInt(change.at, 10);
var labels = Array.isArray(change.labels) ? change.labels : [];
if (!(at > 0 && at < (slide.bullets || []).length) || labels.length !== 2) return;
slides[index] = { type: 'compare', heading: slide.heading, notes: slide.notes, columns: [
{ label: String(labels[0]).slice(0, 60), bullets: slide.bullets.slice(0, at) },
{ label: String(labels[1]).slice(0, 60), bullets: slide.bullets.slice(at) }] };
applied++;
return;
}
if (action === 'split' && slide.type === 'bullets') {
var after = parseInt(change.after, 10);
if (!(after >= 0 && after < (slide.bullets || []).length - 1)) return;
// The continuation heading is the reviewer's only piece of text, and it
// has to repeat or extend the original or it is not a continuation.
var heading = String(change.heading || '').trim();
var original = String(slide.heading || '').trim();
if (!heading || heading.indexOf(original) !== 0) heading = original + ' (continued)';
var tail = { type: 'bullets', heading: heading, bullets: slide.bullets.slice(after + 1) };
slides[index] = Object.assign({}, slide, { bullets: slide.bullets.slice(0, after + 1) });
slides.splice(index + 1, 0, tail);
applied++;
}
});
return { deck: Object.assign({}, deck, { slides: slides }), applied: applied };
}
/**
* Review a deck and return a corrected one, or the original.
*
* Never throws and never fails a generation: a deck that could not be reviewed
* is the deck that was written, which is what would have shipped anyway.
*/
async function review(deck, options) {
var reasons = [];
try {
if (!options.model) return { deck: deck, reviewed: false, reason: 'no reviewer configured' };
var slides = (deck.slides || []).length;
if (!slides) return { deck: deck, reviewed: false, reason: 'nothing to review' };
if (slides > MAX_SLIDES) return { deck: deck, reviewed: false, reason: 'deck too long to review' };
var images = await slideImages(options.pptx, options.gotenberg, options.mime);
if (!images.length) return { deck: deck, reviewed: false, reason: 'could not render the deck' };
var ai = await options.callAI(
[{ role: 'user', content: instructions(deck.slides.length) +
'\n\nDECK JSON:\n' + JSON.stringify({ slides: deck.slides }) }],
{ model: options.model, temperature: 0.1, images: images, maxTokens: MAX_REPLY_TOKENS }
);
// A list of changes, not a deck. Asking for the whole deck back put the
// reply in proportion to the deck rather than to the number of problems,
// and a fourteen-slide deck came back cut off mid-object every time.
var reply = options.extractJson(String((ai && ai.content) || ''));
var changes = reply && Array.isArray(reply.changes) ? reply.changes : null;
if (!changes) return { deck: deck, reviewed: false, reason: 'the reviewer did not return changes' };
if (!changes.length) return { deck: deck, reviewed: false, reason: 'nothing to fix' };
var result = applyChanges(deck, changes.slice(0, MAX_CHANGES));
if (!result.applied) return { deck: deck, reviewed: false, reason: 'no change was applicable' };
// The text never went through the model, so this should always hold. It is
// checked anyway: a bug in applyChanges would be as bad as a model rewriting
// the words, and silently worse for being trusted.
var complaint = movedOnly(fingerprint(deck), fingerprint(result.deck));
if (complaint) {
console.warn('[deck-review] discarded: ' + complaint);
return { deck: deck, reviewed: false, reason: complaint };
}
return { deck: result.deck, reviewed: true, reason: null,
slides: images.length, changes: result.applied };
} catch (err) {
console.warn('[deck-review] skipped:', err.message);
reasons.push(err.message);
return { deck: deck, reviewed: false, reason: reasons[0] };
}
}
// slideImages is exported because modifying a deck wants the same picture of it
// that reviewing does — same render path, same DPI, same per-slide cap — and two
// copies of this would drift.
module.exports = { review, slideImages, fingerprint, movedOnly, applyChanges, instructions, MAX_SLIDES, MAX_CHANGES, RENDER_DPI };