@peopl-health/nexus 5.52.0-dev.7711 → 5.52.0-dev.7718
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/config/safetyNetConfig.js +3 -1
- package/lib/clinical/services/pausedSafetyNetService.js +49 -18
- package/lib/clinical/services/safetyNetPledgeService.js +21 -1
- package/lib/config/lifecycle.js +2 -0
- package/lib/core/NexusMessaging.js +1 -3
- package/lib/core/interactiveRouteService.js +1 -0
- package/lib/index.js +6 -1
- package/lib/services/queueReconciliationService.js +6 -19
- package/package.json +1 -1
|
@@ -4,6 +4,7 @@ const DEFAULT_OFFER_WINDOW_MS = 4 * 60 * 60 * 1000;
|
|
|
4
4
|
const DEFAULT_HUMAN_QUIET_MS = 60 * 60 * 1000;
|
|
5
5
|
const DEFAULT_TAP_WINDOW_MS = 30 * 60 * 1000;
|
|
6
6
|
const DEFAULT_PLEDGE_MS = 30 * 60 * 1000;
|
|
7
|
+
const DEFAULT_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
|
|
7
8
|
|
|
8
9
|
const CONTACT_WINDOW = { timeZone: 'America/Mexico_City', startHour: 8, endHour: 21 };
|
|
9
10
|
|
|
@@ -58,5 +59,6 @@ module.exports = {
|
|
|
58
59
|
getOfferWindowMs: () => duration('offerWindowMs', 'PAUSED_SAFETY_NET_WINDOW_MS', DEFAULT_OFFER_WINDOW_MS),
|
|
59
60
|
getHumanQuietMs: () => duration('humanQuietMs', 'SAFETY_NET_HUMAN_QUIET_MS', DEFAULT_HUMAN_QUIET_MS),
|
|
60
61
|
getTapWindowMs: () => duration('tapWindowMs', 'SAFETY_NET_TAP_WINDOW_MS', DEFAULT_TAP_WINDOW_MS),
|
|
61
|
-
getPledgeMs: () => duration('pledgeMs', 'SAFETY_NET_PLEDGE_MS', DEFAULT_PLEDGE_MS)
|
|
62
|
+
getPledgeMs: () => duration('pledgeMs', 'SAFETY_NET_PLEDGE_MS', DEFAULT_PLEDGE_MS),
|
|
63
|
+
getSweepIntervalMs: () => duration('sweepIntervalMs', 'SAFETY_NET_SWEEP_INTERVAL_MS', DEFAULT_SWEEP_INTERVAL_MS)
|
|
62
64
|
};
|
|
@@ -11,14 +11,15 @@ const {
|
|
|
11
11
|
} = require('../config/safetyNetConfig');
|
|
12
12
|
const { logger } = require('../../utils/logger');
|
|
13
13
|
|
|
14
|
-
const { acquireSendClaim, completeSendClaim } = require('../../helpers/sendClaimHelper');
|
|
14
|
+
const { acquireSendClaim, completeSendClaim, releaseSendClaim } = require('../../helpers/sendClaimHelper');
|
|
15
15
|
|
|
16
|
-
const { registerInteractiveRoute } = require('../../core/interactiveRouteService');
|
|
16
|
+
const { clearInteractiveRoutes, normalizePayload: normalize, registerInteractiveRoute } = require('../../core/interactiveRouteService');
|
|
17
|
+
const { getMessages } = require('../../services/messageService');
|
|
17
18
|
const { getPatientTasks, updatePatientTask } = require('../../services/patientTaskService');
|
|
18
19
|
|
|
19
20
|
const { WriteClaimStore } = require('./writeClaimStore');
|
|
20
21
|
const { PAUSED_THREAD_DETAILS_PREFIX, recordUnresolvedRequest } = require('./clinicalAirtableService');
|
|
21
|
-
const { humanRepliedSince, openPledge } = require('./safetyNetPledgeService');
|
|
22
|
+
const { humanRepliedSince, openPledge, stopSafetyNetSweep } = require('./safetyNetPledgeService');
|
|
22
23
|
|
|
23
24
|
const getMessaging = () => require('../../core/NexusMessaging');
|
|
24
25
|
|
|
@@ -44,8 +45,6 @@ let offerClaimStore = null;
|
|
|
44
45
|
let tapClaimStore = null;
|
|
45
46
|
let phoneCheckedFor = null;
|
|
46
47
|
|
|
47
|
-
const normalize = (value) => String(value ?? '').trim().toLowerCase();
|
|
48
|
-
|
|
49
48
|
const offerClaims = () => {
|
|
50
49
|
if (!offerClaimStore) offerClaimStore = new WriteClaimStore({ namespace: 'paused-safety-net', ttlMs: getOfferWindowMs() });
|
|
51
50
|
return offerClaimStore;
|
|
@@ -56,6 +55,11 @@ const tapClaims = () => {
|
|
|
56
55
|
return tapClaimStore;
|
|
57
56
|
};
|
|
58
57
|
|
|
58
|
+
const disarmSafetyNet = () => {
|
|
59
|
+
clearInteractiveRoutes();
|
|
60
|
+
stopSafetyNetSweep();
|
|
61
|
+
};
|
|
62
|
+
|
|
59
63
|
const _resetSafetyNetState = () => {
|
|
60
64
|
offerClaimStore = null;
|
|
61
65
|
tapClaimStore = null;
|
|
@@ -96,7 +100,7 @@ async function warnIfPhoneDiffersFromTemplate(contentSid) {
|
|
|
96
100
|
async function offerSafetyNet({ code }) {
|
|
97
101
|
try {
|
|
98
102
|
const contentSid = getSafetyNetContentSid();
|
|
99
|
-
if (!code || !contentSid) return { sent: false, reason: 'not_configured' };
|
|
103
|
+
if (!code || !contentSid || !getSafetyNetPhone()) return { sent: false, reason: 'not_configured' };
|
|
100
104
|
if (await offerClaims().get(code)) return { sent: false, reason: 'recent_offer' };
|
|
101
105
|
if (await humanRepliedSince(code, new Date(Date.now() - getHumanQuietMs()))) return { sent: false, reason: 'human_active' };
|
|
102
106
|
|
|
@@ -114,6 +118,20 @@ async function offerSafetyNet({ code }) {
|
|
|
114
118
|
}
|
|
115
119
|
}
|
|
116
120
|
|
|
121
|
+
async function offerIsLive(code) {
|
|
122
|
+
try {
|
|
123
|
+
const since = new Date(Date.now() - getOfferWindowMs());
|
|
124
|
+
const [sent] = await getMessages(
|
|
125
|
+
{ numero: code, from_me: true, content_sid: getSafetyNetContentSid(), createdAt: { $gt: since } },
|
|
126
|
+
{ sort: { createdAt: -1 }, limit: 1 }
|
|
127
|
+
);
|
|
128
|
+
return Boolean(sent);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
logger.error('[PausedSafetyNet] Could not read the offer history; honouring the tap', { code, error: error.message });
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
117
135
|
async function fileUrgentRequest(code, choice) {
|
|
118
136
|
const copy = REQUEST_COPY[choice];
|
|
119
137
|
return recordUnresolvedRequest({
|
|
@@ -131,7 +149,7 @@ async function markCanWait(code) {
|
|
|
131
149
|
const target = requests.find((request) => request.status === ATTENDED_STATUS
|
|
132
150
|
&& request.category === REQUEST_CATEGORY
|
|
133
151
|
&& String(request.details || '').startsWith(PAUSED_THREAD_DETAILS_PREFIX));
|
|
134
|
-
if (!target) return
|
|
152
|
+
if (!target) return false;
|
|
135
153
|
|
|
136
154
|
await updatePatientTask({
|
|
137
155
|
code,
|
|
@@ -148,7 +166,7 @@ async function markCanWait(code) {
|
|
|
148
166
|
}
|
|
149
167
|
|
|
150
168
|
async function applyChoice(code, choice) {
|
|
151
|
-
if (choice === 'later') return { queued: await markCanWait(code), pledge: null };
|
|
169
|
+
if (choice === 'later') return { queued: true, downgraded: await markCanWait(code), pledge: null };
|
|
152
170
|
|
|
153
171
|
const queued = await fileUrgentRequest(code, choice);
|
|
154
172
|
if (choice !== 'callback') return { queued, pledge: null };
|
|
@@ -181,7 +199,9 @@ function ackFor(choice, outcome) {
|
|
|
181
199
|
}
|
|
182
200
|
|
|
183
201
|
if (choice === 'later') {
|
|
184
|
-
return
|
|
202
|
+
return outcome.downgraded
|
|
203
|
+
? withPhone('Gracias, lo anotamos. El equipo revisará tu mensaje.', 'Si necesitas ayuda antes, llámanos al {phone}.')
|
|
204
|
+
: withPhone('Gracias. El equipo revisará tu mensaje.', 'Si necesitas ayuda antes, llámanos al {phone}.');
|
|
185
205
|
}
|
|
186
206
|
|
|
187
207
|
if (!outcome.pledge) {
|
|
@@ -205,8 +225,10 @@ function ackFor(choice, outcome) {
|
|
|
205
225
|
async function sendAck(code, choice, outcome) {
|
|
206
226
|
try {
|
|
207
227
|
await getMessaging().sendMessage({ code, body: ackFor(choice, outcome), triggeredBy: SOURCE });
|
|
228
|
+
return true;
|
|
208
229
|
} catch (error) {
|
|
209
230
|
logger.error('[PausedSafetyNet] Could not acknowledge the tap', { code, choice, error: error.message });
|
|
231
|
+
return false;
|
|
210
232
|
}
|
|
211
233
|
}
|
|
212
234
|
|
|
@@ -217,36 +239,44 @@ async function handleSafetyNetTap({ code, payload, title }) {
|
|
|
217
239
|
const claim = await acquireSendClaim({ idempotencyKey: `safetynet:${code}:${choice}` });
|
|
218
240
|
if (!claim.acquired) return { handled: true, choice, reason: 'concurrent_tap' };
|
|
219
241
|
|
|
242
|
+
let acknowledged = false;
|
|
220
243
|
try {
|
|
221
|
-
if (!await
|
|
222
|
-
await sendAck(code, choice, { queued: true, pledge: null, repeated: false, stale: true });
|
|
244
|
+
if (!await offerIsLive(code)) {
|
|
245
|
+
acknowledged = await sendAck(code, choice, { queued: true, pledge: null, repeated: false, stale: true });
|
|
223
246
|
return { handled: true, choice, queued: false, stale: true };
|
|
224
247
|
}
|
|
225
248
|
await offerClaims().set(code, true);
|
|
226
249
|
|
|
227
|
-
const
|
|
228
|
-
const repeated = Boolean(
|
|
250
|
+
const chosen = await tapClaims().get(code);
|
|
251
|
+
const repeated = Boolean(chosen);
|
|
252
|
+
const effective = repeated ? chosen : choice;
|
|
229
253
|
|
|
230
254
|
let outcome = { queued: true, pledge: null, repeated };
|
|
231
255
|
if (!repeated) {
|
|
232
256
|
try {
|
|
233
257
|
outcome = { ...await applyChoice(code, choice), repeated: false };
|
|
234
|
-
await tapClaims().set(
|
|
258
|
+
await tapClaims().set(code, choice);
|
|
235
259
|
} catch (error) {
|
|
236
260
|
logger.error('[PausedSafetyNet] Could not record the tap', { code, choice, error: error.message });
|
|
237
261
|
outcome = { queued: false, pledge: null, repeated: false };
|
|
238
262
|
}
|
|
239
263
|
}
|
|
240
264
|
|
|
241
|
-
await sendAck(code,
|
|
242
|
-
return { handled: true, choice, ...outcome };
|
|
265
|
+
acknowledged = await sendAck(code, effective, outcome);
|
|
266
|
+
return { handled: true, choice: effective, ...outcome };
|
|
243
267
|
} finally {
|
|
244
|
-
await completeSendClaim(claim.claimId);
|
|
268
|
+
if (acknowledged) await completeSendClaim(claim.claimId);
|
|
269
|
+
else await releaseSendClaim(claim.claimId);
|
|
245
270
|
}
|
|
246
271
|
}
|
|
247
272
|
|
|
248
273
|
function registerSafetyNetRoutes() {
|
|
249
|
-
|
|
274
|
+
const armed = Boolean(getSafetyNetContentSid()) && Boolean(getSafetyNetPhone());
|
|
275
|
+
if (!armed) {
|
|
276
|
+
if (getSafetyNetContentSid()) logger.error('[PausedSafetyNet] A template is configured without a phone number; the safety net stays disarmed');
|
|
277
|
+
disarmSafetyNet();
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
250
280
|
|
|
251
281
|
registerInteractiveRoute(PAYLOAD_PREFIX, handleSafetyNetTap);
|
|
252
282
|
for (const choice of CHOICES) {
|
|
@@ -257,6 +287,7 @@ function registerSafetyNetRoutes() {
|
|
|
257
287
|
}
|
|
258
288
|
|
|
259
289
|
module.exports = {
|
|
290
|
+
disarmSafetyNet,
|
|
260
291
|
offerSafetyNet,
|
|
261
292
|
handleSafetyNetTap,
|
|
262
293
|
matchSafetyNetChoice,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { CONTACT_WINDOW, getPledgeMs, humanReplyFilter } = require('../config/safetyNetConfig');
|
|
1
|
+
const { CONTACT_WINDOW, getPledgeMs, getSweepIntervalMs, humanReplyFilter } = require('../config/safetyNetConfig');
|
|
2
2
|
const { logger } = require('../../utils/logger');
|
|
3
3
|
const { clampToHourWindow } = require('../../utils/scheduleUtils');
|
|
4
4
|
|
|
@@ -91,7 +91,27 @@ async function sweepSafetyNetPledges() {
|
|
|
91
91
|
return breached;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
let sweepTimer = null;
|
|
95
|
+
|
|
96
|
+
function startSafetyNetSweep({ intervalMs = getSweepIntervalMs() } = {}) {
|
|
97
|
+
if (sweepTimer) return sweepTimer;
|
|
98
|
+
sweepTimer = setInterval(() => {
|
|
99
|
+
sweepSafetyNetPledges().catch((error) =>
|
|
100
|
+
logger.error('[SafetyNetPledge] Sweep failed', { error: error.message }));
|
|
101
|
+
}, intervalMs);
|
|
102
|
+
if (typeof sweepTimer.unref === 'function') sweepTimer.unref();
|
|
103
|
+
return sweepTimer;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function stopSafetyNetSweep() {
|
|
107
|
+
if (!sweepTimer) return;
|
|
108
|
+
clearInterval(sweepTimer);
|
|
109
|
+
sweepTimer = null;
|
|
110
|
+
}
|
|
111
|
+
|
|
94
112
|
module.exports = {
|
|
113
|
+
startSafetyNetSweep,
|
|
114
|
+
stopSafetyNetSweep,
|
|
95
115
|
humanRepliedSince,
|
|
96
116
|
openPledge,
|
|
97
117
|
pledgeDueAt,
|
package/lib/config/lifecycle.js
CHANGED
|
@@ -7,6 +7,7 @@ const { clearInteractiveRoutes } = require('../core/interactiveRouteService');
|
|
|
7
7
|
const { _resetDefaultInstance } = require('../core/NexusMessaging');
|
|
8
8
|
const { _resetSafetyNetConfig } = require('../clinical/config/safetyNetConfig');
|
|
9
9
|
const { _resetSafetyNetState } = require('../clinical/services/pausedSafetyNetService');
|
|
10
|
+
const { stopSafetyNetSweep } = require('../clinical/services/safetyNetPledgeService');
|
|
10
11
|
const { _clearRecentIntakeCache } = require('../clinical/services/clinicalAirtableService');
|
|
11
12
|
const { _clearExtractorConfigCache } = require('../clinical/services/clinicalExtractionService');
|
|
12
13
|
const { _resetRegistry } = require('../clinical/services/assistantResolver');
|
|
@@ -20,6 +21,7 @@ function resetAll() {
|
|
|
20
21
|
clearInteractiveRoutes();
|
|
21
22
|
_resetSafetyNetConfig();
|
|
22
23
|
_resetSafetyNetState();
|
|
24
|
+
stopSafetyNetSweep();
|
|
23
25
|
_resetOverrides();
|
|
24
26
|
_resetMetaConfig();
|
|
25
27
|
_clearClinicalContextCache();
|
|
@@ -30,7 +30,6 @@ const { releaseHeldMessages, sweepHeldMessages } = require('../services/heldMess
|
|
|
30
30
|
|
|
31
31
|
const { BatchingManager } = require('../core/BatchingManager');
|
|
32
32
|
const { ProcessingPipeline } = require('../core/ProcessingPipeline');
|
|
33
|
-
const { sweepSafetyNetPledges } = require('../clinical/services/safetyNetPledgeService');
|
|
34
33
|
const { preProcessMessages, configureOpenAIProvider, AssistantProcessor, TURN_KIND } = require('../clinical');
|
|
35
34
|
const { buildOutreachCarrier } = require('../clinical/services/outreachCarrierService');
|
|
36
35
|
const { routineOutreachCollisionReason } = require('../clinical/services/outreachSweepService');
|
|
@@ -171,8 +170,7 @@ class NexusMessaging {
|
|
|
171
170
|
...(config.queueReconciliation || {}),
|
|
172
171
|
scheduleMessage: (options) => this.scheduledMessageJob.schedule(options),
|
|
173
172
|
scheduleTemplateApproval: (options) => this.scheduleTemplateApproval(options),
|
|
174
|
-
sweepHeldMessages: () => sweepHeldMessages()
|
|
175
|
-
sweepSafetyNetPledges
|
|
173
|
+
sweepHeldMessages: () => sweepHeldMessages()
|
|
176
174
|
});
|
|
177
175
|
|
|
178
176
|
this._providerStores = false;
|
package/lib/index.js
CHANGED
|
@@ -29,6 +29,7 @@ const { setPreprocessingHandler, hasPreprocessingHandler, invokePreprocessingHan
|
|
|
29
29
|
const { registerInteractiveRoute, clearInteractiveRoutes } = require('./core/interactiveRouteService');
|
|
30
30
|
const { configureSafetyNet } = require('./clinical/config/safetyNetConfig');
|
|
31
31
|
const { registerSafetyNetRoutes } = require('./clinical/services/pausedSafetyNetService');
|
|
32
|
+
const { startSafetyNetSweep, stopSafetyNetSweep } = require('./clinical/services/safetyNetPledgeService');
|
|
32
33
|
const { requestIdMiddleware, getRequestId } = require('./middleware/requestId');
|
|
33
34
|
const { QueueAdapter, LocalQueueAdapter, RedisQueueAdapter, createQueueAdapter, registerQueueAdapter } = require('./queue');
|
|
34
35
|
const { createWorkflowRunner } = require('./core/workflowRunner');
|
|
@@ -189,7 +190,10 @@ class Nexus {
|
|
|
189
190
|
|
|
190
191
|
try {
|
|
191
192
|
configureSafetyNet(options.safetyNet || {});
|
|
192
|
-
if (registerSafetyNetRoutes())
|
|
193
|
+
if (registerSafetyNetRoutes()) {
|
|
194
|
+
startSafetyNetSweep();
|
|
195
|
+
logger.info('[Nexus] paused-thread safety net armed');
|
|
196
|
+
}
|
|
193
197
|
} catch (e) {
|
|
194
198
|
logger.warn('Warning: failed to arm the paused-thread safety net:', e?.message || e);
|
|
195
199
|
}
|
|
@@ -248,6 +252,7 @@ class Nexus {
|
|
|
248
252
|
}
|
|
249
253
|
|
|
250
254
|
async shutdown({ drainMs, abortGraceMs, teardownMs, closeStorage = true } = {}) {
|
|
255
|
+
stopSafetyNetSweep();
|
|
251
256
|
if (this._shutdownPromise) return this._shutdownPromise;
|
|
252
257
|
this._shutdownPromise = (async () => {
|
|
253
258
|
const result = await this.messaging.shutdown({ drainMs, abortGraceMs, teardownMs });
|
|
@@ -13,7 +13,6 @@ class QueueReconciliationService {
|
|
|
13
13
|
scheduleMessage,
|
|
14
14
|
scheduleTemplateApproval,
|
|
15
15
|
sweepHeldMessages,
|
|
16
|
-
sweepSafetyNetPledges,
|
|
17
16
|
intervalMs = DEFAULT_INTERVAL_MS,
|
|
18
17
|
lookbackMs = DEFAULT_LOOKBACK_MS,
|
|
19
18
|
staleAfterMs = DEFAULT_STALE_AFTER_MS,
|
|
@@ -22,7 +21,6 @@ class QueueReconciliationService {
|
|
|
22
21
|
this.scheduleMessage = scheduleMessage;
|
|
23
22
|
this.scheduleTemplateApproval = scheduleTemplateApproval;
|
|
24
23
|
this.sweepHeldMessages = sweepHeldMessages;
|
|
25
|
-
this.sweepSafetyNetPledges = sweepSafetyNetPledges;
|
|
26
24
|
this.intervalMs = intervalMs;
|
|
27
25
|
this.lookbackMs = lookbackMs;
|
|
28
26
|
this.staleAfterMs = staleAfterMs;
|
|
@@ -62,20 +60,19 @@ class QueueReconciliationService {
|
|
|
62
60
|
}
|
|
63
61
|
|
|
64
62
|
async runOnce() {
|
|
65
|
-
if (this.stopped) return { scheduledMessages: 0, recoveries: 0, heldMessages: 0
|
|
63
|
+
if (this.stopped) return { scheduledMessages: 0, recoveries: 0, heldMessages: 0 };
|
|
66
64
|
const since = new Date(Date.now() - this.lookbackMs);
|
|
67
|
-
const [scheduledMessages, recoveries, heldMessages
|
|
65
|
+
const [scheduledMessages, recoveries, heldMessages] = await Promise.all([
|
|
68
66
|
this._reEnqueueScheduledMessages(since),
|
|
69
67
|
this._reArmTemplateApprovals(since),
|
|
70
|
-
this._releaseHeldMessages()
|
|
71
|
-
this._resolveSafetyNetPledges()
|
|
68
|
+
this._releaseHeldMessages()
|
|
72
69
|
]);
|
|
73
70
|
|
|
74
|
-
if (scheduledMessages || recoveries || heldMessages
|
|
75
|
-
logger.warn('[QueueReconciliation] Re-armed dropped jobs', { scheduledMessages, recoveries, heldMessages
|
|
71
|
+
if (scheduledMessages || recoveries || heldMessages) {
|
|
72
|
+
logger.warn('[QueueReconciliation] Re-armed dropped jobs', { scheduledMessages, recoveries, heldMessages });
|
|
76
73
|
}
|
|
77
74
|
|
|
78
|
-
return { scheduledMessages, recoveries, heldMessages
|
|
75
|
+
return { scheduledMessages, recoveries, heldMessages };
|
|
79
76
|
}
|
|
80
77
|
|
|
81
78
|
async _releaseHeldMessages() {
|
|
@@ -88,16 +85,6 @@ class QueueReconciliationService {
|
|
|
88
85
|
}
|
|
89
86
|
}
|
|
90
87
|
|
|
91
|
-
async _resolveSafetyNetPledges() {
|
|
92
|
-
if (typeof this.sweepSafetyNetPledges !== 'function') return 0;
|
|
93
|
-
try {
|
|
94
|
-
return await this.sweepSafetyNetPledges();
|
|
95
|
-
} catch (error) {
|
|
96
|
-
logger.error('[QueueReconciliation] Safety-net pledge sweep failed', { error: error.message });
|
|
97
|
-
return 0;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
88
|
async _reEnqueueScheduledMessages(since) {
|
|
102
89
|
if (typeof this.scheduleMessage !== 'function') return 0;
|
|
103
90
|
|