@peopl-health/nexus 4.4.5 → 4.5.21
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/README.md +4 -9
- package/lib/adapters/BaileysProvider.js +4 -2
- package/lib/adapters/MessageProvider.js +2 -2
- package/lib/adapters/TwilioProvider.js +7 -3
- package/lib/controllers/assistantController.js +2 -6
- package/lib/controllers/bugReportController.js +2 -2
- package/lib/controllers/conversationController.js +13 -13
- package/lib/controllers/interactionController.js +2 -2
- package/lib/controllers/messageController.js +6 -5
- package/lib/controllers/qualityMessageController.js +3 -2
- package/lib/core/AssistantProcessor.js +3 -3
- package/lib/core/BatchingManager.js +6 -5
- package/lib/core/NexusMessaging.js +225 -154
- package/lib/core/PhiProcessor.js +113 -0
- package/lib/eval/EvalProvider.js +6 -1
- package/lib/helpers/baileysHelper.js +3 -1
- package/lib/helpers/conversationWindowHelper.js +4 -4
- package/lib/helpers/deliveryAttemptHelper.js +3 -1
- package/lib/helpers/filesHelper.js +2 -5
- package/lib/helpers/messageHelper.js +10 -71
- package/lib/helpers/messageStatusHelper.js +3 -3
- package/lib/helpers/nerHelper.js +64 -0
- package/lib/helpers/templateRecoveryHelper.js +2 -0
- package/lib/index.d.ts +16 -1
- package/lib/jobs/ScheduledMessageJob.js +15 -23
- package/lib/jobs/TemplateApprovalJob.js +4 -1
- package/lib/memory/DefaultMemoryManager.js +5 -5
- package/lib/memory/SessionManager.js +3 -6
- package/lib/models/deliveryAttemptModel.js +1 -1
- package/lib/models/globalEntityMapModel.js +27 -0
- package/lib/models/messageModel.js +0 -94
- package/lib/models/tokenMapModel.js +28 -0
- package/lib/providers/NerClient.js +43 -0
- package/lib/providers/OpenAIResponsesProvider.js +9 -7
- package/lib/providers/OpenAIResponsesProviderTools.js +26 -9
- package/lib/services/assistantService.js +20 -11
- package/lib/services/dashboardService.js +4 -4
- package/lib/services/globalEntityService.js +59 -0
- package/lib/services/messageService.js +107 -0
- package/lib/services/patientService.js +3 -2
- package/lib/services/tokenMapService.js +100 -0
- package/lib/storage/MongoStorage.js +9 -14
- package/lib/utils/tokenMapUtils.js +12 -0
- package/package.json +1 -1
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const axios = require('axios');
|
|
2
|
+
|
|
3
|
+
const { logger } = require('../utils/logger');
|
|
4
|
+
|
|
5
|
+
const { detectStructured, personaFallback, mergeSpans } = require('../helpers/nerHelper');
|
|
6
|
+
|
|
7
|
+
class NerClient {
|
|
8
|
+
constructor({ nerUrl, nerApiKey, nerTimeoutMs } = {}) {
|
|
9
|
+
this.url = nerUrl || null;
|
|
10
|
+
this.apiKey = nerApiKey || null;
|
|
11
|
+
this.timeout = nerTimeoutMs || 3000;
|
|
12
|
+
|
|
13
|
+
if (this.url && !this.apiKey) {
|
|
14
|
+
logger.warn('[NerClient] NER URL configured without an API key');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async detectEntities(text) {
|
|
19
|
+
if (typeof text !== 'string' || !text.trim()) return [];
|
|
20
|
+
|
|
21
|
+
const structured = detectStructured(text);
|
|
22
|
+
|
|
23
|
+
if (!this.url) {
|
|
24
|
+
logger.debug('[NerClient] No NER endpoint configured, using regex fallback');
|
|
25
|
+
return mergeSpans(personaFallback(text), structured);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
30
|
+
if (this.apiKey) headers['x-api-key'] = this.apiKey;
|
|
31
|
+
|
|
32
|
+
// same-length replacement keeps span offsets valid against original text
|
|
33
|
+
const cleaned = text.replace(/[?!]/g, ' ');
|
|
34
|
+
const { data } = await axios.post(this.url, { text: cleaned }, { headers, timeout: this.timeout });
|
|
35
|
+
return mergeSpans(data.entities || [], structured);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
logger.warn('[NerClient] Lambda call failed, falling back to regex', { error: err.message });
|
|
38
|
+
return mergeSpans(personaFallback(text), structured);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { NerClient };
|
|
@@ -7,10 +7,10 @@ const { getCurrentMexicoDateTime } = require('../utils/dateUtils');
|
|
|
7
7
|
|
|
8
8
|
const { DefaultMemoryManager } = require('../memory/DefaultMemoryManager');
|
|
9
9
|
|
|
10
|
-
const { getLastNMessages } = require('../helpers/messageHelper');
|
|
11
|
-
|
|
12
10
|
const { composePrompt, resolveTools } = require('../services/promptComposerService');
|
|
13
11
|
const { getToolSchemas: getRegistrySchemas } = require('../services/toolRegistryService');
|
|
12
|
+
const { getMessages } = require('../services/messageService');
|
|
13
|
+
|
|
14
14
|
const { logBugReportToAirtable } = require('../controllers/bugReportController');
|
|
15
15
|
const { handleFunctionCalls } = require('./OpenAIResponsesProviderTools');
|
|
16
16
|
|
|
@@ -248,10 +248,12 @@ class OpenAIResponsesProvider {
|
|
|
248
248
|
}
|
|
249
249
|
});
|
|
250
250
|
|
|
251
|
-
const lastMessage = await
|
|
251
|
+
const [lastMessage] = await getMessages({ numero: thread.code }, { sort: { createdAt: -1 }, limit: 1 });
|
|
252
|
+
const phiProcessor = config.phiProcessor;
|
|
253
|
+
const maskedCode = phiProcessor ? phiProcessor.hashCode(thread.code) : thread.code;
|
|
252
254
|
const metadata = {
|
|
253
|
-
numero:
|
|
254
|
-
message_id: message?.message_id || lastMessage
|
|
255
|
+
numero: maskedCode,
|
|
256
|
+
message_id: message?.message_id || lastMessage?.message_id || null
|
|
255
257
|
};
|
|
256
258
|
|
|
257
259
|
logger.info('[executeRun] Context built', { conversationId, messageCount: context?.length || 0 });
|
|
@@ -342,7 +344,7 @@ class OpenAIResponsesProvider {
|
|
|
342
344
|
threadId, assistantId, presetId = null, presetVersion = null, additionalMessages = [], context = null,
|
|
343
345
|
instructions = null, additionalInstructions = null, metadata = {},
|
|
344
346
|
assistant, promptVersion = null, promptVariables = null,
|
|
345
|
-
toolChoice = 'auto', prePromptResult = null
|
|
347
|
+
toolChoice = 'auto', prePromptResult = null, phiProcessor = null
|
|
346
348
|
} = config;
|
|
347
349
|
|
|
348
350
|
let totalRetries = 0;
|
|
@@ -466,7 +468,7 @@ class OpenAIResponsesProvider {
|
|
|
466
468
|
const functionCalls = finalResponse.output.filter(item => item.type === 'function_call');
|
|
467
469
|
if (!functionCalls.length) break;
|
|
468
470
|
|
|
469
|
-
const { outputs, toolsExecuted } = await handleFunctionCalls(functionCalls, assistant);
|
|
471
|
+
const { outputs, toolsExecuted } = await handleFunctionCalls(functionCalls, assistant, phiProcessor);
|
|
470
472
|
|
|
471
473
|
currentInput.push(...finalResponse.output, ...outputs);
|
|
472
474
|
allToolsExecuted.push(...toolsExecuted);
|
|
@@ -1,32 +1,49 @@
|
|
|
1
1
|
const { logger } = require('../utils/logger');
|
|
2
|
+
const { safeParse } = require('../utils/jsonUtils');
|
|
3
|
+
|
|
2
4
|
const { hasTool, executeTool: registryExecuteTool } = require('../services/toolRegistryService');
|
|
3
5
|
|
|
4
|
-
async function executeFunctionCall(assistant, call) {
|
|
6
|
+
async function executeFunctionCall(assistant, call, phiProcessor) {
|
|
5
7
|
const startTime = Date.now();
|
|
6
8
|
const name = call.name;
|
|
7
|
-
const
|
|
9
|
+
const code = assistant?.thread?.code;
|
|
10
|
+
const argsStr = call.arguments || '{}';
|
|
11
|
+
const args = safeParse(argsStr);
|
|
12
|
+
let decodedArgs = args;
|
|
13
|
+
try {
|
|
14
|
+
decodedArgs = JSON.parse(await phiProcessor?.decodeBody(argsStr, code) ?? argsStr);
|
|
15
|
+
} catch (e) {
|
|
16
|
+
logger.warn('[executeFunctionCall] arg decode produced invalid JSON, falling back to original args', { name, error: e.message });
|
|
17
|
+
}
|
|
8
18
|
|
|
9
19
|
logger.info('[executeFunctionCall] Calling', { name, call_id: call.call_id });
|
|
10
20
|
|
|
11
21
|
let result, success = true;
|
|
12
22
|
try {
|
|
13
23
|
if (hasTool(name)) {
|
|
14
|
-
result = await registryExecuteTool(name,
|
|
24
|
+
result = await registryExecuteTool(name, decodedArgs, assistant?.thread);
|
|
15
25
|
} else {
|
|
16
|
-
result = await assistant.executeTool(name,
|
|
26
|
+
result = await assistant.executeTool(name, decodedArgs);
|
|
17
27
|
}
|
|
18
28
|
logger.info('[executeFunctionCall] Completed', { name, call_id: call.call_id, source: hasTool(name) ? 'registry' : 'assistant', duration_ms: Date.now() - startTime });
|
|
19
29
|
} catch (error) {
|
|
20
|
-
result = { error: error?.message || 'Tool execution failed' };
|
|
30
|
+
result = JSON.stringify({ success: false, error: error?.message || 'Tool execution failed' });
|
|
21
31
|
success = false;
|
|
22
32
|
logger.error('[executeFunctionCall] Failed', { name, call_id: call.call_id, error: error?.message });
|
|
23
33
|
}
|
|
24
|
-
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
result = await phiProcessor?.encodeBody(result, code) ?? result;
|
|
37
|
+
} catch (e) {
|
|
38
|
+
logger.error('[executeFunctionCall] PHI encoding failed, returning safe error', { name, call_id: call.call_id, error: e.message });
|
|
39
|
+
result = JSON.stringify({ success: false, error: 'PHI encoding failed' });
|
|
40
|
+
}
|
|
41
|
+
|
|
25
42
|
return {
|
|
26
43
|
functionOutput: {
|
|
27
44
|
type: 'function_call_output',
|
|
28
45
|
call_id: call.call_id,
|
|
29
|
-
output:
|
|
46
|
+
output: result,
|
|
30
47
|
},
|
|
31
48
|
toolData: {
|
|
32
49
|
tool_name: name,
|
|
@@ -40,14 +57,14 @@ async function executeFunctionCall(assistant, call) {
|
|
|
40
57
|
};
|
|
41
58
|
}
|
|
42
59
|
|
|
43
|
-
async function handleFunctionCalls(functionCalls, assistant) {
|
|
60
|
+
async function handleFunctionCalls(functionCalls, assistant, phiProcessor) {
|
|
44
61
|
if (!functionCalls?.length) return { outputs: [], toolsExecuted: [] };
|
|
45
62
|
|
|
46
63
|
const outputs = [];
|
|
47
64
|
const toolsExecuted = [];
|
|
48
65
|
|
|
49
66
|
for (const call of functionCalls) {
|
|
50
|
-
const { functionOutput, toolData } = await executeFunctionCall(assistant, call);
|
|
67
|
+
const { functionOutput, toolData } = await executeFunctionCall(assistant, call, phiProcessor);
|
|
51
68
|
outputs.push(functionOutput);
|
|
52
69
|
toolsExecuted.push(toolData);
|
|
53
70
|
}
|
|
@@ -5,15 +5,16 @@ const { logger } = require('../utils/logger');
|
|
|
5
5
|
const { withTracing } = require('../utils/tracingDecorator.js');
|
|
6
6
|
|
|
7
7
|
const { Thread } = require('../models/threadModel.js');
|
|
8
|
-
const { insertMessage } = require('../models/messageModel');
|
|
9
8
|
|
|
10
9
|
const { getCurRow } = require('../helpers/assistantHelper.js');
|
|
11
10
|
const { getThread, switchThreadStoppedStatus, setThreadPromptId } = require('../helpers/threadHelper.js');
|
|
12
11
|
const { processThreadMessage } = require('../helpers/processHelper.js');
|
|
13
|
-
const { getLastNMessages, storeProcessedContent } = require('../helpers/messageHelper.js');
|
|
14
12
|
const { cleanupFiles } = require('../helpers/filesHelper.js');
|
|
15
13
|
|
|
16
14
|
const { createLLMProvider } = require('../providers/createLLMProvider');
|
|
15
|
+
|
|
16
|
+
const { getMessages, insertMessage } = require('../services/messageService');
|
|
17
|
+
|
|
17
18
|
const { getAssistantById } = require('./assistantResolver');
|
|
18
19
|
|
|
19
20
|
const createAssistantCore = async (code, assistant_id, _messages = [], force = false) => {
|
|
@@ -127,14 +128,14 @@ const preProcessMessagesCore = async (code, message_ = null, thread) => {
|
|
|
127
128
|
try {
|
|
128
129
|
const messagesStart = Date.now();
|
|
129
130
|
const beforeCheckpoint = message_?.createdAt ?? null;
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
});
|
|
131
|
+
const query = { numero: code, from_me: false };
|
|
132
|
+
if (beforeCheckpoint) query.createdAt = { $lte: beforeCheckpoint };
|
|
133
|
+
const lastMessage = await getMessages(query, { sort: { createdAt: -1 }, limit: 1 });
|
|
133
134
|
timings.get_messages_ms = Date.now() - messagesStart;
|
|
134
135
|
|
|
135
136
|
if (!lastMessage || lastMessage.length === 0) {
|
|
136
137
|
logger.info('[preProcessMessages] No relevant data found for this assistant.');
|
|
137
|
-
return { shouldProcess: false, messages: null, timings };
|
|
138
|
+
return { shouldProcess: false, messages: null, timings, pendingMediaUpdates: [] };
|
|
138
139
|
}
|
|
139
140
|
|
|
140
141
|
const provider = createLLMProvider({ variant: getProviderVariant() });
|
|
@@ -160,24 +161,32 @@ const preProcessMessagesCore = async (code, message_ = null, thread) => {
|
|
|
160
161
|
const patientMsg = processResults.some(r => r.isPatient);
|
|
161
162
|
const allTempFiles = processResults.flatMap(r => r.tempFiles || []);
|
|
162
163
|
|
|
163
|
-
|
|
164
|
+
const pendingMediaUpdates = processResults.reduce((acc, r) => {
|
|
164
165
|
const processedContent = r.messages && r.messages.length > 0
|
|
165
166
|
? r.messages
|
|
166
167
|
.filter(msg => msg.content.text !== r.reply?.body)
|
|
167
168
|
.map(msg => msg.content.text)
|
|
168
169
|
.join(' ')
|
|
169
170
|
: null;
|
|
170
|
-
|
|
171
|
-
|
|
171
|
+
if (!r.reply || !processedContent || !r.reply.media) return acc;
|
|
172
|
+
acc.push({
|
|
173
|
+
message_id: r.reply.message_id,
|
|
174
|
+
media: r.reply.media,
|
|
175
|
+
processedContent,
|
|
176
|
+
assistantId: thread.getAssistantId(),
|
|
177
|
+
threadId: thread.getConversationId(),
|
|
178
|
+
});
|
|
179
|
+
return acc;
|
|
180
|
+
}, []);
|
|
172
181
|
|
|
173
182
|
await cleanupFiles(allTempFiles);
|
|
174
183
|
|
|
175
184
|
if (!patientMsg || thread.stopped) {
|
|
176
185
|
logger.info('[preProcessMessages] Skipping AI processing', { patientMsg, stopped: thread.stopped, code });
|
|
177
|
-
return { shouldProcess: false, messages: null, timings };
|
|
186
|
+
return { shouldProcess: false, messages: null, timings, pendingMediaUpdates };
|
|
178
187
|
}
|
|
179
188
|
|
|
180
|
-
return { shouldProcess: true, messages: lastMessage, timings };
|
|
189
|
+
return { shouldProcess: true, messages: lastMessage, timings, pendingMediaUpdates };
|
|
181
190
|
} catch (error) {
|
|
182
191
|
logger.error('[preProcessMessages] Error', { error: error.message, code });
|
|
183
192
|
throw error;
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
const { logger } = require('../utils/logger');
|
|
2
2
|
const MapCache = require('../utils/MapCache');
|
|
3
3
|
|
|
4
|
-
const { Message } = require('../models/messageModel');
|
|
5
|
-
|
|
6
4
|
const { fetchBoxesFromAirtable, fetchDetailsFromAirtable, attachPreview } = require('../helpers/dashboardHelper');
|
|
7
5
|
const { getMexicoDateRange, buildPatientMessagesPipeline, buildToolDistributionPipeline, mergeTrendData } = require('../helpers/trendHelper');
|
|
8
6
|
|
|
7
|
+
const { aggregateMessages } = require('./messageService');
|
|
8
|
+
|
|
9
9
|
const boxCache = new MapCache({ maxSize: 100 });
|
|
10
10
|
const detailCache = new MapCache({ maxSize: 100 });
|
|
11
11
|
|
|
@@ -72,10 +72,10 @@ async function getDailyTrend(days) {
|
|
|
72
72
|
const { startDate, today } = getMexicoDateRange(days);
|
|
73
73
|
|
|
74
74
|
const patientPipeline = buildPatientMessagesPipeline(startDate);
|
|
75
|
-
const patientData = await
|
|
75
|
+
const patientData = await aggregateMessages(patientPipeline, { allowDiskUse: true });
|
|
76
76
|
|
|
77
77
|
const toolPipeline = buildToolDistributionPipeline(startDate);
|
|
78
|
-
const toolData = await
|
|
78
|
+
const toolData = await aggregateMessages(toolPipeline, { allowDiskUse: true });
|
|
79
79
|
|
|
80
80
|
const dailyData = mergeTrendData(patientData, toolData, today, days);
|
|
81
81
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const { logger } = require('../utils/logger');
|
|
2
|
+
const MapCache = require('../utils/MapCache');
|
|
3
|
+
const { normalize } = require('../utils/tokenMapUtils');
|
|
4
|
+
|
|
5
|
+
const { getGlobalEntityMap } = require('../models/globalEntityMapModel');
|
|
6
|
+
|
|
7
|
+
const RELOAD_TTL_MS = 5 * 60 * 1000;
|
|
8
|
+
const CACHE_KEY = 'all';
|
|
9
|
+
|
|
10
|
+
const cache = new MapCache({ maxSize: 1, ttl: RELOAD_TTL_MS });
|
|
11
|
+
|
|
12
|
+
const loadAll = async () => {
|
|
13
|
+
const byNormalized = new Map();
|
|
14
|
+
const byToken = new Map();
|
|
15
|
+
const docs = await getGlobalEntityMap().find({}).lean();
|
|
16
|
+
for (const doc of docs) {
|
|
17
|
+
for (const [k, token] of Object.entries(doc.encode || {})) {
|
|
18
|
+
if (byNormalized.has(k)) {
|
|
19
|
+
logger.error('[loadAll] Normalized key collision', { key: k, existing: byNormalized.get(k).token, incoming: token, category: doc.category });
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
byNormalized.set(k, { token, category: doc.category });
|
|
23
|
+
}
|
|
24
|
+
for (const [token, plaintext] of Object.entries(doc.decode || {})) {
|
|
25
|
+
byToken.set(token, plaintext);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
cache.set(CACHE_KEY, { byNormalized, byToken });
|
|
29
|
+
logger.debug('[loadAll] Loaded global entities', { count: byNormalized.size });
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const getIndexes = async () => {
|
|
33
|
+
const hit = cache.get(CACHE_KEY);
|
|
34
|
+
if (hit) return hit;
|
|
35
|
+
await loadAll();
|
|
36
|
+
return cache.get(CACHE_KEY);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const lookupGlobal = async (plaintext) => {
|
|
40
|
+
const key = normalize(plaintext);
|
|
41
|
+
if (!key) return null;
|
|
42
|
+
const { byNormalized } = await getIndexes();
|
|
43
|
+
const entry = byNormalized.get(key);
|
|
44
|
+
if (!entry) return null;
|
|
45
|
+
return { token: entry.token, category: entry.category };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const lookupGlobalToken = async (token) => {
|
|
49
|
+
if (!token) return null;
|
|
50
|
+
const { byToken } = await getIndexes();
|
|
51
|
+
const plaintext = byToken.get(token);
|
|
52
|
+
return plaintext !== undefined ? plaintext : null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
module.exports = {
|
|
56
|
+
lookupGlobal,
|
|
57
|
+
lookupGlobalToken,
|
|
58
|
+
loadAll,
|
|
59
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
const { Monitoreo_ID } = require('../config/airtableConfig');
|
|
2
|
+
|
|
3
|
+
const { logger } = require('../utils/logger');
|
|
4
|
+
|
|
5
|
+
const { Message } = require('../models/messageModel');
|
|
6
|
+
const { Thread } = require('../models/threadModel');
|
|
7
|
+
|
|
8
|
+
const { getClinicalContext } = require('../helpers/patientInformationHelper');
|
|
9
|
+
|
|
10
|
+
const { updateRecordByFilter } = require('../services/airtableService');
|
|
11
|
+
|
|
12
|
+
const INTERNAL_ORIGINS = new Set(['system', 'instruction']);
|
|
13
|
+
|
|
14
|
+
async function getMessages(query, options = {}) {
|
|
15
|
+
let cursor = Message.find(query);
|
|
16
|
+
if (options.select) cursor = cursor.select(options.select);
|
|
17
|
+
if (options.sort) cursor = cursor.sort(options.sort);
|
|
18
|
+
if (options.skip != null) cursor = cursor.skip(options.skip);
|
|
19
|
+
if (options.limit != null) cursor = cursor.limit(options.limit);
|
|
20
|
+
return cursor.lean();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function countMessages(query) {
|
|
24
|
+
return Message.countDocuments(query);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function aggregateMessages(pipeline, options = {}) {
|
|
28
|
+
return Message.aggregate(pipeline, options);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function insertMessage(values) {
|
|
32
|
+
try {
|
|
33
|
+
if (!values.clinical_context) {
|
|
34
|
+
const { clinicalContext } = await getClinicalContext(values.numero);
|
|
35
|
+
values.clinical_context = clinicalContext;
|
|
36
|
+
}
|
|
37
|
+
const messageData = Object.fromEntries(
|
|
38
|
+
Object.entries(values).filter(([, v]) => v !== undefined)
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
if (!Array.isArray(messageData.tools_executed)) {
|
|
42
|
+
messageData.tools_executed = [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let doc;
|
|
46
|
+
let isNew;
|
|
47
|
+
if (values.message_id == null) {
|
|
48
|
+
doc = await Message.create(messageData);
|
|
49
|
+
isNew = true;
|
|
50
|
+
} else {
|
|
51
|
+
const result = await Message.findOneAndUpdate(
|
|
52
|
+
{ message_id: values.message_id },
|
|
53
|
+
{ $setOnInsert: messageData },
|
|
54
|
+
{ upsert: true, new: true, setDefaultsOnInsert: true, includeResultMetadata: true }
|
|
55
|
+
);
|
|
56
|
+
doc = result.value;
|
|
57
|
+
isNew = !result.lastErrorObject?.updatedExisting;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (isNew && values.numero && !INTERNAL_ORIGINS.has(values.origin)) {
|
|
61
|
+
Thread.findOneAndUpdate(
|
|
62
|
+
{ code: values.numero },
|
|
63
|
+
{
|
|
64
|
+
$set: {
|
|
65
|
+
lastMessageAt: new Date(),
|
|
66
|
+
lastMessageBody: values.body || '',
|
|
67
|
+
lastMessageFromMe: !!values.from_me,
|
|
68
|
+
lastMessageMedia: values.media || null
|
|
69
|
+
},
|
|
70
|
+
$inc: {
|
|
71
|
+
messageCount: 1,
|
|
72
|
+
...(!values.from_me ? { unreadCount: 1 } : {})
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
{ upsert: true, setDefaultsOnInsert: true }
|
|
76
|
+
).catch(err => logger.error('[MongoStorage] Failed to denormalize thread', { numero: values.numero, error: err.message }));
|
|
77
|
+
}
|
|
78
|
+
|
|
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 }),
|
|
81
|
+
...(values.from_me ? { last_message_bot_time: doc.createdAt.toISOString() } : { last_message_patient_time: doc.createdAt.toISOString() })
|
|
82
|
+
}, values.numero).catch(err => logger.error('[MongoStorage] Failed to update message_monitor table', { numero: values.numero, error: err.message }));
|
|
83
|
+
|
|
84
|
+
logger.info('[MongoStorage] Message inserted or updated successfully');
|
|
85
|
+
return { isNew, doc };
|
|
86
|
+
} catch (err) {
|
|
87
|
+
logger.error('[MongoStorage] Error inserting message', { error: err.message, stack: err.stack });
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function updateMessage(filter, update) {
|
|
93
|
+
try {
|
|
94
|
+
await Message.updateMany(filter, update);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
logger.error('[messageService] updateMessage failed', { error: err.message });
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = {
|
|
102
|
+
getMessages,
|
|
103
|
+
countMessages,
|
|
104
|
+
aggregateMessages,
|
|
105
|
+
insertMessage,
|
|
106
|
+
updateMessage,
|
|
107
|
+
};
|
|
@@ -3,11 +3,12 @@ const { Estado_General_ID } = require('../config/airtableConfig');
|
|
|
3
3
|
const { logger } = require('../utils/logger');
|
|
4
4
|
|
|
5
5
|
const { Thread } = require('../models/threadModel');
|
|
6
|
-
const { Message } = require('../models/messageModel');
|
|
7
6
|
const { getPatientAuditLog } = require('../models/patientAuditLogModel');
|
|
8
7
|
|
|
9
8
|
const { getRecordByFilter, updateRecordByFilter } = require('../services/airtableService');
|
|
10
9
|
|
|
10
|
+
const { updateMessage } = require('../services/messageService');
|
|
11
|
+
|
|
11
12
|
const getPatientInformation = async (id) => {
|
|
12
13
|
const records = await getRecordByFilter(Estado_General_ID, 'estado_general', `{whatsapp_id}='${id}'`);
|
|
13
14
|
if (!records || records.length === 0) return null;
|
|
@@ -83,7 +84,7 @@ const updatePatientInformation = async (code, fields, auditContext = {}) => {
|
|
|
83
84
|
const nameUpdated = fields.updt_name && !failedFields.some(f => f.key === 'updt_name');
|
|
84
85
|
if (nameUpdated) {
|
|
85
86
|
await Thread.updateOne({ code }, { nombre: fields.updt_name });
|
|
86
|
-
await
|
|
87
|
+
await updateMessage({ numero: code, from_me: false }, { $set: { nombre_whatsapp: fields.updt_name } });
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
const status = failedFields.length > 0 ? 'partial' : 'success';
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const { logger } = require('../utils/logger');
|
|
2
|
+
const MapCache = require('../utils/MapCache');
|
|
3
|
+
const { normalize } = require('../utils/tokenMapUtils');
|
|
4
|
+
|
|
5
|
+
const { getTokenMap, PHI_CATEGORIES } = require('../models/tokenMapModel');
|
|
6
|
+
|
|
7
|
+
const tokenCache = new MapCache({ maxSize: 500 });
|
|
8
|
+
|
|
9
|
+
const getSubmap = async (code) => {
|
|
10
|
+
const cached = tokenCache.get(code);
|
|
11
|
+
if (cached) return cached;
|
|
12
|
+
|
|
13
|
+
const doc = await getTokenMap().findOne({ code }).lean();
|
|
14
|
+
const submap = {
|
|
15
|
+
encode: new Map(Object.entries(doc?.encode || {})),
|
|
16
|
+
decode: new Map(Object.entries(doc?.decode || {})),
|
|
17
|
+
};
|
|
18
|
+
tokenCache.set(code, submap);
|
|
19
|
+
return submap;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const allocateToken = async (code, plaintext, category, normalized, submap) => {
|
|
23
|
+
const counter = submap.encode.size + 1;
|
|
24
|
+
const token = `<<${category}_${counter}>>`;
|
|
25
|
+
|
|
26
|
+
submap.encode.set(normalized, token);
|
|
27
|
+
submap.decode.set(token, plaintext);
|
|
28
|
+
|
|
29
|
+
const res = await getTokenMap().updateOne(
|
|
30
|
+
{ code },
|
|
31
|
+
{
|
|
32
|
+
$set: {
|
|
33
|
+
encode: Object.fromEntries(submap.encode),
|
|
34
|
+
decode: Object.fromEntries(submap.decode),
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
{ upsert: true }
|
|
38
|
+
);
|
|
39
|
+
if (!res || (res.modifiedCount === 0 && !res.upsertedCount)) {
|
|
40
|
+
logger.error('[allocateToken] Mapping write modified 0 docs', { code, category, token });
|
|
41
|
+
throw new Error(`[allocateToken] Mapping write modified 0 docs for token ${token}`);
|
|
42
|
+
}
|
|
43
|
+
return token;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const encode = async (code, plaintext, category) => {
|
|
47
|
+
if (!PHI_CATEGORIES.includes(category)) {
|
|
48
|
+
throw new Error(`[encode] Unknown category: ${category}`);
|
|
49
|
+
}
|
|
50
|
+
if (plaintext === null || plaintext === undefined || String(plaintext).length === 0) {
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const normalized = normalize(plaintext);
|
|
55
|
+
if (normalized === '') return '';
|
|
56
|
+
|
|
57
|
+
const submap = await getSubmap(code);
|
|
58
|
+
const existing = submap.encode.get(normalized);
|
|
59
|
+
if (existing) return existing;
|
|
60
|
+
|
|
61
|
+
const token = await allocateToken(code, String(plaintext), category, normalized, submap);
|
|
62
|
+
logger.debug('[encode] Allocated', { code, category, token });
|
|
63
|
+
return token;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const decode = async (code, token) => {
|
|
67
|
+
if (!token) return token;
|
|
68
|
+
const submap = await getSubmap(code);
|
|
69
|
+
const plaintext = submap.decode.get(token);
|
|
70
|
+
return plaintext !== undefined ? plaintext : token;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const encodeMany = async (code, items) => {
|
|
74
|
+
const tokens = [];
|
|
75
|
+
for (const { plaintext, category } of items || []) {
|
|
76
|
+
tokens.push(await encode(code, plaintext, category));
|
|
77
|
+
}
|
|
78
|
+
return tokens;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const decodeMany = async (code, tokens) => {
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const token of tokens || []) {
|
|
84
|
+
out.push(await decode(code, token));
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const reset = async (code) => {
|
|
90
|
+
await getTokenMap().deleteOne({ code });
|
|
91
|
+
tokenCache.delete(code);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
module.exports = {
|
|
95
|
+
encode,
|
|
96
|
+
decode,
|
|
97
|
+
encodeMany,
|
|
98
|
+
decodeMany,
|
|
99
|
+
reset,
|
|
100
|
+
};
|
|
@@ -4,11 +4,13 @@ const runtimeConfig = require('../config/runtimeConfig');
|
|
|
4
4
|
const { logger } = require('../utils/logger');
|
|
5
5
|
const { createEvent, safeEmit } = require('../utils/eventUtils');
|
|
6
6
|
|
|
7
|
-
const { Message
|
|
7
|
+
const { Message } = require('../models/messageModel');
|
|
8
8
|
const { Thread } = require('../models/threadModel');
|
|
9
9
|
|
|
10
10
|
const { ensureWhatsAppFormat } = require('../helpers/twilioHelper');
|
|
11
11
|
|
|
12
|
+
const { getMessages, insertMessage } = require('../services/messageService');
|
|
13
|
+
|
|
12
14
|
const { getEventBus: getStatusEventBus } = require('../core/NexusMessaging');
|
|
13
15
|
|
|
14
16
|
class MongoStorage {
|
|
@@ -39,8 +41,8 @@ class MongoStorage {
|
|
|
39
41
|
const values = this.buildMessageValues(messageData);
|
|
40
42
|
const { doc } = await insertMessage(values);
|
|
41
43
|
logger.info('[MongoStorage] Message stored');
|
|
42
|
-
this._emitMessageNew(doc, messageData.from, messageData.frontendId);
|
|
43
|
-
return
|
|
44
|
+
if (!messageData.silent) this._emitMessageNew(doc, messageData.from, messageData.frontendId);
|
|
45
|
+
return doc;
|
|
44
46
|
} catch (error) {
|
|
45
47
|
logger.error('Error saving message', { error });
|
|
46
48
|
throw error;
|
|
@@ -53,7 +55,7 @@ class MongoStorage {
|
|
|
53
55
|
statusInfo: { status: 'queued', updatedAt: new Date() }
|
|
54
56
|
});
|
|
55
57
|
const { doc } = await insertMessage(values);
|
|
56
|
-
this._emitMessageNew(doc, messageData.from, messageData.frontendId, 'queued');
|
|
58
|
+
if (!messageData.silent) this._emitMessageNew(doc, messageData.from, messageData.frontendId, 'queued');
|
|
57
59
|
return doc;
|
|
58
60
|
}
|
|
59
61
|
|
|
@@ -101,7 +103,7 @@ class MongoStorage {
|
|
|
101
103
|
const fromMe = messageData.fromMe !== undefined ? messageData.fromMe : true;
|
|
102
104
|
|
|
103
105
|
const values = {
|
|
104
|
-
nombre_whatsapp: fromMe ? runtimeConfig.get('USER_DB_MONGO') :
|
|
106
|
+
nombre_whatsapp: messageData.pushName ?? (fromMe ? runtimeConfig.get('USER_DB_MONGO') : null),
|
|
105
107
|
numero: ensureWhatsAppFormat(messageData.code),
|
|
106
108
|
body: messageData.body || '',
|
|
107
109
|
processed: messageData.processed || false,
|
|
@@ -114,6 +116,7 @@ class MongoStorage {
|
|
|
114
116
|
template_variables: messageData.variables ? JSON.stringify(messageData.variables) : null,
|
|
115
117
|
raw: messageData.raw || null,
|
|
116
118
|
origin: messageData.origin || 'whatsapp_platform',
|
|
119
|
+
assistant_id: messageData.assistantId || null,
|
|
117
120
|
tools_executed: messageData.tools_executed,
|
|
118
121
|
prompt: messageData.prompt || null,
|
|
119
122
|
preset: messageData.preset || null,
|
|
@@ -126,15 +129,7 @@ class MongoStorage {
|
|
|
126
129
|
}
|
|
127
130
|
|
|
128
131
|
async getMessages(numero, limit = 50) {
|
|
129
|
-
|
|
130
|
-
return await this.schemas.Message
|
|
131
|
-
.find({ numero })
|
|
132
|
-
.sort({ createdAt: -1 })
|
|
133
|
-
.limit(limit);
|
|
134
|
-
} catch (error) {
|
|
135
|
-
logger.error('Error getting messages', { error });
|
|
136
|
-
throw error;
|
|
137
|
-
}
|
|
132
|
+
return getMessages({ numero }, { sort: { createdAt: -1 }, limit });
|
|
138
133
|
}
|
|
139
134
|
|
|
140
135
|
async getThread(code) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const normalize = (plaintext) => {
|
|
2
|
+
if (plaintext === null || plaintext === undefined) return '';
|
|
3
|
+
return String(plaintext)
|
|
4
|
+
.normalize('NFD')
|
|
5
|
+
.replace(/[̀-ͯ]/g, '')
|
|
6
|
+
.toLowerCase()
|
|
7
|
+
.replace(/\s+/g, ' ')
|
|
8
|
+
.trim()
|
|
9
|
+
.replace(/\./g, '·'); // · (U+00B7) avoids Mongoose map key restriction on dots
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
module.exports = { normalize };
|