@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.
- package/lib/clinical/helpers/doctorPlanHelper.js +39 -0
- package/lib/clinical/services/treatmentProposalService.js +11 -4
- package/lib/index.d.ts +1 -1
- package/lib/models/escalationReviewModel.js +1 -0
- package/lib/services/assistantMessageService.js +1 -1
- package/lib/services/escalationReviewService.js +32 -10
- package/package.json +1 -1
|
@@ -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
|
|
94
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
2214
|
-
|
|
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) {
|