@peopl-health/nexus 3.3.18 → 3.4.0-fix-version
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/eval/EvalProvider.js +309 -0
- package/lib/helpers/assistantHelper.js +2 -1
- package/lib/helpers/llmsHelper.js +1 -1
- package/lib/helpers/messageHelper.js +44 -0
- package/lib/index.js +10 -2
- package/lib/memory/DefaultMemoryManager.js +12 -8
- package/lib/observability/index.js +1 -2
- package/lib/observability/telemetry.js +1 -15
- package/lib/providers/OpenAIResponsesProvider.js +13 -7
- package/lib/routes/index.js +0 -6
- package/lib/services/airtableService.js +16 -0
- package/lib/services/assistantService.js +1 -2
- package/package.json +7 -5
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
const { OpenAI } = require('openai');
|
|
2
|
+
|
|
3
|
+
const { Config_ID } = require('../config/airtableConfig');
|
|
4
|
+
const { getCurrentMexicoDateTime } = require('../utils/dateUtils');
|
|
5
|
+
const { retryWithBackoff } = require('../utils/retryUtils');
|
|
6
|
+
const { logger } = require('../utils/logger');
|
|
7
|
+
const { Thread } = require('../models/threadModel');
|
|
8
|
+
const { DefaultMemoryManager } = require('../memory/DefaultMemoryManager');
|
|
9
|
+
const { OpenAIResponsesProvider } = require('../providers/OpenAIResponsesProvider');
|
|
10
|
+
const { handleFunctionCalls } = require('../providers/OpenAIResponsesProviderTools');
|
|
11
|
+
const { getRecordByFilter } = require('../services/airtableService');
|
|
12
|
+
const { getAssistantById } = require('../services/assistantResolver');
|
|
13
|
+
|
|
14
|
+
const MAX_FUNCTION_ROUNDS = parseInt(process.env.MAX_FUNCTION_ROUNDS || '5', 10);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Promptfoo-compatible eval provider that wraps the real Nexus pipeline.
|
|
18
|
+
*
|
|
19
|
+
* Modes:
|
|
20
|
+
* - 'context-only': Builds context, calls LLM, no tool schemas sent.
|
|
21
|
+
* - 'dry-run': Sends tool schemas so LLM can decide to call tools,
|
|
22
|
+
* but captures calls without executing them. (default)
|
|
23
|
+
* - 'full-pipeline': Executes tools for real. Use only with safe/mocked tools.
|
|
24
|
+
*/
|
|
25
|
+
class EvalProvider {
|
|
26
|
+
constructor(options = {}) {
|
|
27
|
+
const config = options.config || {};
|
|
28
|
+
this.model = config.model || process.env.OPENAI_MODEL || 'gpt-5';
|
|
29
|
+
this.temperature = config.temperature ?? 0.7;
|
|
30
|
+
this.maxOutputTokens = config.maxOutputTokens ?? 400;
|
|
31
|
+
this.assistantId = config.assistantId || null;
|
|
32
|
+
this.mode = config.mode || 'dry-run';
|
|
33
|
+
this.promptSource = config.promptSource || null;
|
|
34
|
+
// tool_choice: 'auto' (default, matches production), 'required', 'none',
|
|
35
|
+
// or { type: 'function', name: 'toolName' } to force a specific tool
|
|
36
|
+
this.toolChoice = config.toolChoice || 'auto';
|
|
37
|
+
this.promptVersions = config.promptVersions || {};
|
|
38
|
+
this.label = options.label || `nexus:${this.model}`;
|
|
39
|
+
|
|
40
|
+
this.client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
|
41
|
+
this.memoryManager = new DefaultMemoryManager();
|
|
42
|
+
this.provider = new OpenAIResponsesProvider({
|
|
43
|
+
client: this.client,
|
|
44
|
+
defaultModels: { responseModel: this.model },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
id() {
|
|
49
|
+
return this.label;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async callApi(prompt, context) {
|
|
53
|
+
const vars = context.vars || {};
|
|
54
|
+
const numero = vars.numero;
|
|
55
|
+
if (!numero) return { error: 'Missing required var: numero' };
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const beforeCheckpoint = vars.beforeCheckpoint ? new Date(vars.beforeCheckpoint) : null;
|
|
59
|
+
let assistantId = vars.assistantId || this.assistantId;
|
|
60
|
+
|
|
61
|
+
const thread = await Thread.findOne({ code: numero });
|
|
62
|
+
if (!assistantId && thread) {
|
|
63
|
+
assistantId = thread.prompt_id || thread.assistant_id || null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const { messages, promptVariables, lastUserMessage } = await this._buildContext(numero, beforeCheckpoint);
|
|
67
|
+
|
|
68
|
+
const { devContent, assistant, toolSchemas } = await this._resolvePrompt(prompt, assistantId, thread, promptVariables);
|
|
69
|
+
|
|
70
|
+
const apiConfig = this._buildApiConfig(devContent, messages, assistantId, promptVariables, toolSchemas);
|
|
71
|
+
|
|
72
|
+
const startTime = Date.now();
|
|
73
|
+
const { finalResponse, toolCallsRequested, allToolsExecuted, accumulatedUsage } =
|
|
74
|
+
await this._executeWithToolLoop(apiConfig, assistant);
|
|
75
|
+
|
|
76
|
+
return this._formatResult({
|
|
77
|
+
finalResponse, toolCallsRequested, allToolsExecuted,
|
|
78
|
+
accumulatedUsage, assistantId, numero, lastUserMessage,
|
|
79
|
+
messages, promptVariables, startTime,
|
|
80
|
+
});
|
|
81
|
+
} catch (error) {
|
|
82
|
+
logger.error('[NexusEvalProvider] callApi failed', { error: error.message, numero });
|
|
83
|
+
return { error: error.message };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async _buildContext(numero, beforeCheckpoint) {
|
|
88
|
+
const messages = await this.memoryManager.buildContext({
|
|
89
|
+
thread: { code: numero },
|
|
90
|
+
config: { beforeCheckpoint },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
|
|
94
|
+
const lastUserMessage = (typeof lastUserMsg?.content === 'string'
|
|
95
|
+
? lastUserMsg.content : ''
|
|
96
|
+
).substring(0, 200);
|
|
97
|
+
|
|
98
|
+
const clinicalData = await this.memoryManager.getClinicalData(numero);
|
|
99
|
+
const promptVariables = {
|
|
100
|
+
clinical_context: clinicalData?.clinicalContext ?? '',
|
|
101
|
+
last_symptoms: clinicalData?.lastSymptoms ?? '',
|
|
102
|
+
current_date: getCurrentMexicoDateTime(),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return { messages, promptVariables, lastUserMessage };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async _resolvePrompt(fallbackPrompt, assistantId, thread, promptVariables) {
|
|
109
|
+
let devContent;
|
|
110
|
+
if (this.promptSource === 'airtable' && assistantId) {
|
|
111
|
+
const devRecord = await getRecordByFilter(Config_ID, 'responses', `{prompt_id} = "${assistantId}"`);
|
|
112
|
+
devContent = devRecord?.[0]?.content || '';
|
|
113
|
+
} else {
|
|
114
|
+
devContent = fallbackPrompt;
|
|
115
|
+
}
|
|
116
|
+
devContent = devContent.replace(/\{\{(\w+)\}\}/g, (_, key) => promptVariables[key] ?? '');
|
|
117
|
+
|
|
118
|
+
let assistant = null;
|
|
119
|
+
let toolSchemas = [];
|
|
120
|
+
if (this.mode !== 'context-only' && assistantId) {
|
|
121
|
+
try {
|
|
122
|
+
assistant = getAssistantById(assistantId, thread);
|
|
123
|
+
toolSchemas = assistant.getToolSchemas?.() || [];
|
|
124
|
+
if (assistant.tools?.size) {
|
|
125
|
+
const toolNames = Array.from(assistant.tools.keys()).join(', ');
|
|
126
|
+
devContent += `\n\nYou only have access to these tools: ${toolNames}. Do not call or reference any tools not listed here.`;
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
logger.warn('[NexusEvalProvider] Failed to resolve assistant', { assistantId });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { devContent, assistant, toolSchemas };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
_buildApiConfig(devContent, messages, assistantId, promptVariables, toolSchemas) {
|
|
137
|
+
const convertedMessages = this.provider._convertItemsToApiFormat(messages);
|
|
138
|
+
const input = [{ role: 'developer', content: devContent }, ...convertedMessages];
|
|
139
|
+
const apiConfig = { input, instructions: '' };
|
|
140
|
+
|
|
141
|
+
if (assistantId) {
|
|
142
|
+
apiConfig.prompt = { id: assistantId, variables: promptVariables };
|
|
143
|
+
const version = this.promptVersions[assistantId];
|
|
144
|
+
if (version) apiConfig.prompt.version = String(version);
|
|
145
|
+
} else {
|
|
146
|
+
apiConfig.model = this.model;
|
|
147
|
+
apiConfig.temperature = this.temperature;
|
|
148
|
+
apiConfig.max_output_tokens = this.maxOutputTokens;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (this.mode !== 'context-only') {
|
|
152
|
+
apiConfig.tool_choice = this.toolChoice;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (toolSchemas.length > 0 && !assistantId) {
|
|
156
|
+
apiConfig.tools = toolSchemas.map(schema => {
|
|
157
|
+
if (schema.type === 'function' && schema.function) {
|
|
158
|
+
const { name, description, parameters, strict } = schema.function;
|
|
159
|
+
return { type: 'function', name, description, parameters, strict };
|
|
160
|
+
}
|
|
161
|
+
return schema;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return apiConfig;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async _executeWithToolLoop(apiConfig, assistant) {
|
|
169
|
+
const { result: response } = await retryWithBackoff(
|
|
170
|
+
() => this.client.responses.create(apiConfig),
|
|
171
|
+
{ providerName: 'NexusEvalProvider' }
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
let finalResponse = response;
|
|
175
|
+
const toolCallsRequested = [];
|
|
176
|
+
const allToolsExecuted = [];
|
|
177
|
+
const accumulatedUsage = {
|
|
178
|
+
input_tokens: response.usage?.input_tokens || 0,
|
|
179
|
+
output_tokens: response.usage?.output_tokens || 0,
|
|
180
|
+
total_tokens: response.usage?.total_tokens || 0,
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const functionCalls = (response.output || []).filter(item => item.type === 'function_call');
|
|
184
|
+
if (!functionCalls.length) {
|
|
185
|
+
return { finalResponse, toolCallsRequested, allToolsExecuted, accumulatedUsage };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Record initial tool calls
|
|
189
|
+
for (const call of functionCalls) {
|
|
190
|
+
toolCallsRequested.push({
|
|
191
|
+
name: call.name,
|
|
192
|
+
arguments: call.arguments ? JSON.parse(call.arguments) : {},
|
|
193
|
+
call_id: call.call_id,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (this.mode === 'full-pipeline' && assistant) {
|
|
198
|
+
finalResponse = await this._executeFullPipeline(
|
|
199
|
+
apiConfig, finalResponse, assistant, toolCallsRequested, allToolsExecuted, accumulatedUsage
|
|
200
|
+
);
|
|
201
|
+
} else if (this.mode === 'dry-run') {
|
|
202
|
+
finalResponse = await this._executeDryRun(apiConfig, finalResponse, functionCalls, accumulatedUsage);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { finalResponse, toolCallsRequested, allToolsExecuted, accumulatedUsage };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async _executeFullPipeline(apiConfig, initialResponse, assistant, toolCallsRequested, allToolsExecuted, usage) {
|
|
209
|
+
let finalResponse = initialResponse;
|
|
210
|
+
let currentInput = [...apiConfig.input];
|
|
211
|
+
|
|
212
|
+
for (let round = 1; round <= MAX_FUNCTION_ROUNDS; round++) {
|
|
213
|
+
const calls = finalResponse.output.filter(item => item.type === 'function_call');
|
|
214
|
+
if (!calls.length) break;
|
|
215
|
+
|
|
216
|
+
const { outputs, toolsExecuted } = await handleFunctionCalls(calls, assistant);
|
|
217
|
+
currentInput.push(...finalResponse.output, ...outputs);
|
|
218
|
+
allToolsExecuted.push(...toolsExecuted);
|
|
219
|
+
|
|
220
|
+
for (const call of calls) {
|
|
221
|
+
if (!toolCallsRequested.find(t => t.call_id === call.call_id)) {
|
|
222
|
+
toolCallsRequested.push({
|
|
223
|
+
name: call.name,
|
|
224
|
+
arguments: call.arguments ? JSON.parse(call.arguments) : {},
|
|
225
|
+
call_id: call.call_id,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const { result: followUp } = await retryWithBackoff(
|
|
231
|
+
() => this.client.responses.create({ ...apiConfig, input: currentInput, tool_choice: 'auto' }),
|
|
232
|
+
{ providerName: 'NexusEvalProvider' }
|
|
233
|
+
);
|
|
234
|
+
this._addUsage(usage, followUp.usage);
|
|
235
|
+
finalResponse = followUp;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return finalResponse;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async _executeDryRun(apiConfig, initialResponse, functionCalls, usage) {
|
|
242
|
+
const currentInput = [...apiConfig.input, ...initialResponse.output];
|
|
243
|
+
for (const call of functionCalls) {
|
|
244
|
+
currentInput.push({
|
|
245
|
+
type: 'function_call_output',
|
|
246
|
+
call_id: call.call_id,
|
|
247
|
+
output: JSON.stringify({ success: true }),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
logger.info('[NexusEvalProvider] Dry-run: sending stub outputs', {
|
|
252
|
+
tools: functionCalls.map(c => c.name),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const { result: followUp } = await retryWithBackoff(
|
|
256
|
+
() => this.client.responses.create({ ...apiConfig, input: currentInput, tool_choice: 'auto' }),
|
|
257
|
+
{ providerName: 'NexusEvalProvider' }
|
|
258
|
+
);
|
|
259
|
+
this._addUsage(usage, followUp.usage);
|
|
260
|
+
return followUp;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
_addUsage(accumulated, newUsage) {
|
|
264
|
+
accumulated.input_tokens += newUsage?.input_tokens || 0;
|
|
265
|
+
accumulated.output_tokens += newUsage?.output_tokens || 0;
|
|
266
|
+
accumulated.total_tokens += newUsage?.total_tokens || 0;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
_formatResult({ finalResponse, toolCallsRequested, allToolsExecuted, accumulatedUsage, assistantId, numero, lastUserMessage, messages, promptVariables, startTime }) {
|
|
270
|
+
const output = this.provider._extractMessageOutput(finalResponse);
|
|
271
|
+
const durationMs = Date.now() - startTime;
|
|
272
|
+
const toolNames = toolCallsRequested.map(t => t.name);
|
|
273
|
+
|
|
274
|
+
logger.info('[NexusEvalProvider] Result', {
|
|
275
|
+
assistantId, model: finalResponse.model || this.model,
|
|
276
|
+
lastUserMessage, toolCallsCaptured: toolNames,
|
|
277
|
+
outputLength: (output || '').length, durationMs,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const toolsHeader = toolNames.length ? `[tools: ${toolNames.join(', ')}]\n---\n` : '';
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
output: `${toolsHeader}${output || ''}`,
|
|
284
|
+
tokenUsage: {
|
|
285
|
+
total: accumulatedUsage.total_tokens,
|
|
286
|
+
prompt: accumulatedUsage.input_tokens,
|
|
287
|
+
completion: accumulatedUsage.output_tokens,
|
|
288
|
+
},
|
|
289
|
+
metadata: {
|
|
290
|
+
model: finalResponse.model || this.model,
|
|
291
|
+
numero, assistantId, lastUserMessage,
|
|
292
|
+
mode: this.mode,
|
|
293
|
+
contextMessages: messages.length,
|
|
294
|
+
clinicalContext: promptVariables.clinical_context ? 'present' : 'absent',
|
|
295
|
+
durationMs,
|
|
296
|
+
toolCalls: toolCallsRequested,
|
|
297
|
+
toolsExecuted: allToolsExecuted.map(t => ({
|
|
298
|
+
name: t.tool_name,
|
|
299
|
+
arguments: t.tool_arguments,
|
|
300
|
+
output: t.tool_output,
|
|
301
|
+
success: t.success,
|
|
302
|
+
duration_ms: t.execution_time_ms,
|
|
303
|
+
})),
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
module.exports = { EvalProvider };
|
|
@@ -23,9 +23,10 @@ const runAssistantAndWait = async ({ thread, assistant, message = null, runConfi
|
|
|
23
23
|
const provider = createLLMProvider({ variant: process.env.VARIANT || 'assistants' });
|
|
24
24
|
const { polling, tools: configTools, ...config } = runConfig;
|
|
25
25
|
const tools = assistant.getToolSchemas?.() || configTools || [];
|
|
26
|
+
const messageForRun = Array.isArray(message) && message.length > 0 ? message[0] : message;
|
|
26
27
|
|
|
27
28
|
return withThreadRecovery(
|
|
28
|
-
(currentThread = thread) => provider.executeRun({ thread: currentThread, message, assistant, tools, config, polling }),
|
|
29
|
+
(currentThread = thread) => provider.executeRun({ thread: currentThread, message: messageForRun, assistant, tools, config, polling }),
|
|
29
30
|
thread
|
|
30
31
|
);
|
|
31
32
|
};
|
|
@@ -37,7 +37,7 @@ async function analyzeImage(imagePath, isSticker = false, contentType = null) {
|
|
|
37
37
|
const imageBuffer = await fs.promises.readFile(imagePath);
|
|
38
38
|
const base64Image = imageBuffer.toString('base64');
|
|
39
39
|
|
|
40
|
-
const createImageMessage = (prompt, model = 'claude-
|
|
40
|
+
const createImageMessage = (prompt, model = 'claude-sonnet-4-5-20250929') => anthropicClient.messages.create({
|
|
41
41
|
model, max_tokens: 1024,
|
|
42
42
|
messages: [{ role: 'user', content: [
|
|
43
43
|
{ type: 'image', source: { type: 'base64', media_type: mimeType, data: base64Image } },
|
|
@@ -99,6 +99,49 @@ function formatMessage(reply) {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
function getMessageTools(reply) {
|
|
103
|
+
const tools = reply?.tools_executed;
|
|
104
|
+
if (!tools?.length) return [];
|
|
105
|
+
|
|
106
|
+
const msgId = reply?.message_id ?? `msg_${Date.now()}`;
|
|
107
|
+
const items = [];
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
// API requires function_call id to start with "fc" and function_call_output to reference it via call_id.
|
|
111
|
+
tools.forEach((t, i) => {
|
|
112
|
+
const safeId = `${msgId}_${i}`.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80);
|
|
113
|
+
const functionCallId = `fc_${safeId}`;
|
|
114
|
+
const argsStr = JSON.stringify(t.tool_arguments ?? {});
|
|
115
|
+
let outputStr;
|
|
116
|
+
if (t.tool_output != null) {
|
|
117
|
+
outputStr = typeof t.tool_output === 'string'
|
|
118
|
+
? t.tool_output
|
|
119
|
+
: JSON.stringify(t.tool_output);
|
|
120
|
+
} else {
|
|
121
|
+
outputStr = JSON.stringify({ success: t.success !== false });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
items.push({
|
|
125
|
+
type: 'function_call',
|
|
126
|
+
id: functionCallId,
|
|
127
|
+
call_id: functionCallId,
|
|
128
|
+
name: t.tool_name,
|
|
129
|
+
arguments: argsStr
|
|
130
|
+
});
|
|
131
|
+
items.push({
|
|
132
|
+
type: 'function_call_output',
|
|
133
|
+
id: `fco_${safeId}`,
|
|
134
|
+
call_id: functionCallId,
|
|
135
|
+
output: outputStr
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
return items;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
logger.error('[getMessageTools] Error', { messageId: reply?.message_id, error: error.message });
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
102
145
|
async function isRecentMessage(chatId) {
|
|
103
146
|
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
|
|
104
147
|
const recent = await Message.findOne({
|
|
@@ -113,5 +156,6 @@ module.exports = {
|
|
|
113
156
|
getLastMessages,
|
|
114
157
|
getLastNMessages,
|
|
115
158
|
formatMessage,
|
|
159
|
+
getMessageTools,
|
|
116
160
|
isRecentMessage
|
|
117
161
|
};
|
package/lib/index.js
CHANGED
|
@@ -10,7 +10,7 @@ const { OpenAIAssistantsProvider } = require('./providers/OpenAIAssistantsProvid
|
|
|
10
10
|
const { OpenAIResponsesProvider } = require('./providers/OpenAIResponsesProvider');
|
|
11
11
|
const runtimeConfig = require('./config/runtimeConfig');
|
|
12
12
|
const llmConfigModule = require('./config/llmConfig');
|
|
13
|
-
const { setModelDatabases, setModelDatabase, getModelDatabase } = require('./config/mongoConfig');
|
|
13
|
+
const { setModelDatabases, setModelDatabase, getModelDatabase, connect: mongoConnect, disconnect: mongoDisconnect, getConnection: mongoGetConnection } = require('./config/mongoConfig');
|
|
14
14
|
const { TwilioProvider } = require('./adapters/TwilioProvider');
|
|
15
15
|
const { BaileysProvider } = require('./adapters/BaileysProvider');
|
|
16
16
|
const { BaseAssistant } = require('./assistants/BaseAssistant');
|
|
@@ -219,6 +219,8 @@ class Nexus {
|
|
|
219
219
|
|
|
220
220
|
const routes = require('./routes');
|
|
221
221
|
const { resetAll } = require('./config/lifecycle');
|
|
222
|
+
const { EvalProvider } = require('./eval/EvalProvider');
|
|
223
|
+
const airtableService = require('./services/airtableService');
|
|
222
224
|
|
|
223
225
|
module.exports = {
|
|
224
226
|
Nexus,
|
|
@@ -254,5 +256,11 @@ module.exports = {
|
|
|
254
256
|
createQueueAdapter,
|
|
255
257
|
registerQueueAdapter,
|
|
256
258
|
|
|
257
|
-
resetAll
|
|
259
|
+
resetAll,
|
|
260
|
+
|
|
261
|
+
mongoConnect,
|
|
262
|
+
mongoDisconnect,
|
|
263
|
+
mongoGetConnection,
|
|
264
|
+
EvalProvider,
|
|
265
|
+
airtableService,
|
|
258
266
|
};
|
|
@@ -4,7 +4,7 @@ const { logger } = require('../utils/logger');
|
|
|
4
4
|
|
|
5
5
|
const { MemoryManager } = require('../memory/MemoryManager');
|
|
6
6
|
|
|
7
|
-
const { getLastNMessages, formatMessage } = require('../helpers/messageHelper');
|
|
7
|
+
const { getLastNMessages, getMessageTools, formatMessage } = require('../helpers/messageHelper');
|
|
8
8
|
|
|
9
9
|
const { handlePendingFunctionCalls: handlePendingFunctionCallsUtil } = require('../providers/OpenAIResponsesProviderTools');
|
|
10
10
|
|
|
@@ -20,7 +20,7 @@ class DefaultMemoryManager extends MemoryManager {
|
|
|
20
20
|
this._logActivity('Building context', { threadCode: thread.code });
|
|
21
21
|
|
|
22
22
|
try {
|
|
23
|
-
const beforeCheckpoint =
|
|
23
|
+
const beforeCheckpoint = config.beforeCheckpoint ?? message?.createdAt ?? null;
|
|
24
24
|
const allMessages = await getLastNMessages(thread.code, this.maxHistoricalMessages, beforeCheckpoint, {
|
|
25
25
|
query: { origin: { $ne: 'instruction' } }
|
|
26
26
|
});
|
|
@@ -32,12 +32,16 @@ class DefaultMemoryManager extends MemoryManager {
|
|
|
32
32
|
|
|
33
33
|
const roleMap = { system: 'system', patient: 'user' };
|
|
34
34
|
const context = allMessages.reverse().flatMap(msg => {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
const toolItems = getMessageTools(msg);
|
|
36
|
+
const contentItems = formatMessage(msg)
|
|
37
|
+
.map(content => ({
|
|
38
|
+
role: roleMap[msg.origin] || 'assistant',
|
|
39
|
+
content: content,
|
|
40
|
+
type: 'message'
|
|
41
|
+
}))
|
|
42
|
+
.filter(item => item.content);
|
|
43
|
+
return [...toolItems, ...contentItems];
|
|
44
|
+
});
|
|
41
45
|
|
|
42
46
|
return [...context, ...additional];
|
|
43
47
|
} catch (error) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { trace, metrics } = require('@opentelemetry/api');
|
|
2
2
|
|
|
3
|
-
const { initTelemetry, shutdownTelemetry
|
|
3
|
+
const { initTelemetry, shutdownTelemetry } = require('../observability/telemetry');
|
|
4
4
|
|
|
5
5
|
const tracer = trace.getTracer('nexus-assistant');
|
|
6
6
|
const meter = metrics.getMeter('nexus-assistant');
|
|
@@ -149,7 +149,6 @@ function recordFileOperation(operationType, attributes = {}) {
|
|
|
149
149
|
module.exports = {
|
|
150
150
|
init,
|
|
151
151
|
shutdown: shutdownTelemetry,
|
|
152
|
-
getMetricsRequestHandler,
|
|
153
152
|
traceOperation,
|
|
154
153
|
createSpan,
|
|
155
154
|
tracer,
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
const { NodeSDK } = require('@opentelemetry/sdk-node');
|
|
2
2
|
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
|
|
3
|
-
const { PrometheusExporter } = require('@opentelemetry/exporter-prometheus');
|
|
4
3
|
const { JaegerExporter } = require('@opentelemetry/exporter-jaeger');
|
|
5
4
|
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
|
|
6
5
|
const { BatchSpanProcessor } = require('@opentelemetry/sdk-trace-node');
|
|
@@ -11,13 +10,12 @@ const runtimeConfig = require('../config/runtimeConfig');
|
|
|
11
10
|
|
|
12
11
|
const nodeEnv = runtimeConfig.get('NODE_ENV') || '';
|
|
13
12
|
const isProd = nodeEnv === 'production' || nodeEnv === 'prod';
|
|
14
|
-
const IGNORED_PATHS = ['/health', '/
|
|
13
|
+
const IGNORED_PATHS = ['/health', '/'];
|
|
15
14
|
|
|
16
15
|
class TelemetryManager {
|
|
17
16
|
constructor() {
|
|
18
17
|
this.sdk = null;
|
|
19
18
|
this.isInitialized = false;
|
|
20
|
-
this.prometheusExporter = null;
|
|
21
19
|
}
|
|
22
20
|
|
|
23
21
|
async init(config = {}) {
|
|
@@ -32,8 +30,6 @@ class TelemetryManager {
|
|
|
32
30
|
jaegerEndpoint = 'http://localhost:14268/api/traces',
|
|
33
31
|
otlpEndpoint = null,
|
|
34
32
|
otlpHeaders = {},
|
|
35
|
-
prometheusEndpoint = '/metrics',
|
|
36
|
-
prometheusPort = 9090
|
|
37
33
|
} = config;
|
|
38
34
|
|
|
39
35
|
try {
|
|
@@ -75,18 +71,12 @@ class TelemetryManager {
|
|
|
75
71
|
exportTimeoutMillis: isProd ? 5000 : 2000,
|
|
76
72
|
scheduledDelayMillis: isProd ? 2000 : 500,
|
|
77
73
|
}),
|
|
78
|
-
metricReader: this.prometheusExporter = new PrometheusExporter({
|
|
79
|
-
endpoint: prometheusEndpoint,
|
|
80
|
-
port: prometheusPort,
|
|
81
|
-
preventServerStart: true,
|
|
82
|
-
}),
|
|
83
74
|
});
|
|
84
75
|
|
|
85
76
|
await this.sdk.start();
|
|
86
77
|
this.isInitialized = true;
|
|
87
78
|
|
|
88
79
|
console.log(`🚀 OpenTelemetry initialized for "${serviceName}"`);
|
|
89
|
-
console.log(`📊 Metrics: available at ${prometheusEndpoint} (mounted on Express app)`);
|
|
90
80
|
console.log(`🔍 Traces: ${otlpEndpoint || jaegerEndpoint}`);
|
|
91
81
|
} catch (error) {
|
|
92
82
|
console.error('❌ Failed to initialize OpenTelemetry:', error.message);
|
|
@@ -115,8 +105,4 @@ module.exports = {
|
|
|
115
105
|
telemetryManager,
|
|
116
106
|
initTelemetry: (config) => telemetryManager.init(config),
|
|
117
107
|
shutdownTelemetry: () => telemetryManager.shutdown(),
|
|
118
|
-
getMetricsRequestHandler: () => {
|
|
119
|
-
if (!telemetryManager.prometheusExporter) return null;
|
|
120
|
-
return (req, res) => telemetryManager.prometheusExporter.getMetricsRequestHandler(req, res);
|
|
121
|
-
}
|
|
122
108
|
};
|
|
@@ -115,11 +115,17 @@ class OpenAIResponsesProvider {
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
_convertItemsToApiFormat(items) {
|
|
118
|
-
return items.map(item =>
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
118
|
+
return items.map(item => {
|
|
119
|
+
const type = item.type || 'message';
|
|
120
|
+
if (type === 'function_call' || type === 'function_call_output') {
|
|
121
|
+
return { ...item, type };
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
role: item.role || 'user',
|
|
125
|
+
type,
|
|
126
|
+
content: this._normalizeContent(item.content)
|
|
127
|
+
};
|
|
128
|
+
});
|
|
123
129
|
}
|
|
124
130
|
|
|
125
131
|
_normalizeContent(content) {
|
|
@@ -322,8 +328,8 @@ class OpenAIResponsesProvider {
|
|
|
322
328
|
devContent += `\n\nYou only have access to these tools: ${toolNames}. Do not call or reference any tools not listed here.`;
|
|
323
329
|
}
|
|
324
330
|
|
|
325
|
-
const
|
|
326
|
-
|
|
331
|
+
const rawMessages = context || additionalMessages;
|
|
332
|
+
const messages = this._convertItemsToApiFormat(rawMessages);
|
|
327
333
|
const input = [{ role: 'developer', content: devContent }, ...messages];
|
|
328
334
|
|
|
329
335
|
const promptConfig = { id: assistantId };
|
package/lib/routes/index.js
CHANGED
|
@@ -176,12 +176,6 @@ const builtInControllers = {
|
|
|
176
176
|
};
|
|
177
177
|
|
|
178
178
|
const setupDefaultRoutes = (app) => {
|
|
179
|
-
const { getMetricsRequestHandler } = require('../observability');
|
|
180
|
-
const metricsHandler = getMetricsRequestHandler();
|
|
181
|
-
if (metricsHandler) {
|
|
182
|
-
app.get('/metrics', metricsHandler);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
179
|
app.use('/api/assistant', createRouter(assistantRouteDefinitions, builtInControllers));
|
|
186
180
|
app.use('/api/conversation', createRouter(conversationRouteDefinitions, builtInControllers));
|
|
187
181
|
app.use('/api/interaction', createRouter(interactionRouteDefinitions, builtInControllers));
|
|
@@ -2,6 +2,13 @@ const { airtable } = require('../config/airtableConfig');
|
|
|
2
2
|
|
|
3
3
|
const { logger } = require('../utils/logger');
|
|
4
4
|
|
|
5
|
+
let evalMode = false;
|
|
6
|
+
|
|
7
|
+
function setEvalMode(enabled) {
|
|
8
|
+
evalMode = !!enabled;
|
|
9
|
+
logger.info(`[airtableService] Eval mode ${evalMode ? 'ON' : 'OFF'} — writes will be ${evalMode ? 'muted' : 'live'}`);
|
|
10
|
+
}
|
|
11
|
+
|
|
5
12
|
function getBase(baseID) {
|
|
6
13
|
if (!airtable) throw new Error('Airtable not configured. Set AIRTABLE_API_KEY');
|
|
7
14
|
return airtable.base(baseID);
|
|
@@ -17,6 +24,10 @@ async function collectRecords(query, mapper = r => r.fields) {
|
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
async function addRecord(baseID, tableName, fields) {
|
|
27
|
+
if (evalMode) {
|
|
28
|
+
logger.info('[addRecord:eval] Muted', { tableName });
|
|
29
|
+
return { id: 'eval_mock_record', fields: Array.isArray(fields) ? fields[0]?.fields || {} : fields };
|
|
30
|
+
}
|
|
20
31
|
try {
|
|
21
32
|
const record = await getBase(baseID)(tableName).create(fields);
|
|
22
33
|
logger.info('[addRecord] Created', { tableName });
|
|
@@ -48,6 +59,10 @@ async function getRecordByFilter(baseID, tableName, filter, view = 'Grid view')
|
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
async function updateRecordByFilter(baseID, tableName, filter, updateFields) {
|
|
62
|
+
if (evalMode) {
|
|
63
|
+
logger.info('[updateRecordByFilter:eval] Muted', { tableName, filter });
|
|
64
|
+
return [{ id: 'eval_mock_record', fields: updateFields }];
|
|
65
|
+
}
|
|
51
66
|
try {
|
|
52
67
|
const base = getBase(baseID);
|
|
53
68
|
const updatedRecords = [];
|
|
@@ -88,6 +103,7 @@ async function addLinkedRecord(baseID, targetTable, fields, linkConfig) {
|
|
|
88
103
|
}
|
|
89
104
|
|
|
90
105
|
module.exports = {
|
|
106
|
+
setEvalMode,
|
|
91
107
|
addRecord,
|
|
92
108
|
getRecords,
|
|
93
109
|
getRecordByFilter,
|
|
@@ -159,8 +159,7 @@ const replyAssistantCore = async (code, message_ = null, thread_ = null, runOpti
|
|
|
159
159
|
if (!thread) return null;
|
|
160
160
|
|
|
161
161
|
const messagesStart = Date.now();
|
|
162
|
-
const beforeCheckpoint = message_?.createdAt
|
|
163
|
-
(message_.createdAt.$date ? new Date(message_.createdAt.$date) : message_.createdAt) : null;
|
|
162
|
+
const beforeCheckpoint = message_?.createdAt ?? null;
|
|
164
163
|
const lastMessage = await getLastNMessages(code, 1, beforeCheckpoint, {
|
|
165
164
|
query: { from_me: false }
|
|
166
165
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peopl-health/nexus",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0-fix-version",
|
|
4
4
|
"description": "Core messaging and assistant library for WhatsApp communication platforms",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"whatsapp",
|
|
@@ -58,19 +58,18 @@
|
|
|
58
58
|
"postversion": "git push && git push --tags"
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"body-parser": "^1.20.2",
|
|
62
61
|
"@opentelemetry/api": "1.9.0",
|
|
63
|
-
"@opentelemetry/auto-instrumentations-node": "0.
|
|
62
|
+
"@opentelemetry/auto-instrumentations-node": "0.53.0",
|
|
64
63
|
"@opentelemetry/exporter-jaeger": "1.28.0",
|
|
65
|
-
"@opentelemetry/exporter-prometheus": "0.56.0",
|
|
66
64
|
"@opentelemetry/exporter-trace-otlp-http": "0.56.0",
|
|
67
65
|
"@opentelemetry/resources": "1.28.0",
|
|
68
66
|
"@opentelemetry/sdk-node": "0.56.0",
|
|
69
67
|
"@opentelemetry/sdk-trace-node": "1.28.0",
|
|
70
68
|
"@opentelemetry/semantic-conventions": "1.28.0",
|
|
71
69
|
"airtable": "^0.12.2",
|
|
72
|
-
"aws-sdk": "2.1693.0",
|
|
70
|
+
"aws-sdk": "^2.1693.0",
|
|
73
71
|
"axios": "^1.5.0",
|
|
72
|
+
"body-parser": "^1.20.2",
|
|
74
73
|
"dotenv": "^16.6.1",
|
|
75
74
|
"moment-timezone": "^0.5.43",
|
|
76
75
|
"mongoose": "^7.5.0",
|
|
@@ -108,6 +107,9 @@
|
|
|
108
107
|
"engines": {
|
|
109
108
|
"node": ">=20.0.0"
|
|
110
109
|
},
|
|
110
|
+
"overrides": {
|
|
111
|
+
"p-limit": "2.3.0"
|
|
112
|
+
},
|
|
111
113
|
"publishConfig": {
|
|
112
114
|
"access": "public"
|
|
113
115
|
}
|