@peopl-health/nexus 5.68.2 → 5.68.4

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.
@@ -1,12 +1,18 @@
1
- const { getCurrentMexicoDateTime, getMexicoDayLabels } = require('../../utils/dateUtils');
1
+ const { getCurrentMexicoDateTime, getMexicoDayLabels, getMexicoUpcomingDayLabels } = require('../../utils/dateUtils');
2
2
 
3
3
  const ANCHOR_SPAN_DAYS = 7;
4
+ const UPCOMING_SPAN_DAYS = 14;
4
5
 
5
6
  function buildDateAnchor() {
6
7
  const at = Date.now();
7
8
  const labels = getMexicoDayLabels({ days: ANCHOR_SPAN_DAYS, at });
8
9
  const week = labels.map((label, i) => (i === labels.length - 1 ? `${label} (hoy)` : label));
9
- return `${getCurrentMexicoDateTime(at)}\nLos últimos siete días: ${week.join('; ')}.`;
10
+ const upcoming = getMexicoUpcomingDayLabels({ days: UPCOMING_SPAN_DAYS, at });
11
+ return [
12
+ getCurrentMexicoDateTime(at),
13
+ `Los últimos siete días: ${week.join('; ')}.`,
14
+ `Los próximos días: ${upcoming.join('; ')}.`,
15
+ ].join('\n');
10
16
  }
11
17
 
12
18
  module.exports = { buildDateAnchor };
@@ -1,3 +1,5 @@
1
+ const moment = require('moment-timezone');
2
+
1
3
  const { DefaultMemoryManager } = require('../memory/DefaultMemoryManager');
2
4
  const { REFUSAL, isPurelyConversational } = require('../helpers/clinicalWorkflowGuard');
3
5
  const { isToolFailure, toolRecoveryContract } = require('../helpers/toolResultHelper');
@@ -14,6 +16,7 @@ const runtimeConfig = require('../../config/runtimeConfig');
14
16
  const { TAIL_PLACEMENT, CURRENT_TURN } = require('../promptPlacement');
15
17
  const { logger } = require('../../utils/logger');
16
18
  const { sanitizeInlineText } = require('../../utils/sanitizerUtils');
19
+ const { DEFAULT_TIMEZONE } = require('../../utils/scheduleUtils');
17
20
  const { isJsonBlob } = require('../../utils/formatUtils');
18
21
  const { isPlainObject, safeParse } = require('../../utils/jsonUtils');
19
22
  const { MAX_ADDITIONAL_TOOL_IDS } = require('../../core/runtimeToolPolicy');
@@ -33,6 +36,11 @@ const outreachMinute = (value) => {
33
36
  return match ? `${match[1]}T${match[2]}` : null;
34
37
  };
35
38
 
39
+ const localFireTime = (value) => {
40
+ const at = moment.tz(value, DEFAULT_TIMEZONE);
41
+ return value && at.isValid() ? `${at.format('YYYY-MM-DD HH:mm')} (hora de la paciente)` : sanitizeInlineText(value);
42
+ };
43
+
36
44
  const NEGATIVE_GUIDANCE_MARKER = '**When NOT to call';
37
45
  const CONTINGENCY_RULE = [
38
46
  '[REGLA DE CONTINGENCIA (prioritaria) para este turno]',
@@ -439,7 +447,7 @@ class BaseLLMProvider {
439
447
  _buildFollowUpMessage(promptVariables) {
440
448
  const pending = Array.isArray(promptVariables?.active_follow_ups) ? promptVariables.active_follow_ups : [];
441
449
  if (!pending.length) return [];
442
- const rows = pending.map((item) => `- ${sanitizeInlineText(item.reminderId)} a las ${sanitizeInlineText(item.fireAt)}: ${sanitizeInlineText(item.message)}`);
450
+ const rows = pending.map((item) => `- ${sanitizeInlineText(item.reminderId)} a las ${localFireTime(item.fireAt)}: ${sanitizeInlineText(item.message, 600)}`);
443
451
  return [{
444
452
  role: 'developer',
445
453
  content: [
@@ -5,6 +5,7 @@ const { Symptoms_ID } = require('../../config/airtableConfig');
5
5
  const { getMessages } = require('../../services/messageService');
6
6
  const { DEFAULT_TIMEZONE, isWithinHourWindow } = require('../../utils/scheduleUtils');
7
7
  const { logger } = require('../../utils/logger');
8
+ const { botIsPaused } = require('../../helpers/pausedThreadHelper');
8
9
  const { PATIENT_CONTACT_WINDOW } = require('./contingencyDispatchService');
9
10
  const { hasPendingReminderForPatient } = require('./clinicalAirtableService');
10
11
 
@@ -48,6 +49,16 @@ const AUDIT = {
48
49
  turnId: 'seguimiento_turn_id',
49
50
  };
50
51
 
52
+ const UNDELIVERED_STATUSES = ['failed', 'undelivered'];
53
+ const CONTACT_SENT = {
54
+ from_me: true,
55
+ origin: { $nin: ['instruction', 'system'] },
56
+ $or: [
57
+ { 'statusInfo.status': { $nin: UNDELIVERED_STATUSES } },
58
+ { 'statusInfo.recoveryTemplateSid': { $ne: null }, 'statusInfo.recoverySentAt': null },
59
+ ],
60
+ };
61
+
51
62
  const RECENT_OUTBOUND_HOURS = 24;
52
63
  const COLLIDING_REMINDER_MINUTES = 24 * 60;
53
64
  const INACTIVITY_DAYS = 7;
@@ -226,6 +237,7 @@ async function rejectionReason(candidate, {
226
237
  } = {}) {
227
238
  const opsReason = opsOptedOut(candidate, { now });
228
239
  if (opsReason) return opsReason;
240
+ if (await botIsPaused(candidate.code, { label: '[outreachSweep]' })) return 'bot_paused';
229
241
  if (!includePreviouslySwept) {
230
242
  const sweptReason = priorSweepReason(candidate);
231
243
  if (sweptReason) return sweptReason;
@@ -236,7 +248,7 @@ async function rejectionReason(candidate, {
236
248
  const recentOutbound = await getMessages(
237
249
  {
238
250
  numero: candidate.code,
239
- from_me: true,
251
+ ...CONTACT_SENT,
240
252
  createdAt: { $gt: since },
241
253
  ...(mode === 'monday_triage_recovery' && candidate.triageContentSid
242
254
  ? { content_sid: { $ne: candidate.triageContentSid } }
@@ -271,7 +283,7 @@ async function rejectionReason(candidate, {
271
283
  const recentOutreach = await getMessages(
272
284
  {
273
285
  numero: candidate.code,
274
- from_me: true,
286
+ ...CONTACT_SENT,
275
287
  triggeredBy: { $regex: '^(?:seguimiento:|followup_)' },
276
288
  createdAt: { $gte: inactiveSince },
277
289
  },
@@ -283,7 +295,7 @@ async function rejectionReason(candidate, {
283
295
  const lastTwoOutreaches = await getMessages(
284
296
  {
285
297
  numero: candidate.code,
286
- from_me: true,
298
+ ...CONTACT_SENT,
287
299
  triggeredBy: { $regex: '^(?:seguimiento:|followup_)' },
288
300
  createdAt: { $gte: unansweredSince },
289
301
  },
@@ -320,7 +332,7 @@ async function routineOutreachCollisionReason(code, { now = new Date() } = {}) {
320
332
  { sort: { createdAt: -1 }, limit: 1 },
321
333
  ),
322
334
  getMessages(
323
- { numero: code, from_me: true, createdAt: { $gte: today } },
335
+ { numero: code, ...CONTACT_SENT, createdAt: { $gte: today } },
324
336
  { sort: { createdAt: -1 }, limit: 1 },
325
337
  ),
326
338
  ]);
@@ -331,7 +343,7 @@ async function routineOutreachCollisionReason(code, { now = new Date() } = {}) {
331
343
 
332
344
  async function enrichBrief(candidate) {
333
345
  const [lastInbound] = await getMessages({ numero: candidate.code, from_me: false }, { sort: { createdAt: -1 }, limit: 1 });
334
- const [lastOutbound] = await getMessages({ numero: candidate.code, from_me: true }, { sort: { createdAt: -1 }, limit: 1 });
346
+ const [lastOutbound] = await getMessages({ numero: candidate.code, ...CONTACT_SENT }, { sort: { createdAt: -1 }, limit: 1 });
335
347
  const fmt = (doc) => (doc?.createdAt ? moment.tz(doc.createdAt, DEFAULT_TIMEZONE).format('YYYY-MM-DD HH:mm') : null);
336
348
  return {
337
349
  ...candidate.brief,
@@ -219,9 +219,9 @@ async function applyChoice(code, choice) {
219
219
  const queued = await fileUrgentRequest(code, choice);
220
220
  if (!queued) return { queued, pledge: null };
221
221
 
222
- await cancelPendingFollowUps({ code, reason: 'safety_net_request' }).catch((error) =>
223
- logger.error('[PausedSafetyNet] Pending follow-ups were not stood down', { code, error: error.message })
224
- );
222
+ if (!(await cancelPendingFollowUps({ code, reason: 'safety_net_request' }))) {
223
+ logger.error('[PausedSafetyNet] Pending follow-ups were not stood down', { code });
224
+ }
225
225
 
226
226
  try {
227
227
  return { queued, pledge: await openPledge({ code }) };
@@ -6,11 +6,12 @@ const { recordReminder, readPendingFollowUpsForPatient, cancelFollowUp } = requi
6
6
  const { isWithinTriageProtectionWindow } = require('../services/outreachSweepService');
7
7
  const { UTILITY_BASES, sanitizeVariable } = require('../services/outreachCarrierService');
8
8
  const { logger } = require('../../utils/logger');
9
- const { DEFAULT_TIMEZONE } = require('../../utils/scheduleUtils');
9
+ const { DEFAULT_TIMEZONE, isWithinHourWindow } = require('../../utils/scheduleUtils');
10
+ const { botIsPaused } = require('../../helpers/pausedThreadHelper');
10
11
 
11
12
  const definition = {
12
13
  name: 'schedulePatientReminder',
13
- description: '**Does:** Creates or updates one agent-authored WhatsApp reminder. An ordinary reminder (medication, appointment, exam, or other task) is allowed only when the patient explicitly asks for or explicitly accepts that reminder. Do not infer a reminder merely because the patient mentioned or scheduled something. Schedule it at the exact date and time the patient requested. Never move it earlier to precede an appointment or task unless the patient explicitly requested that offset. On any patient conversation—not only a triage—or on a proactive review, use reminder_type seguimiento when this turn decides both when to check in again and the exact patient-facing words. Schedule one whenever a later contact materially helps continuity and has a concrete utility_basis — typically: you recommended an action (OTC medication, self-care, education) whose effect shows within 24–72h; the patient already gave the deciding datum and you want to know whether the symptom comes back or crosses a threshold you named; you closed a symptom episode worth checking on; or the patient has an upcoming event (starting a treatment, an appointment, an infusion) whose outcome matters. Inactivity alone is not a basis. An open contingency net does not prevent one: the net awaits its datum, the seguimiento asks about what was indicated (e.g. whether a visit or test was booked).\\n\\nA seguimiento is a lightweight engagement plan: it is capped at 600 characters, never truncated, fires at most 48 hours ahead, expires 24 hours after its chosen fire time, and cannot be created while another seguimiento is active. It stays pending when the patient writes again: later turns list it under «Seguimientos ya programados» so you keep it, update it, or cancel it (reminder_action_type cancel) when the conversation already answered it.\\n\\n**When NOT to call:** when the reminder guards unresolved clinical uncertainty, use setContingencyPlan instead; that plan owns its silence window and lifecycle. Never update a contingency-owned `plan_…-patient` reminder here.\\n\\nRequired: future patient-local reminder_date, reminder_description, reminder_type, action type, reminder_id for updates, and utility_basis for seguimiento.',
14
+ description: '**Does:** Creates or updates one agent-authored WhatsApp reminder. An ordinary reminder (medication, appointment, exam, or other task) is allowed only when the patient explicitly asks for or explicitly accepts that reminder. Do not infer a reminder merely because the patient mentioned or scheduled something. Schedule it at the exact date and time the patient requested. Never move it earlier to precede an appointment or task unless the patient explicitly requested that offset. On any patient conversation—not only a triage—or on a proactive review, use reminder_type seguimiento when this turn decides both when to check in again and the exact patient-facing words. Schedule one whenever a later contact materially helps continuity and has a concrete utility_basis — typically: you recommended an action (OTC medication, self-care, education) whose effect shows within 24–72h; the patient already gave the deciding datum and you want to know whether the symptom comes back or crosses a threshold you named; you closed a symptom episode worth checking on; or the patient has an upcoming event (starting a treatment, an appointment, an infusion) whose outcome matters. Inactivity alone is not a basis. An open contingency net does not prevent one: the net awaits its datum, the seguimiento asks about what was indicated (e.g. whether a visit or test was booked).\\n\\nA seguimiento is a lightweight engagement plan: it is capped at 600 characters, never truncated, fires at most 48 hours ahead and only between 11:00 and 21:00 patient-local time, expires 24 hours after its chosen fire time, and cannot be created while another seguimiento is active. It stays pending when the patient writes again: later turns list it under «Seguimientos ya programados» so you keep it, update it, or cancel it (reminder_action_type cancel) when the conversation already answered it.\\n\\n**When NOT to call:** when the reminder guards unresolved clinical uncertainty, use setContingencyPlan instead; that plan owns its silence window and lifecycle. Never update a contingency-owned `plan_…-patient` reminder here.\\n\\nRequired: future patient-local reminder_date, reminder_description, reminder_type, action type, reminder_id for updates, and utility_basis for seguimiento.',
14
15
  strict: true,
15
16
  parameters: {
16
17
  type: 'object',
@@ -52,6 +53,9 @@ const REMINDER_TYPES = definition.parameters.properties.reminder_type.enum;
52
53
  const ACTION_TYPES = definition.parameters.properties.reminder_action_type.enum;
53
54
 
54
55
  const MAX_FOLLOW_UP_LEAD_HOURS = 48;
56
+ const ROUTINE_OUTREACH_START_HOUR = 11;
57
+ const ROUTINE_OUTREACH_END_HOUR = 21;
58
+ const PAUSED_ERROR = 'thread_paused — do not schedule a seguimiento in this turn.';
55
59
 
56
60
  const ISO_DATETIME = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?$/;
57
61
  const FIRE_AT_FORMATS = ['YYYY-MM-DDTHH:mm:ss', 'YYYY-MM-DDTHH:mm', 'YYYY-MM-DD HH:mm:ss', 'YYYY-MM-DD HH:mm'];
@@ -128,7 +132,13 @@ async function handler(args = {}, context = {}) {
128
132
  if (isRoutineOutreach && isWithinTriageProtectionWindow(fireMoment.toDate())) {
129
133
  return JSON.stringify({ success: false, error: 'routine outreach cannot be scheduled during the 07:30-11:00 triage protection window; choose 11:00 or later.', data: {} });
130
134
  }
135
+ if (isRoutineOutreach && !isWithinHourWindow(fireMoment.toDate(), { timeZone, startHour: ROUTINE_OUTREACH_START_HOUR, endHour: ROUTINE_OUTREACH_END_HOUR })) {
136
+ return JSON.stringify({ success: false, error: `routine outreach must go out between ${ROUTINE_OUTREACH_START_HOUR}:00 and ${ROUTINE_OUTREACH_END_HOUR}:00 patient time; choose a time in that range.`, data: {} });
137
+ }
131
138
  if (isRoutineOutreach && actionType === 'create' && runtime.patientCode) {
139
+ if (await botIsPaused(runtime.patientCode, { label: '[schedulePatientReminder]' })) {
140
+ return JSON.stringify({ success: false, error: PAUSED_ERROR, data: {} });
141
+ }
132
142
  const pending = await readPendingFollowUpsForPatient({ code: runtime.patientCode });
133
143
  if (pending.length) {
134
144
  return JSON.stringify({ success: false, error: 'active_follow_up_exists — update the existing plan instead of crossing it.', data: { reminder_id: pending[0].reminderId } });
@@ -182,6 +192,12 @@ async function handler(args = {}, context = {}) {
182
192
  if (!persisted) {
183
193
  return JSON.stringify({ success: false, error: 'reminder persistence failed; retry scheduling', data: {} });
184
194
  }
195
+ if (isRoutineOutreach && actionType === 'create' && await botIsPaused(runtime.patientCode, { label: '[schedulePatientReminder]' })) {
196
+ if (!await cancelFollowUp({ code: runtime.patientCode, reminderId, trace, reason: 'thread_paused' })) {
197
+ logger.error('[schedulePatientReminder] seguimiento created on a paused thread was not stood down', { reminderId });
198
+ }
199
+ return JSON.stringify({ success: false, error: PAUSED_ERROR, data: {} });
200
+ }
185
201
  }
186
202
 
187
203
  trace.setSignals({ reminderIntents: [...existing, intent] });
@@ -9,6 +9,7 @@ const { getThreadInfo, setThreadDoNotAlert, switchThreadStoppedStatus } = requir
9
9
  const { getRecordByFilter } = require('../services/airtableService');
10
10
  const { sendMessage, processInstruction, processSystemMessage } = require('../core/NexusMessaging');
11
11
  const { createAssistant, switchAssistant } = require('../clinical');
12
+ const { cancelPendingFollowUps } = require('../clinical/services/clinicalAirtableService');
12
13
 
13
14
  const asBoolean = (value) => (typeof value === 'string' ? value === 'true' : !!value);
14
15
 
@@ -19,6 +20,9 @@ const stopAssistantController = async (req, res) => {
19
20
  const stopped = asBoolean(stop);
20
21
  try {
21
22
  await switchThreadStoppedStatus(code, stopped, { byOperator: true });
23
+ if (stopped && !(await cancelPendingFollowUps({ code, reason: 'thread_paused' }))) {
24
+ logger.error('[AssistantController] Pending follow-ups were not stood down on pause', { code });
25
+ }
22
26
  return res.status(200).json({ success: true, message: stopped ? 'Assistant stopped' : 'Assistant resumed' });
23
27
  } catch (error) {
24
28
  logger.error('[AssistantController] Failed to stop assistant', { error: error.message, code });
@@ -20,6 +20,7 @@ const { isWithin24HourWindow } = require('../helpers/conversationWindowHelper');
20
20
  const { maskCaregiverLabel, remapCaregiverSender, replyContactFor } = require('../helpers/caregiverHelper');
21
21
  const { triggerTemplateRecovery } = require('../helpers/templateRecoveryHelper');
22
22
  const { prepareMessageMedia } = require('../helpers/mediaHelper');
23
+ const { botIsPaused } = require('../helpers/pausedThreadHelper');
23
24
  const { acquireSendClaim, completeSendClaim, releaseSendClaim } = require('../helpers/sendClaimHelper');
24
25
 
25
26
  const { createMessagingProvider } = require('../adapters/registry');
@@ -490,6 +491,10 @@ class NexusMessaging {
490
491
  logger.info('[NexusMessaging] proactive send cancelled — patient became active', { code: messageData.code });
491
492
  return { success: false, messageId: null, status: 'patient_active', delivered: false, deferred: false };
492
493
  }
494
+ if (await botIsPaused(messageData.code, { label: '[NexusMessaging]' })) {
495
+ logger.info('[NexusMessaging] proactive send cancelled — bot paused by the team', { code: messageData.code });
496
+ return { success: false, messageId: null, status: 'bot_paused', delivered: false, deferred: false };
497
+ }
493
498
  }
494
499
  if (!this.provider) throw new Error('No provider initialized');
495
500
 
@@ -1027,6 +1032,9 @@ class NexusMessaging {
1027
1032
  if (delivery?.status === 'patient_active') {
1028
1033
  return { decision: 'skipped', rationale: 'la paciente escribió durante la decisión', holdReason: 'patient_active', nextCheckAt: null, draft: null, accepted: false, delivered: false, deferred: false, deliveryStatus: 'patient_active', turnId: runResult.turnId || null, traceId: runResult.traceId || null };
1029
1034
  }
1035
+ if (delivery?.status === 'bot_paused') {
1036
+ return { decision: 'skipped', rationale: 'el equipo pausó el bot durante la decisión', holdReason: 'bot_paused', nextCheckAt: null, draft: null, accepted: false, delivered: false, deferred: false, deliveryStatus: 'bot_paused', turnId: runResult.turnId || null, traceId: runResult.traceId || null };
1037
+ }
1030
1038
  const accepted = Boolean(runResult.output) && delivery?.success === true && delivery?.deferred !== true;
1031
1039
  const delivered = ['delivered', 'read'].includes(String(delivery?.status || '').toLowerCase());
1032
1040
  if (!dryRun && (decision.decision === 'write') !== accepted) {
@@ -1050,18 +1058,18 @@ class NexusMessaging {
1050
1058
  };
1051
1059
  }
1052
1060
 
1053
- /*
1054
- * Delivers a message the agent previously authored and scheduled with schedulePatientReminder.
1055
- * The clock only wakes this method; it does not choose copy or timing. Running inside the same
1056
- * patient queue as inbound WhatsApp traffic prevents an already-received message from crossing
1057
- * the follow-up.
1058
- */
1059
1061
  async _retireSkippedFollowUp(code, reminderId, holdReason) {
1060
1062
  const retired = await cancelFollowUp({ code, reminderId, reason: `skipped_${holdReason}` });
1061
1063
  if (!retired) logger.warn('[processScheduledFollowUp] skipped follow-up was not stood down', { reminderId, holdReason });
1062
1064
  return { decision: 'skipped', holdReason, accepted: false, delivered: false, retired };
1063
1065
  }
1064
1066
 
1067
+ /*
1068
+ * Delivers a message the agent previously authored and scheduled with schedulePatientReminder.
1069
+ * The clock only wakes this method; it does not choose copy or timing. Running inside the same
1070
+ * patient queue as inbound WhatsApp traffic prevents an already-received message from crossing
1071
+ * the follow-up.
1072
+ */
1065
1073
  async processScheduledFollowUp(code, { reminderId, firstName, now = new Date() } = {}) {
1066
1074
  if (!code || !reminderId) throw new Error('code and reminderId are required');
1067
1075
  if (typeof firstName !== 'string' || !firstName.trim()) throw new Error('firstName is required');
@@ -6,13 +6,20 @@ const nowInMexico = (at) => moment(at).tz(MEXICO_TZ).locale('es');
6
6
 
7
7
  const getCurrentMexicoDateTime = (at) => nowInMexico(at).format(MEXICO_STAMP);
8
8
 
9
+ const dayLabel = (day, now) => (day.year() === now.year() ? day.format('dddd D [de] MMMM') : day.format('dddd D [de] MMMM [de] YYYY'));
10
+
9
11
  const getMexicoDayLabels = ({ days, at }) => {
10
12
  const now = nowInMexico(at);
11
13
  const labels = [];
12
- for (let back = days - 1; back >= 0; back -= 1) {
13
- const at = now.clone().subtract(back, 'days');
14
- labels.push(at.year() === now.year() ? at.format('dddd D [de] MMMM') : at.format('dddd D [de] MMMM [de] YYYY'));
15
- }
14
+ for (let back = days - 1; back >= 0; back -= 1) labels.push(dayLabel(now.clone().subtract(back, 'days'), now));
15
+ return labels;
16
+ };
17
+
18
+ // The days after today, nearest first; today itself is not included.
19
+ const getMexicoUpcomingDayLabels = ({ days, at }) => {
20
+ const now = nowInMexico(at);
21
+ const labels = [];
22
+ for (let ahead = 1; ahead <= days; ahead += 1) labels.push(dayLabel(now.clone().add(ahead, 'days'), now));
16
23
  return labels;
17
24
  };
18
25
 
@@ -83,6 +90,7 @@ module.exports = {
83
90
  addWorkingMinutes,
84
91
  getCurrentMexicoDateTime,
85
92
  getMexicoDayLabels,
93
+ getMexicoUpcomingDayLabels,
86
94
  parseDate,
87
95
  toIsoInstant,
88
96
  ageInYears,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.68.2",
3
+ "version": "5.68.4",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",