@peopl-health/nexus 6.3.0-dev.18381 → 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.
@@ -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
 
@@ -129,6 +129,7 @@ function crossSymptomIssues(proposals) {
129
129
  }
130
130
 
131
131
  module.exports = {
132
+ JUSTIFICATION_KINDS,
132
133
  validateProposal,
133
134
  unproposedDrugsIn,
134
135
  drugsNamedIn,
@@ -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
- // A higher alarm already rang the doctor's phone since the last handoff, so its update shows in the panel only; Airtable's alert goes to the team.
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
- // Lowering the level, from what this episode's alarms were at, means someone looked at the case; a raise restarts the clock instead.
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
- // Each alarm at the level the team set for it since it was raised; a raise restarts its clock, a lowering keeps it.
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
- // The clock runs at the highest level since the last decision, its stages from the first alarm or team raise at that level.
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())));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "6.3.0-dev.18381",
3
+ "version": "6.3.0-dev.18429",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",