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

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,94 +1,12 @@
1
- const { httpError } = require('../../utils/httpErrorUtils');
2
1
  const { oneLineOf } = require('../../utils/coercionUtils');
3
- const { PLAN_FOLLOW_UPS } = require('../../models/escalationReviewModel');
2
+ const { normalizeForAnchor: drugKey } = require('../../utils/textMatchUtils');
3
+ const { DURATION_SOURCE, durationDays, isRouteOnly } = require('./planTextHelper');
4
4
 
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;
13
- // A route is not a dose: the doctor still says how many tablets.
14
- const ROUTE_ONLY = /^(vo|v[ií]a oral|oral)$/i;
15
-
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
- }
5
+ const DEFAULT_FOLLOW_UP_HOURS = Object.freeze({ check_in: 24, vigilancia: 48 });
6
+ // «1 tableta» «cada 8 horas | c/8h» «por 5 días», then «, …» what else the formulary says.
7
+ const DOSING = new RegExp(`^(.*?)\\s+((?:cada|c\\/)\\s*[^,;]+?)(?:\\s+(${DURATION_SOURCE}))?(?:\\s*[,;]\\s*(.*))?$`, 'i');
8
+ // Only a clause of its own: «…, y tragar, por 7 días» gives the days, «…, no manejar durante 2 días» does not.
9
+ const LATE_DURATION = new RegExp(`(?:^|[,;]\\s*)(${DURATION_SOURCE})\\s*$`, 'i');
92
10
 
93
11
  const needsPrescriber = (plan) => plan.items.length > 0 || plan.studies.length > 0;
94
12
 
@@ -120,7 +38,7 @@ function planInstructions(plan) {
120
38
 
121
39
  // An edit can drop the receta line; the PDF goes out anyway, so the assistant is still told.
122
40
  function assistantInstructionsOf(plan) {
123
- if (!needsPrescriber(plan) || plan.instructions.includes(PRESCRIPTION_LINE)) return plan.instructions;
41
+ if (!needsPrescriber(plan) || oneLineOf(plan.instructions).includes(PRESCRIPTION_LINE)) return plan.instructions;
124
42
  return `${plan.instructions}\n${PRESCRIPTION_LINE}`;
125
43
  }
126
44
 
@@ -139,29 +57,20 @@ function prescriptionText(plan) {
139
57
  return lines.join('\n');
140
58
  }
141
59
 
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
60
  // «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
61
  function dosingOf(instructions) {
153
62
  const text = oneLineOf(instructions);
154
- const match = text.match(/^(.*?)\s+(cada [^,;]+?)(?:\s+por\s+(\d+)\s+d[ií]as?)?(?:\s*[,;]\s*(.*))?$/i);
63
+ const match = text.match(DOSING);
155
64
  if (!match) return { dose: text || null, frequency: null, days: null, rest: null };
156
- const [, dose, frequency, days, rest] = match;
65
+ const [, dose, frequency, duration, , , , rest] = match;
157
66
  // A duration said after other directions («…, y tragar, por 7 días») is still the days.
158
- const lateDays = !days && rest?.match(/(?:^|[,;]\s*|\s)por\s+(\d+)\s+d[ií]as?\s*$/i);
159
- return {
160
- dose: ROUTE_ONLY.test(dose) ? null : dose,
161
- frequency,
162
- days: Number(days ?? lateDays?.[1]) || null,
163
- rest: (lateDays ? rest.slice(0, lateDays.index).replace(/[,;\s]+$/, '') : rest) || null,
164
- };
67
+ const late = !duration ? rest?.match(LATE_DURATION) : null;
68
+ const said = duration ?? late?.[1];
69
+ const days = durationDays(said);
70
+ const others = late ? rest.slice(0, late.index).replace(/[,;\s]+$/, '') : rest;
71
+ // A range («por 3 a 5 días») is the doctor's to settle, so it stays in view with the rest.
72
+ const left = [others, said && !days ? said : null].filter(Boolean).join(', ');
73
+ return { dose: isRouteOnly(dose) ? null : dose, frequency, days, rest: left || null };
165
74
  }
166
75
 
167
76
  // The doctor's starting point: the drugs the bot proposed and confirmed, once each, and a 24 h check-in.
@@ -172,7 +81,7 @@ function proposedPlanOf(draft) {
172
81
  const items = (draft?.symptoms ?? [])
173
82
  .filter((entry) => entry?.proposal?.confirmed)
174
83
  .flatMap((entry) => (entry.proposal.items ?? []).map((item) => ({ item, formularyKey: entry.proposal.formularyKey })))
175
- .filter(({ item }) => !seen.has(oneLineOf(item.drug).toLowerCase()) && seen.add(oneLineOf(item.drug).toLowerCase()))
84
+ .filter(({ item }) => !seen.has(drugKey(item.drug)) && seen.add(drugKey(item.drug)))
176
85
  .map(({ item, formularyKey }) => {
177
86
  const { rest, ...dosing } = dosingOf(item.instructions);
178
87
  if (rest) rests.push(`${item.drug}: ${rest}`);
@@ -181,13 +90,6 @@ function proposedPlanOf(draft) {
181
90
  return { items, studies: [], homeCare: [], note: rests.join('. ') || null, followUp: { kind: 'check_in', hours: DEFAULT_FOLLOW_UP_HOURS.check_in } };
182
91
  }
183
92
 
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
93
  // The plan's own words, so what the doctor wrote anywhere in it is never flagged.
192
94
  const plannedTextOf = (plan) => [
193
95
  ...plan.items.flatMap((item) => [item.drug, item.strength, item.dose, item.frequency, `por ${item.days} dias`]),
@@ -196,37 +98,13 @@ const plannedTextOf = (plan) => [
196
98
  plan.note,
197
99
  ].filter(Boolean).join('\n');
198
100
 
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
101
  module.exports = {
222
- approvedInstructions,
102
+ DEFAULT_FOLLOW_UP_HOURS,
223
103
  assistantInstructionsOf,
224
104
  needsPrescriber,
225
105
  planInstructions,
226
- planOf,
227
106
  planSummary,
107
+ plannedTextOf,
228
108
  prescriptionText,
229
109
  proposedPlanOf,
230
- plannedTextOf,
231
- replyIssues,
232
110
  };
@@ -0,0 +1,104 @@
1
+ const { foldAccents } = require('../../utils/formatUtils');
2
+ const { normalizeForAnchor } = require('../../utils/textMatchUtils');
3
+
4
+ // Reading the Spanish of a doctor's plan and of the assistant's reply about it.
5
+
6
+ const NUMBER_WORDS = Object.freeze({
7
+ un: 1, uno: 1, una: 1, dos: 2, tres: 3, cuatro: 4, cinco: 5, seis: 6, siete: 7, ocho: 8, nueve: 9, diez: 10,
8
+ once: 11, doce: 12, trece: 13, catorce: 14, quince: 15, dieciseis: 16, dieciocho: 18, veinte: 20, veinticuatro: 24,
9
+ treinta: 30, 'treinta y seis': 36, cuarenta: 40, 'cuarenta y ocho': 48, 'setenta y dos': 72,
10
+ });
11
+ const WORDS = Object.keys(NUMBER_WORDS).sort((a, b) => b.length - a.length).join('|');
12
+ const COUNT = `(?:\\d+|${WORDS})`;
13
+ // «y» is left out: «treinta y cinco» is one number, not a range.
14
+ const TO = '\\s*(?:a|-|o|u)\\s*';
15
+ const DAYS_PER = Object.freeze({ d: 1, dia: 1, dias: 1, semana: 7, semanas: 7, mes: 30, meses: 30 });
16
+ const DAY_UNITS = 'd[ií]as?|semanas?|mes(?:es)?|d\\b';
17
+
18
+ // «por 5 días», «durante una semana», «x 7 días», «por unos 5 días», «por 3 a 5 días»
19
+ const DURATION_SOURCE = `(?:por|durante|x)\\s+(?:(?:unos|unas|los|las|pr[oó]ximos|otros)\\s+)*(${COUNT})(?:${TO}(${COUNT}))?\\s*(${DAY_UNITS})`;
20
+ const DURATION = new RegExp(`^${DURATION_SOURCE}$`, 'i');
21
+
22
+ // «VO», «v.o.», «vía oral», «por vía oral», «IV», «SL»…
23
+ const ROUTES = new Set(['vo', 'po', 'oral', 'iv', 'ev', 'intravenosa', 'im', 'intramuscular', 'sc', 'subcutanea', 'sl', 'sublingual', 'topica', 'rectal']);
24
+ // What a drug's name may carry that does not name it: «Ondansetrón 8 mg», «Metoclopramida gotas».
25
+ const NOT_A_NAME = /^(?:\d+(?:mg|ml|mcg|g|ui)?|mg|ml|mcg|g|ui|vo|iv|im|sc|sl|oral|tabletas?|capsulas?|comprimidos?|gotas|jarabe|crema|gel|unguento|solucion|suspension|sobres?|ampolletas?|ampulas?|parches?|spray|colirio)$/;
26
+
27
+ const URGENCY_WORDS = /\b(urgencias?|urgente|de inmediato|inmediatamente|emergencias?|911|lo antes posible|cuanto antes|no esperes)\b/g;
28
+ // «no es urgente», «no hay urgencia», «no necesitas ir a urgencias»: the word calms instead of sending the patient.
29
+ // Only filler words may sit between the negation and the word, so «no es normal, ve a urgencias» still counts.
30
+ const CALMING_BEFORE = /\b(?:no\s+(?:es|son|parece|hay|hace\s+falta|necesitas?|requiere|requieres|tienes\s+(?:que|por\s+que))|sin\s+necesidad\s+de)(?:\s+(?:ir|acudir|a|al|una|un|de|la|que))*\s*$/;
31
+
32
+ const folded = (text) => foldAccents(text).toLowerCase();
33
+
34
+ const countOf = (word) => (/^\d+$/.test(word) ? Number(word) : NUMBER_WORDS[folded(word).replace(/\s+/g, ' ')] ?? null);
35
+ const daysPer = (unit) => DAYS_PER[folded(unit)];
36
+
37
+ const isRouteOnly = (text) => ROUTES.has(folded(text).replace(/\./g, '').replace(/^(?:por\s+)?(?:via\s+)?/, '').trim());
38
+
39
+ // The name in a drug line, without the dose or the form beside it.
40
+ const drugNameOf = (text) => normalizeForAnchor(text).split(' ').filter((word) => word && !NOT_A_NAME.test(word)).join(' ');
41
+
42
+ // The days a duration phrase says; null for a range or an unknown count, which the doctor settles.
43
+ function durationDays(phrase) {
44
+ const match = String(phrase ?? '').trim().match(DURATION);
45
+ if (!match || match[2]) return null;
46
+ const count = countOf(match[1]);
47
+ return count ? count * daysPer(match[3]) : null;
48
+ }
49
+
50
+ const NUMBER_WORD = new RegExp(`\\b(${WORDS})\\b`, 'g');
51
+ const IN_DAYS = new RegExp(`\\b(\\d+)(?:${TO}(\\d+))?\\s*(semanas?|mes(?:es)?|d)\\b`, 'g');
52
+ const DAYS_IN_UNIT = (_, from, to, unit) => [from, to].filter(Boolean).map((value) => value * daysPer(unit)).join(' a ').concat(' dias');
53
+ const TIMES_A_DAY = (written, ...counts) => {
54
+ const times = counts.slice(0, 2).filter(Boolean).map(Number);
55
+ return times.every((count) => count > 0 && 24 % count === 0) ? times.map((count) => `cada ${24 / count} horas`).join(' o ') : written;
56
+ };
57
+
58
+ // One spelling to compare dosing in: no accents, digits for number words, «c/8hs» as «cada 8 horas»,
59
+ // «3 veces al día» as «cada 8 horas», weeks and months as days. A dot stays decimal, as written in Mexico.
60
+ function dosingTextOf(text) {
61
+ return folded(text)
62
+ .replace(/\b[1-9]\d{0,2}(?:,\d{3})+\b/g, (thousands) => thousands.replace(/,/g, ''))
63
+ .replace(/(\d),(\d)/g, '$1.$2')
64
+ .replace(NUMBER_WORD, (word) => String(countOf(word)))
65
+ .replace(/\bc\/\s*(\d+)\s*(?:h|hs|hrs?|horas?)\b/g, 'cada $1 horas')
66
+ .replace(new RegExp(`\\b(\\d+)(?:${TO}(\\d+))?\\s+ve(?:z|ces)\\s+(?:al|por|cada)\\s+dia\\b`, 'g'), TIMES_A_DAY)
67
+ .replace(/\bcada\s+hora\b/g, 'cada 1 horas')
68
+ .replace(/\bcada\s+dia\b/g, 'cada 24 horas')
69
+ .replace(/\b(?:por|durante|x)\s+(?:(?:unos|unas|los|las|proximos|otros)\s+)*(?=\d)/g, 'por ')
70
+ .replace(IN_DAYS, DAYS_IN_UNIT)
71
+ .replace(/\s+/g, ' ');
72
+ }
73
+
74
+ const RANGE = `(\\d+)(?:${TO}(\\d+))?`;
75
+ const HOURS = new RegExp(`cada\\s+${RANGE}\\s*h(?:s|rs?|oras?)?\\b`, 'g');
76
+ const DAYS = new RegExp(`por\\s+${RANGE}\\s+dias?\\b`, 'g');
77
+ const AMOUNTS = /\b(\d+(?:\.\d+)?)\s*(microgramos?|miligramos?|gramos?|mcg|µg|ug|mg|gr?|ml|ui)(?![a-z])/g;
78
+ const UNIT_SCALE = Object.freeze({
79
+ microgramo: ['mg', 0.001], microgramos: ['mg', 0.001], mcg: ['mg', 0.001], µg: ['mg', 0.001], ug: ['mg', 0.001],
80
+ miligramo: ['mg', 1], miligramos: ['mg', 1], mg: ['mg', 1],
81
+ gramo: ['mg', 1000], gramos: ['mg', 1000], g: ['mg', 1000], gr: ['mg', 1000],
82
+ ml: ['ml', 1], ui: ['ui', 1],
83
+ });
84
+
85
+ const valuesIn = (text, pattern) => new Set([...text.matchAll(pattern)].flatMap((match) => [match[1], match[2]]).filter(Boolean).map(Number));
86
+
87
+ // The hours, days and amounts a text gives; amounts are keyed in mg, ml or UI, so «1 g» and «1000 mg» meet.
88
+ function dosingFactsOf(text) {
89
+ const dosing = dosingTextOf(text);
90
+ const amounts = new Map([...dosing.matchAll(AMOUNTS)].map(([written, value, unit]) => {
91
+ const [base, scale] = UNIT_SCALE[unit];
92
+ return [`${Math.round(Number(value) * scale * 1000) / 1000} ${base}`, written];
93
+ }));
94
+ return { hours: valuesIn(dosing, HOURS), days: valuesIn(dosing, DAYS), amounts };
95
+ }
96
+
97
+ function urgencyWordsIn(text) {
98
+ const plain = folded(text);
99
+ return new Set([...plain.matchAll(URGENCY_WORDS)]
100
+ .filter((match) => !CALMING_BEFORE.test(plain.slice(0, match.index)))
101
+ .map((match) => match[1]));
102
+ }
103
+
104
+ module.exports = { DURATION_SOURCE, dosingFactsOf, drugNameOf, durationDays, isRouteOnly, urgencyWordsIn };
@@ -0,0 +1,121 @@
1
+ const { DEFAULT_FOLLOW_UP_HOURS, planInstructions, plannedTextOf } = require('../helpers/doctorPlanHelper');
2
+ const { dosingFactsOf, urgencyWordsIn } = require('../helpers/planTextHelper');
3
+ const { httpError } = require('../../utils/httpErrorUtils');
4
+ const { oneLineOf } = require('../../utils/coercionUtils');
5
+ const { PLAN_FOLLOW_UPS } = require('../../models/escalationReviewModel');
6
+ const { isDrugNamed, unplannedDrugsIn } = require('./treatmentProposalService');
7
+
8
+ const FIRST_LINE = '1L';
9
+ const MAX_ENTRIES = 10;
10
+ const MAX_FIELD_LENGTH = 300;
11
+ const MAX_NOTE_LENGTH = 1000;
12
+ const MAX_INSTRUCTIONS_LENGTH = 4000;
13
+ const MAX_DAYS = 90;
14
+ const MAX_FOLLOW_UP_HOURS = 168;
15
+
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`)?.toUpperCase() ?? null;
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
+ // The doctor's edit wins; with none, the instructions follow the plan.
94
+ function approvedInstructions(plan, edited) {
95
+ if (edited != null && typeof edited !== 'string') throw httpError(400, 'instructions must be text');
96
+ if (!edited?.trim()) return { instructions: planInstructions(plan), instructionsEdited: false };
97
+ const instructions = edited.trim();
98
+ if (instructions.length > MAX_INSTRUCTIONS_LENGTH) throw httpError(400, `instructions exceed ${MAX_INSTRUCTIONS_LENGTH} characters`);
99
+ return { instructions, instructionsEdited: instructions !== planInstructions(plan) };
100
+ }
101
+
102
+ const strays = (said, planned) => [...said].filter((value) => !planned.has(value));
103
+
104
+ // What in the assistant's reply strays from the plan, for the team to check.
105
+ function replyIssues(plan, reply) {
106
+ if (!oneLineOf(reply)) return ['no se pudo leer el mensaje enviado'];
107
+ const planned = plannedTextOf(plan);
108
+ const said = dosingFactsOf(reply);
109
+ const allowed = dosingFactsOf(planned);
110
+ const issues = plan.items.filter((item) => !isDrugNamed(reply, item.drug)).map((item) => `no menciona ${item.drug}`);
111
+ for (const hours of strays(said.hours, allowed.hours)) issues.push(`dice «cada ${hours} horas», que no está en el plan`);
112
+ for (const days of strays(said.days, allowed.days)) issues.push(`dice «por ${days} días», que no está en el plan`);
113
+ for (const [amount, written] of said.amounts) {
114
+ if (!allowed.amounts.has(amount)) issues.push(`dice «${written}», que no está en el plan`);
115
+ }
116
+ for (const name of unplannedDrugsIn(reply, planned)) issues.push(`menciona ${name}, que el médico no indicó`);
117
+ for (const word of strays(urgencyWordsIn(reply), urgencyWordsIn(planned))) issues.push(`usa «${word}», que el médico no indicó`);
118
+ return issues;
119
+ }
120
+
121
+ module.exports = { approvedInstructions, planOf, replyIssues };
@@ -1,3 +1,4 @@
1
+ const { drugNameOf } = require('../helpers/planTextHelper');
1
2
  const { TREATMENT_FORMULARY, findDrug, formularyFor } = require('../protocols/treatmentFormulary');
2
3
 
3
4
  const { normalizeForAnchor: key } = require('../../utils/textMatchUtils');
@@ -90,7 +91,8 @@ function validateProposal(proposal, context = {}) {
90
91
 
91
92
  const namesOf = (drug) => [drug.drug, ...drug.drug.split('/'), ...String(drug.brand ?? '').split('/')].map(key).filter((name) => name.length >= 4);
92
93
 
93
- const FORMULARY_DRUGS = TREATMENT_FORMULARY.flatMap((entry) => entry.drugs).filter((drug) => drug.isPharmacological !== false);
94
+ const ALL_FORMULARY_DRUGS = TREATMENT_FORMULARY.flatMap((entry) => entry.drugs);
95
+ const FORMULARY_DRUGS = ALL_FORMULARY_DRUGS.filter((drug) => drug.isPharmacological !== false);
94
96
  const DRUG_NAMES = [...new Set(FORMULARY_DRUGS.flatMap(namesOf))];
95
97
 
96
98
  function resolvedDrugs(proposals) {
@@ -134,8 +136,22 @@ function crossSymptomIssues(proposals) {
134
136
  return issues;
135
137
  }
136
138
 
139
+ // The text names the drug: every part of it («Paracetamol/Codeína»), a name in parentheses, or a formulary brand of it.
140
+ // The dose or the form beside the name («Ondansetrón 8 mg», «(400 mg)») does not have to be said.
141
+ function isDrugNamed(text, drugName) {
142
+ const padded = ` ${key(text)} `;
143
+ const says = (name) => Boolean(name) && padded.includes(` ${name} `);
144
+ const name = String(drugName ?? '');
145
+ const outside = name.replace(/\(.*?\)/g, ' ');
146
+ const inside = [...name.matchAll(/\((.*?)\)/g)].map((match) => drugNameOf(match[1]));
147
+ const brands = ALL_FORMULARY_DRUGS.filter((drug) => drugNameOf(drug.drug) === drugNameOf(outside)).flatMap((drug) => String(drug.brand ?? '').split('/').map(key));
148
+ const parts = outside.split(/\s*[/+]\s*/).map(drugNameOf).filter(Boolean);
149
+ return (parts.length > 0 && parts.every(says)) || [...inside, ...brands].some(says);
150
+ }
151
+
137
152
  module.exports = {
138
153
  JUSTIFICATION_KINDS,
154
+ isDrugNamed,
139
155
  validateProposal,
140
156
  unproposedDrugsIn,
141
157
  unplannedDrugsIn,
@@ -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.18779",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",