Four changes batched: 1. ED Encounters tab (new) — multi-stage emergency note with don't-miss tooltips and 2023 E/M MDM finalize. New route /api/ed-encounters (generate per-stage + finalize MDM), new ed-encounters.js owning all client logic, new ed-encounter.html component, new template_ed memory category. Persists draft to localStorage every keystroke and to saved_encounters on stage advance. encounters.js touched only to register the new tab in sessionStorage restore + tabMap (save and idempotency code untouched). 2. Notes model selector — /notes/from-voice now accepts a client-supplied model (validated by the existing callAI allow-list); falls back to the admin default. Added <select class="tab-model-select"> to notes.html so the existing app.js populator handles options + default. 3. Remove AI-learning-from-corrections — deleted correctionTracker.js, POST /memories/correction, the corrections branch in /memories/context, the settings UI section, the FAQ entry, and all dead trackAIOutput/saveCorrection guards in callers. Legacy correction_* DB rows are filtered (NOT LIKE) rather than dropped, so no destructive migration. 4. Fix notes AI framing — /notes/from-voice prompt no longer assumes "physician dictation". Plain notes (shopping lists, reminders, ideas) now match the dictation tone instead of being forced into clinical structure. All 46 tests pass.
211 lines
9.2 KiB
JavaScript
211 lines
9.2 KiB
JavaScript
(function() {
|
|
var _inited = false;
|
|
document.addEventListener('tabChanged', function(e) {
|
|
if (e.detail.tab !== 'dictation' || _inited) return;
|
|
_inited = true;
|
|
var recordBtn = document.getElementById('dict-record-btn');
|
|
var pauseBtn = document.getElementById('dict-pause-btn');
|
|
var indicator = document.getElementById('dict-recording-indicator');
|
|
var timerEl = document.getElementById('dict-timer');
|
|
var transcript = document.getElementById('dict-transcript');
|
|
var clearBtn = document.getElementById('dict-clear');
|
|
var generateBtn = document.getElementById('dict-generate-btn');
|
|
var outputCard = document.getElementById('dict-output');
|
|
var hpiText = document.getElementById('dict-hpi-text');
|
|
var modelTag = document.getElementById('dict-model-tag');
|
|
|
|
var recorder = new AudioRecorder();
|
|
recorder._module = 'dictation';
|
|
var timer = createTimer(timerEl);
|
|
var isRecording = false;
|
|
var isPaused = false;
|
|
var recognition = createSpeechRecognition();
|
|
var finalText = '';
|
|
var sessionFinals = '';
|
|
|
|
function escHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
|
|
if (recognition) {
|
|
recognition.onresult = function(e) {
|
|
var interim = '';
|
|
for (var i = e.resultIndex; i < e.results.length; i++) {
|
|
if (e.results[i].isFinal) {
|
|
var chunk = e.results[i][0].transcript + ' ';
|
|
var deduped = deduplicateFinal(chunk, finalText + sessionFinals);
|
|
sessionFinals += deduped;
|
|
} else {
|
|
interim = e.results[i][0].transcript;
|
|
}
|
|
}
|
|
var combined = finalText + sessionFinals;
|
|
transcript.innerHTML = escHtml(combined) + (interim ? '<span style="color:#9ca3af;">' + escHtml(interim) + '</span>' : '');
|
|
};
|
|
recognition.onend = function() {
|
|
finalText += sessionFinals;
|
|
sessionFinals = '';
|
|
if (isRecording && !isPaused) try { recognition.start(); } catch(e) {}
|
|
};
|
|
}
|
|
|
|
recordBtn.addEventListener('click', function() {
|
|
if (!isRecording) {
|
|
finalText = '';
|
|
sessionFinals = '';
|
|
recorder.start().then(function() {
|
|
isRecording = true;
|
|
isPaused = false;
|
|
recordBtn.classList.add('recording');
|
|
recordBtn.querySelector('span').textContent = 'Stop';
|
|
if (pauseBtn) pauseBtn.classList.remove('hidden');
|
|
indicator.classList.remove('hidden');
|
|
timer.start();
|
|
if (recognition) try { recognition.start(); } catch(e) {}
|
|
document.dispatchEvent(new CustomEvent('recording-started', { detail: { module: 'dict' } }));
|
|
}).catch(function() { showToast('Microphone denied', 'error'); });
|
|
} else {
|
|
isRecording = false;
|
|
isPaused = false;
|
|
var dur = timer.stop();
|
|
recordBtn.classList.remove('recording');
|
|
recordBtn.querySelector('span').textContent = 'Start Dictation';
|
|
if (pauseBtn) { pauseBtn.classList.add('hidden'); pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause'; }
|
|
indicator.classList.add('hidden');
|
|
if (recognition) try { recognition.stop(); } catch(e) {}
|
|
document.dispatchEvent(new CustomEvent('recording-stopped', { detail: { module: 'dict' } }));
|
|
|
|
var liveText = (finalText + sessionFinals).trim();
|
|
if (window._transcribeAvailable === false) {
|
|
recorder.stop().then(function() {});
|
|
if (liveText) transcript.textContent = liveText;
|
|
showToast('Using browser speech recognition (no transcription API configured)', 'info');
|
|
} else {
|
|
showBusy('Transcribing...');
|
|
recorder.stop().then(function(blob) {
|
|
if (!blob || blob.size === 0) { hideBusy(); if (liveText) transcript.textContent = liveText; return; }
|
|
if (blob.size > 24 * 1024 * 1024) {
|
|
hideBusy();
|
|
transcript.textContent = liveText;
|
|
showToast('Recording too large for AI transcription — using live transcript', 'info');
|
|
return;
|
|
}
|
|
return transcribeAudio(blob).then(function(data) {
|
|
hideBusy();
|
|
if (data.success) { transcript.textContent = data.text; showToast('Transcribed ' + dur + 's', 'success'); }
|
|
else if (data.noProvider) { if (liveText) transcript.textContent = liveText; showToast('Using live transcript', 'info'); }
|
|
else { if (liveText) transcript.textContent = liveText; showToast(data.error || 'Transcription failed — using live transcript', 'error'); }
|
|
});
|
|
}).catch(function(err) { hideBusy(); if (liveText) transcript.textContent = liveText; showToast('Transcription error: ' + (err.message || 'unknown'), 'error'); });
|
|
}
|
|
}
|
|
});
|
|
|
|
// Pause / Resume
|
|
if (pauseBtn) {
|
|
pauseBtn.addEventListener('click', function() {
|
|
if (!isRecording) return;
|
|
if (!isPaused) {
|
|
try { if (recorder.mediaRecorder && recorder.mediaRecorder.state === 'recording') recorder.mediaRecorder.pause(); } catch(e) {}
|
|
timer.stop();
|
|
isPaused = true;
|
|
pauseBtn.innerHTML = '<i class="fas fa-play"></i> Resume';
|
|
pauseBtn.classList.add('btn-primary');
|
|
pauseBtn.classList.remove('btn-ghost');
|
|
if (recognition) try { recognition.stop(); } catch(e) {}
|
|
showToast('Paused', 'info');
|
|
} else {
|
|
try {
|
|
if (recorder.mediaRecorder && recorder.mediaRecorder.state === 'paused') { recorder.mediaRecorder.resume(); }
|
|
else if (recorder.mediaRecorder && recorder.mediaRecorder.state === 'inactive' && recorder.stream && recorder.stream.active) {
|
|
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
|
|
recorder.mediaRecorder = new MediaRecorder(recorder.stream, { mimeType: mime, audioBitsPerSecond: 32000 });
|
|
recorder.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) recorder.chunks.push(e.data); };
|
|
recorder.mediaRecorder.start(1000);
|
|
}
|
|
} catch(e) {}
|
|
timer.resume();
|
|
isPaused = false;
|
|
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';
|
|
pauseBtn.classList.remove('btn-primary');
|
|
pauseBtn.classList.add('btn-ghost');
|
|
if (recognition) try { recognition.start(); } catch(e) {}
|
|
showToast('Resumed', 'info');
|
|
}
|
|
});
|
|
}
|
|
|
|
clearBtn.addEventListener('click', function() {
|
|
transcript.textContent = ''; finalText = ''; outputCard.classList.add('hidden');
|
|
window._savedEncId_dictation = null;
|
|
var labelEl = document.getElementById('dict-label');
|
|
if (labelEl) labelEl.value = '';
|
|
var refineEl = document.getElementById('dict-refine-input');
|
|
if (refineEl) refineEl.value = '';
|
|
});
|
|
|
|
generateBtn.addEventListener('click', function() {
|
|
var text = transcript.innerText.trim();
|
|
if (!text) { showToast('No dictation', 'error'); return; }
|
|
|
|
var outputType = document.getElementById('dict-output-type').value;
|
|
var endpoint, bodyData;
|
|
|
|
showBusy('Generating...');
|
|
|
|
var memoriesPromise = (typeof getUserMemoryContext === 'function') ? getUserMemoryContext() : Promise.resolve('');
|
|
|
|
memoriesPromise.then(function(memCtx) {
|
|
if (outputType === 'soap-full' || outputType === 'soap-subjective') {
|
|
endpoint = '/api/generate-soap';
|
|
bodyData = {
|
|
transcript: text,
|
|
patientAge: document.getElementById('dict-age').value,
|
|
patientGender: document.getElementById('dict-gender').value,
|
|
type: outputType === 'soap-full' ? 'full' : 'subjective',
|
|
physicianMemories: memCtx || null,
|
|
model: getSelectedModel()
|
|
};
|
|
} else {
|
|
endpoint = '/api/generate-hpi-dictation';
|
|
bodyData = {
|
|
transcript: text,
|
|
patientAge: document.getElementById('dict-age').value,
|
|
patientGender: document.getElementById('dict-gender').value,
|
|
setting: document.getElementById('dict-setting').value,
|
|
physicianMemories: memCtx || null,
|
|
model: getSelectedModel()
|
|
};
|
|
}
|
|
return fetch(endpoint, { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify(bodyData) });
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
hideBusy();
|
|
if (data.success) {
|
|
setOutputText(hpiText, data.hpi || data.soap);
|
|
storeSourceContext('dict-hpi-text', transcript.innerText.trim());
|
|
modelTag.textContent = (data.model || '').split('/').pop();
|
|
outputCard.classList.remove('hidden');
|
|
outputCard.scrollIntoView({ behavior: 'smooth' });
|
|
showToast('Generated!', 'success');
|
|
} else showToast(data.error || 'Failed', 'error');
|
|
})
|
|
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
|
|
});
|
|
|
|
document.getElementById('dict-refine-btn').addEventListener('click', function() { refineDocument('dict-hpi-text', 'dict-refine-input'); });
|
|
document.getElementById('dict-shorten-btn').addEventListener('click', function() { shortenDocument('dict-hpi-text'); });
|
|
|
|
// Register load handler
|
|
if (typeof registerEncounterLoadHandler === 'function') {
|
|
registerEncounterLoadHandler('dictation', function(enc) {
|
|
if (enc.transcript) transcript.textContent = enc.transcript;
|
|
if (enc.generated_note) {
|
|
hpiText.textContent = enc.generated_note;
|
|
outputCard.classList.remove('hidden');
|
|
}
|
|
});
|
|
}
|
|
|
|
console.log('✅ Dictation module loaded');
|
|
});
|
|
})();
|