pediatric-ai-scribe-v3/test/my-resources.test.js
Daniel 087f717f55
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 59s
Forgejo Docker Build / Root app tests (push) Successful in 53s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 8s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
feat: the model designs the deck instead of writing markdown for a parser to guess at
Markdown could express about five of the things the renderer can draw, so the
model had no way to say "put this figure beside these three bullets" or "make
this a comparison with two labelled columns" — my parser inferred a layout from
the shape of a list, and inferring is what made every deck look the same.

A presentation is now described as a deck: the model returns JSON naming a
layout per slide and the prompt it wants each figure drawn from. Four layouts
were added to the renderer for it — two tinted labelled columns for a
comparison, a callout card for a red flag or a dose, a figure beside its
bullets, and a full-slide figure. Articles stay markdown, which is what prose
wants.

Markdown is still produced, serialised from the deck, so Word export and text
editing keep working and the stored artifact stays readable by a person. The
deck is stored alongside it because that serialisation is lossy by design:
round-tripping through markdown would throw away exactly the layout choices this
was built to capture. A resource made before this, or an article forced into
slides, still renders by inferring from its markdown.

Nothing here can cost more than the thing that went wrong. A reply that is not a
deck falls back to asking for markdown rather than saving the model's apology; a
malformed slide degrades to bullets rather than throwing; a comparison with one
column is not a comparison; a figure that cannot be queued leaves a slide of
text rather than an empty frame; and JSON wrapped in fences or a covering
sentence is read rather than refused.

Verified live on "croup versus epiglottitis": the model chose section, bullets,
table, compare, figure, callout and image layouts across thirteen slides, and
the exported deck was rendered to PDF, rasterised and looked at — the comparison
renders as two tinted cards, the red flag as a callout, and the figure sits
beside its bullets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 19:49:55 +02:00

329 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
test('a persons own resources are a separate pathway from Learning', () => {
const route = read('src/routes/myResources.js');
const learning = read('src/routes/learningAI.js');
// Learning stays moderator-owned. This exists so that not being a moderator
// no longer means not being able to generate anything at all.
assert.match(learning, /router\.use\(moderatorMiddleware\)/, 'Learning is unchanged');
assert.doesNotMatch(route, /moderatorMiddleware/, 'and this one never mentions it');
assert.match(route, /router\.use\('\/my-resources', authMiddleware\)/,
'signed in is the only requirement, and the gate names its own prefix');
});
test('nothing here can return another persons work', () => {
const route = read('src/routes/myResources.js');
// Every statement that touches the table filters on the owner. A missing
// WHERE clause here is the whole risk, so it is asserted rather than assumed.
const statements = route.match(/'(SELECT|UPDATE|DELETE|INSERT)[^']*(?:' \+\s*\n\s*'[^']*)*'/g) || [];
const touching = statements.filter(s => /user_resources/.test(s));
assert.ok(touching.length >= 5, 'expected the table statements to be found');
for (const s of touching) {
if (/^'INSERT/.test(s)) continue; // supplies user_id as a value instead
assert.match(s, /user_id = \?/, 'every read and write is scoped to the owner: ' + s.slice(0, 60));
}
assert.match(route, /INSERT INTO user_resources \(user_id,/, 'and an insert records one');
});
test('markdown is the artifact; every format is rendered from it', () => {
const exporter = read('src/utils/documentExport.js');
// Refining means editing text, never patching a binary — which is what makes
// "change slide 4" possible at all.
assert.match(exporter, /async function render\(markdown, kind, format, options\)/);
assert.match(exporter, /var office = kind === 'presentation' \? 'pptx' : 'docx';/);
assert.match(exporter, /--reference-doc=' \+ REFERENCE_DECK/, 'decks keep the house template');
// PDF goes through Gotenberg because pandoc ships no PDF engine in this
// image, and converting the office file preserves the deck's layout.
assert.match(exporter, /forms\/libreoffice\/convert/);
assert.match(exporter, /AbortSignal\.timeout\(90000\)/, 'and cannot hang a request');
// Temporary directories are always cleaned, including on failure.
assert.match(exporter, /\} finally \{[\s\S]{0,200}rm\(workdir, \{ recursive: true, force: true \}\)/);
});
test('a failed PDF says so, because the other two formats still work', () => {
const route = read('src/routes/myResources.js');
assert.match(route, /PDF conversion is unavailable right now\. PowerPoint and Word still work\./);
// Gotenberg is a different stack; PDF is the one export allowed to fail.
assert.match(read('src/utils/documentExport.js'), /GOTENBERG_URL \|\| 'http:\/\/gotenberg:3000'/);
assert.match(read('docker-compose.yml'), /danvics_convert/, 'and ped-ai is on its network');
});
test('the generated markdown is told the rules pandoc enforces', () => {
const route = read('src/routes/myResources.js');
// The same rules the Learning prompt carries, found by rendering decks and
// looking at them: a table alone on its slide, blank lines around it, no
// "Slide 3:" prefixes, no deep nesting.
assert.match(route, /A slide containing a table contains ONLY that table/);
assert.match(route, /a table needs a blank line/);
assert.match(route, /the heading is the slide\\'s subject, not "Slide 3:"/);
// And grounded resources cite only at the end.
assert.match(route, /Do NOT cite in the body/);
assert.match(route, /In a presentation that is the final slide, titled References/);
});
test('a library has a ceiling, and generation says when it is reached', () => {
const route = read('src/routes/myResources.js');
assert.match(route, /var MAX_PER_USER = 100;/);
assert.match(route, /You have reached ' \+ MAX_PER_USER \+ ' saved resources/);
// The count is per owner, so one person filling their library cannot stop
// anyone else generating.
assert.match(route, /SELECT COUNT\(\*\)::int AS n FROM user_resources WHERE user_id = \?/);
});
test('the screen is reachable by anyone signed in, and states that it is private', () => {
const index = read('public/index.html');
const component = read('public/components/my-resources.html');
// A menu item of its own, next to the Learning Hub: related, not the same
// thing, and sitting together is how someone discovers the difference.
assert.match(index, /<button class="tab-btn" data-tab="myresources">/);
assert.match(index, /<section id="myresources-tab" class="tab-content" data-component="my-resources">/);
// No role gate in the markup: the tab button carries no hidden class, unlike
// the admin and CMS ones which JavaScript reveals per role.
const button = index.slice(index.indexOf('data-tab="myresources"') - 40, index.indexOf('data-tab="myresources"') + 40);
assert.doesNotMatch(button, /hidden/, 'visible to every signed-in user');
// Said in the header. It used to be repeated in a paragraph below; the claim
// is what matters, not that it was made twice.
assert.match(component, /Only you can see these/);
});
test('a row offers the right formats, and the download carries its auth', () => {
const js = read('public/js/myResources.js');
assert.match(js, /formats\.forEach\(function \(format\)/);
// An <a href> cannot carry the Authorization header, so the file is fetched
// and saved from a blob instead of linked.
assert.match(js, /headers: getAuthHeaders\(\)/);
assert.match(js, /filename="\(\[\^"\]\+\)"/, 'and keeps the name the server chose');
assert.match(js, /URL\.revokeObjectURL\(url\)/, 'without leaking the object URL');
// Titles come from a model; this is where they reach the page.
assert.match(js, /title\.textContent = row\.title \|\| 'Untitled';/);
assert.doesNotMatch(js, /innerHTML\s*=\s*[^'"]*row\./, 'never interpolated into innerHTML');
});
test('users pick from the models an admin already approved, and nothing else', () => {
const route = read('src/routes/myResources.js');
// One allow-list, the one chat already uses. A second would be another thing
// to keep in step, and would let this reach a model nobody approved.
assert.match(route, /db\.getSetting\('clinical_assistant\.allowed_models', ''\)/);
assert.match(route, /db\.getSetting\('clinical_assistant\.chat_model', ''\)/);
// A stale option in an open browser tab must not cost someone their
// generation, so an unknown model falls back rather than being refused.
assert.match(route, /return wanted && models\.allowed\.indexOf\(wanted\) !== -1 \? wanted : \(models\.configured \|\| undefined\);/);
// Refining goes through the same resolution, not req.body.model directly.
assert.doesNotMatch(route, /model: req\.body\.model \|\| undefined/);
// And the screen only asks when there is a real choice to make.
const js = read('public/js/myResources.js');
assert.match(js, /if \(modelRow\) modelRow\.hidden = models\.length < 2;/);
});
test('illustration is opt-in, with its own dispatcher rather than the assistants', () => {
const route = read('src/routes/myResources.js');
// A model handed a drawing tool will find a reason to use it, so the tool is
// only offered when the author asked for one.
assert.match(route, /var wantsImages = String\(body\.withImages\) === 'true'/);
// Opt-in is the checkbox's own default, which is the fact worth pinning —
// stronger than the sentence that used to explain it.
assert.match(read('public/components/my-resources.html'),
/<input type="checkbox" id="mr-with-images">/, 'unchecked by default');
// Tools are assembled per generation: only what the author asked for.
// Illustration is the only thing left that is genuinely a tool: it needs the
// model to decide there should be a picture and to compose the prompt for it.
// Search does not — see web-search.test.js for why both searches were taken
// away from the model and run by the route instead.
// In deck mode the slides name their own figures, so there is nothing for a
// tool to decide; an article still gets the tool, having no structure to hang
// a figure on.
assert.match(route, /if \(wantsImages && !deckMode\) tools = tools\.concat\(resourceImages\.tools\);/);
assert.doesNotMatch(route, /tools\.concat\((?:webSearch|pubmedSearch)\.tools\)/);
// Its own dispatcher. The assistant's permits one image per request, which is
// right for a chat reply and wrong for a deck, and three features depend on
// that rule — so this is a separate path rather than a relaxed shared one.
assert.match(route, /resourceImages\.dispatch\(ai, \{/);
assert.doesNotMatch(route, /imageTool/, 'the shared single-image dispatcher is not used here');
assert.match(read('src/utils/imageTool.js'), /Only one image tool invocation is permitted per request/,
'and its limit is left exactly as it was');
assert.match(read('src/utils/resourceImages.js'), /'my_resources'/, 'but attributed to this feature');
// The dispatch call itself. It was lost once in a refactor: the tool was
// still offered, the model still called it, and the call was silently
// dropped, so no job was ever enqueued and imageJobs was always empty.
assert.match(route, /ai = await resourceImages\.dispatch\(ai, \{/);
// dispatch expects { request, history }; a bare topic string made the bound
// request undefined and lost the topic entirely.
assert.match(read('src/utils/resourceImages.js'), /images\.imageContext\(opts\.subject, \[\]\)/);
// A model handed a tool schema and then told to "Output ONLY Pandoc markdown"
// obeys the sentence, not the schema — measured: zero tool calls until the
// prompt said the tool existed and that calling it was not a violation.
assert.match(route, /is about the written resource; a tool call is not a violation of it/);
assert.match(route, /wantsImages: Boolean\(wantsImages && imageModel\)/);
// Its own workflow, not a reuse of learning_hub: generated_image_links only
// accepts learning_hub assets, and that is exactly the barrier keeping a
// private illustration out of published content.
assert.match(read('src/utils/generatedImages.js'), /const workflows = \['clinical_assistant', 'learning_hub', 'my_resources'\];/);
assert.match(read('migrations/1780400000000_my-resources-images.js'), /CHECK \(workflow IN \('clinical_assistant', 'learning_hub', 'my_resources'\)\)/);
// Status polling is owner-scoped and workflow-scoped, so it can only report
// on an image the caller made here.
assert.match(route, /service\(\)\.get\(req\.params\.id, req\.user\.id, 'my_resources'\)/);
assert.match(read('public/js/generatedImages.js'), /my_resources: '\/api\/my-resources\/image\/jobs\/'/);
// And it renders where the person is looking, rather than pointing them at an
// image history this feature does not have.
assert.match(read('public/js/myResources.js'), /showIllustrations\(data\.imageJobs \|\| \[\]\)/);
assert.match(read('public/components/my-resources.html'), /id="mr-images"/);
assert.match(route, /imageJobs: ai\.imageJobs \|\| \[\]/, 'and reported back');
// The row is hidden entirely when no image model is configured.
assert.match(read('public/js/myResources.js'), /\['mr-images-row', 'images'\]/);
});
test('the author can ask for the illustration, not only leave it to the model', () => {
const route = read('src/routes/myResources.js');
// Without this the decision is the model's alone, and someone who wants a
// figure of something particular has no way to say so — the instructions
// steer the prose and nothing else.
assert.match(route, /If the author\\'s additional instructions above name what a figure should show/);
assert.match(route, /compose the image description from/);
// How many, when the author says. "use 3 images" is as clear an instruction
// as any other and used to be capped at one figure regardless.
const lib = read('src/utils/resourceImages.js');
assert.match(lib, /function requestedCount\(text\)/);
assert.match(lib, /var MAX_IMAGES = 6;/, 'bounded, because each figure is a paid request');
assert.match(lib, /jobs\.length < MAX_IMAGES/);
assert.match(lib, /'res:' \+ images\.requestKey\(opts\.body\) \+ ':' \+ i/,
'a key per figure, or the second is returned as a replay of the first');
assert.match(route, /resourceImages\.guidance\(opts\.refinement\)/, 'generate');
assert.match(route, /resourceImages\.guidance\(instructions\)/, 'and modify');
// A figure that cannot be queued is said out loud; fewer pictures than asked
// for with no explanation reads as the model ignoring the request.
assert.match(lib, /failures\.push/);
assert.match(read('public/js/myResources.js'), /function reportImageFailures/);
// The paragraph now comes last, so it says "above" — checked, because a
// prompt that points the model at the wrong end of itself is worse than one
// that says nothing.
assert.match(route, /additional instructions above name what a figure should show/);
assert.doesNotMatch(route, /instructions below/);
// Said once on screen too: the label points at Instructions, and the
// Instructions placeholder shows what asking for one looks like.
const html = read('public/components/my-resources.html');
assert.match(html, /use 3 diagrams/, 'and the placeholder shows that asking for several works');
assert.match(html, /Add illustrations &mdash; say how many in Instructions/);
// Saying it in the instructions is as clear as ticking the box, so the box
// follows rather than the request being dropped in silence.
const js = read('public/js/myResources.js');
assert.match(js, /function looksLikeImageRequest/);
assert.match(js, /Illustration switched on, because your instructions ask for a figure/);
assert.match(js, /no image model is configured, so none can be made/);
assert.match(js, /if \(!check\.checked\) overruled = true;/, 'and switching it off by hand sticks');
assert.match(js, /wireImageIntent\('mr-refinement', 'mr-with-images', 'mr-image-hint'\)/);
assert.match(js, /wireImageIntent\('mr-modify-instructions', 'mr-modify-images', 'mr-modify-image-hint'\)/);
});
test('a named number of figures survives a prompt full of library excerpts', () => {
const route = read('src/routes/myResources.js');
const lib = read('src/utils/resourceImages.js');
// Measured, and deterministic on this model: with the library off, "use 3
// diagrams" produced three tool calls; with thirty excerpts in the prompt it
// produced none and a longer deck instead. The excerpts are not wrong to
// dominate — the request simply has to survive them.
assert.match(route, /if \(tools\.length && resourceImages\.requestedCount\(refinement\)\) callOptions\.toolChoice = 'required';/);
assert.match(route, /if \(tools\.length && resourceImages\.requestedCount\(instructions\)\) callOptions\.toolChoice = 'required';/);
// With no number named the choice stays the model's.
assert.doesNotMatch(route, /toolChoice = 'required';\s*\n\s*var ai = await callAI\(messages, Object/);
// Placement matters as much as wording: the illustration paragraph goes after
// the output rules and the author's instructions, because read before them it
// lost to a long "Output ONLY Pandoc markdown" block.
const tail = route.slice(route.indexOf("return 'You are writing teaching material"));
assert.ok(tail.indexOf('+ illustration;') > tail.indexOf('Additional instructions'),
'illustration guidance is the last thing in the prompt');
// A model that has just made three tool calls tends to sign off rather than
// write. Measured: "I'll create the presentation and the three teaching
// diagrams." — 61 characters, saved as the resource, because only a
// completely empty body counted as missing.
assert.match(lib, /function looksLikeResource\(content\)/);
assert.match(lib, /if \(!looksLikeResource\(ai && ai\.content\)\)/);
assert.match(lib, /\^%\/m\.test\(text\) \|\| \/\^#\{1,2\}/, 'a title block or a heading, not mere length');
// And if the continuation is no better, keep whichever actually reads like one.
assert.match(lib, /completed = ai;/);
});
test('the library is bounded, searchable, and drives the modify picker', () => {
const html = read('public/components/my-resources.html');
// Unbounded, a long library pushes everything else off the page.
assert.match(html, /id="mr-list"[^>]*max-height:360px;overflow-y:auto;/);
assert.match(html, /id="mr-search"/);
const js = read('public/js/myResources.js');
// Filtering is local — the rows are already in hand, so it costs no request.
assert.match(js, /search\.addEventListener\('input', renderLibrary\)/);
assert.match(js, /var rows = library\.filter/);
assert.match(js, /String\(row\.title \|\| ''\) \+ ' ' \+ String\(row\.topic \|\| ''\)/, 'title and topic both searched');
// "Nothing yet" and "nothing matches" are different situations.
assert.match(js, /library\.length\s*\n?\s*\? 'Nothing matches/);
});
test('modify revises something already generated, in place', () => {
const js = read('public/js/myResources.js');
// The endpoint existed with no way to reach it: the markdown is what is
// stored precisely so that "redo slide 4" is a text edit.
assert.match(js, /\/refine'/);
assert.match(js, /instructions: instructions/);
// The picker is the library, so it cannot drift from it, and a selection
// survives the refresh that follows a generation.
assert.match(js, /function syncModifyTargets\(\)/);
assert.match(js, /var previous = select\.value;/);
assert.match(js, /if \(previous && library\.some/);
// Refusals are local rather than a wasted round trip.
assert.match(js, /if \(!instructions\) return say\('Say what to change\.', 'bad'\);/);
// And the screen says the old version is gone, because it is.
assert.match(read('public/components/my-resources.html'), /The previous version is replaced/);
});
test('a slide shrinks its text rather than spilling off the bottom', () => {
// pandoc writes a bare <a:bodyPr/> on every shape, which leaves the body with
// no autofit even though the slide master has one. Rendered and counted: a
// slide with eight bullets showed three and cut the third mid-sentence, and
// the remaining five were not on the slide at all.
const exporter = read('src/utils/documentExport.js');
assert.match(exporter, /async function fitSlideText\(bytes\)/);
assert.match(exporter, /<a:bodyPr><a:normAutofit\/><\/a:bodyPr>/);
// No fontScale: the renderer works out the reduction, so a slide that already
// fits is left alone. A fixed scale would shrink every slide regardless.
assert.doesNotMatch(exporter, /normAutofit fontScale/);
// Running it on a deck that already has autofit must not double-inject.
assert.match(exporter, /if \(xml\.indexOf\('normAutofit'\) !== -1\) continue;/);
// It is now only needed on the pandoc fallback: the Python renderer sizes
// text to fit before writing the file, so its decks never need patching.
assert.match(exporter, /await fsp\.writeFile\(out, await fitSlideText\(await fsp\.readFile\(out\)\)\);/);
// A deck that renders imperfectly beats no deck at all.
assert.match(exporter, /could not apply slide autofit/);
assert.ok(JSON.parse(read('package.json')).dependencies.jszip, 'jszip is declared, not borrowed');
});
test('an article is never offered as slides', () => {
const js = read('public/js/myResources.js');
const route = read('src/routes/myResources.js');
// A deck of paragraphs is not a presentation. Word and PDF are fine for
// either; PowerPoint only makes sense for something written as slides.
assert.match(js, /row\.kind === 'article' \? \['docx', 'pdf'\] : \['pptx', 'docx', 'pdf'\]/);
// The route is the boundary that matters, not the button.
assert.match(route, /if \(row\.kind === 'article' && format === 'pptx'\)/);
assert.match(route, /An article has no slides\. Download it as Word or PDF\./);
});