@peopl-health/nexus 5.68.0-dev.12044 → 5.68.0-dev.12084
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/flags/clinicalPatternFloorFlags.js +1 -1
- package/lib/clinical/providers/BaseLLMProvider.js +2 -2
- package/lib/clinical/services/clinicalAirtableService.js +4 -3
- package/lib/clinical/tools/schedulePatientReminderTool.js +4 -2
- package/lib/core/NexusMessaging.js +18 -8
- package/lib/index.d.ts +1 -0
- package/package.json +1 -1
|
@@ -317,7 +317,7 @@ 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
|
|
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 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ó.',
|
|
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
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.',
|
|
@@ -429,7 +429,7 @@ class BaseLLMProvider {
|
|
|
429
429
|
role: 'developer',
|
|
430
430
|
content: [
|
|
431
431
|
'## Antes de entregar tu respuesta',
|
|
432
|
-
'- Si en esta respuesta indicas un medicamento, una medida de cuidado o que contacte a alguien (su médico, el dentista, urgencias), o transmites una indicación del equipo, programa en este turno un seguimiento con schedulePatientReminder (reminder_type seguimiento, utility_basis agreed_action) a 24–48 h: si fue un medicamento o una medida, para saber si funcionó; si fue una cita, visita o estudio, para saber si pudo agendarla o si ya fue y cuándo (el resultado se pregunta más adelante, cuando ya ocurrió). Nunca lo programes a más de 48 h. No lo hagas si ya hay un seguimiento
|
|
432
|
+
'- Si en esta respuesta indicas un medicamento, una medida de cuidado o que contacte a alguien (su médico, el dentista, urgencias), o transmites una indicación del equipo, programa en este turno un seguimiento con schedulePatientReminder (reminder_type seguimiento, utility_basis agreed_action) a 24–48 h: si fue un medicamento o una medida, para saber si funcionó; si fue una cita, visita o estudio, para saber si pudo agendarla o si ya fue y cuándo (el resultado se pregunta más adelante, cuando ya ocurrió). Nunca lo programes a más de 48 h. No lo hagas si ya hay un seguimiento que lo cubra o si la paciente pidió que no; una red abierta no lo impide. Pedirle «avísame cómo te va» no lo sustituye.',
|
|
433
433
|
'- Un síntoma físico nuevo o que empeora en una persona en tratamiento oncológico o que terminó quimio hace poco es clínico aunque parezca de otra especialidad: regístralo como síntoma y escálalo si corresponde; no lo mandes sólo como pedido non_oncology_medical.',
|
|
434
434
|
].join('\n'),
|
|
435
435
|
[TAIL_PLACEMENT]: true,
|
|
@@ -462,8 +462,9 @@ function isAtOrBefore(when, now) {
|
|
|
462
462
|
|
|
463
463
|
/*
|
|
464
464
|
* Another check-in about to reach this patient: a net's patient check-in or another routine
|
|
465
|
-
* follow-up
|
|
466
|
-
*
|
|
465
|
+
* follow-up due within `withinMinutes`. The lower bound is the trigger's own 5-minute lead, so two
|
|
466
|
+
* rows firing together see each other; an unsent row older than that, or expired, was missed and
|
|
467
|
+
* is not coming. Patient-requested reminders are left out; they are what she asked for.
|
|
467
468
|
*/
|
|
468
469
|
async function hasCheckInNear({ code, withinMinutes, excludeReminderId }) {
|
|
469
470
|
const window = Number(withinMinutes);
|
|
@@ -471,7 +472,7 @@ async function hasCheckInNear({ code, withinMinutes, excludeReminderId }) {
|
|
|
471
472
|
const rows = await airtableService.getRecordByFilter(
|
|
472
473
|
Monitoreo_ID,
|
|
473
474
|
'reminder',
|
|
474
|
-
`AND(${fieldEquals('whatsapp_id', code)}, ${PENDING_ROW_FILTER}, OR(RIGHT({schedule_id}, 8) = "-patient", ${ROUTINE_OUTREACH_FILTER}), NOT(${fieldEquals('schedule_id', excludeReminderId)}),
|
|
475
|
+
`AND(${fieldEquals('whatsapp_id', code)}, ${PENDING_ROW_FILTER}, OR(RIGHT({schedule_id}, 8) = "-patient", ${ROUTINE_OUTREACH_FILTER}), NOT(${fieldEquals('schedule_id', excludeReminderId)}), DATETIME_DIFF({reminder_date}, NOW(), 'minutes') >= -5, DATETIME_DIFF({reminder_date}, NOW(), 'minutes') <= ${window}, OR({expires_at} = BLANK(), IS_AFTER({expires_at}, NOW())))`,
|
|
475
476
|
null,
|
|
476
477
|
['schedule_id'],
|
|
477
478
|
);
|
|
@@ -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, 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.',
|
|
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. An open contingency net does not prevent one: the net awaits its datum, the seguimiento asks about what was indicated (e.g. whether a visit or test was booked).\\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',
|
|
@@ -218,9 +218,11 @@ async function cancelSeguimiento(args, runtime) {
|
|
|
218
218
|
const { patientCode, trace } = runtime;
|
|
219
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
220
|
if (!reminderId.startsWith('followup_')) return notPending();
|
|
221
|
-
if (patientCode
|
|
221
|
+
if (patientCode) {
|
|
222
222
|
const pending = await readPendingFollowUpsForPatient({ code: patientCode });
|
|
223
223
|
if (!pending.some((plan) => plan.reminderId === reminderId)) return notPending();
|
|
224
|
+
}
|
|
225
|
+
if (patientCode && !isShadowRun()) {
|
|
224
226
|
if (!await cancelFollowUp({ code: patientCode, reminderId, trace, reason: 'agent_cancelled', isLive: runtime.isLive?.bind(runtime) })) {
|
|
225
227
|
return JSON.stringify({ success: false, error: 'seguimiento cancellation failed; retry', data: { reminder_id: reminderId } });
|
|
226
228
|
}
|
|
@@ -38,7 +38,7 @@ const { preProcessMessages, configureOpenAIProvider, AssistantProcessor, TURN_KI
|
|
|
38
38
|
const { deliveryOutcome } = require('../clinical/AssistantProcessor');
|
|
39
39
|
const { UNASSIGNED, alertOpsForClaimedMessage, coverIfUnassigned, coverUnansweredPatient } = require('../clinical/services/pausedSafetyNetService');
|
|
40
40
|
const { buildOutreachCarrier } = require('../clinical/services/outreachCarrierService');
|
|
41
|
-
const { routineOutreachCollisionReason } = require('../clinical/services/outreachSweepService');
|
|
41
|
+
const { routineOutreachCollisionReason, isWithinTriageProtectionWindow } = require('../clinical/services/outreachSweepService');
|
|
42
42
|
const {
|
|
43
43
|
readPendingReminderForPatient,
|
|
44
44
|
cancelPendingFollowUps,
|
|
@@ -60,6 +60,11 @@ const { PhiProcessor } = require('./PhiProcessor');
|
|
|
60
60
|
|
|
61
61
|
const FOLLOW_UP_SPACING_MIN = 180;
|
|
62
62
|
|
|
63
|
+
async function followUpCollisionReason(code, reminderId, now) {
|
|
64
|
+
if (!reminderId.startsWith('followup_')) return routineOutreachCollisionReason(code, { now });
|
|
65
|
+
return isWithinTriageProtectionWindow(now) ? 'triage_protection_window' : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
63
68
|
const unbatchedRoutes = {
|
|
64
69
|
__proto__: null,
|
|
65
70
|
flow: 'handleFlow',
|
|
@@ -1052,6 +1057,12 @@ class NexusMessaging {
|
|
|
1052
1057
|
* patient queue as inbound WhatsApp traffic prevents an already-received message from crossing
|
|
1053
1058
|
* the follow-up.
|
|
1054
1059
|
*/
|
|
1060
|
+
async _retireSkippedFollowUp(code, reminderId, holdReason) {
|
|
1061
|
+
const retired = await cancelFollowUp({ code, reminderId, reason: `skipped_${holdReason}` });
|
|
1062
|
+
if (!retired) logger.warn('[processScheduledFollowUp] skipped follow-up was not stood down', { reminderId, holdReason });
|
|
1063
|
+
return { decision: 'skipped', holdReason, accepted: false, delivered: false, retired };
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1055
1066
|
async processScheduledFollowUp(code, { reminderId, firstName, now = new Date() } = {}) {
|
|
1056
1067
|
if (!code || !reminderId) throw new Error('code and reminderId are required');
|
|
1057
1068
|
if (typeof firstName !== 'string' || !firstName.trim()) throw new Error('firstName is required');
|
|
@@ -1073,20 +1084,19 @@ class NexusMessaging {
|
|
|
1073
1084
|
}
|
|
1074
1085
|
const expiresAt = plan.expiresAt ? new Date(plan.expiresAt) : null;
|
|
1075
1086
|
if (expiresAt && !Number.isNaN(expiresAt.getTime()) && expiresAt.getTime() <= startedAt.getTime()) {
|
|
1076
|
-
await
|
|
1077
|
-
result = { decision: 'skipped', holdReason: 'expired', accepted: false, delivered: false };
|
|
1087
|
+
result = await this._retireSkippedFollowUp(code, reminderId, 'expired');
|
|
1078
1088
|
return result;
|
|
1079
1089
|
}
|
|
1080
1090
|
|
|
1081
1091
|
// 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.
|
|
1083
|
-
//
|
|
1092
|
+
// another check-in is about to reach the patient. Same-day activity does not hold an agent
|
|
1093
|
+
// seguimiento: the agent saw it on every turn since and kept it. The fire trigger does not come
|
|
1094
|
+
// back for a skipped row, so a skip retires it with its reason instead of leaving it armed.
|
|
1084
1095
|
const holdReason = await hasCheckInNear({ code, withinMinutes: FOLLOW_UP_SPACING_MIN, excludeReminderId: reminderId })
|
|
1085
1096
|
? 'check_in_near'
|
|
1086
|
-
: await
|
|
1097
|
+
: await followUpCollisionReason(code, reminderId, startedAt);
|
|
1087
1098
|
if (holdReason) {
|
|
1088
|
-
await
|
|
1089
|
-
result = { decision: 'skipped', holdReason, accepted: false, delivered: false };
|
|
1099
|
+
result = await this._retireSkippedFollowUp(code, reminderId, holdReason);
|
|
1090
1100
|
return result;
|
|
1091
1101
|
}
|
|
1092
1102
|
|
package/lib/index.d.ts
CHANGED