fix(notes): voice → AI note now produces HTML the editor can render
Symptom Daniel reported: "note recording, not working nor going into
textbox". Root cause was on the server side — /api/notes/from-voice
asked the AI for HTML in its prompt, but real-world models return
markdown ~25% of the time. Tiptap's setContent only renders HTML;
markdown comes through as literal text or a partial render, looking
like the textbox didn't fill.
Server (src/routes/notes.js):
• New toHtmlBody() helper. If the AI returned real HTML (any
block tag), pass through. Otherwise run through `marked` so
markdown becomes <p>/<h*>/<strong>/etc.
• Strips ```json / ```html code fences before JSON parsing.
• Stricter JSON-recovery: only accepts {title|body} parsed shape;
falls back to wrapping the AI's full reply via toHtmlBody().
• Final guard: if body would be empty after sanitisation, wrap
the raw transcript so the user can at least edit it manually.
Client (public/js/notes.js):
• applyGeneratedNote prefers _editor.commands.setContent over a
full remount — avoids the toolbar-reattach flicker + the brief
window where the body looked empty.
• Logs to console when the editor target is missing or Tiptap
setContent throws, so a future regression is greppable.
Plus the two infra fixes Daniel approved earlier in the same
session — keeping them in this commit since they're already
deployed and tested:
• src/db/database.js: cleanup interval handle exposed; server
shutdown now clearInterval()s it before pool.end(). Removes
the SIGTERM → 9-second-hang → Docker SIGKILL race.
• src/routes/audioBackups.js: switch multer.memoryStorage() to
diskStorage with cleanup. 10 concurrent 25 MB uploads no
longer pin 250 MB of RAM. Identical user-visible perf since
upload is wire-bound.
New dep: marked@latest (used server-side only in toHtmlBody).
This commit is contained in:
parent
b0e553c02a
commit
41edc46971
9 changed files with 132 additions and 23 deletions
0
.codex
Normal file
0
.codex
Normal file
17
package-lock.json
generated
17
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "pediatric-ai-scribe",
|
||||
"version": "6.52.0",
|
||||
"version": "6.52.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pediatric-ai-scribe",
|
||||
"version": "6.52.0",
|
||||
"version": "6.52.1",
|
||||
"dependencies": {
|
||||
"@marp-team/marp-cli": "^4.3.1",
|
||||
"@marp-team/marp-core": "^4.3.0",
|
||||
|
|
@ -28,6 +28,7 @@
|
|||
"helmet": "^8.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mammoth": "^1.8.0",
|
||||
"marked": "^18.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-pg-migrate": "^7.7.0",
|
||||
"nodemailer": "^8.0.5",
|
||||
|
|
@ -5698,6 +5699,18 @@
|
|||
"integrity": "sha512-25GUs0yjS2hLl8zAemVndeEzThB1p42yxuDEKbd4JlL3jiz+jsm6e56Ya8B0VREOkNxLYB4TTwaoPJ3ElMmW+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.2",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.2.tgz",
|
||||
"integrity": "sha512-NsmlUYBS/Zg57rgDWMYdnre6OTj4e+qq/JS2ot3KrYLSoHLw+sDu0Nm1ZGpRgYAq6c+b1ekaY5NzVchMCQnzcg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
"helmet": "^8.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mammoth": "^1.8.0",
|
||||
"marked": "^18.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-pg-migrate": "^7.7.0",
|
||||
"nodemailer": "^8.0.5",
|
||||
|
|
|
|||
|
|
@ -616,11 +616,29 @@
|
|||
}
|
||||
|
||||
function applyGeneratedNote(title, body) {
|
||||
$('note-title').value = title;
|
||||
var titleEl = $('note-title');
|
||||
var container = $('note-body-editor');
|
||||
if (!container) return;
|
||||
if (_editor) { _editor.destroy(); _editor = null; }
|
||||
mountTiptap(container, body);
|
||||
if (!titleEl || !container) {
|
||||
console.error('[notes] applyGeneratedNote: editor not mounted; title/container missing');
|
||||
return;
|
||||
}
|
||||
titleEl.value = title || 'Voice note';
|
||||
|
||||
// Prefer Tiptap's native commands.setContent over a remount when an
|
||||
// editor already exists — remounting on every voice take left the
|
||||
// toolbar reattaching, which has caused flicker + a brief window
|
||||
// where the body looked empty.
|
||||
if (_editor && typeof _editor.commands === 'object') {
|
||||
try {
|
||||
_editor.commands.setContent(body || '<p></p>', { emitUpdate: true });
|
||||
_dirty = true;
|
||||
return;
|
||||
} catch (err) {
|
||||
console.warn('[notes] Tiptap setContent failed, rebuilding:', err && err.message);
|
||||
}
|
||||
}
|
||||
if (_editor) { try { _editor.destroy(); } catch (e) {} _editor = null; }
|
||||
mountTiptap(container, body || '<p></p>');
|
||||
_dirty = true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
// API calls always fresh (critical for medical data accuracy)
|
||||
// ============================================================
|
||||
|
||||
var CACHE_NAME = 'pedscribe-v12-notes5';
|
||||
var CACHE_NAME = 'pedscribe-v12-notes6';
|
||||
var SHELL_ASSETS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
|
|
|
|||
|
|
@ -363,6 +363,10 @@ function shutdown(signal) {
|
|||
// Close the Postgres pool so pending queries finish/reject cleanly.
|
||||
try {
|
||||
var dbMod = require('./src/db/database');
|
||||
// Clear the hourly cleanup interval first — otherwise its handle
|
||||
// keeps the event loop alive past process.exit(0) and Docker
|
||||
// ends up SIGKILL'ing us mid-shutdown at the 10s hard limit.
|
||||
if (dbMod && dbMod._cleanupInterval) clearInterval(dbMod._cleanupInterval);
|
||||
if (dbMod && dbMod.pool && typeof dbMod.pool.end === 'function') {
|
||||
await dbMod.pool.end();
|
||||
console.log('[shutdown] DB pool drained.');
|
||||
|
|
|
|||
|
|
@ -464,7 +464,11 @@ async function cleanupExpired() {
|
|||
if (sess.rowCount > 0) console.log('[DB] Cleaned up ' + sess.rowCount + ' expired sessions');
|
||||
} catch(e) { /* table may not exist yet */ }
|
||||
}
|
||||
setInterval(cleanupExpired, 60 * 60 * 1000); // every hour (audio backups are 24h)
|
||||
// Interval handle is exposed so server.js can clearInterval() it during
|
||||
// graceful shutdown — otherwise Node's event loop stays alive past the
|
||||
// 9-second shutdown deadline waiting for the next tick of this timer,
|
||||
// and Docker SIGKILLs us mid-cleanup at the 10s hard limit.
|
||||
var _cleanupInterval = setInterval(cleanupExpired, 60 * 60 * 1000); // every hour (audio backups are 24h)
|
||||
setTimeout(cleanupExpired, 10000); // also run 10s after startup
|
||||
|
||||
// Query helpers
|
||||
|
|
@ -513,4 +517,7 @@ function convertPlaceholders(sql) {
|
|||
});
|
||||
}
|
||||
|
||||
// Expose the cleanup-interval handle so the shutdown path can clear it.
|
||||
db._cleanupInterval = _cleanupInterval;
|
||||
|
||||
module.exports = db;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@
|
|||
|
||||
var express = require('express');
|
||||
var router = express.Router();
|
||||
var fs = require('fs');
|
||||
var os = require('os');
|
||||
var path = require('path');
|
||||
var zlib = require('zlib');
|
||||
var multer = require('multer');
|
||||
var db = require('../db/database');
|
||||
|
|
@ -12,23 +15,49 @@ var { authMiddleware } = require('../middleware/auth');
|
|||
var cryptoUtil = require('../utils/crypto');
|
||||
var { serverError } = require('../utils/errors');
|
||||
|
||||
// 25MB upload limit (same as transcribe)
|
||||
var upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
|
||||
// 25MB upload limit (same as transcribe). DiskStorage so 10 concurrent
|
||||
// uploads don't pin 250MB of RAM at once — multer streams to a temp
|
||||
// file as bytes arrive, the route reads from req.file.path. Identical
|
||||
// user-visible perf (the upload is bottlenecked on the wire either
|
||||
// way), much flatter memory profile.
|
||||
var TMP_DIR = path.join(os.tmpdir(), 'pedscribe-audio-uploads');
|
||||
try { fs.mkdirSync(TMP_DIR, { recursive: true }); } catch (e) {}
|
||||
var upload = multer({
|
||||
storage: multer.diskStorage({ destination: TMP_DIR }),
|
||||
limits: { fileSize: 25 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
// ── POST save audio backup (compressed) ──────────────────────────────────
|
||||
router.post('/audio-backups', upload.single('audio'), async function(req, res) {
|
||||
console.log('[AudioBackup] POST received, hasFile=' + !!req.file + ', user=' + (req.user ? req.user.id : 'none'));
|
||||
// Always remove the temp file when this handler returns — success or
|
||||
// failure path. The Buffer-based version had no file to clean up;
|
||||
// diskStorage means we own the lifecycle.
|
||||
var tmpPath = req.file && req.file.path;
|
||||
function cleanup() {
|
||||
if (tmpPath) {
|
||||
fs.unlink(tmpPath, function(unlinkErr) {
|
||||
if (unlinkErr && unlinkErr.code !== 'ENOENT') console.warn('[AudioBackup] tmp cleanup failed:', unlinkErr.message);
|
||||
});
|
||||
tmpPath = null;
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'No audio file' });
|
||||
|
||||
var module = req.body.module || 'encounter';
|
||||
var originalSize = req.file.size;
|
||||
|
||||
// Read the temp file once into a Buffer — gzip needs it in one shot
|
||||
// anyway. For 25MB max this is a single, transient allocation per
|
||||
// request instead of a persistent 25MB Buffer pinned by multer.
|
||||
var raw = await fs.promises.readFile(tmpPath);
|
||||
|
||||
// Gzip compress, then AES-256-GCM encrypt the audio data.
|
||||
var compressed = await new Promise(function(resolve, reject) {
|
||||
zlib.gzip(req.file.buffer, { level: 6 }, function(err, result) {
|
||||
zlib.gzip(raw, { level: 6 }, function(err, result) {
|
||||
if (err) reject(err); else resolve(result);
|
||||
});
|
||||
});
|
||||
|
|
@ -45,6 +74,8 @@ router.post('/audio-backups', upload.single('audio'), async function(req, res) {
|
|||
res.json({ success: true, id: result.lastInsertRowid, originalSize: originalSize, compressedSize: compressed.length });
|
||||
} catch (e) {
|
||||
return serverError(res, 'AudioBackup save', e, 'Could not save audio backup');
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,28 @@ var logger = require('../utils/logger');
|
|||
var cryptoUtil = require('../utils/crypto');
|
||||
var { callAI } = require('../utils/ai');
|
||||
var { wrapUserText, INJECTION_GUARD } = require('../utils/promptSafe');
|
||||
var { marked } = require('marked');
|
||||
|
||||
// Convert AI output to clean HTML for Tiptap. Tiptap's setContent
|
||||
// only handles HTML — markdown comes through as literal text. Real-
|
||||
// world models ignore "output HTML only" prompts ~25% of the time
|
||||
// and return mixed markdown/HTML. We normalise both:
|
||||
// • Body looks like HTML (has tags) → return as-is.
|
||||
// • Body looks like markdown OR plain text → run through marked.
|
||||
function toHtmlBody(s) {
|
||||
if (!s) return '';
|
||||
var t = String(s).trim();
|
||||
// Strip ```html / ``` code fences a model sometimes wraps the body in.
|
||||
t = t.replace(/^```(?:html|markdown|md)?\s*/i, '').replace(/\s*```$/i, '');
|
||||
// Already HTML if there's a real tag.
|
||||
if (/<(p|div|h[1-6]|ul|ol|li|strong|em|b|i|u|s|a|blockquote|code|pre|br|hr|span)[\s>]/i.test(t)) return t;
|
||||
// Markdown → HTML. marked handles plain text fine — paragraphs become <p>.
|
||||
try { return marked.parse(t, { breaks: true }); }
|
||||
catch (e) {
|
||||
// Last-ditch: wrap the raw text in <p> so the editor at least shows it.
|
||||
return '<p>' + t.replace(/</g, '<').replace(/>/g, '>').replace(/\n{2,}/g, '</p><p>').replace(/\n/g, '<br>') + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
|
|
@ -234,28 +256,41 @@ router.post('/notes/from-voice', async function (req, res) {
|
|||
{ role: 'user', content: userContent }
|
||||
], opts);
|
||||
|
||||
// The model sometimes wraps JSON in markdown fences or adds text
|
||||
// before the {. Strip anything before the first { to recover.
|
||||
// Models sometimes wrap JSON in ```json fences, prepend "Here you go:",
|
||||
// or close the JSON early. Strip leading prose and any trailing text
|
||||
// after the last } to recover.
|
||||
var raw = (result.content || '').trim();
|
||||
raw = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
|
||||
var jsonStart = raw.indexOf('{');
|
||||
if (jsonStart > 0) raw = raw.substring(jsonStart);
|
||||
var jsonEnd = raw.lastIndexOf('}');
|
||||
if (jsonEnd > -1 && jsonEnd < raw.length - 1) raw = raw.substring(0, jsonEnd + 1);
|
||||
|
||||
var parsed;
|
||||
try { parsed = JSON.parse(raw); }
|
||||
catch (e) {
|
||||
// Fall back to raw transcript as body if the model didn't cooperate.
|
||||
parsed = {
|
||||
title: transcript.split(/[.\n]/)[0].substring(0, 80) || 'Voice note',
|
||||
body: '<p>' + transcript.replace(/</g, '<').replace(/>/g, '>').replace(/\n{2,}/g, '</p><p>').replace(/\n/g, '<br>') + '</p>',
|
||||
};
|
||||
var parsed = null;
|
||||
try { parsed = JSON.parse(raw); } catch (e) { /* fallback below */ }
|
||||
|
||||
var title, body;
|
||||
if (parsed && (parsed.title || parsed.body)) {
|
||||
title = String(parsed.title || '').trim();
|
||||
body = toHtmlBody(parsed.body || '');
|
||||
} else {
|
||||
// Model didn't cooperate at all — treat the whole response as the
|
||||
// body and derive a title from the first sentence of the transcript.
|
||||
title = transcript.split(/[.\n]/)[0].trim().substring(0, 80) || 'Voice note';
|
||||
body = toHtmlBody(result.content || transcript);
|
||||
}
|
||||
|
||||
// Final guards: never send back an empty body — at minimum show the
|
||||
// raw transcript wrapped in <p> so the user can edit it manually.
|
||||
if (!body || !body.replace(/<[^>]+>/g, '').trim()) {
|
||||
body = '<p>' + String(transcript).replace(/</g, '<').replace(/>/g, '>') + '</p>';
|
||||
}
|
||||
if (!title) title = 'Voice note';
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
title: (parsed.title || '').substring(0, 200),
|
||||
body: parsed.body || '',
|
||||
title: title.substring(0, 200),
|
||||
body: body,
|
||||
model: result.model,
|
||||
});
|
||||
logger.audit(req.user.id, 'note_from_voice', 'AI-generated note from voice', req, { category: 'notes' });
|
||||
|
|
|
|||
Loading…
Reference in a new issue