Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
PubMed joins web search as an optional source for a generated resource: a
literature search on the topic, with abstracts, cited by PMID in References.
Off by default, admin-enabled, with its own optional API key (NCBI raises the
rate limit from 3/sec to 10/sec; it works without one).
Neither search is a tool any more, and that is the point. Offering them as
function calls meant the model decided whether to search, and with a prompt
ending "Output ONLY Pandoc markdown" it decided not to — every time, with and
without corpus grounding, no matter how the tool description was worded.
Calling callAI with the tool directly produced a correct pubmed_search call, so
the plumbing was never the problem. The search only ever needed the topic, and
the route knows the topic before it calls the model, so both searches now run up
front and their results go into the prompt as findings, exactly the way corpus
excerpts do. Ticking the box now means the search happened.
Verified live against deepseek-v4-flash: 30 corpus excerpts and 6 PubMed
results, and a References slide carrying both the library sources and four real
PMIDs (29562151, 38506440, 35721052, 28814254).
Three fixes to illustration, which had never once fired:
- The dispatch call had been lost in a refactor. The tool was still offered, the
model still called it, and the call was dropped, so no job was ever enqueued.
- imageContext was passed as a bare topic string where dispatch expects
{ request, history }, which made the bound request undefined.
- The prompt never mentioned the tool existed while explicitly demanding only
markdown — the same suppression that killed the searches. It now says an
illustration is available and that calling it is not a violation of that rule.
my_resources is its own image workflow rather than a reuse of learning_hub,
because generated_image_links only accepts learning_hub assets, and that is
exactly the barrier that keeps a private illustration out of published content.
The illustration renders in the panel, rather than a toast pointing at an image
history this feature does not have.
Verified end to end: job queued, rendered, and the asset served to its owner as
a correctly labelled subglottic-anatomy teaching diagram.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
211 lines
13 KiB
JavaScript
211 lines
13 KiB
JavaScript
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 person’s 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 person’s 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\)/);
|
||
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');
|
||
|
||
assert.match(component, /Private to you/);
|
||
assert.match(component, /Nobody else sees 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, and reuses the assistant’s image tool', () => {
|
||
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\(req\.body\.withImages\) === 'true'/);
|
||
// 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.
|
||
assert.match(route, /if \(wantsImages && imageModel\) tools = tools\.concat\(imageTool\.tools\);/);
|
||
assert.doesNotMatch(route, /tools\.concat\((?:webSearch|pubmedSearch)\.tools\)/);
|
||
// The same dispatcher the assistant uses, so an image made here is owned,
|
||
// queued and rendered identically to one made there.
|
||
assert.match(route, /imageTool\.dispatch\(ai, \{/);
|
||
assert.match(route, /workflow: '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 imageTool\.dispatch\(ai, \{/);
|
||
// dispatch expects { request, history }; a bare topic string made the bound
|
||
// request undefined and lost the topic entirely.
|
||
assert.match(route, /imageContext: generatedImages\.imageContext\(topic, \[\]\)/);
|
||
|
||
// 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, /call generate_image ONCE before writing/);
|
||
assert.match(route, /is about the written resource; the 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'), /if \(imagesRow\) imagesRow\.hidden = !data\.imagesAvailable;/);
|
||
assert.match(read('public/components/my-resources.html'), /Off by default: a model handed a drawing tool/);
|
||
});
|
||
|
||
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;/);
|
||
// And it applies to the PDF path too, which renders from the pptx.
|
||
assert.match(exporter, /if \(office === 'pptx'\) bytes = await fitSlideText\(bytes\);/);
|
||
// 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\./);
|
||
});
|