@peopl-health/nexus 5.52.0-dev.7711 → 5.52.0-dev.7717
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 +22 -14
- 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,9 +11,9 @@ 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 { normalizePayload: normalize, registerInteractiveRoute } = require('../../core/interactiveRouteService');
|
|
17
17
|
const { getPatientTasks, updatePatientTask } = require('../../services/patientTaskService');
|
|
18
18
|
|
|
19
19
|
const { WriteClaimStore } = require('./writeClaimStore');
|
|
@@ -44,8 +44,6 @@ let offerClaimStore = null;
|
|
|
44
44
|
let tapClaimStore = null;
|
|
45
45
|
let phoneCheckedFor = null;
|
|
46
46
|
|
|
47
|
-
const normalize = (value) => String(value ?? '').trim().toLowerCase();
|
|
48
|
-
|
|
49
47
|
const offerClaims = () => {
|
|
50
48
|
if (!offerClaimStore) offerClaimStore = new WriteClaimStore({ namespace: 'paused-safety-net', ttlMs: getOfferWindowMs() });
|
|
51
49
|
return offerClaimStore;
|
|
@@ -96,7 +94,7 @@ async function warnIfPhoneDiffersFromTemplate(contentSid) {
|
|
|
96
94
|
async function offerSafetyNet({ code }) {
|
|
97
95
|
try {
|
|
98
96
|
const contentSid = getSafetyNetContentSid();
|
|
99
|
-
if (!code || !contentSid) return { sent: false, reason: 'not_configured' };
|
|
97
|
+
if (!code || !contentSid || !getSafetyNetPhone()) return { sent: false, reason: 'not_configured' };
|
|
100
98
|
if (await offerClaims().get(code)) return { sent: false, reason: 'recent_offer' };
|
|
101
99
|
if (await humanRepliedSince(code, new Date(Date.now() - getHumanQuietMs()))) return { sent: false, reason: 'human_active' };
|
|
102
100
|
|
|
@@ -131,7 +129,7 @@ async function markCanWait(code) {
|
|
|
131
129
|
const target = requests.find((request) => request.status === ATTENDED_STATUS
|
|
132
130
|
&& request.category === REQUEST_CATEGORY
|
|
133
131
|
&& String(request.details || '').startsWith(PAUSED_THREAD_DETAILS_PREFIX));
|
|
134
|
-
if (!target) return
|
|
132
|
+
if (!target) return false;
|
|
135
133
|
|
|
136
134
|
await updatePatientTask({
|
|
137
135
|
code,
|
|
@@ -148,7 +146,7 @@ async function markCanWait(code) {
|
|
|
148
146
|
}
|
|
149
147
|
|
|
150
148
|
async function applyChoice(code, choice) {
|
|
151
|
-
if (choice === 'later') return { queued: await markCanWait(code), pledge: null };
|
|
149
|
+
if (choice === 'later') return { queued: true, downgraded: await markCanWait(code), pledge: null };
|
|
152
150
|
|
|
153
151
|
const queued = await fileUrgentRequest(code, choice);
|
|
154
152
|
if (choice !== 'callback') return { queued, pledge: null };
|
|
@@ -181,7 +179,9 @@ function ackFor(choice, outcome) {
|
|
|
181
179
|
}
|
|
182
180
|
|
|
183
181
|
if (choice === 'later') {
|
|
184
|
-
return
|
|
182
|
+
return outcome.downgraded
|
|
183
|
+
? withPhone('Gracias, lo anotamos. El equipo revisará tu mensaje.', 'Si necesitas ayuda antes, llámanos al {phone}.')
|
|
184
|
+
: withPhone('Gracias. El equipo revisará tu mensaje.', 'Si necesitas ayuda antes, llámanos al {phone}.');
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
if (!outcome.pledge) {
|
|
@@ -205,8 +205,10 @@ function ackFor(choice, outcome) {
|
|
|
205
205
|
async function sendAck(code, choice, outcome) {
|
|
206
206
|
try {
|
|
207
207
|
await getMessaging().sendMessage({ code, body: ackFor(choice, outcome), triggeredBy: SOURCE });
|
|
208
|
+
return true;
|
|
208
209
|
} catch (error) {
|
|
209
210
|
logger.error('[PausedSafetyNet] Could not acknowledge the tap', { code, choice, error: error.message });
|
|
211
|
+
return false;
|
|
210
212
|
}
|
|
211
213
|
}
|
|
212
214
|
|
|
@@ -217,36 +219,42 @@ async function handleSafetyNetTap({ code, payload, title }) {
|
|
|
217
219
|
const claim = await acquireSendClaim({ idempotencyKey: `safetynet:${code}:${choice}` });
|
|
218
220
|
if (!claim.acquired) return { handled: true, choice, reason: 'concurrent_tap' };
|
|
219
221
|
|
|
222
|
+
let acknowledged = false;
|
|
220
223
|
try {
|
|
221
224
|
if (!await offerClaims().get(code)) {
|
|
222
|
-
await sendAck(code, choice, { queued: true, pledge: null, repeated: false, stale: true });
|
|
225
|
+
acknowledged = await sendAck(code, choice, { queued: true, pledge: null, repeated: false, stale: true });
|
|
223
226
|
return { handled: true, choice, queued: false, stale: true };
|
|
224
227
|
}
|
|
225
228
|
await offerClaims().set(code, true);
|
|
226
229
|
|
|
227
|
-
const
|
|
228
|
-
const repeated = Boolean(
|
|
230
|
+
const chosen = await tapClaims().get(code);
|
|
231
|
+
const repeated = Boolean(chosen);
|
|
229
232
|
|
|
230
233
|
let outcome = { queued: true, pledge: null, repeated };
|
|
231
234
|
if (!repeated) {
|
|
232
235
|
try {
|
|
233
236
|
outcome = { ...await applyChoice(code, choice), repeated: false };
|
|
234
|
-
await tapClaims().set(
|
|
237
|
+
await tapClaims().set(code, choice);
|
|
235
238
|
} catch (error) {
|
|
236
239
|
logger.error('[PausedSafetyNet] Could not record the tap', { code, choice, error: error.message });
|
|
237
240
|
outcome = { queued: false, pledge: null, repeated: false };
|
|
238
241
|
}
|
|
239
242
|
}
|
|
240
243
|
|
|
241
|
-
await sendAck(code, choice, outcome);
|
|
244
|
+
acknowledged = await sendAck(code, choice, outcome);
|
|
242
245
|
return { handled: true, choice, ...outcome };
|
|
243
246
|
} finally {
|
|
244
|
-
await completeSendClaim(claim.claimId);
|
|
247
|
+
if (acknowledged) await completeSendClaim(claim.claimId);
|
|
248
|
+
else await releaseSendClaim(claim.claimId);
|
|
245
249
|
}
|
|
246
250
|
}
|
|
247
251
|
|
|
248
252
|
function registerSafetyNetRoutes() {
|
|
249
253
|
if (!getSafetyNetContentSid()) return false;
|
|
254
|
+
if (!getSafetyNetPhone()) {
|
|
255
|
+
logger.error('[PausedSafetyNet] A template is configured without a phone number; the safety net stays disarmed');
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
250
258
|
|
|
251
259
|
registerInteractiveRoute(PAYLOAD_PREFIX, handleSafetyNetTap);
|
|
252
260
|
for (const choice of CHOICES) {
|
|
@@ -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
|
|