@peopl-health/nexus 6.4.0-dev.18675 → 6.4.0-dev.18775

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.
@@ -10,6 +10,7 @@ const {
10
10
  const { isBenchMode } = require('../utils/benchModeHelper');
11
11
 
12
12
  const { ensureWhatsAppFormat } = require('../helpers/twilioHelper');
13
+ const { templateMediaOf } = require('../helpers/mediaHelper');
13
14
  const { recordDeliveryAttempt } = require('../helpers/deliveryAttemptHelper');
14
15
  const { blockedSendResult, isOutboundAllowed, lastDigits } = require('../helpers/outboundAllowlistHelper');
15
16
 
@@ -301,6 +302,10 @@ class TwilioProvider extends MessageProvider {
301
302
  }
302
303
 
303
304
  async renderTemplate(contentSid, variables) {
305
+ return (await this.renderTemplateContent(contentSid, variables))?.body ?? null;
306
+ }
307
+
308
+ async renderTemplateContent(contentSid, variables) {
304
309
  try {
305
310
  if (!contentSid) return null;
306
311
 
@@ -311,18 +316,21 @@ class TwilioProvider extends MessageProvider {
311
316
  return null;
312
317
  }
313
318
 
319
+ const [mediaUrl] = template.types['twilio/media']?.media || template.types['twilio/card']?.media || [];
320
+ const media = mediaUrl ? templateMediaOf(this._renderTemplateWithVariables(mediaUrl, variables)) : null;
321
+
314
322
  let textContent = this._extractTextFromTemplate(template);
315
323
 
316
324
  if (!textContent) {
317
325
  logger.warn('[TwilioProvider] No text content found in template:', contentSid);
318
- return null;
326
+ return { body: null, media };
319
327
  }
320
328
 
321
329
  if (variables && Object.keys(variables).length > 0) {
322
- return this._renderTemplateWithVariables(textContent, variables);
330
+ return { body: this._renderTemplateWithVariables(textContent, variables), media };
323
331
  }
324
332
 
325
- return textContent.trim();
333
+ return { body: textContent.trim(), media };
326
334
  } catch (error) {
327
335
  logger.error('[TwilioProvider] Error rendering template:', error.message);
328
336
  return null;
@@ -356,7 +364,11 @@ class TwilioProvider extends MessageProvider {
356
364
  }
357
365
 
358
366
  if (types['twilio/flows']) return types['twilio/flows'].body || '';
359
- if (types['twilio/media']) return types['twilio/media'].caption || '';
367
+ if (types['twilio/media']) return types['twilio/media'].body || '';
368
+ if (types['twilio/card']) {
369
+ const card = types['twilio/card'];
370
+ return [card.title, card.body, card.subtitle].filter(Boolean).join('\n\n');
371
+ }
360
372
 
361
373
  const fallback = Object.values(types).find(t => t?.body);
362
374
  return fallback?.body || '';
@@ -93,6 +93,9 @@ function planOf(input) {
93
93
  const needsPrescriber = (plan) => plan.items.length > 0 || plan.studies.length > 0;
94
94
 
95
95
  const daysText = (days) => (days === 1 ? '1 día' : `${days} días`);
96
+ const itemLine = (item) => `${[item.drug, item.strength].filter(Boolean).join(' ')}: ${item.dose} ${item.frequency} por ${daysText(item.days)}.`;
97
+ const studyLine = (study) => `${study.name}${study.instructions ? `: ${study.instructions}` : ''}.`;
98
+ const PRESCRIPTION_LINE = 'Justo después de tu mensaje le llega aparte la receta del médico en PDF; dile que la va a recibir.';
96
99
 
97
100
  const FOLLOW_UP_LINES = {
98
101
  check_in: (hours) => `Seguimiento: le escribiremos en unas ${hours} horas para ver cómo va.`,
@@ -103,14 +106,36 @@ const FOLLOW_UP_LINES = {
103
106
  function planInstructions(plan) {
104
107
  const lines = ['Transmítele al paciente lo que indicó el médico, con tus palabras: no cambies dosis, frecuencia ni días, y no agregues otros medicamentos ni estudios.'];
105
108
  if (plan.items.length) {
106
- lines.push('Medicamentos:', ...plan.items.map((item) => `- ${[item.drug, item.strength].filter(Boolean).join(' ')}: ${item.dose} ${item.frequency} por ${daysText(item.days)}.`));
109
+ lines.push('Medicamentos:', ...plan.items.map((item) => `- ${itemLine(item)}`));
107
110
  }
108
111
  if (plan.studies.length) {
109
- lines.push('Estudios:', ...plan.studies.map((study) => `- ${study.name}${study.instructions ? `: ${study.instructions}` : ''}.`));
112
+ lines.push('Estudios:', ...plan.studies.map((study) => `- ${studyLine(study)}`));
110
113
  }
111
114
  if (plan.homeCare.length) lines.push('Cuidados en casa:', ...plan.homeCare.map((entry) => `- ${entry}`));
112
115
  if (plan.note) lines.push(`Nota del médico: «${plan.note}».`);
113
116
  if (plan.followUp.kind !== 'none') lines.push(FOLLOW_UP_LINES[plan.followUp.kind](plan.followUp.hours));
117
+ if (needsPrescriber(plan)) lines.push(PRESCRIPTION_LINE);
118
+ return lines.join('\n');
119
+ }
120
+
121
+ // An edit can drop the receta line; the PDF goes out anyway, so the assistant is still told.
122
+ function assistantInstructionsOf(plan) {
123
+ if (!needsPrescriber(plan) || plan.instructions.includes(PRESCRIPTION_LINE)) return plan.instructions;
124
+ return `${plan.instructions}\n${PRESCRIPTION_LINE}`;
125
+ }
126
+
127
+ // What the team's case shows as the indication, in the doctor's terms rather than the assistant's.
128
+ function planSummary(plan) {
129
+ const lines = [...plan.items.map(itemLine), ...plan.studies.map((study) => `Estudio: ${studyLine(study)}`)];
130
+ if (plan.homeCare.length) lines.push(`Cuidados en casa: ${plan.homeCare.join('; ')}.`);
131
+ if (plan.note) lines.push(`Nota: ${plan.note}`);
132
+ return lines.join('\n');
133
+ }
134
+
135
+ // The receta the Airtable automations turn into the doctor's PDF.
136
+ function prescriptionText(plan) {
137
+ const lines = plan.items.map(itemLine);
138
+ if (plan.studies.length) lines.push('Estudios:', ...plan.studies.map(studyLine));
114
139
  return lines.join('\n');
115
140
  }
116
141
 
@@ -158,8 +183,11 @@ function proposedPlanOf(draft) {
158
183
 
159
184
  module.exports = {
160
185
  approvedInstructions,
186
+ assistantInstructionsOf,
161
187
  needsPrescriber,
162
188
  planInstructions,
163
189
  planOf,
190
+ planSummary,
191
+ prescriptionText,
164
192
  proposedPlanOf,
165
193
  };
@@ -342,7 +342,7 @@ class BaseLLMProvider {
342
342
  '- 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. Si en este turno le indicaste un medicamento, una medida de autocuidado o que contacte a alguien (dentista, su médico, urgencias), o le transmitiste un tratamiento que indicó el equipo, decide el seguimiento en este mismo turno: si no hay ya un seguimiento activo que lo cubra, programa uno a 24–48 h (una red abierta no lo impide: la red espera su dato y el seguimiento pregunta por lo indicado) con utility_basis agreed_action: si fue un medicamento o una medida, para saber si funcionó; si fue una cita o un estudio, para saber si pudo agendarlo o si ya fue y cuándo. Pedirle «avísame cómo te va» no sustituye ese seguimiento. No lo programes por inactividad sola. Si la paciente vuelve a escribir antes, el seguimiento sigue pendiente y lo verás en «Seguimientos ya programados»: mantenlo, actualízalo o cancélalo según lo que cambió.',
343
343
  '- 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.',
344
344
  '- 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.',
345
- '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 o porque la preocupación quedó atendida, la vigilancia que queda es un seguimiento, no una red rearmada ni un cierre del tema. Una indicación del equipo de agendar o hacerse una visita o un estudio no resuelve por sí sola una red cuyo dato sigue faltando: la red se mantiene tal como está y, además, programa el seguimiento sobre la visita o el estudio: una red abierta no lo detiene.',
345
+ '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 o porque la preocupación quedó atendida, la vigilancia que queda es un seguimiento, no una red rearmada ni un cierre del tema. Una indicación del equipo de agendar o hacerse una visita o un estudio no resuelve por sí sola una red cuyo dato sigue faltando: la red se mantiene tal como está y, además, programa el seguimiento sobre la visita o el estudio: una red abierta no lo detiene. Lo mismo vale para un medicamento o una receta que indicó el equipo: el seguimiento sobre si lo consiguió o pasó por ella no espera a que se cierre la red.',
346
346
  ].join('\n'),
347
347
  }];
348
348
  }
@@ -445,19 +445,20 @@ class BaseLLMProvider {
445
445
  parts.push(
446
446
  '', '## Check-ins que ya le llegaron en las últimas 48 h', ...(sent.length ? sent : ['- ninguno']),
447
447
  '',
448
- `Cierra el turno con \`${OUTREACH_DECISION_TOOL}\`. Antes, revisa la conversación desde que quedó pendiente:`,
448
+ `Cierra el turno con \`${OUTREACH_DECISION_TOOL}\`. Antes, compara lo que querías saber con todo lo que dijo sobre este tema, incluido lo que te contó justo antes de que quedara pendiente:`,
449
449
  '- Este check-in es el seguimiento que está venciendo ahora, aunque lo veas programado unos minutos más tarde: ya no está pendiente y no se puede actualizar. Si decides esperar, programa uno nuevo.',
450
- '- Si ya lo respondió, a ti o al equipo, no se lo vuelvas a preguntar. Si es una red de contingencia, resuélvela con setContingencyPlan y registra hold con hold_reason answered_since_armed.',
450
+ '- Si ya te dio lo que querías saber, a ti o al equipo, no se lo vuelvas a preguntar como si faltara. Si es una red de contingencia y ya no hace falta revisarlo, resuélvela con setContingencyPlan y registra hold con hold_reason answered_since_armed. Si todavía importa saber si empeoró, deja la red como está, empieza por lo que te dijo y pregunta sólo si cambió («Me dijiste que…» o «Me dijo que…», según cómo le hables). Si sólo te dio parte del dato, lo que falta sigue pendiente.',
451
451
  '- Si tu último mensaje ya le hace esta pregunta o la cubre, y hoy estuvo en la conversación y dejó de contestar hace menos de 6 h, no la repitas hoy: programa la siguiente vez o resuélvela si ya no hace falta.',
452
452
  '- Si sólo quedó una pregunta tuya sin respuesta y lleva 8 h o más en silencio, retomarla es justo para lo que existe este check-in: escríbele, salvo que sea de noche.',
453
453
  '- Fuera de esos casos, decide la siguiente vez según el tipo de seguimiento y lo que ha ido contando: lo que puede empeorar en horas se pregunta pronto; un síntoma estable o ya en manejo puede esperar días. Si ya le llegó otro check-in hoy y esto puede esperar, no le escribas hoy: programa con schedulePatientReminder (reminder_type seguimiento, con la utility_basis de lo que quedó pendiente, nunca entre 07:30 y 11:00) la hora que corresponda y registra hold con hold_reason rescheduled_for_cadence y el mismo next_check_at.',
454
- '- Si la red viene de una escalación, su urgencia marca el plazo del equipo para revisarla, no una razón para volver a escribirle.',
454
+ '- Si la red viene de una escalación, su urgencia marca el plazo del equipo para revisarla, no una razón para volver a escribirle. Esta red espera cómo evolucionó el síntoma: lo que reportó al abrirla no lo responde.',
455
455
  '- Este check-in es el seguimiento que vencía: no cuenta como otro pendiente. Si un seguimiento distinto en la lista de pendientes, o una red activa, ya va a preguntar esto, no programes otro: registra hold con next_check_at null y hold_reason contingency_already_armed.',
456
456
  '- Si le escribes, un solo mensaje con una sola pregunta; si otro tema pendiente se responde con esa misma pregunta, intégralo y resuelve las redes que ese mensaje ya cubre. Registra en utility_basis lo que contó o acordó.',
457
+ '- Si tu último mensaje dejó una pregunta distinta sin contestar que no cambia el grado ni la urgencia (por ejemplo, cuándo pasa por una receta que indicó el equipo), no la pierdas: le escribas o no, antes de cerrar el turno y de DeliverPatientMessage programa esa pregunta con schedulePatientReminder (reminder_type seguimiento, con la utility_basis que corresponda, al menos 4 h después y nunca entre 07:30 y 11:00). Si una red activa ya la espera, no programes nada. Si ya hay un seguimiento pendiente, no crees otro ni le sumes preguntas. Si en este turno reprogramas este check-in, sólo cabe un seguimiento: programa el de este check-in.',
457
458
  exchange?.sessionOpen === false
458
459
  ? 'Si escribes, tu texto llega dentro de una plantilla que ya empieza con «Hola {su nombre},»: no saludes ni escribas su nombre; entra directo a lo que quieres saber.'
459
460
  : 'Si escribes, le llega como un mensaje normal. Si llevan horas sin hablar, ábrelo con un saludo breve y natural según la hora del día, con el nombre de quien lleva la conversación si lo conoces (a un cuidador cuyo nombre no conoces, sin nombre); luego entra a lo que quieres saber.',
460
- 'En un seguimiento que programaste es la primera vez que se lo preguntas: no digas que ya se lo habías preguntado. En una red, si tu mensaje anterior ya se lo preguntó, dilo en pocas palabras («te había preguntado por…» o «le había preguntado por…», según cómo le hables). Esa única pregunta va por lo que sigue faltando de lo que esperabas saber; si lo que querías preguntarle trae varias, elige la que más importa hoy. Háblale directo a quien lleva la conversación, de tú o de usted, como en la conversación; nunca hables en tercera persona de quien te está leyendo.',
461
+ 'En un seguimiento que programaste es la primera vez que se lo preguntas: no digas que ya se lo habías preguntado. En una red, sólo si tu pregunta sigue sin respuesta y tu último mensaje fue esa pregunta, dilo en pocas palabras («te había preguntado por…» o «le había preguntado por…», según cómo le hables). Si ya te respondió, no digas que se lo habías preguntado: retoma lo que te dijo. Esa única pregunta va por lo que sigue faltando de lo que esperabas saber o por si cambió lo que ya te dijo; si lo que querías preguntarle trae varias, elige la que más importa hoy. Háblale directo a quien lleva la conversación, de tú o de usted, como en la conversación; nunca hables en tercera persona de quien te está leyendo.',
461
462
  'No le comentes como novedad algo que no te dijo en el chat: sus respuestas del cuestionario las conoces tú, no son algo que te acaba de contar. No repitas lo que ya le dijiste (por ejemplo, que el equipo ya tiene el aviso) ni des por hecho detalles que no te dio.',
462
463
  'Breve, sin listas de señales de alarma y sin mencionar recordatorios, planes ni seguimientos programados.',
463
464
  );
@@ -521,7 +522,7 @@ class BaseLLMProvider {
521
522
  const dueNet = outreachBrief?.checkIn?.planId || null;
522
523
  const waiting = active.map(({ carePlan: plan = {} } = {}) => {
523
524
  let fired = '';
524
- if (dueNet && plan.planId === dueNet) fired = ' Su check-in es el de este turno: todavía no le ha llegado.';
525
+ if (dueNet && plan.planId === dueNet) fired = ' Su check-in es el de este turno: todavía no le ha llegado. Decide esta red con las reglas del check-in, no con la decisión de abajo.';
525
526
  else if (plan.status === 'fired_reminder') fired = ' Su check-in ya se envió.';
526
527
  return `- ${plan.planId} (armada ${localFireTime(plan.createdAt)}) espera: ${sanitizeInlineText(plan.awaitingCharacterization)}.${fired}`;
527
528
  });
@@ -5,7 +5,7 @@ const { logger } = require('../../utils/logger');
5
5
 
6
6
  const definition = {
7
7
  name: 'getPatientRiskAssessment',
8
- description: '**Does:** Returns the patient\'s latest full risk assessment and a deterministic `turn_focus` for the symptoms extracted in this turn. Read-only; no model call.\n\n**Required inputs:** none.\n\n**When to call:** immediately before `submitRoutingDecision` only when `getRouterContextBundle.sections_returned` does not contain `risk`, including a bundle result with `_diagnostics.risk_error`. The routine load is the bundle\'s mandatory `risk` section, which resolves the same assessment through the same code path.\n\n**When NOT to call:** when `getRouterContextBundle` already returned `risk` this turn — it is the same assessment, and a second read cannot change it. Nor again in the same turn after a `not_generated` result — generation runs between turns and the result cannot change mid-turn.\n\n**`turn_focus.recent_escalations` — what you already sent the team.** Every escalation dispatched for this patient in the last 14 days, newest first, with `escalated_at`, `days_since`, `urgency`, `reason` and the `channels` used. Read it before `reportMedicalEscalation`. It records only what was SENT; where the team is with it is in `team_cases`. A complaint unchanged since an escalation a day ago rarely needs a second dispatch; one still uncontrolled days after its escalation needs one at a HIGHER urgency, and `urgency_justification` should say which of the two this is. Empty means nothing was escalated in the window.\n\n**`turn_focus.team_cases` — where the care team is with each case.** Every case the team still holds, and those it closed in the last 14 days, newest first: `status`, `is_open`, `level`, `opened_at`, `days_since`, `doctor_decision` and `case_ids` (the symptom cases it covers).\n- `Por revisar`, `En evaluación`, `Con el médico`, `Indicación por enviar`, `Requiere valoración`: the team has it and has not answered yet. Tell the patient it is with the team, without promising a time. A new escalation is for a new symptom, a higher grade, a new red flag, or a symptom that persists or worsens while it waits (send it as an update).\n- `Esperando paciente`, `Indicaciones dadas`, `En vigilancia`: the team already answered (an indication on its way to the patient, given, or under watch). This comes first: ask how the indication is going before escalating again.\n- `Cerrado`, `No requiere`: the team closed it. A new complaint is a new decision.\n`doctor_decision` is internal (`approved`, `edited_approved`, `monitor`, `needs_intervention` = the doctor asked for an in-person evaluation, `returned`, `direct_chat`): use it to understand the case, never quote it. `team_cases` never says whether the problem resolved; ask the patient. An escalation in `recent_escalations` with no case here has an unknown team status, not an absent one.\n\n**Use `turn_focus.symptom_history` before grading or escalating.** It carries what no other section does: how this patient compares to their own past. `worst_grade` and `worst_reported_at` are the worst they have ever been recorded, `at_personal_worst` says whether they are back at it, and `days_since_last_report` says how stale `last_grade` is. (The current grade and trajectory are already in `landscape.active_cases` — do not expect them repeated here.) Escalate on the comparison, not on severity alone: a patient at their personal worst is a different decision from one below it, and a grade unchanged for weeks is different from the same grade reported yesterday. When you ask about it, anchor the question in the value and the date so the patient can compare. Empty means no graded case on record. \n\n**How to use:** read `turn_focus.current_concerns`, then each `relevant_risks[].checks_now` and `escalation_threshold`. It preserves concurrent symptoms. Any `selection` containing `fallback` marks a precaution to check, not a confirmed explanation. When `selection` is `no_current_concerns` the patient reported no current symptom this turn. Use `profile` only for supporting detail.\n\n**`profile` is a forecast, not history:** `predicted_aes[].minimum_symptom_set`, `disease_risks[].warning_symptoms` and every other profile list name symptoms to watch for, none of which the patient has reported. Never tell the patient a profile symptom appears in their report, and never attach a CTCAE grade to one. Reported symptoms come only from `turn_focus.current_concerns`.\n\n**Returns:** `status` — `final` when an assessment exists and carries findings, `no_signal` when one exists but the synthesis found nothing usable, or `not_generated`; `turn_focus`, always present; and the full `profile` (`predicted_aes[]`, `disease_risks[]`, `comorbidity_amplifiers[]`, lifecycle and missing-information fields). `profile` is null when not yet generated.',
8
+ description: '**Does:** Returns the patient\'s latest full risk assessment and a deterministic `turn_focus` for the symptoms extracted in this turn. Read-only; no model call.\n\n**Required inputs:** none.\n\n**When to call:** immediately before `submitRoutingDecision` only when `getRouterContextBundle.sections_returned` does not contain `risk`, including a bundle result with `_diagnostics.risk_error`. The routine load is the bundle\'s mandatory `risk` section, which resolves the same assessment through the same code path.\n\n**When NOT to call:** when `getRouterContextBundle` already returned `risk` this turn — it is the same assessment, and a second read cannot change it. Nor again in the same turn after a `not_generated` result — generation runs between turns and the result cannot change mid-turn.\n\n**`turn_focus.recent_escalations` — what you already sent the team.** Every escalation dispatched for this patient in the last 14 days, newest first, with `escalated_at`, `days_since`, `urgency`, `reason` and the `channels` used. Read it before `reportMedicalEscalation`. It records only what was SENT; where the team is with it is in `team_cases`. A complaint unchanged since an escalation a day ago rarely needs a second dispatch; one still uncontrolled days after its escalation needs one at a HIGHER urgency, and `urgency_justification` should say which of the two this is. Empty means nothing was escalated in the window.\n\n**`turn_focus.team_cases` — where the care team is with each case.** Every case the team still holds, and those it closed in the last 14 days, newest first: `status`, `is_open`, `level`, `opened_at`, `days_since`, `doctor_decision` and `case_ids` (the symptom cases it covers).\n- `Por revisar`, `En evaluación`, `Con el médico`, `Indicación por enviar`, `Requiere valoración`: the team has it and has not answered yet. Tell the patient it is with the team, without promising a time. A new escalation is for a new symptom, a higher grade, a new red flag, or a symptom that persists or worsens while it waits (send it as an update).\n- `Esperando paciente`, `Indicaciones dadas`, `En vigilancia`: the team already answered (an indication on its way to the patient, given, or under watch). This comes first: ask how the indication is going before escalating again.\n- `Cerrado`, `No requiere`: the team closed it. A new complaint is a new decision.\n`doctor_decision` is internal (`approved`, `edited_approved`, `plan_approved` = the doctor gave a structured plan, `monitor`, `needs_intervention` = the doctor asked for an in-person evaluation, `returned`, `direct_chat`): use it to understand the case, never quote it. `team_cases` never says whether the problem resolved; ask the patient. An escalation in `recent_escalations` with no case here has an unknown team status, not an absent one.\n\n**Use `turn_focus.symptom_history` before grading or escalating.** It carries what no other section does: how this patient compares to their own past. `worst_grade` and `worst_reported_at` are the worst they have ever been recorded, `at_personal_worst` says whether they are back at it, and `days_since_last_report` says how stale `last_grade` is. (The current grade and trajectory are already in `landscape.active_cases` — do not expect them repeated here.) Escalate on the comparison, not on severity alone: a patient at their personal worst is a different decision from one below it, and a grade unchanged for weeks is different from the same grade reported yesterday. When you ask about it, anchor the question in the value and the date so the patient can compare. Empty means no graded case on record. \n\n**How to use:** read `turn_focus.current_concerns`, then each `relevant_risks[].checks_now` and `escalation_threshold`. It preserves concurrent symptoms. Any `selection` containing `fallback` marks a precaution to check, not a confirmed explanation. When `selection` is `no_current_concerns` the patient reported no current symptom this turn. Use `profile` only for supporting detail.\n\n**`profile` is a forecast, not history:** `predicted_aes[].minimum_symptom_set`, `disease_risks[].warning_symptoms` and every other profile list name symptoms to watch for, none of which the patient has reported. Never tell the patient a profile symptom appears in their report, and never attach a CTCAE grade to one. Reported symptoms come only from `turn_focus.current_concerns`.\n\n**Returns:** `status` — `final` when an assessment exists and carries findings, `no_signal` when one exists but the synthesis found nothing usable, or `not_generated`; `turn_focus`, always present; and the full `profile` (`predicted_aes[]`, `disease_risks[]`, `comorbidity_amplifiers[]`, lifecycle and missing-information fields). `profile` is null when not yet generated.',
9
9
  strict: false,
10
10
  replayScope: 'turn',
11
11
  parameters: {
@@ -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 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 each message they send pushes the check-in back, but it still goes out once they fall silent unless you resolve the net — so resolve it as soon as they give what it waits for. \`resolve\` when the patient's message answered what the net was waiting to observe; \`rearm\` when the net's definition went stale. Resolve a net only when its awaited datum arrived or the concern it guards was addressed. A care-team indication to book or do a visit or test does not by itself address it: if the net still awaits a grade-changing observation, keep the net as it is and also schedule the seguimiento about the visit or test — an open net does not hold it back. When a net is resolved and the team gave an indication, what remains open is the indication: schedule a \`seguimiento\` with \`schedulePatientReminder\` (\`utility_basis: agreed_action\`) at 24–48 h instead of treating the topic as closed — for a medication or measure, to learn whether it works; for a visit or test, to learn whether it was booked or done and when (ask about the result later, once it has happened).\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 each message they send pushes the check-in back, but it still goes out once they fall silent unless you resolve the net — so resolve it as soon as they give what it waits for. \`resolve\` when the patient's message answered what the net was waiting to observe; \`rearm\` when the net's definition went stale. Resolve a net only when its awaited datum arrived or the concern it guards was addressed. A care-team indication to book or do a visit or test does not by itself address it: if the net still awaits a grade-changing observation, keep the net as it is and also schedule the seguimiento about the visit or test — an open net does not hold it back. The same goes for a medication or prescription the team indicated: schedule the seguimiento about getting it without waiting for the net to close. When a net is resolved and the team gave an indication, what remains open is the indication: schedule a \`seguimiento\` with \`schedulePatientReminder\` (\`utility_basis: agreed_action\`) at 24–48 h instead of treating the topic as closed — for a medication or measure, to learn whether it works; for a visit or test, to learn whether it was booked or done and when (ask about the result later, once it has happened).\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',
@@ -54,8 +54,8 @@ const openEscalationReviewController = async (req, res) => {
54
54
 
55
55
  const getEscalationReviewController = async (req, res) => {
56
56
  try {
57
- const { review, isApprover, responseDeadline, proposedPlan } = await getEscalationReviewById({ id: req.params.id, requestedBy: req.query.requestedBy });
58
- res.status(200).json({ success: true, review: withCaseStatus(review), isApprover, responseDeadline, proposedPlan });
57
+ const { review, isApprover, responseDeadline, proposedPlan, prescribers } = await getEscalationReviewById({ id: req.params.id, requestedBy: req.query.requestedBy });
58
+ res.status(200).json({ success: true, review: withCaseStatus(review), isApprover, responseDeadline, proposedPlan, prescribers });
59
59
  } catch (error) {
60
60
  respondWithError(res, error, 'reading escalation review', { reviewId: req.params?.id });
61
61
  }
@@ -187,6 +187,7 @@ const recordEscalationReviewDecisionController = async (req, res) => {
187
187
  finalText = null,
188
188
  plan = null,
189
189
  instructions = null,
190
+ prescriberEmail = null,
190
191
  comment = null,
191
192
  doctorName = null,
192
193
  doctorReply = null,
@@ -202,6 +203,7 @@ const recordEscalationReviewDecisionController = async (req, res) => {
202
203
  finalText,
203
204
  plan,
204
205
  instructions,
206
+ prescriberEmail,
205
207
  comment,
206
208
  doctorName,
207
209
  doctorReply,
@@ -2,13 +2,7 @@ const { downloadFileFromS3 } = require('../config/awsConfig');
2
2
  const runtimeConfig = require('../config/runtimeConfig');
3
3
 
4
4
  const { logger } = require('../utils/logger');
5
-
6
- const MIME_MAP = {
7
- jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp',
8
- pdf: 'application/pdf',
9
- mp3: 'audio/mp3', wav: 'audio/wav', ogg: 'audio/ogg', m4a: 'audio/m4a',
10
- mp4: 'video/mp4', webm: 'video/webm', '3gp': 'video/3gp'
11
- };
5
+ const { EXTENSION_MIME_TYPES } = require('../utils/mediaValidator');
12
6
 
13
7
  const getMediaController = async (req, res) => {
14
8
  const bucketName = runtimeConfig.get('AWS_S3_BUCKET_NAME');
@@ -48,7 +42,7 @@ const getMediaController = async (req, res) => {
48
42
  let contentType = fileData.ContentType || 'application/octet-stream';
49
43
  if (contentType === 'application/octet-stream') {
50
44
  const ext = mediaKey.split('.').pop().toLowerCase();
51
- contentType = MIME_MAP[ext] || contentType;
45
+ contentType = EXTENSION_MIME_TYPES[ext] || contentType;
52
46
  }
53
47
 
54
48
  res.setHeader('Cache-Control', 'public, max-age=86400');
@@ -1,17 +1,11 @@
1
- const { Monitoreo_ID } = require('../config/airtableConfig');
2
-
3
1
  const { logger } = require('../utils/logger');
4
2
 
5
- const { ensureWhatsAppFormat } = require('../helpers/twilioHelper');
6
-
7
- const { addLinkedRecord } = require('../services/airtableService');
3
+ const { createPrescription } = require('../services/prescriptionService');
8
4
 
9
5
  const RECORD_ID_RX = /^rec[A-Za-z0-9]{14}$/;
10
6
 
11
- // physicianId links the receta to a physicians row; the Airtable automation picks the PDF template from that physician's specialty.
12
7
  const createPrescriptionController = async (req, res) => {
13
- const { prescription, triggeredBy, creationSource = 'other', physicianId } = req.body;
14
- let code = req.body.code;
8
+ const { code, prescription, triggeredBy, creationSource = 'other', physicianId } = req.body;
15
9
 
16
10
  if (!code) return res.status(400).json({ success: false, error: 'Code is required' });
17
11
  if (!prescription) return res.status(400).json({ success: false, error: 'Prescription is required' });
@@ -21,28 +15,10 @@ const createPrescriptionController = async (req, res) => {
21
15
  }
22
16
 
23
17
  try {
24
- code = ensureWhatsAppFormat(code);
25
-
26
- const record = await addLinkedRecord(
27
- Monitoreo_ID,
28
- 'patient_prescriptions',
29
- {
30
- prescription,
31
- author_email: triggeredBy,
32
- creation_source: creationSource,
33
- created: new Date().toISOString(),
34
- ...(physicianId && { physician: [physicianId] })
35
- },
36
- {
37
- referenceTable: 'estado_general',
38
- referenceFilter: `{whatsapp_id}='${code}'`,
39
- linkFieldName: 'patient_id'
40
- },
41
- code
42
- );
18
+ const recordId = await createPrescription({ code, prescription, authorEmail: triggeredBy, creationSource, physicianId });
43
19
 
44
20
  logger.info('[PrescriptionController] Prescription created', { code, physicianId });
45
- res.status(201).json({ success: true, message: 'Prescription created', recordId: record?.id || null });
21
+ res.status(201).json({ success: true, message: 'Prescription created', recordId });
46
22
  } catch (error) {
47
23
  logger.error('[PrescriptionController] Error creating prescription', { error: error.message, code });
48
24
  res.status(500).json({ success: false, error: 'Failed to create prescription' });
@@ -187,8 +187,7 @@ class NexusMessaging {
187
187
 
188
188
  this.scheduledMessageJob = new ScheduledMessageJob({
189
189
  queueAdapter: this.queueAdapter,
190
- sendMessage: this.sendMessage.bind(this),
191
- requireProvider: () => this.getProvider()
190
+ sendMessage: this.sendMessage.bind(this)
192
191
  });
193
192
 
194
193
  this.templateApprovalJob = new TemplateApprovalJob({
@@ -433,9 +432,13 @@ class NexusMessaging {
433
432
 
434
433
  if (messageData.fileUrl && this._providerStores) await prepareMessageMedia(messageData);
435
434
 
436
- if (messageData.contentSid && !messageData.body && this.provider.renderTemplate) {
437
- const rendered = await this.provider.renderTemplate(messageData.contentSid, messageData.variables);
438
- if (rendered) messageData.body = rendered;
435
+ const storesTemplateMedia = Boolean(messageData.contentSid && !messageData.fileUrl && !messageData.parentMessageId && this.provider.renderTemplateContent);
436
+ if (messageData.contentSid && (!messageData.body || storesTemplateMedia)) {
437
+ const content = this.provider.renderTemplateContent
438
+ ? await this.provider.renderTemplateContent(messageData.contentSid, messageData.variables)
439
+ : { body: await this.provider.renderTemplate?.(messageData.contentSid, messageData.variables) };
440
+ if (!messageData.body && content?.body) messageData.body = content.body;
441
+ if (storesTemplateMedia && content?.media) messageData.media = { ...content.media, caption: messageData.body || '' };
439
442
  }
440
443
 
441
444
  if (this._needsTemplateRoute(messageData) && !(await isWithin24HourWindow(messageData.code, { contactCode: messageData.contactCode }))) {
@@ -8,8 +8,9 @@ const { uploadBufferToS3, generatePresignedUrl } = require('../config/awsConfig'
8
8
  const runtimeConfig = require('../config/runtimeConfig');
9
9
 
10
10
  const { sanitizeMediaFilename } = require('../utils/sanitizerUtils.js');
11
- const { validateMedia, getMediaType, MEDIA_LIMITS, STICKER_DIMENSIONS } = require('../utils/mediaValidator');
11
+ const { validateMedia, getMediaType, EXTENSION_MIME_TYPES, MEDIA_LIMITS, STICKER_DIMENSIONS } = require('../utils/mediaValidator');
12
12
  const { logger } = require('../utils/logger');
13
+ const { ownS3Location } = require('../utils/s3Utils');
13
14
 
14
15
  const { ensureWhatsAppFormat } = require('../helpers/twilioHelper');
15
16
 
@@ -177,9 +178,18 @@ function getFileExtension(contentType) {
177
178
  return mimeToExt[contentType] || 'bin';
178
179
  }
179
180
 
181
+ function templateMediaOf(mediaUrl) {
182
+ const location = ownS3Location(mediaUrl);
183
+ if (!location?.key) return null;
184
+ const fileName = path.basename(location.key);
185
+ const contentType = EXTENSION_MIME_TYPES[path.extname(fileName).slice(1)] || 'application/octet-stream';
186
+ return { ...location, contentType, mediaType: getMediaType(contentType), fileName };
187
+ }
188
+
180
189
  module.exports = {
181
190
  uploadMediaToS3,
182
191
  saveMediaToLocal,
183
192
  prepareMessageMedia,
184
- getFileExtension
193
+ getFileExtension,
194
+ templateMediaOf
185
195
  };
package/lib/index.d.ts CHANGED
@@ -274,7 +274,7 @@ declare module '@peopl-health/nexus' {
274
274
  initializeLlmMonitor(): Promise<{ enabled: boolean; error?: string } | undefined>;
275
275
  getWorkflowRunner(): WorkflowRunner;
276
276
  processInstruction(code: string, instruction: string, role?: string, options?: { triggeredBy?: string; beforeRun?: () => Promise<string | null | undefined> | string | null | undefined; dedupeWithinMs?: number }): Promise<{ outcome: string | null; reason?: string; error?: string }>;
277
- processSystemMessage(code: string, messages: string | string[], role?: string, options?: { triggeredBy?: string; reply?: boolean }): Promise<void>;
277
+ processSystemMessage(code: string, messages: string | string[], role?: string, options?: { triggeredBy?: string; reply?: boolean }): Promise<{ delivery: Record<string, unknown> | null; sendThrew: boolean } | undefined>;
278
278
  processOutreach(code: string, options?: { brief?: OutreachBrief | null; triggeredBy?: string | null; reason?: string | null; firstName?: string | null; dryRun?: boolean; replyToContact?: boolean; allowFollowUp?: boolean }): Promise<OutreachDecision>;
279
279
  processCheckInDue(code: string, options: { reminderId: string; type: 'contingencia' | 'seguimiento'; question?: string; firstName?: string | null }): Promise<CheckInDueResult>;
280
280
  processScheduledFollowUp(code: string, options: { reminderId: string; firstName: string; now?: Date }): Promise<ScheduledFollowUpResult>;
@@ -23,13 +23,12 @@ const CANCELLED_SEND_MESSAGES = {
23
23
  };
24
24
 
25
25
  class ScheduledMessageJob extends BaseJob {
26
- constructor({ queueAdapter, sendMessage, requireProvider = null } = {}) {
26
+ constructor({ queueAdapter, sendMessage } = {}) {
27
27
  super({ queueAdapter, queueName: QUEUE_NAME });
28
28
  if (typeof sendMessage !== 'function') {
29
29
  throw new Error('ScheduledMessageJob requires a sendMessage function');
30
30
  }
31
31
  this.sendMessage = sendMessage;
32
- this.requireProvider = requireProvider;
33
32
  }
34
33
 
35
34
  async schedule({ scheduledMessageId, sendTime }) {
@@ -101,11 +100,6 @@ class ScheduledMessageJob extends BaseJob {
101
100
 
102
101
  try {
103
102
  const parentMessageId = msg.parentMessageId || null;
104
- if (!parentMessageId && msg.contentSid && !msg.message) {
105
- const provider = this.requireProvider?.();
106
- const rendered = await provider?.renderTemplate?.(msg.contentSid, msg.variables);
107
- if (rendered) msg.message = rendered;
108
- }
109
103
 
110
104
  const result = await this.sendMessage({
111
105
  code: msg.code,
@@ -219,6 +219,10 @@ const planSchema = new mongoose.Schema({
219
219
  },
220
220
  instructions: { type: String, required: true },
221
221
  instructionsEdited: { type: Boolean, default: false },
222
+ prescriber: {
223
+ type: new mongoose.Schema({ name: { type: String, default: null }, email: { type: String, required: true } }, { _id: false }),
224
+ default: null,
225
+ },
222
226
  }, { _id: false });
223
227
 
224
228
  const decisionSchema = new mongoose.Schema({
@@ -239,7 +243,8 @@ const patientDeliverySchema = new mongoose.Schema({
239
243
  decisionNumber: { type: Number, required: true },
240
244
  messageId: { type: String, default: null },
241
245
  status: { type: String, default: null },
242
- transport: { type: String, enum: ['text', 'template_recovery', 'predefined_template'], required: true },
246
+ transport: { type: String, enum: ['text', 'template_recovery', 'predefined_template', 'assistant'], required: true },
247
+ prescriptionRecordId: { type: String, default: null },
243
248
  deferred: { type: Boolean, default: false },
244
249
  confirmedBy: { type: String, default: null },
245
250
  confirmedAt: { type: Date, default: null },
@@ -26,9 +26,9 @@ const ASSISTANT_MESSAGE_FAILURES = {
26
26
  };
27
27
 
28
28
  class AssistantMessageError extends Error {
29
- constructor(reasonCode, acceptedCount = 0) {
29
+ constructor(reasonCode, acceptedCount = 0, cause = undefined) {
30
30
  const [statusCode, message] = ASSISTANT_MESSAGE_FAILURES[reasonCode];
31
- super(message);
31
+ super(message, cause === undefined ? undefined : { cause });
32
32
  this.reasonCode = reasonCode;
33
33
  this.statusCode = statusCode;
34
34
  this.acceptedCount = acceptedCount;
@@ -117,8 +117,10 @@ async function processAssistantSystemMessage(code, messages, role = 'system', op
117
117
  if (!reply) return;
118
118
  recordPatientSend?.({ code, channel: 'assistant_instruction', sentBy: triggeredBy, text: input.messages.join('\n') });
119
119
 
120
+ let turn;
121
+ let settled = null;
120
122
  try {
121
- await executeWithPipeline(code, 'system', 'queue',
123
+ turn = await executeWithPipeline(code, 'system', 'queue',
122
124
  async (preProcessResult, shouldContinue, runId) => {
123
125
  return await assistantProcessor.process({
124
126
  code,
@@ -131,11 +133,15 @@ async function processAssistantSystemMessage(code, messages, role = 'system', op
131
133
  signal: getAbortSignal(runId),
132
134
  }
133
135
  });
134
- }
136
+ },
137
+ { onSendSettled: async (outcome) => { settled = outcome; } },
135
138
  );
136
- } catch {
137
- throw new AssistantMessageError('MESSAGE_REPLY_FAILED', acceptedCount);
139
+ } catch (error) {
140
+ // The cause stays internal: callers tell a drain that refused the turn from one that may have answered.
141
+ throw new AssistantMessageError('MESSAGE_REPLY_FAILED', acceptedCount, error);
138
142
  }
143
+ // What the reply's send returned, or null when the turn produced or sent nothing; a send that threw may still have reached the patient.
144
+ return { delivery: turn?.delivery ?? null, sendThrew: settled === 'threw' };
139
145
  }
140
146
 
141
147
  module.exports = { AssistantMessageError, injectAssistantMessages, processAssistantSystemMessage };
@@ -33,7 +33,9 @@ const { doctorAlertHeadline, doctorAlertOpener, renderDoctorMessage, templateSum
33
33
  const { generateKeyQuestions } = require('../clinical/services/escalationQuestionsService');
34
34
  const { drugsNamedIn } = require('../clinical/services/treatmentProposalService');
35
35
  const { characterizationSeedOf } = require('../clinical/helpers/reviewUpdateHelper');
36
- const { approvedInstructions, needsPrescriber, planInstructions, planOf, proposedPlanOf } = require('../clinical/helpers/doctorPlanHelper');
36
+ const {
37
+ approvedInstructions, assistantInstructionsOf, needsPrescriber, planInstructions, planOf, planSummary, prescriptionText, proposedPlanOf,
38
+ } = require('../clinical/helpers/doctorPlanHelper');
37
39
  const { readNoteFacts, assertionOf } = require('../clinical/services/escalationNoteFactsService');
38
40
  const { humanReplyFilter } = require('../clinical/config/safetyNetConfig');
39
41
  const {
@@ -53,6 +55,7 @@ const { getPatientInformation, logPatientChange } = require('./patientService');
53
55
  const { MAX_NOTE_LENGTH, openCaseIds, readPatientCases, updatePatientTask } = require('./patientTaskService');
54
56
  const { countMessages } = require('./messageService');
55
57
  const { getEscalationApprover, listEscalationApprovers } = require('./physicianService');
58
+ const { createPrescription } = require('./prescriptionService');
56
59
  const { getThreadInfo, switchThreadStoppedStatus } = require('./threadService');
57
60
  const { effectiveUrgencyOf, levelsOf, patientTimerMinutes, responseDeadlineOf, responseDeadlinePolicy } = require('./reviewDeadlineService');
58
61
  const { caseTaskLive, scheduleCaseSync } = require('./escalationCaseTaskService');
@@ -106,7 +109,6 @@ const SAME_URGENCY = 'The review is already at that urgency';
106
109
  const RECLASSIFY_CHANGED = 'The review changed while you were reclassifying it; read it again before changing the urgency';
107
110
  const DIRECT_CHAT_STATUSES = ['open', 'documenting', 'draft_ready', 'returned', 'awaiting_doctor', 'approved', 'needs_intervention'];
108
111
  const APPROVALS = ['approved', 'edited_approved', 'plan_approved'];
109
- const PRESCRIBER_ONLY = 'Only an approving doctor can indicate drugs or studies, or edit the instructions to the assistant';
110
112
  const STALE_DRAFT = 'New information reached the review after this draft; wait for the updated draft before approving';
111
113
  const STALE_HANDOFF = 'New information reached the review after the draft the doctor received; it went back to documenting for a new draft';
112
114
  const DRAFT_CHANGED = 'A newer draft was written after the one you saw; read it before deciding';
@@ -730,16 +732,31 @@ async function isEscalationApprover(operator) {
730
732
  }
731
733
  }
732
734
 
733
- // Unlike a read, a failed lookup refuses the decision instead of treating the doctor as not approving.
734
- async function requireApprover(operator) {
735
- let approver;
735
+ // The doctor whose word the plan is, from the approving doctors; unpicked, the one recording it if they approve, or the only one there is.
736
+ async function prescriberOf(email, operator) {
737
+ if (email != null && typeof email !== 'string') throw httpError(400, 'prescriberEmail must be text');
738
+ let approvers;
736
739
  try {
737
- approver = await getEscalationApprover(operator);
740
+ approvers = await listEscalationApprovers();
738
741
  } catch (error) {
739
- logger.error('[EscalationReview] Approver lookup failed; the plan was not recorded', { error: redactedError(error) });
742
+ logger.error('[EscalationReview] The approving doctors could not be listed; the plan was not recorded', { error: redactedError(error) });
740
743
  throw httpError(503, 'The approving doctors could not be read; try again');
741
744
  }
742
- if (!approver) throw httpError(403, PRESCRIBER_ONLY);
745
+ if (!approvers.length) throw httpError(409, 'No physician signs recetas: tick aprueba_vaiven on a physician');
746
+ const wanted = (email?.trim() || operator).trim().toLowerCase();
747
+ const chosen = approvers.find((approver) => approver.email === wanted) ?? (!email?.trim() && approvers.length === 1 ? approvers[0] : null);
748
+ if (!chosen) throw httpError(400, `prescriberEmail must be one of: ${approvers.map((approver) => approver.email).join(', ')}`);
749
+ return { name: chosen.name, email: chosen.email };
750
+ }
751
+
752
+ // Who can sign a receta, for whoever records the plan to pick; null when the list could not be read, which the read survives.
753
+ async function prescribersForRead() {
754
+ try {
755
+ return (await listEscalationApprovers()).map(({ name, email }) => ({ name, email }));
756
+ } catch (error) {
757
+ logger.warn('[EscalationReview] The approving doctors could not be listed for the review', { error: redactedError(error) });
758
+ return null;
759
+ }
743
760
  }
744
761
 
745
762
  async function getEscalationReviewById({ id, requestedBy } = {}) {
@@ -768,6 +785,7 @@ async function getEscalationReviewById({ id, requestedBy } = {}) {
768
785
  isApprover,
769
786
  responseDeadline: responseDeadlineOf(review, responseDeadlinePolicy()),
770
787
  proposedPlan: draft ? { draftNumber: review.drafts.length, ...proposedPlanOf(draft) } : null,
788
+ prescribers: draft ? await prescribersForRead() : [],
771
789
  };
772
790
  }
773
791
 
@@ -1815,6 +1833,7 @@ async function recordDoctorDecision({
1815
1833
  finalText = null,
1816
1834
  plan: requestedPlan = null,
1817
1835
  instructions = null,
1836
+ prescriberEmail = null,
1818
1837
  comment = null,
1819
1838
  doctorName = null,
1820
1839
  doctorReply = null,
@@ -1850,9 +1869,12 @@ async function recordDoctorDecision({
1850
1869
  if (seenDraftNumber === null) throw httpError(400, 'draftNumber is required for a plan');
1851
1870
  plan = planOf(requestedPlan);
1852
1871
  plan = { ...plan, ...approvedInstructions(plan, instructions) };
1853
- // The team may send home care alone; a note, an edit or a named drug goes out on a doctor's word.
1872
+ // Anyone may record it, often beside the doctor; a drug, a study, a note, an edit or a named drug names the doctor who gave it.
1854
1873
  const namesDrug = drugsNamedIn(plan.homeCare.join(' ')).length > 0;
1855
- if (needsPrescriber(plan) || plan.note || plan.instructionsEdited || namesDrug) await requireApprover(operator);
1874
+ if (needsPrescriber(plan) || plan.note || plan.instructionsEdited || namesDrug) {
1875
+ plan = { ...plan, prescriber: await prescriberOf(prescriberEmail, operator) };
1876
+ decidedBy = plan.prescriber.email;
1877
+ }
1856
1878
  }
1857
1879
 
1858
1880
  const review = await loadReview(reviewId);
@@ -2033,10 +2055,10 @@ async function sendApprovedIndications({ id, outsideWindowRoute = null, sentBy,
2033
2055
  const claim = newOperationClaim();
2034
2056
  const claimed = await updateReview(reviewId, SENDABLE_STATUSES, { $set: { status: 'sending', operationClaim: claim } });
2035
2057
  const decisionNumber = claimed.decisions.length;
2036
- const { finalText, decision } = claimed.decisions[decisionNumber - 1];
2058
+ const { finalText, decision, plan } = claimed.decisions[decisionNumber - 1];
2037
2059
  const claimedFrom = statusAfter(decision);
2038
2060
 
2039
- if (!finalText) {
2061
+ if (!finalText && !plan) {
2040
2062
  await releaseReview(reviewId, 'sending', claimedFrom, claim).catch((releaseError) => {
2041
2063
  logger.error('[EscalationReview] The claim was not released after a decision with no text', {
2042
2064
  escalationReviewId: String(claimed._id), error: releaseError.message,
@@ -2054,6 +2076,7 @@ async function sendApprovedIndications({ id, outsideWindowRoute = null, sentBy,
2054
2076
  });
2055
2077
  throw httpError(409, 'The agent characterized the case after this decision; it went back to documenting for a new draft and decision');
2056
2078
  }
2079
+ if (plan) return sendPlanThroughAssistant({ claimed, claim, decisionNumber, plan, operator, requestId });
2057
2080
 
2058
2081
  // Built outside the try: composing reads Mongo and Airtable, and failing there never sent.
2059
2082
  let message;
@@ -2109,6 +2132,98 @@ async function sendApprovedIndications({ id, outsideWindowRoute = null, sentBy,
2109
2132
  return syncCase(review, operator, requestId);
2110
2133
  }
2111
2134
 
2135
+ // The PDF goes out from the Airtable automations; a failed receta never undoes the message already sent.
2136
+ async function issuePrescription(review, plan, prescriberEmail) {
2137
+ try {
2138
+ return await createPrescription({
2139
+ code: review.patientCode, prescription: prescriptionText(plan), authorEmail: prescriberEmail, creationSource: 'patient case',
2140
+ });
2141
+ } catch (error) {
2142
+ logger.error('[EscalationReview] The prescription was not created after the plan was sent', {
2143
+ escalationReviewId: String(review._id), error: redactedError(error),
2144
+ });
2145
+ return null;
2146
+ }
2147
+ }
2148
+
2149
+ // How the assistant's reply left: sent, queued for the patient's reply window, not sent, or unknown.
2150
+ function replyOutcome(delivery, sendThrew) {
2151
+ if (sendThrew) return 'unknown';
2152
+ if (!delivery) return 'not_sent';
2153
+ if (delivery.success === true && delivery.deferred === true) return 'queued';
2154
+ if (delivery.success === true && typeof delivery.messageId === 'string' && delivery.messageId) return 'sent';
2155
+ if (delivery.success === false && delivery.deferred !== true && delivery.duplicate !== true) return 'not_sent';
2156
+ return 'unknown';
2157
+ }
2158
+
2159
+ // The assistant writes the patient message from the doctor's instructions; the review closes unless a follow-up was chosen.
2160
+ async function sendPlanThroughAssistant({ claimed, claim, decisionNumber, plan, operator, requestId }) {
2161
+ const reviewId = claimed._id;
2162
+ const release = () => releaseReview(reviewId, 'sending', 'approved', claim).catch((releaseError) => {
2163
+ logger.error('[EscalationReview] The claim was not released after a plan the assistant did not send', {
2164
+ escalationReviewId: String(reviewId), error: releaseError.message,
2165
+ });
2166
+ });
2167
+
2168
+ const prescriberEmail = needsPrescriber(plan) ? plan.prescriber?.email ?? null : null;
2169
+ let delivery;
2170
+ let sendThrew;
2171
+ try {
2172
+ if (needsPrescriber(plan) && !prescriberEmail) throw httpError(409, 'This plan names no doctor to sign the receta; record the plan again');
2173
+ ({ delivery = null, sendThrew = false } = await getMessaging().processSystemMessage(
2174
+ claimed.patientCode, [assistantInstructionsOf(plan)], 'system', { reply: true, triggeredBy: operator },
2175
+ ) ?? {});
2176
+ } catch (error) {
2177
+ // A turn refused by a deploy's drain never ran; any other failure after the instructions were stored may have answered.
2178
+ if (error?.reasonCode === 'MESSAGE_REPLY_FAILED' && !(error.cause instanceof DrainingError)) {
2179
+ logger.error('[EscalationReview] The assistant turn failed after the instructions were stored; leaving the review in sending', {
2180
+ escalationReviewId: String(reviewId),
2181
+ });
2182
+ throw httpError(502, 'The assistant turn failed after the instructions were stored; check the chat before sending again');
2183
+ }
2184
+ await release();
2185
+ if (error?.cause instanceof DrainingError) throw httpError(503, 'The server is restarting; send the plan again in a minute');
2186
+ throw error;
2187
+ }
2188
+
2189
+ const outcome = replyOutcome(delivery, sendThrew);
2190
+ if (outcome === 'not_sent') {
2191
+ await release();
2192
+ throw httpError(502, 'The assistant did not send a message to the patient; check the chat and send the plan again');
2193
+ }
2194
+ if (outcome === 'unknown') {
2195
+ logger.error('[EscalationReview] The assistant reply could not be confirmed; leaving the review in sending', { escalationReviewId: String(reviewId) });
2196
+ throw httpError(502, 'The assistant reply could not be confirmed; check the chat before sending again');
2197
+ }
2198
+
2199
+ // A queued reply goes out once the patient answers the recovery template; the receta, a template itself, goes now.
2200
+ const queued = outcome === 'queued';
2201
+ const prescriptionRecordId = prescriberEmail ? await issuePrescription(claimed, plan, prescriberEmail) : null;
2202
+ const record = {
2203
+ decisionNumber, messageId: delivery.messageId || null, status: delivery.status || null, transport: 'assistant', deferred: queued, sentBy: operator,
2204
+ ...(prescriptionRecordId && { prescriptionRecordId }),
2205
+ };
2206
+ const recordUpdate = withCaseMirror(claimed, { $set: { patientDelivery: record } }, { recommendation: planSummary(plan) });
2207
+ const status = queued ? 'indications_queued' : 'indications_sent';
2208
+ const review = await completeOperation(reviewId, claim, 'sending', recordUpdate, status);
2209
+ const prescriptionFailed = Boolean(prescriberEmail) && !prescriptionRecordId;
2210
+ await audit(review, operator, requestId, {
2211
+ action: status, decisionNumber, transport: 'assistant', ...(prescriberEmail && { prescription: prescriptionFailed ? 'failed' : 'created' }),
2212
+ });
2213
+ // Closing waits for a sent reply and the receta it promised; otherwise the team still has something to do.
2214
+ if (plan.followUp.kind === 'none' && !queued && !prescriptionFailed) {
2215
+ try {
2216
+ return await closeEscalationReview({ id: reviewId, closedBy: operator, requestId });
2217
+ } catch (error) {
2218
+ logger.error('[EscalationReview] The review was not closed after its plan was sent', {
2219
+ escalationReviewId: String(reviewId), error: redactedError(error),
2220
+ });
2221
+ return syncCase(await loadReview(reviewId), operator, requestId);
2222
+ }
2223
+ }
2224
+ return syncCase(review, operator, requestId);
2225
+ }
2226
+
2112
2227
  const EPISODE_TRUST = 0.7;
2113
2228
  const EPISODE_VARIABLE = 'intercurrent_events_summary';
2114
2229
  const EPISODE_SOURCE = 'doctor';
@@ -1,9 +1,9 @@
1
1
  const { Estado_General_ID, Monitoreo_ID } = require('../config/airtableConfig');
2
- const runtimeConfig = require('../config/runtimeConfig');
3
2
 
4
3
  const { httpError, requiredText } = require('../utils/httpErrorUtils');
5
4
  const { inlineOf } = require('../utils/coercionUtils');
6
5
  const { escapeRegExp } = require('../utils/textMatchUtils');
6
+ const { ownS3Location } = require('../utils/s3Utils');
7
7
  const { logger } = require('../utils/logger');
8
8
  const { PATIENT_CODE, redactDirectIdentifiers } = require('../utils/sanitizerUtils');
9
9
  const { OfficeReadMarker } = require('../models/officeReadMarkerModel');
@@ -194,20 +194,14 @@ async function readOfficeMessages({ requestedBy, code, limit }) {
194
194
 
195
195
  function officeDocumentUrl(fileUrl) {
196
196
  if (!fileUrl) return null;
197
- const bucketName = runtimeConfig.get('AWS_S3_BUCKET_NAME');
198
197
  let url = null;
199
198
  try {
200
199
  url = new URL(String(fileUrl));
201
200
  } catch {
202
201
  url = null;
203
202
  }
204
- // The approved document template pins the S3 host and takes the rest of the URL as a variable. A bucket name
205
- // with dots (whatsapp.prod) is presigned path-style, so the bucket is then the first path segment.
206
- const bucket = escapeRegExp(bucketName ?? '');
207
- const isVirtualHosted = new RegExp(`^${bucket}\\.s3(\\.[a-z0-9-]+)?\\.amazonaws\\.com$`).test(url?.hostname ?? '');
208
- const isPathStyle = /^s3(\.[a-z0-9-]+)?\.amazonaws\.com$/.test(url?.hostname ?? '')
209
- && url.pathname.startsWith(`/${bucketName}/`);
210
- if (!bucketName || url?.protocol !== 'https:' || !(isVirtualHosted || isPathStyle)) {
203
+ // The approved document template pins the S3 host and takes the rest of the URL as a variable.
204
+ if (!ownS3Location(url)) {
211
205
  throw httpError(400, 'The document must be uploaded before it is sent');
212
206
  }
213
207
  return url;
@@ -0,0 +1,30 @@
1
+ const { Monitoreo_ID } = require('../config/airtableConfig');
2
+
3
+ const { ensureWhatsAppFormat } = require('../helpers/twilioHelper');
4
+
5
+ const { addLinkedRecord } = require('./airtableService');
6
+
7
+ // physicianId links the receta to a physicians row; the Airtable automation picks the PDF template from that physician's specialty.
8
+ async function createPrescription({ code, prescription, authorEmail, creationSource = 'other', physicianId }) {
9
+ const patientCode = ensureWhatsAppFormat(code);
10
+ const record = await addLinkedRecord(
11
+ Monitoreo_ID,
12
+ 'patient_prescriptions',
13
+ {
14
+ prescription,
15
+ author_email: authorEmail,
16
+ creation_source: creationSource,
17
+ created: new Date().toISOString(),
18
+ ...(physicianId && { physician: [physicianId] })
19
+ },
20
+ {
21
+ referenceTable: 'estado_general',
22
+ referenceFilter: `{whatsapp_id}='${patientCode}'`,
23
+ linkFieldName: 'patient_id'
24
+ },
25
+ patientCode
26
+ );
27
+ return record?.id || null;
28
+ }
29
+
30
+ module.exports = { createPrescription };
@@ -57,6 +57,13 @@ function validateMediaFormat(contentType, mediaType) {
57
57
  };
58
58
  }
59
59
 
60
+ const EXTENSION_MIME_TYPES = {
61
+ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp',
62
+ pdf: 'application/pdf',
63
+ mp3: 'audio/mp3', wav: 'audio/wav', ogg: 'audio/ogg', m4a: 'audio/m4a',
64
+ mp4: 'video/mp4', webm: 'video/webm', '3gp': 'video/3gp'
65
+ };
66
+
60
67
  function getMediaType(contentType) {
61
68
  for (const [type, formats] of Object.entries(ALLOWED_FORMATS)) {
62
69
  if (formats.includes(contentType)) {
@@ -95,6 +102,7 @@ module.exports = {
95
102
  validateMediaSize,
96
103
  validateMediaFormat,
97
104
  getMediaType,
105
+ EXTENSION_MIME_TYPES,
98
106
  MEDIA_LIMITS,
99
107
  ALLOWED_FORMATS,
100
108
  STICKER_DIMENSIONS
@@ -0,0 +1,32 @@
1
+ const runtimeConfig = require('../config/runtimeConfig');
2
+
3
+ const { escapeRegExp } = require('./textMatchUtils');
4
+
5
+ function decodedKey(rawKey) {
6
+ try {
7
+ return decodeURIComponent(rawKey);
8
+ } catch {
9
+ return rawKey;
10
+ }
11
+ }
12
+
13
+ // A dotted bucket name is presigned path-style, so the bucket is then the first path segment.
14
+ function ownS3Location(value) {
15
+ let url = value;
16
+ try {
17
+ if (!(value instanceof URL)) url = new URL(String(value));
18
+ } catch {
19
+ return null;
20
+ }
21
+ const bucketName = runtimeConfig.get('AWS_S3_BUCKET_NAME');
22
+ if (!bucketName || url.protocol !== 'https:') return null;
23
+ if (new RegExp(`^${escapeRegExp(bucketName)}\\.s3(\\.[a-z0-9-]+)?\\.amazonaws\\.com$`).test(url.hostname)) {
24
+ return { bucketName, key: decodedKey(url.pathname.slice(1)) };
25
+ }
26
+ if (/^s3(\.[a-z0-9-]+)?\.amazonaws\.com$/.test(url.hostname) && url.pathname.startsWith(`/${bucketName}/`)) {
27
+ return { bucketName, key: decodedKey(url.pathname.slice(bucketName.length + 2)) };
28
+ }
29
+ return null;
30
+ }
31
+
32
+ module.exports = { ownS3Location };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "6.4.0-dev.18675",
3
+ "version": "6.4.0-dev.18775",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",