@peopl-health/nexus 5.67.2 → 5.68.0-dev.12044
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.
- package/lib/clinical/AssistantProcessor.js +2 -3
- package/lib/clinical/providers/BaseLLMProvider.js +11 -3
- package/lib/clinical/services/clinicalAirtableService.js +57 -4
- package/lib/clinical/tools/schedulePatientReminderTool.js +33 -5
- package/lib/clinical/tools/setContingencyPlanTool.js +1 -1
- package/lib/core/NexusMessaging.js +20 -14
- package/package.json +1 -1
|
@@ -127,10 +127,9 @@ class AssistantProcessor {
|
|
|
127
127
|
|
|
128
128
|
try {
|
|
129
129
|
if (!runOptions.shadow && isPatientTurn) {
|
|
130
|
-
await cancelPendingFollowUps({ code: thread.code, trace: ctx?.trace || null });
|
|
131
|
-
} else {
|
|
132
|
-
followUpSnapshot = await readPendingFollowUpsForPatient({ code: thread.code });
|
|
130
|
+
await cancelPendingFollowUps({ code: thread.code, trace: ctx?.trace || null, recoveryOnly: true });
|
|
133
131
|
}
|
|
132
|
+
followUpSnapshot = await readPendingFollowUpsForPatient({ code: thread.code });
|
|
134
133
|
} catch (error) {
|
|
135
134
|
logger.warn('[AssistantProcessor] follow-up state update failed', { code: thread.code, error: error.message });
|
|
136
135
|
// A proactive turn must not create or send a second follow-up while the plan read is stale.
|
|
@@ -317,10 +317,10 @@ class BaseLLMProvider {
|
|
|
317
317
|
role: 'developer',
|
|
318
318
|
content: [
|
|
319
319
|
'## Seguimiento, recordatorio y contingencia — no son intercambiables',
|
|
320
|
-
'- 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 queda una red ni un seguimiento activo para ese síntoma, programa uno a 24–48 h 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
|
|
320
|
+
'- 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 queda una red ni un seguimiento activo para ese síntoma, programa uno a 24–48 h 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ó.',
|
|
321
321
|
'- 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.',
|
|
322
322
|
'- 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.',
|
|
323
|
-
'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
|
|
323
|
+
'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.',
|
|
324
324
|
].join('\n'),
|
|
325
325
|
}];
|
|
326
326
|
}
|
|
@@ -440,7 +440,15 @@ class BaseLLMProvider {
|
|
|
440
440
|
const pending = Array.isArray(promptVariables?.active_follow_ups) ? promptVariables.active_follow_ups : [];
|
|
441
441
|
if (!pending.length) return [];
|
|
442
442
|
const rows = pending.map((item) => `- ${sanitizeInlineText(item.reminderId)} a las ${sanitizeInlineText(item.fireAt)}: ${sanitizeInlineText(item.message)}`);
|
|
443
|
-
return [{
|
|
443
|
+
return [{
|
|
444
|
+
role: 'developer',
|
|
445
|
+
content: [
|
|
446
|
+
'## Seguimientos ya programados',
|
|
447
|
+
...rows,
|
|
448
|
+
'Siguen pendientes aunque la paciente haya vuelto a escribir. Si esta conversación ya respondió su pregunta o la volvió innecesaria, cancélalo (schedulePatientReminder con reminder_action_type cancel y su reminder_id); si cambió lo que hay que preguntar o cuándo, actualízalo; si sigue valiendo, déjalo. No crees otro seguimiento que lo cruce.',
|
|
449
|
+
].join('\n'),
|
|
450
|
+
[TAIL_PLACEMENT]: true,
|
|
451
|
+
}];
|
|
444
452
|
}
|
|
445
453
|
|
|
446
454
|
_buildClinicalMessage(promptVariables, interpolatedVariables = []) {
|
|
@@ -48,6 +48,9 @@ const REMINDER_TYPE_TO_AIRTABLE = {
|
|
|
48
48
|
// of pending rows so the predicate cannot drift between them.
|
|
49
49
|
const PENDING_ROW_FILTER = 'IF({is_active}, "true", "false") = "true", IF({is_sent}, "true", "false") = "false"';
|
|
50
50
|
const ROUTINE_OUTREACH_FILTER = 'OR({type} = "seguimiento", LEFT({schedule_id}, 9) = "recovery_")';
|
|
51
|
+
const RECOVERY_FILTER = 'LEFT({schedule_id}, 9) = "recovery_"';
|
|
52
|
+
// The fire trigger opens 5 min either side of reminder_date; a row still pending after that was not sent.
|
|
53
|
+
const FIRE_TRIGGER_GRACE_MS = 10 * 60 * 1000;
|
|
51
54
|
|
|
52
55
|
// intake.source only offers self|doctor; team_relay carries "según médico" authority.
|
|
53
56
|
const INTAKE_SOURCE_TO_AIRTABLE = {
|
|
@@ -432,7 +435,9 @@ async function hasPendingReminderForPatient({ code, withinMinutes }) {
|
|
|
432
435
|
return Array.isArray(rows) && rows.length > 0;
|
|
433
436
|
}
|
|
434
437
|
|
|
435
|
-
|
|
438
|
+
// A row whose fire time passed unsent was skipped and is never re-triggered, and one past its expiry
|
|
439
|
+
// can never fire; neither is a plan the agent or the create guard should see.
|
|
440
|
+
async function readPendingFollowUpsForPatient({ code, now = new Date() }) {
|
|
436
441
|
if (!code) return [];
|
|
437
442
|
const rows = await airtableService.getRecordByFilter(
|
|
438
443
|
Monitoreo_ID,
|
|
@@ -447,7 +452,30 @@ async function readPendingFollowUpsForPatient({ code }) {
|
|
|
447
452
|
message: row.description || '',
|
|
448
453
|
expiresAt: row.expires_at || null,
|
|
449
454
|
windowReasoning: row.window_reasoning || null,
|
|
450
|
-
}));
|
|
455
|
+
})).filter((plan) => !isAtOrBefore(plan.expiresAt, now) && !isAtOrBefore(plan.fireAt, new Date(now).getTime() - FIRE_TRIGGER_GRACE_MS));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function isAtOrBefore(when, now) {
|
|
459
|
+
const at = when ? new Date(when).getTime() : NaN;
|
|
460
|
+
return Number.isFinite(at) && at <= new Date(now).getTime();
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/*
|
|
464
|
+
* Another check-in about to reach this patient: a net's patient check-in or another routine
|
|
465
|
+
* follow-up, due within `withinMinutes` either side of now so two rows firing together see each
|
|
466
|
+
* other. Patient-requested reminders are left out; they are what she asked for.
|
|
467
|
+
*/
|
|
468
|
+
async function hasCheckInNear({ code, withinMinutes, excludeReminderId }) {
|
|
469
|
+
const window = Number(withinMinutes);
|
|
470
|
+
if (!code || !Number.isFinite(window) || window <= 0) return false;
|
|
471
|
+
const rows = await airtableService.getRecordByFilter(
|
|
472
|
+
Monitoreo_ID,
|
|
473
|
+
'reminder',
|
|
474
|
+
`AND(${fieldEquals('whatsapp_id', code)}, ${PENDING_ROW_FILTER}, OR(RIGHT({schedule_id}, 8) = "-patient", ${ROUTINE_OUTREACH_FILTER}), NOT(${fieldEquals('schedule_id', excludeReminderId)}), ABS(DATETIME_DIFF({reminder_date}, NOW(), 'minutes')) <= ${window})`,
|
|
475
|
+
null,
|
|
476
|
+
['schedule_id'],
|
|
477
|
+
);
|
|
478
|
+
return Array.isArray(rows) && rows.length > 0;
|
|
451
479
|
}
|
|
452
480
|
|
|
453
481
|
async function readPendingReminderForPatient({ code, reminderId }) {
|
|
@@ -470,14 +498,37 @@ async function readPendingReminderForPatient({ code, reminderId }) {
|
|
|
470
498
|
} : null;
|
|
471
499
|
}
|
|
472
500
|
|
|
473
|
-
|
|
501
|
+
// Patient activity retires only turn-loss recovery rows; a seguimiento stays for the agent to keep, update or cancel.
|
|
502
|
+
async function cancelPendingFollowUps({ code, trace = null, reason = 'patient_replied', recoveryOnly = false }) {
|
|
474
503
|
return standDownRow({
|
|
475
504
|
code,
|
|
476
505
|
trace,
|
|
477
506
|
table: 'reminder',
|
|
478
507
|
planId: null,
|
|
479
508
|
standDownReason: reason,
|
|
480
|
-
filter: `AND(${fieldEquals('whatsapp_id', code)}, ${ROUTINE_OUTREACH_FILTER}, ${PENDING_ROW_FILTER})`,
|
|
509
|
+
filter: `AND(${fieldEquals('whatsapp_id', code)}, ${recoveryOnly ? RECOVERY_FILTER : ROUTINE_OUTREACH_FILTER}, ${PENDING_ROW_FILTER})`,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function cancelFollowUp({ code, reminderId, trace = null, reason, isLive = null }) {
|
|
514
|
+
if (!code || !reminderId) return false;
|
|
515
|
+
return auditedWrite({
|
|
516
|
+
trace,
|
|
517
|
+
code,
|
|
518
|
+
isLive,
|
|
519
|
+
table: 'reminder',
|
|
520
|
+
meta: { reminderId, standDown: true },
|
|
521
|
+
write: async () => {
|
|
522
|
+
const updated = await airtableService.updateRecordByFilter(
|
|
523
|
+
Monitoreo_ID,
|
|
524
|
+
'reminder',
|
|
525
|
+
`AND(${fieldEquals('whatsapp_id', code)}, ${fieldEquals('schedule_id', reminderId)}, ${ROUTINE_OUTREACH_FILTER}, ${PENDING_ROW_FILTER})`,
|
|
526
|
+
{ is_active: false, stand_down_reason: reason },
|
|
527
|
+
code,
|
|
528
|
+
);
|
|
529
|
+
if (!updated?.length) throw new Error(`pending follow-up ${reminderId} was not stood down`);
|
|
530
|
+
return updated;
|
|
531
|
+
},
|
|
481
532
|
});
|
|
482
533
|
}
|
|
483
534
|
|
|
@@ -637,6 +688,8 @@ module.exports = {
|
|
|
637
688
|
readPendingFollowUpsForPatient,
|
|
638
689
|
readPendingReminderForPatient,
|
|
639
690
|
cancelPendingFollowUps,
|
|
691
|
+
cancelFollowUp,
|
|
692
|
+
hasCheckInNear,
|
|
640
693
|
markReminderSent,
|
|
641
694
|
recordContingencyEscalation,
|
|
642
695
|
cancelContingencyRows,
|
|
@@ -2,7 +2,7 @@ const moment = require('moment-timezone');
|
|
|
2
2
|
|
|
3
3
|
const { isShadowRun } = require('../helpers/shadowHelper');
|
|
4
4
|
const { dispatchReminder } = require('../services/reminderDispatchService');
|
|
5
|
-
const { recordReminder, readPendingFollowUpsForPatient } = require('../services/clinicalAirtableService');
|
|
5
|
+
const { recordReminder, readPendingFollowUpsForPatient, cancelFollowUp } = require('../services/clinicalAirtableService');
|
|
6
6
|
const { isWithinTriageProtectionWindow } = require('../services/outreachSweepService');
|
|
7
7
|
const { UTILITY_BASES, sanitizeVariable } = require('../services/outreachCarrierService');
|
|
8
8
|
const { logger } = require('../../utils/logger');
|
|
@@ -10,7 +10,7 @@ const { DEFAULT_TIMEZONE } = require('../../utils/scheduleUtils');
|
|
|
10
10
|
|
|
11
11
|
const definition = {
|
|
12
12
|
name: 'schedulePatientReminder',
|
|
13
|
-
description: '**Does:** Creates or updates one agent-authored WhatsApp reminder. An ordinary reminder (medication, appointment, exam, or other task) is allowed only when the patient explicitly asks for or explicitly accepts that reminder. Do not infer a reminder merely because the patient mentioned or scheduled something. Schedule it at the exact date and time the patient requested. Never move it earlier to precede an appointment or task unless the patient explicitly requested that offset. On any patient conversation—not only a triage—or on a proactive review, use reminder_type seguimiento when this turn decides both when to check in again and the exact patient-facing words. Schedule one whenever a later contact materially helps continuity and has a concrete utility_basis — typically: you recommended an action (OTC medication, self-care, education) whose effect shows within 24–72h; the patient already gave the deciding datum and you want to know whether the symptom comes back or crosses a threshold you named; you closed a symptom episode worth checking on; or the patient has an upcoming event (starting a treatment, an appointment, an infusion) whose outcome matters. Inactivity alone is not a basis.\\n\\nA seguimiento is a lightweight engagement plan: it is capped at 600 characters, never truncated, expires 24 hours after its chosen fire time, and cannot be created while another seguimiento is active.
|
|
13
|
+
description: '**Does:** Creates or updates one agent-authored WhatsApp reminder. An ordinary reminder (medication, appointment, exam, or other task) is allowed only when the patient explicitly asks for or explicitly accepts that reminder. Do not infer a reminder merely because the patient mentioned or scheduled something. Schedule it at the exact date and time the patient requested. Never move it earlier to precede an appointment or task unless the patient explicitly requested that offset. On any patient conversation—not only a triage—or on a proactive review, use reminder_type seguimiento when this turn decides both when to check in again and the exact patient-facing words. Schedule one whenever a later contact materially helps continuity and has a concrete utility_basis — typically: you recommended an action (OTC medication, self-care, education) whose effect shows within 24–72h; the patient already gave the deciding datum and you want to know whether the symptom comes back or crosses a threshold you named; you closed a symptom episode worth checking on; or the patient has an upcoming event (starting a treatment, an appointment, an infusion) whose outcome matters. Inactivity alone is not a basis.\\n\\nA seguimiento is a lightweight engagement plan: it is capped at 600 characters, never truncated, fires at most 48 hours ahead, expires 24 hours after its chosen fire time, and cannot be created while another seguimiento is active. It stays pending when the patient writes again: later turns list it under «Seguimientos ya programados» so you keep it, update it, or cancel it (reminder_action_type cancel) when the conversation already answered it.\\n\\n**When NOT to call:** when the reminder guards unresolved clinical uncertainty, use setContingencyPlan instead; that plan owns its silence window and lifecycle. Never update a contingency-owned `plan_…-patient` reminder here.\\n\\nRequired: future patient-local reminder_date, reminder_description, reminder_type, action type, reminder_id for updates, and utility_basis for seguimiento.',
|
|
14
14
|
strict: true,
|
|
15
15
|
parameters: {
|
|
16
16
|
type: 'object',
|
|
@@ -26,12 +26,12 @@ const definition = {
|
|
|
26
26
|
reminder_type: {
|
|
27
27
|
type: 'string',
|
|
28
28
|
enum: ['medicamento', 'exámen', 'examen', 'cita', 'check_in', 'seguimiento', 'otros'],
|
|
29
|
-
description: '\'seguimiento\' = the agent chooses to contact later with an exact continuity/engagement question once no datum that could raise the grade is still missing;
|
|
29
|
+
description: '\'seguimiento\' = the agent chooses to contact later with an exact continuity/engagement question once no datum that could raise the grade is still missing; it stays pending until sent, expired, updated or cancelled by you. \'medicamento\', \'cita\', \'examen\'/\'exámen\', and \'otros\' require an explicit patient request or acceptance to be reminded. A mention or scheduled event alone is insufficient. \'check_in\' is legacy compatibility; use seguimiento for new agent-owned follow-ups. Never use this tool to guard unresolved clinical uncertainty—use setContingencyPlan.',
|
|
30
30
|
},
|
|
31
31
|
reminder_action_type: {
|
|
32
32
|
type: 'string',
|
|
33
|
-
enum: ['create', 'update'],
|
|
34
|
-
description: '\'create\' = new reminder. \'update\' = modify an existing reminder (change date/time/description) — pass its id in `reminder_id`.',
|
|
33
|
+
enum: ['create', 'update', 'cancel'],
|
|
34
|
+
description: '\'create\' = new reminder. \'update\' = modify an existing reminder (change date/time/description) — pass its id in `reminder_id`. \'cancel\' = retire a pending seguimiento that the conversation already answered or made unnecessary — pass its id in `reminder_id`; the date and description are ignored.',
|
|
35
35
|
},
|
|
36
36
|
reminder_id: {
|
|
37
37
|
type: ['string', 'null'],
|
|
@@ -51,6 +51,8 @@ const definition = {
|
|
|
51
51
|
const REMINDER_TYPES = definition.parameters.properties.reminder_type.enum;
|
|
52
52
|
const ACTION_TYPES = definition.parameters.properties.reminder_action_type.enum;
|
|
53
53
|
|
|
54
|
+
const MAX_FOLLOW_UP_LEAD_HOURS = 48;
|
|
55
|
+
|
|
54
56
|
const ISO_DATETIME = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?$/;
|
|
55
57
|
const FIRE_AT_FORMATS = ['YYYY-MM-DDTHH:mm:ss', 'YYYY-MM-DDTHH:mm', 'YYYY-MM-DD HH:mm:ss', 'YYYY-MM-DD HH:mm'];
|
|
56
58
|
|
|
@@ -76,6 +78,9 @@ async function handler(args = {}, context = {}) {
|
|
|
76
78
|
}
|
|
77
79
|
const { turnId } = runtime;
|
|
78
80
|
|
|
81
|
+
const requestedAction = (args?.reminder_action_type || '').toLowerCase();
|
|
82
|
+
if (requestedAction === 'cancel') return await cancelSeguimiento(args, runtime);
|
|
83
|
+
|
|
79
84
|
const reminderDate = (args?.reminder_date || '').trim();
|
|
80
85
|
const description = sanitizeVariable(args?.reminder_description || '');
|
|
81
86
|
if (!reminderDate || !description) {
|
|
@@ -116,6 +121,9 @@ async function handler(args = {}, context = {}) {
|
|
|
116
121
|
if (!fireMoment.isAfter(moment.tz(timeZone))) {
|
|
117
122
|
return JSON.stringify({ success: false, error: `reminder_date must be in the future (${timeZone}) — got '${reminderDate}'.`, data: {} });
|
|
118
123
|
}
|
|
124
|
+
if (reminderType === 'seguimiento' && fireMoment.isAfter(moment.tz(timeZone).add(MAX_FOLLOW_UP_LEAD_HOURS, 'hours'))) {
|
|
125
|
+
return JSON.stringify({ success: false, error: `seguimiento must fire within ${MAX_FOLLOW_UP_LEAD_HOURS} hours; ask about a later event in a seguimiento closer to it.`, data: {} });
|
|
126
|
+
}
|
|
119
127
|
const isRoutineOutreach = reminderType === 'seguimiento' || laneType === 'otros';
|
|
120
128
|
if (isRoutineOutreach && isWithinTriageProtectionWindow(fireMoment.toDate())) {
|
|
121
129
|
return JSON.stringify({ success: false, error: 'routine outreach cannot be scheduled during the 07:30-11:00 triage protection window; choose 11:00 or later.', data: {} });
|
|
@@ -202,6 +210,26 @@ async function handler(args = {}, context = {}) {
|
|
|
202
210
|
}
|
|
203
211
|
}
|
|
204
212
|
|
|
213
|
+
async function cancelSeguimiento(args, runtime) {
|
|
214
|
+
const reminderId = typeof args?.reminder_id === 'string' ? args.reminder_id.trim() : '';
|
|
215
|
+
if (!reminderId) {
|
|
216
|
+
return JSON.stringify({ success: false, error: 'cancel requires the reminder_id of a pending seguimiento.', data: {} });
|
|
217
|
+
}
|
|
218
|
+
const { patientCode, trace } = runtime;
|
|
219
|
+
const notPending = () => JSON.stringify({ success: false, error: 'not_pending — only a pending seguimiento listed under «Seguimientos ya programados» can be cancelled.', data: { reminder_id: reminderId } });
|
|
220
|
+
if (!reminderId.startsWith('followup_')) return notPending();
|
|
221
|
+
if (patientCode && !isShadowRun()) {
|
|
222
|
+
const pending = await readPendingFollowUpsForPatient({ code: patientCode });
|
|
223
|
+
if (!pending.some((plan) => plan.reminderId === reminderId)) return notPending();
|
|
224
|
+
if (!await cancelFollowUp({ code: patientCode, reminderId, trace, reason: 'agent_cancelled', isLive: runtime.isLive?.bind(runtime) })) {
|
|
225
|
+
return JSON.stringify({ success: false, error: 'seguimiento cancellation failed; retry', data: { reminder_id: reminderId } });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const existing = Array.isArray(trace.signals?.reminderIntents) ? trace.signals.reminderIntents : [];
|
|
229
|
+
trace.setSignals({ reminderIntents: [...existing, { reminderId, reminderType: 'seguimiento', actionType: 'cancel', channel: 'whatsapp' }] });
|
|
230
|
+
return JSON.stringify({ success: true, data: { reminder_id: reminderId, action_type: 'cancel' } });
|
|
231
|
+
}
|
|
232
|
+
|
|
205
233
|
module.exports = {
|
|
206
234
|
definition,
|
|
207
235
|
handler,
|
|
@@ -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
|
|
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.`,
|
|
16
16
|
strict: false,
|
|
17
17
|
parameters: {
|
|
18
18
|
type: 'object',
|
|
@@ -43,8 +43,10 @@ const {
|
|
|
43
43
|
readPendingReminderForPatient,
|
|
44
44
|
cancelPendingFollowUps,
|
|
45
45
|
markReminderSent,
|
|
46
|
+
cancelFollowUp,
|
|
47
|
+
hasCheckInNear,
|
|
46
48
|
} = require('../clinical/services/clinicalAirtableService');
|
|
47
|
-
const { cancelContingencies
|
|
49
|
+
const { cancelContingencies } = require('../clinical/services/contingencyDispatchService');
|
|
48
50
|
const { isContingencyCancelOnReplyLive } = require('../clinical/flags/contingencyFlags');
|
|
49
51
|
const { isLlmMonitorEnabled } = require('../clinical/flags/llmMonitorConfig');
|
|
50
52
|
|
|
@@ -56,6 +58,8 @@ const { invokeInteractiveRoute } = require('./interactiveRouteService');
|
|
|
56
58
|
|
|
57
59
|
const { PhiProcessor } = require('./PhiProcessor');
|
|
58
60
|
|
|
61
|
+
const FOLLOW_UP_SPACING_MIN = 180;
|
|
62
|
+
|
|
59
63
|
const unbatchedRoutes = {
|
|
60
64
|
__proto__: null,
|
|
61
65
|
flow: 'handleFlow',
|
|
@@ -597,9 +601,10 @@ class NexusMessaging {
|
|
|
597
601
|
const plainText = messageData.body;
|
|
598
602
|
messageData = { ...messageData, body };
|
|
599
603
|
if (chatId) {
|
|
600
|
-
// This is the common ingress for text, media, buttons, lists, and flows. Retire
|
|
601
|
-
//
|
|
602
|
-
|
|
604
|
+
// This is the common ingress for text, media, buttons, lists, and flows. Retire turn-loss
|
|
605
|
+
// recovery here, before specialized handlers split off, so all patient activity counts.
|
|
606
|
+
// A seguimiento survives: the next turn shows it to the agent, who keeps, updates or cancels it.
|
|
607
|
+
await cancelPendingFollowUps({ code: chatId, recoveryOnly: true }).catch(error =>
|
|
603
608
|
logger.error('[processIncomingMessage] Follow-up stand-down failed', { code: chatId, error: error.message })
|
|
604
609
|
);
|
|
605
610
|
if (isContingencyCancelOnReplyLive()) {
|
|
@@ -1045,7 +1050,7 @@ class NexusMessaging {
|
|
|
1045
1050
|
* Delivers a message the agent previously authored and scheduled with schedulePatientReminder.
|
|
1046
1051
|
* The clock only wakes this method; it does not choose copy or timing. Running inside the same
|
|
1047
1052
|
* patient queue as inbound WhatsApp traffic prevents an already-received message from crossing
|
|
1048
|
-
* the follow-up.
|
|
1053
|
+
* the follow-up.
|
|
1049
1054
|
*/
|
|
1050
1055
|
async processScheduledFollowUp(code, { reminderId, firstName, now = new Date() } = {}) {
|
|
1051
1056
|
if (!code || !reminderId) throw new Error('code and reminderId are required');
|
|
@@ -1068,19 +1073,20 @@ class NexusMessaging {
|
|
|
1068
1073
|
}
|
|
1069
1074
|
const expiresAt = plan.expiresAt ? new Date(plan.expiresAt) : null;
|
|
1070
1075
|
if (expiresAt && !Number.isNaN(expiresAt.getTime()) && expiresAt.getTime() <= startedAt.getTime()) {
|
|
1076
|
+
await cancelFollowUp({ code, reminderId, reason: 'expired' });
|
|
1071
1077
|
result = { decision: 'skipped', holdReason: 'expired', accepted: false, delivered: false };
|
|
1072
1078
|
return result;
|
|
1073
1079
|
}
|
|
1074
1080
|
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
result = { decision: 'skipped', holdReason
|
|
1081
|
+
// An open net alone does not hold a seguimiento: the two ask different things. Hold only when
|
|
1082
|
+
// another check-in is about to reach the patient. The fire trigger does not come back for a
|
|
1083
|
+
// skipped row, so a skip retires it with its reason instead of leaving a plan that never sends.
|
|
1084
|
+
const holdReason = await hasCheckInNear({ code, withinMinutes: FOLLOW_UP_SPACING_MIN, excludeReminderId: reminderId })
|
|
1085
|
+
? 'check_in_near'
|
|
1086
|
+
: await routineOutreachCollisionReason(code, { now: startedAt });
|
|
1087
|
+
if (holdReason) {
|
|
1088
|
+
await cancelFollowUp({ code, reminderId, reason: `skipped_${holdReason}` });
|
|
1089
|
+
result = { decision: 'skipped', holdReason, accepted: false, delivered: false };
|
|
1084
1090
|
return result;
|
|
1085
1091
|
}
|
|
1086
1092
|
|