feat: My Resources takes Details — what to cover, at length, folded away until needed
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:
parent
aebb820216
commit
28877ba0c4
6 changed files with 56 additions and 4 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 || '',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 \|\| ''/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue