@peopl-health/nexus 4.3.3 → 4.4.0

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.
@@ -10,6 +10,7 @@ const { sanitizeMediaFilename } = require('../utils/sanitizerUtils');
10
10
  const { validateMedia, getMediaType, MEDIA_LIMITS, STICKER_DIMENSIONS } = require('../utils/mediaValidator');
11
11
  const { logger } = require('../utils/logger');
12
12
  const { calculateDelay } = require('../utils/scheduleUtils');
13
+ const { isBenchMode } = require('../utils/benchModeHelper');
13
14
 
14
15
  const { ScheduledMessage } = require('../models/agendaMessageModel');
15
16
 
@@ -49,6 +50,24 @@ class TwilioProvider extends MessageProvider {
49
50
  throw new Error('Twilio provider not initialized');
50
51
  }
51
52
 
53
+ if (isBenchMode(messageData?.code)) {
54
+ const benchSid = `bench_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
55
+ logger.info('[TwilioProvider] bench.suppressed_send', {
56
+ event: 'bench.suppressed_send',
57
+ code: messageData.code,
58
+ body: messageData.body || null,
59
+ contentSid: messageData.contentSid || null
60
+ });
61
+ return {
62
+ success: true,
63
+ messageId: benchSid,
64
+ provider: 'twilio',
65
+ status: 'bench_suppressed',
66
+ result: { sid: benchSid, status: 'bench_suppressed' },
67
+ finalize: { sid: benchSid, status: 'bench_suppressed' }
68
+ };
69
+ }
70
+
52
71
  const { code, body, fileUrl, fileType, variables, contentSid } = messageData;
53
72
 
54
73
  const formattedFrom = ensureWhatsAppFormat(this.whatsappNumber);
@@ -236,7 +255,8 @@ class TwilioProvider extends MessageProvider {
236
255
  });
237
256
  const response = await sender(payload);
238
257
  const messageId = response?.result?.sid || null;
239
- await updateStatus('sent', messageId);
258
+ const persistedStatus = response?.status === 'bench_suppressed' ? 'bench_suppressed' : 'sent';
259
+ await updateStatus(persistedStatus, messageId);
240
260
  } catch (error) {
241
261
  await updateStatus('failed', null, error);
242
262
  logger.error(`Scheduled message failed: ${error.message}`);
@@ -441,7 +441,7 @@ const markMessagesAsReadController = async (req, res) => {
441
441
  .catch(err => logger.error('[markMessagesAsRead] Failed to reset thread unreadCount', { phoneNumber, error: err.message }));
442
442
 
443
443
  if (modifiedCount > 0) {
444
- updateRecordByFilter(Monitoreo_ID, 'message_monitor', `{whatsapp_id} = "${phoneNumber}"`, { read: true })
444
+ updateRecordByFilter(Monitoreo_ID, 'message_monitor', `{whatsapp_id} = "${phoneNumber}"`, { read: true }, phoneNumber)
445
445
  .catch(err => logger.error('[markMessagesAsRead] Failed to update message_monitor', { phoneNumber, error: err.message }));
446
446
  }
447
447
 
@@ -29,7 +29,8 @@ const createPrescriptionController = async (req, res) => {
29
29
  referenceTable: 'estado_general',
30
30
  referenceFilter: `{whatsapp_id}='${whatsappId}'`,
31
31
  linkFieldName: 'patient_id'
32
- }
32
+ },
33
+ whatsappId
33
34
  );
34
35
 
35
36
  logger.info('[PrescriptionController] Prescription created', { code: whatsappId });
@@ -61,14 +61,16 @@ async function updateMessageStatus(messageSid, status, errorCode = null, errorMe
61
61
  referenceTable: 'message_monitor',
62
62
  referenceFilter: `{whatsapp_id}="${updated.numero}"`,
63
63
  linkFieldName: 'message_monitor'
64
- }
64
+ },
65
+ updated.numero
65
66
  ).catch(err => logger.error('[MessageStatus] Failed to create undelivered_messages record', { messageSid, error: err.message }));
66
67
  } else {
67
68
  updateRecordByFilter(
68
69
  Monitoreo_ID,
69
70
  'undelivered_messages',
70
71
  `{message_id}="${updated.message_id}"`,
71
- { status }
72
+ { status },
73
+ updated.numero
72
74
  ).catch(err => logger.error('[MessageStatus] Failed to update undelivered_messages record', { messageSid, error: err.message }));
73
75
  }
74
76
  }
@@ -36,7 +36,7 @@ const getThreadInfo = async (code) => {
36
36
  const switchThreadStoppedStatus = async (code, stopped) => {
37
37
  await Thread.updateOne({ code }, { active: true, stopped });
38
38
 
39
- updateRecordByFilter(Monitoreo_ID, 'message_monitor', `{whatsapp_id} = "${code}"`, { stopped })
39
+ updateRecordByFilter(Monitoreo_ID, 'message_monitor', `{whatsapp_id} = "${code}"`, { stopped }, code)
40
40
  .catch(err => logger.error('[switchThreadStoppedStatus] Failed to update message_monitor', { code, error: err.message }));
41
41
  };
42
42
 
@@ -45,7 +45,7 @@ const setThreadPromptId = async (code, promptId) => {
45
45
  Thread.setAssistantId(updateFields, promptId);
46
46
  await Thread.updateOne({ code: code }, { $set: updateFields });
47
47
 
48
- updateRecordByFilter(Monitoreo_ID, 'message_monitor', `{whatsapp_id} = "${code}"`, { prompt_id: promptId })
48
+ updateRecordByFilter(Monitoreo_ID, 'message_monitor', `{whatsapp_id} = "${code}"`, { prompt_id: promptId }, code)
49
49
  .catch(err => logger.error('[setThreadPromptId] Failed to update message_monitor', { code, error: err.message }));
50
50
  };
51
51
 
@@ -31,7 +31,7 @@ async function uploadMediaToAirtable(whatsappId, mediaUrl, baseID, tableName) {
31
31
  referenceTable: 'estado_general',
32
32
  referenceFilter: `whatsapp_id = "${whatsappId}"`,
33
33
  linkFieldName: 'patient_id'
34
- });
34
+ }, whatsappId);
35
35
  } catch (error) {
36
36
  logger.warn('[uploadMediaToAirtable] Failed', { whatsappId, error: error.message });
37
37
  throw error;
@@ -2,6 +2,7 @@ const { OpenAI } = require('openai');
2
2
 
3
3
  const { logger } = require('../utils/logger');
4
4
  const { retryWithBackoff } = require('../utils/retryUtils');
5
+ const { isBenchMode } = require('../utils/benchModeHelper');
5
6
 
6
7
  const { getPatientMemory } = require('../models/patientMemoryModel');
7
8
  const { getConversationSummary } = require('../models/conversationSummaryModel');
@@ -69,6 +70,10 @@ class MemoryExtractor {
69
70
  }
70
71
 
71
72
  async processSessionEnd(numero, sessionMessages, sessionId) {
73
+ if (isBenchMode(numero)) {
74
+ logger.info('[MemoryExtractor] bench.suppressed_extraction', { event: 'bench.suppressed_extraction', numero, sessionId });
75
+ return { summary: null, memoriesCreated: 0, memoriesReinforced: 0 };
76
+ }
72
77
  if (!sessionMessages?.length) {
73
78
  logger.info('[MemoryExtractor] No messages to process', { numero, sessionId });
74
79
  return { summary: null, memoriesCreated: 0, memoriesReinforced: 0 };
@@ -1,4 +1,5 @@
1
1
  const { logger } = require('../utils/logger');
2
+ const { isBenchMode } = require('../utils/benchModeHelper');
2
3
 
3
4
  const { Message } = require('../models/messageModel');
4
5
 
@@ -13,6 +14,7 @@ class SessionManager {
13
14
  }
14
15
 
15
16
  recordActivity(numero) {
17
+ if (isBenchMode(numero)) return;
16
18
  const now = Date.now();
17
19
  const existing = this.activeSessions.get(numero);
18
20
 
@@ -58,6 +60,7 @@ class SessionManager {
58
60
  }
59
61
 
60
62
  async processSessionEndForNumero(numero) {
63
+ if (isBenchMode(numero)) return null;
61
64
  const session = this.activeSessions.get(numero);
62
65
  const sessionId = session?.sessionId || this._generateSessionId(numero);
63
66
  const sessionStart = session?.sessionStart || Date.now() - this.idleTimeoutMs;
@@ -169,7 +169,7 @@ async function insertMessage(values) {
169
169
  updateRecordByFilter(Monitoreo_ID, 'message_monitor', `{whatsapp_id} = "${values.numero}"`, {
170
170
  ...(values.from_me ? { last_message_bot: values.body } : { last_message_patient: values.body, read: false }),
171
171
  ...(values.from_me ? { last_message_bot_time: values.timestamp } : { last_message_patient_time: values.timestamp })
172
- }).catch(err => logger.error('[MongoStorage] Failed to update message_monitor table', { numero: values.numero, error: err.message }));
172
+ }, values.numero).catch(err => logger.error('[MongoStorage] Failed to update message_monitor table', { numero: values.numero, error: err.message }));
173
173
 
174
174
  logger.info('[MongoStorage] Message inserted or updated successfully');
175
175
  return { isNew, doc };
@@ -1,5 +1,6 @@
1
1
  const { trace, metrics } = require('@opentelemetry/api');
2
2
 
3
+ const { hasBenchAttribute } = require('../utils/benchModeHelper');
3
4
  const { initTelemetry, shutdownTelemetry } = require('../observability/telemetry');
4
5
 
5
6
  const tracer = trace.getTracer('nexus-assistant');
@@ -51,33 +52,39 @@ const s3UploadDuration = meter.createHistogram('nexus_s3_upload_duration', {
51
52
  });
52
53
 
53
54
  async function traceOperation(name, operation, attributes = {}) {
54
- const span = tracer.startSpan(name, { attributes });
55
+ const isBench = hasBenchAttribute(attributes);
56
+ const spanAttributes = isBench ? { ...attributes, bench: true } : attributes;
57
+ const span = tracer.startSpan(name, { attributes: spanAttributes });
55
58
  const startTime = Date.now();
56
-
59
+
57
60
  try {
58
- activeOperationsGauge.add(1, attributes);
59
-
61
+ if (!isBench) activeOperationsGauge.add(1, attributes);
62
+
60
63
  const result = await operation(span);
61
-
64
+
62
65
  const duration = Date.now() - startTime;
63
-
64
- responseTimeHistogram.record(duration, attributes);
65
- operationCounter.add(1, { ...attributes, success: true });
66
-
66
+
67
+ if (!isBench) {
68
+ responseTimeHistogram.record(duration, attributes);
69
+ operationCounter.add(1, { ...attributes, success: true });
70
+ }
71
+
67
72
  span.setAttributes({
68
73
  'operation.duration_ms': duration,
69
74
  'operation.success': true,
70
75
  });
71
-
76
+
72
77
  span.setStatus({ code: 1 });
73
78
  return result;
74
-
79
+
75
80
  } catch (error) {
76
81
  const duration = Date.now() - startTime;
77
-
78
- operationCounter.add(1, { ...attributes, success: false });
79
- responseTimeHistogram.record(duration, { ...attributes, error: true });
80
-
82
+
83
+ if (!isBench) {
84
+ operationCounter.add(1, { ...attributes, success: false });
85
+ responseTimeHistogram.record(duration, { ...attributes, error: true });
86
+ }
87
+
81
88
  span.recordException(error);
82
89
  span.setStatus({ code: 2, message: error.message });
83
90
  span.setAttributes({
@@ -85,16 +92,18 @@ async function traceOperation(name, operation, attributes = {}) {
85
92
  'operation.success': false,
86
93
  'error.message': error.message,
87
94
  });
88
-
95
+
89
96
  throw error;
90
97
  } finally {
91
- activeOperationsGauge.add(-1, attributes);
98
+ if (!isBench) activeOperationsGauge.add(-1, attributes);
92
99
  span.end();
93
100
  }
94
101
  }
95
102
 
96
103
  function createSpan(name, attributes = {}) {
97
- return tracer.startSpan(name, { attributes });
104
+ const isBench = hasBenchAttribute(attributes);
105
+ const spanAttributes = isBench ? { ...attributes, bench: true } : attributes;
106
+ return tracer.startSpan(name, { attributes: spanAttributes });
98
107
  }
99
108
 
100
109
  function init(config = {}) {
@@ -103,46 +112,51 @@ function init(config = {}) {
103
112
  }
104
113
 
105
114
  function traceAssistantReply(operation, attributes = {}) {
115
+ const isBench = hasBenchAttribute(attributes);
106
116
  return traceOperation('assistant_reply', async (span) => {
107
117
  const startTime = Date.now();
108
118
  try {
109
119
  const result = await operation(span);
110
120
  const duration = Date.now() - startTime;
111
- assistantReplyDuration.record(duration, attributes);
121
+ if (!isBench) assistantReplyDuration.record(duration, attributes);
112
122
  return result;
113
123
  } catch (error) {
114
124
  const duration = Date.now() - startTime;
115
- assistantReplyDuration.record(duration, { ...attributes, error: true });
125
+ if (!isBench) assistantReplyDuration.record(duration, { ...attributes, error: true });
116
126
  throw error;
117
127
  }
118
128
  }, attributes);
119
129
  }
120
130
 
121
131
  function traceAssistantInstruction(operation, attributes = {}) {
132
+ const isBench = hasBenchAttribute(attributes);
122
133
  return traceOperation('assistant_instruction', async (span) => {
123
134
  const startTime = Date.now();
124
135
  try {
125
136
  const result = await operation(span);
126
137
  const duration = Date.now() - startTime;
127
- assistantInstructionDuration.record(duration, attributes);
138
+ if (!isBench) assistantInstructionDuration.record(duration, attributes);
128
139
  return result;
129
140
  } catch (error) {
130
141
  const duration = Date.now() - startTime;
131
- assistantInstructionDuration.record(duration, { ...attributes, error: true });
142
+ if (!isBench) assistantInstructionDuration.record(duration, { ...attributes, error: true });
132
143
  throw error;
133
144
  }
134
145
  }, attributes);
135
146
  }
136
147
 
137
148
  function recordAssistantRetry(attributes = {}) {
149
+ if (hasBenchAttribute(attributes)) return;
138
150
  assistantRetryCounter.add(1, attributes);
139
151
  }
140
152
 
141
153
  function recordThreadOperation(operationType, attributes = {}) {
154
+ if (hasBenchAttribute(attributes)) return;
142
155
  threadOperationsCounter.add(1, { ...attributes, operation_type: operationType });
143
156
  }
144
157
 
145
158
  function recordFileOperation(operationType, attributes = {}) {
159
+ if (hasBenchAttribute(attributes)) return;
146
160
  fileOperationsCounter.add(1, { ...attributes, operation_type: operationType });
147
161
  }
148
162
 
@@ -1,6 +1,7 @@
1
1
  const { airtable } = require('../config/airtableConfig');
2
2
 
3
3
  const { logger } = require('../utils/logger');
4
+ const { isBenchMode } = require('../utils/benchModeHelper');
4
5
 
5
6
  let evalMode = false;
6
7
 
@@ -23,7 +24,11 @@ async function collectRecords(query, mapper = r => r.fields) {
23
24
  return records;
24
25
  }
25
26
 
26
- async function addRecord(baseID, tableName, fields) {
27
+ async function addRecord(baseID, tableName, fields, context = null) {
28
+ if (isBenchMode(context)) {
29
+ logger.info('[addRecord:bench] Suppressed', { tableName, code: context });
30
+ return { id: 'bench_mock_record', fields: Array.isArray(fields) ? fields[0]?.fields || {} : fields };
31
+ }
27
32
  if (evalMode) {
28
33
  logger.info('[addRecord:eval] Muted', { tableName });
29
34
  return { id: 'eval_mock_record', fields: Array.isArray(fields) ? fields[0]?.fields || {} : fields };
@@ -60,7 +65,11 @@ async function getRecordByFilter(baseID, tableName, filter, view = 'Grid view',
60
65
  }
61
66
  }
62
67
 
63
- async function updateRecordByFilter(baseID, tableName, filter, updateFields) {
68
+ async function updateRecordByFilter(baseID, tableName, filter, updateFields, context = null) {
69
+ if (isBenchMode(context)) {
70
+ logger.info('[updateRecordByFilter:bench] Suppressed', { tableName, filter, code: context });
71
+ return [{ id: 'bench_mock_record', fields: updateFields }];
72
+ }
64
73
  if (evalMode) {
65
74
  logger.info('[updateRecordByFilter:eval] Muted', { tableName, filter });
66
75
  return [{ id: 'eval_mock_record', fields: updateFields }];
@@ -85,9 +94,14 @@ async function updateRecordByFilter(baseID, tableName, filter, updateFields) {
85
94
  }
86
95
  }
87
96
 
88
- async function addLinkedRecord(baseID, targetTable, fields, linkConfig) {
97
+ async function addLinkedRecord(baseID, targetTable, fields, linkConfig, context = null) {
89
98
  if (!baseID) throw new Error('[addLinkedRecord] Base ID is required');
90
99
 
100
+ if (isBenchMode(context)) {
101
+ logger.info('[addLinkedRecord:bench] Suppressed', { targetTable, code: context });
102
+ return { id: 'bench_mock_record', fields };
103
+ }
104
+
91
105
  try {
92
106
  if (linkConfig) {
93
107
  const { referenceTable, referenceFilter, linkFieldName } = linkConfig;
@@ -55,13 +55,13 @@ const updatePatientInformation = async (code, fields, auditContext = {}) => {
55
55
  .map(([key, value]) => [key.replace(/^updt_/, ''), value])
56
56
  );
57
57
 
58
- const result = await updateRecordByFilter(Estado_General_ID, 'estado_general', filter, fields);
58
+ const result = await updateRecordByFilter(Estado_General_ID, 'estado_general', filter, fields, code);
59
59
 
60
60
  let failedFields = [];
61
61
  if (!result) {
62
62
  logger.warn('[updatePatientInformation] Bulk update failed, retrying fields individually', { code });
63
63
  for (const [key, value] of Object.entries(fields)) {
64
- const fieldResult = await updateRecordByFilter(Estado_General_ID, 'estado_general', filter, { [key]: value });
64
+ const fieldResult = await updateRecordByFilter(Estado_General_ID, 'estado_general', filter, { [key]: value }, code);
65
65
  if (!fieldResult) {
66
66
  failedFields.push({ key, value });
67
67
  const cleanKey = key.replace(/^updt_/, '');
@@ -0,0 +1,31 @@
1
+ const BENCH_CODE_PREFIX = 'bench:';
2
+
3
+ function extractCode(codeOrThread) {
4
+ if (!codeOrThread) return null;
5
+ if (typeof codeOrThread === 'string') return codeOrThread;
6
+ if (typeof codeOrThread === 'object') {
7
+ return codeOrThread.code || codeOrThread.numero || codeOrThread.thread_code || null;
8
+ }
9
+ return null;
10
+ }
11
+
12
+ function isBenchMode(codeOrThread) {
13
+ const code = extractCode(codeOrThread);
14
+ return typeof code === 'string' && code.startsWith(BENCH_CODE_PREFIX);
15
+ }
16
+
17
+ function hasBenchAttribute(attributes) {
18
+ if (!attributes || typeof attributes !== 'object') return false;
19
+ if (attributes.bench === true) return true;
20
+ for (const key of Object.keys(attributes)) {
21
+ const value = attributes[key];
22
+ if (typeof value === 'string' && value.startsWith(BENCH_CODE_PREFIX)) return true;
23
+ }
24
+ return false;
25
+ }
26
+
27
+ module.exports = {
28
+ BENCH_CODE_PREFIX,
29
+ isBenchMode,
30
+ hasBenchAttribute
31
+ };
@@ -1,6 +1,7 @@
1
1
  const { SpanStatusCode } = require('@opentelemetry/api');
2
2
 
3
3
  const { createSpan } = require('../observability');
4
+ const { hasBenchAttribute } = require('./benchModeHelper');
4
5
 
5
6
  const withTracing = (fn, spanName, attributeMapper = null, options = {}) => {
6
7
  return async function (...args) {
@@ -9,7 +10,11 @@ const withTracing = (fn, spanName, attributeMapper = null, options = {}) => {
9
10
 
10
11
  try {
11
12
  if (typeof attributeMapper === 'function') {
12
- span.setAttributes(attributeMapper(...args));
13
+ const attributes = attributeMapper(...args);
14
+ span.setAttributes(attributes);
15
+ if (hasBenchAttribute(attributes)) {
16
+ span.setAttribute('bench', true);
17
+ }
13
18
  }
14
19
  const result = await fn.apply(this, args);
15
20
  span.setStatus({ code: SpanStatusCode.OK });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "4.3.3",
3
+ "version": "4.4.0",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",