pediatric-ai-scribe-v3/src/utils/clinicalAnswer.js
Daniel db83255c58 feat: display-only sources toggle, signed-out preview, and a composer that carries the toolbar
Sources (correcting what I built earlier)
The previous toggle branched the SYSTEM PROMPT, so the same question could get a
different answer depending on a display setting — the bias this was meant to
avoid. The prompt is now unconditional: buildSystemPrompt takes no display
argument and is byte-identical either way. Hiding sources happens on the way out
— the server omits them and strips the now-orphaned [n] markers from the copy it
sends. The answer is generated, stored and exported with citations intact, so
turning the setting back on restores them without re-asking anything. Renamed to
clinical_assistant.show_sources; the old key is still honoured.

Signed-out preview (admin opt-in, default off)
A visitor may try the assistant; reaching for the workspace asks them to sign in.
Deliberately narrow:
- Reachable paths are an exact allow-list, not a pattern, so a new endpoint is
  private unless someone adds it on purpose.
- A preview visitor gets no identity at all (id: null), so nothing can be owned,
  saved, billed or addressed to them.
- The image tool is withheld rather than left to fail on a null owner, and no
  audit rows are written.
- A caller presenting a token is authenticated normally, so preview can never
  downgrade a real session; if the setting cannot be read, authentication is
  required.
- Actions needing an account are hidden rather than offered and refused.

Composer
The bar above the transcript is gone. Patient take home, Export PDF, Download
transcript and Attach images moved into a + menu in the composer, and the model
selector moved beside send — shown only when there is more than one model, as
before. Both views now start at the same top edge, so switching modes cannot
nudge the page up or down. On an empty transcript the tiled ground runs behind
and below the composer, which floats on it above centre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
2026-09-10 04:12:36 +02:00

90 lines
7.2 KiB
JavaScript

// The prompt is IDENTICAL whether or not sources are displayed. Branching it
// would change how the model reasons and cites, so the same question could get a
// different answer depending on a display setting — exactly the bias this must
// avoid. Hiding sources is a presentation decision, applied after generation.
function buildSystemPrompt(behavior) {
return behavior + '\n\nIf the user asks for an image, call the generate_image tool with a self-contained prompt and briefly say you are preparing the image; the image appears automatically. Do not merely write an image prompt.\n\nRules:\n- Answer only the user question; do not dump unrelated textbook content.\n- For recognizable medical terms, abbreviations, diseases, and acronyms, answer directly without prefacing with "Assuming you meant".\n- For genuinely misspelled or partial terms, use the retrieved sources to infer the closest medical concept when there is a plausible match, then answer directly. Ask for clarification only when the retrieved sources do not indicate any plausible concept.\n- Use the exact source numbers from the retrieved sources; do not renumber citations for order or style.\n- Cite factual claims immediately with numbered citations like [1] or [1, 3]. Never escape citation brackets: write [1], not \\[1\\]; reserved LaTeX delimiters are not citations.\n- Every clinical recommendation, dose, threshold, lab value, statistic, comparison, contraindication, red flag, and table row must include its own supporting citation.\n- If a table has a Source, Source(s), Citation, or Citation(s) column, every cell in that column must use bracketed citation tokens like [1] or [1, 3], never bare numbers like 1 or 1, 3.\n- Do not leave a paragraph, bullet, or table row with multiple factual claims supported only by an uncited heading.\n- Do not cite a source number that is not provided.\n- If a claim is not directly supported by retrieved sources, omit it or say the available sources are insufficient.\n- If the user names a specific source, textbook, guideline, or table, do not claim that another source is from the named source. If the named source is absent from the retrieved sources, say that explicitly before using other sources.\n- If retrieved sources mention the medication/intervention only for other diseases, explicitly say the available sources do not support it for the user\'s requested disease.\n- If sources disagree or are insufficient, say so.\n- Keep the main answer concise and clinically useful.\n- Use clear markdown with headings, bullets, and tables when useful.\n- When using a table, output a valid GitHub-flavored markdown table with pipe characters and a separator row. Never output tab-separated tables.\n- Put any summary sentence in a separate paragraph after the table, not as a table row.\n- Do not add a final Sources or References section; the UI displays all retrieved sources separately.\n- Do not add generic disclaimers about clinician judgment.';
}
// With the sources panel hidden the [n] markers point at nothing, so they are
// removed from the copy that is DISPLAYED. The answer is generated, stored and
// exported with its citations intact, so turning the setting back on restores
// them without re-asking anything.
function stripCitationMarkers(answer) {
return String(answer || '')
.replace(/(?:\s*\[(?:\d+\s*,\s*)*\d+\])+/g, '')
.replace(/[ \t]+([.,;:])/g, '$1')
.replace(/[ \t]{2,}/g, ' ');
}
function buildUserPrompt(question, context, history, searchQuery) {
var hist = history.map(function(m) { return m.role.toUpperCase() + ': ' + String(m.content); }).join('\n');
var searchNote = searchQuery && searchQuery !== question ? ('\n\nStandalone retrieval query used:\n' + searchQuery) : '';
return 'Question:\n' + question + searchNote + '\n\nFull conversation context (prior AI output is not evidence; use fresh retrieved sources for factual claims):\n' + (hist || 'None') + '\n\nRetrieved sources:\n' + context + '\n\nWrite the answer now. If the question is a short misspelled or partial term and the sources point to a likely concept, answer the likely concept rather than asking for clarification.';
}
function assistantGenerationOptions(overrides) {
return Object.assign({
reasoningEffort: 'low',
reasoningFormat: 'hidden'
}, overrides || {});
}
async function finalizeAssistantAnswer(ai, options) {
options = options || {};
var answer = stripModelSourcesSection(String(ai && ai.content || '').trim());
if (shouldRegenerateTruncatedAnswer(answer, ai && ai.finishReason) && typeof options.callAI === 'function') {
console.warn('[clinical-assistant] answer looked truncated; regenerating final answer', { finishReason: ai && ai.finishReason, chars: answer.length, streamed: Boolean(options.streamed) });
if (typeof options.onRegenerating === 'function') options.onRegenerating();
var completed = await options.callAI(options.messages, Object.assign({}, options.generationOptions || {}, {
model: options.chatModel || undefined,
maxTokens: 5000
}));
answer = stripModelSourcesSection(String(completed.content || '').trim()) || answer;
ai.model = completed.model || ai.model;
ai.provider = completed.provider || ai.provider;
ai.finishReason = completed.finishReason || ai.finishReason;
}
return { answer: answer, ai: ai };
}
function stripModelSourcesSection(answer) {
return cleanDanglingSourceLeadIn(String(answer || '')
.replace(/(?:^|\s)(?:\bsrc\b\s*){2,}$/i, '')
.replace(/\bsrc\b(?=\s*src\b)/gi, '')
.replace(/\n\s*(---\s*)?(#{1,3}\s*)?(Sources|References)\s*\n[\s\S]*$/i, '')
.replace(/\n\s*>?\s*(⚠️\s*)?Clinical decision support[^\n]*$/gim, '')
.trim());
}
function cleanDanglingSourceLeadIn(answer) {
answer = String(answer || '').trim();
return answer
.replace(/([.!?])\s+(?:however,?\s*)?(?:but\s*)?(?:the\s*)?available(?:\s+(?:sources?|retrieved\s+sources?|evidence|material))?\s*$/i, '$1')
.replace(/([.!?])\s+(?:however,?\s*)?(?:but\s*)?(?:based\s+on\s+)?(?:the\s*)?available\s*$/i, '$1')
.replace(/(?:^|\n\n)(?:however,?\s*)?(?:but\s*)?(?:the\s*)?available(?:\s+(?:sources?|retrieved\s+sources?|evidence|material))?\s*$/i, '')
.replace(/(?:^|\n\n)(?:however,?\s*)?(?:but\s*)?(?:based\s+on\s+)?(?:the\s*)?available\s*$/i, '')
.trim();
}
function shouldRegenerateTruncatedAnswer(answer, finishReason) {
answer = String(answer || '').trim();
if (!answer) return false;
if (finishReason === 'length') return true;
if (/\b(the|a|an|and|or|but|with|without|for|to|of|in|on|as|by|from|because|therefore|however|some|many|most|few|several|additional|other|further|including|such|available)$/i.test(answer)) return true;
if (/[,:;\-(]$/.test(answer)) return true;
var lines = answer.split(/\n+/).map(function(line) { return line.trim(); }).filter(Boolean);
var last = lines.length ? lines[lines.length - 1] : answer;
if (last.length > 24 && /[A-Za-z]$/.test(last) && !/[.!?\])"']$/.test(last)) return true;
return false;
}
module.exports = {
buildSystemPrompt: buildSystemPrompt,
stripCitationMarkers: stripCitationMarkers,
buildUserPrompt: buildUserPrompt,
assistantGenerationOptions: assistantGenerationOptions,
finalizeAssistantAnswer: finalizeAssistantAnswer,
stripModelSourcesSection: stripModelSourcesSection,
shouldRegenerateTruncatedAnswer: shouldRegenerateTruncatedAnswer
};