@peopl-health/nexus 5.67.0-dev.11680 → 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.
@@ -3,7 +3,7 @@ const runtimeConfig = require('../../config/runtimeConfig');
3
3
  const flagOn = (key) => String(runtimeConfig.get(key, 'false')).trim().toLowerCase() === 'true';
4
4
 
5
5
  function escalationUpdateModeLive() {
6
- return flagOn('ESCALATION_UPDATE_MODE_LIVE');
6
+ return String(runtimeConfig.get('ESCALATION_UPDATE_MODE_LIVE', 'true')).trim().toLowerCase() !== 'false';
7
7
  }
8
8
 
9
9
  function escalationAssessmentLive() {
@@ -2,6 +2,7 @@ const { pairTriageSymptoms } = require('../helpers/triageSymptomsHelper');
2
2
  const { handlePendingFunctionCalls: handlePendingFunctionCallsUtil } = require('../providers/OpenAIResponsesProviderTools');
3
3
 
4
4
  const { assembleRiskHeadline } = require('../services/riskHeadlineService');
5
+ const { resolveNutritionPlan } = require('../services/nutritionPlanService');
5
6
  const { resolvePatientIdentity, identityError } = require('../services/patientIdentityService');
6
7
  const { Follow_Up_ID } = require('../../config/airtableConfig');
7
8
  const runtimeConfig = require('../../config/runtimeConfig');
@@ -350,7 +351,8 @@ class DefaultMemoryManager extends MemoryManager {
350
351
 
351
352
  return {
352
353
  clinicalContext: [contextParts.join('. '), riskHeadline].filter(Boolean).join('\n\n'),
353
- lastSymptoms: symptomParts.join('; ') || 'Sin síntomas reportados recientemente'
354
+ lastSymptoms: symptomParts.join('; ') || 'Sin síntomas reportados recientemente',
355
+ nutritionPlan: await resolveNutritionPlan(data.nutricion),
354
356
  };
355
357
  } catch (error) {
356
358
  logger.error('[DefaultMemoryManager] Error getting clinical data', { error: error.message });
@@ -49,6 +49,7 @@ class EnhancedMemoryManager extends DefaultMemoryManager {
49
49
  return {
50
50
  clinicalContext: baseClinicalData?.clinicalContext ?? '',
51
51
  lastSymptoms: baseClinicalData?.lastSymptoms ?? '',
52
+ nutritionPlan: baseClinicalData?.nutritionPlan ?? '',
52
53
  patientMemories: formattedMemories,
53
54
  conversationSummaries: formattedSummaries,
54
55
  };
@@ -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();
@@ -305,10 +316,10 @@ class BaseLLMProvider {
305
316
  role: 'developer',
306
317
  content: [
307
318
  '## Seguimiento, recordatorio y contingencia — no son intercambiables',
308
- '- SEGUIMIENTO: tú decides volver a contactar para mantener continuidad cuando ya no falta un dato clínico que pueda cambiar el riesgo. Elige cuándo y redacta la pregunta exacta para esa persona. Usa schedulePatientReminder con reminder_type seguimiento y la utility_basis concreta. No lo programes por rutina ni por inactividad sola; cualquier actividad posterior de la paciente lo cancela y entonces respondes a lo nuevo.',
319
+ '- SEGUIMIENTO: tú decides volver a contactar para mantener continuidad cuando ya no falta un dato clínico que pueda cambiar el riesgo. Elige cuándo y redacta la pregunta exacta para esa persona. Usa schedulePatientReminder con reminder_type seguimiento y la utility_basis concreta. Prográmalo cuando indicaste una acción (medicamento de venta libre, autocuidado, educación) cuyo efecto se ve en 24–72 h; cuando la paciente ya dio el dato y quieres saber si el síntoma vuelve o cruza un umbral que nombraste; cuando cerraste un episodio que vale la pena revisar; o cuando tiene un evento próximo (inicio de tratamiento, cita, infusión) cuyo resultado importa. No lo programes por inactividad sola; cualquier actividad posterior de la paciente lo cancela y entonces respondes a lo nuevo.',
309
320
  '- RECORDATORIO: sólo existe si la paciente pidió explícitamente que se lo recuerdes o aceptó explícitamente tu oferta de recordárselo. Usa schedulePatientReminder con el tipo concreto (medicamento, cita, examen u otros) y exactamente a la hora que pidió. No lo adelantes para que llegue antes de una cita o tarea salvo que ella haya pedido ese adelanto. No infieras un recordatorio porque mencionó o agendó algo, ni lo uses para preguntar cómo sigue o vigilar incertidumbre clínica.',
310
321
  '- CONTINGENCIA: protege una incertidumbre clínica todavía abierta; falta un dato u observación que podría cambiar el grado, la urgencia o la conducta. Usa setContingencyPlan: la contingencia define qué se espera, cuánto silencio tolerar y, si corresponde, la escalada posterior. Nunca la sustituyas por seguimiento ni por recordatorio.',
311
- 'Regla de desempate: si esperas saber “cómo sigue” por continuidad, es seguimiento; si la paciente pidió explícitamente o aceptó que le recuerdes hacer algo, es recordatorio; si esperas un dato porque no tenerlo podría volver insegura la conducta, es contingencia. Una tarea acordada o agendada sin esa petición no es un recordatorio. Una contingencia activa para la misma preocupación gana y no debe cruzarse con otro seguimiento.',
322
+ 'Regla de desempate: si esperas saber “cómo sigue” por continuidad, es seguimiento; si la paciente pidió explícitamente o aceptó que le recuerdes hacer algo, es recordatorio; si esperas un dato porque no tenerlo podría volver insegura la conducta, es contingencia. Una tarea acordada o agendada sin esa petición no es un recordatorio. Una contingencia activa gana sólo mientras siga faltando su dato; cuando la resuelves porque la paciente ya lo dio, la vigilancia que queda es un seguimiento, no una red rearmada.',
312
323
  ].join('\n'),
313
324
  }];
314
325
  }
@@ -402,7 +413,7 @@ class BaseLLMProvider {
402
413
  }
403
414
  for (const net of cancelled) {
404
415
  const plan = net?.carePlan || {};
405
- parts.push(`- (este turno) ${plan.planId} — la red que vigilaba "${sanitizeInlineText(plan.concern)}" se desactivó automáticamente porque el paciente escribió. Si todavía falta un dato de la rúbrica que podría subir el grado, considera rearmarla con setContingencyPlan.`);
416
+ parts.push(`- (este turno) ${plan.planId} — la red que vigilaba "${sanitizeInlineText(plan.concern)}" se desactivó automáticamente porque el paciente escribió. Si todavía falta un dato de la rúbrica que podría subir el grado, considera rearmarla con setContingencyPlan. Si ya no falta ese dato y sólo quieres saber cómo sigue, programa un seguimiento (schedulePatientReminder, reminder_type seguimiento) en lugar de rearmarla.`);
406
417
  }
407
418
  if (active.length) {
408
419
  parts.push('DECISIÓN ESTE TURNO: si el mensaje del paciente responde lo que una red espera observar, llama setContingencyPlan {action: \'resolve\', plan_id: \'<plan>\', resolution_reasoning: \'…\'} o cierra el caso. Si cambió la preocupación o la ventana, usa {action: \'rearm\'}. Si el mensaje trata de otra cosa, no hagas nada con la red.');
@@ -428,6 +439,10 @@ class BaseLLMProvider {
428
439
  if (symptoms && !inPrompt.has('last_symptoms')) {
429
440
  parts.push(`## Síntomas recientes\n${symptoms}`);
430
441
  }
442
+ const nutritionPlan = this._clinicalText(promptVariables?.nutrition_plan, 'nutrition_plan');
443
+ if (nutritionPlan && !inPrompt.has('nutrition_plan')) {
444
+ parts.push(`## Plan de nutrición indicado por el equipo (indicado; no asumas que el paciente ya lo cumple)\n${nutritionPlan}`);
445
+ }
431
446
  if (parts.length === 0) return [];
432
447
  // Refreshed most turns (symptoms, risks); ahead of the history it rewrote the whole cached
433
448
  // conversation. Informational only, so it rides behind it like the clock. Directives stay in system.
@@ -510,6 +525,7 @@ class BaseLLMProvider {
510
525
  const promptVariables = {
511
526
  clinical_context: clinicalData?.clinicalContext ?? '',
512
527
  last_symptoms: clinicalData?.lastSymptoms ?? '',
528
+ nutrition_plan: clinicalData?.nutritionPlan ?? '',
513
529
  current_date: buildDateAnchor(),
514
530
  patient_memories: clinicalData?.patientMemories ?? '',
515
531
  conversation_summaries: clinicalData?.conversationSummaries ?? '',
@@ -18,8 +18,8 @@ const ESCALATION_SILENCE_MINUTES = { stat: 120, asap: 180, urgent: 240 };
18
18
  const OPEN_CEILING_SILENCE_MINUTES = 720;
19
19
  const OPEN_CEILING_ORIGIN = 'open_ceiling';
20
20
  const OPEN_CEILING_CHECK_IN = 'Hola, quería saber cómo sigues. ¿Cómo va lo que me contaste?';
21
- const ESCALATION_AWAITING = 'confirmación de que el equipo la contactó, y cómo evolucionó el síntoma';
22
- const ESCALATION_CHECK_IN = 'Hola, quería saber cómo sigues. ¿Ya te contactó el equipo médico? ¿Cómo te sientes ahora?';
21
+ const ESCALATION_AWAITING = 'cómo evolucionó el síntoma escalado: si empeoró, mejoró o apareció un signo de alarma';
22
+ const ESCALATION_CHECK_IN = 'Hola, quería saber cómo sigues. ¿Cómo te sientes ahora con lo que me contaste?';
23
23
  const FIRED_STATUS_BY_STAGE = { patient: 'fired_reminder', team: 'fired_team' };
24
24
  const STATUS_RANK = { armed: 0, fired_reminder: 1, fired_team: 2 };
25
25
  const TEAM_WINDOW_FRACTION = 0.5;
@@ -302,7 +302,7 @@ async function armEscalationNet({ patientCode, urgencyLevel, symptom, suspectedC
302
302
  recipient: 'patient',
303
303
  message: String(checkIn || '').trim() || ESCALATION_CHECK_IN,
304
304
  firesAfterSilenceMinutes: silence,
305
- windowReasoning: `Escalación ${urgencyLevel}: se vuelve a buscar a la paciente si no hay señal suya ni constancia de contacto del equipo.`,
305
+ windowReasoning: `Escalación ${urgencyLevel}: se vuelve a buscar a la paciente si no hay señal suya.`,
306
306
  },
307
307
  });
308
308
 
@@ -10,7 +10,7 @@ const { readStructuredText } = require('../helpers/structuredTextHelper');
10
10
  const { getEscalationContext } = require('../../services/escalationContextService');
11
11
 
12
12
  const { logger } = require('../../utils/logger');
13
- const { safeParse } = require('../../utils/jsonUtils');
13
+ const { arrayArg, safeParse } = require('../../utils/jsonUtils');
14
14
  const { withTiming } = require('../../utils/tracingDecorator');
15
15
 
16
16
  const { crossSymptomIssues, unproposedDrugsIn, validateProposal } = require('./treatmentProposalService');
@@ -130,9 +130,15 @@ async function generateEscalationDraft({
130
130
 
131
131
  const parsed = safeParse(readStructuredText(result), null);
132
132
  const patientMessage = trimmed(parsed?.patientMessage);
133
- const blocks = parsed?.symptoms === undefined ? [parsed] : parsed.symptoms;
134
- if (!Array.isArray(blocks) || !blocks.length) {
135
- logger.warn('[escalationDraft] generation returned a malformed symptom list', { reviewId });
133
+ // The schema is not strict, so the model sometimes sends the list JSON-encoded as a string.
134
+ const blocks = parsed?.symptoms === undefined ? [parsed] : arrayArg(parsed.symptoms);
135
+ if (!blocks.length) {
136
+ const shape = parsed.symptoms;
137
+ logger.warn('[escalationDraft] generation returned a malformed symptom list', {
138
+ reviewId,
139
+ symptomsType: Array.isArray(shape) ? 'array' : typeof shape,
140
+ symptomsLength: Array.isArray(shape) || typeof shape === 'string' ? shape.length : null,
141
+ });
136
142
  return { completed: false, attempted: true, reason: 'the model returned a malformed symptoms list' };
137
143
  }
138
144
  if (parsed?.symptoms !== undefined && blocks.some((block) => !trimmed(block?.symptom))) {
@@ -0,0 +1,82 @@
1
+ const { safeParse, isPlainObject } = require('../../utils/jsonUtils');
2
+ const { logger } = require('../../utils/logger');
3
+ const { sanitizeInlineText } = require('../../utils/sanitizerUtils');
4
+ const { composePrompt } = require('./promptComposerService');
5
+
6
+ const NUTRITION_GUIDE_PROMPT_ID = 'nutrition_plan_guide';
7
+ const PLAN_MAX_CHARS = 1500;
8
+ const GUIDE_MAX_CHARS = 2000;
9
+ const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/;
10
+ const GUIDE_TIMEOUT_MS = 3000;
11
+
12
+ // Each line of a plan or guide is one kind of indication, so the breaks survive and only each line is flattened.
13
+ function keepLines(text, maxChars) {
14
+ return String(text)
15
+ .split('\n')
16
+ .map((line) => sanitizeInlineText(line, Infinity))
17
+ .filter(Boolean)
18
+ .join('\n')
19
+ .slice(0, maxChars);
20
+ }
21
+
22
+ // The section arrives as {plan_vigente, revisar_el}, as a {previous, current} slot, or as either one JSON-encoded.
23
+ function readPlanSection(section) {
24
+ const parsed = typeof section === 'string' ? safeParse(section, section) : section;
25
+ if (typeof parsed === 'string') return { text: parsed, reviewBy: null };
26
+ if (!isPlainObject(parsed)) return { text: '', reviewBy: null };
27
+ const value = parsed.plan_vigente ?? parsed.current ?? parsed.nutrition_context ?? '';
28
+ const inner = typeof value === 'string' ? safeParse(value, null) : value;
29
+ const text = isPlainObject(inner) ? inner.current ?? '' : value;
30
+ const reviewBy = typeof parsed.revisar_el === 'string' && ISO_DATE_RX.test(parsed.revisar_el) ? parsed.revisar_el : null;
31
+ return { text: typeof text === 'string' ? text : '', reviewBy };
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.
54
+ async function loadNutritionGuide() {
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;
62
+ }
63
+
64
+ async function resolveNutritionPlan(section, { now = new Date() } = {}) {
65
+ const { text, reviewBy } = readPlanSection(section);
66
+ const plan = keepLines(text, PLAN_MAX_CHARS);
67
+ if (!plan) return '';
68
+ const expired = reviewBy && reviewBy < now.toISOString().slice(0, 10);
69
+ const guide = await loadNutritionGuide();
70
+ return [
71
+ plan,
72
+ expired ? `Fecha de revisión vencida: ${reviewBy}.` : '',
73
+ guide,
74
+ ].filter(Boolean).join('\n\n');
75
+ }
76
+
77
+ module.exports = {
78
+ NUTRITION_GUIDE_PROMPT_ID,
79
+ GUIDE_TIMEOUT_MS,
80
+ resolveNutritionPlan,
81
+ _test: { reset: () => { lastGuide = ''; guideRead = null; } },
82
+ };
@@ -19,7 +19,7 @@ const { logger } = require('../../utils/logger');
19
19
 
20
20
  const definition = {
21
21
  'name': 'reportMedicalEscalation',
22
- 'description': '**Does:** Escalates a MEDICAL situation to the oncology care team at a chosen urgency, with an explicit justification for the level. Covers the full medical-urgency spectrum — immediate emergencies through asynchronous team review. One-way commit; the payload is INTERNAL — the patient never sees it.\n\n**Required inputs:** `urgency_level` — the FHIR request-priority code (`stat` | `asap` | `urgent` | `routine`), `urgency_justification` (one or two sentences: why this level AND why not the tier above/below — the auditable decision record), `details` (object — `symptom` + `evidence_quote` required; pass `null` when unknown for `category`, `indicators`, `suspected_condition`, `recommended_action`).\n\n**Urgency levels:** FHIR request-priority, a RELATIVE order `routine` < `urgent` < `asap` < `stat` (priority/speed, not a fixed clock). The full per-tier rubric with clinical examples lives on the `urgency_level` input enum — read it there. Out-of-enum values are coerced to `urgent` and disclosed via `coerced_urgency` on the result.\n\n**Neurological constellation override:** TWO OR MORE of — visual change, dizziness/vertigo/loss of balance, inability to walk/stand, focal weakness/numbness, acute confusion, sudden severe headache — reported this turn or across the last 2–3 turns → escalate without a further clarifying question: `asap` by default, `stat` if there is an acute deficit (can\'t walk/stand, sphincter loss, bilateral leg weakness → possible cord compression) or any instability.\n\n**When NOT to call:**\n- Unconfirmed/unspecific symptom — ask ONE characterization question via `DeliverPatientMessage` first.\n- Active mental-health crisis — use `reportCrisis`.\n- Patient shared lab/imaging/values/files — use `reportResultsReceived`.\n- Administrative / operative matter (appointments, insurance, authorizations, letters, transport, scheduling, out-of-scope request) or a non-clinical referral (psico-oncología, paliativos, non-oncology medical) — use `reportUnresolvedRequestv2`.\n- Twice in the same turn — already committed.\n\n**After the commit:** deliver a brief `DeliverPatientMessage` matched to the level — `stat`: firm, no te muevas / 911 if alone or immobile, the team was notified; `asap`: wait 15 min for the team\'s word, then acude a urgencias (ve acompañada); `urgent`: \'es importante que te valoren hoy\' + the 15-min fallback; `routine`: warm acknowledgement that the team will review. Never confirm a diagnosis; avoid alarmist language; say the team was notified.\n\n**Returns:** a RECEIPT, not a resolution — `{urgency_level, queued_for_team, safety_net_armed, safety_net_reason, team_has_reviewed, means, follow_up}`. `safety_net_armed` is `true`/`false`/`null`, where **`null` means this urgency tier gets no automatic check-in at all** (`routine`); read `follow_up` and make the fallback explicit to the patient whenever nothing is armed. `queued_for_team` says only that the ticket was written; `team_has_reviewed` is always `false` here because nothing on this path can know otherwise. Treat the escalation as OPEN and still your responsibility for the rest of the conversation: never tell the patient the team already reviewed it, and never close the topic because this call succeeded. If `queued_for_team` is `false` the team was NOT notified — say so instead of implying they were. When your `urgency_level` was out-of-enum it is coerced to `urgent` and disclosed via `{coerced_urgency, requested_urgency_level}`. A `details.category` outside the enum is dropped to `null` and disclosed via `{coerced_category, requested_category}`.\n\n**Side effects:** Emits `MEDICAL_ESCALATION_TRIGGERED`; the runtime routes it to the team by level (pages at `stat`/`asap`, ticket at `urgent`/`routine`).',
22
+ 'description': '**Does:** Escalates a MEDICAL situation to the oncology care team at a chosen urgency, with an explicit justification for the level. Covers the full medical-urgency spectrum — immediate emergencies through asynchronous team review. One-way commit; the payload is INTERNAL — the patient never sees it.\n\n**Required inputs:** `urgency_level` — the FHIR request-priority code (`stat` | `asap` | `urgent` | `routine`), `urgency_justification` (one or two sentences: why this level AND why not the tier above/below — the auditable decision record), `details` (object — `symptom` + `evidence_quote` required; pass `null` when unknown for `category`, `indicators`, `suspected_condition`, `recommended_action`).\n\n**Urgency levels:** FHIR request-priority, a RELATIVE order `routine` < `urgent` < `asap` < `stat` (priority/speed, not a fixed clock). The full per-tier rubric with clinical examples lives on the `urgency_level` input enum — read it there. Out-of-enum values are coerced to `urgent` and disclosed via `coerced_urgency` on the result.\n\n**Neurological constellation override:** TWO OR MORE of — visual change, dizziness/vertigo/loss of balance, inability to walk/stand, focal weakness/numbness, acute confusion, sudden severe headache — reported this turn or across the last 2–3 turns → escalate without a further clarifying question: `asap` by default, `stat` if there is an acute deficit (can\'t walk/stand, sphincter loss, bilateral leg weakness → possible cord compression) or any instability.\n\n**When NOT to call:**\n- Unconfirmed/unspecific symptom — ask ONE characterization question via `DeliverPatientMessage` first.\n- Active mental-health crisis — use `reportCrisis`.\n- Patient shared lab/imaging/values/files — use `reportResultsReceived`.\n- Administrative / operative matter (appointments, insurance, authorizations, letters, transport, scheduling, out-of-scope request) or a non-clinical referral (psico-oncología, paliativos, non-oncology medical) — use `reportUnresolvedRequestv2`.\n- Twice in the same turn for the same episode — already committed; a later datum belongs to the same open escalation.\n\n**After the commit:** the escalation does not end the conversation.\n- **Patient message** (`DeliverPatientMessage`, calm, ≤60 words): acknowledge what they told you → \'Ya avisé al equipo médico\' → ONE concrete next step → a safety net specific to THIS case (1–2 observable signs and what to do if they appear) → availability. Explain the purpose in everyday words. Reassure with the plan (\'ya le avisé al equipo médico para que lo revise\', \'vamos a darle seguimiento contigo\'), never with promises (\'no es nada\', \'no te preocupes\'). No diagnosis names, no lists of alarming symptoms, no \'grave/peligroso\', no timed fallback (\'si en 15 minutos…\'), and no promised contact time — not even \'te contactan hoy en las próximas horas\'; say what the team was asked to do (\'le pedí al equipo médico que lo revise\'), never that someone is already reviewing it, and not when they will reach out.\n- **By level:** `stat` is the ONLY level that sends the patient now — calm and unambiguous: ve a urgencias ahora, acompañado; 911 (or an ambulance) if they are alone with no one to take them, cannot move safely, or are unstable — being alone counts as unsafe transport, and they never drive themselves. Fever that is `stat`: ≥38.0 °C within 8 weeks of cytotoxic chemotherapy (or a hematologic cancer, transplant, known neutropenia), any fever within 4 weeks of CAR-T or during bispecific step-up dosing, or sepsis signs with or without fever — say it calmly: "ve a urgencias ahora y di que estás en tratamiento oncológico" (CAR-T / bispecific: call the treating center first). If you don\'t know the treatment or the last dose date, ask in the same message; unknown date on chemo counts as within the window. Fever 37.5–37.9 °C in that window with the patient well is `asap`: remeasure in 30–60 min. `asap`: do NOT lead with urgencias — it appears only in the conditional safety net (if [sign] appears or they feel much worse). `urgent`: they can stay home; urgencias only as a worsening-sign safety net. `routine`: acknowledgement, what to watch, when to write again.\n- **Keep characterizing (except `stat`):** in the same message ask the ONE question that most helps the team; in later turns keep going, one question per message, and give high-value guidance that does not prescribe (comfort measures already in their plan or indicated by the team, what to have at hand, how to monitor). Each new datum goes to the SAME open escalation, not a new one: raise the level if it makes something more serious plausible; never lower it. In `stat`, after the instruction only confirm they are on their way and accompanied.\n\n**Returns:** a RECEIPT, not a resolution — `{urgency_level, queued_for_team, safety_net_armed, safety_net_reason, team_has_reviewed, means, follow_up}`. `safety_net_armed` is `true`/`false`/`null`, where **`null` means this urgency tier gets no automatic check-in at all** (`routine`); read `follow_up` and make the fallback explicit to the patient whenever nothing is armed. `queued_for_team` says only that the ticket was written; `team_has_reviewed` is always `false` here because nothing on this path can know otherwise. Treat the escalation as OPEN and still your responsibility for the rest of the conversation: never tell the patient the team already reviewed it, and never close the topic because this call succeeded. If `queued_for_team` is `false` the team was NOT notified — say so instead of implying they were. When your `urgency_level` was out-of-enum it is coerced to `urgent` and disclosed via `{coerced_urgency, requested_urgency_level}`. A `details.category` outside the enum is dropped to `null` and disclosed via `{coerced_category, requested_category}`.\n\n**Side effects:** Emits `MEDICAL_ESCALATION_TRIGGERED`; the runtime routes it to the team by level (pages at `stat`/`asap`, ticket at `urgent`/`routine`).',
23
23
  'strict': true,
24
24
  'parameters': {
25
25
  'type': 'object',
@@ -32,13 +32,13 @@ const definition = {
32
32
  'urgent',
33
33
  'routine'
34
34
  ],
35
- 'description': 'Tier of medical escalation — the FHIR request-priority code (RequestPriority), a RELATIVE urgency ordered routine < urgent < asap < stat. It encodes priority / speed-of-action, NOT a fixed time window — the per-case clock is safety_gate.max_action_time_hours. 1:1 with the gate route. In urgency_justification say why this level and not the one above or below.\n- `stat` (inmediato, gate emergencia) — acción inmediata, prioridad máxima, flujo de emergencia. Inestabilidad o amenaza vital: neutropenia febril CON inestabilidad, disnea severa, alteración del estado mental, sangrado mayor, compresión medular con déficit agudo.\n- `asap` (lo antes posible, gate urgencias) — máxima prioridad NO inmediata; revisión en la primera oportunidad posible porque la demora puede crear riesgo. Complicación seria sospechada pero aún no claramente inestable: dolor que empeora rápido, riesgo de infección / neutropenia febril ESTABLE, síntoma neurológico nuevo sin déficit agudo.\n- `urgent` (urgente, gate equipo_tratante_hoy) — revisión clínica acelerada, por encima de rutina porque la demora podría importar clínicamente. Mismo día, no presencial-ahora: síntoma nuevo moderado, anormalidad de laboratorio preocupante pero estable, toxicidad de tratamiento que necesita input oportuno.\n- `routine` (rutina, sin gate o verdict suavizado) — prioridad normal, vía de atención habitual. Sin riesgo por demora: seguimiento, aclaración de medicación, síntoma >72h sin empeorar, contexto clínico desactualizado.'
35
+ 'description': 'Tier of medical escalation — the FHIR request-priority code (RequestPriority), a RELATIVE urgency ordered routine < urgent < asap < stat. It encodes priority / speed-of-action, NOT a fixed time window — the per-case clock is safety_gate.max_action_time_hours. 1:1 with the gate route. In urgency_justification say why this level and not the one above or below.\n- `stat` (inmediato, gate emergencia) — acción inmediata, prioridad máxima, flujo de emergencia. Inestabilidad o amenaza vital: neutropenia febril CON inestabilidad, fiebre ≥38.0 °C en la ventana de quimioterapia descrita en la regla de fiebre, disnea severa, alteración del estado mental, sangrado mayor, compresión medular con déficit agudo.\n- `asap` (lo antes posible, gate urgencias) — máxima prioridad NO inmediata; objetivo interno de valoración en horas (no se le promete al paciente), porque la demora puede crear riesgo; el paciente no va a urgencias salvo que aparezca una señal de la red de seguridad. Complicación seria sospechada pero aún no claramente inestable: dolor que empeora rápido, riesgo de infección sin fiebre ≥38.0 °C en esa ventana (p. ej. 37.5–37.9 °C con el paciente bien), síntoma neurológico nuevo sin déficit agudo.\n- `urgent` (urgente, gate equipo_tratante_hoy) — objetivo interno de revisión en 24 h (no se le promete al paciente), por encima de rutina porque la demora podría importar clínicamente; el paciente puede seguir en casa con una red de seguridad: síntoma nuevo moderado, anormalidad de laboratorio preocupante pero estable, toxicidad de tratamiento que necesita input oportuno.\n- `routine` (rutina, sin gate o verdict suavizado) — prioridad normal, vía de atención habitual. Sin riesgo por demora: seguimiento, aclaración de medicación, síntoma >72h sin empeorar, contexto clínico desactualizado.'
36
36
  },
37
37
  'patient_check_in': {
38
38
  'anyOf': [
39
39
  {
40
40
  'type': 'string',
41
- 'description': 'Nullable. The check-in this patient receives ONLY IF they go silent for hours after this escalation — the safety net writes to them again so the thread is not lost. Write it now, for delivery later: in their voice and language, short, warm, asking how they are and whether the team reached them. Reference what they told you in their own words. NO diagnosis, NO grades or CTCAE terms, NO internal phrasing — they never see `details`. Always send the key: pass `null` when you have nothing better than the neutral fallback, which is what gets used then — a bad check-in is worse than none.'
41
+ 'description': 'Nullable. The check-in this patient receives ONLY IF they go silent for hours after this escalation — the safety net writes to them again so the thread is not lost. Write it now, for delivery later: in their voice and language, short, warm, asking how they are and how the symptom has evolved since. Reference what they told you in their own words. NO diagnosis, NO grades or CTCAE terms, NO internal phrasing — they never see `details`. Always send the key: pass `null` when you have nothing better than the neutral fallback, which is what gets used then — a bad check-in is worse than none.'
42
42
  },
43
43
  {
44
44
  'type': 'null'
@@ -10,7 +10,7 @@ const { DEFAULT_TIMEZONE } = require('../../utils/scheduleUtils');
10
10
 
11
11
  const definition = {
12
12
  name: 'schedulePatientReminder',
13
- description: '**Does:** Creates or updates one agent-authored WhatsApp reminder. An ordinary reminder (medication, appointment, exam, or other task) is allowed only when the patient explicitly asks for or explicitly accepts that reminder. Do not infer a reminder merely because the patient mentioned or scheduled something. Schedule it at the exact date and time the patient requested. Never move it earlier to precede an appointment or task unless the patient explicitly requested that offset. On any patient conversation—not only a triage—or on a proactive review, use reminder_type seguimiento when this turn decides both when to check in again and the exact patient-facing words. Do not schedule one by default: use it only when a later contact materially helps continuity or engagement and has a concrete utility_basis.\\n\\nA seguimiento is a lightweight engagement plan: it is capped at 600 characters, never truncated, expires 24 hours after its chosen fire time, and cannot be created while another seguimiento is active. Any patient activity cancels it.\\n\\n**When NOT to call:** when the reminder guards unresolved clinical uncertainty, use setContingencyPlan instead; that plan owns its silence window and lifecycle. Never update a contingency-owned `plan_…-patient` reminder here.\\n\\nRequired: future patient-local reminder_date, reminder_description, reminder_type, action type, reminder_id for updates, and utility_basis for seguimiento.',
13
+ description: '**Does:** Creates or updates one agent-authored WhatsApp reminder. An ordinary reminder (medication, appointment, exam, or other task) is allowed only when the patient explicitly asks for or explicitly accepts that reminder. Do not infer a reminder merely because the patient mentioned or scheduled something. Schedule it at the exact date and time the patient requested. Never move it earlier to precede an appointment or task unless the patient explicitly requested that offset. On any patient conversation—not only a triage—or on a proactive review, use reminder_type seguimiento when this turn decides both when to check in again and the exact patient-facing words. Schedule one whenever a later contact materially helps continuity and has a concrete utility_basis — typically: you recommended an action (OTC medication, self-care, education) whose effect shows within 24–72h; the patient already gave the deciding datum and you want to know whether the symptom comes back or crosses a threshold you named; you closed a symptom episode worth checking on; or the patient has an upcoming event (starting a treatment, an appointment, an infusion) whose outcome matters. Inactivity alone is not a basis.\\n\\nA seguimiento is a lightweight engagement plan: it is capped at 600 characters, never truncated, expires 24 hours after its chosen fire time, and cannot be created while another seguimiento is active. Any patient activity cancels it.\\n\\n**When NOT to call:** when the reminder guards unresolved clinical uncertainty, use setContingencyPlan instead; that plan owns its silence window and lifecycle. Never update a contingency-owned `plan_…-patient` reminder here.\\n\\nRequired: future patient-local reminder_date, reminder_description, reminder_type, action type, reminder_id for updates, and utility_basis for seguimiento.',
14
14
  strict: true,
15
15
  parameters: {
16
16
  type: 'object',
@@ -26,7 +26,7 @@ const definition = {
26
26
  reminder_type: {
27
27
  type: 'string',
28
28
  enum: ['medicamento', 'exámen', 'examen', 'cita', 'check_in', 'seguimiento', 'otros'],
29
- description: '\'seguimiento\' = the agent chooses to contact later with an exact continuity/engagement question after clinical uncertainty is resolved; patient activity cancels it. \'medicamento\', \'cita\', \'examen\'/\'exámen\', and \'otros\' require an explicit patient request or acceptance to be reminded. A mention or scheduled event alone is insufficient. \'check_in\' is legacy compatibility; use seguimiento for new agent-owned follow-ups. Never use this tool to guard unresolved clinical uncertainty—use setContingencyPlan.',
29
+ description: '\'seguimiento\' = the agent chooses to contact later with an exact continuity/engagement question once no datum that could raise the grade is still missing; patient activity cancels it. \'medicamento\', \'cita\', \'examen\'/\'exámen\', and \'otros\' require an explicit patient request or acceptance to be reminded. A mention or scheduled event alone is insufficient. \'check_in\' is legacy compatibility; use seguimiento for new agent-owned follow-ups. Never use this tool to guard unresolved clinical uncertainty—use setContingencyPlan.',
30
30
  },
31
31
  reminder_action_type: {
32
32
  type: 'string',
@@ -12,7 +12,7 @@ const MAX_PATIENT_SILENCE_MIN = 1380;
12
12
 
13
13
  const definition = {
14
14
  name: 'setContingencyPlan',
15
- description: `**Does:** Manages the contingency safety net guarding an open case or cluster, selected via \`action\`. A net is a single promise: if the patient goes quiet, you write to them again.\n- \`arm\` (default): arms a new net — a CarePlan anchor plus the follow-up check-in that goes out to the patient after true silence.\n- \`resolve\`: stands an active net down because this turn's conversation addressed the guarded concern, or it is no longer clinically relevant. Requires \`plan_id\` + \`resolution_reasoning\`.\n- \`rearm\`: replaces the target's active net with this new definition (fresh silence window anchored now) — same required fields as \`arm\`. Use when new information changes the concern, window, or message.\n\n**Required inputs:** \`arm\`/\`rearm\`: \`concern\`, \`awaiting_characterization\`; at least one of \`related_case_id\` / \`related_cluster_id\`; \`patient_followup_reminder\` \`${STEP_SHAPE}\` (message + a positive \`fires_after_silence_minutes\` <= ${MAX_PATIENT_SILENCE_MIN}, plus \`window_reasoning\`: one sentence on why that wait). \`resolve\`: \`plan_id\` + \`resolution_reasoning\`.\n\n**When to call:** \`arm\` when, read against the symptom's CTCAE grade scale, a datum is still missing that could put the case at a grade above your current read. If everything needed to settle the severity is already known, do not arm. The rubric is the test, not how alarming the symptom sounds.\n- Missing information could move it from G1 to G2/G3 → arm, and wait for exactly that datum.\n- Everything needed to rule out the higher grades is already known → do not arm.\n- Serious AND fully assessed → escalate. A finished assessment needs a person, not a check-in.\n\nFind the gap in the \`grade_scale\` you graded against: which clause of the next grade up can you neither confirm nor rule out? Name that datum in \`awaiting_characterization\`, concrete enough that the next turn can tell whether their message addressed it. It usually arrives as an answer you asked for, the result of something you told the patient to do, or a threshold you named — but what qualifies it is that it moves the grade, not the form it takes. A pending detail that cannot change the grade (an administrative confirmation, a name, an appointment) is not a reason to arm. Set \`resolves_on\` to match: \`reply\` for an answer they owe, \`observation\` for an event their next message may not carry.\n\nWaiting on the care team does NOT satisfy this test — a non-routine escalation normally arms its own net, so team follow-up is not this tool's job. A \`routine\` escalation does not — it routes to the team's ticket lane and leaves no net, so on those turns the ceiling test decides on its own merits. Do not condition it on predicting whether the patient will go quiet: that cannot be known, and a net they turn out not to need never reaches them — it steps aside every time they write. \`resolve\` when the patient's message answered what the net was waiting to observe; \`rearm\` when the net's definition went stale.\n\n**When NOT to call:** \`arm\` on a target that already has an active plan (fails hard — use \`resolve\` or \`rearm\`) or whose case/cluster is closed/dissolved; \`resolve\` while the concern is still unobserved (closing the case retires its net automatically). This tool does NOT notify the care team — to reach a human, use the escalation tools.\n\n**Returns:** \`arm\`/\`rearm\` → \`{plan_id}\` (\`rearm\` adds \`superseded_plan_ids\` when it replaced a net). \`resolve\` → \`{plan_id}\`, with \`already_resolved: true\` when the net was retired earlier — an idempotent success, not an error. Fails hard with \`case_not_found_or_wrong_patient\` / \`cluster_not_found_or_wrong_patient\`, \`target_not_open\`, \`active_plan_already_exists_for_case\` / \`_for_cluster\` (arm only), \`plan_not_found_or_wrong_patient\`, \`store_failed_retry_next_turn\`, or \`stand_down_failed_retry_next_turn\`.\n\n**Side effects:** records the action on the turn trace; writes the FHIR CarePlan + patient CommunicationRequest + Provenance; arms or deactivates the dispatch row — the check-in goes out over WhatsApp only if the patient stayed silent through its window, and is pushed back each time they write. Nets also stand down when their case closes or cluster dissolves, and expire on their own after a bounded multiple of the check-in window.`,
15
+ description: `**Does:** Manages the contingency safety net guarding an open case or cluster, selected via \`action\`. A net is a single promise: if the patient goes quiet, you write to them again.\n- \`arm\` (default): arms a new net — a CarePlan anchor plus the follow-up check-in that goes out to the patient after true silence.\n- \`resolve\`: stands an active net down because this turn's conversation addressed the guarded concern, or it is no longer clinically relevant. Requires \`plan_id\` + \`resolution_reasoning\`.\n- \`rearm\`: replaces the target's active net with this new definition (fresh silence window anchored now) — same required fields as \`arm\`. Use when new information changes the concern, window, or message.\n\n**Required inputs:** \`arm\`/\`rearm\`: \`concern\`, \`awaiting_characterization\`; at least one of \`related_case_id\` / \`related_cluster_id\`; \`patient_followup_reminder\` \`${STEP_SHAPE}\` (message + a positive \`fires_after_silence_minutes\` <= ${MAX_PATIENT_SILENCE_MIN}, plus \`window_reasoning\`: one sentence on why that wait). \`resolve\`: \`plan_id\` + \`resolution_reasoning\`.\n\n**When to call:** \`arm\` when, read against the symptom's CTCAE grade scale, a datum is still missing that could put the case at a grade above your current read. If everything needed to settle the severity is already known, do not arm. The rubric is the test, not how alarming the symptom sounds.\n- Missing information could move it from G1 to G2/G3 → arm, and wait for exactly that datum.\n- Everything needed to rule out the higher grades is already known → do not arm.\n- Serious AND fully assessed → escalate. A finished assessment needs a person, not a check-in.\n\nFind the gap in the \`grade_scale\` you graded against: which clause of the next grade up can you neither confirm nor rule out? Name that datum in \`awaiting_characterization\`, concrete enough that the next turn can tell whether their message addressed it. It is an answer the patient still owes about the symptom as it stands now — what qualifies it is that it moves the grade, not the form it takes. A pending detail that cannot change the grade (an administrative confirmation, a name, an appointment) is not a reason to arm. Neither is a watch that starts after the patient already gave that datum — whether the symptom comes back, crosses a threshold you named, or eases with what you recommended: that is continuity, not uncertainty, so schedule a \`seguimiento\` with \`schedulePatientReminder\` instead, and do not re-arm a net you just resolved to cover it. Set \`resolves_on\` to match: \`reply\` for an answer they owe, \`observation\` for an event their next message may not carry.\n\nWaiting on the care team does NOT satisfy this test — a non-routine escalation normally arms its own net, so team follow-up is not this tool's job. A \`routine\` escalation does not — it routes to the team's ticket lane and leaves no net, so on those turns the ceiling test decides on its own merits. Do not condition it on predicting whether the patient will go quiet: that cannot be known, and a net they turn out not to need never reaches them — it steps aside every time they write. \`resolve\` when the patient's message answered what the net was waiting to observe; \`rearm\` when the net's definition went stale.\n\n**When NOT to call:** \`arm\` on a target that already has an active plan (fails hard — use \`resolve\` or \`rearm\`) or whose case/cluster is closed/dissolved; \`resolve\` while the concern is still unobserved (closing the case retires its net automatically). This tool does NOT notify the care team — to reach a human, use the escalation tools.\n\n**Returns:** \`arm\`/\`rearm\` → \`{plan_id}\` (\`rearm\` adds \`superseded_plan_ids\` when it replaced a net). \`resolve\` → \`{plan_id}\`, with \`already_resolved: true\` when the net was retired earlier — an idempotent success, not an error. Fails hard with \`case_not_found_or_wrong_patient\` / \`cluster_not_found_or_wrong_patient\`, \`target_not_open\`, \`active_plan_already_exists_for_case\` / \`_for_cluster\` (arm only), \`plan_not_found_or_wrong_patient\`, \`store_failed_retry_next_turn\`, or \`stand_down_failed_retry_next_turn\`.\n\n**Side effects:** records the action on the turn trace; writes the FHIR CarePlan + patient CommunicationRequest + Provenance; arms or deactivates the dispatch row — the check-in goes out over WhatsApp only if the patient stayed silent through its window, and is pushed back each time they write. Nets also stand down when their case closes or cluster dissolves, and expire on their own after a bounded multiple of the check-in window.`,
16
16
  strict: false,
17
17
  parameters: {
18
18
  type: 'object',
@@ -41,7 +41,7 @@ const definition = {
41
41
  },
42
42
  awaiting_characterization: {
43
43
  type: 'string',
44
- description: 'The datum from the term\'s grade_scale that would rule out a grade above your current read, concrete enough that the next turn can tell whether the patient\'s message addressed it — an answer they owe (\'temperature reading\'), the result of something you asked them to do (\'whether the pain eased after the analgesic\'), or a threshold you named (\'whether the fever goes past 38.5\'). It must be able to move the grade: an administrative confirmation, a name or an appointment does not qualify, and neither does a process confirmation about the care team or a restatement of the concern.',
44
+ description: 'The datum from the term\'s grade_scale that would rule out a grade above your current read, concrete enough that the next turn can tell whether the patient\'s message addressed it — an answer they still owe about the current presentation (\'temperature reading\', \'whether liquids also get stuck\'). It must be able to move the grade: an administrative confirmation, a name or an appointment does not qualify, and neither does a process confirmation about the care team or a restatement of the concern. A later recurrence, threshold or response to treatment once that datum is known is a seguimiento (schedulePatientReminder), not a net.',
45
45
  },
46
46
  resolves_on: {
47
47
  type: 'string',
@@ -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();
@@ -116,6 +117,7 @@ class EvalProvider {
116
117
  const promptVariables = {
117
118
  clinical_context: clinicalData?.clinicalContext ?? '',
118
119
  last_symptoms: clinicalData?.lastSymptoms ?? '',
120
+ nutrition_plan: clinicalData?.nutritionPlan ?? '',
119
121
  current_date: buildDateAnchor(),
120
122
  patient_memories: clinicalData?.patientMemories ?? '',
121
123
  conversation_summaries: clinicalData?.conversationSummaries ?? '',
@@ -132,6 +134,7 @@ class EvalProvider {
132
134
 
133
135
  async _resolvePrompt(fallbackPrompt, assistantId, thread, promptVariables) {
134
136
  let devContent;
137
+ let interpolatedVariables = [];
135
138
  let resolvedPromptId = assistantId;
136
139
  let presetToolIds = null;
137
140
  let presetModelConfig = null;
@@ -142,18 +145,21 @@ class EvalProvider {
142
145
  if (this.promptSource === 'airtable' && (presetId || assistantId)) {
143
146
  const {
144
147
  resolvedPrompt, promptId, presetToolIds: toolIds, modelConfig, presetId: composedPresetId,
148
+ interpolatedVariables: interpolated,
145
149
  } = await composePrompt({
146
150
  presetId,
147
151
  promptId: assistantId,
148
152
  variables: promptVariables,
149
153
  });
150
154
  devContent = resolvedPrompt;
155
+ interpolatedVariables = interpolated || [];
151
156
  resolvedPromptId = promptId || assistantId;
152
157
  presetToolIds = toolIds;
153
158
  presetModelConfig = modelConfig || null;
154
159
  resolvedPresetId = composedPresetId || resolvedPresetId;
155
160
  } else {
156
161
  devContent = fallbackPrompt;
162
+ interpolatedVariables = [...new Set([...(devContent || '').matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]))];
157
163
  devContent = devContent.replace(/\{\{(\w+)\}\}/g, (_, key) => promptVariables[key] ?? '');
158
164
  }
159
165
 
@@ -190,7 +196,7 @@ class EvalProvider {
190
196
  }
191
197
  }
192
198
 
193
- return { devContent, assistant, toolSchemas, resolvedPromptId, presetModelConfig, resolvedPresetId };
199
+ return { devContent, assistant, toolSchemas, resolvedPromptId, presetModelConfig, resolvedPresetId, interpolatedVariables };
194
200
  }
195
201
 
196
202
  _modelConfig(presetModelConfig) {
@@ -203,7 +209,7 @@ class EvalProvider {
203
209
 
204
210
  _buildRequest({
205
211
  devContent, messages, promptVariables, toolSchemas, assistant, assistantId, presetId,
206
- presetModelConfig = null,
212
+ presetModelConfig = null, interpolatedVariables = [],
207
213
  }) {
208
214
  const convertedMessages = this.provider._convertItemsToApiFormat(messages);
209
215
 
@@ -214,7 +220,11 @@ class EvalProvider {
214
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')}` }]
215
221
  : [];
216
222
 
217
- 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]);
218
228
  const isContextOnly = this.mode === 'context-only';
219
229
 
220
230
  const shouldUsePromptRegistry = !presetId && this.provider.supportsPromptRegistry && !!assistantId;
@@ -188,6 +188,13 @@ function gradeExtensions(assessment) {
188
188
  return out;
189
189
  }
190
190
 
191
+ // Triage PRO-CTCAE Conditions share the case identifier but never carry the case status.
192
+ function isManagedCaseCondition(resource) {
193
+ return resource.resourceType === 'Condition'
194
+ && Boolean(identifierValue(resource, SYMPTOM_CASE_SLUG))
195
+ && SYMPTOM_CASE_EXT.STATUS in extMap(resource);
196
+ }
197
+
191
198
  function fromFhir(resources, { patientId }) {
192
199
  const byReference = new Map();
193
200
  for (const resource of resources) {
@@ -196,9 +203,7 @@ function fromFhir(resources, { patientId }) {
196
203
  }
197
204
  }
198
205
  return resources
199
- .filter((resource) => resource.resourceType === 'Condition'
200
- && identifierValue(resource, SYMPTOM_CASE_SLUG)
201
- && SYMPTOM_CASE_EXT.STATUS in extMap(resource))
206
+ .filter(isManagedCaseCondition)
202
207
  .map((condition) => reconstruct(condition, byReference, patientId))
203
208
  .filter(Boolean);
204
209
  }
@@ -315,4 +320,5 @@ function reconstructCondition(condition, patientId, conditionId) {
315
320
  module.exports = {
316
321
  toFhir,
317
322
  fromFhir,
323
+ isManagedCaseCondition,
318
324
  };
@@ -1,7 +1,7 @@
1
1
  const { fhirId } = require('../helpers/fhirHelper');
2
2
  const { SYMPTOM_CASE_SLUG, SYMPTOM_ASSESSMENT_SLUG, SYMPTOM_GRADE_SLUG } = require('../constants/projectionSlugs');
3
3
  const { getFhirStore } = require('../stores/fhirStore');
4
- const { toFhir, fromFhir } = require('../projections/symptomCaseProjection');
4
+ const { toFhir, fromFhir, isManagedCaseCondition } = require('../projections/symptomCaseProjection');
5
5
  const { storeResources } = require('./fhirService');
6
6
 
7
7
  async function storeSymptomCase({ patientId, managedSymptom }) {
@@ -23,7 +23,8 @@ async function readSymptomCases({ patientId, ctcaeTerm = null, status = null, in
23
23
  const reconstructed = fromFhir([...conditions, ...impressions, ...grades], { patientId });
24
24
  const records = reconstructed.filter((managed) => !status || managed.condition.clinicalStatus === status);
25
25
  if (!includeDiagnostics) return records;
26
- return { records, omittedCount: Math.max(0, conditions.length - reconstructed.length) };
26
+ const managedCases = conditions.filter(isManagedCaseCondition).length;
27
+ return { records, omittedCount: Math.max(0, managedCases - reconstructed.length) };
27
28
  }
28
29
 
29
30
  module.exports = {
package/lib/index.d.ts CHANGED
@@ -625,6 +625,7 @@ declare module '@peopl-health/nexus' {
625
625
  export interface ClinicalDataResult {
626
626
  clinicalContext: string;
627
627
  lastSymptoms: string;
628
+ nutritionPlan?: string;
628
629
  patientMemories?: string;
629
630
  conversationSummaries?: string;
630
631
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.67.0-dev.11680",
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",