pediatric-ai-scribe-v3/public/js/sickVisit.js
Daniel b3d66caaca feat: a retried transcript goes back to the tab the audio came from
Retrying a kept recording transcribed it and put the text on the clipboard,
leaving you to find the right tab and paste. The app already had the answer: the
module was recorded with the audio. Retry now opens that tab and puts the text
in its transcript box.

Two things had to be true first.

The module was not actually being recorded. transcribeAudio never sent one, so
the server stored its default for every upload — all 28 rows in audio_backups
said "recording", and a retry had nowhere to send anything back to. Each
module's call now tags its own upload.

And the names disagreed. The recorders tagged 'encounter', 'soap', 'dictation'
while the recording-started events said 'enc', 'sick', 'dict'. One table now
holds the mapping and resolves the aliases, so the recorder that tags the
upload, the backup row that labels it and the retry that delivers it cannot
drift apart again.

Existing text is appended to, never replaced: a retry usually recovers
something on top of a live transcript, and overwriting would lose the words the
browser did hear. The box only exists once its tab's markup has been fetched, so
delivery polls briefly rather than guessing a delay, and falls back to the
clipboard if the tab never opens. An empty result says so rather than claiming
success. Backup rows now name their source and the button reads "Retry into
SOAP Note" instead of "Retry".

Verified in a browser: enc resolves to encounter, delivery switched tabs and
produced 'existing live transcript\n\nRECOVERED TEXT'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 12:39:50 +02:00

439 lines
24 KiB
JavaScript

// ============================================================
// SICK VISIT TAB — Recording, ROS/PE inference, Note generation
// ============================================================
// ── 15 ROS systems (same keys as shadess.js) ─────────────────────────────
var ROS_SYSTEMS = [
{ key: 'constitutional', label: 'Constitutional', detail: 'fever, fatigue, weight loss/gain, appetite' },
{ key: 'eyes', label: 'Eyes', detail: 'vision changes, discharge, redness' },
{ key: 'ent', label: 'ENT', detail: 'ear pain, congestion, sore throat, mouth sores' },
{ key: 'cardiovascular', label: 'Cardiovascular', detail: 'chest pain, palpitations, murmur' },
{ key: 'respiratory', label: 'Respiratory', detail: 'cough, wheeze, dyspnea' },
{ key: 'gastrointestinal', label: 'Gastrointestinal', detail: 'nausea, vomiting, diarrhea, constipation, abdominal pain' },
{ key: 'genitourinary', label: 'Genitourinary', detail: 'dysuria, frequency, discharge, menstrual' },
{ key: 'musculoskeletal', label: 'Musculoskeletal', detail: 'joint pain, swelling, gait issues, back pain' },
{ key: 'skin', label: 'Skin', detail: 'rash, lesions, itch, hair/nail changes' },
{ key: 'neurological', label: 'Neurological', detail: 'headache, dizziness, seizures, numbness' },
{ key: 'psychiatric', label: 'Psychiatric', detail: 'mood, anxiety, sleep, behavior changes' },
{ key: 'endocrine', label: 'Endocrine', detail: 'polyuria/polydipsia, heat/cold intolerance, growth concerns' },
{ key: 'hematologic', label: 'Hematologic/Lymphatic', detail: 'bruising, bleeding, lymph nodes' },
{ key: 'allergic', label: 'Allergic/Immunologic', detail: 'allergic reactions, frequent infections' },
{ key: 'developmental', label: 'Developmental/Behavioral', detail: 'milestones, school, social' },
];
// ── 17 PE systems ─────────────────────────────────────────────────────────
var PE_SYSTEMS = [
{ key: 'general', label: 'General Appearance', detail: 'WDWN, alert, NAD, etc.' },
{ key: 'peEyes', label: 'Eyes', detail: 'PERRL, conjunctiva, EOM' },
{ key: 'ears', label: 'Ears', detail: 'TMs, canals, hearing' },
{ key: 'nose', label: 'Nose', detail: 'mucosa, septum, turbinates' },
{ key: 'mouthThroat', label: 'Mouth/Throat', detail: 'lips, gums, teeth, tonsils, pharynx' },
{ key: 'neck', label: 'Neck', detail: 'lymph nodes, thyroid, masses' },
{ key: 'chestLungs', label: 'Chest/Lungs', detail: 'air entry, wheezing, retractions' },
{ key: 'peCv', label: 'Cardiovascular', detail: 'S1/S2, murmur, pulses, cap refill' },
{ key: 'abdomen', label: 'Abdomen', detail: 'soft, tenderness, organomegaly, BS' },
{ key: 'guTanner', label: 'Genitourinary/Tanner', detail: 'Tanner stage, genitalia, hernias' },
{ key: 'msk', label: 'Musculoskeletal', detail: 'strength, ROM, gait, spine' },
{ key: 'extremities', label: 'Extremities', detail: 'tone, reflexes, clubbing, edema' },
{ key: 'peSkin', label: 'Skin', detail: 'rashes, lesions, cafe au lait, bruises' },
{ key: 'peNeuro', label: 'Neurological', detail: 'CN intact, DTRs, coordination' },
{ key: 'lymphatic', label: 'Lymphatic', detail: 'cervical, axillary, inguinal nodes' },
{ key: 'breast', label: 'Breast', detail: 'if indicated by age/Tanner' },
{ key: 'pePsych', label: 'Psychiatric', detail: 'affect, mood, behavior during exam' },
];
// ── Inference rules (CC keyword → ROS and PE system keys) ─────────────────
var INFERENCE_RULES = [
{ keywords: ['ear', 'hearing', 'otalgia', 'otitis'],
ros: ['constitutional', 'ent'],
pe: ['general', 'ears', 'nose', 'mouthThroat', 'neck'] },
{ keywords: ['throat', 'strep', 'pharyn', 'tonsil'],
ros: ['constitutional', 'ent'],
pe: ['general', 'mouthThroat', 'neck', 'ears', 'lymphatic'] },
{ keywords: ['cough', 'breath', 'asthma', 'wheez', 'respiratory', 'bronch'],
ros: ['constitutional', 'respiratory', 'cardiovascular', 'ent'],
pe: ['general', 'chestLungs', 'peCv', 'ears', 'nose'] },
{ keywords: ['stomach', 'vomit', 'nausea', 'diarr', 'abdomin', 'constip', 'belly'],
ros: ['constitutional', 'gastrointestinal', 'genitourinary'],
pe: ['general', 'abdomen', 'chestLungs', 'peCv', 'peSkin'] },
{ keywords: ['fever', 'temp', 'hot'],
ros: ['constitutional', 'ent', 'respiratory', 'gastrointestinal'],
pe: ['general', 'ears', 'mouthThroat', 'chestLungs', 'abdomen', 'peSkin', 'lymphatic'] },
{ keywords: ['rash', 'skin', 'itch', 'eczema', 'hives', 'dermat'],
ros: ['constitutional', 'skin', 'allergic'],
pe: ['general', 'peSkin', 'chestLungs', 'peCv', 'lymphatic'] },
{ keywords: ['headache', 'head', 'migrain'],
ros: ['constitutional', 'neurological', 'eyes', 'ent'],
pe: ['general', 'peNeuro', 'peEyes', 'ears', 'neck'] },
{ keywords: ['urin', 'uti', 'dysuria', 'bladder', 'kidney'],
ros: ['constitutional', 'genitourinary', 'gastrointestinal'],
pe: ['general', 'abdomen', 'guTanner', 'chestLungs', 'peCv'] },
{ keywords: ['joint', 'knee', 'limb', 'leg', 'arm', 'ankle', 'musculo', 'back', 'pain'],
ros: ['constitutional', 'musculoskeletal', 'neurological'],
pe: ['general', 'msk', 'extremities', 'peNeuro', 'peSkin'] },
{ keywords: ['anxi', 'mood', 'behav', 'depress', 'mental', 'school'],
ros: ['constitutional', 'psychiatric', 'neurological', 'developmental'],
pe: ['general', 'pePsych', 'peNeuro', 'peSkin'] },
];
var DEFAULT_ROS = ['constitutional', 'ent', 'respiratory', 'gastrointestinal'];
var DEFAULT_PE = ['general', 'ears', 'chestLungs', 'peCv', 'abdomen', 'peSkin', 'peNeuro'];
function inferSystems(cc) {
var lc = (cc || '').toLowerCase();
var rosSet = {};
var peSet = {};
var matched = false;
INFERENCE_RULES.forEach(function(rule) {
if (rule.keywords.some(function(kw) { return lc.indexOf(kw) !== -1; })) {
matched = true;
rule.ros.forEach(function(k) { rosSet[k] = true; });
rule.pe.forEach(function(k) { peSet[k] = true; });
}
});
if (!matched) {
DEFAULT_ROS.forEach(function(k) { rosSet[k] = true; });
DEFAULT_PE.forEach(function(k) { peSet[k] = true; });
}
// Return top 4 ROS, top 7 PE
var rosKeys = Object.keys(rosSet).slice(0, 4);
var peKeys = Object.keys(peSet).slice(0, 7);
// Ensure we always have at least the default counts
DEFAULT_ROS.forEach(function(k) { if (rosKeys.length < 4 && rosKeys.indexOf(k) === -1) rosKeys.push(k); });
DEFAULT_PE.forEach(function(k) { if (peKeys.length < 7 && peKeys.indexOf(k) === -1) peKeys.push(k); });
return {
ros: rosKeys.map(function(k) { return ROS_SYSTEMS.find(function(s) { return s.key === k; }); }).filter(Boolean),
pe: peKeys.map(function(k) { return PE_SYSTEMS.find(function(s) { return s.key === k; }); }).filter(Boolean),
};
}
// ── Module state ─────────────────────────────────────────────────────────
var _sickRosData = {};
var _sickPeData = {};
var _sickSelectedDx = [];
var _sickRosSystems = DEFAULT_ROS.map(function(k) { return ROS_SYSTEMS.find(function(s) { return s.key === k; }); }).filter(Boolean);
var _sickPeSystems = DEFAULT_PE.map(function(k) { return PE_SYSTEMS.find(function(s) { return s.key === k; }); }).filter(Boolean);
// ── Recording ────────────────────────────────────────────────────────────
var _sickRecorder = null;
var _sickTimer = null;
var _sickRecording = false;
var _sickPaused = false;
var _sickRecognition = null;
var _sickTranscript = '';
function initRecording() {
var recordBtn = document.getElementById('sick-record-btn');
var pauseBtn = document.getElementById('sick-pause-btn');
var indicator = document.getElementById('sick-rec-indicator');
var timerEl = document.getElementById('sick-timer');
var transcriptEl = document.getElementById('sick-transcript');
if (!recordBtn) return;
_sickTimer = createTimer(timerEl);
_sickRecognition = createSpeechRecognition();
if (_sickRecognition) {
_sickRecognition.onresult = function(e) {
var interim = '';
for (var i = e.resultIndex; i < e.results.length; i++) {
if (e.results[i].isFinal) _sickTranscript += e.results[i][0].transcript + ' ';
else interim = e.results[i][0].transcript;
}
if (transcriptEl) transcriptEl.innerHTML = esc(_sickTranscript) + (interim ? '<span style="color:#9ca3af;">' + esc(interim) + '</span>' : '');
};
_sickRecognition.onend = function() { if (_sickRecording && !_sickPaused) try { _sickRecognition.start(); } catch(e) {} };
}
recordBtn.addEventListener('click', function() {
if (!_sickRecording) {
_sickTranscript = '';
_sickRecorder = new AudioRecorder();
_sickRecorder.start().then(function() {
_sickRecording = true; _sickPaused = false;
recordBtn.innerHTML = '<i class="fas fa-stop"></i> Stop';
recordBtn.classList.add('recording');
if (pauseBtn) pauseBtn.classList.remove('hidden');
indicator.classList.remove('hidden');
_sickTimer.start();
if (_sickRecognition) try { _sickRecognition.start(); } catch(e) {}
showToast('Sick visit recording started', 'info');
document.dispatchEvent(new CustomEvent('recording-started', { detail: { module: 'sick' } }));
}).catch(function() { showToast('Microphone denied', 'error'); });
} else {
_sickRecording = false; _sickPaused = false;
var dur = _sickTimer.stop();
recordBtn.innerHTML = '<i class="fas fa-microphone"></i> Listen In';
recordBtn.classList.remove('recording');
if (pauseBtn) pauseBtn.classList.add('hidden');
indicator.classList.add('hidden');
if (_sickRecognition) try { _sickRecognition.stop(); } catch(e) {}
document.dispatchEvent(new CustomEvent('recording-stopped', { detail: { module: 'sick' } }));
showBusy('Transcribing...');
_sickRecorder.stop().then(function(blob) {
if (!blob || blob.size === 0) { hideBusy(); if (_sickTranscript.trim() && transcriptEl) transcriptEl.textContent = _sickTranscript.trim(); return; }
return transcribeAudio(blob, 'sick').then(function(data) {
hideBusy();
if (data.success && data.text) { if (transcriptEl) transcriptEl.textContent = data.text; _sickTranscript = data.text; showToast('Transcribed ' + dur + 's', 'success'); }
else if (data.success) { if (_sickTranscript.trim() && transcriptEl) transcriptEl.textContent = _sickTranscript.trim(); showToast('No speech detected in the recording', 'error'); }
else { if (_sickTranscript.trim() && transcriptEl) transcriptEl.textContent = _sickTranscript.trim(); }
});
}).catch(function() { hideBusy(); });
}
});
if (pauseBtn) {
pauseBtn.addEventListener('click', function() {
if (!_sickRecording || !_sickRecorder || !_sickRecorder.mediaRecorder) return;
if (!_sickPaused) {
try { if (_sickRecorder.mediaRecorder.state === 'recording') _sickRecorder.mediaRecorder.pause(); } catch(e) {}
_sickTimer.stop(); _sickPaused = true;
pauseBtn.innerHTML = '<i class="fas fa-play"></i> Resume';
if (_sickRecognition) try { _sickRecognition.stop(); } catch(e) {}
} else {
_sickRecorder.resumeCapture();
_sickTimer.resume(); _sickPaused = false;
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';
if (_sickRecognition) try { _sickRecognition.start(); } catch(e) {}
}
});
}
}
// ── ROS/PE rendering ──────────────────────────────────────────────────────
function populateAddDropdowns() {
var rosAddEl = document.getElementById('sick-ros-add');
var peAddEl = document.getElementById('sick-pe-add');
if (rosAddEl) {
rosAddEl.innerHTML = '<option value="">+ Add system</option>' +
ROS_SYSTEMS.map(function(s) {
return '<option value="' + esc(s.key) + '">' + esc(s.label) + '</option>';
}).join('');
rosAddEl.addEventListener('change', function() {
var key = this.value;
if (!key) return;
this.value = '';
var sys = ROS_SYSTEMS.find(function(s) { return s.key === key; });
if (!sys) return;
if (_sickRosSystems.some(function(s) { return s.key === key; })) return;
_sickRosSystems.push(sys);
renderRosPe();
});
}
if (peAddEl) {
peAddEl.innerHTML = '<option value="">+ Add system</option>' +
PE_SYSTEMS.map(function(s) {
return '<option value="' + esc(s.key) + '">' + esc(s.label) + '</option>';
}).join('');
peAddEl.addEventListener('change', function() {
var key = this.value;
if (!key) return;
this.value = '';
var sys = PE_SYSTEMS.find(function(s) { return s.key === key; });
if (!sys) return;
if (_sickPeSystems.some(function(s) { return s.key === key; })) return;
_sickPeSystems.push(sys);
renderRosPe();
});
}
}
function renderRosPe() {
var rosContainer = document.getElementById('sick-ros-container');
var peContainer = document.getElementById('sick-pe-container');
if (rosContainer && typeof window.renderRosRows === 'function') {
rosContainer.innerHTML = window.renderRosRows(_sickRosSystems, _sickRosData, 'sros', { wnl: 'WNL', abnormal: 'Abnormal', notrev: 'Not reviewed' });
window.wireRosContainer(rosContainer, _sickRosData);
}
if (peContainer && typeof window.renderRosRows === 'function') {
peContainer.innerHTML = window.renderRosRows(_sickPeSystems, _sickPeData, 'spe', { wnl: 'Normal', abnormal: 'Abnormal', notrev: 'Not examined' });
window.wireRosContainer(peContainer, _sickPeData);
}
}
function inferAndRender() {
var cc = (document.getElementById('sick-cc') || {}).value || '';
var inferred = inferSystems(cc);
_sickRosSystems = inferred.ros;
_sickPeSystems = inferred.pe;
_sickRosData = {};
_sickPeData = {};
renderRosPe();
if (cc) showToast('ROS/PE updated for: ' + cc, 'info');
}
// ── Diagnoses ─────────────────────────────────────────────────────────────
function initDx() {
var dxContainer = document.getElementById('sick-dx-container');
if (dxContainer && typeof window.renderDxComponent === 'function') {
window.renderDxComponent(dxContainer, _sickSelectedDx, 'sick');
}
}
// ── Generate note ─────────────────────────────────────────────────────────
function generateSickNote() {
var age = (document.getElementById('sick-age') || {}).value || '';
var gender = (document.getElementById('sick-gender') || {}).value || '';
var cc = (document.getElementById('sick-cc') || {}).value || '';
if (!cc) { showToast('Enter a chief complaint first', 'error'); return; }
var transcriptEl = document.getElementById('sick-transcript');
var transcript = transcriptEl ? (transcriptEl.innerText || transcriptEl.textContent || '') : '';
var rosText = typeof window.formatRosForAI === 'function' ? window.formatRosForAI(_sickRosSystems, _sickRosData, 'Review of Systems') : '';
var peText = typeof window.formatRosForAI === 'function' ? window.formatRosForAI(_sickPeSystems, _sickPeData, 'Physical Examination') : '';
var dxText = typeof window.formatDxForAI === 'function' ? window.formatDxForAI(_sickSelectedDx, 'sick-dx-freetext') : '';
showBusy('Generating sick visit note...');
var modelEl = document.getElementById('sick-model-select');
var memoriesPromise = (typeof getUserMemoryContext === 'function') ? getUserMemoryContext() : Promise.resolve('');
memoriesPromise.then(function(memCtx) {
return fetch('/api/sick-visit/note', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
patientAge: age,
patientGender: gender,
chiefComplaint: cc,
transcript: transcript || null,
ros: rosText || null,
physicalExam: peText || null,
diagnoses: dxText || null,
physicianMemories: memCtx || null,
model: modelEl ? modelEl.value : getSelectedModel()
})
});
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideBusy();
if (data.success) {
var noteEl = document.getElementById('sick-note-text');
var outCard = document.getElementById('sick-note-output');
var tag = document.getElementById('sick-note-model-tag');
if (noteEl) setOutputText(noteEl, data.note);
var sickTranscript = document.getElementById('sick-transcript');
if (sickTranscript) storeSourceContext('sick-note-text', sickTranscript.innerText.trim());
if (tag) tag.textContent = (data.model || '').split('/').pop();
if (outCard) { outCard.classList.remove('hidden'); outCard.scrollIntoView({ behavior: 'smooth' }); }
showToast('Sick visit note generated!', 'success');
if (typeof attachPatientEducation === 'function') attachPatientEducation('sick-note-text', { patientAge: document.getElementById('sick-age').value });
if (typeof suggestBillingCodes === 'function') suggestBillingCodes('sick-note-text', data.note, 'sickvisit', document.getElementById('sick-age').value);
if (typeof suggestDontMiss === 'function') suggestDontMiss('sick-note-text', data.note, 'sickvisit', document.getElementById('sick-age').value, document.getElementById('sick-cc').value);
} else {
showToast(data.error || 'Generation failed', 'error');
}
})
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
}
// ── Reset ─────────────────────────────────────────────────────────────────
function resetSickVisit() {
var fields = ['sick-age', 'sick-gender', 'sick-cc', 'sick-label'];
fields.forEach(function(id) { var el = document.getElementById(id); if (el) el.value = ''; });
var transcriptEl = document.getElementById('sick-transcript');
if (transcriptEl) transcriptEl.textContent = '';
_sickTranscript = '';
_sickRosData = {};
_sickPeData = {};
_sickSelectedDx.length = 0;
_sickRosSystems = DEFAULT_ROS.map(function(k) { return ROS_SYSTEMS.find(function(s) { return s.key === k; }); }).filter(Boolean);
_sickPeSystems = DEFAULT_PE.map(function(k) { return PE_SYSTEMS.find(function(s) { return s.key === k; }); }).filter(Boolean);
renderRosPe();
initDx();
var outCard = document.getElementById('sick-note-output');
var noteEl = document.getElementById('sick-note-text');
if (outCard) outCard.classList.add('hidden');
if (noteEl) noteEl.textContent = '';
window._savedEncId_sickvisit = null;
showToast('Sick visit cleared for new patient', 'info');
}
// ── Click handler ─────────────────────────────────────────────────────────
document.addEventListener('click', function(e) {
if (e.target.closest('#btn-sick-generate')) generateSickNote();
if (e.target.closest('#btn-sick-infer')) inferAndRender();
if (e.target.closest('#btn-sick-new-patient')) resetSickVisit();
if (e.target.closest('#sick-refine-btn')) refineDocument('sick-note-text', 'sick-refine-input');
if (e.target.closest('#sick-shorten-btn')) shortenDocument('sick-note-text');
if (e.target.closest('#sick-ros-all-wnl')) {
_sickRosSystems.forEach(function(sys) { _sickRosData[sys.key + '_status'] = 'wnl'; });
renderRosPe();
showToast('All ROS set to WNL', 'success');
}
if (e.target.closest('#sick-ros-clear')) {
_sickRosSystems.forEach(function(sys) { _sickRosData[sys.key + '_status'] = ''; _sickRosData[sys.key + '_note'] = ''; });
renderRosPe();
showToast('ROS cleared', 'info');
}
if (e.target.closest('#sick-pe-all-normal')) {
_sickPeSystems.forEach(function(sys) { _sickPeData[sys.key + '_status'] = 'wnl'; });
renderRosPe();
showToast('All PE set to Normal', 'success');
}
if (e.target.closest('#sick-pe-clear')) {
_sickPeSystems.forEach(function(sys) { _sickPeData[sys.key + '_status'] = ''; _sickPeData[sys.key + '_note'] = ''; });
renderRosPe();
showToast('PE cleared', 'info');
}
if (e.target.closest('#btn-sick-save')) {
var labelEl = document.getElementById('sick-label');
var transcriptEl = document.getElementById('sick-transcript');
var noteEl = document.getElementById('sick-note-text');
if (typeof saveEncounter === 'function') {
saveEncounter({
id: window._savedEncId_sickvisit || null,
label: labelEl ? labelEl.value.trim() : '',
enc_type: 'sickvisit',
transcript: transcriptEl ? (transcriptEl.innerText || '') : '',
generated_note: noteEl ? (noteEl.innerText || '') : '',
onSaved: function(id) { window._savedEncId_sickvisit = id; }
});
}
}
});
// ── Load handler ──────────────────────────────────────────────────────────
if (typeof registerEncounterLoadHandler === 'function') {
registerEncounterLoadHandler('sickvisit', function(enc) {
var transcriptEl = document.getElementById('sick-transcript');
if (transcriptEl && enc.transcript) transcriptEl.textContent = enc.transcript;
if (enc.generated_note) {
var noteEl = document.getElementById('sick-note-text');
var outCard = document.getElementById('sick-note-output');
if (noteEl) noteEl.textContent = enc.generated_note;
if (outCard) outCard.classList.remove('hidden');
}
});
}
function esc(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── Init ──────────────────────────────────────────────────────────────────
var _inited = false;
document.addEventListener('tabChanged', function(e) {
if (e.detail.tab !== 'sickvisit' || _inited) return;
_inited = true;
initRecording();
populateAddDropdowns();
renderRosPe();
initDx();
});
console.log('SickVisit module loaded');