@peopl-health/nexus 5.71.8 → 5.71.10
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/escalationFlags.js +5 -0
- package/lib/clinical/services/clinicalExtractionService.js +12 -5
- package/lib/clinical/tools/extractClinicalInfoTool.js +27 -1
- package/lib/clinical/tools/reportMedicalEscalationTool.js +36 -4
- package/lib/core/NexusMessaging.js +50 -8
- package/lib/index.d.ts +1 -1
- package/lib/models/escalationReviewModel.js +10 -2
- package/lib/services/escalationReviewService.js +246 -47
- package/lib/services/reviewAlertSweepService.js +7 -2
- package/lib/services/threadService.js +2 -1
- package/lib/utils/retryUtils.js +1 -0
- package/package.json +1 -1
|
@@ -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
|
};
|
|
@@ -78,11 +78,18 @@ async function extractClinicalMentions({ text, turnId, code, provider, detectedT
|
|
|
78
78
|
const { datosClinicos, contextGaps } = await loadPatientContext(code);
|
|
79
79
|
const vagueWords = await getVagueEntityWords();
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
81
|
+
let timed;
|
|
82
|
+
try {
|
|
83
|
+
timed = await withTiming(() => provider.runStructured({
|
|
84
|
+
presetId,
|
|
85
|
+
input: [{ role: 'user', content: buildExtractorInput(text, detectedTags, curatedRows, template, { priorTurns, patientContext: datosClinicos }) }],
|
|
86
|
+
metadata: { extractor: 'clinical-mention', turnId },
|
|
87
|
+
}));
|
|
88
|
+
} catch (err) {
|
|
89
|
+
logger.error('[clinicalExtraction] extractor provider unavailable; returning deterministic safety flags only', { turnId, error: err?.message });
|
|
90
|
+
return { completed: false, unavailable: Boolean(patterns?.length), clinicalMentions: [], safetyFlags: [...flags], safetyFlagSources: sources() };
|
|
91
|
+
}
|
|
92
|
+
const { result, durationMs } = timed;
|
|
86
93
|
trace?.addSubCall({ response: result, durationMs, presetId });
|
|
87
94
|
|
|
88
95
|
if (result?.status && result.status !== 'completed') throw new Error(`clinical extraction incomplete: status=${result.status}`);
|
|
@@ -38,6 +38,8 @@ function nonClinicalRequestSchema() {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
const MAX_CONTEXT_TURNS = 6;
|
|
41
|
+
const EXTRACTOR_UNAVAILABLE = 'extractor_unavailable';
|
|
42
|
+
const ESCALATION_WHILE_UNAVAILABLE = 'described in the patient\'s words and graded on the symptom itself; never mention the extractor or a technical failure, to the patient or the team.';
|
|
41
43
|
|
|
42
44
|
const definition = {
|
|
43
45
|
name: 'extractClinicalInfo',
|
|
@@ -239,13 +241,19 @@ async function handler(args, context = {}) {
|
|
|
239
241
|
});
|
|
240
242
|
}
|
|
241
243
|
|
|
244
|
+
if (!intakeCompleted && extraction.unavailable && !safetyFlags.length && !detectedTags.includes('symptom')) {
|
|
245
|
+
return degradedIntake(trace);
|
|
246
|
+
}
|
|
247
|
+
|
|
242
248
|
if (!intakeCompleted) {
|
|
243
249
|
return JSON.stringify({
|
|
244
250
|
success: false,
|
|
245
251
|
error: 'clinical_intake_incomplete',
|
|
246
252
|
data: {
|
|
247
253
|
safety_flags_received: safetyFlags,
|
|
248
|
-
recovery:
|
|
254
|
+
recovery: extraction.unavailable
|
|
255
|
+
? `The clinical extractor is unavailable and a retry will not succeed. Send what the message reports to the team with reportMedicalEscalation (reportCrisis for a crisis), ${ESCALATION_WHILE_UNAVAILABLE}`
|
|
256
|
+
: 'Retry extractClinicalInfo with the complete patient message and all applicable supported tags.',
|
|
249
257
|
},
|
|
250
258
|
});
|
|
251
259
|
}
|
|
@@ -312,6 +320,10 @@ async function declareNonClinical({ declaredRequest, rawMessage, intakeSource, p
|
|
|
312
320
|
});
|
|
313
321
|
}
|
|
314
322
|
|
|
323
|
+
if (check?.unavailable && !safetyFlags.length) {
|
|
324
|
+
return degradedIntake(trace, { non_clinical_request: declaredRequest });
|
|
325
|
+
}
|
|
326
|
+
|
|
315
327
|
trace?.setSignals?.({ clinicalIntakeCompleted: false, clinicalMentions: [], safetyFlags });
|
|
316
328
|
const supported = [...SUPPORTED_TAGS].sort();
|
|
317
329
|
const reason = found
|
|
@@ -325,6 +337,20 @@ async function declareNonClinical({ declaredRequest, rawMessage, intakeSource, p
|
|
|
325
337
|
});
|
|
326
338
|
}
|
|
327
339
|
|
|
340
|
+
function degradedIntake(trace, data = {}) {
|
|
341
|
+
trace?.setSignals?.({ clinicalIntakeCompleted: true, clinicalIntakeDegraded: EXTRACTOR_UNAVAILABLE, clinicalMentions: [], safetyFlags: [] });
|
|
342
|
+
return JSON.stringify({
|
|
343
|
+
success: true,
|
|
344
|
+
data: {
|
|
345
|
+
...data,
|
|
346
|
+
degraded: true,
|
|
347
|
+
degraded_reasons: [EXTRACTOR_UNAVAILABLE],
|
|
348
|
+
clinical_facts: [],
|
|
349
|
+
agent_hint: `The clinical extractor is unavailable, so nothing from this message was recorded as clinical information. Do not call extractClinicalInfo again this turn. A new or worsening symptom goes to the team with reportMedicalEscalation, ${ESCALATION_WHILE_UNAVAILABLE} Anything else gets a reply only.`,
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
328
354
|
async function scanSafetyFlags(text) {
|
|
329
355
|
try {
|
|
330
356
|
const patterns = await getSafetyFlagPatterns();
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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,
|
|
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
|
|
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
|
|
896
|
-
const minutes = Number(runtimeConfig.get('
|
|
897
|
-
return (Number.isFinite(minutes) && minutes > 0 ? minutes :
|
|
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
|
-
|
|
901
|
-
const
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
945
|
-
if (
|
|
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
|
-
|
|
950
|
-
|
|
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 =
|
|
1159
|
+
const urgencyLevel = topUrgencyOf(review);
|
|
955
1160
|
if (!ALERTED_URGENCIES.includes(urgencyLevel)) {
|
|
956
1161
|
await release();
|
|
957
1162
|
return null;
|
|
958
1163
|
}
|
|
959
|
-
if (
|
|
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,
|
|
1171
|
+
const { approver, message } = await prepareDoctorAlert(review, urgencyLevel);
|
|
967
1172
|
const fresh = await loadReview(review._id);
|
|
968
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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 (
|
|
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:
|
|
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/lib/utils/retryUtils.js
CHANGED
|
@@ -53,6 +53,7 @@ function isAbortError(error) {
|
|
|
53
53
|
function isRetryableError(error) {
|
|
54
54
|
if (!error) return false;
|
|
55
55
|
if (isAbortError(error)) return false;
|
|
56
|
+
if (error.code === 'insufficient_quota') return false;
|
|
56
57
|
|
|
57
58
|
const status = error?.status;
|
|
58
59
|
const code = error?.code;
|