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 \|\| ''/);
+});