From 41edc46971cff6e7bdcd0b4e62699d39f9305e28 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 25 Apr 2026 01:35:09 +0200 Subject: [PATCH] =?UTF-8?q?fix(notes):=20voice=20=E2=86=92=20AI=20note=20n?= =?UTF-8?q?ow=20produces=20HTML=20the=20editor=20can=20render?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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

///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). --- .codex | 0 package-lock.json | 17 +++++++++-- package.json | 1 + public/js/notes.js | 26 ++++++++++++++--- public/sw.js | 2 +- server.js | 4 +++ src/db/database.js | 9 +++++- src/routes/audioBackups.js | 37 ++++++++++++++++++++++-- src/routes/notes.js | 59 ++++++++++++++++++++++++++++++-------- 9 files changed, 132 insertions(+), 23 deletions(-) create mode 100644 .codex diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/package-lock.json b/package-lock.json index 6089e07..9e61c94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e15d743..efe0fea 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/js/notes.js b/public/js/notes.js index c678a00..d81775d 100644 --- a/public/js/notes.js +++ b/public/js/notes.js @@ -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 || '

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

'); _dirty = true; } diff --git a/public/sw.js b/public/sw.js index bb31f1f..7f66d43 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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', diff --git a/server.js b/server.js index fb07a3b..2dce218 100644 --- a/server.js +++ b/server.js @@ -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.'); diff --git a/src/db/database.js b/src/db/database.js index b759365..fab327c 100644 --- a/src/db/database.js +++ b/src/db/database.js @@ -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; diff --git a/src/routes/audioBackups.js b/src/routes/audioBackups.js index cb5d69e..3c0c1bb 100644 --- a/src/routes/audioBackups.js +++ b/src/routes/audioBackups.js @@ -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(); } }); diff --git a/src/routes/notes.js b/src/routes/notes.js index 51f5a89..a46b433 100644 --- a/src/routes/notes.js +++ b/src/routes/notes.js @@ -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

. + try { return marked.parse(t, { breaks: true }); } + catch (e) { + // Last-ditch: wrap the raw text in

so the editor at least shows it. + return '

' + t.replace(//g, '>').replace(/\n{2,}/g, '

').replace(/\n/g, '
') + '

'; + } +} 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: '

' + transcript.replace(//g, '>').replace(/\n{2,}/g, '

').replace(/\n/g, '
') + '

', - }; + 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

so the user can edit it manually. + if (!body || !body.replace(/<[^>]+>/g, '').trim()) { + body = '

' + String(transcript).replace(//g, '>') + '

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