@peopl-health/nexus 4.5.26 → 4.5.28

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.
@@ -15,12 +15,6 @@ class MessageAdapter {
15
15
  case 'list':
16
16
  return interactive.title || interactive.description || '[List item selected]';
17
17
  case 'flow':
18
- if (interactive.data) {
19
- const flowData = typeof interactive.data === 'string'
20
- ? interactive.data
21
- : JSON.stringify(interactive.data, null, 2);
22
- return `Flow Response:\n${flowData}`;
23
- }
24
18
  return '[Flow response]';
25
19
  default:
26
20
  return '[Interactive message]';
@@ -91,6 +91,7 @@ const getConversationMessagesController = async (req, res) => {
91
91
 
92
92
  const total = await countMessages(query);
93
93
  const messages = await getMessages(query, { sort: { createdAt: -1 }, skip, limit });
94
+ messages.forEach(msg => { msg.body = msg.plainBody || msg.body; });
94
95
  const totalPages = Math.ceil(total / limit);
95
96
 
96
97
  res.status(200).json({
@@ -207,7 +208,10 @@ const searchConversationsController = async (req, res) => {
207
208
  secondary: [
208
209
  { $match: {
209
210
  group_id: null,
210
- body: { $regex: escapedQuery, $options: 'i' }
211
+ $or: [
212
+ { plainBody: { $regex: escapedQuery, $options: 'i' } },
213
+ { body: { $regex: escapedQuery, $options: 'i' } }
214
+ ]
211
215
  }},
212
216
  { $group: { _id: '$numero' } }
213
217
  ]
@@ -400,6 +404,7 @@ const getNewMessagesController = async (req, res) => {
400
404
  const raw = parseInt(req.query.limit, 10);
401
405
  const clampedLimit = Number.isFinite(raw) ? Math.min(Math.max(raw, 1), 100) : 20;
402
406
  const messages = await getMessages(query, { sort: { createdAt: 1 }, limit: clampedLimit });
407
+ messages.forEach(msg => { msg.body = msg.plainBody || msg.body; });
403
408
 
404
409
  res.status(200).json({
405
410
  success: true,
@@ -554,13 +559,17 @@ const searchMessagesByNumberController = async (req, res) => {
554
559
  const mongoQuery = {
555
560
  group_id: null,
556
561
  numero: formattedPhoneNumber,
557
- body: { $regex: escapedQuery, $options: 'i' }
562
+ $or: [
563
+ { plainBody: { $regex: escapedQuery, $options: 'i' } },
564
+ { body: { $regex: escapedQuery, $options: 'i' } }
565
+ ]
558
566
  };
559
567
 
560
568
  const mongoSort = { createdAt: -1 };
561
569
 
562
570
  const total = await countMessages(mongoQuery);
563
571
  const messages = await getMessages(mongoQuery, { sort: mongoSort, skip, limit });
572
+ messages.forEach(msg => { msg.body = msg.plainBody || msg.body; });
564
573
  const totalPages = Math.ceil(total / limit);
565
574
 
566
575
  res.status(200).json({
@@ -15,7 +15,7 @@ async function logInteractionToAirtable(messageIds, whatsapp_id, reporter, quali
15
15
  const conversation = messageObjects.map(msg => {
16
16
  const timestamp = msg.createdAt.toISOString().slice(0, 16).replace('T', ' ');
17
17
  const role = msg.from_me ? 'Assistant' : 'Patient';
18
- return `[${timestamp}] ${role}: ${msg.body || '(media)'}`;
18
+ return `[${timestamp}] ${role}: ${msg.plainBody || msg.body || '(media)'}`;
19
19
  }).join('\n');
20
20
 
21
21
  let patientId = null;
@@ -232,7 +232,6 @@ const submitForApproval = async (req, res) => {
232
232
  status: 'PENDING',
233
233
  approvalRequest: {
234
234
  sid: response.sid,
235
- status: response.status || 'PENDING',
236
235
  dateSubmitted: validSubmittedDate,
237
236
  dateUpdated: validUpdatedDate,
238
237
  rejectionReason: response.rejection_reason || ''
@@ -282,7 +281,6 @@ const checkApprovalStatus = async (req, res) => {
282
281
  );
283
282
  dbTemplate.approvalRequest = {
284
283
  sid: status.approvalRequest.sid,
285
- status: status.approvalRequest.status,
286
284
  dateSubmitted,
287
285
  dateUpdated: parseDate(
288
286
  status.approvalRequest.date_updated || status.approvalRequest.dateUpdated || status.content.dateUpdated,
@@ -8,17 +8,20 @@ class MessageParser {
8
8
  this.config = config;
9
9
  this.commandPrefixes = config.commandPrefixes || ['/', '!'];
10
10
  this.keywords = config.keywords || [];
11
- this.flowTriggers = config.flowTriggers || [];
12
11
  }
13
12
 
14
13
  async parseMessage(normalizedMessage) {
15
14
  const {media, interactive, ...base} = normalizedMessage;
16
15
  await parsePatientInformation(base);
17
16
 
18
- if (interactive) {
17
+ if (interactive && interactive.type !== 'flow') {
19
18
  return {...base, type: 'interactive', interactive, isInteractive: true};
20
19
  }
21
20
 
21
+ if (interactive && interactive.type === 'flow') {
22
+ return {...base, type: 'flow', flow: interactive, interactive};
23
+ }
24
+
22
25
  if (media) {
23
26
  return {...base, type: 'media', media, isMedia: true};
24
27
  }
@@ -38,11 +41,6 @@ class MessageParser {
38
41
  return {...base, type: 'keyword', keyword};
39
42
  }
40
43
 
41
- const flow = this.findFlowTrigger(body);
42
- if (flow) {
43
- return {...base, type: 'flow', flow};
44
- }
45
-
46
44
  return {...base, type: 'message'};
47
45
  }
48
46
 
@@ -76,15 +74,10 @@ class MessageParser {
76
74
  return this._findMatch(text, this.keywords);
77
75
  }
78
76
 
79
- findFlowTrigger(text) {
80
- return this._findMatch(text, this.flowTriggers);
81
- }
82
-
83
77
  updateConfig(newConfig) {
84
78
  this.config = { ...this.config, ...newConfig };
85
79
  this.commandPrefixes = this.config.commandPrefixes || this.commandPrefixes;
86
80
  this.keywords = this.config.keywords || this.keywords;
87
- this.flowTriggers = this.config.flowTriggers || this.flowTriggers;
88
81
  }
89
82
  }
90
83
 
@@ -380,10 +380,10 @@ class NexusMessaging {
380
380
  if (stop) return;
381
381
  }
382
382
 
383
+ if (messageData.flow) return this.handleFlow(messageData);
383
384
  if (messageData.interactive) return this.handleInteractive(messageData);
384
385
  if (messageData.command) return this.handleCommand(messageData);
385
386
  if (messageData.keyword) return this.handleKeyword(messageData);
386
- if (messageData.flow) return this.handleFlow(messageData);
387
387
 
388
388
  if (this.batchingConfig.enabled && chatId) return this._handleWithCheckAfter(chatId);
389
389
  return messageData.media ? this.handleMedia(messageData) : this.handleMessage(messageData);
@@ -415,6 +415,8 @@ class NexusMessaging {
415
415
 
416
416
  async handleFlow(messageData) {
417
417
  if (this.handlers.onFlow) return await this.handlers.onFlow(messageData, this);
418
+ // Backward compatibility for consumers who don't have onFlow handler
419
+ if (this.handlers.onInteractive) return await this.handlers.onInteractive(messageData, this);
418
420
  }
419
421
 
420
422
  /*
@@ -518,7 +520,6 @@ class NexusMessaging {
518
520
  }
519
521
 
520
522
  async processInstruction(code, instruction, role = 'developer', { triggeredBy } = {}) {
521
- const assistantId = await this._getThreadAssistantId(code);
522
523
  const messageData = {
523
524
  pushName: 'Instruction',
524
525
  code,
@@ -527,7 +528,6 @@ class NexusMessaging {
527
528
  fromMe: true,
528
529
  processed: true,
529
530
  origin: 'instruction',
530
- assistantId,
531
531
  raw: { role },
532
532
  triggeredBy: triggeredBy || null,
533
533
  silent: true,
@@ -556,7 +556,6 @@ class NexusMessaging {
556
556
  const thread = await Thread.findOne({ code }).lean();
557
557
  if (!thread) return null;
558
558
 
559
- const assistantId = await this._getThreadAssistantId(code);
560
559
  const normalizedMessages = Array.isArray(messages) ? messages : [messages];
561
560
 
562
561
  for (let i = 0; i < normalizedMessages.length; i++) {
@@ -569,7 +568,6 @@ class NexusMessaging {
569
568
  fromMe: true,
570
569
  processed: true,
571
570
  origin: 'system',
572
- assistantId,
573
571
  raw: { role },
574
572
  triggeredBy: triggeredBy || null,
575
573
  silent: true,
@@ -597,11 +595,6 @@ class NexusMessaging {
597
595
  return result?.output || null;
598
596
  }
599
597
 
600
- async _getThreadAssistantId(code) {
601
- const thread = await Thread.findOne({ code }).select('assistant_id prompt_id').lean();
602
- return thread?.prompt_id || thread?.assistant_id || null;
603
- }
604
-
605
598
  async _processMessages(chatId, processingFn, shouldFinalize = () => true) {
606
599
  const query = { numero: chatId, from_me: false, processed: false };
607
600
  const unprocessed = await getMessages(query, { select: '_id' });
@@ -69,16 +69,18 @@ class PhiProcessor {
69
69
  }
70
70
 
71
71
  async inData(messageData, saveFn, handler) {
72
- const encoded = await this.encodeBody(messageData.body, messageData.code);
73
- const saved = await saveFn({ ...messageData, body: encoded });
74
- return handler({ body: encoded, _id: saved?._id });
72
+ const decodedBody = messageData.body;
73
+ const encodedBody = await this.encodeBody(decodedBody, messageData.code);
74
+ const saved = await saveFn({ ...messageData, body: encodedBody, plainBody: decodedBody });
75
+ return handler({ body: encodedBody, _id: saved?._id });
75
76
  }
76
77
 
77
78
  async outDataFromPlain(messageData, saveFn, handler, { onSaved } = {}) {
78
- const encoded = await this.encodeBody(messageData.body, messageData.code);
79
- const saved = await saveFn({ ...messageData, body: encoded });
79
+ const decodedBody = messageData.body;
80
+ const encodedBody = await this.encodeBody(decodedBody, messageData.code);
81
+ const saved = await saveFn({ ...messageData, body: encodedBody, plainBody: decodedBody });
80
82
  await onSaved?.(saved?._id);
81
- return handler({ body: messageData.body, _id: saved?._id });
83
+ return handler({ body: decodedBody, _id: saved?._id });
82
84
  }
83
85
 
84
86
  async decodeBody(body, numero) {
@@ -100,10 +102,10 @@ class PhiProcessor {
100
102
  }
101
103
 
102
104
  async outDataFromEncoded(messageData, saveFn, handler) {
103
- const saved = await saveFn(messageData);
104
105
  const decodedBody = this._phiEnabled
105
106
  ? await this.decodeBody(messageData.body, messageData.code)
106
107
  : messageData.body;
108
+ const saved = await saveFn({ ...messageData, plainBody: decodedBody });
107
109
  return handler({ body: decodedBody, _id: saved?._id });
108
110
  }
109
111
  }
@@ -90,7 +90,9 @@ async function getLastNMessages(code, n, anchor = null, opts = {}) {
90
90
  numero: code,
91
91
  ...(anchor ? { createdAt: { [beforeOperator]: anchor } } : {}),
92
92
  };
93
- return getMessages(query, { sort: { createdAt: -1 }, limit: n });
93
+ const messages = await getMessages(query, { sort: { createdAt: -1 }, limit: n });
94
+ messages.forEach(msg => { msg.body = msg.plainBody || msg.body; });
95
+ return messages;
94
96
  }
95
97
 
96
98
  module.exports = {
package/lib/index.d.ts CHANGED
@@ -13,7 +13,7 @@ declare module '@peopl-health/nexus' {
13
13
  media?: MediaData;
14
14
  command?: CommandData;
15
15
  keyword?: string;
16
- flow?: string;
16
+ flow?: InteractiveData;
17
17
  type?: 'message' | 'interactive' | 'media' | 'command' | 'keyword' | 'flow';
18
18
  }
19
19
 
@@ -93,7 +93,6 @@ declare module '@peopl-health/nexus' {
93
93
  export interface ParserConfig {
94
94
  commandPrefixes?: string[];
95
95
  keywords?: (string | { pattern: string; flags?: string })[];
96
- flowTriggers?: (string | { pattern: string; flags?: string })[];
97
96
  }
98
97
 
99
98
  // Handler Types
@@ -5,6 +5,7 @@ const { DELIVERY_ATTEMPT_STATUSES } = require('./deliveryAttemptModel');
5
5
  const messageSchema = new mongoose.Schema({
6
6
  raw: { type: Object, default: null },
7
7
  body: { type: String, default: '' },
8
+ plainBody: { type: String, default: null },
8
9
  numero: { type: String, required: true },
9
10
  nombre_whatsapp: { type: String, default: null },
10
11
  message_id: { type: String, default: null},
@@ -37,7 +37,6 @@ const TemplateSchema = new mongoose.Schema({
37
37
  ],
38
38
  approvalRequest: {
39
39
  sid: String,
40
- status: String,
41
40
  dateSubmitted: Date,
42
41
  dateUpdated: Date,
43
42
  rejectionReason: String
@@ -0,0 +1,275 @@
1
+ const runtimeConfig = require('../config/runtimeConfig');
2
+ const { logger } = require('../utils/logger');
3
+ const { getCurrentMexicoDateTime } = require('../utils/dateUtils');
4
+ const { isJsonBlob } = require('../utils/formatUtils');
5
+
6
+ const { DefaultMemoryManager } = require('../memory/DefaultMemoryManager');
7
+
8
+ const { getMessages } = require('../services/messageService');
9
+
10
+ const { logBugReportToAirtable } = require('../controllers/bugReportController');
11
+
12
+ const DEFAULT_MAX_CONVERSATION_RETRIES = 3;
13
+
14
+ class BaseLLMProvider {
15
+ constructor(options = {}) {
16
+ if (new.target === BaseLLMProvider) {
17
+ throw new Error('BaseLLMProvider is abstract and cannot be instantiated directly');
18
+ }
19
+ const { conversationManager, sessionManager, maxConversationRetries } = options;
20
+ this.client = null;
21
+ this.variant = null;
22
+ this.conversationManager = conversationManager || new DefaultMemoryManager();
23
+ this.sessionManager = sessionManager || null;
24
+ const retries = parseInt(maxConversationRetries, 10);
25
+ this.maxConversationRetries = retries > 0 ? retries : DEFAULT_MAX_CONVERSATION_RETRIES;
26
+ }
27
+
28
+ getVariant() { return this.variant; }
29
+ getClient() { return this.client; }
30
+
31
+ async _executeConversation() {
32
+ throw new Error(`${this.constructor.name} must implement _executeConversation()`);
33
+ }
34
+
35
+ async createConversation() {
36
+ throw new Error(`createConversation is not supported by ${this.constructor.name}`);
37
+ }
38
+
39
+ async addMessage() {
40
+ throw new Error(`addMessage is not supported by ${this.constructor.name}`);
41
+ }
42
+
43
+ async listMessages() {
44
+ throw new Error(`listMessages is not supported by ${this.constructor.name}`);
45
+ }
46
+
47
+ async transcribeAudio() {
48
+ throw new Error(`transcribeAudio is not supported by ${this.constructor.name}`);
49
+ }
50
+
51
+ async executeRun({ thread, assistant, message = null, tools = [], config = {} }) {
52
+ const { conversationId, assistantId } = this._normalizeThread(thread);
53
+ const promptVersion = thread?.version || null;
54
+ const presetId = thread?.preset_id || null;
55
+ const presetVersion = thread?.preset_version || null;
56
+
57
+ logger.info('[executeRun] Starting', { conversationId, assistantId, promptVersion });
58
+
59
+ try {
60
+ const context = await this.conversationManager.buildContext({
61
+ thread,
62
+ message,
63
+ config: {
64
+ ...config,
65
+ threadId: conversationId,
66
+ assistantId,
67
+ }
68
+ });
69
+
70
+ const [lastMessage] = await getMessages({ numero: thread.code }, { sort: { createdAt: -1 }, limit: 1 });
71
+ const phiProcessor = config.phiProcessor;
72
+ const maskedCode = phiProcessor ? phiProcessor.hashCode(thread.code) : thread.code;
73
+ const metadata = {
74
+ numero: maskedCode,
75
+ message_id: message?.message_id || lastMessage?.message_id || null
76
+ };
77
+
78
+ logger.info('[executeRun] Context built', { conversationId, messageCount: context?.length || 0 });
79
+
80
+ const override = config.clinicalData;
81
+ const hasOverride = override && typeof override === 'object' && Object.keys(override).length > 0;
82
+ const clinicalData = hasOverride ? override : await this.conversationManager.getClinicalData(thread.code);
83
+ const promptVariables = {
84
+ clinical_context: clinicalData?.clinicalContext ?? '',
85
+ last_symptoms: clinicalData?.lastSymptoms ?? '',
86
+ current_date: getCurrentMexicoDateTime(),
87
+ patient_memories: clinicalData?.patientMemories ?? '',
88
+ conversation_summaries: clinicalData?.conversationSummaries ?? '',
89
+ };
90
+
91
+ const result = await this.runConversation({
92
+ threadId: conversationId,
93
+ assistantId,
94
+ presetId,
95
+ presetVersion,
96
+ tools,
97
+ context,
98
+ promptVariables,
99
+ promptVersion,
100
+ assistant,
101
+ metadata,
102
+ ...config
103
+ });
104
+
105
+ await this.conversationManager.processResponse(result, thread, config);
106
+ this.sessionManager?.recordActivity(thread.code);
107
+
108
+ const completed = result.status === 'completed';
109
+ const output = result.output_text || this._extractMessageOutput(result);
110
+ const toolsExecuted = result.tools_executed?.length || 0;
111
+
112
+ logger.info('[executeRun] Complete', {
113
+ runId: result.id,
114
+ completed,
115
+ outputLength: output?.length || 0,
116
+ toolsExecuted
117
+ });
118
+
119
+ return { run: result, completed, output, tools_executed: result.tools_executed || [], retries: result.retries || 0 };
120
+ } catch (error) {
121
+ logger.error('[executeRun] Failed', { conversationId, assistantId, error: error.message });
122
+ throw error;
123
+ }
124
+ }
125
+
126
+ async runConversation(config = {}) {
127
+ const { threadId, assistantId } = config;
128
+ const maxRetries = this.maxConversationRetries;
129
+
130
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
131
+ try {
132
+ logger.info('[runConversation] Attempt', { attempt, maxRetries, threadId, assistantId });
133
+
134
+ const result = await this._executeConversation(config);
135
+ const extractedOutput = this._extractMessageOutput(result);
136
+
137
+ if (extractedOutput?.trim()) {
138
+ result.output_text = extractedOutput;
139
+ logger.info('[runConversation] Success', {
140
+ attempt,
141
+ outputLength: extractedOutput.length,
142
+ toolsExecuted: result.tools_executed?.length || 0
143
+ });
144
+ return result;
145
+ }
146
+
147
+ logger.warn('[runConversation] Empty output', { attempt });
148
+ if (attempt === maxRetries) {
149
+ throw new Error(`Conversation failed after ${attempt} attempts - no valid output`);
150
+ }
151
+ await new Promise(r => setTimeout(r, 500));
152
+ } catch (error) {
153
+ logger.error('[runConversation] Attempt failed', { attempt, error: error.message });
154
+ if (attempt === maxRetries) throw error;
155
+ await new Promise(r => setTimeout(r, 500));
156
+ }
157
+ }
158
+ }
159
+
160
+ _normalizeThread(thread) {
161
+ return {
162
+ conversationId: thread.conversation_id || thread.getConversationId?.(),
163
+ assistantId: thread.prompt_id || thread.getAssistantId?.()
164
+ };
165
+ }
166
+
167
+ _convertItemsToApiFormat(items) {
168
+ return items.map(item => {
169
+ const type = item.type || 'message';
170
+ if (type === 'function_call' || type === 'function_call_output') {
171
+ return { ...item, type };
172
+ }
173
+ return {
174
+ role: item.role || 'user',
175
+ type,
176
+ content: this._normalizeContent(item.content)
177
+ };
178
+ });
179
+ }
180
+
181
+ _normalizeContent(content) {
182
+ if (typeof content === 'string') return content;
183
+ if (Array.isArray(content)) return content;
184
+ if (content?.text) return content.text;
185
+ if (content && typeof content === 'object') return JSON.stringify(content);
186
+ return content || '';
187
+ }
188
+
189
+ _contentPartToText(part) {
190
+ if (part == null || typeof part !== 'object') return '';
191
+ return typeof part.text === 'string' ? part.text : (typeof part.text?.value === 'string' ? part.text.value : '');
192
+ }
193
+
194
+ _messageItemToText(item) {
195
+ if (!item) return '';
196
+ if (Array.isArray(item.content)) {
197
+ return item.content.map(c => this._contentPartToText(c)).filter(Boolean).join('').trim();
198
+ }
199
+ if (typeof item.content === 'string') return item.content.trim();
200
+ return '';
201
+ }
202
+
203
+ _extractMessageOutput(result) {
204
+ if (result == null || typeof result !== 'object') return '';
205
+
206
+ if (result.output && Array.isArray(result.output)) {
207
+ const messageItems = result.output.filter(item => item && item.type === 'message');
208
+ if (messageItems.length > 0) {
209
+ const texts = messageItems.map(item => this._messageItemToText(item)).filter(Boolean);
210
+ const proseTexts = texts.filter(text => !isJsonBlob(text));
211
+ const jsonDiscarded = texts.length - proseTexts.length;
212
+ const keptOne = proseTexts.length > 0 ? 1 : 0;
213
+
214
+ if (messageItems.length > 1 || jsonDiscarded > 0) {
215
+ const hasFunctionCalls = result.output.some(item => item?.type === 'function_call');
216
+ logger.warn(`[${this.constructor.name}] Multiple/structured message items in response; keeping the first natural-language reply (likely OpenAI multi-output bug)`, {
217
+ discarded: messageItems.length - keptOne,
218
+ jsonDiscarded,
219
+ responseId: result.id,
220
+ model: result.model,
221
+ hasFunctionCalls
222
+ });
223
+ logBugReportToAirtable({
224
+ reporter: 'system',
225
+ description: [
226
+ `Responses API (${this.constructor.name}) returned multiple message items in a single response.`,
227
+ 'Suspected upstream bug (model failed to emit stop-of-message token).',
228
+ `Discarded ${messageItems.length - keptOne} extra message(s); kept the first natural-language reply as the canonical reply.`,
229
+ jsonDiscarded > 0 ? `${jsonDiscarded} discarded item(s) were raw JSON/structured output that must never reach the patient.` : null,
230
+ `Response ID: ${result.id || 'unknown'}`,
231
+ `Model: ${result.model || 'unknown'}`,
232
+ `Function calls present: ${hasFunctionCalls ? 'yes' : 'no'}`
233
+ ].filter(Boolean).join('\n'),
234
+ severity: 'medium',
235
+ status: 'Open',
236
+ clasificacion: 'alucinaciones',
237
+ bugType: 'backend',
238
+ owner: ['ariana'],
239
+ request_id: result.id || null,
240
+ server: runtimeConfig.get('SERVICE_NAME') || 'nexus'
241
+ }).catch((err) => logger.warn(`[${this.constructor.name}] Bug report logger failed`, { error: err.message }));
242
+ }
243
+
244
+ if (proseTexts.length > 0) return proseTexts[0];
245
+ }
246
+ }
247
+ if (result.output_text) return this._extractMessageFromConcatenatedOutput(result.output_text);
248
+ return '';
249
+ }
250
+
251
+ _extractMessageFromConcatenatedOutput(outputText) {
252
+ if (!outputText || typeof outputText !== 'string') return '';
253
+ if (!/^\s*assistant\s*\n/i.test(outputText)) return outputText.trim();
254
+
255
+ const segments = outputText
256
+ .split(/\n\s*assistant\s*\n/i)
257
+ .map(s => s.replace(/^assistant\s*\n?/i, '').trim())
258
+ .filter(Boolean);
259
+
260
+ if (segments.length === 0) return '';
261
+ if (segments.length > 1) logger.debug(`[${this.constructor.name}] Concatenated transcript detected, using last segment`, { segmentCount: segments.length });
262
+ return segments[segments.length - 1].trim();
263
+ }
264
+
265
+ _ensureId(value) {
266
+ if (!value) throw new Error('Identifier value is required');
267
+ if (typeof value === 'string') return value;
268
+ if (value?.id) return value.id;
269
+ throw new Error('Unable to resolve identifier value');
270
+ }
271
+ }
272
+
273
+ module.exports = {
274
+ BaseLLMProvider,
275
+ };
@@ -1,18 +1,12 @@
1
1
  const { OpenAI } = require('openai');
2
2
 
3
- const runtimeConfig = require('../config/runtimeConfig');
4
3
  const { retryWithBackoff } = require('../utils/retryUtils');
5
4
  const { logger } = require('../utils/logger');
6
- const { getCurrentMexicoDateTime } = require('../utils/dateUtils');
7
- const { isJsonBlob } = require('../utils/formatUtils');
8
-
9
- const { DefaultMemoryManager } = require('../memory/DefaultMemoryManager');
10
5
 
11
6
  const { composePrompt, resolveTools } = require('../services/promptComposerService');
12
7
  const { getToolSchemas: getRegistrySchemas } = require('../services/toolRegistryService');
13
- const { getMessages } = require('../services/messageService');
14
8
 
15
- const { logBugReportToAirtable } = require('../controllers/bugReportController');
9
+ const { BaseLLMProvider } = require('./BaseLLMProvider');
16
10
  const { handleFunctionCalls } = require('./OpenAIResponsesProviderTools');
17
11
 
18
12
  const CONVERSATION_PREFIX = 'conv_';
@@ -20,19 +14,17 @@ const RESPONSE_PREFIX = 'resp_';
20
14
  const MAX_ITEMS_ON_CREATE = 20;
21
15
  const MAX_ITEMS_PER_BATCH = 20;
22
16
  const DEFAULT_MAX_HISTORICAL_MESSAGES = parseInt(process.env.MAX_HISTORICAL_MESSAGES || '50', 10);
23
- const MAX_CONVERSATION_RETRIES = parseInt(process.env.MAX_CONVERSATION_RETRIES || '3', 10);
24
17
  const MAX_FUNCTION_ROUNDS = parseInt(process.env.MAX_FUNCTION_ROUNDS || '5', 10);
25
18
  const PROVIDER_NAME = 'OpenAIResponsesProvider';
26
19
 
27
- class OpenAIResponsesProvider {
20
+ class OpenAIResponsesProvider extends BaseLLMProvider {
28
21
  constructor(options = {}) {
22
+ super(options);
29
23
  const {
30
24
  apiKey = process.env.OPENAI_API_KEY,
31
25
  organization,
32
26
  client,
33
27
  defaultModels = {},
34
- conversationManager,
35
- sessionManager,
36
28
  } = options;
37
29
 
38
30
  if (!client && !apiKey) {
@@ -51,16 +43,11 @@ class OpenAIResponsesProvider {
51
43
  };
52
44
 
53
45
  this.variant = 'responses';
54
- this.conversationManager = conversationManager || new DefaultMemoryManager();
55
- this.sessionManager = sessionManager || null;
56
46
 
57
47
  this.responses = this.client.responses;
58
48
  this.conversations = this.client.conversations;
59
49
  }
60
50
 
61
- getVariant() { return this.variant; }
62
- getClient() { return this.client; }
63
-
64
51
  async createConversation({ metadata, messages = [], toolResources } = {}) {
65
52
  const capped = messages.length > DEFAULT_MAX_HISTORICAL_MESSAGES;
66
53
  const messagesToProcess = capped ? messages.slice(-DEFAULT_MAX_HISTORICAL_MESSAGES) : messages;
@@ -116,28 +103,6 @@ class OpenAIResponsesProvider {
116
103
  logger.info(`[_addItemsInBatches] Added ${items.length} messages in ${Math.ceil(items.length / batchSize)} batches`);
117
104
  }
118
105
 
119
- _convertItemsToApiFormat(items) {
120
- return items.map(item => {
121
- const type = item.type || 'message';
122
- if (type === 'function_call' || type === 'function_call_output') {
123
- return { ...item, type };
124
- }
125
- return {
126
- role: item.role || 'user',
127
- type,
128
- content: this._normalizeContent(item.content)
129
- };
130
- });
131
- }
132
-
133
- _normalizeContent(content) {
134
- if (typeof content === 'string') return content;
135
- if (Array.isArray(content)) return content;
136
- if (content?.text) return content.text;
137
- if (content && typeof content === 'object') return JSON.stringify(content);
138
- return content || '';
139
- }
140
-
141
106
  async addMessage({ threadId, messages, role = 'user', content, metadata }) {
142
107
  const id = this._ensurethreadId(threadId);
143
108
  const messagesToAdd = messages || [{ role, content, metadata }];
@@ -156,199 +121,6 @@ class OpenAIResponsesProvider {
156
121
  );
157
122
  }
158
123
 
159
- _normalizeThread(thread) {
160
- return {
161
- conversationId: thread.conversation_id || thread.getConversationId?.(),
162
- assistantId: thread.prompt_id || thread.getAssistantId?.()
163
- };
164
- }
165
-
166
- _contentPartToText(part) {
167
- if (part == null || typeof part !== 'object') return '';
168
- return typeof part.text === 'string' ? part.text : (typeof part.text?.value === 'string' ? part.text.value : '');
169
- }
170
-
171
- _messageItemToText(item) {
172
- if (!item) return '';
173
- if (Array.isArray(item.content)) {
174
- return item.content.map(c => this._contentPartToText(c)).filter(Boolean).join('').trim();
175
- }
176
- if (typeof item.content === 'string') return item.content.trim();
177
- return '';
178
- }
179
-
180
- _extractMessageOutput(result) {
181
- if (result == null || typeof result !== 'object') return '';
182
-
183
- if (result.output && Array.isArray(result.output)) {
184
- const messageItems = result.output.filter(item => item && item.type === 'message');
185
- if (messageItems.length > 0) {
186
- const texts = messageItems.map(item => this._messageItemToText(item)).filter(Boolean);
187
- const proseTexts = texts.filter(text => !isJsonBlob(text));
188
- const jsonDiscarded = texts.length - proseTexts.length;
189
- const keptOne = proseTexts.length > 0 ? 1 : 0;
190
-
191
- if (messageItems.length > 1 || jsonDiscarded > 0) {
192
- const hasFunctionCalls = result.output.some(item => item?.type === 'function_call');
193
- logger.warn('[OpenAIResponsesProvider] Multiple/structured message items in response; keeping the first natural-language reply (likely OpenAI multi-output bug)', {
194
- discarded: messageItems.length - keptOne,
195
- jsonDiscarded,
196
- responseId: result.id,
197
- model: result.model,
198
- hasFunctionCalls
199
- });
200
- logBugReportToAirtable({
201
- reporter: 'system',
202
- description: [
203
- 'OpenAI Responses API returned multiple message items in a single response.',
204
- 'Suspected upstream bug (model failed to emit stop-of-message token).',
205
- `Discarded ${messageItems.length - keptOne} extra message(s); kept the first natural-language reply as the canonical reply.`,
206
- jsonDiscarded > 0 ? `${jsonDiscarded} discarded item(s) were raw JSON/structured output that must never reach the patient.` : null,
207
- `Response ID: ${result.id || 'unknown'}`,
208
- `Model: ${result.model || 'unknown'}`,
209
- `Function calls present: ${hasFunctionCalls ? 'yes' : 'no'}`
210
- ].filter(Boolean).join('\n'),
211
- severity: 'medium',
212
- status: 'Open',
213
- clasificacion: 'alucinaciones',
214
- bugType: 'backend',
215
- owner: ['ariana'],
216
- request_id: result.id || null,
217
- server: runtimeConfig.get('SERVICE_NAME') || 'nexus'
218
- }).catch((err) => logger.warn('[OpenAIResponsesProvider] Bug report logger failed', { error: err.message }));
219
- }
220
-
221
- if (proseTexts.length > 0) return proseTexts[0];
222
- }
223
- }
224
- if (result.output_text) return this._extractMessageFromConcatenatedOutput(result.output_text);
225
- return '';
226
- }
227
-
228
- _extractMessageFromConcatenatedOutput(outputText) {
229
- if (!outputText || typeof outputText !== 'string') return '';
230
- if (!/^\s*assistant\s*\n/i.test(outputText)) return outputText.trim();
231
-
232
- const segments = outputText
233
- .split(/\n\s*assistant\s*\n/i)
234
- .map(s => s.replace(/^assistant\s*\n?/i, '').trim())
235
- .filter(Boolean);
236
-
237
- if (segments.length === 0) return '';
238
- if (segments.length > 1) logger.debug('[OpenAIResponsesProvider] Concatenated transcript detected, using last segment', { segmentCount: segments.length });
239
- return segments[segments.length - 1].trim();
240
- }
241
-
242
- async executeRun({ thread, assistant, message = null, tools = [], config = {} }) {
243
- const { conversationId, assistantId } = this._normalizeThread(thread);
244
- const promptVersion = thread?.version || null;
245
- const presetId = thread?.preset_id || null;
246
- const presetVersion = thread?.preset_version || null;
247
-
248
- logger.info('[executeRun] Starting', { conversationId, assistantId, promptVersion });
249
-
250
- try {
251
- const context = await this.conversationManager.buildContext({
252
- thread,
253
- message,
254
- config: {
255
- ...config,
256
- threadId: conversationId,
257
- assistantId,
258
- }
259
- });
260
-
261
- const [lastMessage] = await getMessages({ numero: thread.code }, { sort: { createdAt: -1 }, limit: 1 });
262
- const phiProcessor = config.phiProcessor;
263
- const maskedCode = phiProcessor ? phiProcessor.hashCode(thread.code) : thread.code;
264
- const metadata = {
265
- numero: maskedCode,
266
- message_id: message?.message_id || lastMessage?.message_id || null
267
- };
268
-
269
- logger.info('[executeRun] Context built', { conversationId, messageCount: context?.length || 0 });
270
-
271
- // config.clinicalData, when given, fully replaces the fetched bundle (all four fields).
272
- // Must be a complete, non-empty object; anything else falls through to the normal fetch.
273
- const override = config.clinicalData;
274
- const hasOverride = override && typeof override === 'object' && Object.keys(override).length > 0;
275
- const clinicalData = hasOverride ? override : await this.conversationManager.getClinicalData(thread.code);
276
- const promptVariables = {
277
- clinical_context: clinicalData?.clinicalContext ?? '',
278
- last_symptoms: clinicalData?.lastSymptoms ?? '',
279
- current_date: getCurrentMexicoDateTime(),
280
- patient_memories: clinicalData?.patientMemories ?? '',
281
- conversation_summaries: clinicalData?.conversationSummaries ?? '',
282
- };
283
-
284
- const result = await this.runConversation({
285
- threadId: conversationId,
286
- assistantId,
287
- presetId,
288
- presetVersion,
289
- tools,
290
- context,
291
- promptVariables,
292
- promptVersion,
293
- assistant,
294
- metadata,
295
- ...config
296
- });
297
-
298
- await this.conversationManager.processResponse(result, thread, config);
299
- this.sessionManager?.recordActivity(thread.code);
300
-
301
- const completed = result.status === 'completed';
302
- const output = result.output_text || this._extractMessageOutput(result);
303
- const toolsExecuted = result.tools_executed?.length || 0;
304
-
305
- logger.info('[executeRun] Complete', {
306
- runId: result.id,
307
- completed,
308
- outputLength: output?.length || 0,
309
- toolsExecuted
310
- });
311
-
312
- return { run: result, completed, output, tools_executed: result.tools_executed || [], retries: result.retries || 0 };
313
- } catch (error) {
314
- logger.error('[executeRun] Failed', { conversationId, assistantId, error: error.message });
315
- throw error;
316
- }
317
- }
318
-
319
- async runConversation(config = {}) {
320
- const { threadId, assistantId } = config;
321
-
322
- for (let attempt = 1; attempt <= MAX_CONVERSATION_RETRIES; attempt++) {
323
- try {
324
- logger.info('[runConversation] Attempt', { attempt, maxRetries: MAX_CONVERSATION_RETRIES, threadId, assistantId });
325
-
326
- const result = await this._executeConversation(config);
327
- const extractedOutput = this._extractMessageOutput(result);
328
-
329
- if (extractedOutput?.trim()) {
330
- result.output_text = extractedOutput;
331
- logger.info('[runConversation] Success', {
332
- attempt,
333
- outputLength: extractedOutput.length,
334
- toolsExecuted: result.tools_executed?.length || 0
335
- });
336
- return result;
337
- }
338
-
339
- logger.warn('[runConversation] Empty output', { attempt });
340
- if (attempt === MAX_CONVERSATION_RETRIES) {
341
- throw new Error(`Conversation failed after ${attempt} attempts - no valid output`);
342
- }
343
- await new Promise(r => setTimeout(r, 500));
344
- } catch (error) {
345
- logger.error('[runConversation] Attempt failed', { attempt, error: error.message });
346
- if (attempt === MAX_CONVERSATION_RETRIES) throw error;
347
- await new Promise(r => setTimeout(r, 500));
348
- }
349
- }
350
- }
351
-
352
124
  async _executeConversation(config = {}) {
353
125
  const {
354
126
  threadId, assistantId, presetId = null, presetVersion = null, additionalMessages = [], context = null,
@@ -471,7 +243,6 @@ class OpenAIResponsesProvider {
471
243
  if (assistant && response.output) {
472
244
  let currentInput = [...input];
473
245
 
474
- // Follow-up calls always use the default tool_choice.
475
246
  apiCallConfig.tool_choice = toolChoice;
476
247
 
477
248
  for (let round = 1; round <= MAX_FUNCTION_ROUNDS; round++) {
@@ -532,13 +303,6 @@ class OpenAIResponsesProvider {
532
303
  return id;
533
304
  }
534
305
 
535
- _ensureId(value) {
536
- if (!value) throw new Error('Identifier value is required');
537
- if (typeof value === 'string') return value;
538
- if (value?.id) return value.id;
539
- throw new Error('Unable to resolve identifier value');
540
- }
541
-
542
306
  async _post(path, body, options = {}) { return this.client.post(path, { ...options, body }); }
543
307
  async _get(path, query, options = {}) { return this.client.get(path, { ...options, query }); }
544
308
  async _delete(path, options = {}) { return this.client.delete(path, options); }
@@ -63,7 +63,7 @@ async function insertMessage(values) {
63
63
  {
64
64
  $set: {
65
65
  lastMessageAt: new Date(),
66
- lastMessageBody: values.body || '',
66
+ lastMessageBody: values.plainBody || '',
67
67
  lastMessageFromMe: !!values.from_me,
68
68
  lastMessageMedia: values.media || null
69
69
  },
@@ -77,7 +77,7 @@ async function insertMessage(values) {
77
77
  }
78
78
 
79
79
  updateRecordByFilter(Monitoreo_ID, 'message_monitor', `{whatsapp_id} = "${values.numero}"`, {
80
- ...(values.from_me ? { last_message_bot: values.body } : { last_message_patient: values.body, read: false }),
80
+ ...(values.from_me ? { last_message_bot: doc.plainBody } : { last_message_patient: doc.plainBody, read: false }),
81
81
  ...(values.from_me ? { last_message_bot_time: doc.createdAt.toISOString() } : { last_message_patient_time: doc.createdAt.toISOString() })
82
82
  }, values.numero).catch(err => logger.error('[MongoStorage] Failed to update message_monitor table', { numero: values.numero, error: err.message }));
83
83
 
@@ -16,7 +16,6 @@ const refreshApprovalStatuses = async (templates) => {
16
16
  const updateFields = {
17
17
  approvalRequest: {
18
18
  sid: reqData.sid,
19
- status: reqData.status,
20
19
  dateSubmitted: reqData.dateCreated ? new Date(reqData.dateCreated) : new Date(),
21
20
  dateUpdated: reqData.dateUpdated ? new Date(reqData.dateUpdated) : new Date(),
22
21
  rejectionReason: reqData.rejectionReason || ''
@@ -89,7 +89,7 @@ class MongoStorage {
89
89
  code: doc.numero,
90
90
  name: doc.nombre_whatsapp,
91
91
  origin: doc.origin,
92
- body: doc.body,
92
+ body: doc.plainBody,
93
93
  media: doc.media,
94
94
  type: doc.interactive_type ? 'interactive' : doc.media ? 'media' : 'message',
95
95
  triggeredBy: doc.triggeredBy,
@@ -106,6 +106,7 @@ class MongoStorage {
106
106
  nombre_whatsapp: messageData.pushName ?? (fromMe ? runtimeConfig.get('USER_DB_MONGO') : null),
107
107
  numero: ensureWhatsAppFormat(messageData.code),
108
108
  body: messageData.body || '',
109
+ plainBody: messageData.plainBody || '',
109
110
  processed: messageData.processed || false,
110
111
  message_id: messageId,
111
112
  interactive_type: messageData.interactive?.type || null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "4.5.26",
3
+ "version": "4.5.28",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",