@peopl-health/nexus 2.4.6 → 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
|
|
20
|
-
if (imagePath.toLowerCase().includes('.wbmp')
|
|
21
|
-
console.log('Skipping WBMP image
|
|
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'
|
|
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
|
-
//
|
|
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
|
|
179
|
-
if (fileName.toLowerCase().includes('.wbmp')
|
|
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
|
-
|
|
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;
|
|
@@ -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 {
|
|
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 (
|
|
121
|
-
|
|
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 });
|
|
@@ -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
|
};
|