@peopl-health/nexus 6.4.0-dev.18777 → 6.4.0-dev.18778

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,95 +1,9 @@
1
- const { httpError } = require('../../utils/httpErrorUtils');
2
1
  const { oneLineOf } = require('../../utils/coercionUtils');
3
- const { PLAN_FOLLOW_UPS } = require('../../models/escalationReviewModel');
4
2
 
5
- const DEFAULT_FOLLOW_UP_HOURS = { check_in: 24, vigilancia: 48 };
6
- const FIRST_LINE = '1L';
7
- const MAX_ENTRIES = 10;
8
- const MAX_FIELD_LENGTH = 300;
9
- const MAX_NOTE_LENGTH = 1000;
10
- const MAX_INSTRUCTIONS_LENGTH = 4000;
11
- const MAX_DAYS = 90;
12
- const MAX_FOLLOW_UP_HOURS = 168;
3
+ const DEFAULT_FOLLOW_UP_HOURS = Object.freeze({ check_in: 24, vigilancia: 48 });
13
4
  // A route is not a dose: the doctor still says how many tablets.
14
5
  const ROUTE_ONLY = /^(vo|v[ií]a oral|oral)$/i;
15
6
 
16
- const textOf = (value, name, max = MAX_FIELD_LENGTH) => {
17
- if (value != null && typeof value !== 'string') throw httpError(400, `${name} must be text`);
18
- const text = oneLineOf(value);
19
- if (text.length > max) throw httpError(400, `${name} exceeds ${max} characters`);
20
- return text || null;
21
- };
22
-
23
- const neededText = (value, name) => {
24
- const text = textOf(value, name);
25
- if (!text) throw httpError(400, `${name} is required`);
26
- return text;
27
- };
28
-
29
- const isPlainObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
30
-
31
- // Only a number or a string of digits: `true`, `[5]` and `'1e1'` are not days.
32
- const wholeNumber = (value, name, max) => {
33
- const number = typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value.trim())) ? Number(value) : NaN;
34
- if (!Number.isInteger(number) || number < 1 || number > max) throw httpError(400, `${name} must be a whole number from 1 to ${max}`);
35
- return number;
36
- };
37
-
38
- const listOf = (value, name) => {
39
- if (value == null) return [];
40
- if (!Array.isArray(value)) throw httpError(400, `${name} must be a list`);
41
- if (value.length > MAX_ENTRIES) throw httpError(400, `${name} allows at most ${MAX_ENTRIES} entries`);
42
- return value;
43
- };
44
-
45
- function itemOf(item, index) {
46
- const name = `plan.items[${index}]`;
47
- const position = textOf(item?.position, `${name}.position`);
48
- const reason = textOf(item?.reason, `${name}.reason`);
49
- // An alternative to the first line has to say why the first line does not fit.
50
- if (position && position !== FIRST_LINE && !reason) throw httpError(400, `${name}.reason is required for a drug that is not first line`);
51
- return {
52
- drug: neededText(item?.drug, `${name}.drug`),
53
- strength: textOf(item?.strength, `${name}.strength`),
54
- dose: neededText(item?.dose, `${name}.dose`),
55
- frequency: neededText(item?.frequency, `${name}.frequency`),
56
- days: wholeNumber(item?.days, `${name}.days`, MAX_DAYS),
57
- position,
58
- reason,
59
- formularyKey: textOf(item?.formularyKey, `${name}.formularyKey`),
60
- };
61
- }
62
-
63
- function followUpOf(value) {
64
- if (value != null && !isPlainObject(value)) throw httpError(400, 'plan.followUp must be an object with a kind');
65
- const kind = value?.kind ?? 'none';
66
- if (!PLAN_FOLLOW_UPS.includes(kind)) throw httpError(400, `plan.followUp.kind must be one of: ${PLAN_FOLLOW_UPS.join(', ')}`);
67
- if (kind === 'none') return { kind, hours: null };
68
- return { kind, hours: wholeNumber(value?.hours ?? DEFAULT_FOLLOW_UP_HOURS[kind], 'plan.followUp.hours', MAX_FOLLOW_UP_HOURS) };
69
- }
70
-
71
- // The plan as the doctor approved it, refused whole when any line is incomplete.
72
- function planOf(input) {
73
- if (!isPlainObject(input)) throw httpError(400, 'plan is required');
74
- const plan = {
75
- items: listOf(input.items, 'plan.items').map((item, index) => {
76
- if (!isPlainObject(item)) throw httpError(400, `plan.items[${index}] must be an object`);
77
- return itemOf(item, index);
78
- }),
79
- studies: listOf(input.studies, 'plan.studies').map((study, index) => ({
80
- name: neededText(study?.name, `plan.studies[${index}].name`),
81
- instructions: textOf(study?.instructions, `plan.studies[${index}].instructions`),
82
- })),
83
- homeCare: listOf(input.homeCare, 'plan.homeCare').map((entry, index) => neededText(entry, `plan.homeCare[${index}]`)),
84
- note: textOf(input.note, 'plan.note', MAX_NOTE_LENGTH),
85
- followUp: followUpOf(input.followUp),
86
- };
87
- if (!plan.items.length && !plan.studies.length && !plan.homeCare.length && !plan.note) {
88
- throw httpError(400, 'plan needs a drug, a study, a home care measure or a note');
89
- }
90
- return plan;
91
- }
92
-
93
7
  const needsPrescriber = (plan) => plan.items.length > 0 || plan.studies.length > 0;
94
8
 
95
9
  const daysText = (days) => (days === 1 ? '1 día' : `${days} días`);
@@ -139,15 +53,6 @@ function prescriptionText(plan) {
139
53
  return lines.join('\n');
140
54
  }
141
55
 
142
- // The doctor's edit wins; with none, the instructions follow the plan.
143
- function approvedInstructions(plan, edited) {
144
- if (edited != null && typeof edited !== 'string') throw httpError(400, 'instructions must be text');
145
- if (!edited?.trim()) return { instructions: planInstructions(plan), instructionsEdited: false };
146
- const instructions = edited.trim();
147
- if (instructions.length > MAX_INSTRUCTIONS_LENGTH) throw httpError(400, `instructions exceed ${MAX_INSTRUCTIONS_LENGTH} characters`);
148
- return { instructions, instructionsEdited: instructions !== planInstructions(plan) };
149
- }
150
-
151
56
  // «1 tableta cada 24 horas por 5 días; además, …» → dose, frequency, days and the rest; what does not match is left for the doctor.
152
57
  function dosingOf(instructions) {
153
58
  const text = oneLineOf(instructions);
@@ -181,13 +86,6 @@ function proposedPlanOf(draft) {
181
86
  return { items, studies: [], homeCare: [], note: rests.join('. ') || null, followUp: { kind: 'check_in', hours: DEFAULT_FOLLOW_UP_HOURS.check_in } };
182
87
  }
183
88
 
184
- const plain = (text) => String(text ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
185
- const URGENCY_WORDS = /\b(urgencias?|urgente|de inmediato|inmediatamente|emergencias?|911|lo antes posible|cuanto antes|no esperes)\b/g;
186
- const numbersIn = (text, pattern) => new Set([...plain(text).matchAll(pattern)].map((match) => match[1].replace(',', '.')));
187
- const HOURS = /cada\s+(\d+)\s*h(?:rs?|oras?)?\b/g;
188
- const DAYS = /por\s+(\d+)\s+dias?/g;
189
- const MG = /(\d+(?:[.,]\d+)?)\s*mg\b/g;
190
-
191
89
  // The plan's own words, so what the doctor wrote anywhere in it is never flagged.
192
90
  const plannedTextOf = (plan) => [
193
91
  ...plan.items.flatMap((item) => [item.drug, item.strength, item.dose, item.frequency, `por ${item.days} dias`]),
@@ -196,37 +94,13 @@ const plannedTextOf = (plan) => [
196
94
  plan.note,
197
95
  ].filter(Boolean).join('\n');
198
96
 
199
- // What in the assistant's reply strays from the plan, for the team to check; `unplannedDrugs` are the formulary drugs it names beyond the plan.
200
- function replyIssues(plan, reply, unplannedDrugs = []) {
201
- const text = plain(reply);
202
- if (!text) return ['no se pudo leer el mensaje enviado'];
203
- const planned = plannedTextOf(plan);
204
- const issues = [];
205
- for (const item of plan.items) {
206
- const name = plain(item.drug).split(/[\s/]/)[0];
207
- if (name && !text.includes(name)) issues.push(`no menciona ${item.drug}`);
208
- }
209
- const strays = (pattern) => [...numbersIn(reply, pattern)].filter((value) => !numbersIn(planned, pattern).has(value));
210
- for (const value of strays(HOURS)) issues.push(`dice «cada ${value} horas», que no está en el plan`);
211
- for (const value of strays(DAYS)) issues.push(`dice «por ${value} días», que no está en el plan`);
212
- for (const value of strays(MG)) issues.push(`dice «${value} mg», que no está en el plan`);
213
- for (const name of unplannedDrugs) issues.push(`menciona ${name}, que el médico no indicó`);
214
- const plannedWords = new Set([...plain(planned).matchAll(URGENCY_WORDS)].map((match) => match[1]));
215
- for (const word of new Set([...text.matchAll(URGENCY_WORDS)].map((match) => match[1]))) {
216
- if (!plannedWords.has(word)) issues.push(`usa «${word}», que el médico no indicó`);
217
- }
218
- return issues;
219
- }
220
-
221
97
  module.exports = {
222
- approvedInstructions,
98
+ DEFAULT_FOLLOW_UP_HOURS,
223
99
  assistantInstructionsOf,
224
100
  needsPrescriber,
225
101
  planInstructions,
226
- planOf,
227
102
  planSummary,
103
+ plannedTextOf,
228
104
  prescriptionText,
229
105
  proposedPlanOf,
230
- plannedTextOf,
231
- replyIssues,
232
106
  };
@@ -0,0 +1,131 @@
1
+ const { DEFAULT_FOLLOW_UP_HOURS, planInstructions, plannedTextOf } = require('../helpers/doctorPlanHelper');
2
+ const { httpError } = require('../../utils/httpErrorUtils');
3
+ const { oneLineOf } = require('../../utils/coercionUtils');
4
+ const { PLAN_FOLLOW_UPS } = require('../../models/escalationReviewModel');
5
+ const { unplannedDrugsIn } = require('./treatmentProposalService');
6
+
7
+ const FIRST_LINE = '1L';
8
+ const MAX_ENTRIES = 10;
9
+ const MAX_FIELD_LENGTH = 300;
10
+ const MAX_NOTE_LENGTH = 1000;
11
+ const MAX_INSTRUCTIONS_LENGTH = 4000;
12
+ const MAX_DAYS = 90;
13
+ const MAX_FOLLOW_UP_HOURS = 168;
14
+
15
+ const textOf = (value, name, max = MAX_FIELD_LENGTH) => {
16
+ if (value != null && typeof value !== 'string') throw httpError(400, `${name} must be text`);
17
+ const text = oneLineOf(value);
18
+ if (text.length > max) throw httpError(400, `${name} exceeds ${max} characters`);
19
+ return text || null;
20
+ };
21
+
22
+ const neededText = (value, name) => {
23
+ const text = textOf(value, name);
24
+ if (!text) throw httpError(400, `${name} is required`);
25
+ return text;
26
+ };
27
+
28
+ const isPlainObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
29
+
30
+ // Only a number or a string of digits: `true`, `[5]` and `'1e1'` are not days.
31
+ const wholeNumber = (value, name, max) => {
32
+ const number = typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value.trim())) ? Number(value) : NaN;
33
+ if (!Number.isInteger(number) || number < 1 || number > max) throw httpError(400, `${name} must be a whole number from 1 to ${max}`);
34
+ return number;
35
+ };
36
+
37
+ const listOf = (value, name) => {
38
+ if (value == null) return [];
39
+ if (!Array.isArray(value)) throw httpError(400, `${name} must be a list`);
40
+ if (value.length > MAX_ENTRIES) throw httpError(400, `${name} allows at most ${MAX_ENTRIES} entries`);
41
+ return value;
42
+ };
43
+
44
+ function itemOf(item, index) {
45
+ const name = `plan.items[${index}]`;
46
+ const position = textOf(item?.position, `${name}.position`);
47
+ const reason = textOf(item?.reason, `${name}.reason`);
48
+ // An alternative to the first line has to say why the first line does not fit.
49
+ if (position && position !== FIRST_LINE && !reason) throw httpError(400, `${name}.reason is required for a drug that is not first line`);
50
+ return {
51
+ drug: neededText(item?.drug, `${name}.drug`),
52
+ strength: textOf(item?.strength, `${name}.strength`),
53
+ dose: neededText(item?.dose, `${name}.dose`),
54
+ frequency: neededText(item?.frequency, `${name}.frequency`),
55
+ days: wholeNumber(item?.days, `${name}.days`, MAX_DAYS),
56
+ position,
57
+ reason,
58
+ formularyKey: textOf(item?.formularyKey, `${name}.formularyKey`),
59
+ };
60
+ }
61
+
62
+ function followUpOf(value) {
63
+ if (value != null && !isPlainObject(value)) throw httpError(400, 'plan.followUp must be an object with a kind');
64
+ const kind = value?.kind ?? 'none';
65
+ if (!PLAN_FOLLOW_UPS.includes(kind)) throw httpError(400, `plan.followUp.kind must be one of: ${PLAN_FOLLOW_UPS.join(', ')}`);
66
+ if (kind === 'none') return { kind, hours: null };
67
+ return { kind, hours: wholeNumber(value?.hours ?? DEFAULT_FOLLOW_UP_HOURS[kind], 'plan.followUp.hours', MAX_FOLLOW_UP_HOURS) };
68
+ }
69
+
70
+ // The plan as the doctor approved it, refused whole when any line is incomplete.
71
+ function planOf(input) {
72
+ if (!isPlainObject(input)) throw httpError(400, 'plan is required');
73
+ const plan = {
74
+ items: listOf(input.items, 'plan.items').map((item, index) => {
75
+ if (!isPlainObject(item)) throw httpError(400, `plan.items[${index}] must be an object`);
76
+ return itemOf(item, index);
77
+ }),
78
+ studies: listOf(input.studies, 'plan.studies').map((study, index) => ({
79
+ name: neededText(study?.name, `plan.studies[${index}].name`),
80
+ instructions: textOf(study?.instructions, `plan.studies[${index}].instructions`),
81
+ })),
82
+ homeCare: listOf(input.homeCare, 'plan.homeCare').map((entry, index) => neededText(entry, `plan.homeCare[${index}]`)),
83
+ note: textOf(input.note, 'plan.note', MAX_NOTE_LENGTH),
84
+ followUp: followUpOf(input.followUp),
85
+ };
86
+ if (!plan.items.length && !plan.studies.length && !plan.homeCare.length && !plan.note) {
87
+ throw httpError(400, 'plan needs a drug, a study, a home care measure or a note');
88
+ }
89
+ return plan;
90
+ }
91
+
92
+ // The doctor's edit wins; with none, the instructions follow the plan.
93
+ function approvedInstructions(plan, edited) {
94
+ if (edited != null && typeof edited !== 'string') throw httpError(400, 'instructions must be text');
95
+ if (!edited?.trim()) return { instructions: planInstructions(plan), instructionsEdited: false };
96
+ const instructions = edited.trim();
97
+ if (instructions.length > MAX_INSTRUCTIONS_LENGTH) throw httpError(400, `instructions exceed ${MAX_INSTRUCTIONS_LENGTH} characters`);
98
+ return { instructions, instructionsEdited: instructions !== planInstructions(plan) };
99
+ }
100
+
101
+ const plain = (text) => String(text ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
102
+ const URGENCY_WORDS = /\b(urgencias?|urgente|de inmediato|inmediatamente|emergencias?|911|lo antes posible|cuanto antes|no esperes)\b/g;
103
+ const numbersIn = (text, pattern) => new Set([...plain(text).matchAll(pattern)].map((match) => match[1].replace(',', '.')));
104
+ const HOURS = /cada\s+(\d+)\s*h(?:rs?|oras?)?\b/g;
105
+ const DAYS = /por\s+(\d+)\s+dias?/g;
106
+ const MG = /(\d+(?:[.,]\d+)?)\s*mg\b/g;
107
+
108
+ // What in the assistant's reply strays from the plan, for the team to check.
109
+ function replyIssues(plan, reply) {
110
+ const text = plain(reply);
111
+ if (!text) return ['no se pudo leer el mensaje enviado'];
112
+ const planned = plannedTextOf(plan);
113
+ const unplannedDrugs = unplannedDrugsIn(reply, planned);
114
+ const issues = [];
115
+ for (const item of plan.items) {
116
+ const name = plain(item.drug).split(/[\s/]/)[0];
117
+ if (name && !text.includes(name)) issues.push(`no menciona ${item.drug}`);
118
+ }
119
+ const strays = (pattern) => [...numbersIn(reply, pattern)].filter((value) => !numbersIn(planned, pattern).has(value));
120
+ for (const value of strays(HOURS)) issues.push(`dice «cada ${value} horas», que no está en el plan`);
121
+ for (const value of strays(DAYS)) issues.push(`dice «por ${value} días», que no está en el plan`);
122
+ for (const value of strays(MG)) issues.push(`dice «${value} mg», que no está en el plan`);
123
+ for (const name of unplannedDrugs) issues.push(`menciona ${name}, que el médico no indicó`);
124
+ const plannedWords = new Set([...plain(planned).matchAll(URGENCY_WORDS)].map((match) => match[1]));
125
+ for (const word of new Set([...text.matchAll(URGENCY_WORDS)].map((match) => match[1]))) {
126
+ if (!plannedWords.has(word)) issues.push(`usa «${word}», que el médico no indicó`);
127
+ }
128
+ return issues;
129
+ }
130
+
131
+ module.exports = { approvedInstructions, planOf, replyIssues };
@@ -31,11 +31,10 @@ const { generateEscalationDraft } = require('../clinical/services/escalationDraf
31
31
  const { conversationLink, readGroupMessageId, writeUnresolvedRequest } = require('../clinical/services/clinicalAirtableService');
32
32
  const { doctorAlertHeadline, doctorAlertOpener, renderDoctorMessage, templateSummary, withIdentity } = require('../clinical/helpers/doctorReviewMessageHelper');
33
33
  const { generateKeyQuestions } = require('../clinical/services/escalationQuestionsService');
34
- const { drugsNamedIn, unplannedDrugsIn } = require('../clinical/services/treatmentProposalService');
34
+ const { drugsNamedIn } = require('../clinical/services/treatmentProposalService');
35
35
  const { characterizationSeedOf } = require('../clinical/helpers/reviewUpdateHelper');
36
- const {
37
- approvedInstructions, assistantInstructionsOf, needsPrescriber, planInstructions, planOf, planSummary, prescriptionText, plannedTextOf, proposedPlanOf, replyIssues,
38
- } = require('../clinical/helpers/doctorPlanHelper');
36
+ const { assistantInstructionsOf, needsPrescriber, planInstructions, planSummary, prescriptionText, proposedPlanOf } = require('../clinical/helpers/doctorPlanHelper');
37
+ const { approvedInstructions, planOf, replyIssues } = require('../clinical/services/doctorPlanService');
39
38
  const { readNoteFacts, assertionOf } = require('../clinical/services/escalationNoteFactsService');
40
39
  const { humanReplyFilter } = require('../clinical/config/safetyNetConfig');
41
40
  const {
@@ -2219,8 +2218,7 @@ async function sendPlanThroughAssistant({ claimed, claim, decisionNumber, plan,
2219
2218
  const queued = outcome === 'queued';
2220
2219
  const prescriptionRecordId = prescriberEmail ? await issuePrescription(claimed, plan, prescriberEmail) : null;
2221
2220
  const prescriptionFailed = Boolean(prescriberEmail) && !prescriptionRecordId;
2222
- const unplanned = reply ? unplannedDrugsIn(reply, plannedTextOf(plan)) : [];
2223
- const issues = [...replyIssues(plan, reply, unplanned), ...(prescriptionFailed ? ['no se pudo crear la receta'] : [])];
2221
+ const issues = [...replyIssues(plan, reply), ...(prescriptionFailed ? ['no se pudo crear la receta'] : [])];
2224
2222
  const record = {
2225
2223
  decisionNumber, messageId: delivery.messageId || null, status: delivery.status || null, transport: 'assistant', deferred: queued, sentBy: operator,
2226
2224
  ...(prescriptionRecordId && { prescriptionRecordId }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "6.4.0-dev.18777",
3
+ "version": "6.4.0-dev.18778",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",