Cases are three to four times the length of headings, and 30 of them did not fit the 2,600-token ceiling: the JSON was cut mid-list, failed to parse, and whole categories came back with nothing kept. The ceiling is 7,000, batches are 20, and a reply that is still cut off yields every question that finished rather than none. Each batch logs what it offered. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
625 lines
29 KiB
JavaScript
625 lines
29 KiB
JavaScript
function positiveInt(value, fallback) {
|
|
var n = Number(value);
|
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
|
}
|
|
|
|
function clip(text, max) {
|
|
text = String(text == null ? '' : text);
|
|
return text.length > max ? text.substring(0, max - 1).trimEnd() + '…' : text;
|
|
}
|
|
|
|
var PEDIATRIC_TAXONOMY = [
|
|
{
|
|
category: 'neonates', weight: 85, ageBands: ['neonate'],
|
|
seeds: [
|
|
'febrile neonate sepsis evaluation empiric antibiotics lumbar puncture',
|
|
'neonatal jaundice bilirubin phototherapy cholestasis red flags',
|
|
'newborn respiratory distress differential management NICU admission',
|
|
'neonate poor feeding vomiting bilious emesis congenital obstruction',
|
|
'neonatal hypoglycemia seizures lethargy initial management'
|
|
]
|
|
},
|
|
{
|
|
category: 'respiratory', weight: 85, ageBands: ['infant', 'toddler', 'school_age', 'adolescent'],
|
|
seeds: [
|
|
'pediatric asthma exacerbation severity treatment discharge criteria',
|
|
'bronchiolitis infant oxygen hydration admission criteria',
|
|
'croup stridor epiglottitis bacterial tracheitis airway red flags',
|
|
'pediatric pneumonia wheezing foreign body aspiration differential',
|
|
'cystic fibrosis respiratory exacerbation airway clearance antibiotics'
|
|
]
|
|
},
|
|
{
|
|
category: 'gi', weight: 75, ageBands: ['infant', 'toddler', 'school_age', 'adolescent'],
|
|
seeds: [
|
|
'pediatric abdominal pain appendicitis intussusception differential',
|
|
'acute gastroenteritis dehydration oral rehydration ondansetron',
|
|
'bloody diarrhea hemolytic uremic syndrome STEC salmonella',
|
|
'pediatric vomiting bilious emesis increased intracranial pressure',
|
|
'constipation encopresis functional abdominal pain counseling'
|
|
]
|
|
},
|
|
{
|
|
category: 'infectious_disease', weight: 75, ageBands: ['neonate', 'infant', 'toddler', 'school_age', 'adolescent'],
|
|
seeds: [
|
|
'pediatric fever without source serious bacterial infection workup',
|
|
'meningitis encephalitis sepsis child empiric antibiotics',
|
|
'pertussis exposure prophylaxis vaccination household contacts',
|
|
'skin soft tissue infection cellulitis abscess child antibiotics',
|
|
'fever of unknown origin child infectious noninfectious differential'
|
|
]
|
|
},
|
|
{
|
|
category: 'emergency', weight: 70, ageBands: ['neonate', 'infant', 'toddler', 'school_age', 'adolescent'],
|
|
seeds: [
|
|
'pediatric emergency triage red flags resuscitation shock',
|
|
'child altered mental status seizure hypoglycemia toxic ingestion',
|
|
'pediatric anaphylaxis epinephrine airway hypotension disposition',
|
|
'trauma child head injury abdominal injury non accidental trauma',
|
|
'pediatric shock dehydration sepsis cardiac emergency management'
|
|
]
|
|
},
|
|
{
|
|
category: 'derm', weight: 55, ageBands: ['neonate', 'infant', 'toddler', 'school_age', 'adolescent'],
|
|
seeds: [
|
|
'pediatric rash fever petechiae purpura emergency differential',
|
|
'newborn skin lesions hemangioma melanocytic nevus epidermolysis bullosa',
|
|
'eczema atopic dermatitis impetigo cellulitis child management',
|
|
'urticaria angioedema anaphylaxis pediatric counseling',
|
|
'Kawasaki disease mucocutaneous findings MIS-C differential'
|
|
]
|
|
},
|
|
{
|
|
category: 'endocrine', weight: 55, ageBands: ['infant', 'toddler', 'school_age', 'adolescent'],
|
|
seeds: [
|
|
'diabetic ketoacidosis child fluids insulin cerebral edema',
|
|
'pediatric hypoglycemia adrenal insufficiency endocrine emergency',
|
|
'short stature delayed puberty precocious puberty evaluation',
|
|
'thyroid disease child hyperthyroidism hypothyroidism symptoms',
|
|
'polyuria polydipsia diabetes insipidus diabetes mellitus child'
|
|
]
|
|
},
|
|
{
|
|
category: 'cardiology', weight: 55, ageBands: ['neonate', 'infant', 'toddler', 'school_age', 'adolescent'],
|
|
seeds: [
|
|
'pediatric chest pain syncope murmur red flags cardiology referral',
|
|
'cyanotic congenital heart disease neonate prostaglandin ductal dependent',
|
|
'heart failure infant poor feeding tachypnea hepatomegaly',
|
|
'arrhythmia palpitations supraventricular tachycardia child management',
|
|
'Kawasaki disease coronary aneurysm aspirin IVIG'
|
|
]
|
|
},
|
|
{
|
|
category: 'neurology', weight: 65, ageBands: ['infant', 'toddler', 'school_age', 'adolescent'],
|
|
seeds: [
|
|
'pediatric headache red flags neuroimaging referral',
|
|
'first seizure child febrile seizure epilepsy workup',
|
|
'altered mental status child meningitis encephalitis toxic metabolic',
|
|
'developmental regression weakness ataxia neurologic emergency',
|
|
'migraine child acute treatment prevention counseling'
|
|
]
|
|
},
|
|
{
|
|
category: 'renal', weight: 50, ageBands: ['infant', 'toddler', 'school_age', 'adolescent'],
|
|
seeds: [
|
|
'urinary tract infection child fever pyelonephritis imaging',
|
|
'hematuria proteinuria nephrotic nephritic syndrome child',
|
|
'acute kidney injury child dehydration sepsis nephrotoxin workup',
|
|
'hypertension pediatric evaluation renal endocrine cardiac',
|
|
'electrolyte disorders child sodium potassium emergency management'
|
|
]
|
|
},
|
|
{
|
|
category: 'rheum', weight: 45, ageBands: ['toddler', 'school_age', 'adolescent'],
|
|
seeds: [
|
|
'juvenile idiopathic arthritis limp joint swelling fever differential',
|
|
'Kawasaki MIS-C fever rash conjunctivitis pediatric differential',
|
|
'pediatric lupus vasculitis purpura renal symptoms workup',
|
|
'periodic fever syndromes child aphthous pharyngitis adenitis',
|
|
'limp child septic arthritis osteomyelitis transient synovitis'
|
|
]
|
|
},
|
|
{
|
|
category: 'hematology', weight: 55, ageBands: ['infant', 'toddler', 'school_age', 'adolescent'],
|
|
seeds: [
|
|
'pediatric anemia microcytic hemolytic iron deficiency workup',
|
|
'sickle cell fever pain crisis acute chest child management',
|
|
'thrombocytopenia petechiae purpura ITP leukemia child differential',
|
|
'bleeding bruising child coagulation disorder non accidental trauma',
|
|
'neutropenia fever oncology pediatric emergency antibiotics'
|
|
]
|
|
},
|
|
{
|
|
category: 'development', weight: 60, ageBands: ['infant', 'toddler', 'school_age', 'adolescent'],
|
|
seeds: [
|
|
'developmental milestones delay screening autism speech motor',
|
|
'ADHD learning difficulty school performance pediatric assessment',
|
|
'autism spectrum toddler screening counseling referral',
|
|
'developmental regression child neurologic metabolic genetic evaluation',
|
|
'well child developmental behavioral screening anticipatory guidance'
|
|
]
|
|
},
|
|
{
|
|
category: 'toxicology', weight: 45, ageBands: ['toddler', 'school_age', 'adolescent'],
|
|
seeds: [
|
|
'pediatric ingestion acetaminophen iron button battery emergency',
|
|
'adolescent overdose suicide risk toxidrome initial management',
|
|
'carbon monoxide poisoning child headache altered mental status',
|
|
'caustic ingestion child drooling dysphagia endoscopy',
|
|
'medication poisoning toddler decontamination antidote observation'
|
|
]
|
|
},
|
|
{
|
|
category: 'procedures', weight: 45, ageBands: ['infant', 'toddler', 'school_age', 'adolescent'],
|
|
seeds: [
|
|
'pediatric lumbar puncture indications contraindications consent',
|
|
'laceration repair child sedation analgesia wound infection',
|
|
'foreign body removal ear nose airway child management',
|
|
'splinting fracture child neurovascular assessment analgesia',
|
|
'procedural sedation pediatric fasting monitoring discharge criteria'
|
|
]
|
|
},
|
|
{
|
|
category: 'dosing_fluids', weight: 70, ageBands: ['neonate', 'infant', 'toddler', 'school_age', 'adolescent'],
|
|
seeds: [
|
|
'pediatric maintenance fluids dehydration bolus electrolyte correction',
|
|
'weight based medication dosing child safety maximum dose',
|
|
'oral rehydration solution dosing gastroenteritis child',
|
|
'DKA fluids insulin potassium pediatric cerebral edema',
|
|
'antibiotic dosing renal adjustment pediatric infection'
|
|
]
|
|
},
|
|
{
|
|
category: 'adolescent', weight: 60, ageBands: ['adolescent'],
|
|
seeds: [
|
|
'adolescent abdominal pain pregnancy STI pelvic inflammatory disease',
|
|
'depression suicide screening adolescent confidentiality safety plan',
|
|
'eating disorder adolescent bradycardia weight loss admission criteria',
|
|
'substance use adolescent confidential history counseling',
|
|
'sports injury concussion return to play adolescent'
|
|
]
|
|
},
|
|
{
|
|
category: 'counseling', weight: 55, ageBands: ['neonate', 'infant', 'toddler', 'school_age', 'adolescent'],
|
|
seeds: [
|
|
'parent counseling fever return precautions child safety net',
|
|
'vaccine counseling pediatric hesitancy contraindications',
|
|
'asthma action plan inhaler technique parent education',
|
|
'gastroenteritis home care hydration return precautions',
|
|
'newborn discharge anticipatory guidance feeding jaundice safe sleep'
|
|
]
|
|
}
|
|
];
|
|
|
|
var VALID_INTENTS = {
|
|
diagnosis: true,
|
|
management: true,
|
|
red_flags: true,
|
|
differential: true,
|
|
counseling: true,
|
|
dosing: true,
|
|
admission: true,
|
|
discharge: true,
|
|
review: true
|
|
};
|
|
|
|
var VALID_AGE_BANDS = {
|
|
neonate: true,
|
|
infant: true,
|
|
toddler: true,
|
|
school_age: true,
|
|
adolescent: true
|
|
};
|
|
|
|
function taxonomyByCategory() {
|
|
var out = {};
|
|
PEDIATRIC_TAXONOMY.forEach(function(item) { out[item.category] = item; });
|
|
return out;
|
|
}
|
|
|
|
var TAXONOMY_BY_CATEGORY = taxonomyByCategory();
|
|
var CATEGORY_ALIASES = {
|
|
'infectious disease': 'infectious_disease',
|
|
infectious: 'infectious_disease',
|
|
'dosing/fluids': 'dosing_fluids',
|
|
dosing: 'dosing_fluids',
|
|
fluids: 'dosing_fluids',
|
|
'red flags': 'emergency',
|
|
'admission/discharge': 'emergency',
|
|
general: 'counseling'
|
|
};
|
|
|
|
function parseJsonObject(text) {
|
|
text = String(text || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
|
|
try { return JSON.parse(text); } catch (e) {}
|
|
var start = text.indexOf('{');
|
|
var end = text.lastIndexOf('}');
|
|
if (start !== -1 && end > start) {
|
|
try { return JSON.parse(text.slice(start, end + 1)); } catch (e2) {}
|
|
}
|
|
// A reply cut off mid-list still holds every question that finished. Each
|
|
// complete {...} object is taken on its own rather than the whole batch
|
|
// being thrown away — the way an entire category came back with nothing.
|
|
var questions = [];
|
|
var objects = text.match(/\{[^{}]*"prompt"\s*:\s*"(?:[^"\\]|\\.)*"[^{}]*\}/g) || [];
|
|
objects.forEach(function (chunk) {
|
|
try { questions.push(JSON.parse(chunk)); } catch (e3) {}
|
|
});
|
|
return questions.length ? { questions: questions, truncated: true } : {};
|
|
}
|
|
|
|
// 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);
|
|
var refreshMs = nonNegativeInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_REFRESH_MS, 7 * 24 * 60 * 60 * 1000);
|
|
var ttlSeconds = refreshMs > 0 ? Math.max(3600, Math.ceil(refreshMs / 1000) * 2) : 0;
|
|
var target = positiveInt(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_TARGET, 1000);
|
|
var redisBaseKey = String(process.env.CLINICAL_ASSISTANT_PROMPT_POOL_KEY || 'clinical-assistant:prompt-pool:v2').replace(/:all$/, '');
|
|
var redisAllKey = redisBaseKey + ':all';
|
|
var redisMetaKey = redisBaseKey + ':meta';
|
|
var redisLastGoodKey = redisBaseKey + ':last-good';
|
|
var memoryCache = { expiresAt: 0, examples: [] };
|
|
var refreshPromise = null;
|
|
|
|
async function getAvailableExamples() {
|
|
var now = Date.now();
|
|
if (memoryCache.expiresAt > now && memoryCache.examples.length) return memoryCache.examples;
|
|
var cached = await readPromptPool();
|
|
if (cached && Array.isArray(cached.examples) && cached.examples.length) {
|
|
memoryCache = { expiresAt: now + cacheMs, examples: cached.examples };
|
|
if (isStale(cached, now)) refreshIfNeeded(false).catch(function() {});
|
|
return cached.examples;
|
|
}
|
|
|
|
refreshIfNeeded(false).catch(function() {});
|
|
|
|
var examples = await fallbackIndexedExamples();
|
|
|
|
memoryCache = { expiresAt: now + cacheMs, examples: examples };
|
|
return examples;
|
|
}
|
|
|
|
async function refreshIfNeeded(force, context) {
|
|
context = context || {};
|
|
var now = Date.now();
|
|
var cached = await readPromptPool();
|
|
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(), 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) {
|
|
refreshPromise = null;
|
|
console.warn('[clinical-assistant] prompt pool refresh failed:', e.message);
|
|
return cached && Array.isArray(cached.examples) ? cached.examples : [];
|
|
});
|
|
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;
|
|
var lastGood = await opts.redisCache.getJson(redisLastGoodKey).catch(function() { return null; });
|
|
if (lastGood && Array.isArray(lastGood.examples) && lastGood.examples.length) return lastGood;
|
|
if (typeof opts.loadStoredPromptPool === 'function') {
|
|
var stored = await opts.loadStoredPromptPool().catch(function() { return null; });
|
|
if (stored && Array.isArray(stored.examples) && stored.examples.length) {
|
|
await writePromptPool(stored, { skipStore: true }).catch(function() {});
|
|
return stored;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function buildCorpusPromptPool() {
|
|
var taxonomy = taxonomyWithQuotas(target);
|
|
var chatModel = await opts.getSetting('clinical_assistant.prompt_model', '') || process.env.CLINICAL_ASSISTANT_PROMPT_MODEL || await opts.getSetting('clinical_assistant.chat_model', '') || await opts.getSetting('models.default', '');
|
|
var generated = [];
|
|
var startedAt = Date.now();
|
|
// A build that produced nothing used to vanish without a word; now it says
|
|
// what it did per category, so an empty pool can be traced to its cause.
|
|
console.info('[clinical-assistant] prompt pool build started: ' + taxonomy.length + ' categories, target ' + target + ', model ' + (chatModel || 'default'));
|
|
for (var t = 0; t < taxonomy.length && generated.length < target; t++) {
|
|
var item = taxonomy[t];
|
|
var snippets = await collectPromptSeedSnippets(item);
|
|
if (!snippets.length) continue;
|
|
var categoryExamples = [];
|
|
var batches = Math.max(1, Math.min(6, Math.ceil(item.quota / 18)));
|
|
for (var i = 0; i < batches && categoryExamples.length < item.quota; i++) {
|
|
var batchSnippets = rotateExamples(snippets, i * 18).slice(0, 18);
|
|
var sourceText = batchSnippets.map(function(s, idx) {
|
|
return '[' + (idx + 1) + '] ' + s.title + (s.page ? ', page ' + s.page : '') + '\n' + clip(s.excerpt, 700);
|
|
}).join('\n\n');
|
|
var ai = await opts.callAI([
|
|
{
|
|
role: 'system',
|
|
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 20 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,
|
|
// A case is three to four times the length of a heading; the old
|
|
// ceiling cut the list off mid-JSON and a whole category was lost.
|
|
maxTokens: 7000
|
|
});
|
|
var parsed = parseJsonObject(String(ai.content || ''));
|
|
var offered = Array.isArray(parsed.questions) ? parsed.questions.length : 0;
|
|
if (!offered) console.warn('[clinical-assistant] prompt pool ' + item.category + ' batch ' + (i + 1) + ': nothing parseable in ' + String(ai.content || '').length + ' chars');
|
|
else if (parsed.truncated) console.warn('[clinical-assistant] prompt pool ' + item.category + ' batch ' + (i + 1) + ': reply was cut off; ' + offered + ' questions salvaged');
|
|
categoryExamples = normalizeGeneratedExamples(categoryExamples.concat(parsed.questions || []), item).slice(0, item.quota);
|
|
}
|
|
generated = normalizeGeneratedExamples(generated.concat(categoryExamples));
|
|
console.info('[clinical-assistant] prompt pool ' + item.category + ': ' + categoryExamples.length + ' kept of quota ' + item.quota + ' (' + snippets.length + ' snippets)');
|
|
}
|
|
console.info('[clinical-assistant] prompt pool build finished: ' + generated.length + ' questions in ' + Math.round((Date.now() - startedAt) / 1000) + 's');
|
|
if (generated.length < 8) return [];
|
|
return generated.slice(0, target);
|
|
}
|
|
|
|
async function collectPromptSeedSnippets(taxonomyItem) {
|
|
var seeds = promptSeedQueries(taxonomyItem);
|
|
var seen = new Set();
|
|
var snippets = [];
|
|
for (var i = 0; i < seeds.length && snippets.length < 36; i++) {
|
|
try {
|
|
var searchResponse = await opts.semanticSearch(seeds[i], { limit: 5, includeContext: true, contextChars: 900 });
|
|
opts.dedupeSources(opts.normalizeMcpSearchResponse(searchResponse)).forEach(function(source) {
|
|
var key = [source.title, source.page, source.chunk_index].join('|');
|
|
if (seen.has(key) || snippets.length >= 36) return;
|
|
seen.add(key);
|
|
snippets.push(source);
|
|
});
|
|
} catch (e) {
|
|
continue;
|
|
}
|
|
}
|
|
return snippets;
|
|
}
|
|
|
|
function promptSeedQueries(taxonomyItem) {
|
|
var seeds = (opts.exampleCandidates || []).map(function(item) { return item.prompt; });
|
|
Object.keys(opts.topicSuggestions || {}).forEach(function(key) {
|
|
seeds = seeds.concat(opts.topicSuggestions[key]);
|
|
});
|
|
seeds = seeds.concat(taxonomyItem.seeds).concat([
|
|
taxonomyItem.category + ' pediatric ' + taxonomyItem.seeds.join(' '),
|
|
'pediatric emergency red flags admission criteria treatment dosing',
|
|
'neonatal fever jaundice respiratory distress vomiting dehydration',
|
|
'childhood infectious diseases empiric antibiotics workup disposition',
|
|
'pediatric respiratory illness asthma bronchiolitis pneumonia croup',
|
|
'pediatric gastrointestinal dehydration abdominal pain bilious vomiting',
|
|
'developmental milestones anemia endocrine neurologic pediatric review'
|
|
]);
|
|
return shuffle(seeds).slice(0, 8);
|
|
}
|
|
|
|
function fallbackIndexedExamples() {
|
|
return typeof opts.getIndexedTopicExamples === 'function' ? opts.getIndexedTopicExamples().catch(function() { return []; }) : [];
|
|
}
|
|
|
|
async function getMeta() {
|
|
return await opts.redisCache.getJson(redisMetaKey).catch(function() { return null; });
|
|
}
|
|
|
|
function normalizeGeneratedExamples(items, defaultTaxonomy) {
|
|
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(), 320);
|
|
if (!isUsefulQuestion(prompt, item)) return;
|
|
var key = prompt.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
if (seen.has(key)) return;
|
|
seen.add(key);
|
|
var category = sanitizeCategory(item.category, defaultTaxonomy);
|
|
var ageBand = sanitizeAgeBand(item.age_band || item.ageBand || item.age, TAXONOMY_BY_CATEGORY[category] || defaultTaxonomy, prompt);
|
|
out.push({
|
|
label: cleanExampleLabel(item.label || prompt),
|
|
prompt: prompt,
|
|
category: category,
|
|
age_band: ageBand,
|
|
intent: sanitizeIntent(item.intent)
|
|
});
|
|
});
|
|
return out.slice(0, target);
|
|
}
|
|
|
|
async function writePromptPool(payload, context) {
|
|
context = context || {};
|
|
var counts = buildPoolCounts(payload.examples);
|
|
var writes = [
|
|
opts.redisCache.setJson(redisAllKey, payload, ttlSeconds),
|
|
opts.redisCache.setJson(redisLastGoodKey, payload, 0),
|
|
opts.redisCache.setJson(redisMetaKey, {
|
|
generatedAt: payload.generatedAt,
|
|
target: target,
|
|
count: payload.examples.length,
|
|
categories: counts.categories,
|
|
age_bands: counts.age_bands,
|
|
intents: counts.intents
|
|
}, ttlSeconds)
|
|
];
|
|
Object.keys(counts.categoryExamples).forEach(function(category) {
|
|
writes.push(opts.redisCache.setJson(redisBaseKey + ':category:' + category, {
|
|
generatedAt: payload.generatedAt,
|
|
category: category,
|
|
examples: counts.categoryExamples[category]
|
|
}, ttlSeconds));
|
|
});
|
|
Object.keys(counts.ageExamples).forEach(function(ageBand) {
|
|
writes.push(opts.redisCache.setJson(redisBaseKey + ':age:' + ageBand, {
|
|
generatedAt: payload.generatedAt,
|
|
age_band: ageBand,
|
|
examples: counts.ageExamples[ageBand]
|
|
}, ttlSeconds));
|
|
});
|
|
Object.keys(counts.intentExamples).forEach(function(intent) {
|
|
writes.push(opts.redisCache.setJson(redisBaseKey + ':intent:' + intent, {
|
|
generatedAt: payload.generatedAt,
|
|
intent: intent,
|
|
examples: counts.intentExamples[intent]
|
|
}, ttlSeconds));
|
|
});
|
|
await Promise.all(writes);
|
|
if (!context.skipStore && typeof opts.savePromptPool === 'function') {
|
|
await opts.savePromptPool(payload, context).catch(function(e) {
|
|
console.warn('[clinical-assistant] prompt pool snapshot save failed:', e.message);
|
|
});
|
|
}
|
|
}
|
|
|
|
function buildPoolCounts(examples) {
|
|
var counts = { categories: {}, age_bands: {}, intents: {}, categoryExamples: {}, ageExamples: {}, intentExamples: {} };
|
|
examples.forEach(function(example) {
|
|
addCount(counts.categories, example.category);
|
|
addCount(counts.age_bands, example.age_band);
|
|
addCount(counts.intents, example.intent);
|
|
pushGroup(counts.categoryExamples, example.category, example);
|
|
pushGroup(counts.ageExamples, example.age_band, example);
|
|
pushGroup(counts.intentExamples, example.intent, example);
|
|
});
|
|
return counts;
|
|
}
|
|
|
|
function addCount(counts, key) {
|
|
counts[key] = (counts[key] || 0) + 1;
|
|
}
|
|
|
|
function pushGroup(groups, key, example) {
|
|
if (!groups[key]) groups[key] = [];
|
|
groups[key].push(example);
|
|
}
|
|
|
|
function taxonomyWithQuotas(total) {
|
|
var weightTotal = PEDIATRIC_TAXONOMY.reduce(function(sum, item) { return sum + item.weight; }, 0);
|
|
var assigned = 0;
|
|
var rows = PEDIATRIC_TAXONOMY.map(function(item) {
|
|
var exact = total * item.weight / weightTotal;
|
|
var quota = Math.max(10, Math.floor(exact));
|
|
assigned += quota;
|
|
return Object.assign({}, item, { quota: quota, remainder: exact - Math.floor(exact) });
|
|
}).sort(function(a, b) { return b.remainder - a.remainder; });
|
|
for (var i = 0; assigned < total; i = (i + 1) % rows.length) {
|
|
rows[i].quota += 1;
|
|
assigned += 1;
|
|
}
|
|
for (var j = rows.length - 1; assigned > total && j >= 0; j--) {
|
|
if (rows[j].quota <= 10) continue;
|
|
rows[j].quota -= 1;
|
|
assigned -= 1;
|
|
}
|
|
return rows.sort(function(a, b) {
|
|
return PEDIATRIC_TAXONOMY.indexOf(TAXONOMY_BY_CATEGORY[a.category]) - PEDIATRIC_TAXONOMY.indexOf(TAXONOMY_BY_CATEGORY[b.category]);
|
|
});
|
|
}
|
|
|
|
function sanitizeCategory(category, defaultTaxonomy) {
|
|
category = String(category || '').trim().toLowerCase().replace(/[\s/-]+/g, '_');
|
|
category = CATEGORY_ALIASES[category] || CATEGORY_ALIASES[category.replace(/_/g, ' ')] || category;
|
|
if (TAXONOMY_BY_CATEGORY[category]) return category;
|
|
return defaultTaxonomy && defaultTaxonomy.category || 'counseling';
|
|
}
|
|
|
|
function sanitizeIntent(intent) {
|
|
intent = String(intent || 'review').trim().toLowerCase().replace(/[\s/-]+/g, '_');
|
|
return VALID_INTENTS[intent] ? intent : 'review';
|
|
}
|
|
|
|
function sanitizeAgeBand(ageBand, taxonomyItem, prompt) {
|
|
ageBand = String(ageBand || '').trim().toLowerCase().replace(/[\s/-]+/g, '_');
|
|
if (VALID_AGE_BANDS[ageBand]) return ageBand;
|
|
if (/\b(neonate|newborn)\b/i.test(prompt)) return 'neonate';
|
|
if (/\b(infant|baby)\b/i.test(prompt)) return 'infant';
|
|
if (/\b(toddler)\b/i.test(prompt)) return 'toddler';
|
|
if (/\b(adolescent|teen)\b/i.test(prompt)) return 'adolescent';
|
|
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 < 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);
|
|
}
|
|
|
|
function hasPediatricSignal(prompt, item) {
|
|
if (/\b(child|children|pediatric|paediatric|infant|neonate|newborn|adolescent|teen|toddler|baby)\b/i.test(prompt)) return true;
|
|
var category = sanitizeCategory(item && item.category, null);
|
|
var intent = sanitizeIntent(item && item.intent);
|
|
var ageBand = String(item && (item.age_band || item.ageBand || item.age) || '').toLowerCase().replace(/[\s/-]+/g, '_');
|
|
return !!TAXONOMY_BY_CATEGORY[category] && !!VALID_INTENTS[intent] && (!ageBand || !!VALID_AGE_BANDS[ageBand]);
|
|
}
|
|
|
|
function cleanExampleLabel(label) {
|
|
label = String(label || '').replace(/[?!.:,;]+$/g, '').replace(/\s+/g, ' ').trim();
|
|
if (!label || label.length > 42) label = labelFromQuestion(label || 'Clinical question');
|
|
return clip(label, 42);
|
|
}
|
|
|
|
function labelFromQuestion(question) {
|
|
return String(question || 'Clinical question')
|
|
.replace(/^(how|what|when|which|why)\s+(should|do|does|can|are|is)\s+/i, '')
|
|
.replace(/\?.*$/, '')
|
|
.split(/\s+/)
|
|
.slice(0, 4)
|
|
.join(' ') || 'Clinical question';
|
|
}
|
|
|
|
function rotateExamples(items, seed) {
|
|
if (!items.length) return [];
|
|
var copy = items.slice();
|
|
var offset = Math.abs(Math.floor(seed)) % copy.length;
|
|
return copy.slice(offset).concat(copy.slice(0, offset));
|
|
}
|
|
|
|
function shuffle(items) {
|
|
var copy = items.slice();
|
|
for (var i = copy.length - 1; i > 0; i--) {
|
|
var j = Math.floor(Math.random() * (i + 1));
|
|
var tmp = copy[i];
|
|
copy[i] = copy[j];
|
|
copy[j] = tmp;
|
|
}
|
|
return copy;
|
|
}
|
|
|
|
return {
|
|
getAvailableExamples: getAvailableExamples,
|
|
refreshIfNeeded: refreshIfNeeded,
|
|
isUsefulQuestion: isUsefulQuestion,
|
|
PROMPT_VERSION: PROMPT_VERSION,
|
|
getMeta: getMeta,
|
|
writePromptPool: writePromptPool
|
|
};
|
|
}
|
|
|
|
function nonNegativeInt(value, fallback) {
|
|
if (value == null || value === '') return fallback;
|
|
var n = Number(value);
|
|
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
|
|
}
|
|
|
|
module.exports = { createClinicalPromptPool };
|