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
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
272 lines
13 KiB
JavaScript
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 };
|