diff --git a/public/index.html b/public/index.html index a1493cac..85eedc46 100644 --- a/public/index.html +++ b/public/index.html @@ -469,6 +469,7 @@ integrity="sha384-JUh163oCRItcbPme8pYnROHQMC6fNKTBWtRG3I3I0erJkzNgL7uxKlNwcrcFKeqF" crossorigin="anonymous" referrerpolicy="no-referrer" defer> + diff --git a/public/js/app.js b/public/js/app.js index 07666966..6c6d6cbb 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1222,11 +1222,14 @@ function checkTranscribeStatus() { .catch(function() { window._transcribeAvailable = false; }); } -function transcribeAudio(blob) { - return _serverTranscribe(blob); +// module says which tab this audio came from. The server stores it with the +// 24h backup, which is what lets a later retry put the transcript back where it +// belongs instead of only on the clipboard. +function transcribeAudio(blob, module) { + return _serverTranscribe(blob, module); } -function _serverTranscribe(blob) { +function _serverTranscribe(blob, module) { // If no server transcription is configured, skip upload entirely if (window._transcribeAvailable === false) { return Promise.resolve({ success: false, noProvider: true, error: 'No transcription API configured — using live transcript' }); @@ -1235,6 +1238,8 @@ function _serverTranscribe(blob) { var startTime = Date.now(); var formData = new FormData(); formData.append('audio', blob, 'audio.webm'); + var moduleName = window.RecordingModules ? window.RecordingModules.normalize(module) : module; + if (moduleName) formData.append('module', moduleName); var headers = getAuthHeaders(); delete headers['Content-Type']; // FormData supplies its own boundary. return fetch('/api/transcribe', { diff --git a/public/js/audioBackup.js b/public/js/audioBackup.js index ede140d4..089d0352 100644 --- a/public/js/audioBackup.js +++ b/public/js/audioBackup.js @@ -245,7 +245,73 @@ function guardTransaction(tx, owner) { }); }; - window.retryAudioBackup = function(id) { + + // Put a retried transcript back where the audio came from. + // + // The module travelled with the recording, so the app already knows which tab + // and which box. Opening that tab and inserting the text is what someone was + // going to do by hand with the clipboard copy; doing it for them is the whole + // point of having stored the module. + // + // Existing text is never destroyed — a retry is usually recovering something + // ON TOP of a live transcript, and silently replacing it would lose the words + // the browser did hear. The new text is appended and revealed. + function deliverTranscript(module, text) { + var entry = window.RecordingModules && window.RecordingModules.lookup(module); + if (!entry || !text) return false; + if (typeof window.activateTab !== 'function') return false; + if (!window.activateTab(entry.tab)) return false; + + // The tab's markup is fetched on first activation, so the box may not exist + // for a moment. Poll briefly rather than guessing a delay. + var attempts = 0; + (function place() { + var box = document.getElementById(entry.target); + if (!box) { + if (attempts++ > 40) { + copyToClipboard(text); + showToast('Transcript copied — could not open ' + entry.label, 'info'); + return; + } + return setTimeout(place, 50); + } + var existing = (box.textContent || '').trim(); + box.textContent = existing ? existing + '\n\n' + text : text; + box.scrollIntoView({ block: 'center' }); + try { box.focus(); } catch (e) {} + showToast('Transcript added to ' + entry.label, 'success'); + }()); + return true; + } + + function copyToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text); + return true; + } + return false; + } + + // One place decides what happens with a retried transcript, so the server and + // local paths cannot drift apart. + function handleRetryResult(data, module) { + if (!data || !data.success) { + showToast('Retry failed: ' + ((data && data.error) || 'unknown'), 'error'); + return data; + } + if (!data.text) { + showToast('That recording still transcribes to nothing', 'error'); + return data; + } + if (deliverTranscript(module, data.text)) return data; + // Unknown module, or no tab to open: the clipboard is still better than + // nothing, and says so rather than claiming success. + if (copyToClipboard(data.text)) showToast('Transcript copied to clipboard', 'info'); + else showToast('Transcribed, but there was nowhere to put it', 'info'); + return data; + } + + window.retryAudioBackup = function(id, module) { var owner = boundary.capture(); if (!owner) return Promise.reject(boundary.error()); if (typeof id === 'string' && id.startsWith('server_')) { @@ -258,19 +324,10 @@ function guardTransaction(tx, owner) { .then(function(blob) { if (!boundary.valid(owner)) throw boundary.error(); window._lastAudioBackupId = id; - return transcribeAudio(blob).then(function(data) { + return transcribeAudio(blob, module).then(function(data) { if (!boundary.valid(owner)) throw boundary.error(); hideLoading(); - if (data.success) { - showToast('Backup transcribed!', 'success'); - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(data.text); - showToast('Transcript copied to clipboard', 'info'); - } - } else { - showToast('Retry failed: ' + (data.error || 'unknown'), 'error'); - } - return data; + return handleRetryResult(data, module); }); }) .catch(function(err) { hideLoading(); showToast('Retry failed: ' + err.message, 'error'); }); @@ -292,19 +349,10 @@ function guardTransaction(tx, owner) { if (!boundary.valid(owner)) throw boundary.error(); showLoading('Re-transcribing audio backup...'); window._lastAudioBackupId = id; - return transcribeAudio(record.blob).then(function(data) { + return transcribeAudio(record.blob, module || record.module).then(function(data) { if (!boundary.valid(owner)) throw boundary.error(); hideLoading(); - if (data.success) { - showToast('Backup transcribed!', 'success'); - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(data.text); - showToast('Transcript copied to clipboard', 'info'); - } - } else { - showToast('Retry failed: ' + (data.error || 'unknown'), 'error'); - } - return data; + return handleRetryResult(data, module || record.module); }); }); }; @@ -360,7 +408,8 @@ function guardTransaction(tx, owner) { title.style.display = 'flex'; title.style.alignItems = 'center'; title.style.gap = '6px'; - title.appendChild(document.createTextNode((b.module || '') + ' recording ')); + title.appendChild(document.createTextNode( + (window.RecordingModules ? window.RecordingModules.label(b.module) : (b.module || 'Recording')) + ' ')); var sourceTag = document.createElement('span'); sourceTag.style.fontSize = '10px'; @@ -382,10 +431,15 @@ function guardTransaction(tx, owner) { var retryBtn = document.createElement('button'); retryBtn.className = 'btn-sm btn-primary audio-backup-retry'; retryBtn.dataset.id = b.id; + retryBtn.dataset.module = b.module || ''; var retryIcon = document.createElement('i'); retryIcon.className = 'fas fa-rotate-right'; retryBtn.appendChild(retryIcon); - retryBtn.appendChild(document.createTextNode(' Retry')); + var retryTarget = window.RecordingModules && window.RecordingModules.lookup(b.module); + retryBtn.appendChild(document.createTextNode(retryTarget ? ' Retry into ' + retryTarget.label : ' Retry')); + retryBtn.title = retryTarget + ? 'Transcribe again and add the text to ' + retryTarget.label + : 'Transcribe again and copy the text to the clipboard'; var downloadBtn = document.createElement('button'); downloadBtn.className = 'btn-sm btn-ghost audio-backup-download'; @@ -411,7 +465,7 @@ function guardTransaction(tx, owner) { container.appendChild(row); }); container.querySelectorAll('.audio-backup-retry').forEach(function(btn) { - btn.addEventListener('click', function() { retryAudioBackup(btn.dataset.id); }); + btn.addEventListener('click', function() { retryAudioBackup(btn.dataset.id, btn.dataset.module); }); }); container.querySelectorAll('.audio-backup-download').forEach(function(btn) { btn.addEventListener('click', function() { downloadAudioBackup(btn.dataset.id, btn.dataset.stamp); }); diff --git a/public/js/ed-encounters.js b/public/js/ed-encounters.js index 5e1d5419..320feb17 100644 --- a/public/js/ed-encounters.js +++ b/public/js/ed-encounters.js @@ -316,7 +316,7 @@ persistLocal(); return; } - return transcribeAudio(blob).then(function(data) { + return transcribeAudio(blob, 'ed').then(function(data) { hideBusy(); if (data && data.success && data.text) { if (transcriptEl) transcriptEl.textContent = data.text; diff --git a/public/js/liveEncounter.js b/public/js/liveEncounter.js index b9934fef..acb8aac6 100644 --- a/public/js/liveEncounter.js +++ b/public/js/liveEncounter.js @@ -111,7 +111,7 @@ var _liveEncounterInited = false; showToast('Recording too large for AI transcription — using live transcript', 'info'); return; } - return transcribeAudio(blob).then(function(data) { + return transcribeAudio(blob, 'encounter').then(function(data) { hideBusy(); // A successful call that returns no words must not wipe what the // browser already heard — that looked exactly like a lost recording. diff --git a/public/js/recordingModules.js b/public/js/recordingModules.js new file mode 100644 index 00000000..9afe9c0a --- /dev/null +++ b/public/js/recordingModules.js @@ -0,0 +1,58 @@ +// ============================================================ +// RECORDING MODULES +// Where a recording came from, and where its transcript belongs. +// +// Every recording is kept for 24 hours, so a transcription that fails or comes +// back empty can be retried later. Retrying used to hand you the text on the +// clipboard and leave you to find the right tab and paste it — which is work +// the app already has the answer to: the module was recorded with the audio. +// +// One table, because three things have to agree about a module: the recorder +// that tags the upload, the backup list that labels it, and the retry that +// delivers the transcript. They disagreed before — recorders tagged +// 'encounter' while the recording-started events said 'enc' — so this also +// normalises the aliases rather than leaving each caller to guess. +// ============================================================ + +(function () { + var MODULES = { + encounter: { tab: 'encounter', target: 'enc-transcript', label: 'Encounter HPI' }, + dictation: { tab: 'dictation', target: 'dict-transcript', label: 'Dictation HPI' }, + soap: { tab: 'soap', target: 'soap-transcript', label: 'SOAP Note' }, + sick: { tab: 'sickvisit', target: 'sick-transcript', label: 'Sick Visit' }, + wellvisit: { tab: 'wellvisit', target: 'wv-transcript', label: 'Well Visit' }, + ed: { tab: 'ed', target: 'ed-transcript', label: 'ED Encounter' } + }; + + // The short names the recording-started/stopped events have always used, and + // the plain ones a person might see in a backup row. + var ALIASES = { + enc: 'encounter', dict: 'dictation', sickvisit: 'sick', 'sick-visit': 'sick', + wv: 'wellvisit', 'well-visit': 'wellvisit', 'ed-encounter': 'ed' + }; + + function normalize(module) { + var name = String(module == null ? '' : module).trim().toLowerCase(); + return ALIASES[name] || name; + } + + function lookup(module) { + return MODULES[normalize(module)] || null; + } + + // A label for a backup row. Unknown modules keep their raw name rather than + // being hidden, because an unrecognised one is worth seeing. + function label(module) { + var entry = lookup(module); + if (entry) return entry.label; + var name = normalize(module); + return name && name !== 'recording' ? name : 'Recording'; + } + + window.RecordingModules = { + normalize: normalize, + lookup: lookup, + label: label, + known: function () { return Object.keys(MODULES); } + }; +}()); diff --git a/public/js/shadess.js b/public/js/shadess.js index 728cf48a..3d328f3f 100644 --- a/public/js/shadess.js +++ b/public/js/shadess.js @@ -748,7 +748,7 @@ function setupShadessModule() { showBusy('Transcribing...'); _wvRecorder.stop().then(function(blob) { if (!blob || blob.size === 0) { hideBusy(); if (_wvTranscript.trim() && transcriptEl) transcriptEl.textContent = _wvTranscript.trim(); return; } - return transcribeAudio(blob).then(function(data) { + return transcribeAudio(blob, 'wellvisit').then(function(data) { hideBusy(); if (data.success && data.text) { if (transcriptEl) transcriptEl.textContent = data.text; _wvTranscript = data.text; showToast('Transcribed ' + dur + 's', 'success'); } else if (data.success) { if (_wvTranscript.trim() && transcriptEl) transcriptEl.textContent = _wvTranscript.trim(); showToast('No speech detected in the recording', 'error'); } diff --git a/public/js/sickVisit.js b/public/js/sickVisit.js index 3678b819..df261b0a 100644 --- a/public/js/sickVisit.js +++ b/public/js/sickVisit.js @@ -177,7 +177,7 @@ 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).then(function(data) { + 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'); } diff --git a/public/js/soap.js b/public/js/soap.js index f78001e3..85958562 100644 --- a/public/js/soap.js +++ b/public/js/soap.js @@ -97,7 +97,7 @@ showToast('Recording too large for AI transcription — using live transcript', 'info'); return; } - return transcribeAudio(blob).then(function(data) { + return transcribeAudio(blob, 'soap').then(function(data) { hideBusy(); if (data.success && data.text) transcript.textContent = data.text; else if (data.success) { if (liveText) transcript.textContent = liveText; showToast('No speech detected in the recording', 'error'); } diff --git a/public/js/voiceDictation.js b/public/js/voiceDictation.js index b833bbd9..c16753fe 100644 --- a/public/js/voiceDictation.js +++ b/public/js/voiceDictation.js @@ -87,7 +87,7 @@ var _voiceDictationInited = false; showToast('Recording too large for AI transcription — using live transcript', 'info'); return; } - return transcribeAudio(blob).then(function(data) { + return transcribeAudio(blob, 'dictation').then(function(data) { hideBusy(); if (data.success && data.text) { transcript.textContent = data.text; showToast('Transcribed ' + dur + 's', 'success'); } else if (data.success) { if (liveText) transcript.textContent = liveText; showToast('No speech detected in the recording', 'error'); } diff --git a/test/transcription-memory-policy.test.js b/test/transcription-memory-policy.test.js index ebb12e44..ae5458e2 100644 --- a/test/transcription-memory-policy.test.js +++ b/test/transcription-memory-policy.test.js @@ -254,3 +254,54 @@ test('a transcription that finds no words leaves the live transcript alone', () assert.match(read('public/js/liveEncounter.js'), /showToast\('No audio was captured — check the microphone and try again', 'error'\);/); }); + +test('a retried transcript goes back to the tab the audio came from', () => { + // The module travels with the recording, so the app already knows which tab + // and which box. Handing someone the clipboard and leaving them to find the + // right tab was work the app could do itself. + const modules = read('public/js/recordingModules.js'); + for (const [name, tab, target] of [ + ['encounter', 'encounter', 'enc-transcript'], + ['dictation', 'dictation', 'dict-transcript'], + ['soap', 'soap', 'soap-transcript'], + ['sick', 'sickvisit', 'sick-transcript'], + ['wellvisit', 'wellvisit', 'wv-transcript'], + ['ed', 'ed', 'ed-transcript'], + ]) { + const row = new RegExp(name + ":\\s*\\{\\s*tab:\\s*'" + tab + "',\\s*target:\\s*'" + target + "'"); + assert.match(modules, row, name + ' maps to its own tab and box'); + } + // The recorders and the recording-started events used different names for the + // same module — 'encounter' against 'enc' — so the aliases are resolved here + // rather than left for each caller to guess. + assert.match(modules, /enc: 'encounter', dict: 'dictation'/); + + const backup = read('public/js/audioBackup.js'); + assert.match(backup, /function deliverTranscript\(module, text\)/); + assert.match(backup, /if \(!window\.activateTab\(entry\.tab\)\) return false;/, 'it opens that tab'); + // A retry usually recovers text ON TOP of a live transcript, so replacing the + // box would lose the words the browser did hear. + assert.match(backup, /box\.textContent = existing \? existing \+ '\\n\\n' \+ text : text;/, + 'existing text is appended to, never replaced'); + // The box only exists once the tab's markup has been fetched. + assert.match(backup, /if \(attempts\+\+ > 40\)/, 'it waits for the component rather than guessing a delay'); + assert.match(backup, /showToast\('Transcript copied — could not open '/, 'and falls back to the clipboard'); + assert.match(backup, /if \(!data\.text\) \{[\s\S]{0,120}still transcribes to nothing/, + 'an empty result says so instead of claiming success'); +}); + +test('every recording is uploaded with the module it came from', () => { + // Without this the server stored "recording" for all of them — 28 rows of it — + // and a retry had nowhere to send the text back to. + const app = read('public/js/app.js'); + assert.match(app, /function transcribeAudio\(blob, module\)/); + assert.match(app, /if \(moduleName\) formData\.append\('module', moduleName\);/); + + for (const [file, module] of [ + ['liveEncounter', 'encounter'], ['soap', 'soap'], ['voiceDictation', 'dictation'], + ['sickVisit', 'sick'], ['ed-encounters', 'ed'], ['shadess', 'wellvisit'], + ]) { + assert.match(read('public/js/' + file + '.js'), new RegExp("transcribeAudio\\(blob, '" + module + "'\\)"), + file + ' tags its uploads'); + } +});