@peopl-health/nexus 5.67.0-dev.11849 → 5.67.0-dev.11889

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.
@@ -125,13 +125,13 @@ class BaseLLMProvider {
125
125
  assistant.toolRuntimeContext.enabledSkills = enabledSkills;
126
126
  }
127
127
 
128
- const resolvedSchemas = this._resolveToolSchemas({
128
+ const resolvedSchemas = this._scopeOutreachDecisionTool(this._resolveToolSchemas({
129
129
  assistant, toolIds: resolvedToolIds, filtered, toolDescriptions,
130
- });
130
+ }), isOutreach);
131
131
  const { toolChoice: flooredToolChoice, toolSchemas, narrowed } = this._applyDeliveryFloor(
132
132
  toolChoice, resolvedSchemas, Boolean(assistant?.toolRuntimeContext), turnKind);
133
133
  const effectiveToolIds = toolSchemas.map(s => s.function?.name).filter(Boolean);
134
- const instructionToolIds = narrowed ? effectiveToolIds : resolvedToolIds;
134
+ const instructionToolIds = narrowed || isOutreach ? effectiveToolIds : resolvedToolIds;
135
135
  const requestedToolChoice = prePromptResult?.toolChoice;
136
136
  const requestedToolName = requestedToolChoice?.name || requestedToolChoice?.function?.name || null;
137
137
  const supportedGenericChoices = new Set(['auto', 'none', 'required', 'any']);
@@ -234,6 +234,17 @@ class BaseLLMProvider {
234
234
  };
235
235
  }
236
236
 
237
+ // The decision tool belongs to the turn kind, not to a preset: without it an outreach turn cannot
238
+ // close and the brief is dropped, and on any other turn it would invite a decision nobody asked for.
239
+ _scopeOutreachDecisionTool(schemas, isOutreach) {
240
+ const others = schemas.filter(s => s.function?.name !== OUTREACH_DECISION_TOOL);
241
+ if (!isOutreach) return others;
242
+ const decision = schemas.find(s => s.function?.name === OUTREACH_DECISION_TOOL)
243
+ || getRegistrySchemas([OUTREACH_DECISION_TOOL])[0];
244
+ if (!decision) throw new Error('OUTREACH_DECISION_TOOL_UNAVAILABLE');
245
+ return [...others, decision];
246
+ }
247
+
237
248
  _resolveToolSchemas({ assistant, toolIds, filtered, toolDescriptions }) {
238
249
  const assistantSchemas = assistant?.getToolSchemas?.() || [];
239
250
  const registrySchemas = getRegistrySchemas();
@@ -7,6 +7,7 @@ const NUTRITION_GUIDE_PROMPT_ID = 'nutrition_plan_guide';
7
7
  const PLAN_MAX_CHARS = 1500;
8
8
  const GUIDE_MAX_CHARS = 2000;
9
9
  const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/;
10
+ const GUIDE_TIMEOUT_MS = 3000;
10
11
 
11
12
  // Each line of a plan or guide is one kind of indication, so the breaks survive and only each line is flattened.
12
13
  function keepLines(text, maxChars) {
@@ -30,14 +31,34 @@ function readPlanSection(section) {
30
31
  return { text: typeof text === 'string' ? text : '', reviewBy };
31
32
  }
32
33
 
34
+ let lastGuide = '';
35
+ let guideRead = null;
36
+
37
+ // One shared read; a failure keeps the last guide that was read successfully.
38
+ function readGuide() {
39
+ if (guideRead) return guideRead;
40
+ guideRead = composePrompt({ promptId: NUTRITION_GUIDE_PROMPT_ID })
41
+ .then(({ resolvedPrompt }) => {
42
+ lastGuide = keepLines(resolvedPrompt || '', GUIDE_MAX_CHARS);
43
+ return lastGuide;
44
+ })
45
+ .catch((error) => {
46
+ logger.warn('[nutritionPlanService] nutrition guide unreadable; using the last one read', { error: error.message });
47
+ return lastGuide;
48
+ })
49
+ .finally(() => { guideRead = null; });
50
+ return guideRead;
51
+ }
52
+
53
+ // The guide only adds to the plan, so a slow read must not hold the patient's turn.
33
54
  async function loadNutritionGuide() {
34
- try {
35
- const { resolvedPrompt } = await composePrompt({ promptId: NUTRITION_GUIDE_PROMPT_ID });
36
- return keepLines(resolvedPrompt || '', GUIDE_MAX_CHARS);
37
- } catch (error) {
38
- logger.warn('[nutritionPlanService] nutrition guide unavailable; plan sent without it', { error: error.message });
39
- return '';
40
- }
55
+ let timer;
56
+ const timedOut = new Promise((resolve) => { timer = setTimeout(() => resolve(null), GUIDE_TIMEOUT_MS); });
57
+ const guide = await Promise.race([readGuide(), timedOut]);
58
+ clearTimeout(timer);
59
+ if (guide !== null) return guide;
60
+ logger.warn('[nutritionPlanService] nutrition guide read timed out; using the last one read', { timeoutMs: GUIDE_TIMEOUT_MS });
61
+ return lastGuide;
41
62
  }
42
63
 
43
64
  async function resolveNutritionPlan(section, { now = new Date() } = {}) {
@@ -55,5 +76,7 @@ async function resolveNutritionPlan(section, { now = new Date() } = {}) {
55
76
 
56
77
  module.exports = {
57
78
  NUTRITION_GUIDE_PROMPT_ID,
79
+ GUIDE_TIMEOUT_MS,
58
80
  resolveNutritionPlan,
81
+ _test: { reset: () => { lastGuide = ''; guideRead = null; } },
59
82
  };
@@ -75,7 +75,7 @@ class EvalProvider {
75
75
  const { messages, promptVariables, lastUserMessage, contextSource } = await this._buildContext(numero, beforeCheckpoint);
76
76
 
77
77
  const {
78
- devContent, assistant, toolSchemas, resolvedPromptId, presetModelConfig, resolvedPresetId,
78
+ devContent, assistant, toolSchemas, resolvedPromptId, presetModelConfig, resolvedPresetId, interpolatedVariables,
79
79
  } = await this._resolvePrompt(prompt, assistantId, thread, promptVariables);
80
80
 
81
81
  const request = this._buildRequest({
@@ -83,6 +83,7 @@ class EvalProvider {
83
83
  assistantId: resolvedPromptId || assistantId,
84
84
  presetId: resolvedPresetId,
85
85
  presetModelConfig,
86
+ interpolatedVariables,
86
87
  });
87
88
 
88
89
  const startTime = Date.now();
@@ -133,6 +134,7 @@ class EvalProvider {
133
134
 
134
135
  async _resolvePrompt(fallbackPrompt, assistantId, thread, promptVariables) {
135
136
  let devContent;
137
+ let interpolatedVariables = [];
136
138
  let resolvedPromptId = assistantId;
137
139
  let presetToolIds = null;
138
140
  let presetModelConfig = null;
@@ -143,18 +145,21 @@ class EvalProvider {
143
145
  if (this.promptSource === 'airtable' && (presetId || assistantId)) {
144
146
  const {
145
147
  resolvedPrompt, promptId, presetToolIds: toolIds, modelConfig, presetId: composedPresetId,
148
+ interpolatedVariables: interpolated,
146
149
  } = await composePrompt({
147
150
  presetId,
148
151
  promptId: assistantId,
149
152
  variables: promptVariables,
150
153
  });
151
154
  devContent = resolvedPrompt;
155
+ interpolatedVariables = interpolated || [];
152
156
  resolvedPromptId = promptId || assistantId;
153
157
  presetToolIds = toolIds;
154
158
  presetModelConfig = modelConfig || null;
155
159
  resolvedPresetId = composedPresetId || resolvedPresetId;
156
160
  } else {
157
161
  devContent = fallbackPrompt;
162
+ interpolatedVariables = [...new Set([...(devContent || '').matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]))];
158
163
  devContent = devContent.replace(/\{\{(\w+)\}\}/g, (_, key) => promptVariables[key] ?? '');
159
164
  }
160
165
 
@@ -191,7 +196,7 @@ class EvalProvider {
191
196
  }
192
197
  }
193
198
 
194
- return { devContent, assistant, toolSchemas, resolvedPromptId, presetModelConfig, resolvedPresetId };
199
+ return { devContent, assistant, toolSchemas, resolvedPromptId, presetModelConfig, resolvedPresetId, interpolatedVariables };
195
200
  }
196
201
 
197
202
  _modelConfig(presetModelConfig) {
@@ -204,7 +209,7 @@ class EvalProvider {
204
209
 
205
210
  _buildRequest({
206
211
  devContent, messages, promptVariables, toolSchemas, assistant, assistantId, presetId,
207
- presetModelConfig = null,
212
+ presetModelConfig = null, interpolatedVariables = [],
208
213
  }) {
209
214
  const convertedMessages = this.provider._convertItemsToApiFormat(messages);
210
215
 
@@ -215,7 +220,11 @@ class EvalProvider {
215
220
  ? [{ role: 'developer', content: `Usa la siguiente información que conoces del paciente para personalizar tu respuesta. Adapta tu tono, contenido o recomendaciones según estas preferencias y contexto cuando sea relevante. No menciones que tienes esta información almacenada.\n\n${memoryParts.join('\n\n')}` }]
216
221
  : [];
217
222
 
218
- const input = this.provider._sanitizeInput([...memoryMessage, ...convertedMessages]);
223
+ // The same runtime clinical block a live turn gets (context, symptoms, nutrition plan), unless the prompt interpolates it.
224
+ const clinicalMessage = typeof this.provider._buildClinicalMessage === 'function'
225
+ ? this.provider._buildClinicalMessage(promptVariables, interpolatedVariables)
226
+ : [];
227
+ const input = this.provider._sanitizeInput([...memoryMessage, ...clinicalMessage, ...convertedMessages]);
219
228
  const isContextOnly = this.mode === 'context-only';
220
229
 
221
230
  const shouldUsePromptRegistry = !presetId && this.provider.supportsPromptRegistry && !!assistantId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.67.0-dev.11849",
3
+ "version": "5.67.0-dev.11889",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",