noteguard-agent / app /static /index.html
github-actions[bot]
Deploy 9aa839066bbf99a8ada733b41479a39770b3bb83 from main
eb83689
Raw
History Blame Contribute Delete
22.9 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NoteGuard β€” the trust layer for clinical AI</title>
<style>
:root {
--nhs-dark-blue: #003087;
--nhs-blue: #005EB8;
--nhs-mid-blue: #0072CE;
--nhs-green: #007f3b;
--nhs-red: #d5281b;
--nhs-light-grey: #f0f4f5;
--nhs-mid-grey: #768692;
--radius: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Arial, sans-serif; background: var(--nhs-light-grey); color: #212b32; min-height: 100vh; }
header {
background: var(--nhs-dark-blue); color: #fff;
padding: 0 32px; height: 56px;
display: flex; align-items: center; gap: 12px;
}
header .logo { font-size: 20px; font-weight: 700; letter-spacing: -.4px; }
header .tag { font-size: 13px; opacity: .65; }
.main {
padding: 24px 32px;
display: grid; grid-template-columns: 1fr 1fr; gap: 20px;
}
.card {
background: #fff; border-radius: var(--radius);
padding: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.08);
}
.card-title {
font-size: 11px; font-weight: 700; color: var(--nhs-dark-blue);
text-transform: uppercase; letter-spacing: .7px; margin-bottom: 14px;
}
/* Toggle */
.toggle-row { display: flex; gap: 6px; margin-bottom: 14px; }
.toggle-btn {
padding: 5px 16px; border-radius: 20px;
border: 2px solid var(--nhs-blue); background: #fff;
color: var(--nhs-blue); font-size: 13px; font-weight: 600; cursor: pointer;
transition: background .12s, color .12s;
}
.toggle-btn.active { background: var(--nhs-blue); color: #fff; }
/* Input */
textarea {
width: 100%; min-height: 160px; resize: vertical;
border: 2px solid #d8dde0; border-radius: var(--radius);
padding: 10px 12px; font-size: 14px; font-family: inherit;
line-height: 1.5; outline: none; transition: border-color .12s;
}
textarea:focus { border-color: var(--nhs-blue); }
.btn-row { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.btn {
padding: 8px 20px; border-radius: 4px; border: none;
font-size: 14px; font-weight: 600; cursor: pointer; transition: background .12s;
}
.btn-primary { background: var(--nhs-blue); color: #fff; }
.btn-primary:hover:not(:disabled) { background: var(--nhs-dark-blue); }
.btn-primary:disabled { background: var(--nhs-mid-grey); cursor: not-allowed; }
.btn-ghost { background: #e8edee; color: #212b32; font-size: 13px; }
.btn-ghost:hover { background: #d3d8da; }
.dataset-hint {
display: none; font-size: 11px; color: var(--nhs-mid-grey);
margin-top: 6px;
}
/* Note display */
.note-display {
display: none; min-height: 160px;
border: 2px solid #d8dde0; border-radius: var(--radius);
padding: 10px 12px; font-size: 14px; line-height: 1.65;
background: #fafbfc; white-space: pre-wrap; word-break: break-word;
}
.note-display.visible { display: block; }
mark.phi {
background: #ffe8e6; color: #900; border-radius: 3px;
padding: 1px 3px; font-weight: 600;
}
.chip {
display: inline-block; font-family: Consolas, Menlo, monospace;
background: #e3f2fd; color: var(--nhs-mid-blue);
border: 1px solid #90caf9; border-radius: 4px;
padding: 0 5px; font-size: 12px; line-height: 1.8;
}
/* Right pane */
.placeholder {
min-height: 160px; display: flex; align-items: center; justify-content: center;
border: 2px dashed #d8dde0; border-radius: var(--radius);
color: var(--nhs-mid-grey); font-size: 14px; font-style: italic;
}
.summary-text {
display: none; font-size: 14px; line-height: 1.7;
word-break: break-word;
}
.summary-text.visible { display: block; }
.summary-text p { margin: 0 0 10px; }
.summary-text p:last-child { margin-bottom: 0; }
.card-title {
font-weight: 700; font-size: 15px; color: var(--nhs-dark-blue);
}
.card-followup .fu-label {
font-weight: 700; color: var(--nhs-dark-blue);
}
.card-grounded {
font-size: 12px; color: var(--nhs-mid-grey);
padding-top: 8px; margin-top: 4px !important;
border-top: 1px solid #e5e9ec; display: flex; align-items: flex-start; gap: 5px;
}
.card-grounded svg { flex-shrink: 0; margin-top: 2px; }
.powered-by {
display: none; margin-top: 8px;
font-size: 11px; color: var(--nhs-mid-grey); text-align: right;
}
.powered-by.visible { display: block; }
/* Trust panel */
.trust-row {
display: grid; grid-template-columns: repeat(4, 1fr);
gap: 12px; padding: 0 32px 32px;
}
.metric {
background: #fff; border-radius: var(--radius); padding: 14px 16px;
box-shadow: 0 1px 4px rgba(0,0,0,.08); text-align: center;
}
.metric[hidden] { display: none; }
.metric .mlabel {
font-size: 11px; color: var(--nhs-mid-grey);
font-weight: 700; text-transform: uppercase; letter-spacing: .5px;
}
.metric .mvalue {
font-size: 26px; font-weight: 700; color: var(--nhs-dark-blue); margin-top: 4px;
}
.mvalue.ok { color: var(--nhs-green); }
.mvalue.risk { color: var(--nhs-red); }
.leak-detail {
font-size: 10px; color: var(--nhs-red); margin-top: 4px;
word-break: break-all; line-height: 1.4;
}
/* Spinner */
.spinner {
display: none; width: 14px; height: 14px;
border: 2px solid rgba(255,255,255,.4); border-top-color: #fff;
border-radius: 50%; animation: spin .6s linear infinite;
vertical-align: middle; margin-right: 5px;
}
.spinner.on { display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }
.edit-link {
font-size: 12px; color: var(--nhs-mid-grey); cursor: pointer;
margin-top: 8px; display: none; text-decoration: underline; text-underline-offset: 2px;
}
.edit-link.visible { display: block; }
/* ── Note picker modal ── */
.picker-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.5);
display: none; align-items: center; justify-content: center; z-index: 100;
}
.picker-overlay.open { display: flex; }
.picker-panel {
background: #fff; border-radius: var(--radius);
width: min(620px, 95vw); max-height: 82vh;
display: flex; flex-direction: column; overflow: hidden;
box-shadow: 0 8px 32px rgba(0,0,0,.22);
}
.picker-header {
display: flex; justify-content: space-between; align-items: center;
padding: 15px 20px; border-bottom: 1px solid #e5e9ec;
font-weight: 700; font-size: 14px; color: var(--nhs-dark-blue);
flex-shrink: 0;
}
.picker-close {
background: none; border: none; font-size: 22px; line-height: 1;
cursor: pointer; color: var(--nhs-mid-grey); padding: 0 2px;
}
.picker-controls {
padding: 10px 14px; display: flex; gap: 8px;
border-bottom: 1px solid #e5e9ec; flex-shrink: 0;
}
.picker-input {
flex: 1; padding: 7px 10px; border: 2px solid #d8dde0; border-radius: 4px;
font-size: 13px; font-family: inherit; outline: none; min-width: 0;
}
.picker-input:focus { border-color: var(--nhs-blue); }
.picker-select {
padding: 7px 8px; border: 2px solid #d8dde0; border-radius: 4px;
font-size: 12px; font-family: inherit; background: #fff;
outline: none; cursor: pointer; max-width: 160px;
}
.picker-list { overflow-y: auto; flex: 1; }
.picker-row {
padding: 10px 16px; cursor: pointer;
border-bottom: 1px solid #f0f4f5; transition: background .1s;
}
.picker-row:hover { background: var(--nhs-light-grey); }
.picker-row .ptype {
font-size: 11px; font-weight: 700; color: var(--nhs-mid-blue);
text-transform: uppercase; letter-spacing: .5px;
}
.picker-row .pexcerpt { font-size: 13px; color: #212b32; margin-top: 2px; line-height: 1.45; }
.picker-empty {
padding: 36px 16px; text-align: center;
font-size: 13px; color: var(--nhs-mid-grey); font-style: italic;
}
@media (max-width: 860px) {
.main { grid-template-columns: 1fr; }
.trust-row { grid-template-columns: repeat(2, 1fr); }
}
</style>
</head>
<body>
<header>
<div class="logo">NoteGuard</div>
<div class="tag">β€” the trust layer for clinical AI</div>
</header>
<div class="main">
<!-- LEFT: input / rendered note -->
<div class="card">
<div class="card-title">Clinical Note</div>
<div class="toggle-row">
<button class="toggle-btn active" id="btnClinician" onclick="setView('clinician')">Clinician view</button>
<button class="toggle-btn" id="btnAI" onclick="setView('ai')">What the AI sees</button>
</div>
<div id="inputArea">
<textarea id="noteInput" placeholder="Paste a ward note, discharge summary or referral letter…"></textarea>
<div class="btn-row">
<button class="btn btn-ghost" id="browseBtn" onclick="openPicker()" style="display:none">Browse notes</button>
<button class="btn btn-ghost" id="sampleBtn" onclick="loadSample()">Load sample note</button>
<button class="btn btn-primary" id="genBtn" onclick="generate()">
<span class="spinner" id="spinner"></span>Generate
</button>
</div>
<div class="dataset-hint" id="datasetHint">
Dataset not loaded β€” run <code>python scripts/fetch_dataset.py</code> to enable note browsing.
</div>
</div>
<div id="noteDisplay" class="note-display"></div>
<span class="edit-link" id="editLink" onclick="resetUI()">← Edit note</span>
</div>
<!-- RIGHT: discharge summary -->
<div class="card">
<div class="card-title">Discharge Summary</div>
<div class="placeholder" id="placeholder">Output will appear here after generation</div>
<div class="summary-text" id="summaryText"></div>
<div class="powered-by" id="poweredBy">powered by Gemini</div>
</div>
</div>
<!-- TRUST PANEL β€” every metric reports whether de-identification was done correctly -->
<div class="trust-row">
<div class="metric" id="mDeid">
<div class="mlabel">De-identification</div>
<div class="mvalue" id="mDeidVal">β€”</div>
</div>
<div class="metric" id="mIds">
<div class="mlabel">Identifiers replaced</div>
<div class="mvalue" id="mIdsVal">β€”</div>
</div>
<div class="metric" id="mResidual">
<div class="mlabel">Residual PII Β· model input</div>
<div class="mvalue" id="mResidualVal">β€”</div>
<div id="residualDetail" class="leak-detail" style="display:none"></div>
</div>
<div class="metric" id="mRev">
<div class="mlabel">Reversible</div>
<div class="mvalue" id="mRevVal">β€”</div>
<div id="revDetail" class="leak-detail" style="display:none"></div>
</div>
</div>
<!-- NOTE PICKER MODAL -->
<div id="pickerOverlay" class="picker-overlay" onclick="if(event.target===this)closePicker()">
<div class="picker-panel">
<div class="picker-header">
<span id="pickerTitle">Synthetic Note Library</span>
<button class="picker-close" onclick="closePicker()">&#x2715;</button>
</div>
<div class="picker-controls">
<input id="pickerSearch" class="picker-input" type="text"
placeholder="Search notes…" oninput="debouncedSearch()">
<select id="pickerType" class="picker-select" onchange="fetchSamples()">
<option value="">All types</option>
</select>
<button class="btn btn-ghost" onclick="pickerShuffle()" style="white-space:nowrap;font-size:13px">&#x1F500; Shuffle</button>
</div>
<div id="pickerList" class="picker-list"></div>
</div>
</div>
<script>
const SAMPLE = `Ward 4B. Pt Margaret Okafor (NHS 485 777 3456, DOB 22/09/1958, F, 45 Elm Road SW1A 1AA). GP: Dr James Obi, Riverside Surgery, Lambeth SE1 7PB.
Admitted 12 Jan 2025 via ED with acute exacerbation of COPD. PMH: COPD (GOLD stage III), T2DM on metformin, hypertension on amlodipine. NKDA. O2 sats 88% on air on admission.
Managed with nebulised salbutamol 2.5 mg QDS and ipratropium 500 mcg QDS, IV hydrocortisone 100 mg TDS (switched to prednisolone 30 mg OD after 48 h), doxycycline 200 mg stat then 100 mg OD (sputum purulent). O2 titrated to 88-92%.
CXR: bilateral hyperinflation, no consolidation. Bloods: WBC 11.2, CRP 78. HbA1c 58 mmol/mol. Sputum C&S pending at discharge.
Discharged home 14 Jan 2025. TTO: carbocisteine 375 mg TDS, prednisolone 30 mg OD for 5 days, doxycycline 100 mg OD for 4 more days. Metformin and amlodipine continued unchanged.
Responsible consultant: Dr Sarah Chen, Respiratory Medicine.`;
let view = 'clinician';
let result = null;
let pickerTimer = null;
let currentPersonId = null;
let noteTypesPopulated = false;
// ── Startup: check if dataset is present ──────────────────────────────────
window.addEventListener('DOMContentLoaded', async () => {
try {
const h = await fetch('/health').then(r => r.json());
if (h.notes_loaded > 0) {
const browseBtn = document.getElementById('browseBtn');
browseBtn.textContent = `Browse notes (${h.notes_loaded.toLocaleString()})`;
browseBtn.style.display = '';
document.getElementById('sampleBtn').style.display = 'none';
} else {
document.getElementById('datasetHint').style.display = 'block';
}
} catch (_) {
// server not running yet β€” keep fallback button
}
});
// ── Picker logic ──────────────────────────────────────────────────────────
function openPicker() {
document.getElementById('pickerOverlay').classList.add('open');
document.getElementById('pickerSearch').value = '';
document.getElementById('pickerType').value = '';
document.getElementById('pickerSearch').focus();
fetchSamples();
}
function closePicker() {
document.getElementById('pickerOverlay').classList.remove('open');
}
function debouncedSearch() {
clearTimeout(pickerTimer);
pickerTimer = setTimeout(fetchSamples, 300);
}
async function fetchSamples() {
const q = document.getElementById('pickerSearch').value.trim();
const nt = document.getElementById('pickerType').value;
const params = new URLSearchParams({ limit: 50 });
if (q) params.set('q', q);
if (nt) params.set('note_type', nt);
const list = document.getElementById('pickerList');
list.innerHTML = '<div class="picker-empty">Loading…</div>';
try {
const data = await fetch('/samples?' + params).then(r => r.json());
// Update header count
const filterLabel = (q || nt) ? ' β€” filtered' : '';
document.getElementById('pickerTitle').textContent =
data.total.toLocaleString() + ' synthetic notes' + filterLabel;
// Populate type dropdown once
if (!noteTypesPopulated && data.items.length > 0) {
populateTypes(data.items);
}
if (data.items.length === 0) {
list.innerHTML = '<div class="picker-empty">No notes match your search.</div>';
return;
}
list.innerHTML = data.items.map(n =>
`<div class="picker-row" onclick="selectNote('${n.clinical_note_id}')">` +
`<div class="ptype">${esc(n.note_type || 'Note')}</div>` +
`<div class="pexcerpt">${esc(n.excerpt)}</div>` +
`</div>`
).join('');
} catch (_) {
list.innerHTML = '<div class="picker-empty">Failed to load notes.</div>';
}
}
async function populateTypes(items) {
// Seed from first page; also fetch a wider set to catch rarer types
try {
const all = await fetch('/samples?limit=200').then(r => r.json());
const types = [...new Set(all.items.map(n => n.note_type).filter(Boolean))].sort();
const sel = document.getElementById('pickerType');
types.forEach(t => {
const o = document.createElement('option');
o.value = t; o.textContent = t;
sel.appendChild(o);
});
noteTypesPopulated = true;
} catch (_) {}
}
async function pickerShuffle() {
try {
const note = await fetch('/sample/random').then(r => r.json());
applyPickedNote(note);
} catch (_) {}
}
async function selectNote(id) {
try {
const note = await fetch('/sample/' + encodeURIComponent(id)).then(r => r.json());
applyPickedNote(note);
} catch (_) {}
}
function applyPickedNote(note) {
closePicker();
resetUI();
currentPersonId = note.person_id || null;
document.getElementById('noteInput').value = note.note_text;
}
// ── Existing UI logic ─────────────────────────────────────────────────────
function setView(v) {
view = v;
document.getElementById('btnClinician').classList.toggle('active', v === 'clinician');
document.getElementById('btnAI').classList.toggle('active', v === 'ai');
if (result) renderNote();
}
function loadSample() {
resetUI();
document.getElementById('noteInput').value = SAMPLE;
}
function resetUI() {
result = null;
currentPersonId = null;
document.getElementById('inputArea').style.display = '';
document.getElementById('noteDisplay').classList.remove('visible');
document.getElementById('editLink').classList.remove('visible');
document.getElementById('placeholder').style.display = '';
document.getElementById('summaryText').classList.remove('visible');
document.getElementById('poweredBy').classList.remove('visible');
for (const id of ['mDeidVal', 'mIdsVal', 'mResidualVal', 'mRevVal']) {
const el = document.getElementById(id);
el.textContent = 'β€”';
el.className = 'mvalue';
}
for (const id of ['residualDetail', 'revDetail']) {
const el = document.getElementById(id);
if (el) { el.textContent = ''; el.style.display = 'none'; }
}
}
function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
const _LINK_ICON = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`;
function renderSummary(text) {
const lines = (text || '').trim().split('\n').map(l => l.trim()).filter(l => l);
return lines.map(line => {
if (line.startsWith('Follow-up:')) {
const rest = esc(line.slice('Follow-up:'.length));
return `<p class="card-followup"><span class="fu-label">Follow-up:</span>${rest}</p>`;
}
if (line.startsWith('Grounded:')) {
const rest = esc(line.slice('Grounded:'.length));
return `<p class="card-grounded">${_LINK_ICON}<span>${rest}</span></p>`;
}
// Defensive: should the model ever emit a "<name> β€” discharge summary" title
// line, drop it β€” the patient is never named and the card already has a header.
if (line.includes('β€” discharge summary') || line.includes('- discharge summary')) {
return '';
}
return `<p>${esc(line)}</p>`;
}).join('');
}
function highlightPHI(text, ids) {
if (!ids || !ids.length) return esc(text);
const sorted = [...ids].sort((a, b) => b.length - a.length);
let out = '', i = 0;
while (i < text.length) {
let hit = false;
for (const id of sorted) {
if (text.startsWith(id, i)) {
out += `<mark class="phi">${esc(id)}</mark>`;
i += id.length;
hit = true;
break;
}
}
if (!hit) { out += esc(text[i]); i++; }
}
return out;
}
function renderAI(text) {
return esc(text).replace(/\[([A-Z]+)_(\d+)\]/g, m => `<span class="chip">${m}</span>`);
}
function renderNote() {
const el = document.getElementById('noteDisplay');
el.innerHTML = view === 'clinician'
? highlightPHI(result.clinician_note, result.identifiers)
: renderAI(result.ai_note);
}
async function generate() {
const note = document.getElementById('noteInput').value.trim();
if (!note) return;
const btn = document.getElementById('genBtn');
const sp = document.getElementById('spinner');
btn.disabled = true;
sp.classList.add('on');
try {
const resp = await fetch('/process', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note, question: 'Draft an NHS eDischarge summary.', person_id: currentPersonId || undefined })
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
alert('Error ' + resp.status + ': ' + (err.detail || resp.statusText));
return;
}
result = await resp.json();
// Switch left pane to rendered note
document.getElementById('inputArea').style.display = 'none';
document.getElementById('noteDisplay').classList.add('visible');
document.getElementById('editLink').classList.add('visible');
renderNote();
// Right pane
document.getElementById('placeholder').style.display = 'none';
const sumEl = document.getElementById('summaryText');
sumEl.innerHTML = renderSummary(result.discharge_summary);
sumEl.classList.add('visible');
document.getElementById('poweredBy').classList.add('visible');
// Metrics β€” all four report whether de-identification succeeded
const m = result.metrics;
// 1. Overall verdict
const dv = document.getElementById('mDeidVal');
dv.textContent = m.deid_ok ? 'PASS' : 'FAIL';
dv.className = 'mvalue ' + (m.deid_ok ? 'ok' : 'risk');
// 2. Identifiers replaced (pseudonymised this turn)
document.getElementById('mIdsVal').textContent = m.identifiers_removed;
// 3. Residual PII the model still saw β€” the headline failure signal
const rv = document.getElementById('mResidualVal');
rv.textContent = m.residual_pii_count;
rv.className = 'mvalue ' + (m.residual_pii_count === 0 ? 'ok' : 'risk');
const rd = document.getElementById('residualDetail');
if (m.residual_pii_count > 0) {
rd.textContent = m.residual_pii.slice(0, 4)
.map(p => p.type + ': ' + p.text).join(' Β· ');
rd.style.display = 'block';
}
// 4. Reversibility β€” every surrogate restores to a real value
const ev = document.getElementById('mRevVal');
ev.textContent = m.reversible ? 'βœ“' : 'βœ—';
ev.className = 'mvalue ' + (m.reversible ? 'ok' : 'risk');
const ed = document.getElementById('revDetail');
if (!m.reversible && m.leaked_tokens && m.leaked_tokens.length) {
ed.textContent = 'Unresolved: ' + m.leaked_tokens.slice(0, 3).join(', ');
ed.style.display = 'block';
}
} catch (e) {
alert('Request failed: ' + e.message);
} finally {
btn.disabled = false;
sp.classList.remove('on');
}
}
</script>
</body>
</html>