@peopl-health/nexus 6.3.0-dev.18380 → 6.3.0-dev.18429
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/services/escalationDraftService.js +36 -3
- package/lib/clinical/services/treatmentProposalService.js +1 -0
- package/lib/models/escalationReviewModel.js +30 -0
- package/lib/services/characterizationOutcomeService.js +1 -2
- package/lib/services/escalationReviewService.js +2 -1
- package/lib/services/gravePendingService.js +0 -1
- package/lib/services/reviewDeadlineService.js +1 -1
- package/lib/services/untakenAlertService.js +1 -2
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { getEscalationDraft } = require('../config/subAgentsConfig');
|
|
2
2
|
|
|
3
|
-
const { FORMULARY_VERSION, TREATMENT_FORMULARY } = require('../protocols/treatmentFormulary');
|
|
3
|
+
const { FORMULARY_VERSION, TREATMENT_FORMULARY, findDrug, formularyFor } = require('../protocols/treatmentFormulary');
|
|
4
4
|
|
|
5
5
|
const { createLLMProvider } = require('../providers/createLLMProvider');
|
|
6
6
|
|
|
@@ -9,12 +9,12 @@ const { readStructuredText } = require('../helpers/structuredTextHelper');
|
|
|
9
9
|
|
|
10
10
|
const { getEscalationContext } = require('../../services/escalationContextService');
|
|
11
11
|
|
|
12
|
-
const { normalizedKeyOf } = require('../../utils/coercionUtils');
|
|
12
|
+
const { inlineOf, normalizedKeyOf } = require('../../utils/coercionUtils');
|
|
13
13
|
const { logger } = require('../../utils/logger');
|
|
14
14
|
const { arrayArg, safeParse } = require('../../utils/jsonUtils');
|
|
15
15
|
const { withTiming } = require('../../utils/tracingDecorator');
|
|
16
16
|
|
|
17
|
-
const { crossSymptomIssues, drugsNamedIn, unproposedDrugsIn, validateProposal } = require('./treatmentProposalService');
|
|
17
|
+
const { JUSTIFICATION_KINDS, crossSymptomIssues, drugsNamedIn, unproposedDrugsIn, validateProposal } = require('./treatmentProposalService');
|
|
18
18
|
|
|
19
19
|
const CONVERSATION_TURNS = 40;
|
|
20
20
|
const TRIAGE_LIMIT = 3;
|
|
@@ -46,6 +46,38 @@ function settleProposal(parsed) {
|
|
|
46
46
|
|
|
47
47
|
const textList = (value) => (Array.isArray(value) ? value.map(trimmed).filter(Boolean) : []);
|
|
48
48
|
|
|
49
|
+
function symptomRecordOf({ block, proposal, conduct }, confirmed) {
|
|
50
|
+
const { present, absent, unassessed } = flagsByStatus(block);
|
|
51
|
+
const entry = proposal && formularyFor(proposal.symptom);
|
|
52
|
+
const itemOf = (item) => {
|
|
53
|
+
const drug = findDrug(entry, item);
|
|
54
|
+
return {
|
|
55
|
+
drug: drug.drug,
|
|
56
|
+
brand: drug.brand ?? null,
|
|
57
|
+
strength: drug.strength ?? null,
|
|
58
|
+
instructions: drug.instructions ?? null,
|
|
59
|
+
position: drug.position,
|
|
60
|
+
justification: trimmed(item.justification?.text) || null,
|
|
61
|
+
justificationKind: JUSTIFICATION_KINDS.includes(item.justification?.kind) ? item.justification.kind : null,
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
return {
|
|
65
|
+
symptom: inlineOf(block.symptom) || null,
|
|
66
|
+
grade: [1, 2, 3, 4, 5].includes(block.grade) ? block.grade : null,
|
|
67
|
+
presentRedFlags: present,
|
|
68
|
+
absentRedFlags: absent,
|
|
69
|
+
unassessedRedFlags: unassessed,
|
|
70
|
+
proposal: proposal ? {
|
|
71
|
+
formularyKey: entry.key,
|
|
72
|
+
items: proposal.items.map(itemOf),
|
|
73
|
+
conditions: textList(proposal.conditions),
|
|
74
|
+
ruledOut: textList(proposal.ruledOut),
|
|
75
|
+
confirmed,
|
|
76
|
+
} : null,
|
|
77
|
+
conduct: conduct ?? null,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
49
81
|
function suggestCallOf(value) {
|
|
50
82
|
if (typeof value?.value !== 'boolean') return null;
|
|
51
83
|
return { value: value.value, reason: trimmed(value.reason) || null };
|
|
@@ -231,6 +263,7 @@ async function generateEscalationDraft({
|
|
|
231
263
|
blockingFlag: sufficientForDoctor === false ? trimmed(parsed?.blockingFlag) || null : null,
|
|
232
264
|
suggestCall: suggestCallOf(parsed?.suggestCall),
|
|
233
265
|
sectionsUnavailable: context.degradedSections ?? [],
|
|
266
|
+
symptoms: settled.map((entry) => symptomRecordOf(entry, isConfirmed(entry))),
|
|
234
267
|
};
|
|
235
268
|
}
|
|
236
269
|
|
|
@@ -133,6 +133,35 @@ const suggestCallSchema = new mongoose.Schema({
|
|
|
133
133
|
reason: { type: String, default: null },
|
|
134
134
|
}, { _id: false });
|
|
135
135
|
|
|
136
|
+
const proposedItemSchema = new mongoose.Schema({
|
|
137
|
+
drug: { type: String, required: true },
|
|
138
|
+
brand: { type: String, default: null },
|
|
139
|
+
strength: { type: String, default: null },
|
|
140
|
+
instructions: { type: String, default: null },
|
|
141
|
+
position: { type: String, default: null },
|
|
142
|
+
justification: { type: String, default: null },
|
|
143
|
+
justificationKind: { type: String, default: null },
|
|
144
|
+
}, { _id: false });
|
|
145
|
+
|
|
146
|
+
const draftSymptomSchema = new mongoose.Schema({
|
|
147
|
+
symptom: { type: String, default: null },
|
|
148
|
+
grade: { type: Number, default: null },
|
|
149
|
+
presentRedFlags: { type: [String], default: undefined },
|
|
150
|
+
absentRedFlags: { type: [String], default: undefined },
|
|
151
|
+
unassessedRedFlags: { type: [String], default: undefined },
|
|
152
|
+
proposal: {
|
|
153
|
+
type: new mongoose.Schema({
|
|
154
|
+
formularyKey: { type: String, required: true },
|
|
155
|
+
items: { type: [proposedItemSchema], default: undefined },
|
|
156
|
+
conditions: { type: [String], default: undefined },
|
|
157
|
+
ruledOut: { type: [String], default: undefined },
|
|
158
|
+
confirmed: { type: Boolean, default: false },
|
|
159
|
+
}, { _id: false }),
|
|
160
|
+
default: null,
|
|
161
|
+
},
|
|
162
|
+
conduct: { type: String, default: null },
|
|
163
|
+
}, { _id: false });
|
|
164
|
+
|
|
136
165
|
const draftSchema = new mongoose.Schema({
|
|
137
166
|
doctorText: { type: String, required: true },
|
|
138
167
|
patientMessage: { type: String, required: true },
|
|
@@ -149,6 +178,7 @@ const draftSchema = new mongoose.Schema({
|
|
|
149
178
|
missingCharacterization: { type: [String], default: undefined },
|
|
150
179
|
suggestCall: { type: suggestCallSchema, default: undefined },
|
|
151
180
|
sectionsUnavailable: { type: [String], default: undefined },
|
|
181
|
+
symptoms: { type: [draftSymptomSchema], default: undefined },
|
|
152
182
|
snapshotAt: { type: Date, default: undefined },
|
|
153
183
|
}, { timestamps: { createdAt: true, updatedAt: false } });
|
|
154
184
|
|
|
@@ -57,7 +57,6 @@ const graveDueAt = (review, clock, flag) => {
|
|
|
57
57
|
function outcomeOf(review, clock, now) {
|
|
58
58
|
const wait = pendingWaitMinutes(clock?.level);
|
|
59
59
|
if (!wait || !OUTCOME_LEVELS.includes(clock.level) || !['draft_ready', 'awaiting_doctor'].includes(review.status)) return null;
|
|
60
|
-
// Handoffs count since the last decision, so a higher alarm on a case already with the doctor goes as an update.
|
|
61
60
|
const decidedAt = Math.max(0, ...(review.decisions ?? []).map((decision) => timeOf(decision.createdAt)));
|
|
62
61
|
const handoffs = (review.doctorHandoffs ?? []).filter((handoff) => timeOf(handoff.createdAt) > decidedAt);
|
|
63
62
|
const last = handoffs.at(-1);
|
|
@@ -74,7 +73,7 @@ function outcomeOf(review, clock, now) {
|
|
|
74
73
|
const present = latest.urgentPresentRedFlags ?? [];
|
|
75
74
|
// An update reaches the doctor's phone only with a grave flag the draft they have did not show; the panel always shows the latest draft.
|
|
76
75
|
const handedPresent = new Set((update ? review.drafts[last.draftNumber - 1]?.urgentPresentRedFlags ?? [] : []).map(flagKey));
|
|
77
|
-
//
|
|
76
|
+
// Airtable's alert reaches the team, not the doctor's phone.
|
|
78
77
|
const raisedSince = (level) => (review.alarms ?? []).some((alarm) => alarm.urgencyLevel === level && timeOf(alarm.raisedAt) > timeOf(last.createdAt));
|
|
79
78
|
const rangSince = update && (review.doctorAlerts ?? []).some((sent) => sent.kind === 'initial' && !sent.error
|
|
80
79
|
&& sent.transport && sent.transport !== 'airtable' && timeOf(sent.sentAt) > timeOf(last.createdAt) && raisedSince(sent.urgencyLevel));
|
|
@@ -1030,7 +1030,7 @@ async function caseTakenReason(review, from, level) {
|
|
|
1030
1030
|
const changes = review.urgencyReclassifications ?? [];
|
|
1031
1031
|
const levelsAt = (at) => levelsOf(currentAlarms(review).filter((alarm) => new Date(alarm.raisedAt) <= new Date(at)), changes.filter((change) => new Date(change.at) < new Date(at)), appliedLevelOf);
|
|
1032
1032
|
if (level !== 'stat' && levelsAt(new Date(8.64e15)).some((entry) => entry.level === 'stat')) return 'stat_alarm';
|
|
1033
|
-
//
|
|
1033
|
+
// A lowering means someone looked at the case; a raise restarts the clock instead.
|
|
1034
1034
|
const lowered = (change) => (URGENCY_RANK[change.toLevel] || 0) < Math.max(0, ...levelsAt(change.at).map((entry) => URGENCY_RANK[entry.level] || 0));
|
|
1035
1035
|
if (changes.some((change) => atOrAfter(change.at) && lowered(change))) return 'reclassified';
|
|
1036
1036
|
if ((review.callLogs ?? []).some((call) => call.outcome === 'contacted' && atOrAfter(call.calledAt))) return 'call_contacted';
|
|
@@ -1489,6 +1489,7 @@ async function generateBotDraft({ id, lease = null } = {}) {
|
|
|
1489
1489
|
missingCharacterization: draft.missingCharacterization,
|
|
1490
1490
|
...(draft.suggestCall && { suggestCall: draft.suggestCall }),
|
|
1491
1491
|
...(draft.sectionsUnavailable?.length && { sectionsUnavailable: draft.sectionsUnavailable }),
|
|
1492
|
+
...(draft.symptoms?.length && { symptoms: draft.symptoms }),
|
|
1492
1493
|
};
|
|
1493
1494
|
const EscalationReview = getEscalationReview();
|
|
1494
1495
|
const saved = await EscalationReview.findOneAndUpdate(
|
|
@@ -20,7 +20,6 @@ function pendingFlagsOf(review, since) {
|
|
|
20
20
|
return { latest: drafts.at(-1) ?? null, flags: [...firstSeen].map(([flag, firstSeenAt]) => ({ flag, firstSeenAt, unobtainable: unobtainable.includes(flag) })) };
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
// The bot keeps documenting a case it handed to the doctor itself while it still redrafts it, so its grave questions still wait for the patient there.
|
|
24
23
|
const stillDocumented = (review) => DOCUMENTING_STATUSES.includes(review.status) || updatesDoctor(review);
|
|
25
24
|
|
|
26
25
|
// The latest draft speaks for the case only while no newer patient turn or redraft is waiting to replace it.
|
|
@@ -45,7 +45,7 @@ function responseDeadlinePolicy() {
|
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
//
|
|
48
|
+
// A raise restarts the alarm's clock; a lowering keeps it.
|
|
49
49
|
function levelsOf(alarms, reclassifications, startLevel = (alarm) => alarm.urgencyLevel) {
|
|
50
50
|
const changes = [...(reclassifications ?? [])].sort((a, b) => timeOf(a.at) - timeOf(b.at));
|
|
51
51
|
return (alarms ?? []).map((alarm) => changes
|
|
@@ -34,8 +34,7 @@ const duration = (minutes) => {
|
|
|
34
34
|
return rest ? `${hours} h ${rest} min` : `${hours} h`;
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
-
//
|
|
38
|
-
// The episode keeps the start the alarms' own levels give it, so a reclassification never re-keys its drafts or grave alerts.
|
|
37
|
+
// Only the stages restart on a raise; the episode start ignores reclassifications so drafts and grave alerts keep their key.
|
|
39
38
|
function episodeStartOf(alarms) {
|
|
40
39
|
const top = alarms.reduce((level, alarm) => ((URGENCY_RANK[appliedLevelOf(alarm)] || 0) > (URGENCY_RANK[level] || 0) ? appliedLevelOf(alarm) : level), null);
|
|
41
40
|
return new Date(Math.min(...alarms.filter((alarm) => appliedLevelOf(alarm) === top).map((alarm) => new Date(alarm.raisedAt).getTime())));
|