@peopl-health/nexus 5.71.8 → 5.71.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,12 @@ function escalationAssessmentLive() {
10
10
  return flagOn('ESCALATION_ASSESSMENT_LIVE');
11
11
  }
12
12
 
13
+ function feverPatientTimerLive() {
14
+ return flagOn('FEVER_PATIENT_TIMER_LIVE');
15
+ }
16
+
13
17
  module.exports = {
14
18
  escalationAssessmentLive,
15
19
  escalationUpdateModeLive,
20
+ feverPatientTimerLive,
16
21
  };
@@ -9,17 +9,19 @@ const { armEscalationNet } = require('../services/contingencyDispatchService');
9
9
  const { recordEmergency, writeUnresolvedRequest } = require('../services/clinicalAirtableService');
10
10
  const { activeTriageGateFloor } = require('../services/triageGateFloorService');
11
11
  const { clinicalPatternFloor } = require('../services/clinicalPatternFloorService');
12
- const { escalationAssessmentLive, escalationUpdateModeLive } = require('../flags/escalationFlags');
12
+ const { escalationAssessmentLive, escalationUpdateModeLive, feverPatientTimerLive } = require('../flags/escalationFlags');
13
13
  const { triageGateFloorLive } = require('../flags/triageGateFloorFlags');
14
14
  const { clinicalPatternFloorLive } = require('../flags/clinicalPatternFloorFlags');
15
- const { attachEmergencyRow, escalationAnchorOf, recordEscalationAlarm } = require('../../services/escalationReviewService');
15
+ const { attachEmergencyRow, escalationAnchorOf, recordEscalationAlarm, setAlarmAppliedLevel } = require('../../services/escalationReviewService');
16
16
  const { URGENCY_RANK } = require('../../models/escalationReviewModel');
17
17
  const { objectArg, safeParse, isPlainObject } = require('../../utils/jsonUtils');
18
18
  const { logger } = require('../../utils/logger');
19
19
 
20
+ const FEVER_RULE = 'Fever that is `stat`: ≥38.0 °C within 8 weeks of cytotoxic chemotherapy (or a hematologic cancer, transplant, known neutropenia), any fever within 4 weeks of CAR-T or during bispecific step-up dosing, or sepsis signs with or without fever — say it calmly: "ve a urgencias ahora y di que estás en tratamiento oncológico" (CAR-T / bispecific: call the treating center first). If you don\'t know the treatment or the last dose date, ask in the same message; unknown date on chemo counts as within the window. Fever 37.5–37.9 °C in that window with the patient well is `asap`: remeasure in 30–60 min. ';
21
+
20
22
  const definition = {
21
23
  'name': 'reportMedicalEscalation',
22
- 'description': '**Does:** Escalates a MEDICAL situation to the oncology care team at a chosen urgency, with an explicit justification for the level. Covers the full medical-urgency spectrum — immediate emergencies through asynchronous team review. One-way commit; the payload is INTERNAL — the patient never sees it.\n\n**Required inputs:** `urgency_level` — the FHIR request-priority code (`stat` | `asap` | `urgent` | `routine`), `urgency_justification` (one or two sentences: why this level AND why not the tier above/below — the auditable decision record), `details` (object — `symptom` + `evidence_quote` required; pass `null` when unknown for `category`, `indicators`, `suspected_condition`, `recommended_action`).\n\n**Urgency levels:** FHIR request-priority, a RELATIVE order `routine` < `urgent` < `asap` < `stat` (priority/speed, not a fixed clock). The full per-tier rubric with clinical examples lives on the `urgency_level` input enum — read it there. Out-of-enum values are coerced to `urgent` and disclosed via `coerced_urgency` on the result.\n\n**Neurological constellation override:** TWO OR MORE of — visual change, dizziness/vertigo/loss of balance, inability to walk/stand, focal weakness/numbness, acute confusion, sudden severe headache — reported this turn or across the last 2–3 turns → escalate without a further clarifying question: `asap` by default, `stat` if there is an acute deficit (can\'t walk/stand, sphincter loss, bilateral leg weakness → possible cord compression) or any instability.\n\n**When NOT to call:**\n- Unconfirmed/unspecific symptom — ask ONE characterization question via `DeliverPatientMessage` first.\n- Active mental-health crisis — use `reportCrisis`.\n- Patient shared lab/imaging/values/files — use `reportResultsReceived`.\n- Administrative / operative matter (appointments, insurance, authorizations, letters, transport, scheduling, out-of-scope request) or a non-clinical referral (psico-oncología, paliativos, non-oncology medical) — use `reportUnresolvedRequestv2`.\n- Twice in the same turn for the same episode — already committed; a later datum belongs to the same open escalation.\n\n**After the commit:** the escalation does not end the conversation.\n- **Patient message** (`DeliverPatientMessage`, calm, ≤60 words): acknowledge what they told you → \'Ya avisé al equipo médico\' → ONE concrete next step → a safety net specific to THIS case (1–2 observable signs and what to do if they appear) → availability. Explain the purpose in everyday words. Reassure with the plan (\'ya le avisé al equipo médico para que lo revise\', \'vamos a darle seguimiento contigo\'), never with promises (\'no es nada\', \'no te preocupes\'). No diagnosis names, no lists of alarming symptoms, no \'grave/peligroso\', no timed fallback (\'si en 15 minutos…\'), and no promised contact time — not even \'te contactan hoy en las próximas horas\'; say what the team was asked to do (\'le pedí al equipo médico que lo revise\'), never that someone is already reviewing it, and not when they will reach out.\n- **By level:** `stat` is the ONLY level that sends the patient now — calm and unambiguous: ve a urgencias ahora, acompañado; 911 (or an ambulance) if they are alone with no one to take them, cannot move safely, or are unstable — being alone counts as unsafe transport, and they never drive themselves. Fever that is `stat`: ≥38.0 °C within 8 weeks of cytotoxic chemotherapy (or a hematologic cancer, transplant, known neutropenia), any fever within 4 weeks of CAR-T or during bispecific step-up dosing, or sepsis signs with or without fever — say it calmly: "ve a urgencias ahora y di que estás en tratamiento oncológico" (CAR-T / bispecific: call the treating center first). If you don\'t know the treatment or the last dose date, ask in the same message; unknown date on chemo counts as within the window. Fever 37.5–37.9 °C in that window with the patient well is `asap`: remeasure in 30–60 min. `asap`: do NOT lead with urgencias — it appears only in the conditional safety net (if [sign] appears or they feel much worse). `urgent`: they can stay home; urgencias only as a worsening-sign safety net. `routine`: acknowledgement, what to watch, when to write again.\n- **Keep characterizing (except `stat`):** in the same message ask the ONE question that most helps the team; in later turns keep going, one question per message, and give high-value guidance that does not prescribe (comfort measures already in their plan or indicated by the team, what to have at hand, how to monitor). Each new datum goes to the SAME open escalation, not a new one: raise the level if it makes something more serious plausible; never lower it. In `stat`, after the instruction only confirm they are on their way and accompanied.\n\n**Returns:** a RECEIPT, not a resolution — `{urgency_level, queued_for_team, safety_net_armed, safety_net_reason, team_has_reviewed, means, follow_up}`. `safety_net_armed` is `true`/`false`/`null`, where **`null` means this urgency tier gets no automatic check-in at all** (`routine`); read `follow_up` and make the fallback explicit to the patient whenever nothing is armed. `queued_for_team` says only that the ticket was written; `team_has_reviewed` is always `false` here because nothing on this path can know otherwise. Treat the escalation as OPEN and still your responsibility for the rest of the conversation: never tell the patient the team already reviewed it, and never close the topic because this call succeeded. If `queued_for_team` is `false` the team was NOT notified — say so instead of implying they were. When your `urgency_level` was out-of-enum it is coerced to `urgent` and disclosed via `{coerced_urgency, requested_urgency_level}`. A `details.category` outside the enum is dropped to `null` and disclosed via `{coerced_category, requested_category}`.\n\n**Side effects:** Emits `MEDICAL_ESCALATION_TRIGGERED`; the runtime routes it to the team by level (pages at `stat`/`asap`, ticket at `urgent`/`routine`).',
24
+ 'description': '**Does:** Escalates a MEDICAL situation to the oncology care team at a chosen urgency, with an explicit justification for the level. Covers the full medical-urgency spectrum — immediate emergencies through asynchronous team review. One-way commit; the payload is INTERNAL — the patient never sees it.\n\n**Required inputs:** `urgency_level` — the FHIR request-priority code (`stat` | `asap` | `urgent` | `routine`), `urgency_justification` (one or two sentences: why this level AND why not the tier above/below — the auditable decision record), `details` (object — `symptom` + `evidence_quote` required; pass `null` when unknown for `category`, `indicators`, `suspected_condition`, `recommended_action`).\n\n**Urgency levels:** FHIR request-priority, a RELATIVE order `routine` < `urgent` < `asap` < `stat` (priority/speed, not a fixed clock). The full per-tier rubric with clinical examples lives on the `urgency_level` input enum — read it there. Out-of-enum values are coerced to `urgent` and disclosed via `coerced_urgency` on the result.\n\n**Neurological constellation override:** TWO OR MORE of — visual change, dizziness/vertigo/loss of balance, inability to walk/stand, focal weakness/numbness, acute confusion, sudden severe headache — reported this turn or across the last 2–3 turns → escalate without a further clarifying question: `asap` by default, `stat` if there is an acute deficit (can\'t walk/stand, sphincter loss, bilateral leg weakness → possible cord compression) or any instability.\n\n**When NOT to call:**\n- Unconfirmed/unspecific symptom — ask ONE characterization question via `DeliverPatientMessage` first.\n- Active mental-health crisis — use `reportCrisis`.\n- Patient shared lab/imaging/values/files — use `reportResultsReceived`.\n- Administrative / operative matter (appointments, insurance, authorizations, letters, transport, scheduling, out-of-scope request) or a non-clinical referral (psico-oncología, paliativos, non-oncology medical) — use `reportUnresolvedRequestv2`.\n- Twice in the same turn for the same episode — already committed; a later datum belongs to the same open escalation.\n\n**After the commit:** the escalation does not end the conversation.\n- **Patient message** (`DeliverPatientMessage`, calm, ≤60 words): acknowledge what they told you → \'Ya avisé al equipo médico\' → ONE concrete next step → a safety net specific to THIS case (1–2 observable signs and what to do if they appear) → availability. Explain the purpose in everyday words. Reassure with the plan (\'ya le avisé al equipo médico para que lo revise\', \'vamos a darle seguimiento contigo\'), never with promises (\'no es nada\', \'no te preocupes\'). No diagnosis names, no lists of alarming symptoms, no \'grave/peligroso\', no timed fallback (\'si en 15 minutos…\'), and no promised contact time — not even \'te contactan hoy en las próximas horas\'; say what the team was asked to do (\'le pedí al equipo médico que lo revise\'), never that someone is already reviewing it, and not when they will reach out.\n- **By level:** `stat` is the ONLY level that sends the patient now — calm and unambiguous: ve a urgencias ahora, acompañado; 911 (or an ambulance) if they are alone with no one to take them, cannot move safely, or are unstable — being alone counts as unsafe transport, and they never drive themselves. ' + FEVER_RULE + '`asap`: do NOT lead with urgencias — it appears only in the conditional safety net (if [sign] appears or they feel much worse). `urgent`: they can stay home; urgencias only as a worsening-sign safety net. `routine`: acknowledgement, what to watch, when to write again.\n- **Keep characterizing (except `stat`):** in the same message ask the ONE question that most helps the team; in later turns keep going, one question per message, and give high-value guidance that does not prescribe (comfort measures already in their plan or indicated by the team, what to have at hand, how to monitor). Each new datum goes to the SAME open escalation, not a new one: raise the level if it makes something more serious plausible; never lower it. In `stat`, after the instruction only confirm they are on their way and accompanied.\n\n**Returns:** a RECEIPT, not a resolution — `{urgency_level, queued_for_team, safety_net_armed, safety_net_reason, team_has_reviewed, means, follow_up}`. `safety_net_armed` is `true`/`false`/`null`, where **`null` means this urgency tier gets no automatic check-in at all** (`routine`); read `follow_up` and make the fallback explicit to the patient whenever nothing is armed. `queued_for_team` says only that the ticket was written; `team_has_reviewed` is always `false` here because nothing on this path can know otherwise. Treat the escalation as OPEN and still your responsibility for the rest of the conversation: never tell the patient the team already reviewed it, and never close the topic because this call succeeded. If `queued_for_team` is `false` the team was NOT notified — say so instead of implying they were. When your `urgency_level` was out-of-enum it is coerced to `urgent` and disclosed via `{coerced_urgency, requested_urgency_level}`. A `details.category` outside the enum is dropped to `null` and disclosed via `{coerced_category, requested_category}`.\n\n**Side effects:** Emits `MEDICAL_ESCALATION_TRIGGERED`; the runtime routes it to the team by level (pages at `stat`/`asap`, ticket at `urgent`/`routine`).',
23
25
  'strict': true,
24
26
  'parameters': {
25
27
  'type': 'object',
@@ -153,6 +155,19 @@ const MODE_PROPERTY = {
153
155
 
154
156
  const MODE_DESCRIPTION = '\n\n**mode:** `new` abre un aviso; `update` actualiza la escalación abierta y nunca baja su nivel. Lo que el paciente responde después de escalar va con `update`. El `urgency_level` del recibo es el nivel aplicado: puede ser mayor que el que pediste, nunca menor.';
155
157
 
158
+ const FEVER_RULE_TIMER = 'Fever within 6 weeks (42 days) of cytotoxic chemotherapy (or with a hematologic cancer, transplant, known neutropenia) — ≥38.0 °C, ≤36.0 °C, feeling feverish or chills with no thermometer, or malaise while on an antipyretic or steroid — is `asap` with `category: febrile_neutropenia`. It is `stat` only with a clear, confirmed red flag (clear shortness of breath, clear confusion, strong rigors, mottled skin, bleeding), or any fever within 4 weeks of CAR-T or during bispecific step-up dosing — say it calmly: "ve a urgencias ahora y di que estás en tratamiento oncológico" (CAR-T / bispecific: call the treating center first). If you don\'t know the treatment or the last dose date, ask in the same message. 37.5–37.9 °C in that window is `urgent`: remeasure in 30–60 min. ';
159
+
160
+ const CHEMO_WINDOW_PROPERTY = nullable({
161
+ 'type': 'object',
162
+ 'description': 'Quimioterapia citotóxica, del expediente o de lo que el paciente confirmó. Requerida y confirmada para `febrile_neutropenia`; pasa `null` si no está confirmada. No la supongas.',
163
+ 'properties': {
164
+ 'on_cytotoxic_chemo': { 'type': 'boolean' },
165
+ 'days_since_last_cycle': nullable({ 'type': 'integer' }),
166
+ },
167
+ 'required': ['on_cytotoxic_chemo', 'days_since_last_cycle'],
168
+ 'additionalProperties': false,
169
+ });
170
+
156
171
  const CLINICAL_ASSESSMENT_PROPERTY = nullable({
157
172
  'type': 'object',
158
173
  'description': 'Tu razonamiento clínico, estructurado para auditoría. Todos los campos aceptan `null`. No cambia el nivel: el nivel lo decides tú en `urgency_level`.',
@@ -195,7 +210,8 @@ const CLINICAL_ASSESSMENT_PROPERTY = nullable({
195
210
  function liveDefinition() {
196
211
  const updateMode = escalationUpdateModeLive();
197
212
  const assessment = escalationAssessmentLive();
198
- if (!updateMode && !assessment) return definition;
213
+ const feverTimer = feverPatientTimerLive();
214
+ if (!updateMode && !assessment && !feverTimer) return definition;
199
215
  const live = structuredClone(definition);
200
216
  const { properties, required } = live.parameters;
201
217
  if (updateMode) {
@@ -207,9 +223,21 @@ function liveDefinition() {
207
223
  properties.details.properties.clinical_assessment = CLINICAL_ASSESSMENT_PROPERTY;
208
224
  properties.details.required.push('clinical_assessment');
209
225
  }
226
+ if (feverTimer) {
227
+ live.description = live.description.replace('`recommended_action`).', '`recommended_action`, `chemo_window`).');
228
+ live.description = live.description.replace(FEVER_RULE, FEVER_RULE_TIMER);
229
+ properties.details.properties.chemo_window = CHEMO_WINDOW_PROPERTY;
230
+ properties.details.required.push('chemo_window');
231
+ }
210
232
  return live;
211
233
  }
212
234
 
235
+ function chemoWindowOf(raw) {
236
+ if (!isPlainObject(raw) || typeof raw.on_cytotoxic_chemo !== 'boolean') return null;
237
+ const days = raw.days_since_last_cycle;
238
+ return { onCytotoxicChemo: raw.on_cytotoxic_chemo, daysSinceLastCycle: Number.isInteger(days) && days >= 0 ? days : null };
239
+ }
240
+
213
241
  const yesNo = (value) => (value === true ? 'sí' : value === false ? 'no' : null);
214
242
 
215
243
  function knownExplanationNote(known) {
@@ -306,6 +334,7 @@ async function handler(args, context = {}) {
306
334
  indicators: (Array.isArray(details.indicators) ? details.indicators : [])
307
335
  .filter((indicator) => typeof indicator === 'string' && indicator.trim()),
308
336
  turnId: runtime.turnId || null,
337
+ chemoWindow: chemoWindowOf(details.chemo_window),
309
338
  ...(clinicalAssessment ? { clinicalAssessment } : {}),
310
339
  },
311
340
  })
@@ -324,6 +353,9 @@ async function handler(args, context = {}) {
324
353
  const raisedByPattern = Boolean(patternFloor) && (URGENCY_RANK[patternFloor.level] || 0) > (URGENCY_RANK[flooredLevel] || 0);
325
354
  const urgencyLevel = raisedByPattern ? patternFloor.level : flooredLevel;
326
355
  const raisesOpen = !anchor || (URGENCY_RANK[urgencyLevel] || 0) > (URGENCY_RANK[anchor.urgencyLevel] || 0);
356
+ // Recorded on the alarm now, not only with the emergency row, so a floor-raised level is never
357
+ // treated by the review as the level the bot asked for.
358
+ if (review && (raisedByGate || raisedByPattern)) await setAlarmAppliedLevel({ reviewId: review._id, alarmId, appliedLevel: urgencyLevel });
327
359
 
328
360
  trace.setSafetyDecision({ ...(trace.safetyDecision || {}), escalationKind: 'medical_urgent' });
329
361
  trace.setSignals({
@@ -33,7 +33,7 @@ const { beginInstruction, endInstruction } = require('../services/instructionInF
33
33
  const { QueueReconciliationService } = require('../services/queueReconciliationService');
34
34
  const { releaseHeldMessages, sweepHeldMessages } = require('../services/heldMessageService');
35
35
 
36
- const { BatchingManager } = require('../core/BatchingManager');
36
+ const { BatchingManager, DrainingError } = require('../core/BatchingManager');
37
37
  const { ProcessingPipeline } = require('../core/ProcessingPipeline');
38
38
  const { preProcessMessages, configureOpenAIProvider, AssistantProcessor, TURN_KIND } = require('../clinical');
39
39
  const { deliveryOutcome } = require('../clinical/AssistantProcessor');
@@ -830,7 +830,13 @@ class NexusMessaging {
830
830
  }, shouldContinue, runId);
831
831
  }
832
832
 
833
- async processInstruction(code, instruction, role = 'developer', { triggeredBy } = {}) {
833
+ /*
834
+ * `beforeRun`, when given, is asked under the chat lock right before the turn (and before every retry);
835
+ * a truthy reason vetoes the turn. Such a gated instruction never rides along in another instruction's
836
+ * batch, and is marked processed when vetoed or refused by a draining instance, so it can only ever
837
+ * run behind its own gate.
838
+ */
839
+ async processInstruction(code, instruction, role = 'developer', { triggeredBy, beforeRun = null } = {}) {
834
840
  const receivedAt = Date.now();
835
841
  const messageData = {
836
842
  pushName: 'Instruction',
@@ -840,33 +846,58 @@ class NexusMessaging {
840
846
  fromMe: true,
841
847
  processed: false,
842
848
  origin: 'instruction',
843
- raw: { role },
849
+ raw: beforeRun ? { role, gated: true } : { role },
844
850
  triggeredBy: triggeredBy || null,
845
851
  silent: true,
846
852
  };
847
853
 
848
854
  beginInstruction(code);
849
855
  try {
850
- await this.phiProcessor.inData(
856
+ return await this.phiProcessor.inData(
851
857
  messageData,
852
858
  this._saveMessageFn,
853
859
  async ({ body, _id }) => {
854
860
  if (this.batchingConfig.instructionSettleMs > 0 && !this.batchingManager.isDraining()) {
855
861
  await new Promise(resolve => setTimeout(resolve, this.batchingConfig.instructionSettleMs));
856
862
  }
863
+ const markOwnProcessed = async () => {
864
+ if (!_id) return;
865
+ try {
866
+ await updateMessage({ _id }, { $set: { processed: true } });
867
+ } catch (err) {
868
+ logger.error('[processInstruction] Could not mark a gated instruction processed', { error: err.message, code });
869
+ }
870
+ };
857
871
  let batchIds = [];
858
872
  let outcome = null;
859
873
  let runId = null;
874
+ let gate = null;
875
+ let alreadyAnswered = false;
860
876
  const answerPending = () => {
861
877
  batchIds = [];
862
878
  outcome = null;
863
879
  runId = null;
880
+ gate = null;
881
+ alreadyAnswered = false;
864
882
  return this._executeWithPipeline(code, 'instruction', 'queue',
865
883
  async (preProcessResult, shouldContinue, currentRunId) => {
866
884
  runId = currentRunId;
885
+ if (beforeRun) {
886
+ try {
887
+ const reason = await beforeRun();
888
+ if (reason) gate = { outcome: 'gated', reason };
889
+ } catch (err) {
890
+ gate = { outcome: 'gate_error', error: err.message };
891
+ }
892
+ if (gate) {
893
+ await markOwnProcessed();
894
+ return null;
895
+ }
896
+ }
867
897
  const batch = await this._pendingInstructionBatch(code, _id, body, receivedAt);
868
898
  if (!batch.length) {
869
899
  logger.info('[processInstruction] Instruction already answered by an earlier run', { code, runId });
900
+ alreadyAnswered = true;
870
901
  return null;
871
902
  }
872
903
  batchIds = batch.map(m => m._id).filter(Boolean);
@@ -899,15 +930,26 @@ class NexusMessaging {
899
930
  const needsAnotherTurn = () => batchIds.length > 0
900
931
  && ((outcome === null && this.batchingManager.isAbandoned(runId)) || outcome === 'known_not_sent');
901
932
 
902
- let runResult = await answerPending();
933
+ const answer = async () => {
934
+ try {
935
+ return await answerPending();
936
+ } catch (err) {
937
+ if (beforeRun && err instanceof DrainingError) await markOwnProcessed();
938
+ throw err;
939
+ }
940
+ };
941
+ await answer();
903
942
  if (needsAnotherTurn()) {
904
943
  logger.warn('[processInstruction] Instruction reply was not sent; queueing one more turn', { code, outcome });
905
- runResult = await answerPending();
944
+ await answer();
906
945
  }
907
946
  if (batchIds.length && outcome !== 'sent' && outcome !== 'unknown') {
908
947
  logger.error('[processInstruction] Instruction left unanswered', { code, outcome, instructionIds: batchIds.map(String) });
909
948
  }
910
- return runResult;
949
+ // Only the send outcome goes back: the turn's output was already delivered, and handing it to
950
+ // the caller would invite a second send.
951
+ if (gate) return gate;
952
+ return { outcome: outcome === null && alreadyAnswered ? 'already_answered' : outcome };
911
953
  },
912
954
  );
913
955
  } finally {
@@ -923,7 +965,7 @@ class NexusMessaging {
923
965
  numero: code,
924
966
  origin: 'instruction',
925
967
  message_id: /^instruction_/,
926
- $or: [{ _id: ownId }, { processed: false, createdAt: { $gte: new Date(receivedAt - PENDING_INSTRUCTION_WINDOW_MS) } }],
968
+ $or: [{ _id: ownId }, { processed: false, 'raw.gated': { $ne: true }, createdAt: { $gte: new Date(receivedAt - PENDING_INSTRUCTION_WINDOW_MS) } }],
927
969
  },
928
970
  { sort: { _id: 1 }, select: '_id body processed' },
929
971
  );
package/lib/index.d.ts CHANGED
@@ -272,7 +272,7 @@ declare module '@peopl-health/nexus' {
272
272
  getLlmMonitor(): { start(options?: { scheduleDaily?: boolean }): Promise<{ enabled: boolean }> } | null;
273
273
  initializeLlmMonitor(): Promise<{ enabled: boolean; error?: string } | undefined>;
274
274
  getWorkflowRunner(): WorkflowRunner;
275
- processInstruction(code: string, instruction: string, role?: string, options?: { triggeredBy?: string }): Promise<void>;
275
+ processInstruction(code: string, instruction: string, role?: string, options?: { triggeredBy?: string; beforeRun?: () => Promise<string | null | undefined> | string | null | undefined }): Promise<{ outcome: string | null; reason?: string; error?: string }>;
276
276
  processSystemMessage(code: string, messages: string | string[], role?: string, options?: { triggeredBy?: string; reply?: boolean }): Promise<void>;
277
277
  processOutreach(code: string, options?: { brief?: OutreachBrief | null; triggeredBy?: string | null; reason?: string | null; firstName?: string | null; dryRun?: boolean }): Promise<OutreachDecision>;
278
278
  processScheduledFollowUp(code: string, options: { reminderId: string; firstName: string; now?: Date }): Promise<ScheduledFollowUpResult>;
@@ -16,6 +16,11 @@ const DOCTOR_DECISIONS = ['approved', 'edited_approved', 'needs_intervention', '
16
16
  const DECISION_SOURCES = ['platform', 'registered_by_navigator', 'chat'];
17
17
  const RETURN_REASONS = ['llamar', 'falta_dato', 'no_requiere'];
18
18
 
19
+ const chemoWindowSchema = new mongoose.Schema({
20
+ onCytotoxicChemo: { type: Boolean, default: null },
21
+ daysSinceLastCycle: { type: Number, default: null },
22
+ }, { _id: false });
23
+
19
24
  const alarmSchema = new mongoose.Schema({
20
25
  alarmId: { type: String, default: null },
21
26
  urgencyLevel: { type: String, enum: URGENCY_LEVELS, required: true },
@@ -30,6 +35,7 @@ const alarmSchema = new mongoose.Schema({
30
35
  clinicalAssessment: { type: mongoose.Schema.Types.Mixed, default: null },
31
36
  emergencyRecordId: { type: String, default: null },
32
37
  appliedLevel: { type: String, default: null },
38
+ chemoWindow: { type: chemoWindowSchema, default: null },
33
39
  raisedAt: { type: Date, required: true },
34
40
  }, { _id: false });
35
41
 
@@ -44,13 +50,14 @@ const impressionUpdateSchema = new mongoose.Schema({
44
50
  }, { _id: false });
45
51
 
46
52
  const doctorAlertSchema = new mongoose.Schema({
47
- kind: { type: String, enum: ['initial', 'realert'], required: true },
53
+ kind: { type: String, enum: ['initial', 'realert', 'patient_prompt'], required: true },
48
54
  urgencyLevel: { type: String, enum: URGENCY_LEVELS, default: null },
49
55
  physicianEmail: { type: String, default: null },
50
56
  transport: { type: String, default: null },
51
57
  messageId: { type: String, default: null },
52
58
  error: { type: String, default: null },
53
59
  teamAlerted: { type: Boolean, default: false },
60
+ possiblyDelivered: { type: Boolean, default: false },
54
61
  sentAt: { type: Date, required: true },
55
62
  }, { _id: false });
56
63
 
@@ -198,8 +205,9 @@ const escalationReviewSchema = new mongoose.Schema({
198
205
  draftFailedAt: { type: Date, default: null },
199
206
  doctorAlerts: { type: [doctorAlertSchema], default: [] },
200
207
  doctorAlertDueAt: { type: Date, default: null },
201
- doctorAlertKind: { type: String, enum: ['initial', 'realert', null], default: null },
208
+ doctorAlertKind: { type: String, enum: ['initial', 'realert', 'patient_prompt', null], default: null },
202
209
  doctorAlertAttempts: { type: Number, default: 0 },
210
+ feverPromptStartedAt: { type: Date, default: null },
203
211
  approverViewedAt: { type: Date, default: null },
204
212
  previousReviewId: { type: String, default: null },
205
213
  callLogs: { type: [callLogSchema], default: [] },
@@ -23,19 +23,24 @@ const {
23
23
  const { getPatientAuditLog } = require('../models/patientAuditLogModel');
24
24
  const { isWithin24HourWindow } = require('../helpers/conversationWindowHelper');
25
25
  const { ensureWhatsAppFormat } = require('../helpers/twilioHelper');
26
+ const { DrainingError } = require('../core/BatchingManager');
26
27
  const { generateEscalationDraft } = require('../clinical/services/escalationDraftService');
27
- const { conversationLink, recordEmergency, writeUnresolvedRequest } = require('../clinical/services/clinicalAirtableService');
28
+ const { conversationLink, writeUnresolvedRequest } = require('../clinical/services/clinicalAirtableService');
28
29
  const { renderDoctorMessage, templateSummary, withIdentity } = require('../clinical/helpers/doctorReviewMessageHelper');
29
30
  const { generateKeyQuestions } = require('../clinical/services/escalationQuestionsService');
30
31
  const { drugsNamedIn } = require('../clinical/services/treatmentProposalService');
31
32
  const { characterizationSeedOf } = require('../clinical/helpers/reviewUpdateHelper');
32
33
  const { readNoteFacts, assertionOf } = require('../clinical/services/escalationNoteFactsService');
34
+ const { humanReplyFilter } = require('../clinical/config/safetyNetConfig');
35
+ const { feverPatientTimerLive: feverTimerLive } = require('../clinical/flags/escalationFlags');
36
+ const { composePrompt } = require('../clinical/services/promptComposerService');
33
37
  const { storeContextFacts, readContextFacts } = require('../fhir/services/clinicalContextService');
34
38
 
35
39
  const { getPatientInformation, logPatientChange } = require('./patientService');
36
40
  const { MAX_NOTE_LENGTH, getPatientTasks, openCaseIds, updatePatientTask } = require('./patientTaskService');
41
+ const { countMessages } = require('./messageService');
37
42
  const { getEscalationApprover, listEscalationApprovers } = require('./physicianService');
38
- const { switchThreadStoppedStatus } = require('./threadService');
43
+ const { getThreadInfo, switchThreadStoppedStatus } = require('./threadService');
39
44
  const { effectiveUrgencyOf, responseDeadlineOf, responseDeadlinePolicy } = require('./reviewDeadlineService');
40
45
 
41
46
  const getMessaging = () => require('../core/NexusMessaging');
@@ -49,6 +54,7 @@ const DRAFT_OPERATOR = 'bot:escalationDraft';
49
54
  const DIRECT_CHAT_ATTEMPTS = 3;
50
55
  const RECHECK_DELAY_MS = 60 * 1000;
51
56
  const ALERT_RETRY_DELAY_MS = 2 * 60 * 1000;
57
+ const MAX_ALERT_ATTEMPTS = 3;
52
58
  const BOOKKEEPING = { timestamps: false };
53
59
  const PREVIOUS_REVIEW_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
54
60
  const DUPLICATE_KEY_ERROR = 11000;
@@ -63,7 +69,12 @@ const DRAFTLESS_DECISIONS = ['monitor', 'returned'];
63
69
  const SETTLED_TRAJECTORIES = ['improving', 'stable'];
64
70
  const ALERTED_URGENCIES = ['stat', 'asap'];
65
71
  const ALERT_MARKS = { stat: '🔴', asap: '🟠' };
66
- const DEFAULT_STAT_REALERT_MINUTES = 15;
72
+ const DEFAULT_FEVER_TIMER_MINUTES = 15;
73
+ const DEFAULT_FEVER_CHEMO_WINDOW_DAYS = 42;
74
+ const FEVER_TIMER_PROMPT_ID = 'fever_patient_timer';
75
+ const FEVER_TIMER_OPERATOR = 'fever_patient_timer';
76
+ const FEVER_CATEGORY = 'febrile_neutropenia';
77
+ const STAFF_ORIGINS = ['whatsapp_platform', 'instruction', 'system'];
67
78
  const PRE_DECISION_STATUSES = ['open', 'documenting', 'draft_ready', 'handing_off', 'awaiting_doctor'];
68
79
  const ALERT_OPERATOR = 'bot:escalationAlert';
69
80
  const RECLASSIFIABLE_STATUSES = [...DOCUMENTING_STATUSES, 'awaiting_doctor'];
@@ -541,6 +552,7 @@ async function recordEscalationAlarm({ code, alarm, now = new Date() } = {}) {
541
552
  const joined = await join();
542
553
  if (joined) {
543
554
  await scheduleDoctorAlert(joined, entry, now);
555
+ await armFeverTimerOnJoin(joined, entry, now);
544
556
  if (FOLLOWUP_STATUSES.includes(joined.status)) return await reopenFromFollowUp(joined, 'new_alarm');
545
557
  if (!DOCUMENTING_STATUSES.includes(joined.status)) {
546
558
  logger.error('[EscalationReview] An alarm arrived after the doctor already had the case', {
@@ -597,6 +609,20 @@ async function attachEmergencyRow({ reviewId, alarmId, emergencyRecordId, applie
597
609
  }
598
610
  }
599
611
 
612
+ async function setAlarmAppliedLevel({ reviewId, alarmId, appliedLevel }) {
613
+ try {
614
+ await getEscalationReview().updateOne(
615
+ { _id: reviewId, 'alarms.alarmId': alarmId },
616
+ { $set: { 'alarms.$.appliedLevel': appliedLevel } },
617
+ { timestamps: false },
618
+ );
619
+ } catch (error) {
620
+ logger.error('[EscalationReview] The applied level was not recorded on its alarm', {
621
+ error: error.message, stack: redactedStack(error),
622
+ });
623
+ }
624
+ }
625
+
600
626
  async function openEscalationReview({
601
627
  code,
602
628
  patientCaseRecordId = null,
@@ -892,19 +918,105 @@ async function scheduleDoctorAlert(review, alarm, now) {
892
918
  }
893
919
  }
894
920
 
895
- const statRealertMs = () => {
896
- const minutes = Number(runtimeConfig.get('ESCALATION_REVIEW_STAT_REALERT_MINUTES', DEFAULT_STAT_REALERT_MINUTES));
897
- return (Number.isFinite(minutes) && minutes > 0 ? minutes : DEFAULT_STAT_REALERT_MINUTES) * 60 * 1000;
921
+ const feverTimerMs = () => {
922
+ const minutes = Number(runtimeConfig.get('FEVER_PATIENT_TIMER_MINUTES', DEFAULT_FEVER_TIMER_MINUTES));
923
+ return (Number.isFinite(minutes) && minutes > 0 ? minutes : DEFAULT_FEVER_TIMER_MINUTES) * 60 * 1000;
898
924
  };
899
925
 
900
- function answeredSince(review, since) {
901
- const sinceMs = new Date(since).getTime();
902
- if (!PRE_DECISION_STATUSES.includes(review.status)) return true;
903
- if (review.approverViewedAt && new Date(review.approverViewedAt).getTime() >= sinceMs) return true;
904
- return (review.decisions ?? []).some((decision) => decision.createdAt && new Date(decision.createdAt).getTime() >= sinceMs);
926
+ const feverChemoWindowDays = () => {
927
+ const days = Number(runtimeConfig.get('FEVER_CHEMO_WINDOW_DAYS', DEFAULT_FEVER_CHEMO_WINDOW_DAYS));
928
+ return Number.isFinite(days) && days > 0 ? days : DEFAULT_FEVER_CHEMO_WINDOW_DAYS;
929
+ };
930
+
931
+ const appliedLevelOf = (alarm) => alarm.appliedLevel || alarm.urgencyLevel;
932
+
933
+ const feverAlarmsOf = (review) => currentAlarms(review)
934
+ .filter((alarm) => appliedLevelOf(alarm) === 'asap' && alarm.category === FEVER_CATEGORY);
935
+
936
+ // The timer is only for a patient confirmed on cytotoxic chemotherapy with a known last cycle inside the
937
+ // window; anything short of that (unknown, off chemo, no date, too long ago) leaves it unarmed.
938
+ function chemoWindowGap(alarm) {
939
+ const chemo = alarm?.chemoWindow;
940
+ if (!chemo) return 'chemo_window_missing';
941
+ if (chemo.onCytotoxicChemo !== true) return 'not_on_cytotoxic_chemo';
942
+ const days = chemo.daysSinceLastCycle;
943
+ if (!Number.isInteger(days)) return 'days_since_last_cycle_missing';
944
+ return days >= 0 && days <= feverChemoWindowDays() ? null : 'outside_chemo_window';
905
945
  }
906
946
 
907
- async function prepareDoctorAlert(review, kind, urgencyLevel) {
947
+ function feverTimerGap(review) {
948
+ const fevers = feverAlarmsOf(review);
949
+ if (fevers.some((alarm) => !chemoWindowGap(alarm))) return null;
950
+ return chemoWindowGap(fevers.at(-1));
951
+ }
952
+
953
+ // The timer runs from the first asap alert of this episode, so a retried alert never restarts it and a
954
+ // decision taken after it (which counts as taking the case) never moves it.
955
+ function feverTimerSince(review) {
956
+ const asapAlerts = (review.doctorAlerts ?? []).filter((sent) => sent.kind === 'initial' && sent.urgencyLevel === 'asap');
957
+ if (!asapAlerts.length) return null;
958
+ const lastMs = new Date(asapAlerts.at(-1).sentAt).getTime();
959
+ const decidedMs = Math.max(-Infinity, ...(review.decisions ?? [])
960
+ .map((decision) => new Date(decision.createdAt).getTime())
961
+ .filter((ms) => ms < lastMs));
962
+ return new Date(asapAlerts.find((sent) => new Date(sent.sentAt).getTime() > decidedMs).sentAt);
963
+ }
964
+
965
+ // One prompt per episode: a prompt delivered (or possibly delivered) since the episode began, or one whose
966
+ // turn is still running, blocks another; an errored or dropped attempt does not. `ownStart` is the
967
+ // marker of the run asking, so its own in-flight marker never blocks it.
968
+ function promptedSince(review, since, ownStart = null) {
969
+ const sinceMs = since.getTime();
970
+ const delivered = (review.doctorAlerts ?? [])
971
+ .some((sent) => sent.kind === 'patient_prompt' && (!sent.error || sent.possiblyDelivered) && new Date(sent.sentAt).getTime() >= sinceMs);
972
+ const startedMs = review.feverPromptStartedAt ? new Date(review.feverPromptStartedAt).getTime() : null;
973
+ return delivered || (startedMs != null && startedMs >= sinceMs && startedMs !== ownStart?.getTime());
974
+ }
975
+
976
+ // An approver merely opening the review does not count: only an answer, a stat escalation, a paused
977
+ // thread or a staff member writing to the patient means someone took the case.
978
+ async function feverTakenReason(review, since, ownStart = null) {
979
+ // Staff acting before the fever reached the review (on an earlier asap of the same episode) did not
980
+ // take the fever case: those signals count only from the first asap fever alarm onwards.
981
+ const firstFever = feverAlarmsOf(review)[0];
982
+ const from = new Date(Math.max(since.getTime(), firstFever ? new Date(firstFever.raisedAt).getTime() : -Infinity));
983
+ const atOrAfter = (at) => at != null && new Date(at).getTime() >= from.getTime();
984
+ if (review.closedAt || !PRE_DECISION_STATUSES.includes(review.status)) return 'review_moved_on';
985
+ if (promptedSince(review, since, ownStart)) return 'already_prompted';
986
+ if ((review.decisions ?? []).some((decision) => atOrAfter(decision.createdAt))) return 'decision';
987
+ const stat = (alarm) => alarm.urgencyLevel === 'stat' || alarm.appliedLevel === 'stat';
988
+ if (currentAlarms(review).some(stat)) return 'stat_alarm';
989
+ if ((review.urgencyReclassifications ?? []).some((change) => atOrAfter(change.at))) return 'reclassified';
990
+ if ((review.callLogs ?? []).some((call) => call.outcome === 'contacted' && atOrAfter(call.calledAt))) return 'call_contacted';
991
+ // A read error goes back to the sweep to retry; only a thread that does not exist (no turn to run
992
+ // anyway) skips the prompt.
993
+ const thread = await getThreadInfo(review.patientCode, { rethrow: true });
994
+ if (!thread) return 'thread_missing';
995
+ if (thread.stopped === true) return 'thread_paused';
996
+ // Staff write from the platform or act through add-instruction / add-msg, always under their email;
997
+ // automations (the timer itself included) sign with a name and never count.
998
+ const staffMessages = await countMessages({ ...humanReplyFilter(review.patientCode, from), origin: { $in: STAFF_ORIGINS }, $and: [{ triggeredBy: /@/ }] });
999
+ return staffMessages ? 'staff_message' : null;
1000
+ }
1001
+
1002
+ async function armFeverTimerOnJoin(review, alarm, now) {
1003
+ try {
1004
+ if (!feverTimerLive() || appliedLevelOf(alarm) !== 'asap' || alarm.category !== FEVER_CATEGORY || chemoWindowGap(alarm)) return;
1005
+ if (alertedRankOf(review) !== URGENCY_RANK.asap) return;
1006
+ const since = feverTimerSince(review);
1007
+ if (!since || promptedSince(review, since)) return;
1008
+ // The patient gets the full timer from when the case became eligible, not from the earlier alert.
1009
+ await getEscalationReview().updateOne(
1010
+ { _id: review._id, doctorAlertDueAt: null },
1011
+ { $set: { doctorAlertDueAt: new Date(now.getTime() + feverTimerMs()), doctorAlertKind: 'patient_prompt', doctorAlertAttempts: 0 } },
1012
+ BOOKKEEPING,
1013
+ );
1014
+ } catch (error) {
1015
+ logger.error('[EscalationReview] The fever patient timer was not set for a joining alarm', { escalationReviewId: String(review?._id), error: redactedError(error) });
1016
+ }
1017
+ }
1018
+
1019
+ async function prepareDoctorAlert(review, urgencyLevel) {
908
1020
  const identity = await readDoctorIdentity(review);
909
1021
  if (identity.missing.length) throw new Error(`missing ${identity.missing.join(' and ')}`);
910
1022
  const approver = await soleApprover();
@@ -912,7 +1024,7 @@ async function prepareDoctorAlert(review, kind, urgencyLevel) {
912
1024
  if (!link) throw new Error('missing the interface URL for the chat link');
913
1025
  const alarm = (review.alarms ?? []).findLast((raised) => raised.urgencyLevel === urgencyLevel) ?? review.alarms?.at(-1);
914
1026
  const symptom = inlineOf(alarm?.symptom);
915
- const urgency = [kind === 'realert' && 'Sigue pendiente', urgencyLevel.toUpperCase()].filter(Boolean).join(' · ');
1027
+ const urgency = urgencyLevel.toUpperCase();
916
1028
  const patient = `${identity.patientName} (${identity.treatingDoctor})`;
917
1029
  const message = {
918
1030
  body: `${ALERT_MARKS[urgencyLevel]} ${[urgency, patient, symptom].filter(Boolean).join(' · ')}\n${link}`,
@@ -933,7 +1045,98 @@ const topUrgencyOf = (review) => {
933
1045
  .reduce((top, alarm) => (URGENCY_RANK[alarm.urgencyLevel] > (URGENCY_RANK[top] || 0) ? alarm.urgencyLevel : top), null);
934
1046
  };
935
1047
 
936
- const realertStillDue = (review, initial) => !review.closedAt && Boolean(initial) && !answeredSince(review, initial.sentAt);
1048
+ const SENT_OUTCOMES = ['sent', 'unknown', 'already_answered'];
1049
+
1050
+ async function sendPatientPrompt({ id, release, lease, now }) {
1051
+ const review = await loadReview(reviewIdOf(id));
1052
+ const skip = async (current, reason) => {
1053
+ await release();
1054
+ await audit(current, ALERT_OPERATOR, null, { action: 'patient_prompt_skipped', reason });
1055
+ return null;
1056
+ };
1057
+ if (!feverTimerLive()) return skip(review, 'flag_off');
1058
+ const since = feverTimerSince(review);
1059
+ if (!since) return skip(review, 'no_asap_alert');
1060
+ const taken = await feverTakenReason(review, since);
1061
+ if (taken) return skip(review, taken);
1062
+
1063
+ let directive;
1064
+ try {
1065
+ directive = String((await composePrompt({ promptId: FEVER_TIMER_PROMPT_ID }))?.resolvedPrompt ?? '').trim();
1066
+ } catch (composeError) {
1067
+ // A failed read may be transient: the same slot comes back shortly, and the sweep's attempt cap
1068
+ // drops it (with a ledger entry) if Airtable stays unreachable.
1069
+ logger.error('[EscalationReview] The fever patient prompt could not be read; trying again shortly', { escalationReviewId: String(review._id), error: redactedError(composeError) });
1070
+ await release({ doctorAlertDueAt: new Date(Date.now() + ALERT_RETRY_DELAY_MS), doctorAlertKind: 'patient_prompt' });
1071
+ return null;
1072
+ }
1073
+ let error = directive ? null : `the ${FEVER_TIMER_PROMPT_ID} prompt is empty or missing`;
1074
+ let outcome = null;
1075
+ let startedAt = null;
1076
+ const settle = () => getEscalationReview()
1077
+ .updateOne({ _id: review._id, feverPromptStartedAt: startedAt }, { $set: { feverPromptStartedAt: null } }, BOOKKEEPING);
1078
+ if (error) {
1079
+ logger.error('[EscalationReview] The fever patient prompt has no directive; nothing was sent', { escalationReviewId: String(review._id), error });
1080
+ await release();
1081
+ } else {
1082
+ const fresh = await loadReview(review._id);
1083
+ const takenNow = await feverTakenReason(fresh, since);
1084
+ if (takenNow) return skip(fresh, takenNow);
1085
+ // The instruction runs a whole agent turn, which can outlast the sweep's lease; letting go of the
1086
+ // slot first keeps a second sweep from prompting the patient twice.
1087
+ const released = await release();
1088
+ if (lease && !released?.modifiedCount) {
1089
+ await audit(fresh, ALERT_OPERATOR, null, { action: 'lease_lost' });
1090
+ return null;
1091
+ }
1092
+ // While the turn runs, this marker stops both arming paths from setting a second prompt; it is
1093
+ // cleared once the turn settles, after the ledger entry that takes over is written.
1094
+ startedAt = new Date();
1095
+ await getEscalationReview().updateOne({ _id: review._id }, { $set: { feverPromptStartedAt: startedAt } }, BOOKKEEPING);
1096
+ // No turn ran: the same prompt comes back shortly, under the sweep's attempt cap. A draining
1097
+ // instance refused it before doing anything, so that refusal does not use up an attempt.
1098
+ const rearm = async (why) => {
1099
+ logger.warn('[EscalationReview] The fever patient prompt ran no turn; trying again shortly', { escalationReviewId: String(review._id), why });
1100
+ const attempts = review.doctorAlertAttempts ?? 0;
1101
+ await settle();
1102
+ await getEscalationReview().updateOne(
1103
+ { _id: review._id, doctorAlertDueAt: null },
1104
+ { $set: { doctorAlertDueAt: new Date(Date.now() + ALERT_RETRY_DELAY_MS), doctorAlertKind: 'patient_prompt', doctorAlertAttempts: why === 'draining' ? Math.max(0, attempts - 1) : attempts } },
1105
+ BOOKKEEPING,
1106
+ );
1107
+ return null;
1108
+ };
1109
+ // The gate runs again under the chat lock, right before the turn: a patient turn queued ahead of
1110
+ // this one can raise the case to stat, pause the bot or bring staff in while the prompt waits.
1111
+ const beforeRun = async () => feverTakenReason(await loadReview(review._id), since, startedAt);
1112
+ try {
1113
+ const result = await getMessaging().processInstruction(review.patientCode, directive, 'developer', { triggeredBy: FEVER_TIMER_OPERATOR, beforeRun });
1114
+ outcome = result?.outcome ?? null;
1115
+ if (outcome === 'gated') {
1116
+ await settle();
1117
+ await audit(fresh, ALERT_OPERATOR, null, { action: 'patient_prompt_skipped', reason: result.reason });
1118
+ return null;
1119
+ }
1120
+ if (outcome === 'gate_error') return rearm(result.error ?? 'gate_error');
1121
+ if (outcome === null) return rearm('no_turn');
1122
+ // A send that threw ('threw') may still have reached the patient: recorded as possibly delivered,
1123
+ // so it is never sent again this episode.
1124
+ if (!SENT_OUTCOMES.includes(outcome)) error = `the instruction turn was not sent (outcome: ${outcome})`;
1125
+ } catch (sendError) {
1126
+ // processInstruction swallows send errors (they come back as 'threw'), so a throw here means no
1127
+ // send happened: the same prompt comes back under the sweep's attempt cap.
1128
+ return rearm(sendError instanceof DrainingError ? 'draining' : redactedError(sendError));
1129
+ }
1130
+ if (error) logger.error('[EscalationReview] The fever patient prompt was not sent', { escalationReviewId: String(review._id), error });
1131
+ }
1132
+
1133
+ const sent = { kind: 'patient_prompt', urgencyLevel: 'asap', error, ...(outcome === 'threw' ? { possiblyDelivered: true } : {}), sentAt: now };
1134
+ const updated = await getEscalationReview()
1135
+ .findOneAndUpdate({ _id: review._id }, { $push: { doctorAlerts: sent } }, { ...BOOKKEEPING, new: true }).lean();
1136
+ if (startedAt) await settle();
1137
+ await audit(updated ?? review, ALERT_OPERATOR, null, { action: 'patient_prompted', delivered: !error, outcome });
1138
+ return updated;
1139
+ }
937
1140
 
938
1141
  async function sendScheduledDoctorAlert({ id, kind, lease = null, now = new Date() } = {}) {
939
1142
  const EscalationReview = getEscalationReview();
@@ -941,34 +1144,33 @@ async function sendScheduledDoctorAlert({ id, kind, lease = null, now = new Date
941
1144
  const release = (set = { doctorAlertDueAt: null, doctorAlertKind: null, doctorAlertAttempts: 0 }) => EscalationReview
942
1145
  .updateOne({ _id: id, ...ours }, { $set: set }, BOOKKEEPING);
943
1146
 
944
- const review = await loadReview(reviewIdOf(id));
945
- if (review.closedAt || !PRE_DECISION_STATUSES.includes(review.status)) {
1147
+ // Stat re-alerts were retired; a review that still holds one lets the slot go without sending.
1148
+ if (kind === 'realert') {
946
1149
  await release();
947
1150
  return null;
948
1151
  }
949
- const initial = (review.doctorAlerts ?? []).filter((sent) => sent.kind === 'initial').at(-1);
950
- if (kind === 'realert' && !realertStillDue(review, initial)) {
1152
+ if (kind === 'patient_prompt') return sendPatientPrompt({ id, release, lease, now });
1153
+
1154
+ const review = await loadReview(reviewIdOf(id));
1155
+ if (review.closedAt || !PRE_DECISION_STATUSES.includes(review.status)) {
951
1156
  await release();
952
1157
  return null;
953
1158
  }
954
- const urgencyLevel = kind === 'realert' ? initial.urgencyLevel : topUrgencyOf(review);
1159
+ const urgencyLevel = topUrgencyOf(review);
955
1160
  if (!ALERTED_URGENCIES.includes(urgencyLevel)) {
956
1161
  await release();
957
1162
  return null;
958
1163
  }
959
- if (kind === 'initial' && (URGENCY_RANK[urgencyLevel] || 0) <= alertedRankOf(review)) {
1164
+ if ((URGENCY_RANK[urgencyLevel] || 0) <= alertedRankOf(review)) {
960
1165
  await release();
961
1166
  return null;
962
1167
  }
963
1168
 
964
1169
  let delivery;
965
1170
  try {
966
- const { approver, message } = await prepareDoctorAlert(review, kind, urgencyLevel);
1171
+ const { approver, message } = await prepareDoctorAlert(review, urgencyLevel);
967
1172
  const fresh = await loadReview(review._id);
968
- const stillDue = kind === 'realert'
969
- ? realertStillDue(fresh, initial)
970
- : !fresh.closedAt && PRE_DECISION_STATUSES.includes(fresh.status);
971
- if (!stillDue) {
1173
+ if (fresh.closedAt || !PRE_DECISION_STATUSES.includes(fresh.status)) {
972
1174
  await release();
973
1175
  return null;
974
1176
  }
@@ -978,32 +1180,26 @@ async function sendScheduledDoctorAlert({ id, kind, lease = null, now = new Date
978
1180
  logger.error('[EscalationReview] The doctor alert was not sent', { escalationReviewId: String(review._id), kind, error: delivery.error });
979
1181
  }
980
1182
 
981
- let teamAlerted = false;
982
- if (kind === 'realert') {
983
- const lead = review.alarms?.findLast((alarm) => alarm.urgencyLevel === 'stat') ?? review.alarms?.at(-1);
984
- teamAlerted = Boolean(await recordEmergency({
985
- code: review.patientCode,
986
- trace: null,
987
- symptom: lead?.symptom || 'Revisión clínica STAT',
988
- urgencyFhir: 'stat',
989
- documentation: `Re-aviso: revisión clínica STAT sin respuesta del médico ${Math.round(statRealertMs() / 60000)} min después del aviso.`,
990
- mergeWithRecent: false,
991
- }).catch((error) => {
992
- logger.error('[EscalationReview] The team alert for an unanswered stat review was not filed', { escalationReviewId: String(review._id), error: redactedError(error) });
993
- return false;
994
- }));
995
- }
996
-
997
- const sent = { kind, urgencyLevel, transport: null, messageId: null, physicianEmail: null, error: null, ...delivery, teamAlerted, sentAt: now };
1183
+ const sent = { kind, urgencyLevel, transport: null, messageId: null, physicianEmail: null, error: null, ...delivery, sentAt: now };
998
1184
  const updated = await EscalationReview.findOneAndUpdate({ _id: review._id }, { $push: { doctorAlerts: sent } }, { ...BOOKKEEPING, new: true }).lean();
999
- if (kind === 'initial' && urgencyLevel === 'stat') {
1000
- await release({ doctorAlertDueAt: new Date(now.getTime() + statRealertMs()), doctorAlertKind: 'realert', doctorAlertAttempts: 0 });
1001
- } else if (kind === 'initial' && sent.error) {
1185
+ // A fever asap case gets the patient timer even when the doctor alert failed: the doctor is retried
1186
+ // while there is time before the prompt and attempts left, then the patient prompt is the safety net.
1187
+ // Stat already sends the patient to the ED.
1188
+ const latestReview = updated ?? review;
1189
+ const feverCase = urgencyLevel === 'asap' && feverTimerLive() && feverAlarmsOf(latestReview).length > 0;
1190
+ const feverGap = feverCase ? feverTimerGap(latestReview) : null;
1191
+ const since = feverTimerSince(latestReview) ?? now;
1192
+ const promptAt = feverCase && !feverGap && !promptedSince(latestReview, since) ? since.getTime() + feverTimerMs() : null;
1193
+ const retriesDoctor = sent.error
1194
+ && (promptAt == null || (now.getTime() + ALERT_RETRY_DELAY_MS < promptAt && (review.doctorAlertAttempts ?? 0) < MAX_ALERT_ATTEMPTS));
1195
+ if (promptAt != null && !retriesDoctor) {
1196
+ await release({ doctorAlertDueAt: new Date(promptAt), doctorAlertKind: 'patient_prompt', doctorAlertAttempts: 0 });
1197
+ } else if (sent.error) {
1002
1198
  await release({ doctorAlertDueAt: new Date(Date.now() + ALERT_RETRY_DELAY_MS), doctorAlertKind: 'initial' });
1003
1199
  } else {
1004
1200
  await release();
1005
1201
  }
1006
- if (kind === 'initial' && urgencyLevel !== 'stat' && !sent.error) {
1202
+ if (urgencyLevel !== 'stat' && !sent.error) {
1007
1203
  try {
1008
1204
  const latest = await loadReview(review._id);
1009
1205
  const topNow = topUrgencyOf(latest);
@@ -1018,8 +1214,9 @@ async function sendScheduledDoctorAlert({ id, kind, lease = null, now = new Date
1018
1214
  }
1019
1215
  }
1020
1216
  await audit(updated ?? review, ALERT_OPERATOR, null, {
1021
- action: kind === 'realert' ? 'doctor_realerted' : 'doctor_alerted', urgencyLevel, delivered: !sent.error, teamAlerted,
1217
+ action: 'doctor_alerted', urgencyLevel, delivered: !sent.error,
1022
1218
  });
1219
+ if (feverGap) await audit(updated ?? review, ALERT_OPERATOR, null, { action: 'patient_prompt_not_armed', reason: feverGap });
1023
1220
  return updated;
1024
1221
  }
1025
1222
 
@@ -1840,6 +2037,7 @@ async function syncEscalationReviewCase({ id, syncedBy, requestId = null } = {})
1840
2037
  module.exports = {
1841
2038
  BOOKKEEPING,
1842
2039
  DOCUMENTING_STATUSES,
2040
+ MAX_ALERT_ATTEMPTS,
1843
2041
  PRE_DECISION_STATUSES,
1844
2042
  _resetEscalationReviewIndexes,
1845
2043
  addCallLog,
@@ -1862,6 +2060,7 @@ module.exports = {
1862
2060
  recordReviewUpdate,
1863
2061
  requestBotDraft,
1864
2062
  sendApprovedIndications,
2063
+ setAlarmAppliedLevel,
1865
2064
  setReviewCases,
1866
2065
  suggestDraft,
1867
2066
  suggestKeyQuestions,
@@ -3,13 +3,12 @@ const { createIntervalSweeper } = require('../utils/intervalSweeper');
3
3
  const { withTimeout } = require('../utils/timeoutUtils');
4
4
  const { getEscalationReview } = require('../models/escalationReviewModel');
5
5
 
6
- const { BOOKKEEPING, PRE_DECISION_STATUSES, sendScheduledDoctorAlert } = require('./escalationReviewService');
6
+ const { BOOKKEEPING, MAX_ALERT_ATTEMPTS, PRE_DECISION_STATUSES, sendScheduledDoctorAlert } = require('./escalationReviewService');
7
7
 
8
8
  const SWEEP_LIMIT = 10;
9
9
  const DEFAULT_SWEEP_INTERVAL_MS = 30 * 1000;
10
10
  const LEASE_MS = 5 * 60 * 1000;
11
11
  const SEND_TIMEOUT_MS = 2 * 60 * 1000;
12
- const MAX_ALERT_ATTEMPTS = 3;
13
12
  const SELF_HEAL_WINDOW_MS = 60 * 60 * 1000;
14
13
  const ALERTED_URGENCIES = ['stat', 'asap'];
15
14
 
@@ -63,6 +62,12 @@ async function sweepDoctorAlerts({ now = new Date(), clock = () => new Date() }
63
62
  .lean();
64
63
  if (!claimed) continue;
65
64
  const alert = sendScheduledDoctorAlert({ id: String(reviewId), kind, lease, now: claimedAt });
65
+ if (kind === 'patient_prompt') {
66
+ // A patient prompt runs a whole agent turn; the pass moves on so doctor alerts for other reviews
67
+ // are not held behind it. Its ledger entry and audit are written when the turn settles.
68
+ alert.catch((error) => logger.error('[ReviewAlertSweep] The fever patient prompt failed', { escalationReviewId: String(reviewId), error: error.message }));
69
+ continue;
70
+ }
66
71
  if (await withTimeout(alert, SEND_TIMEOUT_MS, 'doctor alert')) sent += 1;
67
72
  } catch (error) {
68
73
  logger.error('[ReviewAlertSweep] Could not alert the doctor for a review', { escalationReviewId: String(reviewId), error: error.message });
@@ -25,11 +25,12 @@ const getThread = async (code, message = null) => {
25
25
  }
26
26
  };
27
27
 
28
- const getThreadInfo = async (code) => {
28
+ const getThreadInfo = async (code, { rethrow = false } = {}) => {
29
29
  try {
30
30
  return await Thread.findOne({ code });
31
31
  } catch (error) {
32
32
  logger.error('[getThreadInfo] Error', { code, error: error.message });
33
+ if (rethrow) throw error;
33
34
  return null;
34
35
  }
35
36
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.71.8",
3
+ "version": "5.71.9",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",