pediatric-ai-scribe-v3/test/transcription-memory-policy.test.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

307 lines
18 KiB
JavaScript

const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..');
function read(relativePath) {
return fs.readFileSync(path.join(root, relativePath), 'utf8');
}
test('AI memory context is limited to saved template categories', () => {
const route = read('src/routes/memories.js');
assert.match(route, /var AI_CONTEXT_CATEGORIES = \[/);
assert.match(route, /'physical_exam'/);
assert.match(route, /'template_ed'/);
assert.match(route, /rows = rows\.filter\(function\(r\) \{ return AI_CONTEXT_CATEGORIES\.indexOf\(r\.category\) !== -1; \}\)/);
assert.doesNotMatch(route.match(/var AI_CONTEXT_CATEGORIES = \[[\s\S]*?\];/)[0], /'custom'/);
});
test('template settings do not offer new custom AI memories', () => {
const settings = read('public/components/settings.html');
const memories = read('public/js/memories.js');
assert.doesNotMatch(settings, /<option value="custom">/);
assert.match(settings, /Only template categories are sent to AI/);
assert.match(memories, /custom: 'Custom \(not used by AI\)'/);
assert.match(memories, /window\.getUserMemoryContext = function\(\)/);
});
test('note refine corrections still call AI without storing learning memories', () => {
const app = read('public/js/app.js');
const refine = read('src/routes/refine.js');
assert.match(app, /function refineDocument\(outputElementId, inputElementId\)/);
assert.match(app, /fetch\('\/api\/refine'/);
assert.match(app, /currentDocument: docText, instructions: instructions/);
assert.match(refine, /router\.post\('\/refine'/);
assert.match(refine, /PROMPTS\.refine \+ INJECTION_GUARD/);
assert.match(refine, /sourceContext/);
assert.doesNotMatch(refine, /INSERT INTO user_memories|correction_/);
});
test('browser Whisper is removed from public runtime and user settings', () => {
assert.equal(fs.existsSync(path.join(root, 'public/js/browserWhisper.js')), false);
assert.equal(fs.existsSync(path.join(root, 'public/js/whisperWorker.js')), false);
assert.equal(fs.existsSync(path.join(root, 'public/js/whisperWorkerV2.js')), false);
assert.equal(fs.existsSync(path.join(root, 'docs/browser-whisper-setup.md')), false);
assert.equal(fs.existsSync(path.join(root, 'docs/browser-whisper-troubleshooting.md')), false);
assert.equal(fs.existsSync(path.join(root, 'scripts/download-whisper-models.sh')), false);
const publicRuntimeFiles = [
'public/index.html',
'public/js/app.js',
'public/js/transcriptionSettings.js',
'public/components/settings.html',
'public/components/faq.html'
];
publicRuntimeFiles.forEach((file) => {
assert.doesNotMatch(read(file), /BrowserWhisper|browser-whisper|Browser Whisper|Xenova\/whisper|transformers\.min\.js/, file);
});
const dockerfile = read('Dockerfile');
assert.doesNotMatch(dockerfile, /Xenova\/whisper|transformers\.min\.js|Browser Whisper/);
const server = read('server.js');
assert.doesNotMatch(server, /wasm-unsafe-eval|unsafe-eval|huggingface\.co|cdn-lfs|transformers/);
});
test('browser speech recognition is gated by explicit user setting', () => {
const app = read('public/js/app.js');
const speechFactory = app.match(/function createSpeechRecognition\(\) \{[\s\S]*?\n\}/)[0];
assert.match(speechFactory, /window\.WebSpeechRecognition && !window\.WebSpeechRecognition\.isEnabled\(\)/);
});
test('audio backup settings render without dynamic HTML templates', () => {
const audioBackup = read('public/js/audioBackup.js');
const renderer = audioBackup.match(/window\.renderAudioBackups = function\(\) \{[\s\S]*?\n \};/)[0];
assert.doesNotMatch(renderer, /innerHTML/);
assert.match(renderer, /document\.createElement\('button'\)/);
assert.match(renderer, /textContent =/);
});
// The Settings picker offered six hardcoded ids. On this gateway none of them
// resolve, and /api/transcribe prefers the user's choice over the admin
// default — so choosing one broke every recording with "Invalid model name".
test('the STT picker offers what the gateway has, not a hardcoded list', () => {
const stt = read('src/utils/sttProvider.js');
const prefs = read('src/routes/userPreferences.js');
assert.match(stt, /async function discoverSTTModels\(options\)/);
assert.match(stt, /getLiteLLMSTTModels\(resp\.data && resp\.data\.data\)/, 'filtered by audio_transcription mode');
assert.match(stt, /STT_DISCOVERY_TTL_MS = 5 \* 60 \* 1000;/, 'cached, so a user-facing page does not hit the gateway every load');
assert.match(stt, /module\.exports = \{[\s\S]{0,80}discoverSTTModels,/);
assert.match(prefs, /var sttIds = await discoverSTTModels\(\);/);
assert.match(prefs, /if \(!sttIds\.length\) sttIds = getSTTModelLists\(\)\.litellm\.slice\(\);/,
'the built-in list survives only as a fallback');
assert.match(prefs, /model === adminSttModel \? ' \(default\)' : ''/, 'the admin default is marked');
});
// A recording is significant clinical material: it must be possible to take a
// copy out of the app, and a recording that has silently stopped must say so.
test('recordings can be exported, and a dead recorder is reported', () => {
const backup = read('public/js/audioBackup.js');
assert.match(backup, /window\.downloadAudioBackup = function\(id, stamp\)/);
assert.match(backup, /'\/api\/audio-backups\/' \+ id\.replace\('server_', ''\) \+ '\/audio'/, 'server-side copies');
assert.match(backup, /objectStore\(STORE_NAME\)\.get\(localId\)/, 'and local ones');
// A local record belongs to one account; another must not be able to pull it.
assert.match(backup, /!boundary\.valid\(owner\) \|\| record\.owner !== owner/);
assert.match(backup, /audio-backup-download/, 'the list offers it');
assert.match(backup, /indexOf\('mp4'\) !== -1 \? 'm4a'/, 'the extension matches what was recorded');
const app = read('public/js/app.js');
assert.match(app, /self\.mediaRecorder\.onerror = function\(event\)/);
assert.match(app, /track\.addEventListener\('ended'/, 'the microphone being taken away is a failure too');
assert.match(app, /AudioRecorder\.prototype\.notifyFailure/);
assert.match(app, /if \(this\.notified\) return;/, 'reported once, not per chunk');
assert.match(app, /audio-recorder-failed/, 'callers can react');
});
test('a running recording holds the screen awake and survives a glance away', () => {
const app = read('public/js/app.js');
// The screen sleeping suspends the recording, and the browser drops a wake
// lock whenever the page is hidden — so it has to be taken again on return,
// or one glance away ends it for the rest of the session.
assert.match(app, /navigator\.wakeLock\.request\('screen'\)/);
assert.match(app, /document\.addEventListener\('visibilitychange', function\(\) \{\s*\n\s*if \(document\.visibilityState === 'visible'\) _acquireWakeLock\(\);/);
assert.match(app, /if \(document\.visibilityState !== 'visible'\) return Promise\.resolve\(null\);/,
'requesting while hidden would just be rejected');
// Counted, so two recorders do not release each other's lock.
assert.match(app, /_wakeLockHolders = Math\.max\(0, _wakeLockHolders - 1\);/);
assert.match(app, /if \(_wakeLockHolders > 0 \|\| !_wakeLock\) return;/);
// Signing out must not leave the screen pinned awake.
assert.match(app, /window\.addEventListener\('account-boundary', function\(\) \{\s*\n\s*_wakeLockHolders = 0;/);
// Denied or unsupported must not stop the recording.
assert.match(app, /\.catch\(function\(\) \{ return null; \}\);/);
});
test('starting an already-running recorder does not throw away what it has', () => {
const app = read('public/js/app.js');
assert.match(app, /if \(self\.mediaRecorder && self\.mediaRecorder\.state === 'recording'\) return Promise\.resolve\(\);/);
assert.match(app, /if \(self\.heldWakeLock\) \{ self\.heldWakeLock = false; releaseWakeLock\(\); \}/, 'and stopping releases the lock');
});
test('a recording that ends by itself is still transcribed, and logging out stops it', () => {
const live = read('public/js/liveEncounter.js');
// Same path as pressing Stop, so the audio is transcribed and stored rather
// than left in a tab that still claims to be recording.
assert.match(live, /document\.addEventListener\('audio-recorder-failed', function\(e\) \{[\s\S]{0,320}recordBtn\.click\(\);/);
// The event is on document, so it reaches every listener. Without checking
// which recorder failed, the assistant's microphone dying ended the
// consultation being recorded in this tab.
assert.match(live, /if \(e && e\.detail && e\.detail\.recorder && e\.detail\.recorder !== recorder\) return;/,
'only this tab\'s own recorder may stop this tab\'s recording');
assert.match(read('public/js/app.js'), /detail: \{ message: message, recorder: this \}/,
'so the recorder has to travel with the event');
assert.match(live, /window\.addEventListener\('account-boundary', function\(\) \{[\s\S]{0,200}recorder\.stop\(\)/,
'signing out mid-recording stops it');
});
test('every recording is kept for 24 hours, not only the failures', () => {
const transcribe = read('src/routes/transcribe.js');
const store = read('src/utils/audioBackupStore.js');
const db = read('src/db/database.js');
// The audio is already on the server for transcription, so keeping it costs
// no second upload.
assert.match(transcribe, /require\('\.\.\/utils\/audioBackupStore'\)\.save\(req\.user\.id, req\.body\.module \|\| 'recording', req\.file\.buffer, mimeType\)/);
assert.match(transcribe, /console\.warn\('\[Transcribe\] backup failed \(transcription continues\)/,
'a storage failure must not lose the transcription someone is waiting for');
// Object storage when configured, the encrypted database column otherwise.
// Which one, and with what credentials, is resolved centrally now.
assert.match(store, /_client = objectStorage\.storeFor\('audio-backups', env\);/);
assert.match(store, /objectStorage\.isConfigured\('audio-backups', env\)/);
assert.match(store, /cryptoUtil\.encryptBuffer\(compressed\)/, 'compressed and encrypted either way');
assert.match(store, /'recordings\/' \+ userId \+ '\/'/, 'keys are scoped to their owner');
assert.match(store, /WHERE id = \$1 AND user_id = \$2 AND expires_at > NOW\(\)/, 'ownership and expiry are in the query');
assert.match(store, /cryptoUtil\.isEncryptedBuffer\(stored\) \? cryptoUtil\.decryptBuffer\(stored\) : stored/,
'rows written before encryption still read back');
// An expired row must take its object with it.
assert.match(db, /DELETE FROM audio_backups WHERE expires_at < NOW\(\) RETURNING storage_key/);
assert.match(db, /await store\.removeObject\(audio\.rows\[i\]\.storage_key\)/);
assert.match(db, /ALTER TABLE audio_backups ADD COLUMN IF NOT EXISTS storage_key TEXT;/,
'existing installations get the column too');
});
test('signing out mid-recording warns, and keeps the audio with its encounter', () => {
const app = read('public/js/app.js');
const auth = read('public/js/auth.js');
// Every running recorder is registered, so anything about to end the session
// can find one instead of discarding minutes of a consultation.
assert.match(app, /var _activeRecorders = new Set\(\);/);
assert.match(app, /_activeRecorders\.add\(self\);/);
assert.match(app, /_activeRecorders\.delete\(self\);/);
assert.match(app, /window\.rescueActiveRecordings = function\(\)/);
// Tagged with the module that produced it, which is what makes it findable
// afterwards: 'encounter', 'soap', 'dictation'.
assert.match(app, /var module = recorder\._module \|\| 'recording';/);
assert.match(app, /return saveAudioBackup\(blob, module\);/);
// The caller is on its way out of the app, so this must never reject.
assert.match(app, /\.catch\(function\(\) \{ return \{ module: module, id: null \}; \}\);/);
assert.match(auth, /activeRecordingCount\(\) > 0/);
assert.match(auth, /showConfirm\('A recording is still running\./);
assert.match(auth, /rescueActiveRecordings\(\)\.then/);
assert.match(auth, /\.finally\(function\(\) \{\s*\n\s*exitApp\(\);/, 'sign-out completes whether or not the save worked');
});
test('switching to the Assistant does not reload the page out from under a recording', () => {
const app = read('public/js/app.js');
const handler = app.slice(app.indexOf('if (!onAssistant) {'), app.indexOf('// Inside the assistant, Workspace opens the launcher'));
// window.location reloads the document, which ends any running recording.
assert.match(handler, /if \(typeof window\.activateTab === 'function'\) window\.activateTab\('assistant'\);/);
assert.match(handler, /else window\.location\.href = '\/assistant';/, 'the reload stays as a fallback');
// activateTab already rewrites the URL, so the address still reads /assistant.
assert.match(app, /var target = tabName === 'assistant' \? '\/assistant' : '\/';/);
});
test('one codec chain, so Safari can record at all', () => {
const app = read('public/js/app.js');
// Safari supports none of the webm types and throws NotSupportedError when
// handed one. Every module used to build its own recorder on resume with
// "opus, else audio/webm" — which meant resuming a recording threw there.
assert.match(app, /var candidates = \['audio\/webm;codecs=opus', 'audio\/webm', 'audio\/mp4'\];/);
assert.match(app, /AudioRecorder\.prototype\.resumeCapture = function\(\)/);
assert.match(app, /self\.mediaRecorder = new MediaRecorder\(self\.stream, AudioRecorder\.encoding\(\)\);/,
'the rebuild on resume uses the same chain');
for (const file of ['liveEncounter', 'soap', 'voiceDictation', 'ed-encounters', 'sickVisit', 'shadess']) {
const source = read('public/js/' + file + '.js');
assert.doesNotMatch(source, /new MediaRecorder\(/,
file + ' must not build a recorder of its own');
assert.match(source, /resumeCapture\(\)/, file + ' resumes through the shared recorder');
}
});
test('a transcription that finds no words leaves the live transcript alone', () => {
// The server answers {success:true, text:''} for silence. Assigning that
// emptied the box the browser had been filling all along, which reads as a
// recording that vanished.
const cases = [
['liveEncounter', /if \(data\.success && data\.text\) \{ transcript\.textContent = data\.text;/],
['voiceDictation', /if \(data\.success && data\.text\) \{ transcript\.textContent = data\.text;/],
['soap', /if \(data\.success && data\.text\) transcript\.textContent = data\.text;/],
['sickVisit', /if \(data\.success && data\.text\) \{ if \(transcriptEl\)/],
['shadess', /if \(data\.success && data\.text\) \{ if \(transcriptEl\)/],
['ed-encounters', /if \(data && data\.success && data\.text\) \{/]
];
for (const [file, pattern] of cases) {
const source = read('public/js/' + file + '.js');
assert.match(source, pattern, file + ' must require text before overwriting');
assert.match(source, /No speech detected in the recording/, file + ' says so instead of blanking');
}
// And a recording that captured nothing at all has to say that much, rather
// than resetting the button and leaving an empty box.
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');
}
});