@peopl-health/nexus 5.67.0-dev.11889 → 5.67.0-dev.11930
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/clinical/providers/AnthropicProvider.js +6 -3
- package/lib/clinical/providers/BaseLLMProvider.js +5 -0
- package/lib/clinical/providers/GatewayProvider.js +5 -0
- package/lib/clinical/services/nutritionPlanService.js +24 -14
- package/lib/clinical/services/promptComposerService.js +12 -3
- package/lib/controllers/escalationReviewController.js +20 -2
- package/lib/eval/EvalProvider.js +8 -3
- package/lib/models/escalationReviewModel.js +10 -0
- package/lib/routes/index.js +2 -0
- package/lib/services/escalationReviewService.js +33 -1
- package/lib/services/reviewBoardService.js +9 -10
- package/lib/services/reviewDeadlineService.js +82 -0
- package/lib/utils/dateUtils.js +22 -0
- package/package.json +1 -1
|
@@ -475,14 +475,17 @@ class AnthropicProvider extends BaseLLMProvider {
|
|
|
475
475
|
// Split placement: the inline copy carries recency on single-round turns, the hoisted developer
|
|
476
476
|
// turn persists once tool results are appended after it on multi-round turns.
|
|
477
477
|
_buildInput({ additionalInstructions, ...rest }) {
|
|
478
|
-
const
|
|
479
|
-
const pinned = { role: 'developer', content: `${RISK_HEADLINE_MARKER}\n${RISK_HEADLINE_DISCLAIMER}` };
|
|
480
|
-
const ordered = [...input.filter(i => !i[TAIL_PLACEMENT]), pinned, ...input.filter(i => i[TAIL_PLACEMENT])];
|
|
478
|
+
const ordered = this._orderRuntimeMessages(super._buildInput({ additionalInstructions, ...rest }));
|
|
481
479
|
if (!additionalInstructions) return ordered;
|
|
482
480
|
const { content } = this._operatorInstructionTurn(additionalInstructions);
|
|
483
481
|
return [...ordered, { role: 'user', content: `${OPERATOR_TURN_PREFIX} ${content}`, type: 'message' }];
|
|
484
482
|
}
|
|
485
483
|
|
|
484
|
+
_orderRuntimeMessages(input) {
|
|
485
|
+
const pinned = { role: 'developer', content: `${RISK_HEADLINE_MARKER}\n${RISK_HEADLINE_DISCLAIMER}` };
|
|
486
|
+
return [...input.filter(i => !i[TAIL_PLACEMENT]), pinned, ...input.filter(i => i[TAIL_PLACEMENT])];
|
|
487
|
+
}
|
|
488
|
+
|
|
486
489
|
_mapModelConfig(modelConfig) {
|
|
487
490
|
const mapped = { ...(modelConfig || {}) };
|
|
488
491
|
if (mapped.max_tokens == null && mapped.max_output_tokens != null) mapped.max_tokens = mapped.max_output_tokens;
|
|
@@ -783,6 +783,11 @@ class BaseLLMProvider {
|
|
|
783
783
|
};
|
|
784
784
|
}
|
|
785
785
|
|
|
786
|
+
// Providers that pin runtime messages around the tail-placed block override this.
|
|
787
|
+
_orderRuntimeMessages(input) {
|
|
788
|
+
return input;
|
|
789
|
+
}
|
|
790
|
+
|
|
786
791
|
_sanitizeInput(input) {
|
|
787
792
|
const isMessage = (item) => !item.type || item.type === 'message';
|
|
788
793
|
const items = (input || []).filter(item => isMessage(item) ? this._hasTextContent(item.content) : true);
|
|
@@ -38,6 +38,11 @@ class GatewayProvider extends BaseLLMProvider {
|
|
|
38
38
|
return this._sub(vendor)._buildInput(config);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
_orderRuntimeMessages(input, modelConfig = null) {
|
|
42
|
+
const { vendor } = this._route(modelConfig?.model);
|
|
43
|
+
return this._sub(vendor)._orderRuntimeMessages(input);
|
|
44
|
+
}
|
|
45
|
+
|
|
41
46
|
_route(model) {
|
|
42
47
|
if (!model) return { vendor: 'openai', model: undefined };
|
|
43
48
|
const slash = model.indexOf('/');
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const { safeParse, isPlainObject } = require('../../utils/jsonUtils');
|
|
2
2
|
const { logger } = require('../../utils/logger');
|
|
3
3
|
const { sanitizeInlineText } = require('../../utils/sanitizerUtils');
|
|
4
|
+
const { withTimeout } = require('../../utils/timeoutUtils');
|
|
4
5
|
const { composePrompt } = require('./promptComposerService');
|
|
5
6
|
|
|
6
7
|
const NUTRITION_GUIDE_PROMPT_ID = 'nutrition_plan_guide';
|
|
@@ -33,32 +34,41 @@ function readPlanSection(section) {
|
|
|
33
34
|
|
|
34
35
|
let lastGuide = '';
|
|
35
36
|
let guideRead = null;
|
|
37
|
+
let readSeq = 0;
|
|
38
|
+
let appliedSeq = 0;
|
|
36
39
|
|
|
37
|
-
// One shared read; a failure keeps the last guide that was read successfully.
|
|
38
40
|
function readGuide() {
|
|
39
41
|
if (guideRead) return guideRead;
|
|
40
|
-
|
|
42
|
+
const seq = ++readSeq;
|
|
43
|
+
const read = composePrompt({ promptId: NUTRITION_GUIDE_PROMPT_ID })
|
|
41
44
|
.then(({ resolvedPrompt }) => {
|
|
42
|
-
|
|
45
|
+
// A read that hung past a newer one must not overwrite what the newer one found.
|
|
46
|
+
if (seq > appliedSeq) {
|
|
47
|
+
appliedSeq = seq;
|
|
48
|
+
lastGuide = keepLines(resolvedPrompt || '', GUIDE_MAX_CHARS);
|
|
49
|
+
}
|
|
43
50
|
return lastGuide;
|
|
44
51
|
})
|
|
45
52
|
.catch((error) => {
|
|
46
53
|
logger.warn('[nutritionPlanService] nutrition guide unreadable; using the last one read', { error: error.message });
|
|
47
54
|
return lastGuide;
|
|
48
55
|
})
|
|
49
|
-
.finally(() => { guideRead = null; });
|
|
50
|
-
|
|
56
|
+
.finally(() => { if (guideRead === read) guideRead = null; });
|
|
57
|
+
guideRead = read;
|
|
58
|
+
return read;
|
|
51
59
|
}
|
|
52
60
|
|
|
53
|
-
// The guide only adds to the plan, so a slow read must not hold the patient's turn
|
|
61
|
+
// The guide only adds to the plan, so a slow read must not hold the patient's turn,
|
|
62
|
+
// and a read that never settles must not hold every later turn either.
|
|
54
63
|
async function loadNutritionGuide() {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
const read = readGuide();
|
|
65
|
+
try {
|
|
66
|
+
return await withTimeout(read, GUIDE_TIMEOUT_MS, 'nutrition guide read');
|
|
67
|
+
} catch {
|
|
68
|
+
if (guideRead === read) guideRead = null;
|
|
69
|
+
logger.warn('[nutritionPlanService] nutrition guide read timed out; using the last one read', { timeoutMs: GUIDE_TIMEOUT_MS });
|
|
70
|
+
return lastGuide;
|
|
71
|
+
}
|
|
62
72
|
}
|
|
63
73
|
|
|
64
74
|
async function resolveNutritionPlan(section, { now = new Date() } = {}) {
|
|
@@ -78,5 +88,5 @@ module.exports = {
|
|
|
78
88
|
NUTRITION_GUIDE_PROMPT_ID,
|
|
79
89
|
GUIDE_TIMEOUT_MS,
|
|
80
90
|
resolveNutritionPlan,
|
|
81
|
-
_test: { reset: () => { lastGuide = ''; guideRead = null; } },
|
|
91
|
+
_test: { reset: () => { lastGuide = ''; guideRead = null; readSeq = 0; appliedSeq = 0; } },
|
|
82
92
|
};
|
|
@@ -14,6 +14,8 @@ const promptCache = new MapCache({ maxSize: 50, ttl: CACHE_TTL });
|
|
|
14
14
|
const snippetCache = new MapCache({ maxSize: 200, ttl: CACHE_TTL });
|
|
15
15
|
const toolCache = new MapCache({ maxSize: 50, ttl: CACHE_TTL });
|
|
16
16
|
const presetCache = new MapCache({ maxSize: 50, ttl: CACHE_TTL });
|
|
17
|
+
let promptReadClock = 0;
|
|
18
|
+
let promptCacheEpoch = 0;
|
|
17
19
|
|
|
18
20
|
async function fetchConfigRecords(table, filter) {
|
|
19
21
|
const records = await getRecordByFilter(Config_ID, table, filter);
|
|
@@ -47,11 +49,17 @@ async function fetchPreset(presetId, version = null) {
|
|
|
47
49
|
async function fetchBasePrompt(promptId) {
|
|
48
50
|
const cacheKey = `prompt:${promptId}`;
|
|
49
51
|
const cached = promptCache.get(cacheKey);
|
|
50
|
-
if (cached) return cached;
|
|
52
|
+
if (cached) return cached.record;
|
|
51
53
|
|
|
54
|
+
// A result may fill the cache unless a read started later already did, or the cache was cleared meanwhile.
|
|
55
|
+
const startedAt = ++promptReadClock;
|
|
56
|
+
const epoch = promptCacheEpoch;
|
|
52
57
|
const records = await getRecordByFilter(Config_ID, 'responses', `{prompt_id} = "${promptId}"`);
|
|
53
58
|
const record = records?.[0] || null;
|
|
54
|
-
|
|
59
|
+
const current = promptCache.get(cacheKey);
|
|
60
|
+
if (record && epoch === promptCacheEpoch && !(current && current.startedAt > startedAt)) {
|
|
61
|
+
promptCache.set(cacheKey, { record, startedAt });
|
|
62
|
+
}
|
|
55
63
|
return record;
|
|
56
64
|
}
|
|
57
65
|
|
|
@@ -257,9 +265,10 @@ async function resolveTools({ promptId, assistant, presetToolIds = null, status
|
|
|
257
265
|
|
|
258
266
|
function clearCache() {
|
|
259
267
|
promptCache.clear();
|
|
268
|
+
promptCacheEpoch += 1;
|
|
260
269
|
snippetCache.clear();
|
|
261
270
|
toolCache.clear();
|
|
262
271
|
presetCache.clear();
|
|
263
272
|
}
|
|
264
273
|
|
|
265
|
-
module.exports = { composePrompt, resolveTools, clearCache, _test: { applyVariables, sortSnippets, resetUnlinkedWarnings: () => warnedUnlinked.clear() } };
|
|
274
|
+
module.exports = { composePrompt, resolveTools, clearCache, variablesReferencedIn, _test: { applyVariables, sortSnippets, resetUnlinkedWarnings: () => warnedUnlinked.clear() } };
|
|
@@ -11,6 +11,7 @@ const {
|
|
|
11
11
|
handOffToDoctor,
|
|
12
12
|
listEscalationReviews,
|
|
13
13
|
openEscalationReview,
|
|
14
|
+
reclassifyUrgency,
|
|
14
15
|
recordDoctorDecision,
|
|
15
16
|
requestBotDraft,
|
|
16
17
|
sendApprovedIndications,
|
|
@@ -49,8 +50,8 @@ const openEscalationReviewController = async (req, res) => {
|
|
|
49
50
|
|
|
50
51
|
const getEscalationReviewController = async (req, res) => {
|
|
51
52
|
try {
|
|
52
|
-
const { review, isApprover } = await getEscalationReviewById({ id: req.params.id, requestedBy: req.query.requestedBy });
|
|
53
|
-
res.status(200).json({ success: true, review, isApprover });
|
|
53
|
+
const { review, isApprover, responseDeadline } = await getEscalationReviewById({ id: req.params.id, requestedBy: req.query.requestedBy });
|
|
54
|
+
res.status(200).json({ success: true, review, isApprover, responseDeadline });
|
|
54
55
|
} catch (error) {
|
|
55
56
|
respondWithError(res, error, 'reading escalation review', { reviewId: req.params?.id });
|
|
56
57
|
}
|
|
@@ -223,6 +224,22 @@ const closeEscalationReviewController = async (req, res) => {
|
|
|
223
224
|
}
|
|
224
225
|
};
|
|
225
226
|
|
|
227
|
+
const reclassifyEscalationReviewUrgencyController = async (req, res) => {
|
|
228
|
+
try {
|
|
229
|
+
const { urgencyLevel, reason, triggeredBy } = req.body || {};
|
|
230
|
+
const { review, responseDeadline } = await reclassifyUrgency({
|
|
231
|
+
id: req.params.id,
|
|
232
|
+
urgencyLevel,
|
|
233
|
+
reason,
|
|
234
|
+
reclassifiedBy: triggeredBy,
|
|
235
|
+
requestId: req.requestId,
|
|
236
|
+
});
|
|
237
|
+
res.status(200).json({ success: true, review, responseDeadline });
|
|
238
|
+
} catch (error) {
|
|
239
|
+
respondWithError(res, error, 'reclassifying escalation review urgency', { reviewId: req.params?.id });
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
226
243
|
const cancelEscalationReviewController = async (req, res) => {
|
|
227
244
|
try {
|
|
228
245
|
const { triggeredBy, reason = null } = req.body || {};
|
|
@@ -265,6 +282,7 @@ module.exports = {
|
|
|
265
282
|
listEscalationReviewsController,
|
|
266
283
|
listReviewBoardController,
|
|
267
284
|
openEscalationReviewController,
|
|
285
|
+
reclassifyEscalationReviewUrgencyController,
|
|
268
286
|
recordEscalationReviewDecisionController,
|
|
269
287
|
sendEscalationReviewIndicationsController,
|
|
270
288
|
setEscalationReviewCasesController,
|
package/lib/eval/EvalProvider.js
CHANGED
|
@@ -7,6 +7,7 @@ const { PhiProcessor } = require('../core/PhiProcessor');
|
|
|
7
7
|
const { buildDateAnchor } = require('../clinical/helpers/dateAnchorHelper');
|
|
8
8
|
const { DefaultMemoryManager, EnhancedMemoryManager, createLLMProvider, composePrompt, resolveTools, getAssistantById } = require('../clinical');
|
|
9
9
|
const { resolveClinicalContext } = require('../clinical/services/clinicalContextReadService');
|
|
10
|
+
const { variablesReferencedIn } = require('../clinical/services/promptComposerService');
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Promptfoo-compatible eval provider that wraps the real Nexus pipeline.
|
|
@@ -159,7 +160,7 @@ class EvalProvider {
|
|
|
159
160
|
resolvedPresetId = composedPresetId || resolvedPresetId;
|
|
160
161
|
} else {
|
|
161
162
|
devContent = fallbackPrompt;
|
|
162
|
-
interpolatedVariables =
|
|
163
|
+
interpolatedVariables = variablesReferencedIn(devContent);
|
|
163
164
|
devContent = devContent.replace(/\{\{(\w+)\}\}/g, (_, key) => promptVariables[key] ?? '');
|
|
164
165
|
}
|
|
165
166
|
|
|
@@ -224,17 +225,21 @@ class EvalProvider {
|
|
|
224
225
|
const clinicalMessage = typeof this.provider._buildClinicalMessage === 'function'
|
|
225
226
|
? this.provider._buildClinicalMessage(promptVariables, interpolatedVariables)
|
|
226
227
|
: [];
|
|
227
|
-
const input = this.provider._sanitizeInput([...memoryMessage, ...clinicalMessage, ...convertedMessages]);
|
|
228
228
|
const isContextOnly = this.mode === 'context-only';
|
|
229
229
|
|
|
230
230
|
const shouldUsePromptRegistry = !presetId && this.provider.supportsPromptRegistry && !!assistantId;
|
|
231
|
+
const modelConfig = shouldUsePromptRegistry ? {} : this._modelConfig(presetModelConfig);
|
|
232
|
+
const input = this.provider._orderRuntimeMessages(
|
|
233
|
+
this.provider._sanitizeInput([...memoryMessage, ...clinicalMessage, ...convertedMessages]),
|
|
234
|
+
modelConfig,
|
|
235
|
+
);
|
|
231
236
|
|
|
232
237
|
return {
|
|
233
238
|
instructions: devContent || '',
|
|
234
239
|
input,
|
|
235
240
|
toolSchemas: isContextOnly ? [] : toolSchemas,
|
|
236
241
|
toolChoice: isContextOnly ? 'none' : this.toolChoice,
|
|
237
|
-
modelConfig
|
|
242
|
+
modelConfig,
|
|
238
243
|
assistant: isContextOnly ? null : assistant,
|
|
239
244
|
phiProcessor: this.phiProcessor,
|
|
240
245
|
promptVariables,
|
|
@@ -145,6 +145,14 @@ const interruptionResolutionSchema = new mongoose.Schema({
|
|
|
145
145
|
notes: { type: String, required: true },
|
|
146
146
|
}, { timestamps: { createdAt: true, updatedAt: false } });
|
|
147
147
|
|
|
148
|
+
const urgencyReclassificationSchema = new mongoose.Schema({
|
|
149
|
+
fromLevel: { type: String, enum: URGENCY_LEVELS, required: true },
|
|
150
|
+
toLevel: { type: String, enum: URGENCY_LEVELS, required: true },
|
|
151
|
+
reason: { type: String, required: true },
|
|
152
|
+
reclassifiedBy: { type: String, required: true },
|
|
153
|
+
at: { type: Date, required: true },
|
|
154
|
+
}, { _id: false });
|
|
155
|
+
|
|
148
156
|
const identityBlockSchema = new mongoose.Schema({
|
|
149
157
|
missing: { type: [String], default: [] },
|
|
150
158
|
blockedAt: { type: Date, required: true },
|
|
@@ -179,6 +187,7 @@ const escalationReviewSchema = new mongoose.Schema({
|
|
|
179
187
|
openedBy: { type: String, required: true },
|
|
180
188
|
assistantPausedByReview: { type: Boolean, default: false },
|
|
181
189
|
alarms: { type: [alarmSchema], default: [] },
|
|
190
|
+
urgencyReclassifications: { type: [urgencyReclassificationSchema], default: [] },
|
|
182
191
|
updates: { type: [reviewUpdateSchema], default: [] },
|
|
183
192
|
updatedSinceSeen: { type: Boolean, default: false },
|
|
184
193
|
followUpCases: { type: [String], default: undefined },
|
|
@@ -228,6 +237,7 @@ module.exports = {
|
|
|
228
237
|
INTERRUPTED_STATUSES,
|
|
229
238
|
FOLLOWUP_STATUSES,
|
|
230
239
|
RETURN_REASONS,
|
|
240
|
+
URGENCY_LEVELS,
|
|
231
241
|
URGENCY_RANK,
|
|
232
242
|
getEscalationReview,
|
|
233
243
|
escalationReviewSchema,
|
package/lib/routes/index.js
CHANGED
|
@@ -136,6 +136,7 @@ const escalationReviewRouteDefinitions = {
|
|
|
136
136
|
'POST /:id/patient-deliveries': 'sendEscalationReviewIndicationsController',
|
|
137
137
|
'POST /:id/close': 'closeEscalationReviewController',
|
|
138
138
|
'POST /:id/cancel': 'cancelEscalationReviewController',
|
|
139
|
+
'POST /:id/urgency-reclassifications': 'reclassifyEscalationReviewUrgencyController',
|
|
139
140
|
'POST /:id/case-sync': 'syncEscalationReviewCaseController'
|
|
140
141
|
};
|
|
141
142
|
|
|
@@ -312,6 +313,7 @@ const builtInControllers = {
|
|
|
312
313
|
sendEscalationReviewIndicationsController: escalationReviewController.sendEscalationReviewIndicationsController,
|
|
313
314
|
closeEscalationReviewController: escalationReviewController.closeEscalationReviewController,
|
|
314
315
|
cancelEscalationReviewController: escalationReviewController.cancelEscalationReviewController,
|
|
316
|
+
reclassifyEscalationReviewUrgencyController: escalationReviewController.reclassifyEscalationReviewUrgencyController,
|
|
315
317
|
syncEscalationReviewCaseController: escalationReviewController.syncEscalationReviewCaseController,
|
|
316
318
|
|
|
317
319
|
// Office assistant controllers
|
|
@@ -16,6 +16,7 @@ const {
|
|
|
16
16
|
INTERRUPTED_STATUSES,
|
|
17
17
|
FOLLOWUP_STATUSES,
|
|
18
18
|
RETURN_REASONS,
|
|
19
|
+
URGENCY_LEVELS,
|
|
19
20
|
URGENCY_RANK,
|
|
20
21
|
getEscalationReview,
|
|
21
22
|
} = require('../models/escalationReviewModel');
|
|
@@ -36,6 +37,7 @@ const { getPatientInformation, logPatientChange } = require('./patientService');
|
|
|
36
37
|
const { MAX_NOTE_LENGTH, getPatientTasks, openCaseIds, updatePatientTask } = require('./patientTaskService');
|
|
37
38
|
const { getEscalationApprover, listEscalationApprovers } = require('./physicianService');
|
|
38
39
|
const { switchThreadStoppedStatus } = require('./threadService');
|
|
40
|
+
const { effectiveUrgencyOf, responseDeadlineOf, responseDeadlinePolicy } = require('./reviewDeadlineService');
|
|
39
41
|
|
|
40
42
|
const AUDIT_SOURCE = 'escalation_review';
|
|
41
43
|
const RECENT_REVIEW_LIMIT = 20;
|
|
@@ -63,6 +65,9 @@ const ALERT_MARKS = { stat: '🔴', asap: '🟠' };
|
|
|
63
65
|
const DEFAULT_STAT_REALERT_MINUTES = 15;
|
|
64
66
|
const PRE_DECISION_STATUSES = ['open', 'documenting', 'draft_ready', 'handing_off', 'awaiting_doctor'];
|
|
65
67
|
const ALERT_OPERATOR = 'bot:escalationAlert';
|
|
68
|
+
const RECLASSIFIABLE_STATUSES = [...DOCUMENTING_STATUSES, 'awaiting_doctor'];
|
|
69
|
+
const SAME_URGENCY = 'The review is already at that urgency';
|
|
70
|
+
const RECLASSIFY_CHANGED = 'The review changed while you were reclassifying it; read it again before changing the urgency';
|
|
66
71
|
const DIRECT_CHAT_STATUSES = ['open', 'documenting', 'draft_ready', 'returned', 'awaiting_doctor', 'approved', 'needs_intervention'];
|
|
67
72
|
const APPROVALS = ['approved', 'edited_approved'];
|
|
68
73
|
const STALE_DRAFT = 'New information reached the review after this draft; wait for the updated draft before approving';
|
|
@@ -687,7 +692,7 @@ async function getEscalationReviewById({ id, requestedBy } = {}) {
|
|
|
687
692
|
.updateOne({ _id: review._id }, { $max: { approverViewedAt: readAt } }, { timestamps: false })
|
|
688
693
|
.catch(warnUnmarked);
|
|
689
694
|
}
|
|
690
|
-
return { review, isApprover };
|
|
695
|
+
return { review, isApprover, responseDeadline: responseDeadlineOf(review, responseDeadlinePolicy()) };
|
|
691
696
|
}
|
|
692
697
|
|
|
693
698
|
async function getReviewTimeline({ id } = {}) {
|
|
@@ -1745,6 +1750,32 @@ async function closeEscalationReview({
|
|
|
1745
1750
|
return syncCase(await resumeAssistant(review, operator, requestId), operator, requestId);
|
|
1746
1751
|
}
|
|
1747
1752
|
|
|
1753
|
+
async function reclassifyUrgency({ id, urgencyLevel, reason, reclassifiedBy, requestId = null } = {}) {
|
|
1754
|
+
const reviewId = reviewIdOf(id);
|
|
1755
|
+
const operator = requiredText(reclassifiedBy, 'reclassifiedBy');
|
|
1756
|
+
if (!URGENCY_LEVELS.includes(urgencyLevel)) throw httpError(400, `urgencyLevel must be one of: ${URGENCY_LEVELS.join(', ')}`);
|
|
1757
|
+
const why = requiredText(reason, 'reason');
|
|
1758
|
+
|
|
1759
|
+
const current = await loadReview(reviewId);
|
|
1760
|
+
const fromLevel = effectiveUrgencyOf(current)?.urgency;
|
|
1761
|
+
if (!fromLevel) throw httpError(409, STATUS_CONFLICT);
|
|
1762
|
+
if (fromLevel === urgencyLevel) throw httpError(409, SAME_URGENCY);
|
|
1763
|
+
|
|
1764
|
+
const entry = { fromLevel, toLevel: urgencyLevel, reason: why, reclassifiedBy: operator, at: new Date() };
|
|
1765
|
+
let review;
|
|
1766
|
+
try {
|
|
1767
|
+
review = await updateReview(reviewId, RECLASSIFIABLE_STATUSES, { $push: { urgencyReclassifications: entry } }, {
|
|
1768
|
+
alarms: { $size: (current.alarms ?? []).length },
|
|
1769
|
+
urgencyReclassifications: { $size: (current.urgencyReclassifications ?? []).length },
|
|
1770
|
+
});
|
|
1771
|
+
} catch (error) {
|
|
1772
|
+
if (error?.statusCode === 409 && RECLASSIFIABLE_STATUSES.includes(current.status)) throw httpError(409, RECLASSIFY_CHANGED);
|
|
1773
|
+
throw error;
|
|
1774
|
+
}
|
|
1775
|
+
await audit(review, operator, requestId, { action: 'urgency_reclassified', fromLevel, toLevel: urgencyLevel, reason: why });
|
|
1776
|
+
return { review, responseDeadline: responseDeadlineOf(review, responseDeadlinePolicy()) };
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1748
1779
|
async function cancelEscalationReview({ id, cancelledBy, reason = null, requestId = null } = {}) {
|
|
1749
1780
|
const reviewId = reviewIdOf(id);
|
|
1750
1781
|
const operator = requiredText(cancelledBy, 'cancelledBy');
|
|
@@ -1794,6 +1825,7 @@ module.exports = {
|
|
|
1794
1825
|
handOffToDoctor,
|
|
1795
1826
|
listEscalationReviews,
|
|
1796
1827
|
openEscalationReview,
|
|
1828
|
+
reclassifyUrgency,
|
|
1797
1829
|
recordEscalationAlarm,
|
|
1798
1830
|
recordDirectIndication,
|
|
1799
1831
|
recordDoctorDecision,
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
const { httpError } = require('../utils/httpErrorUtils');
|
|
2
2
|
const { FOLLOWUP_STATUSES, URGENCY_RANK, getEscalationReview } = require('../models/escalationReviewModel');
|
|
3
3
|
|
|
4
|
+
const { effectiveUrgencyOf, responseDeadlineOf, responseDeadlinePolicy } = require('./reviewDeadlineService');
|
|
5
|
+
|
|
4
6
|
const DONE_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
5
7
|
const DONE_STATUSES = ['closed', 'cancelled'];
|
|
6
8
|
const BOARD_BUCKETS = ['pending', 'followup', 'done'];
|
|
@@ -19,15 +21,9 @@ const bucketQuery = (bucket, now) => {
|
|
|
19
21
|
}
|
|
20
22
|
};
|
|
21
23
|
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
const leadingAlarm = (alarms) => alarms.reduce((lead, alarm) => (
|
|
25
|
-
!lead || rankOf(alarm) > rankOf(lead) || (rankOf(alarm) === rankOf(lead) && alarm.raisedAt > lead.raisedAt) ? alarm : lead
|
|
26
|
-
), null);
|
|
27
|
-
|
|
28
|
-
const boardRowOf = (review, bucket) => {
|
|
24
|
+
const boardRowOf = (review, bucket, deadlinePolicy) => {
|
|
29
25
|
const alarms = review.alarms || [];
|
|
30
|
-
const lead =
|
|
26
|
+
const lead = effectiveUrgencyOf(review);
|
|
31
27
|
const raised = alarms.map((alarm) => alarm.raisedAt).filter(Boolean);
|
|
32
28
|
const since = raised.length ? new Date(Math.min(...raised.map(Number))) : review.createdAt;
|
|
33
29
|
return {
|
|
@@ -35,12 +31,13 @@ const boardRowOf = (review, bucket) => {
|
|
|
35
31
|
patientCode: review.patientCode,
|
|
36
32
|
bucket,
|
|
37
33
|
status: review.status,
|
|
38
|
-
urgency: lead?.
|
|
34
|
+
urgency: lead?.urgency || null,
|
|
39
35
|
symptom: lead?.symptom || null,
|
|
40
36
|
alarmCount: alarms.length,
|
|
41
37
|
since,
|
|
42
38
|
closedAt: review.closedAt || null,
|
|
43
39
|
updatedSinceSeen: review.updatedSinceSeen === true,
|
|
40
|
+
responseDeadline: responseDeadlineOf(review, deadlinePolicy),
|
|
44
41
|
};
|
|
45
42
|
};
|
|
46
43
|
|
|
@@ -53,9 +50,11 @@ async function listReviewBoard({ bucket, now = new Date() } = {}) {
|
|
|
53
50
|
const reviews = await getEscalationReview()
|
|
54
51
|
.find(bucketQuery(bucket, now), {
|
|
55
52
|
patientCode: 1, status: 1, 'alarms.urgencyLevel': 1, 'alarms.symptom': 1, 'alarms.raisedAt': 1, createdAt: 1, closedAt: 1, updatedSinceSeen: 1,
|
|
53
|
+
'decisions.createdAt': 1, urgencyReclassifications: 1,
|
|
56
54
|
})
|
|
57
55
|
.lean();
|
|
58
|
-
const
|
|
56
|
+
const deadlinePolicy = responseDeadlinePolicy();
|
|
57
|
+
const rows = reviews.map((review) => boardRowOf(review, bucket, deadlinePolicy))
|
|
59
58
|
.sort(bucket === 'done' ? byMostRecentlyClosed : byUrgencyThenAge);
|
|
60
59
|
const seen = new Set();
|
|
61
60
|
return rows.filter((row) => !seen.has(row.patientCode) && seen.add(row.patientCode));
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const runtimeConfig = require('../config/runtimeConfig');
|
|
2
|
+
const { addWorkingMinutes } = require('../utils/dateUtils');
|
|
3
|
+
const { FOLLOWUP_STATUSES, URGENCY_RANK } = require('../models/escalationReviewModel');
|
|
4
|
+
|
|
5
|
+
const RESPONSE_MINUTES = { stat: 15, asap: 60, urgent: 240, routine: 720 };
|
|
6
|
+
const MAX_RESPONSE_MINUTES = 7 * 24 * 60;
|
|
7
|
+
const BUSINESS_HOURS_LEVELS = ['urgent', 'routine'];
|
|
8
|
+
const TEAM_TIMEZONE = 'America/Mexico_City';
|
|
9
|
+
const DEFAULT_TEAM_HOURS = { days: [1, 2, 3, 4, 5], startHour: 8, endHour: 20 };
|
|
10
|
+
|
|
11
|
+
const rankOf = (level) => URGENCY_RANK[level] || 0;
|
|
12
|
+
const timeOf = (value) => (value ? new Date(value).getTime() : NaN);
|
|
13
|
+
|
|
14
|
+
const configuredMinutes = (level) => {
|
|
15
|
+
const minutes = Number(runtimeConfig.get(`ESCALATION_REVIEW_RESPONSE_MINUTES_${level.toUpperCase()}`, RESPONSE_MINUTES[level]));
|
|
16
|
+
return Number.isFinite(minutes) && minutes > 0 && minutes <= MAX_RESPONSE_MINUTES ? minutes : RESPONSE_MINUTES[level];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function configuredTeamHours() {
|
|
20
|
+
const entries = String(runtimeConfig.get('ESCALATION_REVIEW_TEAM_DAYS', '')).split(',').map((day) => day.trim()).filter(Boolean);
|
|
21
|
+
const days = entries.map(Number);
|
|
22
|
+
const isValidDays = days.length > 0 && days.every((day) => Number.isInteger(day) && day >= 1 && day <= 7);
|
|
23
|
+
const startHour = Number(runtimeConfig.get('ESCALATION_REVIEW_TEAM_START_HOUR', DEFAULT_TEAM_HOURS.startHour));
|
|
24
|
+
const endHour = Number(runtimeConfig.get('ESCALATION_REVIEW_TEAM_END_HOUR', DEFAULT_TEAM_HOURS.endHour));
|
|
25
|
+
const isValidSpan = Number.isInteger(startHour) && Number.isInteger(endHour) && startHour >= 0 && startHour < endHour && endHour <= 24;
|
|
26
|
+
return {
|
|
27
|
+
timeZone: TEAM_TIMEZONE,
|
|
28
|
+
days: isValidDays ? [...new Set(days)] : DEFAULT_TEAM_HOURS.days,
|
|
29
|
+
startHour: isValidSpan ? startHour : DEFAULT_TEAM_HOURS.startHour,
|
|
30
|
+
endHour: isValidSpan ? endHour : DEFAULT_TEAM_HOURS.endHour,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function responseDeadlinePolicy() {
|
|
35
|
+
return {
|
|
36
|
+
responseMinutes: Object.fromEntries(Object.keys(RESPONSE_MINUTES).map((level) => [level, configuredMinutes(level)])),
|
|
37
|
+
teamHours: configuredTeamHours(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function alarmLevelsOf(review) {
|
|
42
|
+
const reclassifications = [...(review.urgencyReclassifications ?? [])].sort((a, b) => timeOf(a.at) - timeOf(b.at));
|
|
43
|
+
return (review.alarms ?? []).map((alarm) => reclassifications
|
|
44
|
+
.filter((change) => timeOf(alarm.raisedAt) <= timeOf(change.at))
|
|
45
|
+
.reduce((entry, change) => ({
|
|
46
|
+
alarm,
|
|
47
|
+
level: change.toLevel,
|
|
48
|
+
clockFrom: rankOf(change.toLevel) > rankOf(entry.level) ? change.at : entry.clockFrom,
|
|
49
|
+
}), { alarm, level: alarm.urgencyLevel, clockFrom: alarm.raisedAt }));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function effectiveUrgencyOf(review) {
|
|
53
|
+
const lead = alarmLevelsOf(review).reduce((top, entry) => (
|
|
54
|
+
!top || rankOf(entry.level) > rankOf(top.level)
|
|
55
|
+
|| (rankOf(entry.level) === rankOf(top.level) && timeOf(entry.alarm.raisedAt) > timeOf(top.alarm.raisedAt)) ? entry : top
|
|
56
|
+
), null);
|
|
57
|
+
return lead ? { urgency: lead.level, symptom: lead.alarm.symptom ?? null } : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function dueAtFor(from, level, policy) {
|
|
61
|
+
const minutes = policy.responseMinutes[level];
|
|
62
|
+
if (BUSINESS_HOURS_LEVELS.includes(level)) return addWorkingMinutes(new Date(from), minutes, policy.teamHours);
|
|
63
|
+
return new Date(timeOf(from) + minutes * 60 * 1000);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function responseDeadlineOf(review, policy) {
|
|
67
|
+
if (review.closedAt || FOLLOWUP_STATUSES.includes(review.status)) return null;
|
|
68
|
+
const decidedAt = timeOf((review.decisions ?? []).at(-1)?.createdAt);
|
|
69
|
+
const waiting = alarmLevelsOf(review)
|
|
70
|
+
.filter(({ alarm }) => Number.isNaN(decidedAt) || timeOf(alarm.raisedAt) > decidedAt)
|
|
71
|
+
.map(({ level, clockFrom }) => ({ urgency: level, dueAt: dueAtFor(clockFrom, level, policy) }))
|
|
72
|
+
.filter(({ dueAt }) => dueAt);
|
|
73
|
+
if (!waiting.length) return null;
|
|
74
|
+
const earliest = waiting.reduce((first, entry) => (entry.dueAt < first.dueAt ? entry : first));
|
|
75
|
+
return { ...earliest, isBusinessHoursOnly: BUSINESS_HOURS_LEVELS.includes(earliest.urgency) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = {
|
|
79
|
+
effectiveUrgencyOf,
|
|
80
|
+
responseDeadlineOf,
|
|
81
|
+
responseDeadlinePolicy,
|
|
82
|
+
};
|
package/lib/utils/dateUtils.js
CHANGED
|
@@ -58,7 +58,29 @@ const daysSince = (from, now = Date.now()) => {
|
|
|
58
58
|
return Number.isNaN(at) ? null : Math.max(0, Math.floor((now - at) / 86400000));
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
+
const MAX_WORKING_DAY_STEPS = 400;
|
|
62
|
+
|
|
63
|
+
const addWorkingMinutes = (start, minutes, { timeZone, days, startHour, endHour }) => {
|
|
64
|
+
let at = moment.tz(start, timeZone);
|
|
65
|
+
let remaining = minutes;
|
|
66
|
+
for (let step = 0; step < MAX_WORKING_DAY_STEPS; step += 1) {
|
|
67
|
+
const opens = at.clone().startOf('day').hour(startHour);
|
|
68
|
+
const closes = at.clone().startOf('day').hour(endHour);
|
|
69
|
+
if (!days.includes(at.isoWeekday()) || !at.isBefore(closes)) {
|
|
70
|
+
at = at.clone().add(1, 'day').startOf('day').hour(startHour);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (at.isBefore(opens)) at = opens;
|
|
74
|
+
const available = closes.diff(at, 'minutes', true);
|
|
75
|
+
if (remaining <= available) return at.add(remaining, 'minutes').toDate();
|
|
76
|
+
remaining -= available;
|
|
77
|
+
at = closes;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
};
|
|
81
|
+
|
|
61
82
|
module.exports = {
|
|
83
|
+
addWorkingMinutes,
|
|
62
84
|
getCurrentMexicoDateTime,
|
|
63
85
|
getMexicoDayLabels,
|
|
64
86
|
parseDate,
|