Age parser (src/routes/billing.js):
- Now sums year + month + week + day matches so "4 yr 11 mo"
(59 months) correctly maps to the 5-11y billing bracket instead
of being billed as 1-4y. Added bounds sanity check.
Graceful SIGTERM shutdown (server.js):
- Closes the HTTP listener first, then drains batched audit queues,
then ends the Postgres pool. 9-second hard deadline to beat
Docker's 10-second SIGKILL. Previously an in-flight note save
during a container restart could truncate the write.
Explicit LLM fallback opt-in (src/utils/ai.js):
- The OpenRouter / LiteLLM silent fallback now requires admin
setting `ai.allow_model_fallback = true` (default: false). If
primary fails and fallback is disabled, the error is surfaced
to the caller. Prevents silent spillover from a BAA-covered
primary to a non-covered fallback.
Prompt injection delimiters (src/utils/promptSafe.js):
- Wraps user transcripts, dictations, refine-instructions, and
pasted documents in <UNTRUSTED_*>...</UNTRUSTED_*> tags and
appends an explicit system instruction telling the model to
treat the wrapped content as data rather than commands.
- Applied to soap.js, hpi.js, refine.js. Extend to other AI
routes incrementally.
Cross-tab logout sync (public/js/authFetch.js, auth.js):
- BroadcastChannel('pedscribe-auth') — logout in one tab posts
a message; all sibling tabs clear state and reload, dropping
any PHI-containing UI immediately.
Backup code race-free consumption (src/routes/auth.js):
- tryConsumeBackupCode() now uses a Postgres transaction with
SELECT ... FOR UPDATE so concurrent login attempts using the
same code serialize. First wins, second sees the already-
shortened array.
Optimistic encounter locking (migrations/...add-encounter-version):
- saved_encounters.version INTEGER NOT NULL DEFAULT 1
- POST /api/encounters/saved accepts an expected_version and
rejects with 409 if the row has advanced. Falls back to
last-write-wins if the client doesn't pass one (backward compat).
Audit log batching (src/utils/auditQueue.js):
- Audit / api_log / access_log writes are buffered in memory and
flushed every 1s or every 50 entries via one multi-row INSERT.
Under load this reduces DB pressure by ~50x. On SIGTERM the
shutdown path drains the queue before exiting.
87 lines
3.4 KiB
JavaScript
87 lines
3.4 KiB
JavaScript
// ============================================================
|
|
// REFINE ROUTES — Modify, shorten, clarify any document
|
|
// ============================================================
|
|
|
|
var express = require('express');
|
|
var router = express.Router();
|
|
var { callAI } = require('../utils/ai');
|
|
var PROMPTS = require('../utils/prompts');
|
|
var { authMiddleware } = require('../middleware/auth');
|
|
var logger = require('../utils/logger');
|
|
var { wrapUserText, INJECTION_GUARD } = require('../utils/promptSafe');
|
|
|
|
// Refine/modify document with instructions
|
|
router.post('/refine', authMiddleware, async function(req, res) {
|
|
try {
|
|
var currentDocument = req.body.currentDocument;
|
|
var instructions = req.body.instructions;
|
|
var sourceContext = req.body.sourceContext;
|
|
var model = req.body.model;
|
|
|
|
if (!currentDocument) return res.status(400).json({ error: 'No document to refine' });
|
|
if (!instructions) return res.status(400).json({ error: 'No instructions provided' });
|
|
|
|
var userContent = '';
|
|
if (sourceContext) {
|
|
userContent += 'ORIGINAL SOURCE MATERIAL (reference this for any data lookups — labs, notes, history):\n'
|
|
+ wrapUserText('source', sourceContext) + '\n\n';
|
|
}
|
|
userContent += 'CURRENT DOCUMENT (modify this based on user instructions):\n'
|
|
+ wrapUserText('document', currentDocument)
|
|
+ '\n\nUSER INSTRUCTIONS:\n'
|
|
+ wrapUserText('instructions', instructions);
|
|
|
|
var result = await callAI([
|
|
{ role: 'system', content: PROMPTS.refine + INJECTION_GUARD },
|
|
{ role: 'user', content: userContent }
|
|
], { model: model, maxTokens: 6000 });
|
|
|
|
res.json({ success: true, refined: result.content, model: result.model });
|
|
logger.audit(req.user.id, 'refine_document', 'Refined document', req, { category: 'clinical' });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Request failed' });
|
|
}
|
|
});
|
|
|
|
// Shorten document
|
|
router.post('/shorten', authMiddleware, async function(req, res) {
|
|
try {
|
|
var document = req.body.document;
|
|
var model = req.body.model;
|
|
|
|
if (!document) return res.status(400).json({ error: 'No document' });
|
|
|
|
var result = await callAI([
|
|
{ role: 'system', content: PROMPTS.shortenDocument + INJECTION_GUARD },
|
|
{ role: 'user', content: wrapUserText('document', document) }
|
|
], { model: model });
|
|
|
|
res.json({ success: true, shortened: result.content, model: result.model });
|
|
logger.audit(req.user.id, 'shorten_document', 'Shortened document', req, { category: 'clinical' });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Request failed' });
|
|
}
|
|
});
|
|
|
|
// Ask AI for clarification questions
|
|
router.post('/clarify', authMiddleware, async function(req, res) {
|
|
try {
|
|
var document = req.body.document;
|
|
var context = req.body.context;
|
|
var model = req.body.model;
|
|
|
|
if (!document) return res.status(400).json({ error: 'No document' });
|
|
|
|
var result = await callAI([
|
|
{ role: 'system', content: PROMPTS.askClarification + INJECTION_GUARD },
|
|
{ role: 'user', content: 'Document type: ' + (context || 'medical document') + '\n\n' + wrapUserText('document', document) }
|
|
], { model: model, maxTokens: 1000 });
|
|
|
|
res.json({ success: true, questions: result.content, model: result.model });
|
|
logger.audit(req.user.id, 'clarify_document', 'Generated clarification questions', req, { category: 'clinical' });
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Request failed' });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|