@peopl-health/nexus 2.2.10 → 2.3.1
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/adapters/TwilioProvider.js +0 -6
- package/lib/helpers/assistantHelper.js +92 -269
- package/lib/helpers/baileysHelper.js +1 -11
- package/lib/helpers/filesHelper.js +75 -32
- package/lib/helpers/mediaHelper.js +4 -10
- package/lib/helpers/messageHelper.js +136 -0
- package/lib/helpers/processHelper.js +238 -0
- package/lib/helpers/threadHelper.js +73 -0
- package/lib/helpers/twilioHelper.js +2 -14
- package/lib/models/messageModel.js +0 -1
- package/lib/observability/index.js +184 -0
- package/lib/observability/telemetry.js +118 -0
- package/lib/providers/OpenAIResponsesProvider.js +0 -3
- package/lib/services/assistantService.js +107 -203
- package/lib/storage/MongoStorage.js +0 -2
- package/lib/utils/logger.js +91 -4
- package/lib/utils/sanitizer.js +62 -0
- package/lib/utils/tracingDecorator.js +48 -0
- package/package.json +13 -1
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
const moment = require('moment-timezone');
|
|
2
|
+
const { Message } = require('../models/messageModel.js');
|
|
3
|
+
|
|
4
|
+
const addMessageToThread = async (reply, messagesChat, provider, thread) => {
|
|
5
|
+
const threadId = thread.getConversationId();
|
|
6
|
+
|
|
7
|
+
if (reply.origin === 'whatsapp_platform') {
|
|
8
|
+
await provider.addMessage({
|
|
9
|
+
threadId,
|
|
10
|
+
role: 'assistant',
|
|
11
|
+
content: messagesChat
|
|
12
|
+
});
|
|
13
|
+
} else if (reply.origin === 'patient') {
|
|
14
|
+
await provider.addMessage({
|
|
15
|
+
threadId,
|
|
16
|
+
role: 'user',
|
|
17
|
+
content: messagesChat
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
console.log(`[addMessageToThread] Message added - ID: ${reply.message_id}, Thread: ${threadId}, Origin: ${reply.origin}`);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const updateMessageRecord = async (reply, thread) => {
|
|
25
|
+
const threadId = thread.getConversationId();
|
|
26
|
+
|
|
27
|
+
await Message.updateOne(
|
|
28
|
+
{ message_id: reply.message_id, timestamp: reply.timestamp },
|
|
29
|
+
{ $set: {
|
|
30
|
+
assistant_id: thread.getAssistantId(),
|
|
31
|
+
thread_id: threadId,
|
|
32
|
+
processed: true
|
|
33
|
+
} }
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
console.log(`[updateMessageRecord] Record updated - ID: ${reply.message_id}, Thread: ${threadId}, Processed: true`);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async function getLastMessages(code) {
|
|
41
|
+
try {
|
|
42
|
+
let query = { processed: false };
|
|
43
|
+
if (code.endsWith('@g.us')) {
|
|
44
|
+
query = { ...query, numero: code, $or: [{ origin: 'patient' }, { origin: 'whatsapp_platform' }] };
|
|
45
|
+
} else {
|
|
46
|
+
query = { ...query, numero: code, $or: [{ origin: 'patient' }] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const lastMessages = await Message.find(query)
|
|
50
|
+
.sort({ createdAt: 1 });
|
|
51
|
+
|
|
52
|
+
if (!lastMessages || lastMessages.length === 0) {
|
|
53
|
+
console.log(`[getLastMessages] No messages found for code: ${code ? `${code.substring(0, 3)}***${code.slice(-4)}` : 'unknown'}`);
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const unprocessedMessages = lastMessages.filter(msg => !msg.processed);
|
|
58
|
+
if (unprocessedMessages.length === 0) {
|
|
59
|
+
console.log(`[getLastMessages] No unprocessed messages for code: ${code ? `${code.substring(0, 3)}***${code.slice(-4)}` : 'unknown'}`);
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log(`[getLastMessages] Found ${unprocessedMessages.length} unprocessed messages for code: ${code ? `${code.substring(0, 3)}***${code.slice(-4)}` : 'unknown'}`);
|
|
64
|
+
return unprocessedMessages;
|
|
65
|
+
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error(`[getLastMessages] Error for code: ${code ? `${code.substring(0, 3)}***${code.slice(-4)}` : 'unknown'}:`, error?.message || String(error));
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function getLastNMessages(code, n) {
|
|
73
|
+
try {
|
|
74
|
+
const lastMessages = await Message.find({ numero: code })
|
|
75
|
+
.sort({ createdAt: -1 })
|
|
76
|
+
.limit(n);
|
|
77
|
+
|
|
78
|
+
if (!lastMessages || lastMessages.length === 0) {
|
|
79
|
+
console.log(`[getLastNMessages] No messages found for code: ${code ? `${code.substring(0, 3)}***${code.slice(-4)}` : 'unknown'}, limit: ${n}`);
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
console.log(`[getLastNMessages] Found ${lastMessages.length} messages for code: ${code ? `${code.substring(0, 3)}***${code.slice(-4)}` : 'unknown'}, limit: ${n}`);
|
|
84
|
+
return lastMessages;
|
|
85
|
+
|
|
86
|
+
} catch (error) {
|
|
87
|
+
console.error(`[getLastNMessages] Error for code: ${code ? `${code.substring(0, 3)}***${code.slice(-4)}` : 'unknown'}, limit: ${n}:`, error?.message || String(error));
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function formatMessage(reply) {
|
|
93
|
+
try {
|
|
94
|
+
if (!reply.timestamp) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const timestamp = parseInt(reply.timestamp) * 1000;
|
|
99
|
+
const msgDate = new Date(timestamp);
|
|
100
|
+
|
|
101
|
+
if (isNaN(msgDate.getTime())) {
|
|
102
|
+
console.warn(`[formatMessage] Invalid timestamp for message ID: ${reply.message_id}`);
|
|
103
|
+
return reply.body;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const mexicoCityTime = moment(msgDate)
|
|
107
|
+
.tz('America/Mexico_City')
|
|
108
|
+
.locale('es')
|
|
109
|
+
.format('dddd, D [de] MMMM [de] YYYY [a las] h:mm A');
|
|
110
|
+
|
|
111
|
+
return `[${mexicoCityTime}] ${reply.body}`;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
console.error(`[formatMessage] Error for message ID: ${reply.message_id}:`, error?.message || String(error));
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function isRecentMessage(chatId) {
|
|
119
|
+
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
|
|
120
|
+
|
|
121
|
+
const recentMessage = await Message.find({
|
|
122
|
+
$or: [{ group_id: chatId }, { numero: chatId }],
|
|
123
|
+
createdAt: { $gte: fiveMinutesAgo }
|
|
124
|
+
}).sort({ createdAt: -1 }).limit(1);
|
|
125
|
+
|
|
126
|
+
return !!recentMessage;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = {
|
|
130
|
+
addMessageToThread,
|
|
131
|
+
updateMessageRecord,
|
|
132
|
+
getLastMessages,
|
|
133
|
+
getLastNMessages,
|
|
134
|
+
formatMessage,
|
|
135
|
+
isRecentMessage
|
|
136
|
+
};
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const { generatePresignedUrl } = require('../config/awsConfig.js');
|
|
3
|
+
const { analyzeImage } = require('./llmsHelper.js');
|
|
4
|
+
const { cleanupFiles, downloadMediaAndCreateFile } = require('./filesHelper.js');
|
|
5
|
+
const { formatMessage, addMessageToThread, updateMessageRecord } = require('./messageHelper.js');
|
|
6
|
+
const { sanitizeLogMetadata } = require('../utils/sanitizer.js');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Structured logging with PHI protection
|
|
10
|
+
*/
|
|
11
|
+
const DEBUG_ENABLED = process.env.DEBUG_MESSAGE_CONTENT === 'true';
|
|
12
|
+
const logger = {
|
|
13
|
+
info: (context, metadata = {}) => {
|
|
14
|
+
const safeMetadata = sanitizeLogMetadata(metadata);
|
|
15
|
+
console.log(`[${context}]`, JSON.stringify(safeMetadata));
|
|
16
|
+
},
|
|
17
|
+
warn: (context, metadata = {}) => {
|
|
18
|
+
const safeMetadata = sanitizeLogMetadata(metadata);
|
|
19
|
+
console.warn(`[${context}]`, JSON.stringify(safeMetadata));
|
|
20
|
+
},
|
|
21
|
+
error: (context, error, metadata = {}) => {
|
|
22
|
+
const safeMetadata = sanitizeLogMetadata(metadata);
|
|
23
|
+
console.error(`[${context}]`, {
|
|
24
|
+
error: error?.message || String(error),
|
|
25
|
+
...safeMetadata
|
|
26
|
+
});
|
|
27
|
+
},
|
|
28
|
+
debug: (context, sensitiveData = {}) => {
|
|
29
|
+
if (DEBUG_ENABLED) {
|
|
30
|
+
console.log(`[DEBUG:${context}]`, sensitiveData);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Dedicated message processing utilities
|
|
37
|
+
* Handles text messages, media files, audio transcription, and thread operations
|
|
38
|
+
*/
|
|
39
|
+
const processTextMessage = (reply) => {
|
|
40
|
+
const formattedMessage = formatMessage(reply);
|
|
41
|
+
logger.info('processTextMessage', {
|
|
42
|
+
message_id: reply.message_id,
|
|
43
|
+
timestamp: reply.timestamp,
|
|
44
|
+
from_me: reply.from_me,
|
|
45
|
+
body: reply.body,
|
|
46
|
+
hasContent: !!formattedMessage
|
|
47
|
+
});
|
|
48
|
+
logger.debug('processTextMessage_content', { formattedMessage });
|
|
49
|
+
|
|
50
|
+
const messagesChat = [];
|
|
51
|
+
if (formattedMessage) {
|
|
52
|
+
messagesChat.push({ type: 'text', text: formattedMessage });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return messagesChat;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const processImageFile = async (fileName, reply) => {
|
|
59
|
+
let imageAnalysis = null;
|
|
60
|
+
let url = null;
|
|
61
|
+
const messagesChat = [];
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
imageAnalysis = await analyzeImage(fileName);
|
|
65
|
+
|
|
66
|
+
logger.info('processImageFile', {
|
|
67
|
+
message_id: reply.message_id,
|
|
68
|
+
bucketName: reply.media?.bucketName,
|
|
69
|
+
key: reply.media?.key,
|
|
70
|
+
medical_relevance: imageAnalysis?.medical_relevance,
|
|
71
|
+
has_table: imageAnalysis?.has_table,
|
|
72
|
+
analysis_type: imageAnalysis?.medical_analysis ? 'medical' : 'general'
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
logger.debug('processImageFile_analysis', { imageAnalysis });
|
|
76
|
+
|
|
77
|
+
const invalidAnalysis = ['NOT_MEDICAL', 'QUALITY_INSUFFICIENT'];
|
|
78
|
+
|
|
79
|
+
// Generate presigned URL if medically relevant
|
|
80
|
+
if (imageAnalysis?.medical_relevance) {
|
|
81
|
+
url = await generatePresignedUrl(reply.media.bucketName, reply.media.key);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Add appropriate text based on analysis
|
|
85
|
+
if (imageAnalysis?.has_table && imageAnalysis.table_data) {
|
|
86
|
+
messagesChat.push({
|
|
87
|
+
type: 'text',
|
|
88
|
+
text: imageAnalysis.table_data,
|
|
89
|
+
});
|
|
90
|
+
} else if (imageAnalysis?.medical_analysis && !invalidAnalysis.some(tag => imageAnalysis.medical_analysis.includes(tag))) {
|
|
91
|
+
messagesChat.push({
|
|
92
|
+
type: 'text',
|
|
93
|
+
text: imageAnalysis.medical_analysis,
|
|
94
|
+
});
|
|
95
|
+
} else {
|
|
96
|
+
messagesChat.push({
|
|
97
|
+
type: 'text',
|
|
98
|
+
text: imageAnalysis?.description || 'Image processed',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
} catch (error) {
|
|
102
|
+
logger.error('processImageFile', error, {
|
|
103
|
+
message_id: reply.message_id,
|
|
104
|
+
fileName: fileName ? fileName.split('/').pop() : 'unknown'
|
|
105
|
+
});
|
|
106
|
+
messagesChat.push({
|
|
107
|
+
type: 'text',
|
|
108
|
+
text: 'Image received but could not be analyzed',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { messagesChat, url };
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const processAudioFile = async (fileName, provider) => {
|
|
116
|
+
const messagesChat = [];
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const audioTranscript = await provider.transcribeAudio({
|
|
120
|
+
file: fs.createReadStream(fileName),
|
|
121
|
+
responseFormat: 'text',
|
|
122
|
+
language: 'es'
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const transcriptText = audioTranscript?.text || audioTranscript;
|
|
126
|
+
messagesChat.push({
|
|
127
|
+
type: 'text',
|
|
128
|
+
text: transcriptText,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
logger.info('processAudioFile', {
|
|
132
|
+
fileName: fileName ? fileName.split('/').pop().replace(/^[^-]+-[^-]+-/, 'xxx-xxx-') : 'unknown',
|
|
133
|
+
transcription_success: true,
|
|
134
|
+
transcript_length: transcriptText?.length || 0
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
logger.debug('processAudioFile_transcript', { transcriptText });
|
|
138
|
+
|
|
139
|
+
} catch (error) {
|
|
140
|
+
logger.error('processAudioFile', error, {
|
|
141
|
+
fileName: fileName ? fileName.split('/').pop().replace(/^[^-]+-[^-]+-/, 'xxx-xxx-') : 'unknown'
|
|
142
|
+
});
|
|
143
|
+
messagesChat.push({
|
|
144
|
+
type: 'text',
|
|
145
|
+
text: 'Audio received but could not be transcribed',
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return messagesChat;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const processMediaFiles = async (code, reply, provider) => {
|
|
153
|
+
let url = null;
|
|
154
|
+
const messagesChat = [];
|
|
155
|
+
const tempFiles = [];
|
|
156
|
+
|
|
157
|
+
if (!reply.is_media) {
|
|
158
|
+
return { messagesChat, url, tempFiles };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
logger.info('processMediaFiles', {
|
|
162
|
+
message_id: reply.message_id,
|
|
163
|
+
processing_media: true
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const fileNames = await downloadMediaAndCreateFile(code, reply);
|
|
167
|
+
tempFiles.push(...fileNames);
|
|
168
|
+
|
|
169
|
+
for (const fileName of fileNames) {
|
|
170
|
+
const safeFileName = fileName ? fileName.split('/').pop().replace(/^[^-]+-[^-]+-/, 'xxx-xxx-') : 'unknown';
|
|
171
|
+
|
|
172
|
+
logger.info('processMediaFiles_file', {
|
|
173
|
+
message_id: reply.message_id,
|
|
174
|
+
fileName: safeFileName
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Skip unsupported file types
|
|
178
|
+
if (fileName.toLowerCase().includes('.wbmp') || fileName.toLowerCase().includes('sticker')) {
|
|
179
|
+
logger.info('processMediaFiles_skip', {
|
|
180
|
+
message_id: reply.message_id,
|
|
181
|
+
fileName: safeFileName,
|
|
182
|
+
reason: 'unsupported_format'
|
|
183
|
+
});
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (fileName.includes('image') || fileName.includes('document') || fileName.includes('application')) {
|
|
188
|
+
const { messagesChat: imageMessages, url: imageUrl } = await processImageFile(fileName, reply);
|
|
189
|
+
messagesChat.push(...imageMessages);
|
|
190
|
+
if (imageUrl) url = imageUrl;
|
|
191
|
+
} else if (fileName.includes('audio')) {
|
|
192
|
+
const audioMessages = await processAudioFile(fileName, provider);
|
|
193
|
+
messagesChat.push(...audioMessages);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { messagesChat, url, tempFiles };
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const processIndividualMessage = async (code, reply, provider, thread) => {
|
|
201
|
+
let tempFiles = [];
|
|
202
|
+
try {
|
|
203
|
+
const isPatient = reply.origin === 'patient';
|
|
204
|
+
const textMessages = processTextMessage(reply);
|
|
205
|
+
|
|
206
|
+
const { messagesChat: mediaMessages, url, tempFiles: mediaFiles } = await processMediaFiles(code, reply, provider);
|
|
207
|
+
tempFiles = mediaFiles;
|
|
208
|
+
|
|
209
|
+
const allMessages = [...textMessages, ...mediaMessages];
|
|
210
|
+
|
|
211
|
+
if (allMessages.length > 0) {
|
|
212
|
+
await addMessageToThread(reply, allMessages, provider, thread);
|
|
213
|
+
await updateMessageRecord(reply, thread);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { isPatient, url };
|
|
217
|
+
} catch (error) {
|
|
218
|
+
logger.error('processIndividualMessage', error, {
|
|
219
|
+
message_id: reply.message_id,
|
|
220
|
+
code: code ? `${code.substring(0, 3)}***${code.slice(-4)}` : 'unknown',
|
|
221
|
+
origin: reply.origin
|
|
222
|
+
});
|
|
223
|
+
await cleanupFiles(tempFiles);
|
|
224
|
+
return { isPatient: false, url: null };
|
|
225
|
+
} finally {
|
|
226
|
+
if (tempFiles.length > 0) {
|
|
227
|
+
await cleanupFiles(tempFiles);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
module.exports = {
|
|
233
|
+
processTextMessage,
|
|
234
|
+
processImageFile,
|
|
235
|
+
processAudioFile,
|
|
236
|
+
processMediaFiles,
|
|
237
|
+
processIndividualMessage
|
|
238
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const { Thread } = require('../models/threadModel.js');
|
|
2
|
+
const { createProvider } = require('../providers/createProvider.js');
|
|
3
|
+
const llmConfig = require('../config/llmConfig.js');
|
|
4
|
+
|
|
5
|
+
const log = (level, context, data, error = null) => {
|
|
6
|
+
const safeData = { ...data };
|
|
7
|
+
if (safeData.code) safeData.code = `${safeData.code.substring(0, 3)}***${safeData.code.slice(-4)}`;
|
|
8
|
+
console[level](`[${context}]`, error ? { error: error?.message || String(error), ...safeData } : safeData);
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const TERMINAL_STATUSES = ['cancelled', 'expired', 'completed', 'failed', 'incomplete'];
|
|
12
|
+
const MAX_ATTEMPTS = 30;
|
|
13
|
+
const MAX_WAIT_MS = 30000;
|
|
14
|
+
|
|
15
|
+
const getThread = async (code, message = null) => {
|
|
16
|
+
try {
|
|
17
|
+
let thread = await Thread.findOne({ code: code });
|
|
18
|
+
log('log', 'getThread', { code, hasThread: !!thread, hasRunId: !!(thread?.run_id) });
|
|
19
|
+
|
|
20
|
+
if (!thread && message) {
|
|
21
|
+
thread = new Thread({ code, active: true });
|
|
22
|
+
await thread.save();
|
|
23
|
+
}
|
|
24
|
+
if (thread?.run_id) {
|
|
25
|
+
const provider = createProvider({ variant: process.env.VARIANT || 'assistants' }) || llmConfig.requireOpenAIProvider();
|
|
26
|
+
let attempts = 0;
|
|
27
|
+
const start = Date.now();
|
|
28
|
+
|
|
29
|
+
while (thread.run_id && attempts < MAX_ATTEMPTS && (Date.now() - start) < MAX_WAIT_MS) {
|
|
30
|
+
attempts++;
|
|
31
|
+
try {
|
|
32
|
+
const run = await provider.getRun({ threadId: thread.getConversationId(), runId: thread.run_id });
|
|
33
|
+
|
|
34
|
+
if (TERMINAL_STATUSES.includes(run.status)) {
|
|
35
|
+
log('log', 'getThread_terminal', { code, status: run.status, attempts });
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
} catch (error) {
|
|
39
|
+
log('error', 'getThread_getRun_failed', { code, attempt: attempts }, error);
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (attempts >= MAX_ATTEMPTS || (Date.now() - start) >= MAX_WAIT_MS) {
|
|
46
|
+
log('warn', 'getThread_timeout', { code, attempts, elapsed: Date.now() - start });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
await Thread.updateOne({ code, active: true }, { $set: { run_id: null } });
|
|
50
|
+
thread = await Thread.findOne({ code });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return thread;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
log('error', 'getThread', { code }, error);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const getThreadInfo = async (code) => {
|
|
61
|
+
try {
|
|
62
|
+
let thread = await Thread.findOne({ code: code });
|
|
63
|
+
return thread;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
log('error', 'getThreadInfo', { code }, error);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
getThread,
|
|
72
|
+
getThreadInfo
|
|
73
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
const { Message } = require('../models/messageModel');
|
|
2
|
-
|
|
1
|
+
const { Message } = require('../models/messageModel.js');
|
|
2
|
+
const { isRecentMessage } = require('./messageHelper.js');
|
|
3
3
|
const axios = require('axios');
|
|
4
4
|
const { v4: uuidv4 } = require('uuid');
|
|
5
5
|
|
|
@@ -65,18 +65,6 @@ function extractTitle(message, mediaType) {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
|
|
68
|
-
async function isRecentMessage(chatId) {
|
|
69
|
-
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
|
|
70
|
-
|
|
71
|
-
const recentMessage = await Message.find({
|
|
72
|
-
$or: [{ group_id: chatId }, { numero: chatId }],
|
|
73
|
-
createdAt: { $gte: fiveMinutesAgo }
|
|
74
|
-
}).sort({ createdAt: -1 }).limit(1);
|
|
75
|
-
|
|
76
|
-
return !!recentMessage;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
|
|
80
68
|
async function getLastMessages(chatId, n) {
|
|
81
69
|
const messages = await Message.find({ numero: chatId })
|
|
82
70
|
.sort({ createdAt: -1 })
|
|
@@ -106,7 +106,6 @@ async function insertMessage(values) {
|
|
|
106
106
|
origin: values.origin,
|
|
107
107
|
raw: values.raw || null
|
|
108
108
|
};
|
|
109
|
-
console.log('[MongoStorage] Inserting message', messageData);
|
|
110
109
|
|
|
111
110
|
await Message.findOneAndUpdate(
|
|
112
111
|
{ message_id: values.message_id, body: values.body },
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
const { initTelemetry, shutdownTelemetry } = require('./telemetry');
|
|
2
|
+
const { trace, metrics } = require('@opentelemetry/api');
|
|
3
|
+
|
|
4
|
+
const tracer = trace.getTracer('nexus-assistant');
|
|
5
|
+
const meter = metrics.getMeter('nexus-assistant');
|
|
6
|
+
|
|
7
|
+
const responseTimeHistogram = meter.createHistogram('nexus_response_time', {
|
|
8
|
+
description: 'Response time in milliseconds',
|
|
9
|
+
unit: 'ms',
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const operationCounter = meter.createCounter('nexus_operations_total', {
|
|
13
|
+
description: 'Total number of operations',
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const activeOperationsGauge = meter.createUpDownCounter('nexus_active_operations', {
|
|
17
|
+
description: 'Currently active operations',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const assistantReplyDuration = meter.createHistogram('nexus_assistant_reply_duration', {
|
|
21
|
+
description: 'Assistant reply duration in milliseconds',
|
|
22
|
+
unit: 'ms',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const assistantInstructionDuration = meter.createHistogram('nexus_assistant_instruction_duration', {
|
|
26
|
+
description: 'Assistant instruction execution duration in milliseconds',
|
|
27
|
+
unit: 'ms',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const assistantRetryCounter = meter.createCounter('nexus_assistant_retries_total', {
|
|
31
|
+
description: 'Total number of assistant operation retries',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const threadOperationsCounter = meter.createCounter('nexus_thread_operations_total', {
|
|
35
|
+
description: 'Total number of thread operations',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const messageProcessingDuration = meter.createHistogram('nexus_message_processing_duration', {
|
|
39
|
+
description: 'Message processing duration in milliseconds',
|
|
40
|
+
unit: 'ms',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const fileOperationsCounter = meter.createCounter('nexus_file_operations_total', {
|
|
44
|
+
description: 'Total number of file operations',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const s3UploadDuration = meter.createHistogram('nexus_s3_upload_duration', {
|
|
48
|
+
description: 'S3 upload duration in milliseconds',
|
|
49
|
+
unit: 'ms',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Wrapper function to trace any async operation
|
|
54
|
+
*/
|
|
55
|
+
async function traceOperation(name, operation, attributes = {}) {
|
|
56
|
+
const span = tracer.startSpan(name, { attributes });
|
|
57
|
+
const startTime = Date.now();
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
activeOperationsGauge.add(1, attributes);
|
|
61
|
+
|
|
62
|
+
const result = await operation(span);
|
|
63
|
+
|
|
64
|
+
const duration = Date.now() - startTime;
|
|
65
|
+
|
|
66
|
+
responseTimeHistogram.record(duration, attributes);
|
|
67
|
+
operationCounter.add(1, { ...attributes, success: true });
|
|
68
|
+
|
|
69
|
+
span.setAttributes({
|
|
70
|
+
'operation.duration_ms': duration,
|
|
71
|
+
'operation.success': true,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
span.setStatus({ code: 1 });
|
|
75
|
+
return result;
|
|
76
|
+
|
|
77
|
+
} catch (error) {
|
|
78
|
+
const duration = Date.now() - startTime;
|
|
79
|
+
|
|
80
|
+
operationCounter.add(1, { ...attributes, success: false });
|
|
81
|
+
responseTimeHistogram.record(duration, { ...attributes, error: true });
|
|
82
|
+
|
|
83
|
+
span.recordException(error);
|
|
84
|
+
span.setStatus({ code: 2, message: error.message });
|
|
85
|
+
span.setAttributes({
|
|
86
|
+
'operation.duration_ms': duration,
|
|
87
|
+
'operation.success': false,
|
|
88
|
+
'error.message': error.message,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
throw error;
|
|
92
|
+
} finally {
|
|
93
|
+
activeOperationsGauge.add(-1, attributes);
|
|
94
|
+
span.end();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Helper to add custom span with automatic cleanup
|
|
100
|
+
*/
|
|
101
|
+
function createSpan(name, attributes = {}) {
|
|
102
|
+
return tracer.startSpan(name, { attributes });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Initialize telemetry with config
|
|
107
|
+
*/
|
|
108
|
+
function init(config = {}) {
|
|
109
|
+
initTelemetry(config);
|
|
110
|
+
console.log('🎯 Custom tracing and metrics ready');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Enhanced tracing functions for specific operations
|
|
115
|
+
*/
|
|
116
|
+
function traceAssistantReply(operation, attributes = {}) {
|
|
117
|
+
return traceOperation('assistant_reply', async (span) => {
|
|
118
|
+
const startTime = Date.now();
|
|
119
|
+
try {
|
|
120
|
+
const result = await operation(span);
|
|
121
|
+
const duration = Date.now() - startTime;
|
|
122
|
+
assistantReplyDuration.record(duration, attributes);
|
|
123
|
+
return result;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
const duration = Date.now() - startTime;
|
|
126
|
+
assistantReplyDuration.record(duration, { ...attributes, error: true });
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
}, attributes);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function traceAssistantInstruction(operation, attributes = {}) {
|
|
133
|
+
return traceOperation('assistant_instruction', async (span) => {
|
|
134
|
+
const startTime = Date.now();
|
|
135
|
+
try {
|
|
136
|
+
const result = await operation(span);
|
|
137
|
+
const duration = Date.now() - startTime;
|
|
138
|
+
assistantInstructionDuration.record(duration, attributes);
|
|
139
|
+
return result;
|
|
140
|
+
} catch (error) {
|
|
141
|
+
const duration = Date.now() - startTime;
|
|
142
|
+
assistantInstructionDuration.record(duration, { ...attributes, error: true });
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}, attributes);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function recordAssistantRetry(attributes = {}) {
|
|
149
|
+
assistantRetryCounter.add(1, attributes);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function recordThreadOperation(operationType, attributes = {}) {
|
|
153
|
+
threadOperationsCounter.add(1, { ...attributes, operation_type: operationType });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function recordFileOperation(operationType, attributes = {}) {
|
|
157
|
+
fileOperationsCounter.add(1, { ...attributes, operation_type: operationType });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = {
|
|
161
|
+
init,
|
|
162
|
+
shutdown: shutdownTelemetry,
|
|
163
|
+
traceOperation,
|
|
164
|
+
createSpan,
|
|
165
|
+
tracer,
|
|
166
|
+
meter,
|
|
167
|
+
// Enhanced tracing functions
|
|
168
|
+
traceAssistantReply,
|
|
169
|
+
traceAssistantInstruction,
|
|
170
|
+
recordAssistantRetry,
|
|
171
|
+
recordThreadOperation,
|
|
172
|
+
recordFileOperation,
|
|
173
|
+
// Metrics
|
|
174
|
+
responseTimeHistogram,
|
|
175
|
+
operationCounter,
|
|
176
|
+
activeOperationsGauge,
|
|
177
|
+
assistantReplyDuration,
|
|
178
|
+
assistantInstructionDuration,
|
|
179
|
+
assistantRetryCounter,
|
|
180
|
+
threadOperationsCounter,
|
|
181
|
+
messageProcessingDuration,
|
|
182
|
+
fileOperationsCounter,
|
|
183
|
+
s3UploadDuration,
|
|
184
|
+
};
|