@peopl-health/nexus 5.71.12 → 5.71.13
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/helpers/chemoContextHelper.js +6 -1
- package/lib/clinical/helpers/clinicalProfileHelper.js +18 -4
- package/lib/clinical/tools/reportMedicalEscalationTool.js +11 -21
- package/lib/models/escalationReviewModel.js +0 -6
- package/lib/services/escalationReviewService.js +22 -20
- package/package.json +1 -1
|
@@ -25,6 +25,7 @@ function normalize(raw) {
|
|
|
25
25
|
const dated = (value, keys) => (isPlainObject(value) && isDay(value.fecha) && keys.every((k) => k in value) ? value : null);
|
|
26
26
|
return {
|
|
27
27
|
ultimaAplicacion: last,
|
|
28
|
+
ultimaCitotoxica: dayOr(raw.ultimaCitotoxica),
|
|
28
29
|
esquema: list(raw.esquema).filter(isPlainObject).filter((e) => text(e.farmaco)),
|
|
29
30
|
vigilancia: texts(raw.vigilancia),
|
|
30
31
|
nadirDias: nadir,
|
|
@@ -50,6 +51,10 @@ function parseChemoContext(raw) {
|
|
|
50
51
|
return isPlainObject(parsed) && parsed.version === 1 ? normalize(parsed) : null;
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
function daysSinceLastCytotoxic(context, today = mexicoDay(new Date())) {
|
|
55
|
+
return context?.ultimaCitotoxica ? daysBetween(context.ultimaCitotoxica, today) : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
53
58
|
function chemoStatus(context, today) {
|
|
54
59
|
const last = context.ultimaAplicacion?.fecha;
|
|
55
60
|
if (!last) return { active: false, days: null };
|
|
@@ -162,4 +167,4 @@ function withChemoContext(clinical, context, today = mexicoDay(new Date())) {
|
|
|
162
167
|
}
|
|
163
168
|
}
|
|
164
169
|
|
|
165
|
-
module.exports = { parseChemoContext, renderChemoContext, withChemoContext };
|
|
170
|
+
module.exports = { daysSinceLastCytotoxic, parseChemoContext, renderChemoContext, withChemoContext };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { resolveProfileIdentity } = require('../services/patientProfileService');
|
|
2
2
|
const { safeParse } = require('../../utils/jsonUtils');
|
|
3
|
+
const { parseChemoContext } = require('./chemoContextHelper');
|
|
3
4
|
|
|
4
5
|
const CONTEXT_FIELD = 'clinical-context-json';
|
|
5
6
|
|
|
@@ -32,15 +33,28 @@ function richestProfile(records) {
|
|
|
32
33
|
return parsed[0].profile;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
// null: no profile; undefined: unreadable
|
|
37
|
+
async function readResolvedRecord(code) {
|
|
36
38
|
try {
|
|
37
39
|
const identity = await resolveProfileIdentity(code);
|
|
38
40
|
if (identity.kind === 'missing') return null;
|
|
39
|
-
|
|
40
|
-
return richestProfile([identity.record]);
|
|
41
|
+
return identity.kind === 'resolved' ? identity.record : undefined;
|
|
41
42
|
} catch {
|
|
42
43
|
return undefined;
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
async function readClinicalProfile(code) {
|
|
48
|
+
const record = await readResolvedRecord(code);
|
|
49
|
+
return record ? richestProfile([record]) : record;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// null: no chemo record; undefined: unreadable or unparseable
|
|
53
|
+
async function readRecordedChemo(code) {
|
|
54
|
+
const record = await readResolvedRecord(code);
|
|
55
|
+
if (!record) return record;
|
|
56
|
+
if (!record.quimio_context) return null;
|
|
57
|
+
return parseChemoContext(record.quimio_context) ?? undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { readClinicalProfile, readRecordedChemo, richestProfile };
|
|
@@ -17,6 +17,10 @@ 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_RUBRIC_STAT = 'neutropenia febril CON inestabilidad, fiebre ≥38.0 °C en la ventana de quimioterapia descrita en la regla de fiebre,';
|
|
21
|
+
|
|
22
|
+
const FEVER_RUBRIC_ASAP = 'riesgo de infección sin fiebre ≥38.0 °C en esa ventana (p. ej. 37.5–37.9 °C con el paciente bien),';
|
|
23
|
+
|
|
20
24
|
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
25
|
|
|
22
26
|
const definition = {
|
|
@@ -34,7 +38,7 @@ const definition = {
|
|
|
34
38
|
'urgent',
|
|
35
39
|
'routine'
|
|
36
40
|
],
|
|
37
|
-
'description': 'Tier of medical escalation — the FHIR request-priority code (RequestPriority), a RELATIVE urgency ordered routine < urgent < asap < stat. It encodes priority / speed-of-action, NOT a fixed time window — the per-case clock is safety_gate.max_action_time_hours. 1:1 with the gate route. In urgency_justification say why this level and not the one above or below.\n- `stat` (inmediato, gate emergencia) — acción inmediata, prioridad máxima, flujo de emergencia. Inestabilidad o amenaza vital:
|
|
41
|
+
'description': 'Tier of medical escalation — the FHIR request-priority code (RequestPriority), a RELATIVE urgency ordered routine < urgent < asap < stat. It encodes priority / speed-of-action, NOT a fixed time window — the per-case clock is safety_gate.max_action_time_hours. 1:1 with the gate route. In urgency_justification say why this level and not the one above or below.\n- `stat` (inmediato, gate emergencia) — acción inmediata, prioridad máxima, flujo de emergencia. Inestabilidad o amenaza vital: ' + FEVER_RUBRIC_STAT + ' disnea severa, alteración del estado mental, sangrado mayor, compresión medular con déficit agudo.\n- `asap` (lo antes posible, gate urgencias) — máxima prioridad NO inmediata; objetivo interno de valoración en horas (no se le promete al paciente), porque la demora puede crear riesgo; el paciente no va a urgencias salvo que aparezca una señal de la red de seguridad. Complicación seria sospechada pero aún no claramente inestable: dolor que empeora rápido, ' + FEVER_RUBRIC_ASAP + ' síntoma neurológico nuevo sin déficit agudo.\n- `urgent` (urgente, gate equipo_tratante_hoy) — objetivo interno de revisión en 24 h (no se le promete al paciente), por encima de rutina porque la demora podría importar clínicamente; el paciente puede seguir en casa con una red de seguridad: síntoma nuevo moderado, anormalidad de laboratorio preocupante pero estable, toxicidad de tratamiento que necesita input oportuno.\n- `routine` (rutina, sin gate o verdict suavizado) — prioridad normal, vía de atención habitual. Sin riesgo por demora: seguimiento, aclaración de medicación, síntoma >72h sin empeorar, contexto clínico desactualizado.'
|
|
38
42
|
},
|
|
39
43
|
'patient_check_in': {
|
|
40
44
|
'anyOf': [
|
|
@@ -157,16 +161,9 @@ const MODE_DESCRIPTION = '\n\n**mode:** `new` abre un aviso; `update` actualiza
|
|
|
157
161
|
|
|
158
162
|
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
163
|
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
});
|
|
164
|
+
const FEVER_RUBRIC_STAT_TIMER = 'fiebre en la ventana de quimioterapia con una bandera roja confirmada (regla de fiebre),';
|
|
165
|
+
|
|
166
|
+
const FEVER_RUBRIC_ASAP_TIMER = 'fiebre ≥38.0 °C o ≤36.0 °C en la ventana de quimioterapia sin bandera roja (regla de fiebre),';
|
|
170
167
|
|
|
171
168
|
const CLINICAL_ASSESSMENT_PROPERTY = nullable({
|
|
172
169
|
'type': 'object',
|
|
@@ -224,20 +221,14 @@ function liveDefinition() {
|
|
|
224
221
|
properties.details.required.push('clinical_assessment');
|
|
225
222
|
}
|
|
226
223
|
if (feverTimer) {
|
|
227
|
-
live.description = live.description.replace('`recommended_action`).', '`recommended_action`, `chemo_window`).');
|
|
228
224
|
live.description = live.description.replace(FEVER_RULE, FEVER_RULE_TIMER);
|
|
229
|
-
properties.
|
|
230
|
-
|
|
225
|
+
properties.urgency_level.description = properties.urgency_level.description
|
|
226
|
+
.replace(FEVER_RUBRIC_STAT, FEVER_RUBRIC_STAT_TIMER)
|
|
227
|
+
.replace(FEVER_RUBRIC_ASAP, FEVER_RUBRIC_ASAP_TIMER);
|
|
231
228
|
}
|
|
232
229
|
return live;
|
|
233
230
|
}
|
|
234
231
|
|
|
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
|
-
|
|
241
232
|
const yesNo = (value) => (value === true ? 'sí' : value === false ? 'no' : null);
|
|
242
233
|
|
|
243
234
|
function knownExplanationNote(known) {
|
|
@@ -334,7 +325,6 @@ async function handler(args, context = {}) {
|
|
|
334
325
|
indicators: (Array.isArray(details.indicators) ? details.indicators : [])
|
|
335
326
|
.filter((indicator) => typeof indicator === 'string' && indicator.trim()),
|
|
336
327
|
turnId: runtime.turnId || null,
|
|
337
|
-
chemoWindow: chemoWindowOf(details.chemo_window),
|
|
338
328
|
...(clinicalAssessment ? { clinicalAssessment } : {}),
|
|
339
329
|
},
|
|
340
330
|
})
|
|
@@ -16,11 +16,6 @@ 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
|
-
|
|
24
19
|
const alarmSchema = new mongoose.Schema({
|
|
25
20
|
alarmId: { type: String, default: null },
|
|
26
21
|
urgencyLevel: { type: String, enum: URGENCY_LEVELS, required: true },
|
|
@@ -35,7 +30,6 @@ const alarmSchema = new mongoose.Schema({
|
|
|
35
30
|
clinicalAssessment: { type: mongoose.Schema.Types.Mixed, default: null },
|
|
36
31
|
emergencyRecordId: { type: String, default: null },
|
|
37
32
|
appliedLevel: { type: String, default: null },
|
|
38
|
-
chemoWindow: { type: chemoWindowSchema, default: null },
|
|
39
33
|
raisedAt: { type: Date, required: true },
|
|
40
34
|
}, { _id: false });
|
|
41
35
|
|
|
@@ -5,7 +5,7 @@ const { httpError, requiredText } = require('../utils/httpErrorUtils');
|
|
|
5
5
|
const { inlineOf } = require('../utils/coercionUtils');
|
|
6
6
|
const { logger } = require('../utils/logger');
|
|
7
7
|
const { PATIENT_CODE, redactDirectIdentifiers } = require('../utils/sanitizerUtils');
|
|
8
|
-
const { isoDay } = require('../utils/dateUtils');
|
|
8
|
+
const { isoDay, mexicoDay } = require('../utils/dateUtils');
|
|
9
9
|
const {
|
|
10
10
|
CALL_OUTCOMES,
|
|
11
11
|
CANCELLABLE_AFTER_HANDOFF_STATUSES,
|
|
@@ -33,6 +33,9 @@ const { characterizationSeedOf } = require('../clinical/helpers/reviewUpdateHelp
|
|
|
33
33
|
const { readNoteFacts, assertionOf } = require('../clinical/services/escalationNoteFactsService');
|
|
34
34
|
const { humanReplyFilter } = require('../clinical/config/safetyNetConfig');
|
|
35
35
|
const { feverPatientTimerLive: feverTimerLive } = require('../clinical/flags/escalationFlags');
|
|
36
|
+
const { chemoContextLive } = require('../clinical/flags/clinicalContextFlags');
|
|
37
|
+
const { daysSinceLastCytotoxic } = require('../clinical/helpers/chemoContextHelper');
|
|
38
|
+
const { readRecordedChemo } = require('../clinical/helpers/clinicalProfileHelper');
|
|
36
39
|
const { composePrompt } = require('../clinical/services/promptComposerService');
|
|
37
40
|
const { storeContextFacts, readContextFacts } = require('../fhir/services/clinicalContextService');
|
|
38
41
|
|
|
@@ -933,21 +936,14 @@ const appliedLevelOf = (alarm) => alarm.appliedLevel || alarm.urgencyLevel;
|
|
|
933
936
|
const feverAlarmsOf = (review) => currentAlarms(review)
|
|
934
937
|
.filter((alarm) => appliedLevelOf(alarm) === 'asap' && alarm.category === FEVER_CATEGORY);
|
|
935
938
|
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
if (!chemo) return '
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
return days >= 0 && days <= feverChemoWindowDays() ? null : 'outside_chemo_window';
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
function feverTimerGap(review) {
|
|
948
|
-
const fevers = feverAlarmsOf(review);
|
|
949
|
-
if (fevers.some((alarm) => !chemoWindowGap(alarm))) return null;
|
|
950
|
-
return chemoWindowGap(fevers.at(-1));
|
|
939
|
+
async function feverTimerGap(patientCode, now) {
|
|
940
|
+
if (!chemoContextLive()) return 'chemo_context_off';
|
|
941
|
+
const chemo = await readRecordedChemo(patientCode);
|
|
942
|
+
if (chemo === undefined) return 'chemo_record_unreadable';
|
|
943
|
+
if (!chemo?.ultimaCitotoxica) return 'no_recorded_cytotoxic';
|
|
944
|
+
const days = daysSinceLastCytotoxic(chemo, mexicoDay(now));
|
|
945
|
+
if (days < 0) return 'last_cycle_in_future';
|
|
946
|
+
return days > feverChemoWindowDays() ? 'outside_chemo_window' : null;
|
|
951
947
|
}
|
|
952
948
|
|
|
953
949
|
// The timer runs from the first asap alert of this episode, so a retried alert never restarts it and a
|
|
@@ -1001,10 +997,16 @@ async function feverTakenReason(review, since, ownStart = null) {
|
|
|
1001
997
|
|
|
1002
998
|
async function armFeverTimerOnJoin(review, alarm, now) {
|
|
1003
999
|
try {
|
|
1004
|
-
if (!feverTimerLive() || appliedLevelOf(alarm) !== 'asap' || alarm.category !== FEVER_CATEGORY
|
|
1000
|
+
if (!feverTimerLive() || appliedLevelOf(alarm) !== 'asap' || alarm.category !== FEVER_CATEGORY) return;
|
|
1005
1001
|
if (alertedRankOf(review) !== URGENCY_RANK.asap) return;
|
|
1006
1002
|
const since = feverTimerSince(review);
|
|
1007
1003
|
if (!since || promptedSince(review, since)) return;
|
|
1004
|
+
if (review.doctorAlertDueAt) return;
|
|
1005
|
+
const gap = await feverTimerGap(review.patientCode, now);
|
|
1006
|
+
if (gap) {
|
|
1007
|
+
await audit(review, ALERT_OPERATOR, null, { action: 'patient_prompt_not_armed', reason: gap });
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1008
1010
|
// The patient gets the full timer from when the case became eligible, not from the earlier alert.
|
|
1009
1011
|
await getEscalationReview().updateOne(
|
|
1010
1012
|
{ _id: review._id, doctorAlertDueAt: null },
|
|
@@ -1186,10 +1188,10 @@ async function sendScheduledDoctorAlert({ id, kind, lease = null, now = new Date
|
|
|
1186
1188
|
// while there is time before the prompt and attempts left, then the patient prompt is the safety net.
|
|
1187
1189
|
// Stat already sends the patient to the ED.
|
|
1188
1190
|
const latestReview = updated ?? review;
|
|
1189
|
-
const feverCase = urgencyLevel === 'asap' && feverTimerLive() && feverAlarmsOf(latestReview).length > 0;
|
|
1190
|
-
const feverGap = feverCase ? feverTimerGap(latestReview) : null;
|
|
1191
1191
|
const since = feverTimerSince(latestReview) ?? now;
|
|
1192
|
-
const
|
|
1192
|
+
const feverCase = urgencyLevel === 'asap' && feverTimerLive() && feverAlarmsOf(latestReview).length > 0 && !promptedSince(latestReview, since);
|
|
1193
|
+
const feverGap = feverCase ? await feverTimerGap(review.patientCode, now) : null;
|
|
1194
|
+
const promptAt = feverCase && !feverGap ? since.getTime() + feverTimerMs() : null;
|
|
1193
1195
|
const retriesDoctor = sent.error
|
|
1194
1196
|
&& (promptAt == null || (now.getTime() + ALERT_RETRY_DELAY_MS < promptAt && (review.doctorAlertAttempts ?? 0) < MAX_ALERT_ATTEMPTS));
|
|
1195
1197
|
if (promptAt != null && !retriesDoctor) {
|