@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.
@@ -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
- try {
35
- const { resolvedPrompt } = await composePrompt({ promptId: NUTRITION_GUIDE_PROMPT_ID });
36
- return keepLines(resolvedPrompt || '', GUIDE_MAX_CHARS);
37
- } catch (error) {
38
- logger.warn('[nutritionPlanService] nutrition guide unavailable; plan sent without it', { error: error.message });
39
- return '';
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
- if (record) promptCache.set(cacheKey, record);
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,
@@ -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
- const input = this.provider._sanitizeInput([...memoryMessage, ...convertedMessages]);
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,
@@ -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 rankOf = (alarm) => URGENCY_RANK[alarm?.urgencyLevel] || 0;
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 = leadingAlarm(alarms);
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?.urgencyLevel || null,
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 rows = reviews.map((review) => boardRowOf(review, bucket))
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
+ };
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.67.0-dev.11849",
3
+ "version": "5.67.0-dev.11929",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",