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
This commit is contained in:
Daniel 2026-09-11 12:39:50 +02:00
parent 5394fc930b
commit b3d66caaca
11 changed files with 204 additions and 35 deletions

View file

@ -469,6 +469,7 @@
integrity="sha384-JUh163oCRItcbPme8pYnROHQMC6fNKTBWtRG3I3I0erJkzNgL7uxKlNwcrcFKeqF"
crossorigin="anonymous" referrerpolicy="no-referrer" defer></script>
<script defer src="/js/milestonesData.js"></script>
<script src="/js/recordingModules.js"></script>
<script type="module" src="/js/audioBackup.js"></script>
<script type="module" src="/js/speechRecognition.js"></script>
<script type="module" src="/js/transcriptionSettings.js"></script>

View file

@ -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', {

View file

@ -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); });

View file

@ -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;

View file

@ -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.

View file

@ -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); }
};
}());

View file

@ -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'); }

View file

@ -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'); }

View file

@ -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'); }

View file

@ -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'); }

View file

@ -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');
}
});