From 28877ba0c4982457bdc3ffdb26511266dccc842a Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 14:30:09 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20My=20Resources=20takes=20Details=20?= =?UTF-8?q?=E2=80=94=20what=20to=20cover,=20at=20length,=20folded=20away?= =?UTF-8?q?=20until=20needed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Topic is one line and Instructions say how; neither is the place for a list of twelve topics or a case to build around. Details is: optional, collapsed by default, up to 12,000 characters, quoted into the prompt as the author's own account of what the resource must cover — material, not commands, in both the deck and the markdown shapes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- e2e/tests/my-resources.spec.js | 4 ++++ public/components/my-resources.html | 8 ++++++++ public/js/accountBoundary.js | 11 +++++++++++ public/js/myResources.js | 1 + src/routes/myResources.js | 20 ++++++++++++++++---- test/my-resources.test.js | 16 ++++++++++++++++ 6 files changed, 56 insertions(+), 4 deletions(-) diff --git a/e2e/tests/my-resources.spec.js b/e2e/tests/my-resources.spec.js index 6da274c8..f03c5ece 100644 --- a/e2e/tests/my-resources.spec.js +++ b/e2e/tests/my-resources.spec.js @@ -137,6 +137,9 @@ test.describe('My Resources', () => { await page.check('#mr-pubmed'); await page.check('#mr-web-search'); await page.fill('#mr-refinement', 'for FY1s'); + // Details is folded away until somebody has something long to paste. + await page.click('#mr-details-wrap summary'); + await page.fill('#mr-details', 'Cover: febrile seizure definition; red flags; when to LP; discharge advice.'); await page.click('#btn-mr-generate'); await expect.poll(() => sent.length, { timeout: 15000 }).toBeGreaterThan(0); @@ -145,6 +148,7 @@ test.describe('My Resources', () => { expect(body.kind).toBe('presentation'); expect(body.slideCount).toBe('9'); expect(body.refinement).toBe('for FY1s'); + expect(body.details).toContain('when to LP'); // Strings, because the route compares against 'true' / 'false'. expect(body.useCorpus).toBe('true'); expect(body.withPubmed).toBe('true'); diff --git a/public/components/my-resources.html b/public/components/my-resources.html index 3715b541..d69ca3bc 100644 --- a/public/components/my-resources.html +++ b/public/components/my-resources.html @@ -83,6 +83,14 @@ + +
+ Details (optional — a list of topics to cover, a case, an outline) + +
+
diff --git a/public/js/accountBoundary.js b/public/js/accountBoundary.js index 7538d961..61ad384b 100644 --- a/public/js/accountBoundary.js +++ b/public/js/accountBoundary.js @@ -88,10 +88,20 @@ button.focus(); window.dispatchEvent(new Event('account-boundary')); } + // The same person signing in again — in another tab, or coming back from + // the SSO — is a new session, not a new account. Adopting its generation + // keeps this tab working; the reload is for the case the boundary exists + // for, which is a different owner or none. + function adoptSameOwner(shared) { + if (!owner || !shared || shared.signedOut || shared.owner !== owner) return false; + generation = shared.generation; + return true; + } function current() { if (locked) return false; var shared = read(); if (owner && (!shared || shared.signedOut || shared.owner !== owner || shared.generation !== generation)) { + if (adoptSameOwner(shared)) return !locked && !storageFailed && !!owner; freeze(); reload(); } @@ -104,6 +114,7 @@ // Ignore queued events from A after B has already published its session. if (!message || !latest || message.generation !== latest.generation) return; if (owner && (message.signedOut || message.owner !== owner || message.generation !== generation)) { + if (adoptSameOwner(message)) return; freeze(); reload(); // Sibling events must never delete another tab's newly persisted native credentials. } diff --git a/public/js/myResources.js b/public/js/myResources.js index ff1689f1..337e38d3 100644 --- a/public/js/myResources.js +++ b/public/js/myResources.js @@ -269,6 +269,7 @@ slideCount: (document.getElementById('mr-slide-count') || {}).value, wordCount: (document.getElementById('mr-word-count') || {}).value, refinement: (document.getElementById('mr-refinement') || {}).value || '', + details: (document.getElementById('mr-details') || {}).value || '', useCorpus: corpusBox && corpusBox.checked === false ? 'false' : 'true', model: (document.getElementById('mr-model') || {}).value || '', theme: (document.getElementById('mr-theme') || {}).value || '', diff --git a/src/routes/myResources.js b/src/routes/myResources.js index 906d01b5..20f5090f 100644 --- a/src/routes/myResources.js +++ b/src/routes/myResources.js @@ -161,7 +161,7 @@ function buildPrompt(opts) { 'teaching artwork only, never a real patient.'); return 'You are building a teaching presentation for a medical professional ' + - 'audience (pediatrics / primary care).\n\nTOPIC: ' + opts.topic + '\n' + + 'audience (pediatrics / primary care).\n\nTOPIC: ' + opts.topic + '\n' + detailsBlock(opts) + grounding + findings + '\n' + deckSchema.instructions(opts.slideCount, opts.figureCount) + (opts.refinement ? '\n\nAdditional instructions: ' + opts.refinement + '\n' : '') + @@ -188,10 +188,19 @@ function buildPrompt(opts) { // produced three calls. A long, emphatic "Output ONLY Pandoc markdown" block // read afterwards is simply the more recent instruction. return 'You are writing teaching material for a medical professional audience ' + - '(pediatrics / primary care).\n\nTOPIC: ' + opts.topic + '\n' + grounding + findings + '\n' + shape + + '(pediatrics / primary care).\n\nTOPIC: ' + opts.topic + '\n' + detailsBlock(opts) + grounding + findings + '\n' + shape + (opts.refinement ? '\nAdditional instructions: ' + opts.refinement + '\n' : '') + illustration; } +// The author's own account of what to cover. Quoted as material, not as +// instructions, so a pasted list of topics becomes the outline rather than +// being obeyed as commands. +function detailsBlock(opts) { + if (!opts.details) return ''; + return '\nDETAILS FROM THE AUTHOR (what this must cover, in their words):\n' + QUOTE + '\n' + opts.details + '\n' + QUOTE + '\n'; +} +var QUOTE = '"""'; + function firstHeading(markdown, fallback) { var m = String(markdown || '').match(/^%\s*(.+)$/m) || String(markdown || '').match(/^#\s+(.+)$/m); return (m ? m[1] : fallback || 'Untitled').trim().slice(0, MAX_TITLE); @@ -312,6 +321,9 @@ router.post('/my-resources/generate', async function (req, res) { var kind = normalizeKind(req.body.kind); var refinement = String(req.body.refinement || '').slice(0, 2000); + // What the resource must cover, at length: a topic list, a case, an + // outline. Separate from instructions, which say how, not what. + var details = String(req.body.details || '').slice(0, 12000).trim(); var count = await db.get('SELECT COUNT(*)::int AS n FROM user_resources WHERE user_id = ?', [req.user.id]); if (count && count.n >= MAX_PER_USER) { @@ -329,7 +341,7 @@ router.post('/my-resources/generate', async function (req, res) { // its text. Articles stay markdown: prose is what markdown is for. var deckMode = kind === 'presentation'; var prompt = buildPrompt({ - topic: topic, kind: kind, refinement: refinement, corpusContext: corpus.context, + topic: topic, kind: kind, refinement: refinement, details: details, corpusContext: corpus.context, literature: sources.literature, webFindings: sources.webFindings, searchedAndFoundNothing: sources.searchedAndFoundNothing, wantsImages: wantsImages, deckMode: deckMode, @@ -444,7 +456,7 @@ router.post('/my-resources/generate', async function (req, res) { logger.warn('[my-resources] deck reply was not usable twice (' + deckFallback + '); retrying as markdown', { topic: topic, attempt: 2, model: ai && ai.model, reply: String((ai && ai.content) || '').slice(0, 240) }); var plain = buildPrompt({ - topic: topic, kind: kind, refinement: refinement, corpusContext: corpus.context, + topic: topic, kind: kind, refinement: refinement, details: details, corpusContext: corpus.context, literature: sources.literature, webFindings: sources.webFindings, searchedAndFoundNothing: sources.searchedAndFoundNothing, wantsImages: false, deckMode: false, diff --git a/test/my-resources.test.js b/test/my-resources.test.js index 24e80d83..ef0c9c65 100644 --- a/test/my-resources.test.js +++ b/test/my-resources.test.js @@ -383,3 +383,19 @@ test('an article is never offered as slides', () => { assert.match(route, /if \(row\.kind === 'article' && format === 'pptx'\)/); assert.match(route, /An article has no slides\. Download it as Word or PDF\./); }); + +test('Details is material to cover, quoted after the topic and kept apart from instructions', () => { + // Someone with a long list of topics has somewhere to put it that is not + // the one-line topic box and not the instructions. It reaches the prompt + // as quoted material, in both the deck and the markdown shapes. + const route = fs.readFileSync(path.join(__dirname, '..', 'src/routes/myResources.js'), 'utf8'); + assert.match(route, /var details = String\(req\.body\.details \|\| ''\)\.slice\(0, 12000\)\.trim\(\);/); + assert.equal((route.match(/details: details, corpusContext/g) || []).length, 2, 'generation and the markdown fallback both carry it'); + assert.equal((route.match(/opts\.topic \+ '\\n' \+ detailsBlock\(opts\)/g) || []).length, 2, 'both prompt shapes quote it after the topic'); + assert.match(route, /DETAILS FROM THE AUTHOR \(what this must cover, in their words\)/); + const html = fs.readFileSync(path.join(__dirname, '..', 'public/components/my-resources.html'), 'utf8'); + assert.match(html, /
]*maxlength="12000"/); + const js = fs.readFileSync(path.join(__dirname, '..', 'public/js/myResources.js'), 'utf8'); + assert.match(js, /details: \(document\.getElementById\('mr-details'\) \|\| \{\}\)\.value \|\| ''/); +});