@peopl-health/nexus 2.6.0 → 2.7.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.
- package/examples/basic-usage.js +3 -4
- package/lib/assistants/BaseAssistant.js +1 -16
- package/lib/config/airtableConfig.js +3 -0
- package/lib/controllers/conversationController.js +38 -28
- package/lib/core/NexusMessaging.js +66 -87
- package/lib/helpers/messageHelper.js +0 -26
- package/lib/models/messageModel.js +6 -0
- package/lib/providers/OpenAIResponsesProvider.js +85 -80
- package/lib/providers/OpenAIResponsesProviderTools.js +4 -1
- package/lib/providers/createProvider.js +11 -1
- package/lib/services/ConversationManager.js +43 -0
- package/lib/services/DefaultConversationManager.js +207 -0
- package/lib/services/airtableService.js +1 -1
- package/lib/services/assistantServiceCore.js +0 -23
- package/lib/services/conversationService.js +21 -9
- package/package.json +2 -2
package/examples/basic-usage.js
CHANGED
|
@@ -66,13 +66,12 @@ class GeneralAssistant extends BaseAssistant {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
async function startServer() {
|
|
69
|
-
// Initialize Nexus with
|
|
69
|
+
// Initialize Nexus with check-after processing (immediate response, checks for new messages after)
|
|
70
70
|
const nexus = new Nexus({
|
|
71
71
|
messaging: {
|
|
72
72
|
messageBatching: {
|
|
73
|
-
enabled: true,
|
|
74
|
-
|
|
75
|
-
randomVariation: 5000 // 0-5 seconds random variation
|
|
73
|
+
enabled: true, // Enable check-after processing
|
|
74
|
+
checkDelayMs: 100 // Delay before checking for new messages (ms)
|
|
76
75
|
}
|
|
77
76
|
}
|
|
78
77
|
});
|
|
@@ -170,22 +170,7 @@ class BaseAssistant {
|
|
|
170
170
|
async create(code, context = {}) {
|
|
171
171
|
this._ensureClient();
|
|
172
172
|
this.status = 'active';
|
|
173
|
-
|
|
174
|
-
const provider = createProvider({ variant: process.env.VARIANT || 'assistants' });
|
|
175
|
-
if (!provider || typeof provider.createConversation !== 'function') {
|
|
176
|
-
throw new Error('Provider not configured. Cannot create conversation.');
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const initialMessages = await this.buildInitialMessages({ code, context });
|
|
180
|
-
const conversation = await provider.createConversation({
|
|
181
|
-
messages: initialMessages,
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
this.thread = {
|
|
185
|
-
...conversation,
|
|
186
|
-
thread_id: conversation?.id,
|
|
187
|
-
code,
|
|
188
|
-
};
|
|
173
|
+
this.thread = { code };
|
|
189
174
|
|
|
190
175
|
return this.thread;
|
|
191
176
|
}
|
|
@@ -13,6 +13,7 @@ const Logging_ID = require('./runtimeConfig').get('AIRTABLE_LOGGING_ID') || 'app
|
|
|
13
13
|
const Monitoreo_ID = require('./runtimeConfig').get('AIRTABLE_MONITOREO_ID') || 'appdvraKSdp0XVn5n';
|
|
14
14
|
const Programa_Juntas_ID = require('./runtimeConfig').get('AIRTABLE_PROGRAMA_JUNTAS_ID') || 'appKFWzkcDEWlrXBE';
|
|
15
15
|
const Symptoms_ID = require('./runtimeConfig').get('AIRTABLE_SYMPTOMS_ID') || 'appQRhZlQ9tMfYZWJ';
|
|
16
|
+
const Follow_Up_ID = require('./runtimeConfig').get('AIRTABLE_FOLLOW_UP_ID') || 'appBjKw1Ub0KkbZf0';
|
|
16
17
|
const Webinars_Leads_ID = require('./runtimeConfig').get('AIRTABLE_WEBINARS_LEADS_ID') || 'appzjpVXTI0TgqGPq';
|
|
17
18
|
const Product_ID = require('./runtimeConfig').get('AIRTABLE_PRODUCT_ID') || 'appu2YDW2pKDYLL5H';
|
|
18
19
|
|
|
@@ -30,6 +31,7 @@ const BASE_MAP = {
|
|
|
30
31
|
monitoreo: Monitoreo_ID,
|
|
31
32
|
programa: Programa_Juntas_ID,
|
|
32
33
|
symptoms: Symptoms_ID,
|
|
34
|
+
followup: Follow_Up_ID,
|
|
33
35
|
webinars: Webinars_Leads_ID,
|
|
34
36
|
product: Product_ID
|
|
35
37
|
};
|
|
@@ -44,6 +46,7 @@ module.exports = {
|
|
|
44
46
|
Monitoreo_ID,
|
|
45
47
|
Programa_Juntas_ID,
|
|
46
48
|
Symptoms_ID,
|
|
49
|
+
Follow_Up_ID,
|
|
47
50
|
Webinars_Leads_ID,
|
|
48
51
|
Product_ID,
|
|
49
52
|
// Helper function to get base by ID
|
|
@@ -13,7 +13,7 @@ const Message = mongoose.models.Message;
|
|
|
13
13
|
|
|
14
14
|
const getConversationController = async (req, res) => {
|
|
15
15
|
const startTime = Date.now();
|
|
16
|
-
logger.info(
|
|
16
|
+
logger.info(`Starting getConversationController at ${new Date().toISOString()}`);
|
|
17
17
|
|
|
18
18
|
try {
|
|
19
19
|
// Parse pagination parameters
|
|
@@ -34,7 +34,7 @@ const getConversationController = async (req, res) => {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
const messageCount = await Message.countDocuments({});
|
|
37
|
-
logger.info(
|
|
37
|
+
logger.info(`Total message count: ${messageCount}`);
|
|
38
38
|
|
|
39
39
|
if (messageCount === 0) {
|
|
40
40
|
logger.info('No messages found in database, returning empty conversations list');
|
|
@@ -63,7 +63,7 @@ const getConversationController = async (req, res) => {
|
|
|
63
63
|
const totalPages = Math.ceil(total / limit);
|
|
64
64
|
const totalTime = Date.now() - startTime;
|
|
65
65
|
|
|
66
|
-
logger.info(
|
|
66
|
+
logger.info(`Number of conversations found: ${conversations?.length || 0}`);
|
|
67
67
|
logger.info(`Total controller execution time: ${totalTime}ms`);
|
|
68
68
|
logger.info(`Filter: ${filter}, Pagination: ${conversations.length} of ${total} conversations (page ${page}/${totalPages})`);
|
|
69
69
|
|
|
@@ -84,7 +84,7 @@ const getConversationController = async (req, res) => {
|
|
|
84
84
|
|
|
85
85
|
} catch (error) {
|
|
86
86
|
logger.error('Error fetching conversations:', { error });
|
|
87
|
-
logger.error('Error stack:', {
|
|
87
|
+
logger.error('Error stack:', { stack: error.stack });
|
|
88
88
|
res.status(500).json({
|
|
89
89
|
success: false,
|
|
90
90
|
error: error.message || 'Failed to fetch conversations'
|
|
@@ -93,10 +93,10 @@ const getConversationController = async (req, res) => {
|
|
|
93
93
|
};
|
|
94
94
|
|
|
95
95
|
const getConversationMessagesController = async (req, res) => {
|
|
96
|
-
logger.info(
|
|
96
|
+
logger.info(`Starting getConversationMessagesController at ${new Date().toISOString()}`);
|
|
97
97
|
try {
|
|
98
98
|
const { phoneNumber } = req.params;
|
|
99
|
-
logger.info(
|
|
99
|
+
logger.info(`Requested conversation for phone number: ${phoneNumber}`);
|
|
100
100
|
|
|
101
101
|
if (!phoneNumber) {
|
|
102
102
|
return res.status(400).json({
|
|
@@ -113,13 +113,12 @@ const getConversationMessagesController = async (req, res) => {
|
|
|
113
113
|
try {
|
|
114
114
|
query.createdAt = { $lt: new Date(before) };
|
|
115
115
|
} catch (parseError) {
|
|
116
|
-
logger.warn(
|
|
116
|
+
logger.warn(`Invalid date format for before parameter: ${before}`, { parseError });
|
|
117
117
|
query.createdAt = { $lt: before };
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
logger.info(
|
|
122
|
-
logger.info('Executing Message.find', { query });
|
|
121
|
+
logger.info(`Fetching conversation messages ${query}, limit ${limit}`);
|
|
123
122
|
let messages = [];
|
|
124
123
|
|
|
125
124
|
try {
|
|
@@ -136,18 +135,18 @@ const getConversationMessagesController = async (req, res) => {
|
|
|
136
135
|
|
|
137
136
|
const problematicMessages = messages.filter(msg => {
|
|
138
137
|
if (!msg || !msg.numero || !msg.createdAt) {
|
|
139
|
-
logger.warn(
|
|
138
|
+
logger.warn(`Found message missing required fields: ${msg?._id || 'unknown'}`);
|
|
140
139
|
return true;
|
|
141
140
|
}
|
|
142
141
|
|
|
143
142
|
if (msg.media) {
|
|
144
143
|
if (!msg.media || typeof msg.media !== 'object') {
|
|
145
|
-
logger.warn(
|
|
144
|
+
logger.warn(`Found media message with invalid media data: ${msg._id}`);
|
|
146
145
|
return true;
|
|
147
146
|
}
|
|
148
147
|
|
|
149
148
|
if (msg.media && (typeof msg.media.data === 'function' || msg.media.data instanceof Buffer)) {
|
|
150
|
-
logger.warn(
|
|
149
|
+
logger.warn(`Found media message with Buffer data that might cause serialization issues: ${msg._id}`);
|
|
151
150
|
return true;
|
|
152
151
|
}
|
|
153
152
|
}
|
|
@@ -157,12 +156,13 @@ const getConversationMessagesController = async (req, res) => {
|
|
|
157
156
|
|
|
158
157
|
if (problematicMessages.length > 0) {
|
|
159
158
|
logger.warn(`Found ${problematicMessages.length} potentially problematic messages`);
|
|
160
|
-
logger.info(
|
|
159
|
+
logger.info(`First problematic message IDs: ${problematicMessages.slice(0, 3).map(m => m?._id || 'unknown')}`);
|
|
161
160
|
}
|
|
162
161
|
} else {
|
|
163
162
|
logger.info('No messages found for this query');
|
|
164
163
|
}
|
|
165
164
|
} catch (err) {
|
|
165
|
+
logger.error('Database query error in message retrieval:', { err });
|
|
166
166
|
logger.error('Database query error in message retrieval:', { err });
|
|
167
167
|
messages = [];
|
|
168
168
|
}
|
|
@@ -174,7 +174,7 @@ const getConversationMessagesController = async (req, res) => {
|
|
|
174
174
|
JSON.stringify(msg);
|
|
175
175
|
return msg;
|
|
176
176
|
} catch (serializationError) {
|
|
177
|
-
logger.error(`Found non-serializable message with ID ${msg._id}:`, { serializationError
|
|
177
|
+
logger.error(`Found non-serializable message with ID ${msg._id}:`, { serializationError}) ;
|
|
178
178
|
return {
|
|
179
179
|
_id: msg._id?.toString() || 'unknown',
|
|
180
180
|
numero: msg.numero || phoneNumber,
|
|
@@ -197,6 +197,7 @@ const getConversationMessagesController = async (req, res) => {
|
|
|
197
197
|
});
|
|
198
198
|
logger.info('Successfully sent conversation messages response');
|
|
199
199
|
} catch (error) {
|
|
200
|
+
logger.error(`Error fetching conversation for ${req.params?.phoneNumber || 'unknown'}:`, { error });
|
|
200
201
|
logger.error(`Error fetching conversation for ${req.params?.phoneNumber || 'unknown'}:`, { error });
|
|
201
202
|
res.status(500).json({
|
|
202
203
|
success: false,
|
|
@@ -206,7 +207,7 @@ const getConversationMessagesController = async (req, res) => {
|
|
|
206
207
|
};
|
|
207
208
|
|
|
208
209
|
const getConversationReplyController = async (req, res) => {
|
|
209
|
-
logger.info(
|
|
210
|
+
logger.info(`Starting getConversationReplyController at ${new Date().toISOString()}`);
|
|
210
211
|
try {
|
|
211
212
|
const { phoneNumber, message, mediaData, contentSid, variables } = req.body;
|
|
212
213
|
logger.info('Reply request params:', {
|
|
@@ -225,7 +226,7 @@ const getConversationReplyController = async (req, res) => {
|
|
|
225
226
|
}
|
|
226
227
|
|
|
227
228
|
const formattedPhoneNumber = ensureWhatsAppFormat(phoneNumber);
|
|
228
|
-
logger.info(
|
|
229
|
+
logger.info(`Formatted phone number: ${formattedPhoneNumber}`);
|
|
229
230
|
|
|
230
231
|
const messageData = {
|
|
231
232
|
code: formattedPhoneNumber,
|
|
@@ -235,7 +236,7 @@ const getConversationReplyController = async (req, res) => {
|
|
|
235
236
|
|
|
236
237
|
// Handle template message (contentSid provided)
|
|
237
238
|
if (contentSid) {
|
|
238
|
-
logger.info(
|
|
239
|
+
logger.info(`Processing template message with contentSid: ${contentSid}`);
|
|
239
240
|
messageData.contentSid = contentSid;
|
|
240
241
|
|
|
241
242
|
if (variables && Object.keys(variables).length > 0) {
|
|
@@ -281,10 +282,12 @@ const getConversationReplyController = async (req, res) => {
|
|
|
281
282
|
message: 'Reply sent successfully'
|
|
282
283
|
});
|
|
283
284
|
} catch (error) {
|
|
285
|
+
logger.error('Error sending reply:', { error });
|
|
284
286
|
logger.error('Error sending reply:', { error });
|
|
285
287
|
logger.info('Request body', { body: req.body || {} });
|
|
286
288
|
const errorMsg = error.message || 'Failed to send reply';
|
|
287
289
|
logger.error('Responding with error:', { errorMsg });
|
|
290
|
+
logger.error('Responding with error:', { errorMsg });
|
|
288
291
|
res.status(500).json({
|
|
289
292
|
success: false,
|
|
290
293
|
error: errorMsg
|
|
@@ -361,6 +364,7 @@ const searchConversationsController = async (req, res) => {
|
|
|
361
364
|
batch.map(p => `{whatsapp_id} = "${p}"`).join(', ') +
|
|
362
365
|
')';
|
|
363
366
|
return getRecordByFilter(Historial_Clinico_ID, 'estado_general', formula).catch(error => {
|
|
367
|
+
logger.error('Error fetching Airtable batch for search:', { error });
|
|
364
368
|
logger.error('Error fetching Airtable batch for search:', { error });
|
|
365
369
|
return [];
|
|
366
370
|
});
|
|
@@ -381,6 +385,7 @@ const searchConversationsController = async (req, res) => {
|
|
|
381
385
|
logger.info(`Found ${Object.keys(airtableNameMap).length} names in Airtable for search results (${batches.length} batches)`);
|
|
382
386
|
} catch (error) {
|
|
383
387
|
logger.error('Error fetching names from Airtable for search, falling back to nombre_whatsapp:', { error });
|
|
388
|
+
logger.error('Error fetching names from Airtable for search, falling back to nombre_whatsapp:', { error });
|
|
384
389
|
}
|
|
385
390
|
}
|
|
386
391
|
|
|
@@ -445,6 +450,7 @@ const searchConversationsController = async (req, res) => {
|
|
|
445
450
|
});
|
|
446
451
|
|
|
447
452
|
} catch (error) {
|
|
453
|
+
logger.error('Error searching conversations:', { error });
|
|
448
454
|
logger.error('Error searching conversations:', { error });
|
|
449
455
|
res.status(500).json({
|
|
450
456
|
success: false,
|
|
@@ -478,6 +484,7 @@ const getConversationsByNameController = async (req, res) => {
|
|
|
478
484
|
}))
|
|
479
485
|
});
|
|
480
486
|
} catch (error) {
|
|
487
|
+
logger.error('Error fetching conversations by name:', { error });
|
|
481
488
|
logger.error('Error fetching conversations by name:', { error });
|
|
482
489
|
res.status(500).json({
|
|
483
490
|
success: false,
|
|
@@ -524,6 +531,7 @@ const getNewMessagesController = async (req, res) => {
|
|
|
524
531
|
messages: messages
|
|
525
532
|
});
|
|
526
533
|
} catch (error) {
|
|
534
|
+
logger.error(`Error fetching new messages for ${req.params.phoneNumber}:`, { error });
|
|
527
535
|
logger.error(`Error fetching new messages for ${req.params.phoneNumber}:`, { error });
|
|
528
536
|
res.status(500).json({
|
|
529
537
|
success: false,
|
|
@@ -533,7 +541,7 @@ const getNewMessagesController = async (req, res) => {
|
|
|
533
541
|
};
|
|
534
542
|
|
|
535
543
|
const markMessagesAsReadController = async (req, res) => {
|
|
536
|
-
logger.info(
|
|
544
|
+
logger.info(`Starting markMessagesAsReadController at ${new Date().toISOString()}`);
|
|
537
545
|
try {
|
|
538
546
|
const { phoneNumber } = req.params;
|
|
539
547
|
|
|
@@ -544,7 +552,7 @@ const markMessagesAsReadController = async (req, res) => {
|
|
|
544
552
|
});
|
|
545
553
|
}
|
|
546
554
|
|
|
547
|
-
logger.info(
|
|
555
|
+
logger.info(`Marking messages as read for phone number: ${phoneNumber}`);
|
|
548
556
|
const result = await Message.updateMany(
|
|
549
557
|
{
|
|
550
558
|
numero: phoneNumber,
|
|
@@ -564,7 +572,7 @@ const markMessagesAsReadController = async (req, res) => {
|
|
|
564
572
|
});
|
|
565
573
|
} catch (error) {
|
|
566
574
|
logger.error(`Error marking messages as read for ${req.params?.phoneNumber || 'unknown'}:`, { error });
|
|
567
|
-
logger.error('Error stack:', {
|
|
575
|
+
logger.error('Error stack:', { errorStack: error.stack });
|
|
568
576
|
res.status(500).json({
|
|
569
577
|
success: false,
|
|
570
578
|
error: error.message || 'Failed to mark messages as read'
|
|
@@ -573,7 +581,7 @@ const markMessagesAsReadController = async (req, res) => {
|
|
|
573
581
|
};
|
|
574
582
|
|
|
575
583
|
const sendTemplateToNewNumberController = async (req, res) => {
|
|
576
|
-
logger.info(
|
|
584
|
+
logger.info(`Starting sendTemplateToNewNumberController at ${new Date().toISOString()}`);
|
|
577
585
|
try {
|
|
578
586
|
const { phoneNumber, templateId, variables } = req.body;
|
|
579
587
|
|
|
@@ -618,6 +626,7 @@ const sendTemplateToNewNumberController = async (req, res) => {
|
|
|
618
626
|
messageId: message.sid
|
|
619
627
|
});
|
|
620
628
|
} catch (error) {
|
|
629
|
+
logger.error('Error sending template to new number:', { error });
|
|
621
630
|
logger.error('Error sending template to new number:', { error });
|
|
622
631
|
res.status(500).json({
|
|
623
632
|
success: false,
|
|
@@ -627,14 +636,14 @@ const sendTemplateToNewNumberController = async (req, res) => {
|
|
|
627
636
|
};
|
|
628
637
|
|
|
629
638
|
const getOpenAIThreadMessagesController = async (req, res) => {
|
|
630
|
-
logger.info(
|
|
639
|
+
logger.info(`Starting getOpenAIThreadMessagesController at ${new Date().toISOString()}`);
|
|
631
640
|
try {
|
|
632
641
|
const { phoneNumber } = req.params;
|
|
633
642
|
const { limit = 50, order = 'desc', runId } = req.query;
|
|
634
643
|
const variant = process.env.VARIANT || 'assistants';
|
|
635
644
|
|
|
636
|
-
logger.info(
|
|
637
|
-
logger.info(
|
|
645
|
+
logger.info(`Fetching OpenAI thread messages for: ${phoneNumber}`);
|
|
646
|
+
logger.info(`Variant: ${variant}, Limit: ${limit}, Order: ${order}`);
|
|
638
647
|
|
|
639
648
|
if (!phoneNumber) {
|
|
640
649
|
return res.status(400).json({
|
|
@@ -649,7 +658,7 @@ const getOpenAIThreadMessagesController = async (req, res) => {
|
|
|
649
658
|
}).sort({ createdAt: -1 });
|
|
650
659
|
|
|
651
660
|
if (!thread) {
|
|
652
|
-
logger.info(
|
|
661
|
+
logger.info(`No active OpenAI thread found for: ${phoneNumber}`);
|
|
653
662
|
return res.status(404).json({
|
|
654
663
|
success: false,
|
|
655
664
|
error: 'No active OpenAI thread found for this phone number'
|
|
@@ -657,14 +666,14 @@ const getOpenAIThreadMessagesController = async (req, res) => {
|
|
|
657
666
|
}
|
|
658
667
|
|
|
659
668
|
let conversationId = thread.conversation_id;
|
|
660
|
-
logger.info(
|
|
669
|
+
logger.info(`Thread found - Conversation ID: ${conversationId}`);
|
|
661
670
|
|
|
662
671
|
const provider = llmConfig.getOpenAIProvider({ instantiate: true, variant });
|
|
663
672
|
if (!provider) {
|
|
664
673
|
throw new Error('OpenAI provider not initialized');
|
|
665
674
|
}
|
|
666
675
|
|
|
667
|
-
logger.info(
|
|
676
|
+
logger.info(`Using provider variant: ${provider.getVariant()}`);
|
|
668
677
|
|
|
669
678
|
const queryParams = {
|
|
670
679
|
threadId: conversationId,
|
|
@@ -679,6 +688,7 @@ const getOpenAIThreadMessagesController = async (req, res) => {
|
|
|
679
688
|
let messages;
|
|
680
689
|
let threadRecreated = false;
|
|
681
690
|
|
|
691
|
+
logger.info('Calling listMessages with params:', { queryParams });
|
|
682
692
|
logger.info('Calling listMessages with params:', { queryParams });
|
|
683
693
|
messages = await withThreadRecovery(
|
|
684
694
|
async (currentThread = thread) => {
|
|
@@ -713,7 +723,7 @@ const getOpenAIThreadMessagesController = async (req, res) => {
|
|
|
713
723
|
|
|
714
724
|
} catch (error) {
|
|
715
725
|
logger.error('Error fetching OpenAI thread messages:', { error });
|
|
716
|
-
logger.error('Error stack:', {
|
|
726
|
+
logger.error('Error stack:', { errorStack: error.stack });
|
|
717
727
|
res.status(500).json({
|
|
718
728
|
success: false,
|
|
719
729
|
error: error.message || 'Failed to fetch OpenAI thread messages'
|
|
@@ -41,11 +41,11 @@ class NexusMessaging {
|
|
|
41
41
|
keyword: [],
|
|
42
42
|
flow: []
|
|
43
43
|
};
|
|
44
|
-
// Message
|
|
45
|
-
this.
|
|
44
|
+
// Message processing with check-after strategy
|
|
45
|
+
this.processingLocks = new Map(); // Per-chat locks to prevent parallel processing
|
|
46
46
|
this.batchingConfig = {
|
|
47
|
-
enabled: config.messageBatching?.enabled ??
|
|
48
|
-
|
|
47
|
+
enabled: config.messageBatching?.enabled ?? true, // Enabled by default with check-after
|
|
48
|
+
checkDelayMs: config.messageBatching?.checkDelayMs ?? 100 // Delay before checking for new messages
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
51
|
|
|
@@ -393,10 +393,9 @@ class NexusMessaging {
|
|
|
393
393
|
} else if (messageData.flow) {
|
|
394
394
|
return await this.handleFlow(messageData);
|
|
395
395
|
} else {
|
|
396
|
-
// For regular messages and media, use
|
|
397
|
-
logger.info('Batching config:', this.batchingConfig);
|
|
396
|
+
// For regular messages and media, use check-after processing
|
|
398
397
|
if (this.batchingConfig.enabled && chatId) {
|
|
399
|
-
return await this.
|
|
398
|
+
return await this._handleWithCheckAfter(chatId);
|
|
400
399
|
} else {
|
|
401
400
|
if (messageData.media) {
|
|
402
401
|
return await this.handleMedia(messageData);
|
|
@@ -649,77 +648,52 @@ class NexusMessaging {
|
|
|
649
648
|
}
|
|
650
649
|
|
|
651
650
|
/**
|
|
652
|
-
* Handle message with
|
|
651
|
+
* Handle message with check-after strategy - process immediately, check for new messages after
|
|
653
652
|
*/
|
|
654
|
-
async
|
|
655
|
-
|
|
656
|
-
if (
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
clearInterval(existing.typingInterval);
|
|
660
|
-
}
|
|
661
|
-
logger.info(`Received additional message from ${chatId}, resetting wait timer`);
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
// Start typing indicator refresh for batching period
|
|
665
|
-
const typingInterval = await this._startTypingRefresh(chatId);
|
|
666
|
-
|
|
667
|
-
const waitTime = this.batchingConfig.baseWaitTime;
|
|
668
|
-
const timeoutId = setTimeout(async () => {
|
|
669
|
-
try {
|
|
670
|
-
if (typingInterval) {
|
|
671
|
-
clearInterval(typingInterval);
|
|
672
|
-
}
|
|
673
|
-
this.pendingResponses.delete(chatId);
|
|
674
|
-
await this._handleBatchedMessages(chatId);
|
|
675
|
-
} catch (error) {
|
|
676
|
-
logger.error(`Error handling batched messages for ${chatId}:`, error);
|
|
677
|
-
}
|
|
678
|
-
}, waitTime);
|
|
679
|
-
|
|
680
|
-
this.pendingResponses.set(chatId, { timeoutId, typingInterval });
|
|
681
|
-
logger.info(`Waiting ${Math.round(waitTime/1000)} seconds for more messages from ${chatId}`);
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
/**
|
|
685
|
-
* Start typing indicator refresh interval
|
|
686
|
-
*/
|
|
687
|
-
async _startTypingRefresh(chatId) {
|
|
688
|
-
if (!this.provider || typeof this.provider.sendTypingIndicator !== 'function') {
|
|
689
|
-
return null;
|
|
653
|
+
async _handleWithCheckAfter(chatId) {
|
|
654
|
+
// If already processing this chat, just return (message is saved, will be picked up)
|
|
655
|
+
if (this.processingLocks.has(chatId)) {
|
|
656
|
+
logger.info(`[CheckAfter] Already processing ${chatId}, new message will be included`);
|
|
657
|
+
return;
|
|
690
658
|
}
|
|
691
659
|
|
|
692
|
-
|
|
693
|
-
numero: chatId,
|
|
694
|
-
from_me: false,
|
|
695
|
-
message_id: { $exists: true, $ne: null }
|
|
696
|
-
}).sort({ createdAt: -1 });
|
|
697
|
-
|
|
698
|
-
if (!lastMessage?.message_id) return null;
|
|
699
|
-
|
|
700
|
-
return setInterval(() =>
|
|
701
|
-
this.provider.sendTypingIndicator(lastMessage.message_id).catch(err =>
|
|
702
|
-
logger.debug('[_startTypingRefresh] Failed', { error: err.message })
|
|
703
|
-
), 5000
|
|
704
|
-
);
|
|
660
|
+
await this._processWithLock(chatId);
|
|
705
661
|
}
|
|
706
662
|
|
|
707
663
|
/**
|
|
708
|
-
* Process
|
|
664
|
+
* Process messages with per-chat lock and check-after logic
|
|
709
665
|
*/
|
|
710
|
-
async
|
|
666
|
+
async _processWithLock(chatId) {
|
|
667
|
+
this.processingLocks.set(chatId, true);
|
|
711
668
|
let typingInterval = null;
|
|
712
|
-
|
|
669
|
+
|
|
713
670
|
try {
|
|
714
|
-
logger.info(`Processing batched messages from ${chatId} (including media if any)`);
|
|
715
|
-
|
|
716
671
|
typingInterval = await this._startTypingRefresh(chatId);
|
|
717
|
-
|
|
718
|
-
|
|
672
|
+
logger.info(`[CheckAfter] Processing messages for ${chatId}`);
|
|
673
|
+
|
|
674
|
+
// Process with assistant
|
|
719
675
|
const result = await replyAssistant(chatId);
|
|
720
676
|
const botResponse = typeof result === 'string' ? result : result?.output;
|
|
721
677
|
const tools_executed = typeof result === 'object' ? result?.tools_executed : undefined;
|
|
722
|
-
|
|
678
|
+
|
|
679
|
+
// Small delay to catch very recent DB writes
|
|
680
|
+
await new Promise(resolve => setTimeout(resolve, this.batchingConfig.checkDelayMs));
|
|
681
|
+
|
|
682
|
+
// Check for new unprocessed messages
|
|
683
|
+
const hasNewMessages = await Message.exists({
|
|
684
|
+
numero: chatId,
|
|
685
|
+
processed: false,
|
|
686
|
+
from_me: false
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
if (hasNewMessages) {
|
|
690
|
+
logger.info(`[CheckAfter] New messages detected for ${chatId}, discarding response and reprocessing`);
|
|
691
|
+
if (typingInterval) clearInterval(typingInterval);
|
|
692
|
+
// Recursively process with new messages
|
|
693
|
+
return await this._processWithLock(chatId);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// No new messages - send response and mark processed
|
|
723
697
|
if (botResponse) {
|
|
724
698
|
await this.sendMessage({
|
|
725
699
|
code: chatId,
|
|
@@ -729,40 +703,45 @@ class NexusMessaging {
|
|
|
729
703
|
tools_executed
|
|
730
704
|
});
|
|
731
705
|
}
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
706
|
+
|
|
707
|
+
this.events.emit('messages:processed', { chatId, response: botResponse });
|
|
708
|
+
|
|
736
709
|
} catch (error) {
|
|
737
|
-
logger.error('Error
|
|
710
|
+
logger.error('[CheckAfter] Error processing messages:', { chatId, error: error.message });
|
|
738
711
|
} finally {
|
|
739
|
-
if (typingInterval)
|
|
740
|
-
|
|
741
|
-
}
|
|
712
|
+
if (typingInterval) clearInterval(typingInterval);
|
|
713
|
+
this.processingLocks.delete(chatId);
|
|
742
714
|
}
|
|
743
715
|
}
|
|
744
716
|
|
|
745
717
|
/**
|
|
746
|
-
*
|
|
718
|
+
* Start typing indicator refresh interval
|
|
747
719
|
*/
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
if (pending.timeoutId) {
|
|
752
|
-
clearTimeout(pending.timeoutId);
|
|
753
|
-
}
|
|
754
|
-
if (pending.typingInterval) {
|
|
755
|
-
clearInterval(pending.typingInterval);
|
|
756
|
-
}
|
|
757
|
-
this.pendingResponses.delete(chatId);
|
|
720
|
+
async _startTypingRefresh(chatId) {
|
|
721
|
+
if (!this.provider || typeof this.provider.sendTypingIndicator !== 'function') {
|
|
722
|
+
return null;
|
|
758
723
|
}
|
|
724
|
+
|
|
725
|
+
const lastMessage = await Message.findOne({
|
|
726
|
+
numero: chatId,
|
|
727
|
+
from_me: false,
|
|
728
|
+
message_id: { $exists: true, $ne: null }
|
|
729
|
+
}).sort({ createdAt: -1 });
|
|
730
|
+
|
|
731
|
+
if (!lastMessage?.message_id) return null;
|
|
732
|
+
|
|
733
|
+
return setInterval(() =>
|
|
734
|
+
this.provider.sendTypingIndicator(lastMessage.message_id).catch(err =>
|
|
735
|
+
logger.debug('[_startTypingRefresh] Failed', { error: err.message })
|
|
736
|
+
), 5000
|
|
737
|
+
);
|
|
759
738
|
}
|
|
760
739
|
|
|
761
740
|
/**
|
|
762
|
-
*
|
|
741
|
+
* Check if chat is currently being processed
|
|
763
742
|
*/
|
|
764
|
-
|
|
765
|
-
return this.
|
|
743
|
+
isProcessing(chatId) {
|
|
744
|
+
return this.processingLocks.has(chatId);
|
|
766
745
|
}
|
|
767
746
|
}
|
|
768
747
|
|
|
@@ -2,31 +2,6 @@ const moment = require('moment-timezone');
|
|
|
2
2
|
const { Message } = require('../models/messageModel.js');
|
|
3
3
|
const { logger } = require('../utils/logger');
|
|
4
4
|
|
|
5
|
-
const addMessageToThread = async (reply, messagesChat, provider, thread) => {
|
|
6
|
-
const threadId = thread.getConversationId();
|
|
7
|
-
|
|
8
|
-
if (reply.interactive_type === 'flow') {
|
|
9
|
-
logger.info(`[addMessageToThread] Skipping flow message (UI only) - ID: ${reply.message_id}`);
|
|
10
|
-
return;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
if (reply.origin === 'whatsapp_platform') {
|
|
14
|
-
await provider.addMessage({
|
|
15
|
-
threadId,
|
|
16
|
-
role: 'assistant',
|
|
17
|
-
content: messagesChat
|
|
18
|
-
});
|
|
19
|
-
} else if (reply.origin === 'patient') {
|
|
20
|
-
await provider.addMessage({
|
|
21
|
-
threadId,
|
|
22
|
-
role: 'user',
|
|
23
|
-
content: messagesChat
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
logger.info(`[addMessageToThread] Message added - ID: ${reply.message_id}, Thread: ${threadId}, Origin: ${reply.origin}`);
|
|
28
|
-
};
|
|
29
|
-
|
|
30
5
|
const updateMessageRecord = async (reply, thread) => {
|
|
31
6
|
const threadId = thread.getConversationId();
|
|
32
7
|
|
|
@@ -125,7 +100,6 @@ async function isRecentMessage(chatId) {
|
|
|
125
100
|
}
|
|
126
101
|
|
|
127
102
|
module.exports = {
|
|
128
|
-
addMessageToThread,
|
|
129
103
|
updateMessageRecord,
|
|
130
104
|
getLastMessages,
|
|
131
105
|
getLastNMessages,
|
|
@@ -80,6 +80,12 @@ messageSchema.index({ numero: 1, createdAt: -1 });
|
|
|
80
80
|
messageSchema.index({ numero: 1, processed: 1, origin: 1 }, { name: 'numero_processed_origin_idx' });
|
|
81
81
|
messageSchema.index({ numero: 1, createdAt: -1, processed: 1 }, { name: 'numero_created_processed_idx' });
|
|
82
82
|
|
|
83
|
+
// Indexes for conversation aggregation queries
|
|
84
|
+
messageSchema.index({ group_id: 1, createdAt: 1 }, { name: 'conversation_sort_idx' });
|
|
85
|
+
messageSchema.index({ group_id: 1, from_me: 1, read: 1 }, { name: 'unread_filter_idx' });
|
|
86
|
+
messageSchema.index({ group_id: 1, numero: 1, createdAt: -1 }, { name: 'conversation_lookup_idx' });
|
|
87
|
+
messageSchema.index({ createdAt: -1 }, { name: 'global_sort_idx' });
|
|
88
|
+
|
|
83
89
|
messageSchema.pre('save', function (next) {
|
|
84
90
|
if (this.timestamp) {
|
|
85
91
|
this.timestamp = moment.tz(this.timestamp, 'America/Mexico_City').toDate();
|
|
@@ -3,9 +3,8 @@ const { Thread } = require('../models/threadModel');
|
|
|
3
3
|
const { retryWithBackoff } = require('../utils/retryHelper');
|
|
4
4
|
const {
|
|
5
5
|
handleFunctionCalls: handleFunctionCallsUtil,
|
|
6
|
-
handlePendingFunctionCalls: handlePendingFunctionCallsUtil,
|
|
7
|
-
transformToolsForResponsesAPI: transformToolsForResponsesAPIUtil
|
|
8
6
|
} = require('./OpenAIResponsesProviderTools');
|
|
7
|
+
const { DefaultConversationManager } = require('../services/DefaultConversationManager');
|
|
9
8
|
const { logger } = require('../utils/logger');
|
|
10
9
|
|
|
11
10
|
const CONVERSATION_PREFIX = 'conv_';
|
|
@@ -25,6 +24,7 @@ class OpenAIResponsesProvider {
|
|
|
25
24
|
organization,
|
|
26
25
|
client,
|
|
27
26
|
defaultModels = {},
|
|
27
|
+
conversationManager,
|
|
28
28
|
} = options;
|
|
29
29
|
|
|
30
30
|
if (!client && !apiKey) {
|
|
@@ -41,6 +41,7 @@ class OpenAIResponsesProvider {
|
|
|
41
41
|
};
|
|
42
42
|
|
|
43
43
|
this.variant = 'responses';
|
|
44
|
+
this.conversationManager = conversationManager || new DefaultConversationManager();
|
|
44
45
|
|
|
45
46
|
this.responses = this.client.responses;
|
|
46
47
|
this.conversations = this.client.conversations;
|
|
@@ -161,33 +162,6 @@ class OpenAIResponsesProvider {
|
|
|
161
162
|
return await this.client.conversations.items.list(id, { order, limit });
|
|
162
163
|
}
|
|
163
164
|
|
|
164
|
-
async cleanupOrphanedFunctionCalls(threadId, deleteAll = false) {
|
|
165
|
-
try {
|
|
166
|
-
const id = this._ensurethreadId(threadId);
|
|
167
|
-
const messages = await this.listMessages({ threadId: id, order: 'desc' });
|
|
168
|
-
const items = messages?.data || [];
|
|
169
|
-
|
|
170
|
-
if (items.length === 0) return;
|
|
171
|
-
|
|
172
|
-
if (deleteAll) {
|
|
173
|
-
logger.info(`[OpenAIResponsesProvider] Deleting all ${items.length} items from conversation`);
|
|
174
|
-
await Promise.all(items.map(item =>
|
|
175
|
-
this.conversations.items.delete(item.id, {conversation_id: id})
|
|
176
|
-
));
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
const outputCallIds = new Set(items.filter(i => i.type === 'function_call_output').map(o => o.call_id));
|
|
181
|
-
const orphanedCalls = items.filter(i => i.type === 'function_call' && !outputCallIds.has(i.call_id));
|
|
182
|
-
|
|
183
|
-
if (orphanedCalls.length > 0) {
|
|
184
|
-
await Promise.all(orphanedCalls.map(call => this.conversations.items.delete(call.id, {conversation_id: id})));
|
|
185
|
-
}
|
|
186
|
-
} catch (error) {
|
|
187
|
-
logger.warn('[OpenAIResponsesProvider] Failed to cleanup conversation:', error?.message);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
165
|
_normalizeThread(thread) {
|
|
192
166
|
return {
|
|
193
167
|
conversationId: thread.conversation_id || thread.getConversationId?.(),
|
|
@@ -200,107 +174,138 @@ class OpenAIResponsesProvider {
|
|
|
200
174
|
*/
|
|
201
175
|
async executeRun({ thread, assistant, tools = [], config = {} }) {
|
|
202
176
|
const { conversationId, assistantId } = this._normalizeThread(thread);
|
|
203
|
-
|
|
204
|
-
...config,
|
|
205
|
-
assistant,
|
|
206
|
-
toolMetadata: {
|
|
207
|
-
numero: thread.code,
|
|
208
|
-
assistant_id: assistantId
|
|
209
|
-
}
|
|
210
|
-
};
|
|
211
|
-
|
|
177
|
+
|
|
212
178
|
logger.info('[OpenAIResponsesProvider] Starting run', {
|
|
213
179
|
conversationId,
|
|
214
180
|
assistantId
|
|
215
181
|
});
|
|
216
182
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
183
|
+
try {
|
|
184
|
+
// Delegate context building to conversation manager
|
|
185
|
+
const context = await this.conversationManager.buildContext({
|
|
186
|
+
thread,
|
|
187
|
+
assistant,
|
|
188
|
+
config: {
|
|
189
|
+
...config,
|
|
190
|
+
threadId: conversationId,
|
|
191
|
+
assistantId,
|
|
192
|
+
toolMetadata: {
|
|
193
|
+
numero: thread.code,
|
|
194
|
+
assistant_id: assistantId
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
});
|
|
230
198
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
199
|
+
const filter = thread.code ? { code: thread.code, active: true } : null;
|
|
200
|
+
|
|
201
|
+
// Get clinical context for prompt variables
|
|
202
|
+
const clinicalData = await this.conversationManager.getClinicalData(thread.code);
|
|
203
|
+
const promptVariables = clinicalData ? {
|
|
204
|
+
clinical_context: clinicalData.clinicalContext || '',
|
|
205
|
+
last_symptoms: clinicalData.lastSymptoms || ''
|
|
206
|
+
} : null;
|
|
207
|
+
|
|
208
|
+
// Execute with built context
|
|
209
|
+
const result = await this.runConversation({
|
|
210
|
+
threadId: conversationId,
|
|
211
|
+
assistantId,
|
|
212
|
+
tools,
|
|
213
|
+
context,
|
|
214
|
+
promptVariables,
|
|
215
|
+
assistant,
|
|
216
|
+
...config
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// Delegate response processing to conversation manager
|
|
220
|
+
await this.conversationManager.processResponse(result, thread, config);
|
|
221
|
+
|
|
222
|
+
const completed = result.status === 'completed';
|
|
223
|
+
const output = await this.getRunText({
|
|
224
|
+
runId: result.id,
|
|
225
|
+
fallback: ''
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
if (filter) {
|
|
229
|
+
await Thread.updateOne(filter, { $set: { run_id: null } });
|
|
230
|
+
}
|
|
234
231
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
232
|
+
logger.info('[OpenAIResponsesProvider] Run complete', {
|
|
233
|
+
runId: result.id,
|
|
234
|
+
completed,
|
|
235
|
+
toolsExecuted: result.tools_executed?.length || 0
|
|
236
|
+
});
|
|
240
237
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
238
|
+
return {
|
|
239
|
+
run: result,
|
|
240
|
+
completed,
|
|
241
|
+
output,
|
|
242
|
+
tools_executed: result.tools_executed || [],
|
|
243
|
+
retries: result.retries || 0
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
} catch (error) {
|
|
247
|
+
logger.error('[OpenAIResponsesProvider] Execute run failed', {
|
|
248
|
+
conversationId,
|
|
249
|
+
assistantId,
|
|
250
|
+
error: error.message
|
|
251
|
+
});
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
248
254
|
}
|
|
249
255
|
|
|
250
256
|
async runConversation({
|
|
251
257
|
threadId,
|
|
252
258
|
assistantId,
|
|
253
259
|
additionalMessages = [],
|
|
260
|
+
context = null,
|
|
254
261
|
instructions = null,
|
|
255
262
|
additionalInstructions = null,
|
|
256
|
-
toolOutputs = [],
|
|
257
263
|
metadata = {},
|
|
258
264
|
topP,
|
|
259
265
|
temperature,
|
|
260
266
|
maxOutputTokens,
|
|
261
267
|
truncationStrategy = 'auto',
|
|
262
|
-
tools = [],
|
|
263
268
|
model,
|
|
264
269
|
assistant,
|
|
265
270
|
toolMetadata,
|
|
271
|
+
promptVariables = null,
|
|
266
272
|
} = {}) {
|
|
267
273
|
try {
|
|
268
274
|
const id = this._ensurethreadId(threadId);
|
|
269
|
-
|
|
275
|
+
|
|
276
|
+
let input = context || this._convertItemsToApiFormat(additionalMessages);
|
|
270
277
|
let allToolsExecuted = [];
|
|
271
278
|
let totalRetries = 0;
|
|
272
279
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
}
|
|
280
|
+
input = input.filter(item => item.type !== 'function_call' && item.type !== 'function_call_output');
|
|
281
|
+
|
|
282
|
+
const promptConfig = promptVariables
|
|
283
|
+
? { id: assistantId, variables: promptVariables }
|
|
284
|
+
: { id: assistantId };
|
|
285
|
+
logger.info('[OpenAIResponsesProvider] Prompt config', { promptConfig });
|
|
280
286
|
|
|
281
|
-
input = [...input, ...toolOutputs];
|
|
282
287
|
const makeAPICall = (inputData) => retryWithBackoff(() =>
|
|
283
288
|
this.client.responses.create({
|
|
284
|
-
|
|
285
|
-
prompt: { id: assistantId },
|
|
289
|
+
prompt: promptConfig,
|
|
286
290
|
model: model || this.defaults.responseModel,
|
|
287
291
|
instructions: additionalInstructions || instructions,
|
|
288
292
|
input: inputData,
|
|
289
293
|
metadata, top_p: topP, temperature, max_output_tokens: maxOutputTokens,
|
|
290
294
|
truncation: truncationStrategy,
|
|
291
|
-
tools: transformToolsForResponsesAPIUtil(this.variant, tools),
|
|
292
295
|
}), { providerName: PROVIDER_NAME });
|
|
293
296
|
|
|
294
297
|
const { result: response, retries } = await makeAPICall(input);
|
|
295
298
|
totalRetries += retries;
|
|
296
299
|
let finalResponse = response;
|
|
297
300
|
|
|
301
|
+
// Handle function calls following OpenAI pattern
|
|
298
302
|
if (assistant && response.output) {
|
|
299
303
|
const functionCalls = response.output.filter(item => item.type === 'function_call');
|
|
300
304
|
|
|
301
305
|
if (functionCalls.length > 0) {
|
|
302
306
|
const { outputs, toolsExecuted } = await handleFunctionCallsUtil(functionCalls, assistant, toolMetadata || { thread_id: id, assistant_id: assistantId });
|
|
303
307
|
|
|
308
|
+
input.push(...response.output);
|
|
304
309
|
input.push(...outputs);
|
|
305
310
|
allToolsExecuted.push(...toolsExecuted);
|
|
306
311
|
|
|
@@ -5,13 +5,16 @@ async function executeFunctionCall(assistant, call, metadata = {}) {
|
|
|
5
5
|
const name = call.name;
|
|
6
6
|
const args = call.arguments ? JSON.parse(call.arguments) : {};
|
|
7
7
|
|
|
8
|
+
logger.info('[executeFunctionCall] Calling tool', { name, args, call_id: call.call_id });
|
|
9
|
+
|
|
8
10
|
let result, success = true;
|
|
9
11
|
try {
|
|
10
12
|
result = await assistant.executeTool(name, args);
|
|
13
|
+
logger.info('[executeFunctionCall] Tool completed', { name, call_id: call.call_id, duration_ms: Date.now() - startTime });
|
|
11
14
|
} catch (error) {
|
|
12
15
|
result = { error: error?.message || 'Tool execution failed' };
|
|
13
16
|
success = false;
|
|
14
|
-
logger.error('[
|
|
17
|
+
logger.error('[executeFunctionCall] Tool execution failed', { name, call_id: call.call_id, error: error?.message });
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
const toolData = {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { OpenAIAssistantsProvider } = require('./OpenAIAssistantsProvider');
|
|
2
2
|
const { OpenAIResponsesProvider } = require('./OpenAIResponsesProvider');
|
|
3
|
+
const { DefaultConversationManager } = require('../services/DefaultConversationManager');
|
|
3
4
|
const { logger } = require('../utils/logger');
|
|
4
5
|
|
|
5
6
|
const PROVIDER_VARIANTS = {
|
|
@@ -16,8 +17,17 @@ function createProvider(config = {}) {
|
|
|
16
17
|
.toString()
|
|
17
18
|
.toLowerCase();
|
|
18
19
|
|
|
20
|
+
// Create conversation manager if not provided
|
|
21
|
+
const conversationManager = config.conversationManager || new DefaultConversationManager({
|
|
22
|
+
memorySystem: config.memorySystem
|
|
23
|
+
});
|
|
24
|
+
|
|
19
25
|
const ProviderClass = PROVIDER_VARIANTS[variant] || OpenAIAssistantsProvider;
|
|
20
|
-
return new ProviderClass({
|
|
26
|
+
return new ProviderClass({
|
|
27
|
+
...config,
|
|
28
|
+
variant,
|
|
29
|
+
conversationManager
|
|
30
|
+
});
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
module.exports = {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const { logger } = require('../utils/logger');
|
|
2
|
+
|
|
3
|
+
class ConversationManager {
|
|
4
|
+
constructor(options = {}) {
|
|
5
|
+
this.memorySystem = options.memorySystem || null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
async buildContext({ thread, assistant, config = {} }) {
|
|
9
|
+
throw new Error('buildContext must be implemented by subclass');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async processResponse(response, thread, config = {}) {
|
|
13
|
+
throw new Error('processResponse must be implemented by subclass');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
setMemorySystem(memorySystem) {
|
|
17
|
+
this.memorySystem = memorySystem;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
getMemorySystem() {
|
|
21
|
+
return this.memorySystem;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
optimizeContextWindow(messages, maxTokens = 8000) {
|
|
25
|
+
if (!Array.isArray(messages) || messages.length === 0) return [];
|
|
26
|
+
const systemMessages = messages.filter(m => m.role === 'system');
|
|
27
|
+
const recentMessages = messages.filter(m => m.role !== 'system').slice(-20);
|
|
28
|
+
return [...systemMessages, ...recentMessages];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
_convertToApiFormat(messages) {
|
|
32
|
+
return messages.map(msg => ({
|
|
33
|
+
role: msg.role || 'user',
|
|
34
|
+
content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_logActivity(action, metadata = {}) {
|
|
39
|
+
logger.info(`[ConversationManager] ${action}`, metadata);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { ConversationManager };
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
const { ConversationManager } = require('./ConversationManager');
|
|
2
|
+
const { getLastNMessages } = require('../helpers/messageHelper');
|
|
3
|
+
const { handlePendingFunctionCalls: handlePendingFunctionCallsUtil } = require('../providers/OpenAIResponsesProviderTools');
|
|
4
|
+
const { getRecordByFilter } = require('./airtableService');
|
|
5
|
+
const { Follow_Up_ID } = require('../config/airtableConfig');
|
|
6
|
+
const { logger } = require('../utils/logger');
|
|
7
|
+
|
|
8
|
+
class DefaultConversationManager extends ConversationManager {
|
|
9
|
+
constructor(options = {}) {
|
|
10
|
+
super(options);
|
|
11
|
+
this.maxHistoricalMessages = parseInt(process.env.MAX_HISTORICAL_MESSAGES || '50', 10);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async buildContext({ thread, assistant, config = {} }) {
|
|
15
|
+
this._logActivity('Building context', { threadCode: thread.code });
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const allMessages = await getLastNMessages(thread.code, this.maxHistoricalMessages);
|
|
19
|
+
const additionalMessages = config.additionalMessages || [];
|
|
20
|
+
|
|
21
|
+
// New conversation - no history yet
|
|
22
|
+
if (!allMessages?.length) {
|
|
23
|
+
return additionalMessages;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const messageContext = allMessages.reverse().map(msg => ({
|
|
27
|
+
role: msg.origin === 'patient' ? 'user' : 'assistant',
|
|
28
|
+
content: msg.body || msg.content || ''
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
return [...additionalMessages, ...messageContext];
|
|
32
|
+
} catch (error) {
|
|
33
|
+
logger.error('[DefaultConversationManager] Context building failed', {
|
|
34
|
+
threadCode: thread.code,
|
|
35
|
+
error: error.message
|
|
36
|
+
});
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async processResponse(response, thread, config = {}) {
|
|
42
|
+
this._logActivity('Processing response', { threadCode: thread.code, responseId: response.id });
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
if (this.memorySystem && response.output) {
|
|
46
|
+
await this.memorySystem.processResponse(response, thread);
|
|
47
|
+
}
|
|
48
|
+
this._logActivity('Response processed successfully', {
|
|
49
|
+
threadCode: thread.code,
|
|
50
|
+
responseId: response.id
|
|
51
|
+
});
|
|
52
|
+
} catch (error) {
|
|
53
|
+
logger.error('[DefaultConversationManager] Response processing failed', {
|
|
54
|
+
threadCode: thread.code,
|
|
55
|
+
error: error.message
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async handlePendingFunctionCalls(assistant, conversationMessages, toolMetadata) {
|
|
61
|
+
if (!assistant || !conversationMessages?.length) return { outputs: [], toolsExecuted: [] };
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
return await handlePendingFunctionCallsUtil(assistant, conversationMessages, toolMetadata);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
logger.error('[DefaultConversationManager] Function call handling failed', { error: error.message });
|
|
67
|
+
return { outputs: [], toolsExecuted: [] };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
optimizeContextWindow(messages) {
|
|
72
|
+
if (!Array.isArray(messages) || !messages.length) return [];
|
|
73
|
+
|
|
74
|
+
const cappedMessages = messages.length > this.maxHistoricalMessages
|
|
75
|
+
? messages.slice(-this.maxHistoricalMessages)
|
|
76
|
+
: messages;
|
|
77
|
+
|
|
78
|
+
if (messages.length > this.maxHistoricalMessages) {
|
|
79
|
+
this._logActivity('Context capped', { originalCount: messages.length, cappedCount: this.maxHistoricalMessages });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return this._convertToApiFormat(cappedMessages);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
_convertToApiFormat(items) {
|
|
86
|
+
return items.map(item => ({
|
|
87
|
+
role: item.role || 'user',
|
|
88
|
+
content: this._normalizeContent(item.content),
|
|
89
|
+
type: item.type || 'message',
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
_normalizeContent(content) {
|
|
94
|
+
if (typeof content === 'string') return content;
|
|
95
|
+
if (Array.isArray(content)) return content;
|
|
96
|
+
if (content?.text) return content.text;
|
|
97
|
+
if (content?.type === 'text' && content.text) return content.text;
|
|
98
|
+
return content && typeof content === 'object' ? JSON.stringify(content) : content || '';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async _getClinicalContext(whatsappId) {
|
|
102
|
+
try {
|
|
103
|
+
const [clinicalRecords, symptomsRecords] = await Promise.all([
|
|
104
|
+
getRecordByFilter(Follow_Up_ID, 'estado_general', `{whatsapp_id}='${whatsappId}'`),
|
|
105
|
+
getRecordByFilter(Follow_Up_ID, 'all_triages_last_month', `{whatsapp_id}='${whatsappId}'`)
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
let clinicalContext = null;
|
|
109
|
+
if (clinicalRecords && clinicalRecords.length > 0 && clinicalRecords[0]['clinical-context-json']) {
|
|
110
|
+
clinicalContext = JSON.parse(clinicalRecords[0]['clinical-context-json']);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const sortedSymptoms = symptomsRecords
|
|
114
|
+
?.filter(r => r.Date)
|
|
115
|
+
.sort((a, b) => new Date(b.Date) - new Date(a.Date))
|
|
116
|
+
.slice(0, 3) || [];
|
|
117
|
+
|
|
118
|
+
const symptoms = this._parseSymptoms(sortedSymptoms);
|
|
119
|
+
|
|
120
|
+
return { ...clinicalContext, symptoms };
|
|
121
|
+
} catch (error) {
|
|
122
|
+
logger.error('[DefaultConversationManager] Error fetching clinical context', { error: error.message });
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_parseSymptoms(records) {
|
|
128
|
+
if (!records || records.length === 0) return [];
|
|
129
|
+
|
|
130
|
+
return records.map(record => {
|
|
131
|
+
const symptomNames = record['symptom_name (from symptoms)'];
|
|
132
|
+
const grades = record['grade_num (from symptoms)'];
|
|
133
|
+
const recommendations = record['short_recommendations'];
|
|
134
|
+
const date = record['Date'];
|
|
135
|
+
|
|
136
|
+
if (!symptomNames || !grades) return null;
|
|
137
|
+
|
|
138
|
+
const nameArray = typeof symptomNames === 'string'
|
|
139
|
+
? symptomNames.split(',').map(s => s.trim())
|
|
140
|
+
: Array.isArray(symptomNames) ? symptomNames : [symptomNames];
|
|
141
|
+
|
|
142
|
+
const gradeArray = typeof grades === 'string'
|
|
143
|
+
? grades.split(',').map(g => parseInt(g.trim()))
|
|
144
|
+
: Array.isArray(grades) ? grades.map(g => parseInt(g)) : [parseInt(grades)];
|
|
145
|
+
|
|
146
|
+
const recArray = recommendations && typeof recommendations === 'string'
|
|
147
|
+
? recommendations.split('.,').map(r => r.trim())
|
|
148
|
+
: [];
|
|
149
|
+
|
|
150
|
+
const symptoms = nameArray
|
|
151
|
+
.map((name, idx) => ({
|
|
152
|
+
name,
|
|
153
|
+
grade: gradeArray[idx] || 0,
|
|
154
|
+
recommendation: recArray[idx] || ''
|
|
155
|
+
}))
|
|
156
|
+
.filter(s => s.grade > 0);
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
date,
|
|
160
|
+
symptoms
|
|
161
|
+
};
|
|
162
|
+
}).filter(Boolean);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async getClinicalData(whatsappId) {
|
|
166
|
+
try {
|
|
167
|
+
const data = await this._getClinicalContext(whatsappId);
|
|
168
|
+
if (!data) return null;
|
|
169
|
+
|
|
170
|
+
// Format clinical context (demographics + clinical data)
|
|
171
|
+
const contextParts = [];
|
|
172
|
+
if (data.demografia) {
|
|
173
|
+
const d = data.demografia;
|
|
174
|
+
if (d.edad) contextParts.push(`Edad: ${d.edad}`);
|
|
175
|
+
if (d.sexo) contextParts.push(`Sexo: ${d.sexo}`);
|
|
176
|
+
}
|
|
177
|
+
if (data.datos_clinicos) {
|
|
178
|
+
const c = data.datos_clinicos;
|
|
179
|
+
if (c.diagnostico) contextParts.push(`Diagnóstico: ${c.diagnostico}`);
|
|
180
|
+
if (c.tratamiento_actual) contextParts.push(`Tratamiento actual: ${c.tratamiento_actual}`);
|
|
181
|
+
if (c.medicacion_actual) contextParts.push(`Medicación: ${c.medicacion_actual}`);
|
|
182
|
+
if (c.alergias_medicamentos) contextParts.push(`Alergias: ${c.alergias_medicamentos}`);
|
|
183
|
+
if (c.comorbilidades) contextParts.push(`Comorbilidades: ${c.comorbilidades}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Format last symptoms
|
|
187
|
+
const symptomParts = [];
|
|
188
|
+
if (data.symptoms?.length > 0) {
|
|
189
|
+
data.symptoms.forEach(triage => {
|
|
190
|
+
const dateStr = new Date(triage.date).toLocaleDateString('es-PE');
|
|
191
|
+
const symptomsStr = triage.symptoms.map(s => `${s.name} (grado ${s.grade})`).join(', ');
|
|
192
|
+
if (symptomsStr) symptomParts.push(`${dateStr}: ${symptomsStr}`);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
clinicalContext: contextParts.join('. ') || '',
|
|
198
|
+
lastSymptoms: symptomParts.join('; ') || 'Sin síntomas reportados recientemente'
|
|
199
|
+
};
|
|
200
|
+
} catch (error) {
|
|
201
|
+
logger.error('[DefaultConversationManager] Error getting clinical data', { error: error.message });
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = { DefaultConversationManager };
|
|
@@ -52,7 +52,7 @@ async function getRecordByFilter(baseID, tableName, filter, view = 'Grid view')
|
|
|
52
52
|
});
|
|
53
53
|
return records;
|
|
54
54
|
} catch (error) {
|
|
55
|
-
logger.error(`Error fetching records by ${filter}:`, error);
|
|
55
|
+
logger.error(`Error fetching records from ${tableName} by ${filter}:`, error);
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
@@ -41,15 +41,6 @@ const createAssistantCore = async (code, assistant_id, messages = [], force = fa
|
|
|
41
41
|
try {
|
|
42
42
|
const assistant = getAssistantById(assistant_id, null);
|
|
43
43
|
const initialThread = await assistant.create(code, curRow[0]);
|
|
44
|
-
|
|
45
|
-
const provider = createProvider({ variant: process.env.VARIANT || 'assistants' });
|
|
46
|
-
for (const message of messages) {
|
|
47
|
-
await provider.addMessage({
|
|
48
|
-
threadId: initialThread.id,
|
|
49
|
-
role: 'assistant',
|
|
50
|
-
content: message
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
44
|
|
|
54
45
|
const thread = {
|
|
55
46
|
code: code,
|
|
@@ -76,17 +67,9 @@ const addMsgAssistantCore = async (code, inMessages, role = 'system', reply = fa
|
|
|
76
67
|
const thread = await getThread(code);
|
|
77
68
|
if (!thread) return null;
|
|
78
69
|
|
|
79
|
-
const provider = createProvider({ variant: process.env.VARIANT || 'assistants' });
|
|
80
|
-
const threadId = thread.getConversationId();
|
|
81
|
-
|
|
82
70
|
try {
|
|
83
71
|
const messages = Array.isArray(inMessages) ? inMessages : [inMessages];
|
|
84
72
|
|
|
85
|
-
await provider.addMessage({
|
|
86
|
-
threadId,
|
|
87
|
-
messages: messages.map(message => ({ role, content: message }))
|
|
88
|
-
});
|
|
89
|
-
|
|
90
73
|
// Save system messages to database for frontend visibility
|
|
91
74
|
if (!skipSystemMessage) {
|
|
92
75
|
for (let i = 0; i < messages.length; i++) {
|
|
@@ -205,12 +188,6 @@ const replyAssistantCore = async (code, message_ = null, thread_ = null, runOpti
|
|
|
205
188
|
const urls = processResults.filter(r => r.url).map(r => ({ url: r.url }));
|
|
206
189
|
const allMessagesToAdd = processResults.flatMap(r => r.messages || []);
|
|
207
190
|
const allTempFiles = processResults.flatMap(r => r.tempFiles || []);
|
|
208
|
-
|
|
209
|
-
if (allMessagesToAdd.length > 0) {
|
|
210
|
-
logger.info(`[replyAssistantCore] Adding ${allMessagesToAdd.length} messages to thread in batch`);
|
|
211
|
-
const threadId = finalThread.getConversationId();
|
|
212
|
-
await provider.addMessage({ threadId, messages: allMessagesToAdd });
|
|
213
|
-
}
|
|
214
191
|
|
|
215
192
|
await Promise.all(processResults.map(r => updateMessageRecord(r.reply, finalThread)));
|
|
216
193
|
await cleanupFiles(allTempFiles);
|
|
@@ -21,9 +21,20 @@ const fetchConversationData = async (filter, skip, limit) => {
|
|
|
21
21
|
|
|
22
22
|
const pipeline = [
|
|
23
23
|
{ $match: filterConditions },
|
|
24
|
-
{ $
|
|
25
|
-
{ $
|
|
26
|
-
|
|
24
|
+
{ $sort: { numero: 1, createdAt: -1 } },
|
|
25
|
+
{ $group: {
|
|
26
|
+
_id: '$numero',
|
|
27
|
+
latestMessage: { $first: {
|
|
28
|
+
_id: '$_id',
|
|
29
|
+
body: '$body',
|
|
30
|
+
createdAt: '$createdAt',
|
|
31
|
+
media: '$media',
|
|
32
|
+
nombre_whatsapp: '$nombre_whatsapp',
|
|
33
|
+
from_me: '$from_me'
|
|
34
|
+
} },
|
|
35
|
+
messageCount: { $sum: 1 }
|
|
36
|
+
} },
|
|
37
|
+
{ $match: { _id: { $nin: [null, ''] } } },
|
|
27
38
|
...(filter === 'no-response' ? [{ $match: { 'latestMessage.from_me': false } }] : []),
|
|
28
39
|
{
|
|
29
40
|
$lookup: {
|
|
@@ -40,22 +51,23 @@ const fetchConversationData = async (filter, skip, limit) => {
|
|
|
40
51
|
},
|
|
41
52
|
{ $project: { threadInfo: 0 } },
|
|
42
53
|
...(filter === 'pending-review' ? [{ $match: { $or: [{ review: false }, { review: null }] } }] : []),
|
|
43
|
-
{ $sort: { 'latestMessage.createdAt': -1
|
|
54
|
+
{ $sort: { 'latestMessage.createdAt': -1 } },
|
|
44
55
|
{ $skip: skip },
|
|
45
56
|
{ $limit: limit }
|
|
46
57
|
];
|
|
47
58
|
|
|
48
59
|
const startTime = Date.now();
|
|
49
60
|
const [conversations, contactNames, unreadCounts, totalResult] = await Promise.all([
|
|
50
|
-
Message.aggregate(pipeline),
|
|
51
|
-
Message.aggregate([{ $match: { ...baseMatch, from_me: false } }, { $sort: { createdAt: -1 } }, { $group: { _id: '$numero', name: { $first: '$nombre_whatsapp' } } }]),
|
|
52
|
-
Message.aggregate([{ $match: { ...baseMatch, ...unreadMatch } }, { $group: { _id: '$numero', unreadCount: { $sum: 1 } } }]),
|
|
61
|
+
Message.aggregate(pipeline, { allowDiskUse: true }),
|
|
62
|
+
Message.aggregate([{ $match: { ...baseMatch, from_me: false } }, { $sort: { createdAt: -1 } }, { $group: { _id: '$numero', name: { $first: '$nombre_whatsapp' } } }], { allowDiskUse: true }),
|
|
63
|
+
Message.aggregate([{ $match: { ...baseMatch, ...unreadMatch } }, { $group: { _id: '$numero', unreadCount: { $sum: 1 } } }], { allowDiskUse: true }),
|
|
53
64
|
Message.aggregate(
|
|
54
65
|
filter === 'no-response'
|
|
55
|
-
? [{ $match: baseMatch }, { $project: { numero: 1, from_me: 1, createdAt: 1
|
|
66
|
+
? [{ $match: baseMatch }, { $project: { numero: 1, from_me: 1, createdAt: 1 } }, { $sort: { createdAt: -1 } }, { $group: { _id: '$numero', latestMessage: { $first: '$$ROOT' } } }, { $match: { 'latestMessage.from_me': false } }, { $count: 'total' }]
|
|
56
67
|
: filter === 'pending-review'
|
|
57
68
|
? [{ $match: baseMatch }, { $group: { _id: '$numero' } }, { $lookup: { from: 'threads', localField: '_id', foreignField: 'code', as: 'threadInfo' } }, { $addFields: { review: { $arrayElemAt: ['$threadInfo.review', 0] } } }, { $match: { $or: [{ review: false }, { review: null }] } }, { $count: 'total' }]
|
|
58
|
-
: [{ $match: filterConditions }, { $group: { _id: '$numero' } }, { $count: 'total' }]
|
|
69
|
+
: [{ $match: filterConditions }, { $group: { _id: '$numero' } }, { $count: 'total' }],
|
|
70
|
+
{ allowDiskUse: true }
|
|
59
71
|
)
|
|
60
72
|
]);
|
|
61
73
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peopl-health/nexus",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0",
|
|
4
4
|
"description": "Core messaging and assistant library for WhatsApp communication platforms",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"whatsapp",
|
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
"peerDependencies": {
|
|
106
106
|
"@anthropic-ai/sdk": "^0.32.0",
|
|
107
107
|
"baileys": "^6.4.0",
|
|
108
|
-
"express": "4.
|
|
108
|
+
"express": "4.22.1",
|
|
109
109
|
"openai": "6.7.0",
|
|
110
110
|
"twilio": "5.6.0"
|
|
111
111
|
},
|