@peopl-health/nexus 5.67.0-dev.11849 → 5.67.0-dev.11929
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/BaseLLMProvider.js +14 -3
- package/lib/clinical/services/nutritionPlanService.js +40 -7
- package/lib/clinical/services/promptComposerService.js +12 -3
- package/lib/controllers/escalationReviewController.js +20 -2
- package/lib/eval/EvalProvider.js +14 -4
- 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
|
@@ -125,13 +125,13 @@ class BaseLLMProvider {
|
|
|
125
125
|
assistant.toolRuntimeContext.enabledSkills = enabledSkills;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
const resolvedSchemas = this._resolveToolSchemas({
|
|
128
|
+
const resolvedSchemas = this._scopeOutreachDecisionTool(this._resolveToolSchemas({
|
|
129
129
|
assistant, toolIds: resolvedToolIds, filtered, toolDescriptions,
|
|
130
|
-
});
|
|
130
|
+
}), isOutreach);
|
|
131
131
|
const { toolChoice: flooredToolChoice, toolSchemas, narrowed } = this._applyDeliveryFloor(
|
|
132
132
|
toolChoice, resolvedSchemas, Boolean(assistant?.toolRuntimeContext), turnKind);
|
|
133
133
|
const effectiveToolIds = toolSchemas.map(s => s.function?.name).filter(Boolean);
|
|
134
|
-
const instructionToolIds = narrowed ? effectiveToolIds : resolvedToolIds;
|
|
134
|
+
const instructionToolIds = narrowed || isOutreach ? effectiveToolIds : resolvedToolIds;
|
|
135
135
|
const requestedToolChoice = prePromptResult?.toolChoice;
|
|
136
136
|
const requestedToolName = requestedToolChoice?.name || requestedToolChoice?.function?.name || null;
|
|
137
137
|
const supportedGenericChoices = new Set(['auto', 'none', 'required', 'any']);
|
|
@@ -234,6 +234,17 @@ class BaseLLMProvider {
|
|
|
234
234
|
};
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
// The decision tool belongs to the turn kind, not to a preset: without it an outreach turn cannot
|
|
238
|
+
// close and the brief is dropped, and on any other turn it would invite a decision nobody asked for.
|
|
239
|
+
_scopeOutreachDecisionTool(schemas, isOutreach) {
|
|
240
|
+
const others = schemas.filter(s => s.function?.name !== OUTREACH_DECISION_TOOL);
|
|
241
|
+
if (!isOutreach) return others;
|
|
242
|
+
const decision = schemas.find(s => s.function?.name === OUTREACH_DECISION_TOOL)
|
|
243
|
+
|| getRegistrySchemas([OUTREACH_DECISION_TOOL])[0];
|
|
244
|
+
if (!decision) throw new Error('OUTREACH_DECISION_TOOL_UNAVAILABLE');
|
|
245
|
+
return [...others, decision];
|
|
246
|
+
}
|
|
247
|
+
|
|
237
248
|
_resolveToolSchemas({ assistant, toolIds, filtered, toolDescriptions }) {
|
|
238
249
|
const assistantSchemas = assistant?.getToolSchemas?.() || [];
|
|
239
250
|
const registrySchemas = getRegistrySchemas();
|
|
@@ -7,6 +7,7 @@ const NUTRITION_GUIDE_PROMPT_ID = 'nutrition_plan_guide';
|
|
|
7
7
|
const PLAN_MAX_CHARS = 1500;
|
|
8
8
|
const GUIDE_MAX_CHARS = 2000;
|
|
9
9
|
const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/;
|
|
10
|
+
const GUIDE_TIMEOUT_MS = 3000;
|
|
10
11
|
|
|
11
12
|
// Each line of a plan or guide is one kind of indication, so the breaks survive and only each line is flattened.
|
|
12
13
|
function keepLines(text, maxChars) {
|
|
@@ -30,14 +31,44 @@ function readPlanSection(section) {
|
|
|
30
31
|
return { text: typeof text === 'string' ? text : '', reviewBy };
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
let lastGuide = '';
|
|
35
|
+
let guideRead = null;
|
|
36
|
+
let readSeq = 0;
|
|
37
|
+
let appliedSeq = 0;
|
|
38
|
+
|
|
39
|
+
function readGuide() {
|
|
40
|
+
if (guideRead) return guideRead;
|
|
41
|
+
const seq = ++readSeq;
|
|
42
|
+
const read = composePrompt({ promptId: NUTRITION_GUIDE_PROMPT_ID })
|
|
43
|
+
.then(({ resolvedPrompt }) => {
|
|
44
|
+
// A read that hung past a newer one must not overwrite what the newer one found.
|
|
45
|
+
if (seq > appliedSeq) {
|
|
46
|
+
appliedSeq = seq;
|
|
47
|
+
lastGuide = keepLines(resolvedPrompt || '', GUIDE_MAX_CHARS);
|
|
48
|
+
}
|
|
49
|
+
return lastGuide;
|
|
50
|
+
})
|
|
51
|
+
.catch((error) => {
|
|
52
|
+
logger.warn('[nutritionPlanService] nutrition guide unreadable; using the last one read', { error: error.message });
|
|
53
|
+
return lastGuide;
|
|
54
|
+
})
|
|
55
|
+
.finally(() => { if (guideRead === read) guideRead = null; });
|
|
56
|
+
guideRead = read;
|
|
57
|
+
return read;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The guide only adds to the plan, so a slow read must not hold the patient's turn,
|
|
61
|
+
// and a read that never settles must not hold every later turn either.
|
|
33
62
|
async function loadNutritionGuide() {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
63
|
+
let timer;
|
|
64
|
+
const read = readGuide();
|
|
65
|
+
const timedOut = new Promise((resolve) => { timer = setTimeout(() => resolve(null), GUIDE_TIMEOUT_MS); });
|
|
66
|
+
const guide = await Promise.race([read, timedOut]);
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
if (guide !== null) return guide;
|
|
69
|
+
if (guideRead === read) guideRead = null;
|
|
70
|
+
logger.warn('[nutritionPlanService] nutrition guide read timed out; using the last one read', { timeoutMs: GUIDE_TIMEOUT_MS });
|
|
71
|
+
return lastGuide;
|
|
41
72
|
}
|
|
42
73
|
|
|
43
74
|
async function resolveNutritionPlan(section, { now = new Date() } = {}) {
|
|
@@ -55,5 +86,7 @@ async function resolveNutritionPlan(section, { now = new Date() } = {}) {
|
|
|
55
86
|
|
|
56
87
|
module.exports = {
|
|
57
88
|
NUTRITION_GUIDE_PROMPT_ID,
|
|
89
|
+
GUIDE_TIMEOUT_MS,
|
|
58
90
|
resolveNutritionPlan,
|
|
91
|
+
_test: { reset: () => { lastGuide = ''; guideRead = null; readSeq = 0; appliedSeq = 0; } },
|
|
59
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.
|
|
@@ -75,7 +76,7 @@ class EvalProvider {
|
|
|
75
76
|
const { messages, promptVariables, lastUserMessage, contextSource } = await this._buildContext(numero, beforeCheckpoint);
|
|
76
77
|
|
|
77
78
|
const {
|
|
78
|
-
devContent, assistant, toolSchemas, resolvedPromptId, presetModelConfig, resolvedPresetId,
|
|
79
|
+
devContent, assistant, toolSchemas, resolvedPromptId, presetModelConfig, resolvedPresetId, interpolatedVariables,
|
|
79
80
|
} = await this._resolvePrompt(prompt, assistantId, thread, promptVariables);
|
|
80
81
|
|
|
81
82
|
const request = this._buildRequest({
|
|
@@ -83,6 +84,7 @@ class EvalProvider {
|
|
|
83
84
|
assistantId: resolvedPromptId || assistantId,
|
|
84
85
|
presetId: resolvedPresetId,
|
|
85
86
|
presetModelConfig,
|
|
87
|
+
interpolatedVariables,
|
|
86
88
|
});
|
|
87
89
|
|
|
88
90
|
const startTime = Date.now();
|
|
@@ -133,6 +135,7 @@ class EvalProvider {
|
|
|
133
135
|
|
|
134
136
|
async _resolvePrompt(fallbackPrompt, assistantId, thread, promptVariables) {
|
|
135
137
|
let devContent;
|
|
138
|
+
let interpolatedVariables = [];
|
|
136
139
|
let resolvedPromptId = assistantId;
|
|
137
140
|
let presetToolIds = null;
|
|
138
141
|
let presetModelConfig = null;
|
|
@@ -143,18 +146,21 @@ class EvalProvider {
|
|
|
143
146
|
if (this.promptSource === 'airtable' && (presetId || assistantId)) {
|
|
144
147
|
const {
|
|
145
148
|
resolvedPrompt, promptId, presetToolIds: toolIds, modelConfig, presetId: composedPresetId,
|
|
149
|
+
interpolatedVariables: interpolated,
|
|
146
150
|
} = await composePrompt({
|
|
147
151
|
presetId,
|
|
148
152
|
promptId: assistantId,
|
|
149
153
|
variables: promptVariables,
|
|
150
154
|
});
|
|
151
155
|
devContent = resolvedPrompt;
|
|
156
|
+
interpolatedVariables = interpolated || [];
|
|
152
157
|
resolvedPromptId = promptId || assistantId;
|
|
153
158
|
presetToolIds = toolIds;
|
|
154
159
|
presetModelConfig = modelConfig || null;
|
|
155
160
|
resolvedPresetId = composedPresetId || resolvedPresetId;
|
|
156
161
|
} else {
|
|
157
162
|
devContent = fallbackPrompt;
|
|
163
|
+
interpolatedVariables = variablesReferencedIn(devContent);
|
|
158
164
|
devContent = devContent.replace(/\{\{(\w+)\}\}/g, (_, key) => promptVariables[key] ?? '');
|
|
159
165
|
}
|
|
160
166
|
|
|
@@ -191,7 +197,7 @@ class EvalProvider {
|
|
|
191
197
|
}
|
|
192
198
|
}
|
|
193
199
|
|
|
194
|
-
return { devContent, assistant, toolSchemas, resolvedPromptId, presetModelConfig, resolvedPresetId };
|
|
200
|
+
return { devContent, assistant, toolSchemas, resolvedPromptId, presetModelConfig, resolvedPresetId, interpolatedVariables };
|
|
195
201
|
}
|
|
196
202
|
|
|
197
203
|
_modelConfig(presetModelConfig) {
|
|
@@ -204,7 +210,7 @@ class EvalProvider {
|
|
|
204
210
|
|
|
205
211
|
_buildRequest({
|
|
206
212
|
devContent, messages, promptVariables, toolSchemas, assistant, assistantId, presetId,
|
|
207
|
-
presetModelConfig = null,
|
|
213
|
+
presetModelConfig = null, interpolatedVariables = [],
|
|
208
214
|
}) {
|
|
209
215
|
const convertedMessages = this.provider._convertItemsToApiFormat(messages);
|
|
210
216
|
|
|
@@ -215,7 +221,11 @@ class EvalProvider {
|
|
|
215
221
|
? [{ role: 'developer', content: `Usa la siguiente información que conoces del paciente para personalizar tu respuesta. Adapta tu tono, contenido o recomendaciones según estas preferencias y contexto cuando sea relevante. No menciones que tienes esta información almacenada.\n\n${memoryParts.join('\n\n')}` }]
|
|
216
222
|
: [];
|
|
217
223
|
|
|
218
|
-
|
|
224
|
+
// The same runtime clinical block a live turn gets (context, symptoms, nutrition plan), unless the prompt interpolates it.
|
|
225
|
+
const clinicalMessage = typeof this.provider._buildClinicalMessage === 'function'
|
|
226
|
+
? this.provider._buildClinicalMessage(promptVariables, interpolatedVariables)
|
|
227
|
+
: [];
|
|
228
|
+
const input = this.provider._sanitizeInput([...memoryMessage, ...clinicalMessage, ...convertedMessages]);
|
|
219
229
|
const isContextOnly = this.mode === 'context-only';
|
|
220
230
|
|
|
221
231
|
const shouldUsePromptRegistry = !presetId && this.provider.supportsPromptRegistry && !!assistantId;
|
|
@@ -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,
|