@peopl-health/nexus 3.15.5 → 3.15.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/controllers/bugReportController.js +16 -7
- package/lib/core/NexusMessaging.js +25 -0
- package/lib/helpers/templateRecoveryHelper.js +5 -60
- package/lib/jobs/ScheduledMessageJob.js +15 -1
- package/lib/jobs/TemplateApprovalJob.js +128 -4
- package/lib/models/messageModel.js +2 -1
- package/lib/providers/OpenAIResponsesProvider.js +36 -6
- package/lib/queue/LocalQueueAdapter.js +33 -5
- package/package.json +1 -1
|
@@ -9,7 +9,10 @@ const { getBug, VALID_SEVERITIES } = require('../models/bugModel');
|
|
|
9
9
|
const { addRecord, getRecordByFilter } = require('../services/airtableService');
|
|
10
10
|
|
|
11
11
|
async function logBugReportToAirtable(reportedBug) {
|
|
12
|
-
const {
|
|
12
|
+
const {
|
|
13
|
+
reporter, whatsapp_id, description, severity, messages, server, bugType, project,
|
|
14
|
+
clasificacion, status, owner, request_id
|
|
15
|
+
} = reportedBug;
|
|
13
16
|
try {
|
|
14
17
|
let conversation = null;
|
|
15
18
|
if (messages?.length) {
|
|
@@ -22,11 +25,13 @@ async function logBugReportToAirtable(reportedBug) {
|
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
let patientId = null;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
if (whatsapp_id) {
|
|
29
|
+
try {
|
|
30
|
+
const records = await getRecordByFilter(Logging_ID, 'estado_general', `{whatsapp_id}='${whatsapp_id}'`);
|
|
31
|
+
if (records?.length) patientId = records[0].record_logging_id;
|
|
32
|
+
} catch (err) {
|
|
33
|
+
logger.warn('Could not find patient in estado_general:', { error: err.message });
|
|
34
|
+
}
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
const bugRecord = await addRecord(Logging_ID, 'bug_reports', {
|
|
@@ -34,6 +39,10 @@ async function logBugReportToAirtable(reportedBug) {
|
|
|
34
39
|
...(project && {...project}),
|
|
35
40
|
...(patientId && { patient_id: [patientId] }),
|
|
36
41
|
...(conversation && { conversation }),
|
|
42
|
+
...(clasificacion && { clasificacion }),
|
|
43
|
+
...(status && { status }),
|
|
44
|
+
...(owner && { owner }),
|
|
45
|
+
...(request_id && { request_id }),
|
|
37
46
|
server
|
|
38
47
|
});
|
|
39
48
|
logger.debug('Bug report logged to Airtable successfully');
|
|
@@ -148,4 +157,4 @@ const getBugByWhatsappIdController = async (req, res) => {
|
|
|
148
157
|
}
|
|
149
158
|
};
|
|
150
159
|
|
|
151
|
-
module.exports = { reportBugController, getBugByWhatsappIdController, updateBugController };
|
|
160
|
+
module.exports = { reportBugController, getBugByWhatsappIdController, updateBugController, logBugReportToAirtable };
|
|
@@ -23,6 +23,7 @@ const { AssistantProcessor } = require('../core/AssistantProcessor');
|
|
|
23
23
|
|
|
24
24
|
const { createQueueAdapter } = require('../queue');
|
|
25
25
|
const { ScheduledMessageJob } = require('../jobs/ScheduledMessageJob');
|
|
26
|
+
const { TemplateApprovalJob } = require('../jobs/TemplateApprovalJob');
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* Core messaging orchestrator for providers, storage, and assistant processing.
|
|
@@ -83,6 +84,16 @@ class NexusMessaging {
|
|
|
83
84
|
queueAdapter: this.queueAdapter,
|
|
84
85
|
sendMessage: this.sendMessage.bind(this)
|
|
85
86
|
});
|
|
87
|
+
|
|
88
|
+
this.templateApprovalJob = new TemplateApprovalJob({
|
|
89
|
+
queueAdapter: this.queueAdapter,
|
|
90
|
+
sendMessage: this.sendMessage.bind(this),
|
|
91
|
+
requireProvider: () => {
|
|
92
|
+
const provider = this.getProvider();
|
|
93
|
+
if (!provider) throw new Error('Nexus provider not configured');
|
|
94
|
+
return provider;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
86
97
|
}
|
|
87
98
|
|
|
88
99
|
async initialize(options = {}) {
|
|
@@ -275,6 +286,15 @@ class NexusMessaging {
|
|
|
275
286
|
});
|
|
276
287
|
}
|
|
277
288
|
|
|
289
|
+
async scheduleTemplateApproval({ templateSid, messageId, originalMessageSid = null, attempt = 0 }) {
|
|
290
|
+
return await this.templateApprovalJob.schedule({
|
|
291
|
+
templateSid,
|
|
292
|
+
messageId,
|
|
293
|
+
originalMessageSid,
|
|
294
|
+
attempt
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
278
298
|
/*
|
|
279
299
|
* INBOUND MESSAGE PROCESSING
|
|
280
300
|
*/
|
|
@@ -558,6 +578,10 @@ const sendScheduledMessage = async (scheduledMessage) => {
|
|
|
558
578
|
return await requireDefaultInstance().sendScheduledMessage(scheduledMessage);
|
|
559
579
|
};
|
|
560
580
|
|
|
581
|
+
const scheduleTemplateApproval = async (options) => {
|
|
582
|
+
return await requireDefaultInstance().scheduleTemplateApproval(options);
|
|
583
|
+
};
|
|
584
|
+
|
|
561
585
|
const processInstruction = async (code, instruction, role, options) => {
|
|
562
586
|
return await requireDefaultInstance().processInstruction(code, instruction, role, options);
|
|
563
587
|
};
|
|
@@ -574,6 +598,7 @@ module.exports = {
|
|
|
574
598
|
NexusMessaging,
|
|
575
599
|
sendMessage,
|
|
576
600
|
sendScheduledMessage,
|
|
601
|
+
scheduleTemplateApproval,
|
|
577
602
|
processInstruction,
|
|
578
603
|
processSystemMessage,
|
|
579
604
|
setDefaultInstance,
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
const { logger } = require('../utils/logger');
|
|
2
2
|
const { Message } = require('../models/messageModel');
|
|
3
3
|
const { Template } = require('../templates/templateStructure');
|
|
4
|
-
const { pollTemplateApproval } = require('./templateApprovalPoller');
|
|
5
4
|
|
|
6
5
|
const getMessaging = () => require('../core/NexusMessaging');
|
|
7
6
|
|
|
@@ -30,7 +29,7 @@ async function handle24HourWindowError(message, messageSid) {
|
|
|
30
29
|
return;
|
|
31
30
|
}
|
|
32
31
|
|
|
33
|
-
const { requireProvider } = getMessaging();
|
|
32
|
+
const { requireProvider, scheduleTemplateApproval } = getMessaging();
|
|
34
33
|
const provider = requireProvider();
|
|
35
34
|
if (typeof provider.createTemplate !== 'function') {
|
|
36
35
|
logger.warn('[TemplateRecovery] Provider does not support createTemplate, skipping', { messageSid });
|
|
@@ -52,65 +51,11 @@ async function handle24HourWindowError(message, messageSid) {
|
|
|
52
51
|
|
|
53
52
|
logger.info('[TemplateRecovery] Template created', { messageSid, templateSid: twilioContent.sid });
|
|
54
53
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const claimSend = await Message.updateOne(
|
|
60
|
-
{ _id: messageDocId, 'statusInfo.recoverySentAt': { $exists: false } },
|
|
61
|
-
{ $set: { 'statusInfo.recoverySentAt': new Date() } }
|
|
62
|
-
);
|
|
63
|
-
if (!claimSend?.modifiedCount && !claimSend?.nModified) {
|
|
64
|
-
logger.info('[TemplateRecovery] Send already claimed by another process', { messageSid, templateSid: twilioContent.sid });
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
let newMessageId;
|
|
69
|
-
try {
|
|
70
|
-
const sendResult = await provider.sendMessage({
|
|
71
|
-
code: message.numero,
|
|
72
|
-
contentSid: twilioContent.sid,
|
|
73
|
-
variables: {},
|
|
74
|
-
_skipStorage: true
|
|
75
|
-
});
|
|
76
|
-
newMessageId = sendResult?.messageId;
|
|
77
|
-
if (!newMessageId) throw new Error('Provider did not return a messageId for the recovery send');
|
|
78
|
-
} catch (sendErr) {
|
|
79
|
-
await Message.updateOne(
|
|
80
|
-
{ _id: messageDocId },
|
|
81
|
-
{ $unset: { 'statusInfo.recoverySentAt': '' } }
|
|
82
|
-
);
|
|
83
|
-
logger.error('[TemplateRecovery] Error sending approved template', { messageSid, templateSid: twilioContent.sid, error: sendErr.message });
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
try {
|
|
88
|
-
await Message.updateOne(
|
|
89
|
-
{ _id: messageDocId },
|
|
90
|
-
{ $set: { 'statusInfo.recoveryMessageId': newMessageId } }
|
|
91
|
-
);
|
|
92
|
-
logger.info('[TemplateRecovery] Recovered', { originalMessageId: messageSid, recoveryMessageId: newMessageId, templateSid: twilioContent.sid });
|
|
93
|
-
} catch (dbErr) {
|
|
94
|
-
logger.error('[TemplateRecovery] CRITICAL: send succeeded but DB update failed — status tracking lost for recovery delivery', {
|
|
95
|
-
messageSid, newMessageId, templateSid: twilioContent.sid, error: dbErr.message
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
prov.deleteTemplate(twilioContent.sid).catch(deleteErr =>
|
|
100
|
-
logger.warn('[TemplateRecovery] Failed to delete template after send', { newMessageId, templateSid: twilioContent.sid, error: deleteErr.message })
|
|
101
|
-
);
|
|
102
|
-
},
|
|
103
|
-
onRejected: async (prov) => {
|
|
104
|
-
logger.warn('[TemplateRecovery] Template rejected', { messageSid, templateSid: twilioContent.sid });
|
|
105
|
-
try {
|
|
106
|
-
await prov.deleteTemplate(twilioContent.sid);
|
|
107
|
-
logger.info('[TemplateRecovery] Rejected template deleted', { messageSid, templateSid: twilioContent.sid });
|
|
108
|
-
} catch (deleteErr) {
|
|
109
|
-
logger.warn('[TemplateRecovery] Failed to delete rejected template', { messageSid, templateSid: twilioContent.sid, error: deleteErr.message });
|
|
110
|
-
}
|
|
111
|
-
}
|
|
54
|
+
await scheduleTemplateApproval({
|
|
55
|
+
templateSid: twilioContent.sid,
|
|
56
|
+
messageId: messageDocId,
|
|
57
|
+
originalMessageSid: messageSid
|
|
112
58
|
});
|
|
113
|
-
|
|
114
59
|
} catch (error) {
|
|
115
60
|
logger.error('[TemplateRecovery] Error', { messageSid, error: error.message });
|
|
116
61
|
}
|
|
@@ -12,6 +12,7 @@ const DEFAULT_JOB_OPTIONS = {
|
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
const TERMINAL_STATUSES = ['sent', 'cancelled'];
|
|
15
|
+
const STALE_SENDING_MS = 5 * 60 * 1000;
|
|
15
16
|
|
|
16
17
|
class ScheduledMessageJob extends BaseJob {
|
|
17
18
|
constructor({ queueAdapter, sendMessage } = {}) {
|
|
@@ -67,14 +68,27 @@ class ScheduledMessageJob extends BaseJob {
|
|
|
67
68
|
return { success: false, reason: 'already_processed', status: msg.status };
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
const staleThreshold = new Date(Date.now() - STALE_SENDING_MS);
|
|
70
72
|
const claim = await ScheduledMessage.updateOne(
|
|
71
|
-
{
|
|
73
|
+
{
|
|
74
|
+
_id: scheduledMessageId,
|
|
75
|
+
$or: [
|
|
76
|
+
{ status: { $nin: TERMINAL_STATUSES.concat(['sending']) } },
|
|
77
|
+
{ status: 'sending', sendingAt: { $lt: staleThreshold } }
|
|
78
|
+
]
|
|
79
|
+
},
|
|
72
80
|
{ $set: { status: 'sending', sendingAt: new Date() } }
|
|
73
81
|
);
|
|
74
82
|
if (!claim.modifiedCount && !claim.nModified) {
|
|
75
83
|
logger.info('[ScheduledMessageJob] Send already claimed by another worker', { scheduledMessageId });
|
|
76
84
|
return { success: false, reason: 'already_claimed' };
|
|
77
85
|
}
|
|
86
|
+
if (msg.status === 'sending') {
|
|
87
|
+
logger.warn('[ScheduledMessageJob] Re-claimed stale sending state, prior worker likely crashed', {
|
|
88
|
+
scheduledMessageId,
|
|
89
|
+
priorSendingAt: msg.sendingAt
|
|
90
|
+
});
|
|
91
|
+
}
|
|
78
92
|
|
|
79
93
|
try {
|
|
80
94
|
const result = await this.sendMessage({
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
const { logger } = require('../utils/logger');
|
|
2
|
+
const { Message } = require('../models/messageModel');
|
|
2
3
|
const { BaseJob } = require('./BaseJob');
|
|
3
4
|
|
|
4
5
|
const QUEUE_NAME = 'template-approval';
|
|
5
6
|
const DEFAULT_DELAY_MS = 15 * 60 * 1000;
|
|
7
|
+
const MAX_ATTEMPTS = 40;
|
|
6
8
|
|
|
7
9
|
const DEFAULT_JOB_OPTIONS = {
|
|
8
10
|
attempts: 3,
|
|
@@ -12,8 +14,16 @@ const DEFAULT_JOB_OPTIONS = {
|
|
|
12
14
|
};
|
|
13
15
|
|
|
14
16
|
class TemplateApprovalJob extends BaseJob {
|
|
15
|
-
constructor({ queueAdapter } = {}) {
|
|
17
|
+
constructor({ queueAdapter, sendMessage, requireProvider } = {}) {
|
|
16
18
|
super({ queueAdapter, queueName: QUEUE_NAME });
|
|
19
|
+
if (typeof sendMessage !== 'function') {
|
|
20
|
+
throw new Error('TemplateApprovalJob requires a sendMessage function');
|
|
21
|
+
}
|
|
22
|
+
if (typeof requireProvider !== 'function') {
|
|
23
|
+
throw new Error('TemplateApprovalJob requires a requireProvider function');
|
|
24
|
+
}
|
|
25
|
+
this.sendMessage = sendMessage;
|
|
26
|
+
this.requireProvider = requireProvider;
|
|
17
27
|
}
|
|
18
28
|
|
|
19
29
|
async schedule({ templateSid, messageId, originalMessageSid = null, attempt = 0, delayMs = DEFAULT_DELAY_MS }) {
|
|
@@ -36,9 +46,123 @@ class TemplateApprovalJob extends BaseJob {
|
|
|
36
46
|
return `template-approval-${templateSid}-${attempt}`;
|
|
37
47
|
}
|
|
38
48
|
|
|
39
|
-
async _process(
|
|
40
|
-
|
|
41
|
-
|
|
49
|
+
async _process({ templateSid, messageId, originalMessageSid, attempt = 0 }) {
|
|
50
|
+
const message = await Message.findById(messageId).lean();
|
|
51
|
+
|
|
52
|
+
if (!message) {
|
|
53
|
+
logger.warn('[TemplateApprovalJob] Message not found', { messageId, templateSid });
|
|
54
|
+
return { success: false, reason: 'not_found' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const recovery = message.statusInfo || {};
|
|
58
|
+
if (recovery.recoveryMessageId || recovery.recoveryRejectedAt) {
|
|
59
|
+
logger.info('[TemplateApprovalJob] Recovery already completed', { messageId, templateSid });
|
|
60
|
+
return { success: false, reason: 'already_completed' };
|
|
61
|
+
}
|
|
62
|
+
if (recovery.recoveryTemplateSid && recovery.recoveryTemplateSid !== templateSid) {
|
|
63
|
+
logger.info('[TemplateApprovalJob] Template superseded', { messageId, templateSid, currentTemplateSid: recovery.recoveryTemplateSid });
|
|
64
|
+
return { success: false, reason: 'template_superseded' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let provider;
|
|
68
|
+
let approvalStatus;
|
|
69
|
+
try {
|
|
70
|
+
provider = this.requireProvider();
|
|
71
|
+
const status = await provider.checkApprovalStatus(templateSid);
|
|
72
|
+
approvalStatus = status?.approvalRequest?.status?.toUpperCase();
|
|
73
|
+
} catch (err) {
|
|
74
|
+
logger.error('[TemplateApprovalJob] Provider error during approval check, treating as transient', {
|
|
75
|
+
templateSid, messageId, attempt, error: err.message
|
|
76
|
+
});
|
|
77
|
+
return await this._rescheduleOrGiveUp({ templateSid, messageId, originalMessageSid, attempt, reason: 'error' });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
logger.info('[TemplateApprovalJob] Approval status', { templateSid, approvalStatus, attempt, messageId, originalMessageSid });
|
|
81
|
+
|
|
82
|
+
if (approvalStatus === 'APPROVED') {
|
|
83
|
+
return await this._handleApproved({ provider, message, templateSid, originalMessageSid });
|
|
84
|
+
}
|
|
85
|
+
if (approvalStatus === 'REJECTED') {
|
|
86
|
+
return await this._handleRejected({ provider, message, templateSid });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return await this._rescheduleOrGiveUp({ templateSid, messageId, originalMessageSid, attempt, reason: 'pending' });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async _rescheduleOrGiveUp({ templateSid, messageId, originalMessageSid, attempt, reason }) {
|
|
93
|
+
const nextAttempt = attempt + 1;
|
|
94
|
+
if (nextAttempt >= MAX_ATTEMPTS) {
|
|
95
|
+
logger.warn('[TemplateApprovalJob] Max attempts reached, giving up', { templateSid, messageId, attempt, reason });
|
|
96
|
+
return { success: false, reason: 'max_attempts', priorReason: reason };
|
|
97
|
+
}
|
|
98
|
+
await this.schedule({ templateSid, messageId, originalMessageSid, attempt: nextAttempt });
|
|
99
|
+
return { success: false, reason, nextAttempt };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async _handleApproved({ provider, message, templateSid, originalMessageSid }) {
|
|
103
|
+
const claim = await Message.updateOne(
|
|
104
|
+
{ _id: message._id, 'statusInfo.recoverySentAt': { $exists: false } },
|
|
105
|
+
{ $set: { 'statusInfo.recoverySentAt': new Date() } }
|
|
106
|
+
);
|
|
107
|
+
if (!claim.modifiedCount && !claim.nModified) {
|
|
108
|
+
logger.info('[TemplateApprovalJob] Send already claimed by another worker', { templateSid, messageId: message._id });
|
|
109
|
+
return { success: false, reason: 'already_claimed' };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let recoveryMessageId;
|
|
113
|
+
try {
|
|
114
|
+
const sendResult = await this.sendMessage({
|
|
115
|
+
code: message.numero,
|
|
116
|
+
contentSid: templateSid,
|
|
117
|
+
variables: {},
|
|
118
|
+
_skipStorage: true
|
|
119
|
+
});
|
|
120
|
+
recoveryMessageId = sendResult?.messageId;
|
|
121
|
+
if (!recoveryMessageId) {
|
|
122
|
+
throw new Error('Provider did not return a messageId for the recovery send');
|
|
123
|
+
}
|
|
124
|
+
} catch (sendErr) {
|
|
125
|
+
await Message.updateOne(
|
|
126
|
+
{ _id: message._id },
|
|
127
|
+
{ $unset: { 'statusInfo.recoverySentAt': '' } }
|
|
128
|
+
);
|
|
129
|
+
logger.error('[TemplateApprovalJob] Send failed', { templateSid, messageId: message._id, error: sendErr.message });
|
|
130
|
+
throw sendErr;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
await Message.updateOne(
|
|
135
|
+
{ _id: message._id },
|
|
136
|
+
{ $set: { 'statusInfo.recoveryMessageId': recoveryMessageId } }
|
|
137
|
+
);
|
|
138
|
+
logger.info('[TemplateApprovalJob] Recovered', { originalMessageSid, recoveryMessageId, templateSid });
|
|
139
|
+
} catch (dbErr) {
|
|
140
|
+
logger.error('[TemplateApprovalJob] CRITICAL: send succeeded but DB update failed', {
|
|
141
|
+
messageId: message._id, templateSid, recoveryMessageId, error: dbErr.message
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
provider.deleteTemplate(templateSid).catch((deleteErr) =>
|
|
146
|
+
logger.warn('[TemplateApprovalJob] Failed to delete template after send', { templateSid, error: deleteErr.message })
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
return { success: true, recoveryMessageId };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async _handleRejected({ provider, message, templateSid }) {
|
|
153
|
+
await Message.updateOne(
|
|
154
|
+
{ _id: message._id },
|
|
155
|
+
{ $set: { 'statusInfo.recoveryRejectedAt': new Date() } }
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
await provider.deleteTemplate(templateSid);
|
|
160
|
+
logger.info('[TemplateApprovalJob] Rejected template deleted', { templateSid, messageId: message._id });
|
|
161
|
+
} catch (deleteErr) {
|
|
162
|
+
logger.warn('[TemplateApprovalJob] Failed to delete rejected template', { templateSid, error: deleteErr.message });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return { success: false, reason: 'rejected' };
|
|
42
166
|
}
|
|
43
167
|
}
|
|
44
168
|
|
|
@@ -84,7 +84,8 @@ const messageSchema = new mongoose.Schema({
|
|
|
84
84
|
recoveryTemplateSid: { type: String, default: null },
|
|
85
85
|
recoveryStartedAt: { type: Date, default: null },
|
|
86
86
|
recoverySentAt: { type: Date, default: null },
|
|
87
|
-
recoveryMessageId: { type: String, default: null }
|
|
87
|
+
recoveryMessageId: { type: String, default: null },
|
|
88
|
+
recoveryRejectedAt: { type: Date, default: null }
|
|
88
89
|
},
|
|
89
90
|
prompt: { type: Object, default: null },
|
|
90
91
|
preset: { type: Object, default: null },
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { OpenAI } = require('openai');
|
|
2
2
|
|
|
3
|
+
const runtimeConfig = require('../config/runtimeConfig');
|
|
3
4
|
const { retryWithBackoff } = require('../utils/retryUtils');
|
|
4
5
|
const { logger } = require('../utils/logger');
|
|
5
6
|
const { getCurrentMexicoDateTime } = require('../utils/dateUtils');
|
|
@@ -10,6 +11,7 @@ const { getLastNMessages } = require('../helpers/messageHelper');
|
|
|
10
11
|
|
|
11
12
|
const { composePrompt, resolveTools } = require('../services/promptComposerService');
|
|
12
13
|
const { getToolSchemas: getRegistrySchemas } = require('../services/toolRegistryService');
|
|
14
|
+
const { logBugReportToAirtable } = require('../controllers/bugReportController');
|
|
13
15
|
const { handleFunctionCalls } = require('./OpenAIResponsesProviderTools');
|
|
14
16
|
|
|
15
17
|
const CONVERSATION_PREFIX = 'conv_';
|
|
@@ -171,12 +173,40 @@ class OpenAIResponsesProvider {
|
|
|
171
173
|
if (result.output && Array.isArray(result.output)) {
|
|
172
174
|
const messageItems = result.output.filter(item => item && item.type === 'message');
|
|
173
175
|
if (messageItems.length > 0) {
|
|
174
|
-
|
|
176
|
+
if (messageItems.length > 1) {
|
|
177
|
+
const hasFunctionCalls = result.output.some(item => item?.type === 'function_call');
|
|
178
|
+
logger.warn('[OpenAIResponsesProvider] Multiple message items in response; keeping the first (likely OpenAI multi-output bug)', {
|
|
179
|
+
discarded: messageItems.length - 1,
|
|
180
|
+
responseId: result.id,
|
|
181
|
+
model: result.model,
|
|
182
|
+
hasFunctionCalls
|
|
183
|
+
});
|
|
184
|
+
logBugReportToAirtable({
|
|
185
|
+
reporter: 'system',
|
|
186
|
+
description: [
|
|
187
|
+
'OpenAI Responses API returned multiple message items in a single response.',
|
|
188
|
+
'Suspected upstream bug (model failed to emit stop-of-message token).',
|
|
189
|
+
`Discarded ${messageItems.length - 1} extra message(s); kept the first as the canonical reply.`,
|
|
190
|
+
`Response ID: ${result.id || 'unknown'}`,
|
|
191
|
+
`Model: ${result.model || 'unknown'}`,
|
|
192
|
+
`Function calls present: ${hasFunctionCalls ? 'yes' : 'no'}`
|
|
193
|
+
].join('\n'),
|
|
194
|
+
severity: 'medium',
|
|
195
|
+
status: 'Open',
|
|
196
|
+
clasificacion: 'alucinaciones',
|
|
197
|
+
bugType: 'backend',
|
|
198
|
+
owner: ['ariana'],
|
|
199
|
+
request_id: result.id || null,
|
|
200
|
+
server: runtimeConfig.get('SERVICE_NAME') || 'nexus'
|
|
201
|
+
}).catch((err) => logger.warn('[OpenAIResponsesProvider] Bug report logger failed', { error: err.message }));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const firstMessage = messageItems[0];
|
|
175
205
|
let text = '';
|
|
176
|
-
if (
|
|
177
|
-
text =
|
|
178
|
-
} else if (typeof
|
|
179
|
-
text =
|
|
206
|
+
if (firstMessage.content && Array.isArray(firstMessage.content)) {
|
|
207
|
+
text = firstMessage.content.map(c => this._contentPartToText(c)).filter(Boolean).join('');
|
|
208
|
+
} else if (typeof firstMessage.content === 'string') {
|
|
209
|
+
text = firstMessage.content;
|
|
180
210
|
}
|
|
181
211
|
if (text.trim()) return text.trim();
|
|
182
212
|
}
|
|
@@ -253,7 +283,7 @@ class OpenAIResponsesProvider {
|
|
|
253
283
|
this.sessionManager?.recordActivity(thread.code);
|
|
254
284
|
|
|
255
285
|
const completed = result.status === 'completed';
|
|
256
|
-
const output = this._extractMessageOutput(result);
|
|
286
|
+
const output = result.output_text || this._extractMessageOutput(result);
|
|
257
287
|
const toolsExecuted = result.tools_executed?.length || 0;
|
|
258
288
|
|
|
259
289
|
logger.info('[executeRun] Complete', {
|
|
@@ -11,7 +11,14 @@ class LocalQueueAdapter extends QueueAdapter {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
async enqueue(jobType, payload, options = {}) {
|
|
14
|
-
|
|
14
|
+
if (options.jobId && this.jobs.has(options.jobId)) {
|
|
15
|
+
const existing = this.jobs.get(options.jobId);
|
|
16
|
+
if (existing.status === 'pending' || existing.status === 'delayed') {
|
|
17
|
+
return existing.id;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const jobId = options.jobId || `local_${++this.jobCounter}_${Date.now()}`;
|
|
15
22
|
const job = {
|
|
16
23
|
id: jobId,
|
|
17
24
|
type: jobType,
|
|
@@ -21,12 +28,16 @@ class LocalQueueAdapter extends QueueAdapter {
|
|
|
21
28
|
result: null,
|
|
22
29
|
error: null,
|
|
23
30
|
createdAt: new Date(),
|
|
24
|
-
completedAt: null
|
|
31
|
+
completedAt: null,
|
|
32
|
+
timeoutHandle: null
|
|
25
33
|
};
|
|
26
34
|
|
|
27
35
|
this.jobs.set(jobId, job);
|
|
28
36
|
|
|
29
|
-
|
|
37
|
+
const runHandler = async () => {
|
|
38
|
+
job.timeoutHandle = null;
|
|
39
|
+
if (job.status === 'cancelled') return;
|
|
40
|
+
|
|
30
41
|
const handler = this.handlers.get(jobType);
|
|
31
42
|
if (!handler) {
|
|
32
43
|
job.status = 'failed';
|
|
@@ -46,7 +57,14 @@ class LocalQueueAdapter extends QueueAdapter {
|
|
|
46
57
|
}
|
|
47
58
|
this.events.emit(`job:${jobId}`, job);
|
|
48
59
|
this.jobs.delete(jobId);
|
|
49
|
-
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
if (options.delay && options.delay > 0) {
|
|
63
|
+
job.status = 'delayed';
|
|
64
|
+
job.timeoutHandle = setTimeout(runHandler, options.delay);
|
|
65
|
+
} else {
|
|
66
|
+
setImmediate(runHandler);
|
|
67
|
+
}
|
|
50
68
|
|
|
51
69
|
return jobId;
|
|
52
70
|
}
|
|
@@ -63,7 +81,11 @@ class LocalQueueAdapter extends QueueAdapter {
|
|
|
63
81
|
|
|
64
82
|
async cancelJob(jobId) {
|
|
65
83
|
const job = this.jobs.get(jobId);
|
|
66
|
-
if (!job || job.status !== 'pending') return false;
|
|
84
|
+
if (!job || (job.status !== 'pending' && job.status !== 'delayed')) return false;
|
|
85
|
+
if (job.timeoutHandle) {
|
|
86
|
+
clearTimeout(job.timeoutHandle);
|
|
87
|
+
job.timeoutHandle = null;
|
|
88
|
+
}
|
|
67
89
|
job.status = 'cancelled';
|
|
68
90
|
this.events.emit(`job:${jobId}`, job);
|
|
69
91
|
this.jobs.delete(jobId);
|
|
@@ -95,6 +117,12 @@ class LocalQueueAdapter extends QueueAdapter {
|
|
|
95
117
|
}
|
|
96
118
|
|
|
97
119
|
async shutdown() {
|
|
120
|
+
for (const job of this.jobs.values()) {
|
|
121
|
+
if (job.timeoutHandle) {
|
|
122
|
+
clearTimeout(job.timeoutHandle);
|
|
123
|
+
job.timeoutHandle = null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
98
126
|
this.jobs.clear();
|
|
99
127
|
this.handlers.clear();
|
|
100
128
|
this.events.removeAllListeners();
|