stabilize assistant and add diagrams
This commit is contained in:
parent
525671ad2e
commit
12a6022c7d
11 changed files with 4120 additions and 248 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -39,3 +39,9 @@ e2e/playwright-report/
|
|||
|
||||
.codex
|
||||
.firecrawl/
|
||||
|
||||
# Refactored test stack stays local for now
|
||||
.env.refactored
|
||||
.env.refactored.example
|
||||
docker-compose.refactored.yml
|
||||
refactored-ped-ai/
|
||||
|
|
|
|||
24
migrations/1777507197626_add-mermaid-diagrams.js
Normal file
24
migrations/1777507197626_add-mermaid-diagrams.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* Mermaid Diagrams — per-user clinical pathway / algorithm diagrams.
|
||||
* Source is plain Mermaid text; rendered to SVG client-side. Source
|
||||
* encrypted at rest like personal_notes so a row dump stays useless
|
||||
* without the app key.
|
||||
*/
|
||||
|
||||
exports.up = (pgm) => {
|
||||
pgm.createTable('mermaid_diagrams', {
|
||||
id: { type: 'serial', primaryKey: true },
|
||||
user_id: { type: 'integer', notNull: true, references: 'users(id)', onDelete: 'CASCADE' },
|
||||
title: { type: 'text', notNull: true },
|
||||
source: { type: 'text', notNull: true, default: '' },
|
||||
notes: { type: 'text', notNull: true, default: '' },
|
||||
created_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
|
||||
updated_at: { type: 'timestamptz', notNull: true, default: pgm.func('NOW()') },
|
||||
});
|
||||
pgm.createIndex('mermaid_diagrams', 'user_id');
|
||||
pgm.createIndex('mermaid_diagrams', ['user_id', 'updated_at']);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.dropTable('mermaid_diagrams');
|
||||
};
|
||||
235
public/components/diagrams.html
Normal file
235
public/components/diagrams.html
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
<div class="module-header">
|
||||
<h2><i class="fas fa-diagram-project" style="color:#0ea5e9;"></i> Diagrams</h2>
|
||||
</div>
|
||||
|
||||
<div class="diagrams-layout" id="diagrams-layout">
|
||||
|
||||
<aside class="diagrams-sidebar">
|
||||
<div class="diagrams-sidebar-head">
|
||||
<button id="btn-diagram-new" class="btn-primary diagrams-new-btn" type="button">
|
||||
<i class="fas fa-plus"></i> New diagram
|
||||
</button>
|
||||
<div class="diagrams-search">
|
||||
<i class="fas fa-search"></i>
|
||||
<input type="text" id="diagram-search" placeholder="Search" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div id="diagrams-list" class="diagrams-list">
|
||||
<div class="diagrams-empty">Loading…</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section id="diagram-editor" class="diagram-editor">
|
||||
<div class="diagram-toolbar">
|
||||
<input type="text" id="diagram-title" class="diagram-title-input" placeholder="Diagram title" autocomplete="off">
|
||||
<span id="diagram-status" class="diagram-status"></span>
|
||||
<div class="diagram-toolbar-actions">
|
||||
<button id="btn-diagram-export-svg" class="btn-sm" type="button" title="Export SVG">
|
||||
<i class="fas fa-download"></i> SVG
|
||||
</button>
|
||||
<button id="btn-diagram-export-png" class="btn-sm" type="button" title="Export PNG">
|
||||
<i class="fas fa-download"></i> PNG
|
||||
</button>
|
||||
<button id="btn-diagram-delete" class="btn-sm btn-diagram-delete" type="button" title="Delete diagram"
|
||||
style="background:var(--red-light);color:var(--red);border:1px solid var(--red);">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="diagram-panes">
|
||||
<div class="diagram-source-pane">
|
||||
<textarea id="diagram-source" class="diagram-source" spellcheck="false"
|
||||
placeholder="graph TD A[Start] --> B{Decision?} B -->|Yes| C[Action] B -->|No| D[End]"></textarea>
|
||||
</div>
|
||||
<div class="diagram-preview-pane">
|
||||
<div id="diagram-preview" class="diagram-preview"></div>
|
||||
<div id="diagram-error" class="diagram-error hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.diagrams-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
height: calc(100vh - 200px);
|
||||
min-height: 480px;
|
||||
}
|
||||
.diagrams-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--g50, #f8fafb);
|
||||
border: 1px solid var(--g100, #e5ebef);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.diagrams-sidebar-head {
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid var(--g100, #e5ebef);
|
||||
background: white;
|
||||
}
|
||||
.diagrams-new-btn { width: 100%; }
|
||||
.diagrams-search {
|
||||
position: relative;
|
||||
}
|
||||
.diagrams-search i {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--g400, #94a3b8);
|
||||
font-size: 12px;
|
||||
}
|
||||
.diagrams-search input {
|
||||
width: 100%;
|
||||
padding: 7px 10px 7px 28px;
|
||||
border: 1px solid var(--g200, #d5dfe6);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.diagrams-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
}
|
||||
.diagrams-empty {
|
||||
padding: 18px 12px;
|
||||
color: var(--g400, #94a3b8);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
.diagram-row {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.diagram-row:hover {
|
||||
background: white;
|
||||
border-color: var(--g200, #d5dfe6);
|
||||
}
|
||||
.diagram-row.active {
|
||||
background: var(--blue-light, #e0f2fe);
|
||||
border-color: var(--blue, #0ea5e9);
|
||||
}
|
||||
.diagram-row strong {
|
||||
font-size: 13.5px;
|
||||
color: var(--g800, #1e293b);
|
||||
}
|
||||
.diagram-row span {
|
||||
font-size: 11px;
|
||||
color: var(--g400, #94a3b8);
|
||||
}
|
||||
.diagram-editor {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
background: white;
|
||||
border: 1px solid var(--g100, #e5ebef);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.diagram-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--g100, #e5ebef);
|
||||
background: var(--g50, #f8fafb);
|
||||
}
|
||||
.diagram-title-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--g200, #d5dfe6);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
background: white;
|
||||
}
|
||||
.diagram-status {
|
||||
font-size: 11.5px;
|
||||
color: var(--g400, #94a3b8);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.diagram-status.saving { color: var(--amber, #d97706); }
|
||||
.diagram-status.saved { color: var(--green, #16a34a); }
|
||||
.diagram-status.error { color: var(--red, #dc2626); }
|
||||
.diagram-toolbar-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.diagram-panes {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
}
|
||||
.diagram-source-pane {
|
||||
border-right: 1px solid var(--g100, #e5ebef);
|
||||
display: flex;
|
||||
}
|
||||
.diagram-source {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
padding: 12px;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
resize: none;
|
||||
outline: none;
|
||||
background: white;
|
||||
color: var(--g800, #1e293b);
|
||||
}
|
||||
.diagram-preview-pane {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
padding: 18px;
|
||||
background: var(--g50, #f8fafb);
|
||||
}
|
||||
.diagram-preview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.diagram-preview svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.diagram-error {
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
background: var(--red-light, #fef2f2);
|
||||
border: 1px solid var(--red, #dc2626);
|
||||
color: var(--red, #dc2626);
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
border-radius: 6px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.diagrams-layout {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
}
|
||||
.diagrams-sidebar {
|
||||
max-height: 280px;
|
||||
}
|
||||
.diagram-panes {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 320px minmax(280px, 1fr);
|
||||
}
|
||||
.diagram-source-pane {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--g100, #e5ebef);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -7,7 +7,6 @@ export function renderAssistantMarkdown(md, sources, options) {
|
|||
codeBlocks.push({ lang: (lang || '').toLowerCase(), code: code });
|
||||
return '\n@@CODEBLOCK_' + idx + '@@\n';
|
||||
});
|
||||
text = stripSourcesSection(text);
|
||||
text = renderLatexText(text, opts.katex);
|
||||
text = normalizeAdjacentCitationClusters(text, sources || []);
|
||||
|
||||
|
|
@ -84,12 +83,6 @@ function formatCitationCluster(nums) {
|
|||
return '[' + nums.join(', ') + ']';
|
||||
}
|
||||
|
||||
export function stripSourcesSection(text) {
|
||||
return String(text || '')
|
||||
.replace(/\s*(?:-{3,}\s*)?(?:#{1,6}\s*)?(?:Sources|References)\s*:?\s*[\s\S]*$/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function normalizeMarkdownText(text) {
|
||||
return stripOrphanMarkdownMarkers(String(text || '')
|
||||
.replace(/\r\n/g, '\n')
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
// server can call native MCP directly without routing through mcpo.
|
||||
// ============================================================
|
||||
import { EMPTY_PROMPT_SETS } from './assistant/data.js';
|
||||
import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection } from './assistant/citations.js';
|
||||
import { escapeAttr, escapeHtml, renderAssistantMarkdown } from './assistant/citations.js';
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
|
@ -123,7 +123,7 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
|
|||
setBusy(true, 'Looking up sources...');
|
||||
var loading = appendLoadingMessage('Looking up sources', 'Retrieving and synthesizing references...');
|
||||
|
||||
streamAssistantResponse({
|
||||
fetchAssistantResponse({
|
||||
message: text,
|
||||
history: messages.slice(-8),
|
||||
includeContext: !includeContext || includeContext.checked
|
||||
|
|
@ -135,92 +135,17 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
|
|||
});
|
||||
}
|
||||
|
||||
async function streamAssistantResponse(payload, loading) {
|
||||
var response = await fetch('/api/clinical-assistant/chat/stream', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
var fallback = await response.json().catch(function () { return {}; });
|
||||
throw new Error(fallback.error || ('Request failed (' + response.status + ')'));
|
||||
}
|
||||
|
||||
var partial = '';
|
||||
var streamSources = [];
|
||||
var streamSuggestions = [];
|
||||
var lastRender = 0;
|
||||
var bubble = loading ? loading.querySelector('.assistant-bubble') : null;
|
||||
var decoder = new TextDecoder();
|
||||
var buffer = '';
|
||||
var doneData = null;
|
||||
|
||||
function renderPartial(force) {
|
||||
var now = Date.now();
|
||||
if (!force && now - lastRender < 180) return;
|
||||
lastRender = now;
|
||||
if (!bubble) return;
|
||||
loading.classList.remove('assistant-loading-msg');
|
||||
bubble.classList.remove('assistant-thinking');
|
||||
bubble.innerHTML = partial ? renderMarkdown(partial, streamSources) : '<p class="assistant-muted">Generating answer...</p>';
|
||||
var wrap = document.getElementById('assistant-messages');
|
||||
if (wrap) wrap.scrollTop = wrap.scrollHeight;
|
||||
}
|
||||
|
||||
function handleEvent(type, data) {
|
||||
if (type === 'status') {
|
||||
updateLoadingMessage(loading, data.message || 'Working...');
|
||||
return;
|
||||
}
|
||||
if (type === 'sources') {
|
||||
streamSources = data.sources || [];
|
||||
renderSources(streamSources);
|
||||
return;
|
||||
}
|
||||
if (type === 'token') {
|
||||
partial += data.token || '';
|
||||
renderPartial(false);
|
||||
return;
|
||||
}
|
||||
if (type === 'done') {
|
||||
doneData = data || {};
|
||||
streamSuggestions = doneData.suggestions || [];
|
||||
return;
|
||||
}
|
||||
if (type === 'error') throw new Error(data.error || 'Assistant stream failed');
|
||||
}
|
||||
|
||||
var reader = response.body.getReader();
|
||||
while (true) {
|
||||
var chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
buffer += decoder.decode(chunk.value, { stream: true });
|
||||
var parts = buffer.split('\n\n');
|
||||
buffer = parts.pop() || '';
|
||||
parts.forEach(function (part) {
|
||||
var parsed = parseSseEvent(part);
|
||||
if (parsed) handleEvent(parsed.type, parsed.data);
|
||||
});
|
||||
}
|
||||
if (buffer.trim()) {
|
||||
var tail = parseSseEvent(buffer);
|
||||
if (tail) handleEvent(tail.type, tail.data);
|
||||
}
|
||||
if (!doneData) {
|
||||
updateLoadingMessage(loading, 'Stream ended early. Retrying without streaming...');
|
||||
doneData = await fetchAssistantFallback(payload);
|
||||
}
|
||||
|
||||
async function fetchAssistantResponse(payload, loading) {
|
||||
updateLoadingMessage(loading, 'Looking up sources...');
|
||||
var data = await fetchAssistantFallback(payload);
|
||||
setBusy(false, 'Ready');
|
||||
var answer = (doneData && (doneData.answer || doneData.markdown)) || partial;
|
||||
lastAnswer = answer || '';
|
||||
lastSources = (doneData && (doneData.sources || doneData.citations)) || streamSources;
|
||||
replaceLoadingMessage(loading, lastAnswer, lastSources, streamSuggestions);
|
||||
lastAnswer = data.answer || '';
|
||||
lastSources = data.sources || [];
|
||||
replaceLoadingMessage(loading, lastAnswer, lastSources, data.suggestions || []);
|
||||
renderSources(lastSources);
|
||||
if (doneData && doneData.model) {
|
||||
if (data.model) {
|
||||
var label = document.getElementById('assistant-model-label');
|
||||
if (label) label.textContent = 'Chat: ' + doneData.model;
|
||||
if (label) label.textContent = 'Chat: ' + data.model;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -236,18 +161,6 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
|
|||
return data;
|
||||
}
|
||||
|
||||
function parseSseEvent(block) {
|
||||
var type = 'message';
|
||||
var data = '';
|
||||
String(block || '').split(/\r?\n/).forEach(function (line) {
|
||||
if (line.indexOf('event:') === 0) type = line.substring(6).trim();
|
||||
if (line.indexOf('data:') === 0) data += line.substring(5).trim();
|
||||
});
|
||||
if (!data) return null;
|
||||
try { return { type: type, data: JSON.parse(data) }; }
|
||||
catch (e) { return null; }
|
||||
}
|
||||
|
||||
function updateLoadingMessage(row, detail) {
|
||||
if (!row) return;
|
||||
var el = row.querySelector('.assistant-thinking-detail');
|
||||
|
|
@ -284,7 +197,7 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
|
|||
if (!bubble) return;
|
||||
row.classList.remove('assistant-loading-msg');
|
||||
bubble.classList.remove('assistant-thinking');
|
||||
bubble.innerHTML = rawHtml ? sanitize(String(content || '')) : renderMarkdown(content, sources || []);
|
||||
bubble.innerHTML = renderAssistantBubbleHtml(content, sources || [], rawHtml);
|
||||
if (suggestions && suggestions.length) bubble.appendChild(renderSuggestionButtons(suggestions));
|
||||
renderEmbeddedBlocks(bubble);
|
||||
var wrap = document.getElementById('assistant-messages');
|
||||
|
|
@ -292,6 +205,16 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
|
|||
messages.push({ role: 'assistant', content: content, sources: sources || [] });
|
||||
}
|
||||
|
||||
function renderAssistantBubbleHtml(content, sources, rawHtml) {
|
||||
if (rawHtml) return sanitize(String(content || ''));
|
||||
try {
|
||||
return renderMarkdown(content, sources || []);
|
||||
} catch (e) {
|
||||
console.warn('[clinical-assistant] markdown render failed:', e && e.message ? e.message : e);
|
||||
return '<p>' + escapeHtml(String(content || '')).replace(/\n/g, '<br>') + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function appendMessage(role, content, sources, suggestions, rawHtml) {
|
||||
var wrap = document.getElementById('assistant-messages');
|
||||
if (!wrap) return;
|
||||
|
|
@ -647,7 +570,7 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
|
|||
var sources = Array.isArray(item.sources) ? item.sources : [];
|
||||
var heading = item.heading || deriveExportHeading(item.question, idx);
|
||||
var summary = item.summary || '';
|
||||
var answer = stripSourcesSection(item.answer || '');
|
||||
var answer = item.answer || '';
|
||||
var citedNumbers = extractCitedSourceNumbers([summary, answer].filter(Boolean).join('\n\n'));
|
||||
var refs = renderExportRefs(sources, idx + 1, citedNumbers);
|
||||
return '<section class="export-section">' +
|
||||
|
|
@ -688,13 +611,13 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
|
|||
question: pendingQuestion || 'Clinical question',
|
||||
heading: deriveExportHeading(pendingQuestion, items.length),
|
||||
summary: '',
|
||||
answer: stripSourcesSection(m.content),
|
||||
answer: m.content,
|
||||
sources: Array.isArray(m.sources) && m.sources.length ? m.sources : lastSources
|
||||
});
|
||||
pendingQuestion = '';
|
||||
});
|
||||
if (!items.length && lastAnswer) {
|
||||
items.push({ question: 'Clinical question', heading: 'Clinical Answer', summary: '', answer: stripSourcesSection(lastAnswer), sources: lastSources });
|
||||
items.push({ question: 'Clinical question', heading: 'Clinical Answer', summary: '', answer: lastAnswer, sources: lastSources });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
|
|
|||
397
public/js/diagrams.js
Normal file
397
public/js/diagrams.js
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
// ============================================================
|
||||
// MERMAID DIAGRAMS — per-user clinical pathway editor.
|
||||
// Source on the left, live SVG preview on the right. CRUD against
|
||||
// /api/diagrams. Autosaves on quiet typing like Personal Notes.
|
||||
// ============================================================
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var initialized = false;
|
||||
var mermaidReady = false;
|
||||
var library = [];
|
||||
var currentId = null;
|
||||
var currentTitle = '';
|
||||
var currentSource = '';
|
||||
var currentNotes = '';
|
||||
var saveTimer = null;
|
||||
var renderTimer = null;
|
||||
var renderToken = 0;
|
||||
|
||||
document.addEventListener('tabChanged', function (e) {
|
||||
if (e.detail && e.detail.tab === 'diagrams') initIfNeeded();
|
||||
});
|
||||
|
||||
function initIfNeeded() {
|
||||
if (initialized) {
|
||||
reloadLibrary();
|
||||
return;
|
||||
}
|
||||
var tabRoot = document.getElementById('diagrams-tab');
|
||||
if (!tabRoot || !tabRoot.querySelector('#diagrams-list')) {
|
||||
// Component HTML not yet injected — try again on the next tab activation.
|
||||
return;
|
||||
}
|
||||
initialized = true;
|
||||
bindEvents();
|
||||
ensureMermaid().then(reloadLibrary);
|
||||
}
|
||||
|
||||
function ensureMermaid() {
|
||||
if (window.mermaid && mermaidReady) return Promise.resolve();
|
||||
return new Promise(function (resolve) {
|
||||
if (window.mermaid) {
|
||||
configureMermaid();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
var script = document.createElement('script');
|
||||
script.src = '/vendor/mermaid.min.js';
|
||||
script.onload = function () {
|
||||
configureMermaid();
|
||||
resolve();
|
||||
};
|
||||
script.onerror = function () {
|
||||
showError('Failed to load Mermaid. Refresh and try again.');
|
||||
resolve();
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
function configureMermaid() {
|
||||
if (!window.mermaid) return;
|
||||
window.mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: 'strict',
|
||||
theme: 'default',
|
||||
flowchart: { useMaxWidth: true, htmlLabels: true },
|
||||
});
|
||||
mermaidReady = true;
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
var newBtn = document.getElementById('btn-diagram-new');
|
||||
var search = document.getElementById('diagram-search');
|
||||
var titleEl = document.getElementById('diagram-title');
|
||||
var sourceEl = document.getElementById('diagram-source');
|
||||
var deleteBtn = document.getElementById('btn-diagram-delete');
|
||||
var svgBtn = document.getElementById('btn-diagram-export-svg');
|
||||
var pngBtn = document.getElementById('btn-diagram-export-png');
|
||||
|
||||
if (newBtn) newBtn.addEventListener('click', onNew);
|
||||
if (search) search.addEventListener('input', renderList);
|
||||
if (titleEl) titleEl.addEventListener('input', onTitleInput);
|
||||
if (sourceEl) sourceEl.addEventListener('input', onSourceInput);
|
||||
if (deleteBtn) deleteBtn.addEventListener('click', onDelete);
|
||||
if (svgBtn) svgBtn.addEventListener('click', exportSvg);
|
||||
if (pngBtn) pngBtn.addEventListener('click', exportPng);
|
||||
}
|
||||
|
||||
// ── Library ────────────────────────────────────────────────
|
||||
function reloadLibrary() {
|
||||
return fetch('/api/diagrams', { credentials: 'include', headers: window.getAuthHeaders ? window.getAuthHeaders() : {} })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
library = (data && data.diagrams) || [];
|
||||
renderList();
|
||||
if (currentId == null && library.length) {
|
||||
loadDiagram(library[0].id);
|
||||
} else if (!library.length) {
|
||||
showEmptyEditor();
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
var listEl = document.getElementById('diagrams-list');
|
||||
if (listEl) listEl.innerHTML = '<div class="diagrams-empty">Failed to load.</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
var listEl = document.getElementById('diagrams-list');
|
||||
if (!listEl) return;
|
||||
var searchEl = document.getElementById('diagram-search');
|
||||
var q = searchEl ? String(searchEl.value || '').toLowerCase() : '';
|
||||
var rows = library.filter(function (d) {
|
||||
if (!q) return true;
|
||||
return String(d.title || '').toLowerCase().indexOf(q) !== -1;
|
||||
});
|
||||
if (!rows.length) {
|
||||
listEl.innerHTML = '<div class="diagrams-empty">' + (library.length ? 'No matches.' : 'No diagrams yet.') + '</div>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = rows.map(function (d) {
|
||||
return '<div class="diagram-row' + (d.id === currentId ? ' active' : '') + '" data-id="' + d.id + '">'
|
||||
+ '<strong>' + escapeHtml(d.title || 'Untitled') + '</strong>'
|
||||
+ '<span>' + formatDate(d.updated_at) + '</span>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
Array.prototype.forEach.call(listEl.querySelectorAll('.diagram-row'), function (row) {
|
||||
row.addEventListener('click', function () {
|
||||
loadDiagram(parseInt(row.getAttribute('data-id'), 10));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showEmptyEditor() {
|
||||
currentId = null;
|
||||
setFields('', '', '');
|
||||
setStatus('');
|
||||
var preview = document.getElementById('diagram-preview');
|
||||
if (preview) preview.innerHTML = '';
|
||||
hideError();
|
||||
}
|
||||
|
||||
// ── Diagram load / save ────────────────────────────────────
|
||||
function loadDiagram(id) {
|
||||
flushSaveIfPending();
|
||||
fetch('/api/diagrams/' + id, { credentials: 'include', headers: window.getAuthHeaders ? window.getAuthHeaders() : {} })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (!data || !data.diagram) return;
|
||||
currentId = data.diagram.id;
|
||||
setFields(data.diagram.title || '', data.diagram.source || '', data.diagram.notes || '');
|
||||
setStatus('');
|
||||
renderList();
|
||||
scheduleRender(0);
|
||||
})
|
||||
.catch(function () { setStatus('Failed to load', 'error'); });
|
||||
}
|
||||
|
||||
function setFields(title, source, notes) {
|
||||
currentTitle = title;
|
||||
currentSource = source;
|
||||
currentNotes = notes;
|
||||
var titleEl = document.getElementById('diagram-title');
|
||||
var sourceEl = document.getElementById('diagram-source');
|
||||
if (titleEl) titleEl.value = title;
|
||||
if (sourceEl) sourceEl.value = source;
|
||||
}
|
||||
|
||||
function onNew() {
|
||||
flushSaveIfPending();
|
||||
var title = 'New diagram';
|
||||
var starter = 'graph TD\n A[Start] --> B{Decision?}\n B -->|Yes| C[Action]\n B -->|No| D[End]';
|
||||
fetch('/api/diagrams', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: window.getAuthHeaders ? window.getAuthHeaders() : { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: title, source: starter, notes: '' }),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data && data.id) {
|
||||
return reloadLibrary().then(function () { loadDiagram(data.id); });
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
if (window.showToast) window.showToast('Could not create diagram', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function onTitleInput(e) {
|
||||
currentTitle = e.target.value;
|
||||
scheduleSave();
|
||||
var row = document.querySelector('.diagram-row.active strong');
|
||||
if (row) row.textContent = currentTitle || 'Untitled';
|
||||
}
|
||||
|
||||
function onSourceInput(e) {
|
||||
currentSource = e.target.value;
|
||||
scheduleSave();
|
||||
scheduleRender(220);
|
||||
}
|
||||
|
||||
function scheduleSave() {
|
||||
if (currentId == null) return;
|
||||
setStatus('Saving…', 'saving');
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(saveNow, 700);
|
||||
}
|
||||
|
||||
function flushSaveIfPending() {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
saveNow();
|
||||
}
|
||||
}
|
||||
|
||||
function saveNow() {
|
||||
saveTimer = null;
|
||||
if (currentId == null) return;
|
||||
var id = currentId;
|
||||
var payload = JSON.stringify({ title: currentTitle, source: currentSource, notes: currentNotes });
|
||||
fetch('/api/diagrams/' + id, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: window.getAuthHeaders ? window.getAuthHeaders() : { 'Content-Type': 'application/json' },
|
||||
body: payload,
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('save failed');
|
||||
setStatus('Saved', 'saved');
|
||||
var item = library.find(function (d) { return d.id === id; });
|
||||
if (item) {
|
||||
item.title = currentTitle;
|
||||
item.source = currentSource;
|
||||
item.updated_at = new Date().toISOString();
|
||||
}
|
||||
})
|
||||
.catch(function () { setStatus('Save failed', 'error'); });
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (currentId == null) return;
|
||||
var id = currentId;
|
||||
var confirmFn = window.showConfirm || function (msg) { return Promise.resolve(window.confirm(msg)); };
|
||||
Promise.resolve(confirmFn('Delete this diagram?')).then(function (ok) {
|
||||
if (!ok) return;
|
||||
fetch('/api/diagrams/' + id, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: window.getAuthHeaders ? window.getAuthHeaders() : {},
|
||||
}).then(function () {
|
||||
currentId = null;
|
||||
return reloadLibrary();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────
|
||||
function scheduleRender(delay) {
|
||||
if (renderTimer) clearTimeout(renderTimer);
|
||||
renderTimer = setTimeout(renderNow, delay || 0);
|
||||
}
|
||||
|
||||
function renderNow() {
|
||||
renderTimer = null;
|
||||
var preview = document.getElementById('diagram-preview');
|
||||
if (!preview) return;
|
||||
var src = String(currentSource || '').trim();
|
||||
if (!src) {
|
||||
preview.innerHTML = '';
|
||||
hideError();
|
||||
return;
|
||||
}
|
||||
if (!window.mermaid || !mermaidReady) {
|
||||
ensureMermaid().then(renderNow);
|
||||
return;
|
||||
}
|
||||
var token = ++renderToken;
|
||||
var id = 'mermaid-render-' + token;
|
||||
Promise.resolve()
|
||||
.then(function () { return window.mermaid.render(id, src); })
|
||||
.then(function (result) {
|
||||
if (token !== renderToken) return;
|
||||
preview.innerHTML = result.svg;
|
||||
hideError();
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (token !== renderToken) return;
|
||||
var msg = err && err.message ? err.message : String(err);
|
||||
showError(msg);
|
||||
});
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
var el = document.getElementById('diagram-error');
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
function hideError() {
|
||||
var el = document.getElementById('diagram-error');
|
||||
if (el) el.classList.add('hidden');
|
||||
}
|
||||
|
||||
// ── Export ─────────────────────────────────────────────────
|
||||
function exportSvg() {
|
||||
var preview = document.getElementById('diagram-preview');
|
||||
var svg = preview && preview.querySelector('svg');
|
||||
if (!svg) {
|
||||
if (window.showToast) window.showToast('Render a diagram first', 'info');
|
||||
return;
|
||||
}
|
||||
var serializer = new XMLSerializer();
|
||||
var raw = serializer.serializeToString(svg);
|
||||
if (!/^<\?xml/.test(raw)) raw = '<?xml version="1.0" encoding="UTF-8"?>\n' + raw;
|
||||
var blob = new Blob([raw], { type: 'image/svg+xml' });
|
||||
triggerDownload(blob, fileBase() + '.svg');
|
||||
}
|
||||
|
||||
function exportPng() {
|
||||
var preview = document.getElementById('diagram-preview');
|
||||
var svg = preview && preview.querySelector('svg');
|
||||
if (!svg) {
|
||||
if (window.showToast) window.showToast('Render a diagram first', 'info');
|
||||
return;
|
||||
}
|
||||
var serializer = new XMLSerializer();
|
||||
var raw = serializer.serializeToString(svg);
|
||||
var encoded = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(raw);
|
||||
var img = new Image();
|
||||
img.onload = function () {
|
||||
var rect = svg.getBoundingClientRect();
|
||||
var scale = 2;
|
||||
var w = Math.max(1, Math.round(rect.width * scale));
|
||||
var h = Math.max(1, Math.round(rect.height * scale));
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = w; canvas.height = h;
|
||||
var ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
canvas.toBlob(function (blob) {
|
||||
if (blob) triggerDownload(blob, fileBase() + '.png');
|
||||
}, 'image/png');
|
||||
};
|
||||
img.onerror = function () {
|
||||
if (window.showToast) window.showToast('PNG export failed', 'error');
|
||||
};
|
||||
img.src = encoded;
|
||||
}
|
||||
|
||||
function triggerDownload(blob, name) {
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
|
||||
}
|
||||
|
||||
function fileBase() {
|
||||
var t = String(currentTitle || 'diagram').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
return t || 'diagram';
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────
|
||||
function setStatus(text, kind) {
|
||||
var el = document.getElementById('diagram-status');
|
||||
if (!el) return;
|
||||
el.textContent = text || '';
|
||||
el.className = 'diagram-status' + (kind ? ' ' + kind : '');
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
var d = new Date(iso);
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
} catch (e) { return ''; }
|
||||
}
|
||||
|
||||
// Save in-flight on tab switch / page hide so the user never loses keystrokes.
|
||||
window.addEventListener('beforeunload', flushSaveIfPending);
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.visibilityState === 'hidden') flushSaveIfPending();
|
||||
});
|
||||
})();
|
||||
3298
public/vendor/mermaid.min.js
vendored
Normal file
3298
public/vendor/mermaid.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -10,7 +10,7 @@ var axios = require('axios');
|
|||
var router = express.Router();
|
||||
var db = require('../db/database');
|
||||
var { authMiddleware } = require('../middleware/auth');
|
||||
var { callAI, callAIStream } = require('../utils/ai');
|
||||
var { callAI } = require('../utils/ai');
|
||||
var { gatewayUrl } = require('../utils/errors');
|
||||
var logger = require('../utils/logger');
|
||||
var cryptoUtil = require('../utils/crypto');
|
||||
|
|
@ -242,7 +242,7 @@ router.post('/clinical-assistant/chat', async function(req, res) {
|
|||
});
|
||||
var answer = stripModelSourcesSection(String(ai.content || '').trim());
|
||||
if (shouldRegenerateTruncatedAnswer(answer, ai.finishReason)) {
|
||||
console.warn('[clinical-assistant] non-stream answer looked truncated; regenerating', { finishReason: ai.finishReason, chars: answer.length });
|
||||
console.warn('[clinical-assistant] answer looked truncated; regenerating', { finishReason: ai.finishReason, chars: answer.length });
|
||||
var completed = await callAI(prepared.messages, {
|
||||
model: prepared.chatModel || undefined,
|
||||
temperature: 0.15,
|
||||
|
|
@ -273,72 +273,6 @@ router.post('/clinical-assistant/chat', async function(req, res) {
|
|||
}
|
||||
});
|
||||
|
||||
router.post('/clinical-assistant/chat/stream', async function(req, res) {
|
||||
var started = Date.now();
|
||||
var streamOpen = false;
|
||||
function sendEvent(type, data) {
|
||||
if (!streamOpen) return;
|
||||
res.write('event: ' + type + '\n');
|
||||
res.write('data: ' + JSON.stringify(data || {}) + '\n\n');
|
||||
}
|
||||
try {
|
||||
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
||||
streamOpen = true;
|
||||
sendEvent('status', { message: 'Looking up sources...' });
|
||||
|
||||
var prepared = await prepareAssistantChat(req.body);
|
||||
if (prepared.direct) {
|
||||
sendEvent('done', Object.assign({ duration: Date.now() - started }, prepared.direct));
|
||||
return res.end();
|
||||
}
|
||||
sendEvent('sources', { sources: sanitizeSourcesForClient(prepared.sources), search: prepared.search });
|
||||
sendEvent('status', { message: 'Generating answer...' });
|
||||
|
||||
var ai = await callAIStream(prepared.messages, {
|
||||
model: prepared.chatModel || undefined,
|
||||
temperature: 0.15,
|
||||
maxTokens: 2600
|
||||
}, function(delta) {
|
||||
sendEvent('token', { token: delta });
|
||||
});
|
||||
var answer = stripModelSourcesSection(String(ai.content || '').trim());
|
||||
if (shouldRegenerateTruncatedAnswer(answer, ai.finishReason)) {
|
||||
console.warn('[clinical-assistant] stream answer looked truncated; regenerating', { finishReason: ai.finishReason, chars: answer.length });
|
||||
sendEvent('status', { message: 'Completing answer...' });
|
||||
var completed = await callAI(prepared.messages, {
|
||||
model: prepared.chatModel || undefined,
|
||||
temperature: 0.15,
|
||||
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;
|
||||
}
|
||||
logger.audit(req.user.id, 'clinical_assistant_query', 'Clinical assistant streaming query', req, {
|
||||
category: 'clinical', model: ai.model || prepared.chatModel, duration: Date.now() - started
|
||||
});
|
||||
sendEvent('done', {
|
||||
success: true,
|
||||
answer: answer,
|
||||
sources: sanitizeSourcesForClient(prepared.sources),
|
||||
model: ai.model || prepared.chatModel || null,
|
||||
provider: ai.provider || null,
|
||||
duration: Date.now() - started,
|
||||
search: prepared.search
|
||||
});
|
||||
res.end();
|
||||
} catch (e) {
|
||||
console.error('[clinical-assistant stream]', e.message, e.stack || '');
|
||||
if (!streamOpen) return res.status(500).json({ error: assistantErrorMessage(e) });
|
||||
sendEvent('error', { error: assistantErrorMessage(e) });
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/clinical-assistant/image', async function(req, res) {
|
||||
try {
|
||||
var prompt = String(req.body.prompt || '').trim();
|
||||
|
|
@ -865,7 +799,7 @@ function formatSourcesForPrompt(sources) {
|
|||
}
|
||||
|
||||
function buildSystemPrompt(behavior) {
|
||||
return behavior + '\n\nRules:\n- Answer only the user question; do not dump unrelated textbook content.\n- For short typo-like, misspelled, or partial terms, use the retrieved sources to infer the closest medical concept when there is a plausible match. Begin with "Assuming you meant ..." and answer that concept. Do not ask for clarification unless 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].\n- Every clinical recommendation, dose, threshold, lab value, statistic, comparison, contraindication, red flag, and table row must include its own supporting citation.\n- Do not leave a paragraph, bullet, or table row with multiple factual claims supported only by an uncited heading.\n- If a claim is not directly supported by retrieved sources, omit it or say the available sources are insufficient.\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- Do not cite a source number that is not provided.\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.';
|
||||
return behavior + '\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].\n- Every clinical recommendation, dose, threshold, lab value, statistic, comparison, contraindication, red flag, and table row must include its own supporting citation.\n- Do not leave a paragraph, bullet, or table row with multiple factual claims supported only by an uncited heading.\n- If a claim is not directly supported by retrieved sources, omit it or say the available sources are insufficient.\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- Do not cite a source number that is not provided.\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.';
|
||||
}
|
||||
|
||||
function buildUserPrompt(question, context, history, searchQuery) {
|
||||
|
|
@ -876,9 +810,19 @@ function buildUserPrompt(question, context, history, searchQuery) {
|
|||
}
|
||||
|
||||
function stripModelSourcesSection(answer) {
|
||||
return String(answer || '')
|
||||
return cleanDanglingSourceLeadIn(String(answer || '')
|
||||
.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();
|
||||
}
|
||||
|
||||
|
|
@ -886,11 +830,11 @@ 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)$/i.test(answer)) return true;
|
||||
if (/[,:;\-–—(]$/.test(answer)) 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;
|
||||
if (last.length > 24 && /[A-Za-z]$/.test(last) && !/[.!?\])"']$/.test(last)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
114
src/routes/diagrams.js
Normal file
114
src/routes/diagrams.js
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// ============================================================
|
||||
// MERMAID DIAGRAMS ROUTES — per-user clinical pathway diagrams.
|
||||
// Pure CRUD, auth-gated. Title/source/notes encrypted at rest
|
||||
// using the same crypto helper as personal_notes.
|
||||
// ============================================================
|
||||
|
||||
var express = require('express');
|
||||
var router = express.Router();
|
||||
var db = require('../db/database');
|
||||
var { authMiddleware } = require('../middleware/auth');
|
||||
var logger = require('../utils/logger');
|
||||
var cryptoUtil = require('../utils/crypto');
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
var MAX_TITLE = 200;
|
||||
var MAX_SOURCE = 50000;
|
||||
var MAX_NOTES = 10000;
|
||||
var MAX_DIAGRAMS_PER_USER = 200;
|
||||
|
||||
function decryptRow(row) {
|
||||
if (!row) return row;
|
||||
try { row.title = cryptoUtil.decryptString(row.title); } catch (e) {}
|
||||
try { row.source = cryptoUtil.decryptString(row.source); } catch (e) {}
|
||||
try { row.notes = cryptoUtil.decryptString(row.notes); } catch (e) {}
|
||||
return row;
|
||||
}
|
||||
|
||||
function clip(s, n) {
|
||||
return String(s == null ? '' : s).trim().substring(0, n);
|
||||
}
|
||||
|
||||
router.get('/diagrams', async function (req, res) {
|
||||
try {
|
||||
var rows = await db.all(
|
||||
'SELECT id, title, source, notes, created_at, updated_at FROM mermaid_diagrams WHERE user_id = $1 ORDER BY updated_at DESC',
|
||||
[req.user.id]
|
||||
);
|
||||
rows.forEach(decryptRow);
|
||||
res.json({ success: true, diagrams: rows });
|
||||
} catch (err) {
|
||||
console.error('[diagrams] list failed:', err.message);
|
||||
res.status(500).json({ error: 'Request failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/diagrams/:id', async function (req, res) {
|
||||
try {
|
||||
var row = await db.get(
|
||||
'SELECT id, title, source, notes, created_at, updated_at FROM mermaid_diagrams WHERE id = $1 AND user_id = $2',
|
||||
[req.params.id, req.user.id]
|
||||
);
|
||||
if (!row) return res.status(404).json({ error: 'Diagram not found' });
|
||||
decryptRow(row);
|
||||
res.json({ success: true, diagram: row });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Request failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/diagrams', async function (req, res) {
|
||||
try {
|
||||
var title = clip(req.body.title, MAX_TITLE) || 'Untitled diagram';
|
||||
var source = clip(req.body.source, MAX_SOURCE);
|
||||
var notes = clip(req.body.notes, MAX_NOTES);
|
||||
|
||||
var count = await db.get('SELECT COUNT(*) as cnt FROM mermaid_diagrams WHERE user_id = $1', [req.user.id]);
|
||||
if (count && Number(count.cnt) >= MAX_DIAGRAMS_PER_USER) {
|
||||
return res.status(400).json({ error: 'Maximum ' + MAX_DIAGRAMS_PER_USER + ' diagrams per user' });
|
||||
}
|
||||
|
||||
var result = await db.run(
|
||||
'INSERT INTO mermaid_diagrams (user_id, title, source, notes) VALUES ($1, $2, $3, $4) RETURNING id',
|
||||
[req.user.id, cryptoUtil.encryptString(title), cryptoUtil.encryptString(source), cryptoUtil.encryptString(notes)]
|
||||
);
|
||||
logger.audit(req.user.id, 'diagram_create', 'Created mermaid diagram', req, { category: 'clinical' });
|
||||
res.json({ success: true, id: result.lastInsertRowid });
|
||||
} catch (err) {
|
||||
console.error('[diagrams] create failed:', err.message);
|
||||
res.status(500).json({ error: 'Request failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/diagrams/:id', async function (req, res) {
|
||||
try {
|
||||
var existing = await db.get('SELECT id FROM mermaid_diagrams WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]);
|
||||
if (!existing) return res.status(404).json({ error: 'Diagram not found' });
|
||||
|
||||
var title = clip(req.body.title, MAX_TITLE) || 'Untitled diagram';
|
||||
var source = clip(req.body.source, MAX_SOURCE);
|
||||
var notes = clip(req.body.notes, MAX_NOTES);
|
||||
|
||||
await db.run(
|
||||
'UPDATE mermaid_diagrams SET title = $1, source = $2, notes = $3, updated_at = NOW() WHERE id = $4 AND user_id = $5',
|
||||
[cryptoUtil.encryptString(title), cryptoUtil.encryptString(source), cryptoUtil.encryptString(notes), req.params.id, req.user.id]
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('[diagrams] update failed:', err.message);
|
||||
res.status(500).json({ error: 'Request failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/diagrams/:id', async function (req, res) {
|
||||
try {
|
||||
await db.run('DELETE FROM mermaid_diagrams WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]);
|
||||
logger.audit(req.user.id, 'diagram_delete', 'Deleted mermaid diagram', req, { category: 'clinical' });
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Request failed' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -417,52 +417,6 @@ async function assertModelAllowed(requestedModel, options) {
|
|||
}
|
||||
}
|
||||
|
||||
async function callAIStream(messages, options, onToken) {
|
||||
options = options || {};
|
||||
var requestedModel = options.model;
|
||||
var model = requestedModel || DEFAULT_MODEL;
|
||||
var temperature = options.temperature || 0.3;
|
||||
var maxTokens = options.maxTokens || 4000;
|
||||
var startTime = Date.now();
|
||||
await assertModelAllowed(requestedModel, options);
|
||||
|
||||
var client = null;
|
||||
var provider = null;
|
||||
if (activeProvider === 'litellm' && litellmClient) {
|
||||
client = litellmClient;
|
||||
provider = 'litellm';
|
||||
} else if (activeProvider === 'openrouter' && openrouter) {
|
||||
client = openrouter;
|
||||
provider = 'openrouter';
|
||||
} else if (activeProvider === 'azure' && azureClient) {
|
||||
client = azureClient;
|
||||
provider = 'azure';
|
||||
model = process.env.AZURE_DEPLOYMENT_NAME || model;
|
||||
}
|
||||
if (!client) throw new Error('Streaming is only configured for OpenAI-compatible providers');
|
||||
|
||||
var content = '';
|
||||
var finishReason = null;
|
||||
var stream = await client.chat.completions.create({
|
||||
model: model,
|
||||
messages: messages,
|
||||
temperature: temperature,
|
||||
max_tokens: maxTokens,
|
||||
stream: true
|
||||
});
|
||||
for await (var part of stream) {
|
||||
var choice = part && part.choices && part.choices[0] ? part.choices[0] : null;
|
||||
if (choice && choice.finish_reason) finishReason = choice.finish_reason;
|
||||
var delta = choice && choice.delta ? choice.delta.content : '';
|
||||
if (!delta) continue;
|
||||
content += delta;
|
||||
if (typeof onToken === 'function') onToken(delta);
|
||||
}
|
||||
var duration = Date.now() - startTime;
|
||||
logger.apiCall(null, provider + '/' + model, { model: model, duration: duration, statusCode: 200 });
|
||||
return { success: true, content: content, model: model, provider: provider, duration: duration, finishReason: finishReason };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MAIN CALL AI FUNCTION — Routes to correct provider
|
||||
// ============================================================
|
||||
|
|
@ -683,4 +637,4 @@ async function discoverModels() {
|
|||
return discovered;
|
||||
}
|
||||
|
||||
module.exports = { callAI, callAIStream, whisperClient, activeProvider, discoverModels, vertexClient, litellmClient };
|
||||
module.exports = { callAI, whisperClient, activeProvider, discoverModels, vertexClient, litellmClient };
|
||||
|
|
|
|||
|
|
@ -83,18 +83,11 @@ test('escapes source titles inside citation link titles', async () => {
|
|||
assert.doesNotMatch(html, /<script>/);
|
||||
});
|
||||
|
||||
test('strips model-generated Sources and References sections', async () => {
|
||||
test('does not strip sentences that mention available sources', async () => {
|
||||
const { renderAssistantMarkdown } = await loadCitationModule();
|
||||
const html = renderAssistantMarkdown('Answer body [1].\n\n## Sources\n[1] duplicate source', sources);
|
||||
assert.match(html, /Answer body/);
|
||||
assert.doesNotMatch(html, /duplicate source/);
|
||||
});
|
||||
|
||||
test('strips colon-style References sections', async () => {
|
||||
const { renderAssistantMarkdown } = await loadCitationModule();
|
||||
const html = renderAssistantMarkdown('Answer body [1].\n\n--- References:\n[1] duplicate source', sources);
|
||||
assert.match(html, /Answer body/);
|
||||
assert.doesNotMatch(html, /duplicate source/);
|
||||
const html = renderAssistantMarkdown('Pneumonia can cause parapneumonic effusion [1]. However, the available sources do not specifically identify adenovirus as a typical cause [2]. In summary, evidence is insufficient [3].', sources);
|
||||
assert.match(html, /available sources do not specifically identify adenovirus/);
|
||||
assert.match(html, /In summary, evidence is insufficient/);
|
||||
});
|
||||
|
||||
test('normalizes inline markdown headings before rendering', async () => {
|
||||
|
|
@ -110,15 +103,6 @@ test('normalizes old saved assistant answer formatting', async () => {
|
|||
assert.match(html, /<h3>Bronchiolitis Management<\/h3>/);
|
||||
assert.match(html, /<li>Mainstay: Supportive care/);
|
||||
assert.match(html, /Key Differences/);
|
||||
assert.doesNotMatch(html, /duplicate/);
|
||||
});
|
||||
|
||||
test('strips inline model References after a cited sentence', async () => {
|
||||
const { renderAssistantMarkdown } = await loadCitationModule();
|
||||
const html = renderAssistantMarkdown('Infants <2 years often do not respond [2, 3]. --- References: [2] Book p.1 [3] Other p.2', sources);
|
||||
assert.match(html, /Infants <2 years/);
|
||||
assert.doesNotMatch(html, /References/);
|
||||
assert.doesNotMatch(html, /Book p\.1/);
|
||||
});
|
||||
|
||||
test('repairs smashed admission bullet list after citations', async () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue