@peopl-health/nexus 6.4.0-dev.18775 → 6.4.0-dev.18776

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.
@@ -181,6 +181,43 @@ function proposedPlanOf(draft) {
181
181
  return { items, studies: [], homeCare: [], note: rests.join('. ') || null, followUp: { kind: 'check_in', hours: DEFAULT_FOLLOW_UP_HOURS.check_in } };
182
182
  }
183
183
 
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
+ // The plan's own words, so what the doctor wrote anywhere in it is never flagged.
192
+ const plannedTextOf = (plan) => [
193
+ ...plan.items.flatMap((item) => [item.drug, item.strength, item.dose, item.frequency, `por ${item.days} dias`]),
194
+ ...plan.studies.flatMap((study) => [study.name, study.instructions]),
195
+ ...plan.homeCare,
196
+ plan.note,
197
+ ].filter(Boolean).join('\n');
198
+
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
+
184
221
  module.exports = {
185
222
  approvedInstructions,
186
223
  assistantInstructionsOf,
@@ -190,4 +227,6 @@ module.exports = {
190
227
  planSummary,
191
228
  prescriptionText,
192
229
  proposedPlanOf,
230
+ plannedTextOf,
231
+ replyIssues,
193
232
  };
@@ -90,10 +90,8 @@ function validateProposal(proposal, context = {}) {
90
90
 
91
91
  const namesOf = (drug) => [drug.drug, ...drug.drug.split('/'), ...String(drug.brand ?? '').split('/')].map(key).filter((name) => name.length >= 4);
92
92
 
93
- const DRUG_NAMES = [...new Set(TREATMENT_FORMULARY
94
- .flatMap((entry) => entry.drugs)
95
- .filter((drug) => drug.isPharmacological !== false)
96
- .flatMap(namesOf))];
93
+ const FORMULARY_DRUGS = TREATMENT_FORMULARY.flatMap((entry) => entry.drugs).filter((drug) => drug.isPharmacological !== false);
94
+ const DRUG_NAMES = [...new Set(FORMULARY_DRUGS.flatMap(namesOf))];
97
95
 
98
96
  function resolvedDrugs(proposals) {
99
97
  return proposals.filter(Boolean).flatMap((proposal) => {
@@ -107,6 +105,14 @@ function drugsNamedIn(text) {
107
105
  return DRUG_NAMES.filter((name) => padded.includes(` ${name} `));
108
106
  }
109
107
 
108
+ // Drugs the text names that the planned text does not; a brand of a planned drug counts as planned, a combination only when named whole.
109
+ function unplannedDrugsIn(text, plannedText) {
110
+ const planned = new Set(drugsNamedIn(plannedText));
111
+ const ownNames = (drug) => [drug.drug, ...String(drug.brand ?? '').split('/')].map(key).filter(Boolean);
112
+ const allowed = new Set([...planned, ...FORMULARY_DRUGS.filter((drug) => ownNames(drug).some((name) => planned.has(name))).flatMap(namesOf)]);
113
+ return [...new Set(drugsNamedIn(text).filter((name) => !allowed.has(name)))];
114
+ }
115
+
110
116
  function unproposedDrugsIn(text, proposals) {
111
117
  const proposed = new Set(resolvedDrugs(proposals).flatMap(({ drug }) => namesOf(drug)));
112
118
  return drugsNamedIn(text).filter((name) => !proposed.has(name));
@@ -132,6 +138,7 @@ module.exports = {
132
138
  JUSTIFICATION_KINDS,
133
139
  validateProposal,
134
140
  unproposedDrugsIn,
141
+ unplannedDrugsIn,
135
142
  drugsNamedIn,
136
143
  crossSymptomIssues,
137
144
  };
package/lib/index.d.ts CHANGED
@@ -274,7 +274,7 @@ declare module '@peopl-health/nexus' {
274
274
  initializeLlmMonitor(): Promise<{ enabled: boolean; error?: string } | undefined>;
275
275
  getWorkflowRunner(): WorkflowRunner;
276
276
  processInstruction(code: string, instruction: string, role?: string, options?: { triggeredBy?: string; beforeRun?: () => Promise<string | null | undefined> | string | null | undefined; dedupeWithinMs?: number }): Promise<{ outcome: string | null; reason?: string; error?: string }>;
277
- processSystemMessage(code: string, messages: string | string[], role?: string, options?: { triggeredBy?: string; reply?: boolean }): Promise<{ delivery: Record<string, unknown> | null; sendThrew: boolean } | undefined>;
277
+ processSystemMessage(code: string, messages: string | string[], role?: string, options?: { triggeredBy?: string; reply?: boolean }): Promise<{ delivery: Record<string, unknown> | null; sendThrew: boolean; reply: string | null } | undefined>;
278
278
  processOutreach(code: string, options?: { brief?: OutreachBrief | null; triggeredBy?: string | null; reason?: string | null; firstName?: string | null; dryRun?: boolean; replyToContact?: boolean; allowFollowUp?: boolean }): Promise<OutreachDecision>;
279
279
  processCheckInDue(code: string, options: { reminderId: string; type: 'contingencia' | 'seguimiento'; question?: string; firstName?: string | null }): Promise<CheckInDueResult>;
280
280
  processScheduledFollowUp(code: string, options: { reminderId: string; firstName: string; now?: Date }): Promise<ScheduledFollowUpResult>;
@@ -245,6 +245,7 @@ const patientDeliverySchema = new mongoose.Schema({
245
245
  status: { type: String, default: null },
246
246
  transport: { type: String, enum: ['text', 'template_recovery', 'predefined_template', 'assistant'], required: true },
247
247
  prescriptionRecordId: { type: String, default: null },
248
+ checks: { type: [String], default: undefined },
248
249
  deferred: { type: Boolean, default: false },
249
250
  confirmedBy: { type: String, default: null },
250
251
  confirmedAt: { type: Date, default: null },
@@ -141,7 +141,7 @@ async function processAssistantSystemMessage(code, messages, role = 'system', op
141
141
  throw new AssistantMessageError('MESSAGE_REPLY_FAILED', acceptedCount, error);
142
142
  }
143
143
  // What the reply's send returned, or null when the turn produced or sent nothing; a send that threw may still have reached the patient.
144
- return { delivery: turn?.delivery ?? null, sendThrew: settled === 'threw' };
144
+ return { delivery: turn?.delivery ?? null, sendThrew: settled === 'threw', reply: turn?.output ?? null };
145
145
  }
146
146
 
147
147
  module.exports = { AssistantMessageError, injectAssistantMessages, processAssistantSystemMessage };
@@ -28,13 +28,13 @@ const { isWithin24HourWindow } = require('../helpers/conversationWindowHelper');
28
28
  const { ensureWhatsAppFormat } = require('../helpers/twilioHelper');
29
29
  const { DrainingError } = require('../core/BatchingManager');
30
30
  const { generateEscalationDraft } = require('../clinical/services/escalationDraftService');
31
- const { conversationLink, writeUnresolvedRequest } = require('../clinical/services/clinicalAirtableService');
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 } = require('../clinical/services/treatmentProposalService');
34
+ const { drugsNamedIn, unplannedDrugsIn } = require('../clinical/services/treatmentProposalService');
35
35
  const { characterizationSeedOf } = require('../clinical/helpers/reviewUpdateHelper');
36
36
  const {
37
- approvedInstructions, assistantInstructionsOf, needsPrescriber, planInstructions, planOf, planSummary, prescriptionText, proposedPlanOf,
37
+ approvedInstructions, assistantInstructionsOf, needsPrescriber, planInstructions, planOf, planSummary, prescriptionText, plannedTextOf, proposedPlanOf, replyIssues,
38
38
  } = require('../clinical/helpers/doctorPlanHelper');
39
39
  const { readNoteFacts, assertionOf } = require('../clinical/services/escalationNoteFactsService');
40
40
  const { humanReplyFilter } = require('../clinical/config/safetyNetConfig');
@@ -59,7 +59,7 @@ const { createPrescription } = require('./prescriptionService');
59
59
  const { getThreadInfo, switchThreadStoppedStatus } = require('./threadService');
60
60
  const { effectiveUrgencyOf, levelsOf, patientTimerMinutes, responseDeadlineOf, responseDeadlinePolicy } = require('./reviewDeadlineService');
61
61
  const { caseTaskLive, scheduleCaseSync } = require('./escalationCaseTaskService');
62
- const { groupConfigured, sendTeamAlert, teamChannelConfigured } = require('./teamAlertService');
62
+ const { groupConfigured, sendGroupNotice, sendTeamAlert, teamChannelConfigured } = require('./teamAlertService');
63
63
 
64
64
  const getMessaging = () => require('../core/NexusMessaging');
65
65
 
@@ -2156,7 +2156,23 @@ function replyOutcome(delivery, sendThrew) {
2156
2156
  return 'unknown';
2157
2157
  }
2158
2158
 
2159
- // The assistant writes the patient message from the doctor's instructions; the review closes unless a follow-up was chosen.
2159
+ // A reply in the case's alarm thread; a notice that fails is logged and never undoes the send.
2160
+ async function notifyPlanIssues(review, issues) {
2161
+ try {
2162
+ const { patient, doctor, link } = await teamAlertIdentity(review);
2163
+ const header = '⚠️ Revisar el envío del plan';
2164
+ const lines = issues.map((issue) => `- ${issue}`);
2165
+ const action = 'Qué hacer: revisar el chat; corregir con la paciente o volver a mandar la receta si hace falta';
2166
+ const recordId = [...currentAlarms(review)].reverse().find((alarm) => alarm.emergencyRecordId)?.emergencyRecordId;
2167
+ const quoted = await readGroupMessageId(recordId, review.patientCode);
2168
+ const sent = await sendGroupNotice([header, `${patient} (${doctor})`, ...lines, action, link].join('\n'), quoted, [header, ...lines, action].join('\n'));
2169
+ if (!sent.delivered) logger.error('[EscalationReview] The plan check notice did not reach the group', { escalationReviewId: String(review._id), error: sent.error });
2170
+ } catch (error) {
2171
+ logger.error('[EscalationReview] The plan check notice failed', { escalationReviewId: String(review._id), error: redactedError(error) });
2172
+ }
2173
+ }
2174
+
2175
+ // The assistant writes the patient message from the doctor's instructions; the review closes unless a follow-up was chosen or the reply needs a look.
2160
2176
  async function sendPlanThroughAssistant({ claimed, claim, decisionNumber, plan, operator, requestId }) {
2161
2177
  const reviewId = claimed._id;
2162
2178
  const release = () => releaseReview(reviewId, 'sending', 'approved', claim).catch((releaseError) => {
@@ -2168,9 +2184,10 @@ async function sendPlanThroughAssistant({ claimed, claim, decisionNumber, plan,
2168
2184
  const prescriberEmail = needsPrescriber(plan) ? plan.prescriber?.email ?? null : null;
2169
2185
  let delivery;
2170
2186
  let sendThrew;
2187
+ let reply;
2171
2188
  try {
2172
2189
  if (needsPrescriber(plan) && !prescriberEmail) throw httpError(409, 'This plan names no doctor to sign the receta; record the plan again');
2173
- ({ delivery = null, sendThrew = false } = await getMessaging().processSystemMessage(
2190
+ ({ delivery = null, sendThrew = false, reply = null } = await getMessaging().processSystemMessage(
2174
2191
  claimed.patientCode, [assistantInstructionsOf(plan)], 'system', { reply: true, triggeredBy: operator },
2175
2192
  ) ?? {});
2176
2193
  } catch (error) {
@@ -2193,25 +2210,30 @@ async function sendPlanThroughAssistant({ claimed, claim, decisionNumber, plan,
2193
2210
  }
2194
2211
  if (outcome === 'unknown') {
2195
2212
  logger.error('[EscalationReview] The assistant reply could not be confirmed; leaving the review in sending', { escalationReviewId: String(reviewId) });
2213
+ await notifyPlanIssues(claimed, ['no se pudo confirmar que el mensaje salió; el caso sigue en «enviando»']);
2196
2214
  throw httpError(502, 'The assistant reply could not be confirmed; check the chat before sending again');
2197
2215
  }
2198
2216
 
2199
2217
  // A queued reply goes out once the patient answers the recovery template; the receta, a template itself, goes now.
2200
2218
  const queued = outcome === 'queued';
2201
2219
  const prescriptionRecordId = prescriberEmail ? await issuePrescription(claimed, plan, prescriberEmail) : null;
2220
+ const prescriptionFailed = Boolean(prescriberEmail) && !prescriptionRecordId;
2221
+ const unplanned = reply ? unplannedDrugsIn(reply, plannedTextOf(plan)) : [];
2222
+ const issues = [...replyIssues(plan, reply, unplanned), ...(prescriptionFailed ? ['no se pudo crear la receta'] : [])];
2202
2223
  const record = {
2203
2224
  decisionNumber, messageId: delivery.messageId || null, status: delivery.status || null, transport: 'assistant', deferred: queued, sentBy: operator,
2204
2225
  ...(prescriptionRecordId && { prescriptionRecordId }),
2226
+ ...(issues.length && { checks: issues }),
2205
2227
  };
2206
2228
  const recordUpdate = withCaseMirror(claimed, { $set: { patientDelivery: record } }, { recommendation: planSummary(plan) });
2207
2229
  const status = queued ? 'indications_queued' : 'indications_sent';
2208
2230
  const review = await completeOperation(reviewId, claim, 'sending', recordUpdate, status);
2209
- const prescriptionFailed = Boolean(prescriberEmail) && !prescriptionRecordId;
2210
2231
  await audit(review, operator, requestId, {
2211
- action: status, decisionNumber, transport: 'assistant', ...(prescriberEmail && { prescription: prescriptionFailed ? 'failed' : 'created' }),
2232
+ action: status, decisionNumber, transport: 'assistant', checks: issues.length, ...(prescriberEmail && { prescription: prescriptionFailed ? 'failed' : 'created' }),
2212
2233
  });
2213
- // Closing waits for a sent reply and the receta it promised; otherwise the team still has something to do.
2214
- if (plan.followUp.kind === 'none' && !queued && !prescriptionFailed) {
2234
+ if (issues.length) await notifyPlanIssues(review, issues);
2235
+ // Closing waits for a sent reply that kept to the plan and the receta it promised; otherwise the team still has something to do.
2236
+ if (plan.followUp.kind === 'none' && !queued && !issues.length) {
2215
2237
  try {
2216
2238
  return await closeEscalationReview({ id: reviewId, closedBy: operator, requestId });
2217
2239
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "6.4.0-dev.18775",
3
+ "version": "6.4.0-dev.18776",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",