@peopl-health/nexus 6.4.0-dev.18778 → 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,8 +1,12 @@
1
1
  const { oneLineOf } = require('../../utils/coercionUtils');
2
+ const { normalizeForAnchor: drugKey } = require('../../utils/textMatchUtils');
3
+ const { DURATION_SOURCE, durationDays, isRouteOnly } = require('./planTextHelper');
2
4
 
3
5
  const DEFAULT_FOLLOW_UP_HOURS = Object.freeze({ check_in: 24, vigilancia: 48 });
4
- // A route is not a dose: the doctor still says how many tablets.
5
- const ROUTE_ONLY = /^(vo|v[ií]a oral|oral)$/i;
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');
6
10
 
7
11
  const needsPrescriber = (plan) => plan.items.length > 0 || plan.studies.length > 0;
8
12
 
@@ -34,7 +38,7 @@ function planInstructions(plan) {
34
38
 
35
39
  // An edit can drop the receta line; the PDF goes out anyway, so the assistant is still told.
36
40
  function assistantInstructionsOf(plan) {
37
- if (!needsPrescriber(plan) || plan.instructions.includes(PRESCRIPTION_LINE)) return plan.instructions;
41
+ if (!needsPrescriber(plan) || oneLineOf(plan.instructions).includes(PRESCRIPTION_LINE)) return plan.instructions;
38
42
  return `${plan.instructions}\n${PRESCRIPTION_LINE}`;
39
43
  }
40
44
 
@@ -56,17 +60,17 @@ function prescriptionText(plan) {
56
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.
57
61
  function dosingOf(instructions) {
58
62
  const text = oneLineOf(instructions);
59
- const match = text.match(/^(.*?)\s+(cada [^,;]+?)(?:\s+por\s+(\d+)\s+d[ií]as?)?(?:\s*[,;]\s*(.*))?$/i);
63
+ const match = text.match(DOSING);
60
64
  if (!match) return { dose: text || null, frequency: null, days: null, rest: null };
61
- const [, dose, frequency, days, rest] = match;
65
+ const [, dose, frequency, duration, , , , rest] = match;
62
66
  // A duration said after other directions («…, y tragar, por 7 días») is still the days.
63
- const lateDays = !days && rest?.match(/(?:^|[,;]\s*|\s)por\s+(\d+)\s+d[ií]as?\s*$/i);
64
- return {
65
- dose: ROUTE_ONLY.test(dose) ? null : dose,
66
- frequency,
67
- days: Number(days ?? lateDays?.[1]) || null,
68
- rest: (lateDays ? rest.slice(0, lateDays.index).replace(/[,;\s]+$/, '') : rest) || null,
69
- };
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 };
70
74
  }
71
75
 
72
76
  // The doctor's starting point: the drugs the bot proposed and confirmed, once each, and a 24 h check-in.
@@ -77,7 +81,7 @@ function proposedPlanOf(draft) {
77
81
  const items = (draft?.symptoms ?? [])
78
82
  .filter((entry) => entry?.proposal?.confirmed)
79
83
  .flatMap((entry) => (entry.proposal.items ?? []).map((item) => ({ item, formularyKey: entry.proposal.formularyKey })))
80
- .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)))
81
85
  .map(({ item, formularyKey }) => {
82
86
  const { rest, ...dosing } = dosingOf(item.instructions);
83
87
  if (rest) rests.push(`${item.drug}: ${rest}`);
@@ -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 };
@@ -1,8 +1,9 @@
1
1
  const { DEFAULT_FOLLOW_UP_HOURS, planInstructions, plannedTextOf } = require('../helpers/doctorPlanHelper');
2
+ const { dosingFactsOf, urgencyWordsIn } = require('../helpers/planTextHelper');
2
3
  const { httpError } = require('../../utils/httpErrorUtils');
3
4
  const { oneLineOf } = require('../../utils/coercionUtils');
4
5
  const { PLAN_FOLLOW_UPS } = require('../../models/escalationReviewModel');
5
- const { unplannedDrugsIn } = require('./treatmentProposalService');
6
+ const { isDrugNamed, unplannedDrugsIn } = require('./treatmentProposalService');
6
7
 
7
8
  const FIRST_LINE = '1L';
8
9
  const MAX_ENTRIES = 10;
@@ -43,7 +44,7 @@ const listOf = (value, name) => {
43
44
 
44
45
  function itemOf(item, index) {
45
46
  const name = `plan.items[${index}]`;
46
- const position = textOf(item?.position, `${name}.position`);
47
+ const position = textOf(item?.position, `${name}.position`)?.toUpperCase() ?? null;
47
48
  const reason = textOf(item?.reason, `${name}.reason`);
48
49
  // An alternative to the first line has to say why the first line does not fit.
49
50
  if (position && position !== FIRST_LINE && !reason) throw httpError(400, `${name}.reason is required for a drug that is not first line`);
@@ -98,33 +99,22 @@ function approvedInstructions(plan, edited) {
98
99
  return { instructions, instructionsEdited: instructions !== planInstructions(plan) };
99
100
  }
100
101
 
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;
102
+ const strays = (said, planned) => [...said].filter((value) => !planned.has(value));
107
103
 
108
104
  // What in the assistant's reply strays from the plan, for the team to check.
109
105
  function replyIssues(plan, reply) {
110
- const text = plain(reply);
111
- if (!text) return ['no se pudo leer el mensaje enviado'];
106
+ if (!oneLineOf(reply)) return ['no se pudo leer el mensaje enviado'];
112
107
  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ó`);
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`);
127
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ó`);
128
118
  return issues;
129
119
  }
130
120
 
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "6.4.0-dev.18778",
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",