@peopl-health/nexus 2.5.0 → 2.5.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 +35 -0
- package/lib/core/NexusMessaging.js +20 -10
- package/lib/services/assistantService.js +166 -138
- package/package.json +1 -1
|
@@ -154,6 +154,41 @@ class TwilioProvider extends MessageProvider {
|
|
|
154
154
|
}
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
async sendTypingIndicator(messageId) {
|
|
158
|
+
try {
|
|
159
|
+
const response = await axios.post(
|
|
160
|
+
'https://messaging.twilio.com/v2/Indicators/Typing.json',
|
|
161
|
+
new URLSearchParams({
|
|
162
|
+
messageId: messageId,
|
|
163
|
+
channel: 'whatsapp'
|
|
164
|
+
}),
|
|
165
|
+
{
|
|
166
|
+
auth: {
|
|
167
|
+
username: this.accountSid,
|
|
168
|
+
password: this.authToken
|
|
169
|
+
},
|
|
170
|
+
headers: {
|
|
171
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
);
|
|
175
|
+
logger.info('[TwilioProvider] Typing indicator sent successfully', {
|
|
176
|
+
messageId,
|
|
177
|
+
success: response.data?.success,
|
|
178
|
+
status: response.status
|
|
179
|
+
});
|
|
180
|
+
return { success: true };
|
|
181
|
+
} catch (error) {
|
|
182
|
+
logger.error('[TwilioProvider] Failed to send typing indicator', {
|
|
183
|
+
error: error.message,
|
|
184
|
+
messageId,
|
|
185
|
+
status: error.response?.status,
|
|
186
|
+
data: error.response?.data
|
|
187
|
+
});
|
|
188
|
+
return { success: false };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
157
192
|
async sendScheduledMessage(scheduledMessage) {
|
|
158
193
|
const { sendTime, timeZone, __nexusSend } = scheduledMessage;
|
|
159
194
|
const delay = this.calculateDelay(sendTime, timeZone);
|
|
@@ -42,8 +42,7 @@ class NexusMessaging {
|
|
|
42
42
|
this.pendingResponses = new Map();
|
|
43
43
|
this.batchingConfig = {
|
|
44
44
|
enabled: config.messageBatching?.enabled ?? false,
|
|
45
|
-
baseWaitTime: config.messageBatching?.baseWaitTime ?? 15000
|
|
46
|
-
randomVariation: config.messageBatching?.randomVariation ?? 5000
|
|
45
|
+
baseWaitTime: config.messageBatching?.baseWaitTime ?? 15000
|
|
47
46
|
};
|
|
48
47
|
}
|
|
49
48
|
|
|
@@ -373,6 +372,22 @@ class NexusMessaging {
|
|
|
373
372
|
} else if (messageData.flow) {
|
|
374
373
|
return await this.handleFlow(messageData);
|
|
375
374
|
} else {
|
|
375
|
+
if (chatId && this.provider && typeof this.provider.sendTypingIndicator === 'function') {
|
|
376
|
+
const messageId = messageData.id || messageData.MessageSid || messageData.message_id;
|
|
377
|
+
if (messageId) {
|
|
378
|
+
this.provider.sendTypingIndicator(messageId)
|
|
379
|
+
.then(() => logger.debug('[processIncomingMessage] Typing indicator sent successfully', { messageId }))
|
|
380
|
+
.catch(err => logger.warn('[processIncomingMessage] Typing indicator failed', {
|
|
381
|
+
error: err.message,
|
|
382
|
+
messageId
|
|
383
|
+
}));
|
|
384
|
+
} else {
|
|
385
|
+
logger.warn('[processIncomingMessage] No message ID found for typing indicator', {
|
|
386
|
+
messageData: JSON.stringify(messageData).substring(0, 200)
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
376
391
|
// For regular messages and media, use batching if enabled
|
|
377
392
|
logger.info('Batching config:', this.batchingConfig);
|
|
378
393
|
if (this.batchingConfig.enabled && chatId) {
|
|
@@ -635,17 +650,12 @@ class NexusMessaging {
|
|
|
635
650
|
* Handle message with batching - waits for additional messages before processing
|
|
636
651
|
*/
|
|
637
652
|
async _handleWithBatching(messageData, chatId) {
|
|
638
|
-
// Clear existing timeout if there is one
|
|
639
653
|
if (this.pendingResponses.has(chatId)) {
|
|
640
654
|
clearTimeout(this.pendingResponses.get(chatId));
|
|
641
655
|
logger.info(`Received additional message from ${chatId}, resetting wait timer`);
|
|
642
656
|
}
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
const waitTime = this.batchingConfig.baseWaitTime +
|
|
646
|
-
Math.floor(Math.random() * this.batchingConfig.randomVariation);
|
|
647
|
-
|
|
648
|
-
// Set new timeout
|
|
657
|
+
|
|
658
|
+
const waitTime = this.batchingConfig.baseWaitTime;
|
|
649
659
|
const timeoutId = setTimeout(async () => {
|
|
650
660
|
try {
|
|
651
661
|
this.pendingResponses.delete(chatId);
|
|
@@ -685,7 +695,7 @@ class NexusMessaging {
|
|
|
685
695
|
this.events.emit('messages:batched', { chatId, response: botResponse });
|
|
686
696
|
|
|
687
697
|
} catch (error) {
|
|
688
|
-
logger.error('Error in batched message handling:', error);
|
|
698
|
+
logger.error('Error in batched message handling:', { error: error.message });
|
|
689
699
|
}
|
|
690
700
|
}
|
|
691
701
|
|
|
@@ -5,6 +5,7 @@ const runtimeConfig = require('../config/runtimeConfig');
|
|
|
5
5
|
const { BaseAssistant } = require('../assistants/BaseAssistant');
|
|
6
6
|
const { createProvider } = require('../providers/createProvider');
|
|
7
7
|
|
|
8
|
+
const { Message } = require('../models/messageModel.js');
|
|
8
9
|
const { Thread } = require('../models/threadModel.js');
|
|
9
10
|
const { PredictionMetrics } = require('../models/predictionMetricsModel');
|
|
10
11
|
const { insertMessage } = require('../models/messageModel');
|
|
@@ -328,161 +329,188 @@ const addInsAssistant = withTracing(
|
|
|
328
329
|
})
|
|
329
330
|
);
|
|
330
331
|
|
|
332
|
+
const startTypingIndicator = async (provider, code) => {
|
|
333
|
+
try {
|
|
334
|
+
const lastMessage = await Message.findOne({
|
|
335
|
+
numero: code,
|
|
336
|
+
from_me: false,
|
|
337
|
+
message_id: { $exists: true, $ne: null }
|
|
338
|
+
}).sort({ createdAt: -1 });
|
|
339
|
+
|
|
340
|
+
if (!lastMessage?.message_id) return null;
|
|
341
|
+
|
|
342
|
+
return setInterval(() =>
|
|
343
|
+
provider.sendTypingIndicator(lastMessage.message_id).catch(err =>
|
|
344
|
+
logger.debug('[startTypingIndicator] Interval failed', { error: err.message })
|
|
345
|
+
), 25000
|
|
346
|
+
);
|
|
347
|
+
} catch (err) {
|
|
348
|
+
logger.debug('[startTypingIndicator] Failed to start', { error: err.message });
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
|
|
331
353
|
const replyAssistantCore = async (code, message_ = null, thread_ = null, runOptions = {}) => {
|
|
332
354
|
const timings = {};
|
|
333
355
|
const startTotal = Date.now();
|
|
334
356
|
|
|
335
|
-
const { result: thread, duration: getThreadMs } = await withTracing(
|
|
336
|
-
getThread,
|
|
337
|
-
'get_thread_operation',
|
|
338
|
-
(threadCode) => ({
|
|
339
|
-
'thread.code': threadCode,
|
|
340
|
-
'operation.type': 'thread_retrieval',
|
|
341
|
-
'thread.provided': !!thread_
|
|
342
|
-
}),
|
|
343
|
-
{ returnTiming: true }
|
|
344
|
-
)(code);
|
|
345
|
-
timings.get_thread_ms = getThreadMs;
|
|
346
|
-
|
|
347
|
-
if (!thread_ && !thread) return null;
|
|
348
|
-
const finalThread = thread_ || thread;
|
|
349
|
-
|
|
350
|
-
const { result: patientReply, duration: getMessagesMs } = await withTracing(
|
|
351
|
-
getLastMessages,
|
|
352
|
-
'get_last_messages',
|
|
353
|
-
(code) => ({ 'thread.code': code }),
|
|
354
|
-
{ returnTiming: true }
|
|
355
|
-
)(code);
|
|
356
|
-
timings.get_messages_ms = getMessagesMs;
|
|
357
|
-
|
|
358
|
-
if (!patientReply) {
|
|
359
|
-
logger.info('[replyAssistantCore] No relevant data found for this assistant.');
|
|
360
|
-
return null;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
357
|
const provider = createProvider({ variant: process.env.VARIANT || 'assistants' });
|
|
358
|
+
const typingInterval = await startTypingIndicator(provider, code);
|
|
364
359
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
const urls = processResults.filter(r => r.url).map(r => ({ url: r.url }));
|
|
394
|
-
const allMessagesToAdd = processResults.flatMap(r => r.messages || []);
|
|
395
|
-
const allTempFiles = processResults.flatMap(r => r.tempFiles || []);
|
|
396
|
-
|
|
397
|
-
if (allMessagesToAdd.length > 0) {
|
|
398
|
-
logger.info(`[replyAssistantCore] Adding ${allMessagesToAdd.length} messages to thread in batch`);
|
|
399
|
-
await withThreadRecovery(
|
|
400
|
-
async (thread = finalThread) => {
|
|
401
|
-
const threadId = thread.getConversationId();
|
|
402
|
-
await provider.addMessage({ threadId, messages: allMessagesToAdd });
|
|
403
|
-
},
|
|
404
|
-
finalThread,
|
|
405
|
-
process.env.VARIANT || 'assistants'
|
|
406
|
-
);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
await Promise.all(processResults.map(r => updateMessageRecord(r.reply, finalThread)));
|
|
410
|
-
await cleanupFiles(allTempFiles);
|
|
360
|
+
try {
|
|
361
|
+
const { result: thread, duration: getThreadMs } = await withTracing(
|
|
362
|
+
getThread,
|
|
363
|
+
'get_thread_operation',
|
|
364
|
+
(threadCode) => ({
|
|
365
|
+
'thread.code': threadCode,
|
|
366
|
+
'operation.type': 'thread_retrieval',
|
|
367
|
+
'thread.provided': !!thread_
|
|
368
|
+
}),
|
|
369
|
+
{ returnTiming: true }
|
|
370
|
+
)(code);
|
|
371
|
+
timings.get_thread_ms = getThreadMs;
|
|
372
|
+
|
|
373
|
+
if (!thread_ && !thread) return null;
|
|
374
|
+
const finalThread = thread_ || thread;
|
|
375
|
+
|
|
376
|
+
const { result: patientReply, duration: getMessagesMs } = await withTracing(
|
|
377
|
+
getLastMessages,
|
|
378
|
+
'get_last_messages',
|
|
379
|
+
(code) => ({ 'thread.code': code }),
|
|
380
|
+
{ returnTiming: true }
|
|
381
|
+
)(code);
|
|
382
|
+
timings.get_messages_ms = getMessagesMs;
|
|
383
|
+
|
|
384
|
+
if (!patientReply) {
|
|
385
|
+
logger.info('[replyAssistantCore] No relevant data found for this assistant.');
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
411
388
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
'
|
|
419
|
-
'pdf.url_count': urls.length
|
|
389
|
+
logger.info(`[replyAssistantCore] Processing ${patientReply.length} messages in parallel`);
|
|
390
|
+
const { result: processResult, duration: processMessagesMs } = await withTracing(
|
|
391
|
+
processThreadMessage,
|
|
392
|
+
'process_thread_messages',
|
|
393
|
+
(code, patientReply, provider) => ({
|
|
394
|
+
'messages.count': patientReply.length,
|
|
395
|
+
'thread.code': code
|
|
420
396
|
}),
|
|
421
397
|
{ returnTiming: true }
|
|
422
|
-
)(
|
|
423
|
-
timings.pdf_combination_ms = pdfCombinationMs;
|
|
424
|
-
const { pdfBuffer, processedFiles } = pdfResult;
|
|
425
|
-
logger.info(`[replyAssistantCore] PDF combination complete: ${processedFiles?.length || 0} files processed`);
|
|
398
|
+
)(code, patientReply, provider);
|
|
426
399
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
400
|
+
const { results: processResults, timings: processTimings } = processResult;
|
|
401
|
+
timings.process_messages_ms = processMessagesMs;
|
|
402
|
+
|
|
403
|
+
logger.debug('[replyAssistantCore] Process timings breakdown', { processTimings });
|
|
404
|
+
|
|
405
|
+
if (processTimings) {
|
|
406
|
+
timings.process_messages_breakdown = {
|
|
407
|
+
download_ms: processTimings.download_ms || 0,
|
|
408
|
+
image_analysis_ms: processTimings.image_analysis_ms || 0,
|
|
409
|
+
audio_transcription_ms: processTimings.audio_transcription_ms || 0,
|
|
410
|
+
url_generation_ms: processTimings.url_generation_ms || 0,
|
|
411
|
+
total_media_ms: processTimings.total_media_ms || 0
|
|
412
|
+
};
|
|
433
413
|
}
|
|
414
|
+
|
|
415
|
+
const patientMsg = processResults.some(r => r.isPatient);
|
|
416
|
+
const urls = processResults.filter(r => r.url).map(r => ({ url: r.url }));
|
|
417
|
+
const allMessagesToAdd = processResults.flatMap(r => r.messages || []);
|
|
418
|
+
const allTempFiles = processResults.flatMap(r => r.tempFiles || []);
|
|
434
419
|
|
|
435
|
-
if (
|
|
436
|
-
|
|
420
|
+
if (allMessagesToAdd.length > 0) {
|
|
421
|
+
logger.info(`[replyAssistantCore] Adding ${allMessagesToAdd.length} messages to thread in batch`);
|
|
422
|
+
await withThreadRecovery(
|
|
423
|
+
async (thread = finalThread) => {
|
|
424
|
+
const threadId = thread.getConversationId();
|
|
425
|
+
await provider.addMessage({ threadId, messages: allMessagesToAdd });
|
|
426
|
+
},
|
|
427
|
+
finalThread,
|
|
428
|
+
process.env.VARIANT || 'assistants'
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
await Promise.all(processResults.map(r => updateMessageRecord(r.reply, finalThread)));
|
|
433
|
+
await cleanupFiles(allTempFiles);
|
|
434
|
+
|
|
435
|
+
if (urls.length > 0) {
|
|
436
|
+
logger.info(`[replyAssistantCore] Processing ${urls.length} URLs for PDF combination`);
|
|
437
|
+
const { result: pdfResult, duration: pdfCombinationMs } = await withTracing(
|
|
438
|
+
combineImagesToPDF,
|
|
439
|
+
'combine_images_to_pdf',
|
|
440
|
+
({ code }) => ({
|
|
441
|
+
'pdf.thread_code': code,
|
|
442
|
+
'pdf.url_count': urls.length
|
|
443
|
+
}),
|
|
444
|
+
{ returnTiming: true }
|
|
445
|
+
)({ code });
|
|
446
|
+
timings.pdf_combination_ms = pdfCombinationMs;
|
|
447
|
+
const { pdfBuffer, processedFiles } = pdfResult;
|
|
448
|
+
logger.info(`[replyAssistantCore] PDF combination complete: ${processedFiles?.length || 0} files processed`);
|
|
449
|
+
|
|
450
|
+
if (pdfBuffer) {
|
|
451
|
+
const key = `${code}-${Date.now()}-combined.pdf`;
|
|
452
|
+
const bucket = runtimeConfig.get('AWS_S3_BUCKET_NAME');
|
|
453
|
+
if (bucket) {
|
|
454
|
+
await AWS.uploadBufferToS3(pdfBuffer, bucket, key, 'application/pdf');
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (processedFiles && processedFiles.length) {
|
|
459
|
+
cleanupFiles(processedFiles);
|
|
460
|
+
}
|
|
437
461
|
}
|
|
438
|
-
}
|
|
439
462
|
|
|
440
|
-
|
|
463
|
+
if (!patientMsg || finalThread.stopped) return null;
|
|
441
464
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
const { run, output, completed, retries, predictionTimeMs, tools_executed } = runResult;
|
|
457
|
-
|
|
458
|
-
logger.info('[Assistant Reply Complete]', {
|
|
459
|
-
code: code ? `${code.substring(0, 3)}***${code.slice(-4)}` : 'unknown',
|
|
460
|
-
messageCount: patientReply.length,
|
|
461
|
-
hasMedia: urls.length > 0,
|
|
462
|
-
retries,
|
|
463
|
-
totalMs: timings.total_ms,
|
|
464
|
-
toolsExecuted: tools_executed?.length || 0
|
|
465
|
-
});
|
|
466
|
-
|
|
467
|
-
if (output && predictionTimeMs) {
|
|
468
|
-
logger.debug('[replyAssistantCore] Storing metrics with timing_breakdown', {
|
|
469
|
-
timing_breakdown: timings,
|
|
470
|
-
has_breakdown: !!timings.process_messages_breakdown
|
|
471
|
-
});
|
|
465
|
+
const assistant = getAssistantById(finalThread.getAssistantId(), finalThread);
|
|
466
|
+
const { result: runResult, duration: runAssistantMs } = await withTracing(
|
|
467
|
+
runAssistantWithRetries,
|
|
468
|
+
'run_assistant_with_retries',
|
|
469
|
+
(thread, assistant, runConfig, patientReply) => ({
|
|
470
|
+
'assistant.id': thread.getAssistantId(),
|
|
471
|
+
'assistant.max_retries': DEFAULT_MAX_RETRIES,
|
|
472
|
+
'assistant.has_patient_reply': !!patientReply
|
|
473
|
+
}),
|
|
474
|
+
{ returnTiming: true }
|
|
475
|
+
)(finalThread, assistant, runOptions, patientReply);
|
|
476
|
+
timings.run_assistant_ms = runAssistantMs;
|
|
477
|
+
timings.total_ms = Date.now() - startTotal;
|
|
472
478
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
})
|
|
483
|
-
|
|
479
|
+
const { run, output, completed, retries, predictionTimeMs, tools_executed } = runResult;
|
|
480
|
+
|
|
481
|
+
logger.info('[Assistant Reply Complete]', {
|
|
482
|
+
code: code ? `${code.substring(0, 3)}***${code.slice(-4)}` : 'unknown',
|
|
483
|
+
messageCount: patientReply.length,
|
|
484
|
+
hasMedia: urls.length > 0,
|
|
485
|
+
retries,
|
|
486
|
+
totalMs: timings.total_ms,
|
|
487
|
+
toolsExecuted: tools_executed?.length || 0
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
if (output && predictionTimeMs) {
|
|
491
|
+
logger.debug('[replyAssistantCore] Storing metrics with timing_breakdown', {
|
|
492
|
+
timing_breakdown: timings,
|
|
493
|
+
has_breakdown: !!timings.process_messages_breakdown
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
await PredictionMetrics.create({
|
|
497
|
+
message_id: `${code}-${Date.now()}`,
|
|
498
|
+
numero: code,
|
|
499
|
+
assistant_id: finalThread.getAssistantId(),
|
|
500
|
+
thread_id: finalThread.getConversationId(),
|
|
501
|
+
prediction_time_ms: predictionTimeMs,
|
|
502
|
+
retry_count: retries,
|
|
503
|
+
completed: completed,
|
|
504
|
+
timing_breakdown: timings
|
|
505
|
+
}).catch(err => logger.error('[replyAssistantCore] Failed to store metrics:', err));
|
|
506
|
+
}
|
|
484
507
|
|
|
485
|
-
|
|
508
|
+
return { output, tools_executed };
|
|
509
|
+
} finally {
|
|
510
|
+
if (typingInterval) {
|
|
511
|
+
clearInterval(typingInterval);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
486
514
|
};
|
|
487
515
|
|
|
488
516
|
const replyAssistant = withTracing(
|