feat: starter questions are cases with a decision, and the pool rebuilds itself when the prompt changes
The pool read like a chapter index — "What red flags in a child's headache history warrant investigation?", "Which clinical scores are useful for asthma?". The prompt now asks for what a clinician types with a patient in front of them: a one-line vignette with an age and at least one number, then the one decision to make now. The filter enforces it: a number is required, textbook openers are refused, and the length cap makes room for a case. A pool carries the prompt version it was built with. One from an older version is served as before and rebuilt in the background, so a prompt change reaches the screen without anyone pressing Regenerate. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
parent
f95f9c586d
commit
8c6126b112
2 changed files with 76 additions and 7 deletions
|
|
@ -240,6 +240,11 @@ function parseJsonObject(text) {
|
|||
return {};
|
||||
}
|
||||
|
||||
// Bumped whenever the generation prompt or the filter changes. A pool made by
|
||||
// an earlier version is served while a new one is built, so nobody sees an
|
||||
// empty screen and nobody has to remember to press Regenerate.
|
||||
var PROMPT_VERSION = 2;
|
||||
|
||||
function createClinicalPromptPool(opts) {
|
||||
opts = opts || {};
|
||||
var cacheMs = positiveInt(process.env.CLINICAL_ASSISTANT_EXAMPLE_CACHE_MS, 10 * 60 * 1000);
|
||||
|
|
@ -259,7 +264,7 @@ function createClinicalPromptPool(opts) {
|
|||
var cached = await readPromptPool();
|
||||
if (cached && Array.isArray(cached.examples) && cached.examples.length) {
|
||||
memoryCache = { expiresAt: now + cacheMs, examples: cached.examples };
|
||||
if (refreshMs > 0 && (!cached.generatedAt || now - cached.generatedAt > refreshMs)) refreshIfNeeded(false).catch(function() {});
|
||||
if (isStale(cached, now)) refreshIfNeeded(false).catch(function() {});
|
||||
return cached.examples;
|
||||
}
|
||||
|
||||
|
|
@ -275,14 +280,14 @@ function createClinicalPromptPool(opts) {
|
|||
context = context || {};
|
||||
var now = Date.now();
|
||||
var cached = await readPromptPool();
|
||||
if (!force && cached && Array.isArray(cached.examples) && cached.examples.length && (refreshMs === 0 || (cached.generatedAt && now - cached.generatedAt < refreshMs))) {
|
||||
if (!force && cached && Array.isArray(cached.examples) && cached.examples.length && !isStale(cached, now)) {
|
||||
return cached.examples;
|
||||
}
|
||||
if (refreshPromise) return refreshPromise;
|
||||
refreshPromise = buildCorpusPromptPool().then(function(examples) {
|
||||
refreshPromise = null;
|
||||
if (!examples.length) return [];
|
||||
var payload = { generatedAt: Date.now(), target: target, examples: examples };
|
||||
var payload = { generatedAt: Date.now(), promptVersion: PROMPT_VERSION, target: target, examples: examples };
|
||||
memoryCache = { expiresAt: Date.now() + cacheMs, examples: examples };
|
||||
return writePromptPool(payload, context).then(function() { return examples; });
|
||||
}).catch(function(e) {
|
||||
|
|
@ -293,6 +298,11 @@ function createClinicalPromptPool(opts) {
|
|||
return refreshPromise;
|
||||
}
|
||||
|
||||
function isStale(cached, now) {
|
||||
if ((cached.promptVersion || 1) !== PROMPT_VERSION) return true;
|
||||
return refreshMs > 0 && (!cached.generatedAt || now - cached.generatedAt > refreshMs);
|
||||
}
|
||||
|
||||
async function readPromptPool() {
|
||||
var cached = await opts.redisCache.getJson(redisAllKey).catch(function() { return null; });
|
||||
if (cached && Array.isArray(cached.examples) && cached.examples.length) return cached;
|
||||
|
|
@ -326,9 +336,9 @@ function createClinicalPromptPool(opts) {
|
|||
var ai = await opts.callAI([
|
||||
{
|
||||
role: 'system',
|
||||
content: 'Generate realistic pediatric clinical assistant starter questions from indexed source snippets. Use only the provided titles/snippets as inspiration. Do not answer the questions. Do not mention source names. Every question must be about children, neonates, infants, adolescents, pediatric dosing, pediatric diagnosis, pediatric management, pediatric red flags, pediatric counseling, or pediatric disposition. Return strict JSON only: {"questions":[{"label":"2-5 word label","prompt":"question ending with ?","category":"' + item.category + '","age_band":"' + item.ageBands.join('|') + '","intent":"diagnosis|management|red_flags|differential|counseling|dosing|admission|discharge|review"}]}.'
|
||||
content: 'Write starter questions for a pediatric clinical assistant, the kind a clinician actually types at 3 a.m. with a patient in front of them. Use only the provided titles/snippets as inspiration; do not answer the questions and do not mention source names. Every question is a case and a decision: it opens with a one-line vignette — an age with a number and unit (e.g. "a 6-week-old", "a 14-year-old"), the setting, and two or three specific findings of which at least one is a number (a vital sign, a lab value, a dose, a duration, a weight) — and then asks ONE thing the clinician must decide now: the next test, the threshold at which to act, a dose with units, admit or discharge, when to repeat, what changes the plan. Never ask a textbook question: no "What is…", "What are the causes/features/signs of…", "How is X managed?", "Which scores are useful…". 18 to 45 words. Return strict JSON only: {"questions":[{"label":"2-5 word label","prompt":"question ending with ?","category":"' + item.category + '","age_band":"' + item.ageBands.join('|') + '","intent":"diagnosis|management|red_flags|differential|counseling|dosing|admission|discharge|review"}]}.'
|
||||
},
|
||||
{ role: 'user', content: 'Category: ' + item.category + '\nAge bands: ' + item.ageBands.join(', ') + '\nIndexed pediatric source snippets:\n\n' + sourceText + '\n\nCreate 30 varied starter questions for this category. Balance emergencies, outpatient decisions, diagnostic workup, red flags, treatment, fluids, dosing, admission/discharge, and family counseling when relevant.' }
|
||||
{ role: 'user', content: 'Category: ' + item.category + '\nAge bands: ' + item.ageBands.join(', ') + '\nIndexed pediatric source snippets:\n\n' + sourceText + '\n\nCreate 30 starter questions for this category, each a specific case with a specific decision. Vary the setting (emergency department, ward, clinic, neonatal unit, telephone advice), the decision (next test, threshold, dose, fluids, admit/discharge, timing, what if a finding changes), and the age. Example of the depth wanted: "A 6-week-old with 3 days of projectile non-bilious vomiting, weight down 8% from birth and a chloride of 88: which fluid do you start, and what corrects before theatre is safe?"' }
|
||||
], {
|
||||
model: chatModel || undefined,
|
||||
temperature: 0.78,
|
||||
|
|
@ -392,7 +402,7 @@ function createClinicalPromptPool(opts) {
|
|||
var seen = new Set();
|
||||
var out = [];
|
||||
(Array.isArray(items) ? items : []).forEach(function(item) {
|
||||
var prompt = clip(String(item.prompt || item.question || '').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim(), 220);
|
||||
var prompt = clip(String(item.prompt || item.question || '').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim(), 320);
|
||||
if (!isUsefulQuestion(prompt, item)) return;
|
||||
var key = prompt.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
||||
if (seen.has(key)) return;
|
||||
|
|
@ -521,9 +531,17 @@ function createClinicalPromptPool(opts) {
|
|||
return taxonomyItem && taxonomyItem.ageBands && taxonomyItem.ageBands[0] || 'school_age';
|
||||
}
|
||||
|
||||
// A starter question is a case and a decision. "What red flags in a child's
|
||||
// headache history warrant investigation?" is a chapter heading; it survives
|
||||
// nothing here. A number is what tells a case from a heading — an age, a
|
||||
// vital sign, a lab, a dose, a duration — so one is required, and the
|
||||
// openers that only ever introduce a heading are refused outright.
|
||||
var TEXTBOOK_OPENER = /^(what (is|are)( the)? (definition|cause|causes|feature|features|sign|signs|symptom|symptoms|treatment|management|differential|complication|complications|indication|indications|role|difference)\b|what (is|are) (a |an |the )?[a-z\- ]{1,40}\?$|how (is|are|should) [a-z\- ]{1,60} (managed|treated|evaluated|diagnosed)\?$|which (clinical )?(scores?|tools?|scales?) (is|are) useful\b)/i;
|
||||
function isUsefulQuestion(prompt, item) {
|
||||
if (!prompt || prompt.length < 25 || prompt.length > 220) return false;
|
||||
if (!prompt || prompt.length < 40 || prompt.length > 320) return false;
|
||||
if (prompt.indexOf('?') === -1) return false;
|
||||
if (!/\d/.test(prompt)) return false;
|
||||
if (TEXTBOOK_OPENER.test(prompt)) return false;
|
||||
if (!hasPediatricSignal(prompt, item)) return false;
|
||||
return !/\b(source|snippet|textbook|chapter|document|database)\b/i.test(prompt);
|
||||
}
|
||||
|
|
@ -572,6 +590,8 @@ function createClinicalPromptPool(opts) {
|
|||
return {
|
||||
getAvailableExamples: getAvailableExamples,
|
||||
refreshIfNeeded: refreshIfNeeded,
|
||||
isUsefulQuestion: isUsefulQuestion,
|
||||
PROMPT_VERSION: PROMPT_VERSION,
|
||||
getMeta: getMeta,
|
||||
writePromptPool: writePromptPool
|
||||
};
|
||||
|
|
|
|||
|
|
@ -64,3 +64,52 @@ test('clinical assistant prompt requires bracketed citations in table source col
|
|||
assert.match(answer, /must use bracketed citation tokens like \[1\] or \[1, 3\]/);
|
||||
assert.match(answer, /never bare numbers like 1 or 1, 3/);
|
||||
});
|
||||
|
||||
// ---- what a starter question has to be ----------------------------------------
|
||||
|
||||
function fakePool(overrides) {
|
||||
const { createClinicalPromptPool } = require('../src/utils/clinicalPromptPool');
|
||||
const store = new Map();
|
||||
const redisCache = {
|
||||
getJson: async k => store.has(k) ? store.get(k) : null,
|
||||
setJson: async (k, v) => { store.set(k, v); }
|
||||
};
|
||||
return { store, pool: createClinicalPromptPool(Object.assign({
|
||||
redisCache, getSetting: async () => '', semanticSearch: async () => ({}), dedupeSources: x => x,
|
||||
normalizeMcpSearchResponse: () => [], callAI: async () => ({ content: '{"questions":[]}' })
|
||||
}, overrides || {})) };
|
||||
}
|
||||
|
||||
test('a starter question is a case and a decision, not a chapter heading', () => {
|
||||
const { pool } = fakePool();
|
||||
const ok = q => pool.isUsefulQuestion(q, { category: 'respiratory', intent: 'management', age_band: 'infant' });
|
||||
// What the old pool was full of.
|
||||
assert.equal(ok('What red flags in a child\'s headache history warrant further investigation?'), false, 'no number, no case');
|
||||
assert.equal(ok('Which clinical scores are useful for assessing asthma severity in children?'), false, 'a heading');
|
||||
assert.equal(ok('How is bronchiolitis managed in infants?'), false, 'a heading');
|
||||
assert.equal(ok('What are the causes of neonatal jaundice presenting at 2 days?'), false, 'a heading with a number in it is still a heading');
|
||||
// What it should hold.
|
||||
assert.equal(ok('A 6-week-old with 3 days of projectile non-bilious vomiting, weight down 8% and a chloride of 88: which fluid do you start, and what corrects before theatre is safe?'), true);
|
||||
assert.equal(ok('A 14-year-old on the ward with asthma, SpO2 91% after two salbutamol nebulisers: at what point do you add magnesium, and what dose?'), true);
|
||||
assert.equal(ok('In a neonate with bilious emesis and a scaphoid abdomen at 2 days of life, which imaging comes first and what wait is acceptable?'), true);
|
||||
});
|
||||
|
||||
test('a pool built by an older prompt is served, then rebuilt in the background', async () => {
|
||||
let generated = 0;
|
||||
const { store, pool } = fakePool({
|
||||
callAI: async () => { generated++; return { content: '{"questions":[]}' }; }
|
||||
});
|
||||
// A fresh pool from the previous prompt version, one day old.
|
||||
const old = { generatedAt: Date.now() - 86400000, promptVersion: pool.PROMPT_VERSION - 1, target: 10,
|
||||
examples: [{ label: 'Old', prompt: 'Old question?', category: 'respiratory', age_band: 'infant', intent: 'management' }] };
|
||||
await pool.writePromptPool(old, { skipStore: true });
|
||||
const served = await pool.getAvailableExamples();
|
||||
assert.equal(served[0].prompt, 'Old question?', 'the old pool is what the reader gets meanwhile');
|
||||
await new Promise(r => setTimeout(r, 20));
|
||||
assert.ok(generated > 0 || store.size >= 1, 'a rebuild was started');
|
||||
// The same pool stamped with the current version is not stale.
|
||||
const fresh = Object.assign({}, old, { promptVersion: pool.PROMPT_VERSION });
|
||||
await pool.writePromptPool(fresh, { skipStore: true });
|
||||
const again = await pool.refreshIfNeeded(false);
|
||||
assert.equal(again[0].prompt, 'Old question?');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue