@peopl-health/nexus 5.67.1 → 5.67.2
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.
|
@@ -302,6 +302,7 @@ class BaseLLMProvider {
|
|
|
302
302
|
...this._buildContingencyMessage(promptVariables),
|
|
303
303
|
...this._buildFollowUpPolicyMessage(toolIds),
|
|
304
304
|
...this._buildFollowUpMessage(promptVariables),
|
|
305
|
+
...this._buildFollowUpCheckMessage(toolIds),
|
|
305
306
|
...this._buildClockMessage(promptVariables, interpolatedVariables),
|
|
306
307
|
...messages,
|
|
307
308
|
...this._buildOutreachBriefMessage({ outreachBrief, toolIds }),
|
|
@@ -316,10 +317,10 @@ class BaseLLMProvider {
|
|
|
316
317
|
role: 'developer',
|
|
317
318
|
content: [
|
|
318
319
|
'## Seguimiento, recordatorio y contingencia — no son intercambiables',
|
|
319
|
-
'- SEGUIMIENTO: tú decides volver a contactar para mantener continuidad cuando ya no falta un dato clínico que pueda cambiar el riesgo. Elige cuándo y redacta la pregunta exacta para esa persona. Usa schedulePatientReminder con reminder_type seguimiento y la utility_basis concreta. Prográmalo cuando indicaste una acción (medicamento de venta libre, autocuidado, educación) cuyo efecto se ve en 24–72 h; cuando la paciente ya dio el dato y quieres saber si el síntoma vuelve o cruza un umbral que nombraste; cuando cerraste un episodio que vale la pena revisar; o cuando tiene un evento próximo (inicio de tratamiento, cita, infusión) cuyo resultado importa. No lo programes por inactividad sola; cualquier actividad posterior de la paciente lo cancela y entonces respondes a lo nuevo.',
|
|
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; cualquier actividad posterior de la paciente lo cancela y entonces respondes a lo nuevo.',
|
|
320
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.',
|
|
321
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.',
|
|
322
|
-
'Regla de desempate: si esperas saber “cómo sigue” por continuidad, es seguimiento; si la paciente pidió explícitamente o aceptó que le recuerdes hacer algo, es recordatorio; si esperas un dato porque no tenerlo podría volver insegura la conducta, es contingencia. Una tarea acordada o agendada sin esa petición no es un recordatorio. Una contingencia activa gana sólo mientras siga faltando su dato; cuando la resuelves porque la paciente ya lo dio, la vigilancia que queda es un seguimiento, no una red rearmada.',
|
|
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 no programes un seguimiento aparte mientras siga activa (no se enviaría): pregunta por la visita o el estudio cuando la paciente vuelva a escribir o cuando la red se resuelva.',
|
|
323
324
|
].join('\n'),
|
|
324
325
|
}];
|
|
325
326
|
}
|
|
@@ -421,6 +422,20 @@ class BaseLLMProvider {
|
|
|
421
422
|
return [{ role: 'developer', content: parts.join('\n') }];
|
|
422
423
|
}
|
|
423
424
|
|
|
425
|
+
// Rides behind the history, next to the turn: a rule buried in the policy block above was not applied.
|
|
426
|
+
_buildFollowUpCheckMessage(toolIds = []) {
|
|
427
|
+
if (!(toolIds || []).includes('schedulePatientReminder')) return [];
|
|
428
|
+
return [{
|
|
429
|
+
role: 'developer',
|
|
430
|
+
content: [
|
|
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 o una red activa para eso o si la paciente pidió que no. Pedirle «avísame cómo te va» no lo sustituye.',
|
|
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
|
+
].join('\n'),
|
|
435
|
+
[TAIL_PLACEMENT]: true,
|
|
436
|
+
}];
|
|
437
|
+
}
|
|
438
|
+
|
|
424
439
|
_buildFollowUpMessage(promptVariables) {
|
|
425
440
|
const pending = Array.isArray(promptVariables?.active_follow_ups) ? promptVariables.active_follow_ups : [];
|
|
426
441
|
if (!pending.length) return [];
|
|
@@ -9,6 +9,8 @@ const { OPEN_STATUSES } = require('../../shared/dtos/ManagedSymptom');
|
|
|
9
9
|
const { isShadowRun } = require('../helpers/shadowHelper');
|
|
10
10
|
|
|
11
11
|
const PATIENT_CONTACT_WINDOW = { timeZone: 'America/Mexico_City', startHour: 8, endHour: 21 };
|
|
12
|
+
// Net check-ins start an hour later: 08:00–09:00 is when the daily symptom forms reach patients.
|
|
13
|
+
const CHECK_IN_WINDOW = { ...PATIENT_CONTACT_WINDOW, startHour: 9 };
|
|
12
14
|
const MS_PER_MINUTE = 60 * 1000;
|
|
13
15
|
const MS_PER_HOUR = 60 * MS_PER_MINUTE;
|
|
14
16
|
const EXPIRY_WINDOW_MULTIPLIER = 6;
|
|
@@ -33,12 +35,12 @@ function rawDueAt(createdAt, firesAfterSilenceMinutes) {
|
|
|
33
35
|
|
|
34
36
|
function dueAt(createdAt, firesAfterSilenceMinutes) {
|
|
35
37
|
const raw = rawDueAt(createdAt, firesAfterSilenceMinutes);
|
|
36
|
-
return (clampToHourWindow(raw,
|
|
38
|
+
return (clampToHourWindow(raw, CHECK_IN_WINDOW) || raw).toISOString();
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
function quietHoursShiftMinutes(createdAt, firesAfterSilenceMinutes) {
|
|
40
42
|
const raw = rawDueAt(createdAt, firesAfterSilenceMinutes);
|
|
41
|
-
const clamped = clampToHourWindow(raw,
|
|
43
|
+
const clamped = clampToHourWindow(raw, CHECK_IN_WINDOW);
|
|
42
44
|
if (!clamped) return 0;
|
|
43
45
|
return Math.round((clamped.getTime() - raw.getTime()) / MS_PER_MINUTE);
|
|
44
46
|
}
|
|
@@ -460,4 +462,4 @@ async function applyContingencyEvent({ patientCode, planId, stage, event, at = n
|
|
|
460
462
|
return { applied: true, status: next, teamStageArmed: handoff.armed, ...(handoff.armed ? {} : { teamStageReason: handoff.reason }) };
|
|
461
463
|
}
|
|
462
464
|
|
|
463
|
-
module.exports = { isSettledLadder, armContingency, cancelContingencies, snapshotContingencies, resolveContingency, autoResolveNets, computeExpiresAt, quietHoursShiftMinutes, stampContingencyPlan, teamWindowMinutes, teamDueAt, armEscalationNet, armOpenCeilingNet, applyContingencyEvent, PATIENT_CONTACT_WINDOW };
|
|
465
|
+
module.exports = { isSettledLadder, armContingency, cancelContingencies, snapshotContingencies, resolveContingency, autoResolveNets, computeExpiresAt, quietHoursShiftMinutes, stampContingencyPlan, teamWindowMinutes, teamDueAt, armEscalationNet, armOpenCeilingNet, applyContingencyEvent, PATIENT_CONTACT_WINDOW, CHECK_IN_WINDOW };
|
|
@@ -32,7 +32,7 @@ const definition = {
|
|
|
32
32
|
'program_enrollment',
|
|
33
33
|
'other'
|
|
34
34
|
],
|
|
35
|
-
'description': 'Categoría operativa/administrativa o de derivación del pendiente para ruteo. `scheduling` = agendar, confirmar o reprogramar sesiones del programa de acompañamiento de AUNA, también cuando el paciente acepta, rechaza o pide cambiar un horario que propuso el `[equipo]` (no citas médicas, sesiones de tratamiento ni estudios). `contact_team_member` = el paciente quiere hablar con / pasar un mensaje a / que lo llame de vuelta un miembro del equipo. `program_enrollment` = inscribirse a charlas, talleres, grupos de apoyo o check-ups. Las categorías de ESCALACIÓN CLÍNICA (symptom_persistent, treatment_concern, stale_context) se retiraron — esas escalaciones van por `reportMedicalEscalation`.'
|
|
35
|
+
'description': 'Categoría operativa/administrativa o de derivación del pendiente para ruteo. `scheduling` = agendar, confirmar o reprogramar sesiones del programa de acompañamiento de AUNA, también cuando el paciente acepta, rechaza o pide cambiar un horario que propuso el `[equipo]` (no citas médicas, sesiones de tratamiento ni estudios). `contact_team_member` = el paciente quiere hablar con / pasar un mensaje a / que lo llame de vuelta un miembro del equipo. `program_enrollment` = inscribirse a charlas, talleres, grupos de apoyo o check-ups. Las categorías de ESCALACIÓN CLÍNICA (symptom_persistent, treatment_concern, stale_context) se retiraron — esas escalaciones van por `reportMedicalEscalation`. `non_oncology_medical` = derivar a otra especialidad o un trámite médico no oncológico; no es la vía de un síntoma: un síntoma físico nuevo o que empeora (dolor dental o de mandíbula, ronchas, fiebre, etc.) en un paciente en tratamiento oncológico o que terminó quimio hace poco se registra como síntoma clínico y, si corresponde, se escala con `reportMedicalEscalation`, aunque además lo derives.'
|
|
36
36
|
},
|
|
37
37
|
'resolution_attempted': {
|
|
38
38
|
'anyOf': [
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const crypto = require('node:crypto');
|
|
2
2
|
|
|
3
3
|
const { isShadowRun } = require('../helpers/shadowHelper');
|
|
4
|
-
const { armContingency, computeExpiresAt, stampContingencyPlan, resolveContingency } = require('../services/contingencyDispatchService');
|
|
4
|
+
const { armContingency, computeExpiresAt, stampContingencyPlan, resolveContingency, quietHoursShiftMinutes } = require('../services/contingencyDispatchService');
|
|
5
5
|
const { readSymptomCases, readClusters, readContingencies, storeContingency } = require('../../fhir');
|
|
6
6
|
const { ContingencySafetyNet, ESCALATION_PRIORITIES, RESOLVED_REASONS, RESOLVES_ON, isAgentArmed, isNetActive, isExpired } = require('../../shared/dtos/ContingencySafetyNet');
|
|
7
7
|
const { OPEN_STATUSES } = require('../../shared/dtos/ManagedSymptom');
|
|
@@ -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.\n\n**When NOT to call:** \`arm\` on a target that already has an active plan (fails hard — use \`resolve\` or \`rearm\`) or whose case/cluster is closed/dissolved; \`resolve\` while the concern is still unobserved (closing the case retires its net automatically). This tool does NOT notify the care team — to reach a human, use the escalation tools.\n\n**Returns:** \`arm\`/\`rearm\` → \`{plan_id}\` (\`rearm\` adds \`superseded_plan_ids\` when it replaced a net). \`resolve\` → \`{plan_id}\`, with \`already_resolved: true\` when the net was retired earlier — an idempotent success, not an error. Fails hard with \`case_not_found_or_wrong_patient\` / \`cluster_not_found_or_wrong_patient\`, \`target_not_open\`, \`active_plan_already_exists_for_case\` / \`_for_cluster\` (arm only), \`plan_not_found_or_wrong_patient\`, \`store_failed_retry_next_turn\`, or \`stand_down_failed_retry_next_turn\`.\n\n**Side effects:** records the action on the turn trace; writes the FHIR CarePlan + patient CommunicationRequest + Provenance; arms or deactivates the dispatch row — the check-in goes out over WhatsApp only if the patient stayed silent through its window, and is pushed back each time they write. Nets also stand down when their case closes or cluster dissolves, and expire on their own after a bounded multiple of the check-in window.`,
|
|
15
|
+
description: `**Does:** Manages the contingency safety net guarding an open case or cluster, selected via \`action\`. A net is a single promise: if the patient goes quiet, you write to them again.\n- \`arm\` (default): arms a new net — a CarePlan anchor plus the follow-up check-in that goes out to the patient after true silence.\n- \`resolve\`: stands an active net down because this turn's conversation addressed the guarded concern, or it is no longer clinically relevant. Requires \`plan_id\` + \`resolution_reasoning\`.\n- \`rearm\`: replaces the target's active net with this new definition (fresh silence window anchored now) — same required fields as \`arm\`. Use when new information changes the concern, window, or message.\n\n**Required inputs:** \`arm\`/\`rearm\`: \`concern\`, \`awaiting_characterization\`; at least one of \`related_case_id\` / \`related_cluster_id\`; \`patient_followup_reminder\` \`${STEP_SHAPE}\` (message + a positive \`fires_after_silence_minutes\` <= ${MAX_PATIENT_SILENCE_MIN}, plus \`window_reasoning\`: one sentence on why that wait). \`resolve\`: \`plan_id\` + \`resolution_reasoning\`.\n\n**When to call:** \`arm\` when, read against the symptom's CTCAE grade scale, a datum is still missing that could put the case at a grade above your current read. If everything needed to settle the severity is already known, do not arm. The rubric is the test, not how alarming the symptom sounds.\n- Missing information could move it from G1 to G2/G3 → arm, and wait for exactly that datum.\n- Everything needed to rule out the higher grades is already known → do not arm.\n- Serious AND fully assessed → escalate. A finished assessment needs a person, not a check-in.\n\nFind the gap in the \`grade_scale\` you graded against: which clause of the next grade up can you neither confirm nor rule out? Name that datum in \`awaiting_characterization\`, concrete enough that the next turn can tell whether their message addressed it. It is an answer the patient still owes about the symptom as it stands now — what qualifies it is that it moves the grade, not the form it takes. A pending detail that cannot change the grade (an administrative confirmation, a name, an appointment) is not a reason to arm. Neither is a watch that starts after the patient already gave that datum — whether the symptom comes back, crosses a threshold you named, or eases with what you recommended: that is continuity, not uncertainty, so schedule a \`seguimiento\` with \`schedulePatientReminder\` instead, and do not re-arm a net you just resolved to cover it. Set \`resolves_on\` to match: \`reply\` for an answer they owe, \`observation\` for an event their next message may not carry.\n\nWaiting on the care team does NOT satisfy this test — a non-routine escalation normally arms its own net, so team follow-up is not this tool's job. A \`routine\` escalation does not — it routes to the team's ticket lane and leaves no net, so on those turns the ceiling test decides on its own merits. Do not condition it on predicting whether the patient will go quiet: that cannot be known, and a net they turn out not to need never reaches them — it steps aside every time they write. \`resolve\` when the patient's message answered what the net was waiting to observe; \`rearm\` when the net's definition went stale. 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 do not schedule a separate seguimiento while it is active (it would not be delivered); ask about the visit or test when the patient next writes or once the net resolves. 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',
|
|
@@ -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
|
|
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.`,
|
|
62
62
|
},
|
|
63
63
|
},
|
|
64
64
|
required: ['action'],
|
|
@@ -119,7 +119,7 @@ async function resolveNet(args, runtime) {
|
|
|
119
119
|
return JSON.stringify({ success: true, data: { plan_id: planId } });
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
function buildStep(raw, label) {
|
|
122
|
+
function buildStep(raw, label, armedAt = new Date()) {
|
|
123
123
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
124
124
|
return { error: `${label} is required and must be a non-empty object ${STEP_SHAPE}` };
|
|
125
125
|
}
|
|
@@ -130,6 +130,11 @@ function buildStep(raw, label) {
|
|
|
130
130
|
if (silence > MAX_PATIENT_SILENCE_MIN) {
|
|
131
131
|
return { error: `${label}.fires_after_silence_minutes must be <= ${MAX_PATIENT_SILENCE_MIN} — a check-in beyond ~23h of silence lands outside the WhatsApp session and cannot be delivered as a normal message` };
|
|
132
132
|
}
|
|
133
|
+
// The quiet-hours hold counts too: a window that ends overnight is sent at the morning start, which must still fall inside the session.
|
|
134
|
+
const held = quietHoursShiftMinutes(armedAt, silence);
|
|
135
|
+
if (silence + held > MAX_PATIENT_SILENCE_MIN) {
|
|
136
|
+
return { error: `${label}.fires_after_silence_minutes of ${silence} ends in the hours when check-ins are held, so it would go out ~${Math.round((silence + held) / 60)}h from now, outside the WhatsApp session — choose a window that ends before 21:00 today or no later than ${MAX_PATIENT_SILENCE_MIN} minutes including that hold` };
|
|
137
|
+
}
|
|
133
138
|
const message = (typeof raw.message === 'string' ? raw.message : '').trim();
|
|
134
139
|
if (!message) return { error: `${label}.message is required` };
|
|
135
140
|
const step = {
|
|
@@ -191,10 +196,6 @@ async function handler(args = {}, context = {}) {
|
|
|
191
196
|
return fail('at least one of related_case_id or related_cluster_id is required — a plan must link to a target');
|
|
192
197
|
}
|
|
193
198
|
|
|
194
|
-
const built = buildStep(args?.patient_followup_reminder, 'patient_followup_reminder');
|
|
195
|
-
if (built.error) return fail(built.error, built.data);
|
|
196
|
-
const reminder = built.step;
|
|
197
|
-
|
|
198
199
|
const patientId = runtime.patientCode;
|
|
199
200
|
if (relatedCaseId) {
|
|
200
201
|
const targetCase = (await readSymptomCases({ patientId })).find((c) => c.condition.conditionId === relatedCaseId);
|
|
@@ -233,7 +234,13 @@ async function handler(args = {}, context = {}) {
|
|
|
233
234
|
}
|
|
234
235
|
for (const n of held.filter((n) => isExpired(n))) expiredNets.set(n.carePlan.planId, n);
|
|
235
236
|
|
|
236
|
-
|
|
237
|
+
// Taken after the reads, so a slow read cannot eat into the silence; validation and createdAt share it.
|
|
238
|
+
const armedAt = new Date();
|
|
239
|
+
const built = buildStep(args?.patient_followup_reminder, 'patient_followup_reminder', armedAt);
|
|
240
|
+
if (built.error) return fail(built.error, built.data);
|
|
241
|
+
const reminder = built.step;
|
|
242
|
+
|
|
243
|
+
const now = armedAt.toISOString();
|
|
237
244
|
const planId = adopted ? adopted.carePlan.planId : `plan_${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`;
|
|
238
245
|
const contingency = new ContingencySafetyNet({
|
|
239
246
|
carePlan: {
|