feat: My Resources takes Details — what to cover, at length, folded away until needed
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 50s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 6s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-13 14:30:09 +02:00
parent aebb820216
commit 28877ba0c4
6 changed files with 56 additions and 4 deletions

View file

@ -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');

View file

@ -83,6 +83,14 @@
</div>
</div>
<!-- Details: what the resource is about, at length — a list of topics to
cover, a case, a curriculum. Folded away so the short form stays short;
opened by the people who need to paste something long. -->
<details class="admin-row" id="mr-details-wrap" style="display:block;">
<summary style="cursor:pointer;font-size:13px;font-weight:600;color:var(--g700);">Details <span style="color:var(--g400);font-weight:400;">(optional — a list of topics to cover, a case, an outline)</span></summary>
<textarea id="mr-details" class="admin-control" maxlength="12000" style="margin-top:8px;min-height:120px;resize:vertical;font-family:inherit;" placeholder="Paste or type what the resource should cover, one item per line if you like. Everything here is treated as what to cover, not as instructions on how."></textarea>
</details>
<div class="admin-row" style="align-items:flex-start;">
<label for="mr-refinement" class="admin-row-label">Instructions <span style="color:var(--g400);font-weight:400;">(optional)</span></label>
<textarea id="mr-refinement" class="admin-control" style="min-height:70px;resize:vertical;font-family:inherit;" placeholder="e.g. for FY1s, case-based, emphasise red flags, use 3 diagrams"></textarea>

View file

@ -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.
}

View file

@ -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 || '',

View file

@ -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,

View file

@ -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, /<details class="admin-row" id="mr-details-wrap"/, 'collapsed by default');
assert.match(html, /<textarea id="mr-details"[^>]*maxlength="12000"/);
const js = fs.readFileSync(path.join(__dirname, '..', 'public/js/myResources.js'), 'utf8');
assert.match(js, /details: \(document\.getElementById\('mr-details'\) \|\| \{\}\)\.value \|\| ''/);
});