// ============================================================ // 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 };