@peopl-health/nexus 5.71.10 → 5.71.12

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.
@@ -317,6 +317,7 @@ class BaseLLMProvider {
317
317
  ...this._buildFollowUpPolicyMessage(toolIds),
318
318
  ...this._buildFollowUpMessage(promptVariables),
319
319
  ...this._buildFollowUpCheckMessage(toolIds),
320
+ ...this._buildContingencyCheckMessage(promptVariables, toolIds),
320
321
  ...this._buildClockMessage(promptVariables, interpolatedVariables),
321
322
  ...messages,
322
323
  ...this._buildOutreachBriefMessage({ outreachBrief, toolIds }),
@@ -378,7 +379,8 @@ class BaseLLMProvider {
378
379
  '',
379
380
  'Si escribes, que sea para ELLA: lo que te contó, lo que quedó abierto, lo que quieres saber ahora.',
380
381
  '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 decirle, breve (menos de 500 caracteres) y con un emoji como máximo. Es un contacto sobre su cuidado: no le sumes invitaciones a charlas, sesiones ni actividades.',
381
- 'LÍMITE UTILITY: escribe sólo cuando puedas anclar el contacto en algo concreto que la paciente contó, preguntó o acordó, o en una interacción de cuidado todavía vigente. La inactividad por sí sola no basta. Si escribes, registra esa ancla en utility_basis; si sólo queda un saludo general para generar engagement, registra hold con hold_reason no_utility_anchor y utility_basis null.',
382
+ 'LÍMITE UTILITY: escribe sólo cuando puedas anclar el contacto en algo concreto que la paciente contó, preguntó o acordó, o en una interacción de cuidado todavía vigente. La inactividad por sí sola no basta. Si escribes, registra esa ancla en utility_basis; si sólo queda un saludo general para generar engagement, registra hold con hold_reason no_utility_anchor y utility_basis null. El mensaje tiene que nombrar esa ancla; preguntarle cómo se ha sentido o invitarla a escribir si tiene dudas no es un ancla.',
383
+ 'Debe entenderse por sí mismo: si mencionas fechas, un documento o algo que le enviaste, di cuáles. No enumeres señales de alarma; si vigilas algo concreto, menciona sólo esa señal y pídele que te escriba en cuanto aparezca.',
382
384
  'No repitas un mensaje que ya le enviaste, y no menciones automatizaciones, listas ni seguimientos programados.',
383
385
  );
384
386
 
@@ -422,21 +424,37 @@ class BaseLLMProvider {
422
424
  const windows = net?.patientReminder?.firesAfterSilenceMinutes
423
425
  ? `check-in al paciente tras ${net.patientReminder.firesAfterSilenceMinutes} min de silencio`
424
426
  : '';
425
- const fired = plan.status === 'fired_reminder'
426
- ? ' El check-in ya se envió: si este mensaje lo contesta, resuelve la red o el caso.'
427
- : '';
428
- parts.push(`- ACTIVA ${plan.planId} — vigila: ${sanitizeInlineText(plan.concern)} (${target}). Espera observar: ${sanitizeInlineText(plan.awaitingCharacterization)}. ${windows}.${fired}`);
427
+ parts.push(`- ACTIVA ${plan.planId} — vigila: ${sanitizeInlineText(plan.concern)} (${target}). Espera observar: ${sanitizeInlineText(plan.awaitingCharacterization)}. ${windows}.`);
429
428
  }
430
429
  for (const net of cancelled) {
431
430
  const plan = net?.carePlan || {};
432
431
  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.`);
433
432
  }
434
- if (active.length) {
435
- 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.');
436
- }
437
433
  return [{ role: 'developer', content: parts.join('\n') }];
438
434
  }
439
435
 
436
+ // Rides behind the history like the follow-up check: the nets block above lists the nets, the decision lives here,
437
+ // because an unresolved net later asks the patient a question they had already answered.
438
+ // Only answers given after the net was armed count: an escalation net waits for how the reported symptom evolved.
439
+ _buildContingencyCheckMessage(promptVariables, toolIds = []) {
440
+ if (!(toolIds || []).includes('setContingencyPlan')) return [];
441
+ const active = Array.isArray(promptVariables?.active_contingencies) ? promptVariables.active_contingencies : [];
442
+ if (!active.length) return [];
443
+ const waiting = active.map(({ carePlan: plan = {} } = {}) => {
444
+ const fired = plan.status === 'fired_reminder' ? ' Su check-in ya se envió.' : '';
445
+ return `- ${plan.planId} (armada ${localFireTime(plan.createdAt)}) espera: ${sanitizeInlineText(plan.awaitingCharacterization)}.${fired}`;
446
+ });
447
+ return [{
448
+ role: 'developer',
449
+ content: [
450
+ '## Antes de entregar tu respuesta: redes activas',
451
+ ...waiting,
452
+ 'DECISIÓN ESTE TURNO: si el paciente ya respondió lo que una red espera después de que se armó — en este mensaje o en uno anterior, también cuando se lo contestó al equipo —, resuélvela ahora con setContingencyPlan {action: \'resolve\', plan_id: \'<plan>\', resolution_reasoning: \'…\'} o cierra el caso; si no, su check-in le volverá a preguntar lo mismo. Si cambió la preocupación o la ventana, usa {action: \'rearm\'}. Lo que el paciente contó antes de que se armara no cuenta, y una indicación del equipo tampoco es una respuesta. Si nada responde lo que espera, no hagas nada con la red.',
453
+ ].join('\n'),
454
+ [TAIL_PLACEMENT]: true,
455
+ }];
456
+ }
457
+
440
458
  // Rides behind the history, next to the turn: a rule buried in the policy block above was not applied.
441
459
  _buildFollowUpCheckMessage(toolIds = []) {
442
460
  if (!(toolIds || []).includes('schedulePatientReminder')) return [];
@@ -40,7 +40,7 @@ const definition = {
40
40
  'anyOf': [
41
41
  {
42
42
  'type': 'string',
43
- '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.'
43
+ '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. Never ask whether the care team contacted them or gave an indication — that is not yours to ask, and it may arrive after the team already did. 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.'
44
44
  },
45
45
  {
46
46
  'type': 'null'
@@ -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 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. 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. 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',
@@ -46,7 +46,7 @@ const definition = {
46
46
  resolves_on: {
47
47
  type: 'string',
48
48
  enum: Object.values(RESOLVES_ON),
49
- description: 'How the net stands down. \'reply\' (default): the patient\'s next message retires it, which is right when what you await is an answer they owe. \'observation\': the net keeps waiting through messages that do not carry it, and stands down only when you `resolve` it, its target closes, or it expires — use it when what you await is an event their next message may not contain, such as the result of something you asked them to do, or a threshold you named.',
49
+ description: 'How the net stands down. \'reply\' (default): what you await is an answer they owe — resolve the net when they give it; their other messages only push the check-in back. \'observation\': the net keeps waiting through messages that do not carry it, and stands down only when you `resolve` it, its target closes, or it expires — use it when what you await is an event their next message may not contain, such as the result of something you asked them to do, or a threshold you named.',
50
50
  },
51
51
  related_case_id: {
52
52
  type: 'string',
@@ -58,7 +58,7 @@ const definition = {
58
58
  },
59
59
  patient_followup_reminder: {
60
60
  type: 'object',
61
- description: `Required. ${STEP_SHAPE}: the check-in sent to the patient — message (string, in the patient's voice and language) + fires_after_silence_minutes (> 0 and <= ${MAX_PATIENT_SILENCE_MIN}, ~23h, so it stays inside the WhatsApp session window) + window_reasoning: ONE sentence on why that wait — what you expect to happen in it, and why waiting longer would be unsafe or shorter would be noise. A check-in never goes out between 21:00 and 09:00 patient-local time (08:00–09:00 is when the daily symptom forms arrive) — a window landing in those hours is held until 09:00, so pick the window the concern needs and do not shorten it to dodge the small hours. If a concern cannot wait until morning, it needs an escalation, not a net. priority and action_requested are optional.`,
61
+ description: `Required. ${STEP_SHAPE}: the check-in sent to the patient — message (string, in the patient's voice and language, about the symptom only: never ask whether the care team contacted them or gave an indication — that is not yours to ask, and it may arrive after the team already did) + fires_after_silence_minutes (> 0 and <= ${MAX_PATIENT_SILENCE_MIN}, ~23h, so it stays inside the WhatsApp session window) + window_reasoning: ONE sentence on why that wait — what you expect to happen in it, and why waiting longer would be unsafe or shorter would be noise. A check-in never goes out between 21:00 and 09:00 patient-local time (08:00–09:00 is when the daily symptom forms arrive) — a window landing in those hours is held until 09:00, so pick the window the concern needs and do not shorten it to dodge the small hours. If a concern cannot wait until morning, it needs an escalation, not a net. priority and action_requested are optional.`,
62
62
  },
63
63
  },
64
64
  required: ['action'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.71.10",
3
+ "version": "5.71.12",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",