@peopl-health/nexus 5.67.0-dev.11929 → 5.67.0-dev.11931

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.
@@ -475,14 +475,17 @@ class AnthropicProvider extends BaseLLMProvider {
475
475
  // Split placement: the inline copy carries recency on single-round turns, the hoisted developer
476
476
  // turn persists once tool results are appended after it on multi-round turns.
477
477
  _buildInput({ additionalInstructions, ...rest }) {
478
- const input = super._buildInput({ additionalInstructions, ...rest });
479
- const pinned = { role: 'developer', content: `${RISK_HEADLINE_MARKER}\n${RISK_HEADLINE_DISCLAIMER}` };
480
- const ordered = [...input.filter(i => !i[TAIL_PLACEMENT]), pinned, ...input.filter(i => i[TAIL_PLACEMENT])];
478
+ const ordered = this._orderRuntimeMessages(super._buildInput({ additionalInstructions, ...rest }));
481
479
  if (!additionalInstructions) return ordered;
482
480
  const { content } = this._operatorInstructionTurn(additionalInstructions);
483
481
  return [...ordered, { role: 'user', content: `${OPERATOR_TURN_PREFIX} ${content}`, type: 'message' }];
484
482
  }
485
483
 
484
+ _orderRuntimeMessages(input) {
485
+ const pinned = { role: 'developer', content: `${RISK_HEADLINE_MARKER}\n${RISK_HEADLINE_DISCLAIMER}` };
486
+ return [...input.filter(i => !i[TAIL_PLACEMENT]), pinned, ...input.filter(i => i[TAIL_PLACEMENT])];
487
+ }
488
+
486
489
  _mapModelConfig(modelConfig) {
487
490
  const mapped = { ...(modelConfig || {}) };
488
491
  if (mapped.max_tokens == null && mapped.max_output_tokens != null) mapped.max_tokens = mapped.max_output_tokens;
@@ -783,6 +783,11 @@ class BaseLLMProvider {
783
783
  };
784
784
  }
785
785
 
786
+ // Providers that pin runtime messages around the tail-placed block override this.
787
+ _orderRuntimeMessages(input) {
788
+ return input;
789
+ }
790
+
786
791
  _sanitizeInput(input) {
787
792
  const isMessage = (item) => !item.type || item.type === 'message';
788
793
  const items = (input || []).filter(item => isMessage(item) ? this._hasTextContent(item.content) : true);
@@ -38,6 +38,11 @@ class GatewayProvider extends BaseLLMProvider {
38
38
  return this._sub(vendor)._buildInput(config);
39
39
  }
40
40
 
41
+ _orderRuntimeMessages(input, modelConfig = null) {
42
+ const { vendor } = this._route(modelConfig?.model);
43
+ return this._sub(vendor)._orderRuntimeMessages(input);
44
+ }
45
+
41
46
  _route(model) {
42
47
  if (!model) return { vendor: 'openai', model: undefined };
43
48
  const slash = model.indexOf('/');
@@ -1,13 +1,18 @@
1
+ const { Config_ID } = require('../../config/airtableConfig');
2
+ const { fieldEquals } = require('../../helpers/airtableFormulaHelper');
3
+ const { getRecordByFilter } = require('../../services/airtableService');
1
4
  const { safeParse, isPlainObject } = require('../../utils/jsonUtils');
2
5
  const { logger } = require('../../utils/logger');
3
6
  const { sanitizeInlineText } = require('../../utils/sanitizerUtils');
4
- const { composePrompt } = require('./promptComposerService');
7
+ const { withTimeout } = require('../../utils/timeoutUtils');
5
8
 
6
9
  const NUTRITION_GUIDE_PROMPT_ID = 'nutrition_plan_guide';
7
10
  const PLAN_MAX_CHARS = 1500;
8
11
  const GUIDE_MAX_CHARS = 2000;
9
12
  const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/;
10
13
  const GUIDE_TIMEOUT_MS = 3000;
14
+ const GUIDE_REFRESH_MS = 5 * 60 * 1000;
15
+ const GUIDE_RETRY_MS = 30 * 1000;
11
16
 
12
17
  // Each line of a plan or guide is one kind of indication, so the breaks survive and only each line is flattened.
13
18
  function keepLines(text, maxChars) {
@@ -31,44 +36,42 @@ function readPlanSection(section) {
31
36
  return { text: typeof text === 'string' ? text : '', reviewBy };
32
37
  }
33
38
 
34
- let lastGuide = '';
39
+ // The guide is served from memory and refreshed in the background: only the very first read can hold a
40
+ // turn (up to GUIDE_TIMEOUT_MS), and there is never more than one Airtable read in flight.
41
+ let guide = { text: '', freshUntil: 0 };
35
42
  let guideRead = null;
36
- let readSeq = 0;
37
- let appliedSeq = 0;
38
43
 
39
- function readGuide() {
44
+ function refreshGuide() {
40
45
  if (guideRead) return guideRead;
41
- const seq = ++readSeq;
42
- const read = composePrompt({ promptId: NUTRITION_GUIDE_PROMPT_ID })
43
- .then(({ resolvedPrompt }) => {
44
- // A read that hung past a newer one must not overwrite what the newer one found.
45
- if (seq > appliedSeq) {
46
- appliedSeq = seq;
47
- lastGuide = keepLines(resolvedPrompt || '', GUIDE_MAX_CHARS);
48
- }
49
- return lastGuide;
46
+ // Read straight from Airtable: the in-memory copy is this guide's only cache.
47
+ guideRead = getRecordByFilter(Config_ID, 'responses', fieldEquals('prompt_id', NUTRITION_GUIDE_PROMPT_ID))
48
+ .then((records) => {
49
+ const text = keepLines(records?.[0]?.content || '', GUIDE_MAX_CHARS);
50
+ // A missing row reads as empty: keep the last guide and look again soon rather than in five minutes.
51
+ guide = text
52
+ ? { text, freshUntil: Date.now() + GUIDE_REFRESH_MS }
53
+ : { ...guide, freshUntil: Date.now() + GUIDE_RETRY_MS };
50
54
  })
51
55
  .catch((error) => {
52
- logger.warn('[nutritionPlanService] nutrition guide unreadable; using the last one read', { error: error.message });
53
- return lastGuide;
56
+ logger.warn('[nutritionPlanService] nutrition guide unreadable; keeping the last one read', { error: error.message });
57
+ guide = { ...guide, freshUntil: Date.now() + GUIDE_RETRY_MS };
54
58
  })
55
- .finally(() => { if (guideRead === read) guideRead = null; });
56
- guideRead = read;
57
- return read;
59
+ .finally(() => { guideRead = null; });
60
+ return guideRead;
58
61
  }
59
62
 
60
- // The guide only adds to the plan, so a slow read must not hold the patient's turn,
61
- // and a read that never settles must not hold every later turn either.
62
63
  async function loadNutritionGuide() {
63
- let timer;
64
- const read = readGuide();
65
- const timedOut = new Promise((resolve) => { timer = setTimeout(() => resolve(null), GUIDE_TIMEOUT_MS); });
66
- const guide = await Promise.race([read, timedOut]);
67
- clearTimeout(timer);
68
- if (guide !== null) return guide;
69
- if (guideRead === read) guideRead = null;
70
- logger.warn('[nutritionPlanService] nutrition guide read timed out; using the last one read', { timeoutMs: GUIDE_TIMEOUT_MS });
71
- return lastGuide;
64
+ if (guide.freshUntil) {
65
+ if (Date.now() >= guide.freshUntil) refreshGuide();
66
+ return guide.text;
67
+ }
68
+ try {
69
+ await withTimeout(refreshGuide(), GUIDE_TIMEOUT_MS, 'nutrition guide read');
70
+ } catch {
71
+ logger.warn('[nutritionPlanService] first nutrition guide read timed out; plan sent without it for now', { timeoutMs: GUIDE_TIMEOUT_MS });
72
+ if (!guide.freshUntil) guide = { ...guide, freshUntil: Date.now() + GUIDE_RETRY_MS };
73
+ }
74
+ return guide.text;
72
75
  }
73
76
 
74
77
  async function resolveNutritionPlan(section, { now = new Date() } = {}) {
@@ -87,6 +90,8 @@ async function resolveNutritionPlan(section, { now = new Date() } = {}) {
87
90
  module.exports = {
88
91
  NUTRITION_GUIDE_PROMPT_ID,
89
92
  GUIDE_TIMEOUT_MS,
93
+ GUIDE_REFRESH_MS,
94
+ GUIDE_RETRY_MS,
90
95
  resolveNutritionPlan,
91
- _test: { reset: () => { lastGuide = ''; guideRead = null; readSeq = 0; appliedSeq = 0; } },
96
+ _test: { reset: () => { guide = { text: '', freshUntil: 0 }; guideRead = null; } },
92
97
  };
@@ -14,8 +14,6 @@ const promptCache = new MapCache({ maxSize: 50, ttl: CACHE_TTL });
14
14
  const snippetCache = new MapCache({ maxSize: 200, ttl: CACHE_TTL });
15
15
  const toolCache = new MapCache({ maxSize: 50, ttl: CACHE_TTL });
16
16
  const presetCache = new MapCache({ maxSize: 50, ttl: CACHE_TTL });
17
- let promptReadClock = 0;
18
- let promptCacheEpoch = 0;
19
17
 
20
18
  async function fetchConfigRecords(table, filter) {
21
19
  const records = await getRecordByFilter(Config_ID, table, filter);
@@ -49,17 +47,11 @@ async function fetchPreset(presetId, version = null) {
49
47
  async function fetchBasePrompt(promptId) {
50
48
  const cacheKey = `prompt:${promptId}`;
51
49
  const cached = promptCache.get(cacheKey);
52
- if (cached) return cached.record;
50
+ if (cached) return cached;
53
51
 
54
- // A result may fill the cache unless a read started later already did, or the cache was cleared meanwhile.
55
- const startedAt = ++promptReadClock;
56
- const epoch = promptCacheEpoch;
57
52
  const records = await getRecordByFilter(Config_ID, 'responses', `{prompt_id} = "${promptId}"`);
58
53
  const record = records?.[0] || null;
59
- const current = promptCache.get(cacheKey);
60
- if (record && epoch === promptCacheEpoch && !(current && current.startedAt > startedAt)) {
61
- promptCache.set(cacheKey, { record, startedAt });
62
- }
54
+ if (record) promptCache.set(cacheKey, record);
63
55
  return record;
64
56
  }
65
57
 
@@ -265,7 +257,6 @@ async function resolveTools({ promptId, assistant, presetToolIds = null, status
265
257
 
266
258
  function clearCache() {
267
259
  promptCache.clear();
268
- promptCacheEpoch += 1;
269
260
  snippetCache.clear();
270
261
  toolCache.clear();
271
262
  presetCache.clear();
@@ -225,17 +225,21 @@ class EvalProvider {
225
225
  const clinicalMessage = typeof this.provider._buildClinicalMessage === 'function'
226
226
  ? this.provider._buildClinicalMessage(promptVariables, interpolatedVariables)
227
227
  : [];
228
- const input = this.provider._sanitizeInput([...memoryMessage, ...clinicalMessage, ...convertedMessages]);
229
228
  const isContextOnly = this.mode === 'context-only';
230
229
 
231
230
  const shouldUsePromptRegistry = !presetId && this.provider.supportsPromptRegistry && !!assistantId;
231
+ const modelConfig = shouldUsePromptRegistry ? {} : this._modelConfig(presetModelConfig);
232
+ const input = this.provider._orderRuntimeMessages(
233
+ this.provider._sanitizeInput([...memoryMessage, ...clinicalMessage, ...convertedMessages]),
234
+ modelConfig,
235
+ );
232
236
 
233
237
  return {
234
238
  instructions: devContent || '',
235
239
  input,
236
240
  toolSchemas: isContextOnly ? [] : toolSchemas,
237
241
  toolChoice: isContextOnly ? 'none' : this.toolChoice,
238
- modelConfig: shouldUsePromptRegistry ? {} : this._modelConfig(presetModelConfig),
242
+ modelConfig,
239
243
  assistant: isContextOnly ? null : assistant,
240
244
  phiProcessor: this.phiProcessor,
241
245
  promptVariables,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.67.0-dev.11929",
3
+ "version": "5.67.0-dev.11931",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",