Dictate
@@ -33,7 +53,7 @@
-
+
Generate SOAP Note
diff --git a/public/index.html b/public/index.html
index 9f9f384..15ffaae 100644
--- a/public/index.html
+++ b/public/index.html
@@ -300,6 +300,8 @@
+
+
@@ -314,6 +316,7 @@
+
diff --git a/public/js/app.js b/public/js/app.js
index e775d0a..1ac2bae 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -155,6 +155,8 @@ document.addEventListener('DOMContentLoaded', function() {
if (typeof loadNextcloudStatus === 'function') loadNextcloudStatus();
if (typeof loadMemories === 'function') loadMemories();
if (typeof loadSavedEncountersList === 'function') loadSavedEncountersList();
+ if (typeof renderAudioBackups === 'function') renderAudioBackups();
+ if (typeof loadDocuments === 'function') loadDocuments();
}
});
@@ -449,7 +451,7 @@ AudioRecorder.prototype.start = function() {
.then(function(stream) {
self.stream = stream;
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
- self.mediaRecorder = new MediaRecorder(stream, { mimeType: mime });
+ self.mediaRecorder = new MediaRecorder(stream, { mimeType: mime, audioBitsPerSecond: 16000 });
self.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) self.chunks.push(e.data); };
self.mediaRecorder.start(1000);
});
@@ -461,6 +463,11 @@ AudioRecorder.prototype.stop = function() {
self.mediaRecorder.onstop = function() {
var blob = new Blob(self.chunks, { type: self.mediaRecorder.mimeType });
if (self.stream) self.stream.getTracks().forEach(function(t) { t.stop(); });
+ // Auto-save to IndexedDB backup before transcription
+ if (blob.size > 0 && typeof saveAudioBackup === 'function') {
+ var module = self._module || 'unknown';
+ saveAudioBackup(blob, module).catch(function() {});
+ }
resolve(blob);
};
self.mediaRecorder.stop();
@@ -474,7 +481,17 @@ function transcribeAudio(blob) {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + (window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '') },
body: formData
- }).then(function(r) { return r.json(); });
+ }).then(function(r) { return r.json(); }).then(function(data) {
+ if (data.success && data.provider) {
+ showToast('Transcribed via ' + data.provider, 'info');
+ }
+ // Delete audio backup on successful transcription
+ if (data.success && window._lastAudioBackupId) {
+ if (typeof deleteAudioBackup === 'function') deleteAudioBackup(window._lastAudioBackupId);
+ window._lastAudioBackupId = null;
+ }
+ return data;
+ });
}
function refineDocument(outputElementId, inputElementId) {
diff --git a/public/js/audioBackup.js b/public/js/audioBackup.js
new file mode 100644
index 0000000..e5c8e3f
--- /dev/null
+++ b/public/js/audioBackup.js
@@ -0,0 +1,184 @@
+// ============================================================
+// AUDIO BACKUP — IndexedDB-based audio recording backup
+// Saves audio blobs locally so failed transcriptions can be retried
+// ============================================================
+
+(function() {
+ var DB_NAME = 'PedScribeAudioBackup';
+ var STORE_NAME = 'recordings';
+ var DB_VERSION = 1;
+ var MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
+
+ var _db = null;
+
+ function openDB() {
+ if (_db) return Promise.resolve(_db);
+ return new Promise(function(resolve, reject) {
+ var request = indexedDB.open(DB_NAME, DB_VERSION);
+ request.onupgradeneeded = function(e) {
+ var db = e.target.result;
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
+ var store = db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
+ store.createIndex('timestamp', 'timestamp', { unique: false });
+ }
+ };
+ request.onsuccess = function(e) { _db = e.target.result; resolve(_db); };
+ request.onerror = function() { reject(new Error('IndexedDB open failed')); };
+ });
+ }
+
+ // Save audio blob with metadata
+ window.saveAudioBackup = function(blob, module) {
+ return openDB().then(function(db) {
+ return new Promise(function(resolve, reject) {
+ var tx = db.transaction(STORE_NAME, 'readwrite');
+ var store = tx.objectStore(STORE_NAME);
+ var record = {
+ blob: blob,
+ module: module || 'unknown',
+ timestamp: Date.now(),
+ size: blob.size,
+ mimeType: blob.type
+ };
+ var req = store.add(record);
+ req.onsuccess = function() { resolve(req.result); }; // returns ID
+ req.onerror = function() { reject(new Error('Failed to save audio backup')); };
+ });
+ }).then(function(id) {
+ window._lastAudioBackupId = id;
+ cleanupOldBackups();
+ return id;
+ }).catch(function(err) {
+ console.warn('[AudioBackup] Save failed:', err.message);
+ return null;
+ });
+ };
+
+ // Delete a specific backup
+ window.deleteAudioBackup = function(id) {
+ return openDB().then(function(db) {
+ return new Promise(function(resolve) {
+ var tx = db.transaction(STORE_NAME, 'readwrite');
+ tx.objectStore(STORE_NAME).delete(id);
+ tx.oncomplete = function() { resolve(); };
+ tx.onerror = function() { resolve(); };
+ });
+ }).catch(function() {});
+ };
+
+ // Get all backups (for UI listing)
+ window.getAudioBackups = function() {
+ return openDB().then(function(db) {
+ return new Promise(function(resolve) {
+ var tx = db.transaction(STORE_NAME, 'readonly');
+ var store = tx.objectStore(STORE_NAME);
+ var req = store.getAll();
+ req.onsuccess = function() {
+ var records = (req.result || []).filter(function(r) {
+ return (Date.now() - r.timestamp) < MAX_AGE_MS;
+ });
+ resolve(records);
+ };
+ req.onerror = function() { resolve([]); };
+ });
+ }).catch(function() { return []; });
+ };
+
+ // Retry transcription from backup
+ window.retryAudioBackup = function(id) {
+ return openDB().then(function(db) {
+ return new Promise(function(resolve, reject) {
+ var tx = db.transaction(STORE_NAME, 'readonly');
+ var req = tx.objectStore(STORE_NAME).get(id);
+ req.onsuccess = function() {
+ if (!req.result) { reject(new Error('Backup not found')); return; }
+ resolve(req.result);
+ };
+ req.onerror = function() { reject(new Error('Failed to read backup')); };
+ });
+ }).then(function(record) {
+ showLoading('Re-transcribing audio backup...');
+ window._lastAudioBackupId = id;
+ return transcribeAudio(record.blob).then(function(data) {
+ hideLoading();
+ if (data.success) {
+ showToast('Backup transcribed successfully!', 'success');
+ // Copy to clipboard
+ 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;
+ });
+ });
+ };
+
+ // Clean up old backups
+ function cleanupOldBackups() {
+ openDB().then(function(db) {
+ var tx = db.transaction(STORE_NAME, 'readwrite');
+ var store = tx.objectStore(STORE_NAME);
+ var index = store.index('timestamp');
+ var cutoff = Date.now() - MAX_AGE_MS;
+ var range = IDBKeyRange.upperBound(cutoff);
+ var req = index.openCursor(range);
+ req.onsuccess = function(e) {
+ var cursor = e.target.result;
+ if (cursor) {
+ cursor.delete();
+ cursor.continue();
+ }
+ };
+ }).catch(function() {});
+ }
+
+ // Render audio backups list in settings
+ window.renderAudioBackups = function() {
+ var container = document.getElementById('audio-backups-list');
+ if (!container) return;
+ getAudioBackups().then(function(backups) {
+ if (backups.length === 0) {
+ container.innerHTML = '
No audio backups. Recordings are saved here automatically and kept for 24 hours.
';
+ return;
+ }
+ container.innerHTML = backups.map(function(b) {
+ var date = new Date(b.timestamp);
+ var sizeKb = Math.round(b.size / 1024);
+ var age = Math.round((Date.now() - b.timestamp) / 60000);
+ var ageStr = age < 60 ? age + 'm ago' : Math.round(age / 60) + 'h ago';
+ return '
' +
+ '
' +
+ '
' + esc(b.module) + ' recording
' +
+ '
' + date.toLocaleString() + ' · ' + sizeKb + ' KB · ' + ageStr + '
' +
+ '
' +
+ '
Retry' +
+ '
' +
+ '
';
+ }).join('');
+ container.querySelectorAll('.audio-backup-retry').forEach(function(btn) {
+ btn.addEventListener('click', function() { retryAudioBackup(parseInt(btn.dataset.id)); });
+ });
+ container.querySelectorAll('.audio-backup-delete').forEach(function(btn) {
+ btn.addEventListener('click', function() {
+ deleteAudioBackup(parseInt(btn.dataset.id)).then(function() {
+ showToast('Backup deleted', 'info');
+ renderAudioBackups();
+ });
+ });
+ });
+ });
+ };
+
+ function esc(str) {
+ if (!str) return '';
+ return String(str).replace(/&/g, '&').replace(//g, '>');
+ }
+
+ // Clean up on load
+ cleanupOldBackups();
+
+ console.log('Audio backup system loaded');
+})();
diff --git a/public/js/correctionTracker.js b/public/js/correctionTracker.js
new file mode 100644
index 0000000..fa687f0
--- /dev/null
+++ b/public/js/correctionTracker.js
@@ -0,0 +1,86 @@
+// ============================================================
+// CORRECTION TRACKER — Dragon-like AI learning from user edits
+// Tracks when users edit AI-generated output and saves corrections
+// so the AI can learn user preferences over time.
+// ============================================================
+
+(function() {
+
+ // Store original AI outputs per element
+ var _originals = {};
+
+ // Track an output element: store original text when AI generates it
+ window.trackAIOutput = function(elementId, originalText) {
+ if (!elementId || !originalText) return;
+ _originals[elementId] = originalText.trim();
+ };
+
+ // Save correction when user is done editing (call on save or blur)
+ window.saveCorrection = function(elementId, section) {
+ var original = _originals[elementId];
+ if (!original) return;
+ var el = document.getElementById(elementId);
+ if (!el) return;
+ var current = (el.innerText || el.textContent || '').trim();
+ if (!current || current === original) return;
+
+ // Only save if there's a meaningful difference (not just whitespace)
+ var origWords = original.split(/\s+/).length;
+ var currWords = current.split(/\s+/).length;
+ var wordDiff = Math.abs(origWords - currWords);
+ // Require at least some meaningful change
+ if (wordDiff < 2 && original.length > 100) {
+ // Check character-level difference
+ var charDiff = Math.abs(original.length - current.length);
+ if (charDiff < 20) return; // too minor
+ }
+
+ // Find the most significant changed section (not full text)
+ var origSnippet = extractDiffSnippet(original, current);
+ var corrSnippet = extractDiffSnippet(current, original);
+
+ if (!origSnippet || !corrSnippet) {
+ origSnippet = original.substring(0, 500);
+ corrSnippet = current.substring(0, 500);
+ }
+
+ fetch('/api/memories/correction', {
+ method: 'POST',
+ headers: getAuthHeaders(),
+ body: JSON.stringify({
+ section: section || 'encounter',
+ original_snippet: origSnippet,
+ corrected_snippet: corrSnippet
+ })
+ }).then(function(r) { return r.json(); })
+ .then(function(data) {
+ if (data.success && !data.skipped) {
+ console.log('[CorrectionTracker] Saved correction for', section);
+ }
+ }).catch(function() {});
+
+ // Clear so we don't re-save
+ delete _originals[elementId];
+ };
+
+ // Extract the most changed portion between two texts
+ function extractDiffSnippet(text1, text2) {
+ var lines1 = text1.split('\n');
+ var lines2 = text2.split('\n');
+ var changed = [];
+ var maxLen = Math.max(lines1.length, lines2.length);
+
+ for (var i = 0; i < maxLen; i++) {
+ var l1 = (lines1[i] || '').trim();
+ var l2 = (lines2[i] || '').trim();
+ if (l1 !== l2 && l1) {
+ changed.push(l1);
+ }
+ }
+
+ if (changed.length === 0) return null;
+ return changed.slice(0, 10).join('\n').substring(0, 1000);
+ }
+
+ console.log('Correction tracker loaded');
+})();
diff --git a/public/js/documents.js b/public/js/documents.js
new file mode 100644
index 0000000..90ccfb7
--- /dev/null
+++ b/public/js/documents.js
@@ -0,0 +1,123 @@
+// ============================================================
+// DOCUMENTS.JS — S3 document upload & management UI
+// ============================================================
+
+(function() {
+
+ function loadDocuments() {
+ var container = document.getElementById('documents-list');
+ if (!container) return;
+ fetch('/api/documents', { headers: getAuthHeaders() })
+ .then(function(r) { return r.json(); })
+ .then(function(data) {
+ if (!data.s3_configured) {
+ container.innerHTML = '
S3 storage not configured. Set S3_BUCKET in server environment.
';
+ var uploadArea = document.getElementById('doc-upload-area');
+ if (uploadArea) uploadArea.style.display = 'none';
+ return;
+ }
+ var docs = data.documents || [];
+ if (docs.length === 0) {
+ container.innerHTML = '
No documents uploaded yet.
';
+ return;
+ }
+ container.innerHTML = docs.map(function(doc) {
+ var sizeStr = doc.size_bytes < 1024 ? doc.size_bytes + ' B' :
+ doc.size_bytes < 1048576 ? Math.round(doc.size_bytes / 1024) + ' KB' :
+ (doc.size_bytes / 1048576).toFixed(1) + ' MB';
+ var date = doc.created_at ? new Date(doc.created_at).toLocaleDateString() : '';
+ return '
' +
+ '
' +
+ '
' +
+ ' ' + esc(doc.filename) +
+ '
' +
+ '
' + sizeStr + ' · ' + date +
+ (doc.description ? ' · ' + esc(doc.description) : '') +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
';
+ }).join('');
+
+ container.querySelectorAll('.doc-download-btn').forEach(function(btn) {
+ btn.addEventListener('click', function() { downloadDocument(btn.dataset.id); });
+ });
+ container.querySelectorAll('.doc-delete-btn').forEach(function(btn) {
+ btn.addEventListener('click', function() { deleteDocument(btn.dataset.id); });
+ });
+ })
+ .catch(function() {
+ container.innerHTML = '
Failed to load documents.
';
+ });
+ }
+
+ function uploadDocument() {
+ var fileInput = document.getElementById('doc-file-input');
+ var descInput = document.getElementById('doc-description');
+ if (!fileInput || !fileInput.files[0]) { showToast('Select a file first', 'error'); return; }
+
+ var formData = new FormData();
+ formData.append('file', fileInput.files[0]);
+ formData.append('description', descInput ? descInput.value : '');
+
+ showLoading('Uploading document...');
+ fetch('/api/documents/upload', {
+ method: 'POST',
+ headers: { 'Authorization': 'Bearer ' + (window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '') },
+ body: formData
+ })
+ .then(function(r) { return r.json(); })
+ .then(function(data) {
+ hideLoading();
+ if (data.success) {
+ showToast('Document uploaded: ' + data.filename, 'success');
+ fileInput.value = '';
+ if (descInput) descInput.value = '';
+ loadDocuments();
+ } else {
+ showToast(data.error || 'Upload failed', 'error');
+ }
+ })
+ .catch(function(err) { hideLoading(); showToast('Upload failed: ' + err.message, 'error'); });
+ }
+
+ function downloadDocument(id) {
+ fetch('/api/documents/' + id + '/download', { headers: getAuthHeaders() })
+ .then(function(r) { return r.json(); })
+ .then(function(data) {
+ if (data.success && data.url) {
+ window.open(data.url, '_blank');
+ } else {
+ showToast(data.error || 'Download failed', 'error');
+ }
+ })
+ .catch(function() { showToast('Download failed', 'error'); });
+ }
+
+ function deleteDocument(id) {
+ if (!confirm('Delete this document permanently?')) return;
+ fetch('/api/documents/' + id, { method: 'DELETE', headers: getAuthHeaders() })
+ .then(function(r) { return r.json(); })
+ .then(function(data) {
+ if (data.success) { showToast('Document deleted', 'info'); loadDocuments(); }
+ else showToast(data.error || 'Delete failed', 'error');
+ })
+ .catch(function() { showToast('Delete failed', 'error'); });
+ }
+
+ function esc(str) {
+ if (!str) return '';
+ return String(str).replace(/&/g, '&').replace(//g, '>');
+ }
+
+ // Expose for settings tab load
+ window.loadDocuments = loadDocuments;
+
+ // Wire upload button
+ document.addEventListener('click', function(e) {
+ if (e.target.closest('#btn-doc-upload')) uploadDocument();
+ });
+
+ console.log('Documents module loaded');
+})();
diff --git a/public/js/encounters.js b/public/js/encounters.js
index 34660dd..30d48d7 100644
--- a/public/js/encounters.js
+++ b/public/js/encounters.js
@@ -64,9 +64,38 @@
var _loadCallback = null; // set by each module to handle loading a saved encounter
var _savingInProgress = {}; // prevent duplicate saves on double-click
+ // Generate a UUID v4 for idempotency keys
+ function generateUUID() {
+ if (crypto && crypto.randomUUID) return crypto.randomUUID();
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
+ var r = Math.random() * 16 | 0;
+ return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
+ });
+ }
+
+ // Get or create idempotency key for a tab type
+ function getIdempotencyKey(type) {
+ var key = '_idempKey_' + type;
+ if (!window[key]) {
+ try { window[key] = sessionStorage.getItem(key); } catch(e) {}
+ if (!window[key]) {
+ window[key] = generateUUID();
+ try { sessionStorage.setItem(key, window[key]); } catch(e) {}
+ }
+ }
+ return window[key];
+ }
+
+ // Reset idempotency key (on New Patient)
+ function resetIdempotencyKey(type) {
+ var key = '_idempKey_' + type;
+ window[key] = null;
+ try { sessionStorage.removeItem(key); } catch(e) {}
+ }
+
// Restore saved encounter IDs from sessionStorage (survive page refresh, cleared on tab close)
(function() {
- ['encounter','dictation','hospital','chart','wellvisit','sickvisit'].forEach(function(t) {
+ ['encounter','dictation','hospital','chart','wellvisit','sickvisit','soap'].forEach(function(t) {
try {
var id = sessionStorage.getItem('_savedEncId_' + t);
if (id) window['_savedEncId_' + t] = id;
@@ -119,7 +148,7 @@
_savedEncounters = data.encounters || [];
renderSavedList();
// Refresh any open popovers
- ['enc', 'dict', 'wv', 'sick', 'hosp', 'chart'].forEach(function(pfx) {
+ ['enc', 'dict', 'wv', 'sick', 'hosp', 'chart', 'soap'].forEach(function(pfx) {
var pop = document.getElementById(pfx + '-load-popover');
if (pop && !pop.classList.contains('hidden')) {
var search = pop.querySelector('.enc-load-search');
@@ -209,7 +238,7 @@
if (!popover) return;
var isHidden = popover.classList.contains('hidden');
// Close all popovers first
- ['enc', 'dict', 'wv', 'sick', 'hosp', 'chart'].forEach(function(p) {
+ ['enc', 'dict', 'wv', 'sick', 'hosp', 'chart', 'soap'].forEach(function(p) {
var pop = document.getElementById(p + '-load-popover');
if (pop) pop.classList.add('hidden');
});
@@ -247,7 +276,7 @@
if (!data.success) { showToast(data.error || 'Failed', 'error'); return; }
var enc = data.encounter;
// Close all popovers
- ['enc', 'dict', 'wv', 'sick', 'hosp', 'chart'].forEach(function(p) {
+ ['enc', 'dict', 'wv', 'sick', 'hosp', 'chart', 'soap'].forEach(function(p) {
var pop = document.getElementById(p + '-load-popover');
if (pop) pop.classList.add('hidden');
});
@@ -272,7 +301,8 @@
var noteIdMap = {
encounter: 'enc-hpi-text', dictation: 'dict-hpi-text',
hospital: 'hc-course-text', chart: 'cr-review-text',
- wellvisit: 'wv-note-text', sickvisit: 'sick-note-text'
+ wellvisit: 'wv-note-text', sickvisit: 'sick-note-text',
+ soap: 'soap-text'
};
// Fill in data
@@ -349,6 +379,7 @@
if (e.target.closest('#btn-hosp-save')) saveFromTab('hospital', 'hosp');
if (e.target.closest('#btn-chart-save')) saveFromTab('chart', 'chart');
if (e.target.closest('#btn-wv-save')) saveFromTab('wellvisit', 'wv');
+ if (e.target.closest('#btn-soap-save')) saveFromTab('soap', 'soap');
// Load buttons — open inline popovers
if (e.target.closest('#btn-enc-load')) openLoadPopover('enc');
if (e.target.closest('#btn-dict-load')) openLoadPopover('dict');
@@ -356,6 +387,7 @@
if (e.target.closest('#btn-sick-load')) openLoadPopover('sick');
if (e.target.closest('#btn-hosp-load')) openLoadPopover('hosp');
if (e.target.closest('#btn-chart-load')) openLoadPopover('chart');
+ if (e.target.closest('#btn-soap-load')) openLoadPopover('soap');
// New Patient / clear tab buttons
if (e.target.closest('#btn-enc-new')) clearTab('encounter');
if (e.target.closest('#btn-dict-new')) clearTab('dictation');
@@ -363,11 +395,12 @@
if (e.target.closest('#btn-chart-new')) clearTab('chart');
if (e.target.closest('#btn-wv-new')) clearTab('wellvisit');
if (e.target.closest('#btn-sick-new')) clearTab('sickvisit');
+ if (e.target.closest('#btn-soap-new')) clearTab('soap');
// Close popovers when clicking outside
- var loadBtnIds = ['#btn-enc-load','#btn-dict-load','#btn-wv-load','#btn-sick-load','#btn-hosp-load','#btn-chart-load'];
+ var loadBtnIds = ['#btn-enc-load','#btn-dict-load','#btn-wv-load','#btn-sick-load','#btn-hosp-load','#btn-chart-load','#btn-soap-load'];
var clickedLoadBtn = loadBtnIds.some(function(id) { return e.target.closest(id); });
if (!clickedLoadBtn) {
- ['enc', 'dict', 'wv', 'sick', 'hosp', 'chart'].forEach(function(p) {
+ ['enc', 'dict', 'wv', 'sick', 'hosp', 'chart', 'soap'].forEach(function(p) {
var pop = document.getElementById(p + '-load-popover');
if (pop) pop.classList.add('hidden');
});
@@ -386,33 +419,42 @@
var noteIdMap = {
encounter: 'enc-hpi-text', dictation: 'dict-hpi-text',
hospital: 'hc-course-text', chart: 'cr-review-text',
- wellvisit: 'wv-note-text'
+ wellvisit: 'wv-note-text', soap: 'soap-text'
};
var noteEl = document.getElementById(noteIdMap[type] || (prefix + '-hpi-text'));
+ // Save any corrections (Dragon-like learning) before saving encounter
+ var noteElId = noteIdMap[type] || (prefix + '-hpi-text');
+ if (typeof saveCorrection === 'function') {
+ saveCorrection(noteElId, type);
+ }
+
window.saveEncounter({
id: savedId,
label: label,
enc_type: type,
transcript: transcriptEl ? (transcriptEl.innerText || transcriptEl.textContent || '') : '',
generated_note: noteEl ? (noteEl.innerText || noteEl.textContent || '') : '',
+ idempotency_key: getIdempotencyKey(type),
onSaved: function(newId) { window['_savedEncId_' + type] = newId; }
});
}
// ── New Patient / Clear tab ────────────────────────────────────────────────
function clearTab(type) {
- var pfxMap = { encounter:'enc', dictation:'dict', hospital:'hosp', chart:'chart', wellvisit:'wv', sickvisit:'sick' };
+ var pfxMap = { encounter:'enc', dictation:'dict', hospital:'hosp', chart:'chart', wellvisit:'wv', sickvisit:'sick', soap:'soap' };
var pfx = pfxMap[type] || type;
var noteElMap = {
encounter:'enc-hpi-text', dictation:'dict-hpi-text',
hospital:'hc-course-text', chart:'cr-review-text',
- wellvisit:'wv-note-text', sickvisit:'sick-note-text'
+ wellvisit:'wv-note-text', sickvisit:'sick-note-text',
+ soap:'soap-text'
};
var outputElMap = {
encounter:'enc-output', dictation:'dict-output',
hospital:'hc-output', chart:'cr-output',
- wellvisit:'wv-note-output', sickvisit:'sick-note-output'
+ wellvisit:'wv-note-output', sickvisit:'sick-note-output',
+ soap:'soap-output'
};
// Clear label
var lbl = document.getElementById(pfx + '-label');
@@ -426,9 +468,10 @@
// Hide output card
var out = document.getElementById(outputElMap[type]);
if (out) out.classList.add('hidden');
- // Reset saved ID (memory + sessionStorage)
+ // Reset saved ID and idempotency key (memory + sessionStorage)
window['_savedEncId_' + type] = null;
try { sessionStorage.removeItem('_savedEncId_' + type); } catch(e) {}
+ resetIdempotencyKey(type);
// Chart review has additional fields/cards to clear
if (type === 'chart' && typeof window.resetChartReview === 'function') {
window.resetChartReview();
diff --git a/public/js/liveEncounter.js b/public/js/liveEncounter.js
index 20a1c4d..4be3ba7 100644
--- a/public/js/liveEncounter.js
+++ b/public/js/liveEncounter.js
@@ -15,6 +15,7 @@
var modelTag = document.getElementById('enc-model-tag');
var recorder = new AudioRecorder();
+ recorder._module = 'encounter';
var timer = createTimer(timerEl);
var isRecording = false;
var isPaused = false;
@@ -158,6 +159,7 @@
hideLoading();
if (data.success) {
setOutputText(hpiText, data.hpi);
+ if (typeof trackAIOutput === 'function') trackAIOutput('enc-hpi-text', data.hpi);
modelTag.textContent = (data.model || '').split('/').pop();
outputCard.classList.remove('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });
diff --git a/public/js/memories.js b/public/js/memories.js
index d0b1a2c..a95bdee 100644
--- a/public/js/memories.js
+++ b/public/js/memories.js
@@ -13,9 +13,22 @@
encounter_format: 'Encounter Format',
family_history: 'Family History',
assessment_plan: 'Assessment & Plan',
+ template_soap: 'SOAP Template',
+ template_hpi: 'HPI Template',
+ template_wellvisit: 'Well Visit Template',
+ template_sickvisit: 'Sick Visit Template',
custom: 'Custom'
};
+ // Correction categories (hidden from manual editing, managed by correction tracker)
+ var CORRECTION_LABELS = {
+ correction_soap: 'SOAP Correction',
+ correction_hpi: 'HPI Correction',
+ correction_encounter: 'Encounter Correction',
+ correction_wellvisit: 'Well Visit Correction',
+ correction_sickvisit: 'Sick Visit Correction'
+ };
+
// ── Load memories ────────────────────────────────────────────────────────
function loadMemories() {
@@ -32,11 +45,16 @@
function renderMemoryList() {
var container = document.getElementById('mem-list');
if (!container) return;
- if (_memories.length === 0) {
+ // Separate templates from corrections
+ var templates = _memories.filter(function(m) { return !m.category.startsWith('correction_'); });
+ var corrections = _memories.filter(function(m) { return m.category.startsWith('correction_'); });
+ // Render corrections list
+ renderCorrectionsList(corrections);
+ if (templates.length === 0) {
container.innerHTML = '
No templates saved yet. Add one above.
';
return;
}
- container.innerHTML = _memories.map(function(m) {
+ container.innerHTML = templates.map(function(m) {
var catLabel = CATEGORY_LABELS[m.category] || m.category;
var preview = (m.content || '').substring(0, 100).replace(/\n/g, ' ');
return '
' +
@@ -60,6 +78,31 @@
});
}
+ function renderCorrectionsList(corrections) {
+ var container = document.getElementById('corrections-list');
+ if (!container) return;
+ if (corrections.length === 0) {
+ container.innerHTML = '
No corrections yet. Edit AI-generated notes and save to start learning.
';
+ return;
+ }
+ container.innerHTML = corrections.map(function(m) {
+ var catLabel = CORRECTION_LABELS[m.category] || m.category;
+ var preview = (m.content || '').substring(0, 120).replace(/\n/g, ' ');
+ return '
' +
+ '
' +
+ '
' +
+ '' + esc(catLabel) + ' ' +
+ '
' +
+ '
' + esc(preview) + (m.content && m.content.length > 120 ? '...' : '') + '
' +
+ '
' +
+ '
' +
+ '
';
+ }).join('');
+ container.querySelectorAll('.correction-delete-btn').forEach(function(btn) {
+ btn.addEventListener('click', function() { deleteMemory(parseInt(btn.dataset.id)); });
+ });
+ }
+
// ── Save memory ──────────────────────────────────────────────────────────
function saveMemory() {
diff --git a/public/js/soap.js b/public/js/soap.js
index 45fa8a3..94ff92c 100644
--- a/public/js/soap.js
+++ b/public/js/soap.js
@@ -14,6 +14,7 @@
var modelTag = document.getElementById('soap-model-tag');
var recorder = new AudioRecorder();
+ recorder._module = 'soap';
var timer = createTimer(timerEl);
var isRecording = false;
var recognition = createSpeechRecognition();
@@ -76,13 +77,20 @@
return transcribeAudio(blob).then(function(data) {
hideLoading();
if (data.success) transcript.textContent = data.text;
- else if (finalText.trim()) transcript.textContent = finalText.trim();
+ else { if (finalText.trim()) transcript.textContent = finalText.trim(); showToast(data.error || 'Transcription failed — using live transcript', 'error'); }
});
- }).catch(function() { hideLoading(); if (finalText.trim()) transcript.textContent = finalText.trim(); });
+ }).catch(function(err) { hideLoading(); if (finalText.trim()) transcript.textContent = finalText.trim(); showToast('Transcription error: ' + (err.message || 'unknown'), 'error'); });
}
});
- clearBtn.addEventListener('click', function() { transcript.textContent = ''; finalText = ''; outputCard.classList.add('hidden'); });
+ clearBtn.addEventListener('click', function() {
+ transcript.textContent = ''; finalText = ''; outputCard.classList.add('hidden');
+ var instrEl = document.getElementById('soap-instructions');
+ if (instrEl) instrEl.value = '';
+ window._savedEncId_soap = null;
+ var labelEl = document.getElementById('soap-label');
+ if (labelEl) labelEl.value = '';
+ });
generateBtn.addEventListener('click', function() {
var text = transcript.innerText.trim();
@@ -112,6 +120,7 @@
hideLoading();
if (data.success) {
setOutputText(soapText, data.soap);
+ if (typeof trackAIOutput === 'function') trackAIOutput('soap-text', data.soap);
modelTag.textContent = (data.model || '').split('/').pop();
outputCard.classList.remove('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });
@@ -124,6 +133,23 @@
document.getElementById('soap-refine-btn').addEventListener('click', function() { refineDocument('soap-text', 'soap-refine-input'); });
document.getElementById('soap-shorten-btn').addEventListener('click', function() { shortenDocument('soap-text'); });
+ // Register load handler for resuming saved SOAP notes
+ if (typeof registerEncounterLoadHandler === 'function') {
+ registerEncounterLoadHandler('soap', function(enc) {
+ if (enc.transcript) transcript.textContent = enc.transcript;
+ if (enc.generated_note) {
+ setOutputText(soapText, enc.generated_note);
+ outputCard.classList.remove('hidden');
+ }
+ try {
+ var pd = JSON.parse(enc.partial_data || '{}');
+ if (pd.age) document.getElementById('soap-age').value = pd.age;
+ if (pd.gender) document.getElementById('soap-gender').value = pd.gender;
+ if (pd.type) document.getElementById('soap-type').value = pd.type;
+ } catch(e) {}
+ });
+ }
+
console.log('✅ SOAP module loaded');
});
})();
diff --git a/public/js/voiceDictation.js b/public/js/voiceDictation.js
index 1e05511..1b2ebf5 100644
--- a/public/js/voiceDictation.js
+++ b/public/js/voiceDictation.js
@@ -15,6 +15,7 @@
var modelTag = document.getElementById('dict-model-tag');
var recorder = new AudioRecorder();
+ recorder._module = 'dictation';
var timer = createTimer(timerEl);
var isRecording = false;
var isPaused = false;
@@ -84,9 +85,9 @@
return transcribeAudio(blob).then(function(data) {
hideLoading();
if (data.success) { transcript.textContent = data.text; showToast('Transcribed ' + dur + 's', 'success'); }
- else { if (finalText.trim()) transcript.textContent = finalText.trim(); }
+ else { if (finalText.trim()) transcript.textContent = finalText.trim(); showToast(data.error || 'Transcription failed — using live transcript', 'error'); }
});
- }).catch(function() { hideLoading(); if (finalText.trim()) transcript.textContent = finalText.trim(); });
+ }).catch(function(err) { hideLoading(); if (finalText.trim()) transcript.textContent = finalText.trim(); showToast('Transcription error: ' + (err.message || 'unknown'), 'error'); });
}
});
@@ -167,6 +168,7 @@
hideLoading();
if (data.success) {
setOutputText(hpiText, data.hpi || data.soap);
+ if (typeof trackAIOutput === 'function') trackAIOutput('dict-hpi-text', data.hpi || data.soap);
modelTag.textContent = (data.model || '').split('/').pop();
outputCard.classList.remove('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });
diff --git a/public/manifest.json b/public/manifest.json
index d3a8311..8dcba5c 100644
--- a/public/manifest.json
+++ b/public/manifest.json
@@ -7,6 +7,8 @@
"background_color": "#ffffff",
"theme_color": "#2563eb",
"orientation": "any",
+ "categories": ["medical", "productivity"],
+ "prefer_related_applications": false,
"icons": [
{
"src": "/icons/icon-192.png",
@@ -17,6 +19,26 @@
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
+ },
+ {
+ "src": "/icons/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ],
+ "shortcuts": [
+ {
+ "name": "New Encounter",
+ "short_name": "Encounter",
+ "url": "/?tab=encounter",
+ "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
+ },
+ {
+ "name": "SOAP Note",
+ "short_name": "SOAP",
+ "url": "/?tab=soap",
+ "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
}
]
}
diff --git a/server.js b/server.js
index c86dab6..b7fda0f 100644
--- a/server.js
+++ b/server.js
@@ -158,6 +158,7 @@ app.use('/api', require('./src/routes/refine'));
app.use('/api', require('./src/routes/logs'));
app.use('/api', require('./src/routes/encounters'));
app.use('/api', require('./src/routes/memories'));
+app.use('/api', require('./src/routes/documents'));
app.use('/api', require('./src/routes/wellVisit'));
app.use('/api', require('./src/routes/sickVisit'));
app.use('/api/admin/learning', require('./src/routes/learningAI'));
diff --git a/src/db/database.js b/src/db/database.js
index 8175343..56a7257 100644
--- a/src/db/database.js
+++ b/src/db/database.js
@@ -125,6 +125,7 @@ async function initDatabase() {
CREATE INDEX IF NOT EXISTS idx_saved_enc_user ON saved_encounters(user_id);
CREATE INDEX IF NOT EXISTS idx_saved_enc_expires ON saved_encounters(expires_at);
CREATE INDEX IF NOT EXISTS idx_memories_user ON user_memories(user_id);
+ CREATE INDEX IF NOT EXISTS idx_memories_user_cat ON user_memories(user_id, category);
-- Learning Hub tables
CREATE TABLE IF NOT EXISTS learning_categories (
@@ -213,6 +214,23 @@ async function initDatabase() {
try { await client.query("ALTER TABLE app_settings ADD COLUMN IF NOT EXISTS updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL"); } catch(e) {}
// Add webdav_learning_path to users for Learning Hub file browser
try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS webdav_learning_path TEXT DEFAULT NULL"); } catch(e) {}
+ // Add idempotency_key for duplicate prevention
+ try { await client.query("ALTER TABLE saved_encounters ADD COLUMN IF NOT EXISTS idempotency_key TEXT"); } catch(e) {}
+ try { await client.query("CREATE UNIQUE INDEX IF NOT EXISTS idx_saved_enc_idemp ON saved_encounters(user_id, idempotency_key) WHERE idempotency_key IS NOT NULL"); } catch(e) {}
+ // User documents table for S3 storage
+ try { await client.query(`
+ CREATE TABLE IF NOT EXISTS user_documents (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ s3_key TEXT NOT NULL,
+ filename TEXT NOT NULL,
+ mime_type TEXT DEFAULT 'application/octet-stream',
+ size_bytes INTEGER DEFAULT 0,
+ description TEXT DEFAULT '',
+ created_at TIMESTAMPTZ DEFAULT NOW()
+ );
+ CREATE INDEX IF NOT EXISTS idx_user_docs_user ON user_documents(user_id);
+ `); } catch(e) {}
// Seed all default config values (ON CONFLICT DO NOTHING — never overwrites admin changes)
var defaults = [
diff --git a/src/routes/documents.js b/src/routes/documents.js
new file mode 100644
index 0000000..c4feeee
--- /dev/null
+++ b/src/routes/documents.js
@@ -0,0 +1,156 @@
+// ============================================================
+// DOCUMENTS ROUTES — S3-backed document upload & management
+// ============================================================
+
+var express = require('express');
+var router = express.Router();
+var multer = require('multer');
+var crypto = require('crypto');
+var db = require('../db/database');
+var { authMiddleware } = require('../middleware/auth');
+var logger = require('../utils/logger');
+
+router.use(authMiddleware);
+
+var upload = multer({
+ storage: multer.memoryStorage(),
+ limits: { fileSize: 10 * 1024 * 1024 } // 10 MB
+});
+
+var ALLOWED_TYPES = [
+ 'application/pdf',
+ 'image/jpeg', 'image/png', 'image/gif',
+ 'application/msword',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'text/plain', 'text/csv'
+];
+
+// Lazy-load S3 client
+var _s3Client = null;
+function getS3Client() {
+ if (_s3Client) return _s3Client;
+ try {
+ var { S3Client } = require('@aws-sdk/client-s3');
+ var region = process.env.S3_REGION || process.env.AWS_BEDROCK_REGION || 'us-east-1';
+ var config = { region: region };
+
+ // Custom endpoint for S3-compatible providers (Backblaze B2, MinIO, etc.)
+ if (process.env.S3_ENDPOINT) {
+ config.endpoint = process.env.S3_ENDPOINT;
+ config.forcePathStyle = process.env.S3_FORCE_PATH_STYLE === 'true'; // Required for MinIO
+ }
+
+ // Credentials: use S3-specific keys first, fall back to AWS keys
+ var accessKey = process.env.S3_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID;
+ var secretKey = process.env.S3_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY;
+ if (accessKey && secretKey) {
+ config.credentials = {
+ accessKeyId: accessKey,
+ secretAccessKey: secretKey
+ };
+ }
+
+ _s3Client = new S3Client(config);
+ return _s3Client;
+ } catch (e) {
+ return null;
+ }
+}
+
+function isS3Configured() {
+ return !!process.env.S3_BUCKET && !!getS3Client();
+}
+
+// ── GET list user documents ────────────────────────────────────────────
+router.get('/documents', async function(req, res) {
+ try {
+ if (!isS3Configured()) return res.json({ success: true, documents: [], s3_configured: false });
+ var rows = await db.all(
+ 'SELECT id, filename, mime_type, size_bytes, description, created_at FROM user_documents WHERE user_id = $1 ORDER BY created_at DESC',
+ [req.user.id]
+ );
+ res.json({ success: true, documents: rows, s3_configured: true });
+ } catch (e) { logger.error('GET /documents', e.message); res.status(500).json({ error: e.message }); }
+});
+
+// ── POST upload document ───────────────────────────────────────────────
+router.post('/documents/upload', upload.single('file'), async function(req, res) {
+ try {
+ if (!isS3Configured()) return res.status(400).json({ error: 'S3 not configured. Set S3_BUCKET and S3_REGION in .env' });
+ if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
+ if (!ALLOWED_TYPES.includes(req.file.mimetype)) {
+ return res.status(400).json({ error: 'File type not allowed. Supported: PDF, images, Word docs, text, CSV' });
+ }
+
+ var { PutObjectCommand } = require('@aws-sdk/client-s3');
+ var prefix = process.env.S3_PREFIX || 'documents/';
+ var uuid = crypto.randomUUID();
+ var s3Key = prefix + req.user.id + '/' + uuid + '/' + req.file.originalname;
+
+ await getS3Client().send(new PutObjectCommand({
+ Bucket: process.env.S3_BUCKET,
+ Key: s3Key,
+ Body: req.file.buffer,
+ ContentType: req.file.mimetype,
+ ServerSideEncryption: 'AES256'
+ }));
+
+ var result = await db.run(
+ 'INSERT INTO user_documents (user_id, s3_key, filename, mime_type, size_bytes, description) VALUES ($1,$2,$3,$4,$5,$6)',
+ [req.user.id, s3Key, req.file.originalname, req.file.mimetype, req.file.size, req.body.description || '']
+ );
+
+ res.json({ success: true, id: result.lastInsertRowid, filename: req.file.originalname });
+ } catch (e) { logger.error('POST /documents/upload', e.message); res.status(500).json({ error: e.message }); }
+});
+
+// ── GET download document (presigned URL) ──────────────────────────────
+router.get('/documents/:id/download', async function(req, res) {
+ try {
+ if (!isS3Configured()) return res.status(400).json({ error: 'S3 not configured' });
+ var doc = await db.get(
+ 'SELECT * FROM user_documents WHERE id = $1 AND user_id = $2',
+ [req.params.id, req.user.id]
+ );
+ if (!doc) return res.status(404).json({ error: 'Document not found' });
+
+ var { GetObjectCommand } = require('@aws-sdk/client-s3');
+ var { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
+
+ var command = new GetObjectCommand({
+ Bucket: process.env.S3_BUCKET,
+ Key: doc.s3_key,
+ ResponseContentDisposition: 'attachment; filename="' + doc.filename + '"'
+ });
+ var url = await getSignedUrl(getS3Client(), command, { expiresIn: 300 }); // 5 min
+
+ res.json({ success: true, url: url });
+ } catch (e) { logger.error('GET /documents/:id/download', e.message); res.status(500).json({ error: e.message }); }
+});
+
+// ── DELETE document ────────────────────────────────────────────────────
+router.delete('/documents/:id', async function(req, res) {
+ try {
+ if (!isS3Configured()) return res.status(400).json({ error: 'S3 not configured' });
+ var doc = await db.get(
+ 'SELECT * FROM user_documents WHERE id = $1 AND user_id = $2',
+ [req.params.id, req.user.id]
+ );
+ if (!doc) return res.status(404).json({ error: 'Document not found' });
+
+ try {
+ var { DeleteObjectCommand } = require('@aws-sdk/client-s3');
+ await getS3Client().send(new DeleteObjectCommand({
+ Bucket: process.env.S3_BUCKET,
+ Key: doc.s3_key
+ }));
+ } catch (s3err) {
+ logger.warn('S3 delete failed (continuing with DB delete):', s3err.message);
+ }
+
+ await db.run('DELETE FROM user_documents WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]);
+ res.json({ success: true });
+ } catch (e) { logger.error('DELETE /documents/:id', e.message); res.status(500).json({ error: e.message }); }
+});
+
+module.exports = router;
diff --git a/src/routes/encounters.js b/src/routes/encounters.js
index dab54c3..8aaac12 100644
--- a/src/routes/encounters.js
+++ b/src/routes/encounters.js
@@ -36,7 +36,7 @@ router.get('/encounters/saved/:id', async function(req, res) {
// ── POST save/update encounter progress ─────────────────────────────────
router.post('/encounters/saved', async function(req, res) {
try {
- var { id, label, enc_type, transcript, generated_note, partial_data, status } = req.body;
+ var { id, label, enc_type, transcript, generated_note, partial_data, status, idempotency_key } = req.body;
var autoDeleteDays = parseInt(await db.getSetting('site.auto_delete_days') || '7', 10);
if (id) {
@@ -56,9 +56,31 @@ router.post('/encounters/saved', async function(req, res) {
);
res.json({ success: true, id: id });
} else {
+ // Check for duplicate via idempotency_key
+ if (idempotency_key) {
+ var dup = await db.get(
+ 'SELECT id FROM saved_encounters WHERE user_id = $1 AND idempotency_key = $2',
+ [req.user.id, idempotency_key]
+ );
+ if (dup) {
+ // Update existing instead of creating duplicate
+ await db.run(
+ 'UPDATE saved_encounters SET label=$1, transcript=$2, generated_note=$3, partial_data=$4, status=$5, updated_at=NOW() WHERE id=$6 AND user_id=$7',
+ [
+ label || 'Untitled',
+ transcript || '',
+ generated_note || '',
+ typeof partial_data === 'object' ? JSON.stringify(partial_data) : (partial_data || '{}'),
+ status || 'active',
+ dup.id, req.user.id
+ ]
+ );
+ return res.json({ success: true, id: dup.id });
+ }
+ }
// Create new
var result = await db.run(
- 'INSERT INTO saved_encounters (user_id, label, enc_type, transcript, generated_note, partial_data, status, expires_at) VALUES ($1,$2,$3,$4,$5,$6,$7, NOW() + ($8 || \' days\')::INTERVAL)',
+ 'INSERT INTO saved_encounters (user_id, label, enc_type, transcript, generated_note, partial_data, status, idempotency_key, expires_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8, NOW() + ($9 || \' days\')::INTERVAL)',
[
req.user.id,
label || 'Untitled',
@@ -67,6 +89,7 @@ router.post('/encounters/saved', async function(req, res) {
generated_note || '',
typeof partial_data === 'object' ? JSON.stringify(partial_data) : (partial_data || '{}'),
status || 'active',
+ idempotency_key || null,
autoDeleteDays
]
);
diff --git a/src/routes/memories.js b/src/routes/memories.js
index 724b6f1..6e8565f 100644
--- a/src/routes/memories.js
+++ b/src/routes/memories.js
@@ -10,7 +10,11 @@ var logger = require('../utils/logger');
router.use(authMiddleware);
-var VALID_CATEGORIES = ['physical_exam', 'ros', 'encounter_format', 'family_history', 'assessment_plan', 'custom'];
+var VALID_CATEGORIES = [
+ 'physical_exam', 'ros', 'encounter_format', 'family_history', 'assessment_plan', 'custom',
+ 'template_soap', 'template_hpi', 'template_wellvisit', 'template_sickvisit',
+ 'correction_soap', 'correction_hpi', 'correction_encounter', 'correction_wellvisit', 'correction_sickvisit'
+];
// ── GET all memories for current user ───────────────────────────────────
router.get('/memories', async function(req, res) {
@@ -33,7 +37,7 @@ router.post('/memories', async function(req, res) {
// Limit per user
var count = await db.get('SELECT COUNT(*) as cnt FROM user_memories WHERE user_id = $1', [req.user.id]);
- if (count && parseInt(count.cnt) >= 50) return res.status(400).json({ error: 'Maximum 50 memories per user' });
+ if (count && parseInt(count.cnt) >= 200) return res.status(400).json({ error: 'Maximum 200 memories per user' });
var result = await db.run(
'INSERT INTO user_memories (user_id, category, name, content) VALUES ($1,$2,$3,$4)',
@@ -79,12 +83,65 @@ router.get('/memories/context', async function(req, res) {
);
if (rows.length === 0) return res.json({ success: true, context: '' });
- var context = '\n\nPHYSICIAN TEMPLATES AND PREFERENCES:\n';
+ var templates = [];
+ var corrections = [];
rows.forEach(function(r) {
- context += '--- ' + r.category.toUpperCase().replace('_', ' ') + ': ' + r.name + ' ---\n' + r.content + '\n\n';
+ if (r.category.startsWith('correction_')) corrections.push(r);
+ else templates.push(r);
});
+
+ var context = '';
+ if (templates.length > 0) {
+ context += '\n\nPHYSICIAN TEMPLATES AND PREFERENCES:\n';
+ templates.forEach(function(r) {
+ context += '--- ' + r.category.toUpperCase().replace(/_/g, ' ') + ': ' + r.name + ' ---\n' + r.content + '\n\n';
+ });
+ }
+ if (corrections.length > 0) {
+ context += '\n\nPHYSICIAN CORRECTION HISTORY (learn from these preferences — apply similar corrections to future outputs):\n';
+ corrections.slice(-20).forEach(function(r) {
+ context += '--- CORRECTION (' + r.category.replace('correction_', '').toUpperCase() + '): ' + r.name + ' ---\n' + r.content + '\n\n';
+ });
+ }
res.json({ success: true, context: context.trim() });
} catch (e) { logger.error('GET /memories/context', e.message); res.status(500).json({ error: e.message }); }
});
+// ── POST auto-save correction (Dragon-like learning) ──────────────────
+router.post('/memories/correction', async function(req, res) {
+ try {
+ var { section, original_snippet, corrected_snippet } = req.body;
+ if (!section || !original_snippet || !corrected_snippet) {
+ return res.status(400).json({ error: 'section, original_snippet, and corrected_snippet required' });
+ }
+ if (original_snippet.trim() === corrected_snippet.trim()) {
+ return res.json({ success: true, skipped: true });
+ }
+ var cat = 'correction_' + section;
+ if (!VALID_CATEGORIES.includes(cat)) cat = 'correction_encounter';
+
+ // Limit corrections per category: keep only latest 20
+ var existing = await db.all(
+ 'SELECT id FROM user_memories WHERE user_id = $1 AND category = $2 ORDER BY created_at ASC',
+ [req.user.id, cat]
+ );
+ if (existing.length >= 20) {
+ // Delete oldest to make room
+ var toDelete = existing.slice(0, existing.length - 19);
+ for (var i = 0; i < toDelete.length; i++) {
+ await db.run('DELETE FROM user_memories WHERE id = $1 AND user_id = $2', [toDelete[i].id, req.user.id]);
+ }
+ }
+
+ var name = original_snippet.substring(0, 60).replace(/\n/g, ' ') + '...';
+ var content = 'ORIGINAL: ' + original_snippet.substring(0, 2000) + '\nCORRECTED TO: ' + corrected_snippet.substring(0, 2000);
+
+ await db.run(
+ 'INSERT INTO user_memories (user_id, category, name, content) VALUES ($1,$2,$3,$4)',
+ [req.user.id, cat, name, content]
+ );
+ res.json({ success: true });
+ } catch (e) { logger.error('POST /memories/correction', e.message); res.status(500).json({ error: e.message }); }
+});
+
module.exports = router;