@peopl-health/nexus 2.4.5 → 2.4.7

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.
@@ -3,12 +3,13 @@ const fs = require('fs');
3
3
  const mime = require('mime-types');
4
4
 
5
5
 
6
- async function analyzeImage(imagePath) {
6
+ async function analyzeImage(imagePath, isSticker = false) {
7
7
  try {
8
8
  const anthropicClient = llmConfig.anthropicClient;
9
9
  if (!anthropicClient || !anthropicClient.messages) {
10
10
  console.warn('[llmsHelper] Anthropics client not configured; skipping image analysis');
11
11
  return {
12
+ description: 'Image could not be analyzed - Anthropic client not configured',
12
13
  medical_analysis: 'QUALITY_INSUFFICIENT',
13
14
  medical_relevance: false,
14
15
  has_table: false,
@@ -16,10 +17,11 @@ async function analyzeImage(imagePath) {
16
17
  };
17
18
  }
18
19
 
19
- // Skip WBMP images and stickers
20
- if (imagePath.toLowerCase().includes('.wbmp') || imagePath.toLowerCase().includes('sticker')) {
21
- console.log('Skipping WBMP image or sticker analysis:', imagePath);
20
+ // Skip only WBMP images (unsupported format)
21
+ if (imagePath.toLowerCase().includes('.wbmp')) {
22
+ console.log('Skipping WBMP image analysis:', imagePath);
22
23
  return {
24
+ description: 'Unsupported image format',
23
25
  medical_analysis: 'NOT_MEDICAL',
24
26
  medical_relevance: false,
25
27
  has_table: false,
@@ -29,9 +31,10 @@ async function analyzeImage(imagePath) {
29
31
 
30
32
  // Check MIME type
31
33
  const mimeType = mime.lookup(imagePath) || 'image/jpeg';
32
- if (mimeType === 'image/vnd.wap.wbmp' || mimeType.includes('sticker')) {
34
+ if (mimeType === 'image/vnd.wap.wbmp') {
33
35
  console.log('Skipping image with MIME type:', mimeType);
34
36
  return {
37
+ description: 'Unsupported image format',
35
38
  medical_analysis: 'NOT_MEDICAL',
36
39
  medical_relevance: false,
37
40
  has_table: false,
@@ -42,7 +45,7 @@ async function analyzeImage(imagePath) {
42
45
  const imageBuffer = await fs.promises.readFile(imagePath);
43
46
  const base64Image = imageBuffer.toString('base64');
44
47
 
45
- // Descrciption of the image
48
+ // Description of the image (for both stickers and regular images)
46
49
  const imageDescription = 'Describe the image in detail.';
47
50
  const messageDescription = await anthropicClient.messages.create({
48
51
  model: 'claude-sonnet-4-5',
@@ -69,6 +72,17 @@ async function analyzeImage(imagePath) {
69
72
  });
70
73
  const description = messageDescription.content[0].text;
71
74
 
75
+ // For stickers, skip medical analysis and table extraction
76
+ if (isSticker) {
77
+ return {
78
+ description: description,
79
+ medical_analysis: 'NOT_MEDICAL',
80
+ medical_relevance: false,
81
+ has_table: false,
82
+ table_data: null
83
+ };
84
+ }
85
+
72
86
  // Create a more specific prompt for table detection and extraction
73
87
  const tablePrompt = `Please analyze this image and respond in the following format:
74
88
  1. First, determine if there is a table in the image.
@@ -61,13 +61,18 @@ const processImageFile = async (fileName, reply) => {
61
61
  let url = null;
62
62
  const messagesChat = [];
63
63
 
64
+ const isSticker = reply.media?.mediaType === 'sticker' ||
65
+ fileName.toLowerCase().includes('sticker/') ||
66
+ fileName.toLowerCase().includes('/sticker/');
67
+
64
68
  try {
65
- imageAnalysis = await analyzeImage(fileName);
69
+ imageAnalysis = await analyzeImage(fileName, isSticker);
66
70
 
67
71
  logger.info('processImageFile', {
68
72
  message_id: reply.message_id,
69
73
  bucketName: reply.media?.bucketName,
70
74
  key: reply.media?.key,
75
+ is_sticker: isSticker,
71
76
  medical_relevance: imageAnalysis?.medical_relevance,
72
77
  has_table: imageAnalysis?.has_table,
73
78
  analysis_type: imageAnalysis?.medical_analysis ? 'medical' : 'general'
@@ -77,18 +82,18 @@ const processImageFile = async (fileName, reply) => {
77
82
 
78
83
  const invalidAnalysis = ['NOT_MEDICAL', 'QUALITY_INSUFFICIENT'];
79
84
 
80
- // Generate presigned URL if medically relevant
81
- if (imageAnalysis?.medical_relevance) {
85
+ // Generate presigned URL only if medically relevant AND not a sticker
86
+ if (imageAnalysis?.medical_relevance && !isSticker) {
82
87
  url = await generatePresignedUrl(reply.media.bucketName, reply.media.key);
83
88
  }
84
89
 
85
90
  // Add appropriate text based on analysis
86
- if (imageAnalysis?.has_table && imageAnalysis.table_data) {
91
+ if (!isSticker && imageAnalysis?.has_table && imageAnalysis.table_data) {
87
92
  messagesChat.push({
88
93
  type: 'text',
89
94
  text: imageAnalysis.table_data,
90
95
  });
91
- } else if (imageAnalysis?.medical_analysis && !invalidAnalysis.some(tag => imageAnalysis.medical_analysis.includes(tag))) {
96
+ } else if (!isSticker && imageAnalysis?.medical_analysis && !invalidAnalysis.some(tag => imageAnalysis.medical_analysis.includes(tag))) {
92
97
  messagesChat.push({
93
98
  type: 'text',
94
99
  text: imageAnalysis.medical_analysis,
@@ -102,7 +107,8 @@ const processImageFile = async (fileName, reply) => {
102
107
  } catch (error) {
103
108
  logger.error('processImageFile', error, {
104
109
  message_id: reply.message_id,
105
- fileName: fileName ? fileName.split('/').pop() : 'unknown'
110
+ fileName: fileName ? fileName.split('/').pop() : 'unknown',
111
+ is_sticker: isSticker
106
112
  });
107
113
  messagesChat.push({
108
114
  type: 'text',
@@ -175,8 +181,8 @@ const processMediaFiles = async (code, reply, provider) => {
175
181
  fileName: safeFileName
176
182
  });
177
183
 
178
- // Skip unsupported file types
179
- if (fileName.toLowerCase().includes('.wbmp') || fileName.toLowerCase().includes('sticker')) {
184
+ // Skip only WBMP files (unsupported format)
185
+ if (fileName.toLowerCase().includes('.wbmp')) {
180
186
  logger.info('processMediaFiles_skip', {
181
187
  message_id: reply.message_id,
182
188
  fileName: safeFileName,
@@ -185,7 +191,15 @@ const processMediaFiles = async (code, reply, provider) => {
185
191
  continue;
186
192
  }
187
193
 
188
- if (fileName.includes('image') || fileName.includes('document') || fileName.includes('application')) {
194
+ // Process images, documents, applications, and stickers as images
195
+ const isImageLike = fileName.includes('image') ||
196
+ fileName.includes('document') ||
197
+ fileName.includes('application') ||
198
+ reply.media?.mediaType === 'sticker' ||
199
+ fileName.toLowerCase().includes('sticker/') ||
200
+ fileName.toLowerCase().includes('/sticker/');
201
+
202
+ if (isImageLike) {
189
203
  const { messagesChat: imageMessages, url: imageUrl } = await processImageFile(fileName, reply);
190
204
  messagesChat.push(...imageMessages);
191
205
  if (imageUrl) url = imageUrl;
@@ -198,36 +212,43 @@ const processMediaFiles = async (code, reply, provider) => {
198
212
  return { messagesChat, url, tempFiles };
199
213
  };
200
214
 
201
- const processIndividualMessage = async (code, reply, provider, thread) => {
202
- let tempFiles = [];
203
- try {
204
- const isPatient = reply.origin === 'patient';
205
- const textMessages = processTextMessage(reply);
206
-
207
- const { messagesChat: mediaMessages, url, tempFiles: mediaFiles } = await processMediaFiles(code, reply, provider);
208
- tempFiles = mediaFiles;
209
-
210
- const allMessages = [...textMessages, ...mediaMessages];
211
-
212
- if (allMessages.length > 0) {
213
- await addMessageToThread(reply, allMessages, provider, thread);
214
- await updateMessageRecord(reply, thread);
215
- }
216
-
217
- return { isPatient, url };
218
- } catch (error) {
219
- logger.error('processIndividualMessage', error, {
220
- message_id: reply.message_id,
221
- code: code ? `${code.substring(0, 3)}***${code.slice(-4)}` : 'unknown',
222
- origin: reply.origin
223
- });
224
- await cleanupFiles(tempFiles);
225
- return { isPatient: false, url: null };
226
- } finally {
227
- if (tempFiles.length > 0) {
228
- await cleanupFiles(tempFiles);
229
- }
230
- }
215
+ const processThreadMessage = async (code, replies, provider) => {
216
+ const replyArray = Array.isArray(replies) ? replies : [replies];
217
+
218
+ const results = await Promise.all(
219
+ replyArray.map(async (reply, i) => {
220
+ let tempFiles = [];
221
+ try {
222
+ const isPatient = reply.origin === 'patient';
223
+ const [textMessages, mediaResult] = await Promise.all([
224
+ Promise.resolve(processTextMessage(reply)),
225
+ processMediaFiles(code, reply, provider)
226
+ ]);
227
+
228
+ const { messagesChat: mediaMessages, url, tempFiles: mediaFiles } = mediaResult;
229
+ tempFiles = mediaFiles;
230
+
231
+ const allMessages = [...textMessages, ...mediaMessages];
232
+ const role = reply.origin === 'patient' ? 'user' : 'assistant';
233
+ const messages = allMessages.map(content => ({ role, content }));
234
+
235
+ logger.info('processThreadMessage', {
236
+ index: i + 1,
237
+ total: replyArray.length,
238
+ isPatient,
239
+ hasUrl: !!url
240
+ });
241
+
242
+ return { isPatient, url, messages, reply, tempFiles };
243
+ } catch (error) {
244
+ logger.error('processThreadMessage', error, { message_id: reply.message_id, origin: reply.origin });
245
+ await cleanupFiles(tempFiles);
246
+ return { isPatient: false, url: null, messages: [], reply, tempFiles: [] };
247
+ }
248
+ })
249
+ );
250
+
251
+ return results;
231
252
  };
232
253
 
233
254
  module.exports = {
@@ -235,5 +256,5 @@ module.exports = {
235
256
  processImageFile,
236
257
  processAudioFile,
237
258
  processMediaFiles,
238
- processIndividualMessage
259
+ processThreadMessage
239
260
  };
@@ -7,7 +7,7 @@ const {
7
7
  } = require('./twilioHelper');
8
8
  const { validateMedia } = require('../utils/mediaValidator');
9
9
  const { generatePresignedUrl } = require('../config/awsConfig');
10
- const { addRecord, getRecordByFilter } = require('../services/airtableService');
10
+ const { addLinkedRecord } = require('../services/airtableService');
11
11
  const { Monitoreo_ID } = require('../config/airtableConfig');
12
12
 
13
13
  const ensureLogger = (logger) => {
@@ -24,6 +24,34 @@ const normalizeMediaType = (type) => {
24
24
  return type.replace(/Message$/i, '').replace(/WithCaption$/i, '').toLowerCase();
25
25
  };
26
26
 
27
+ async function uploadMediaToAirtable(whatsappId, mediaUrl, log, baseID, tableName) {
28
+ if (!baseID) {
29
+ log.debug && log.debug('[uploadMediaToAirtable] Base ID not configured; skipping Airtable upload');
30
+ return;
31
+ }
32
+
33
+ try {
34
+ await addLinkedRecord(
35
+ baseID,
36
+ tableName,
37
+ {
38
+ estudios: [{ url: mediaUrl }],
39
+ combined_estudios: [{ url: mediaUrl }]
40
+ },
41
+ {
42
+ referenceTable: 'estado_general',
43
+ referenceFilter: `whatsapp_id = "${whatsappId}"`,
44
+ linkFieldName: 'patient_id'
45
+ }
46
+ );
47
+
48
+ log.debug && log.debug('[uploadMediaToAirtable] Successfully uploaded media to estudios table', { whatsappId });
49
+ } catch (error) {
50
+ log.warn && log.warn('[uploadMediaToAirtable] Failed to upload media to estudios table', { whatsappId, error: error?.message || error });
51
+ throw error;
52
+ }
53
+ }
54
+
27
55
  async function processTwilioMediaMessage(twilioMessage, logger, bucketName) {
28
56
  if (!twilioMessage) return [];
29
57
 
@@ -117,16 +145,8 @@ async function processTwilioMediaMessage(twilioMessage, logger, bucketName) {
117
145
  const url = await generatePresignedUrl(bucketName, key);
118
146
  item.metadata.presignedUrl = url;
119
147
 
120
- if (Monitoreo_ID) {
121
- const patient = await getRecordByFilter(Monitoreo_ID, 'estado_general', `whatsapp_id = "${code}"`);
122
- const patientId = Array.isArray(patient) && patient.length > 0 ? [patient[0].recordID || patient[0].recordId || patient[0].record_id] : [];
123
- await addRecord(Monitoreo_ID, 'estudios', [{
124
- fields: {
125
- estudios: [{ url }],
126
- combined_estudios: [{ url }],
127
- patient_id: patientId
128
- }
129
- }]);
148
+ if (derivedMediaType !== 'sticker') {
149
+ await uploadMediaToAirtable(code, url, log, Monitoreo_ID, 'estudios');
130
150
  }
131
151
  } catch (error) {
132
152
  log.warn && log.warn('[TwilioMedia] Failed to update Airtable with media reference', { index: i, error: error?.message || error });
@@ -82,22 +82,28 @@ class OpenAIAssistantsProvider {
82
82
  await this.client.beta.threads.del(this._ensureId(threadId));
83
83
  }
84
84
 
85
- async addMessage({ threadId, role = 'user', content, metadata }) {
86
- const formattedContent = this._normalizeContent(content);
87
-
88
- const payload = {
89
- role,
90
- content: formattedContent,
91
- metadata,
92
- };
93
-
94
- if (payload.metadata && Object.keys(payload.metadata).length === 0) {
95
- delete payload.metadata;
85
+ async addMessage({ threadId, messages, role = 'user', content, metadata }) {
86
+ const id = this._ensureId(threadId);
87
+ const messagesToAdd = messages || [{ role, content, metadata }];
88
+
89
+ const results = [];
90
+ for (const msg of messagesToAdd) {
91
+ const formattedContent = this._normalizeContent(msg.content);
92
+ const payload = {
93
+ role: msg.role || 'user',
94
+ content: formattedContent,
95
+ };
96
+
97
+ if (msg.metadata && Object.keys(msg.metadata).length > 0) {
98
+ payload.metadata = msg.metadata;
99
+ }
100
+
101
+ const result = await this._retryWithRateLimit(() =>
102
+ this.client.beta.threads.messages.create(id, payload)
103
+ );
104
+ results.push(result);
96
105
  }
97
-
98
- return this._retryWithRateLimit(() =>
99
- this.client.beta.threads.messages.create(this._ensureId(threadId), payload)
100
- );
106
+ return messages ? results : results[0];
101
107
  }
102
108
 
103
109
  async listMessages({ threadId, runId, order = 'desc', limit } = {}) {
@@ -197,23 +197,26 @@ class OpenAIResponsesProvider {
197
197
  return await this._delete(`/conversations/${id}`);
198
198
  }
199
199
 
200
- async addMessage({ threadId, role = 'user', content, metadata }) {
200
+ async addMessage({ threadId, messages, role = 'user', content, metadata }) {
201
201
  const id = this._ensurethreadId(threadId);
202
+ const messagesToAdd = messages || [{ role, content, metadata }];
202
203
 
203
- const payload = this._cleanObject({
204
- role,
205
- content: this._normalizeContent(role, content),
206
- type: 'message',
207
- });
204
+ const payloads = messagesToAdd.map(msg =>
205
+ this._cleanObject({
206
+ role: msg.role || 'user',
207
+ content: this._normalizeContent(msg.role || 'user', msg.content),
208
+ type: 'message',
209
+ })
210
+ ).filter(p => p.content);
208
211
 
209
- if (payload.content) {
210
- return this._retryWithRateLimit(async () => {
211
- if (this.conversations?.items?.create) {
212
- return await this.conversations.items.create(id, {items: [payload]});
213
- }
214
- return await this._post(`/conversations/${id}/items`, {items: [payload]});
215
- });
216
- }
212
+ if (payloads.length === 0) return null;
213
+
214
+ return this._retryWithRateLimit(async () => {
215
+ if (this.conversations?.items?.create) {
216
+ return await this.conversations.items.create(id, { items: payloads });
217
+ }
218
+ return await this._post(`/conversations/${id}/items`, { items: payloads });
219
+ });
217
220
  }
218
221
 
219
222
  async listMessages({ threadId, order = 'desc', limit } = {}) {
@@ -78,9 +78,41 @@ async function updateRecordByFilter(baseID, tableName, filter, updateFields) {
78
78
  }
79
79
  }
80
80
 
81
+
82
+ async function addLinkedRecord(baseID, targetTable, fields, linkConfig) {
83
+ if (!baseID) {
84
+ throw new Error('[addLinkedRecord] Base ID is required');
85
+ }
86
+
87
+ try {
88
+ if (linkConfig) {
89
+ const { referenceTable, referenceFilter, linkFieldName } = linkConfig;
90
+
91
+ const base = airtable.base(baseID);
92
+ const referenceRecords = await base(referenceTable).select({
93
+ filterByFormula: referenceFilter
94
+ }).firstPage();
95
+
96
+ if (linkFieldName && referenceRecords && referenceRecords.length > 0) {
97
+ const recordId = referenceRecords[0].id;
98
+ if (recordId) {
99
+ fields[linkFieldName] = [recordId];
100
+ }
101
+ }
102
+ }
103
+
104
+ const record = await addRecord(baseID, targetTable, [{ fields }]);
105
+ return record;
106
+ } catch (error) {
107
+ console.error('[addLinkedRecord] Error adding linked record:', error);
108
+ throw error;
109
+ }
110
+ }
111
+
81
112
  module.exports = {
82
113
  addRecord,
83
114
  getRecords,
84
115
  getRecordByFilter,
85
- updateRecordByFilter
116
+ updateRecordByFilter,
117
+ addLinkedRecord
86
118
  };
@@ -12,8 +12,8 @@ const { getCurRow } = require('../helpers/assistantHelper.js');
12
12
  const { runAssistantAndWait, runAssistantWithRetries } = require('../helpers/assistantHelper.js');
13
13
  const { getThread, getThreadInfo } = require('../helpers/threadHelper.js');
14
14
  const { withTracing } = require('../utils/tracingDecorator.js');
15
- const { processIndividualMessage } = require('../helpers/processHelper.js');
16
- const { getLastMessages } = require('../helpers/messageHelper.js');
15
+ const { processThreadMessage } = require('../helpers/processHelper.js');
16
+ const { getLastMessages, updateMessageRecord } = require('../helpers/messageHelper.js');
17
17
  const { combineImagesToPDF, cleanupFiles } = require('../helpers/filesHelper.js');
18
18
  const { logger } = require('../middleware/requestId');
19
19
 
@@ -298,19 +298,24 @@ const replyAssistantCore = async (code, message_ = null, thread_ = null, runOpti
298
298
 
299
299
  timings.processMessages = Date.now();
300
300
  logger.log(`[replyAssistantCore] Processing ${patientReply.length} messages in parallel`);
301
- const results = await Promise.all(
302
- patientReply.map((reply, i) =>
303
- processIndividualMessage(code, reply, provider, thread)
304
- .then(result => {
305
- logger.log(`[replyAssistantCore] Message ${i + 1}/${patientReply.length}: isPatient=${result.isPatient}, hasUrl=${!!result.url}`);
306
- return result;
307
- })
308
- )
309
- );
310
- timings.processMessages = Date.now() - timings.processMessages;
311
301
 
312
- const patientMsg = results.some(r => r.isPatient);
313
- const urls = results.filter(r => r.url).map(r => ({ url: r.url }));
302
+ const processResults = await processThreadMessage(code, patientReply, provider);
303
+
304
+ const patientMsg = processResults.some(r => r.isPatient);
305
+ const urls = processResults.filter(r => r.url).map(r => ({ url: r.url }));
306
+ const allMessagesToAdd = processResults.flatMap(r => r.messages || []);
307
+ const allTempFiles = processResults.flatMap(r => r.tempFiles || []);
308
+
309
+ if (allMessagesToAdd.length > 0) {
310
+ const threadId = thread.getConversationId();
311
+ logger.log(`[replyAssistantCore] Adding ${allMessagesToAdd.length} messages to thread in batch`);
312
+ await provider.addMessage({ threadId, messages: allMessagesToAdd });
313
+ }
314
+
315
+ await Promise.all(processResults.map(r => updateMessageRecord(r.reply, thread)));
316
+ await cleanupFiles(allTempFiles);
317
+
318
+ timings.processMessages = Date.now() - timings.processMessages;
314
319
 
315
320
  if (urls.length > 0) {
316
321
  timings.pdfCombination = Date.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "2.4.5",
3
+ "version": "2.4.7",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",