@peopl-health/nexus 6.4.0-dev.18775 → 6.4.0-dev.18777
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/clinical/tools/getRouterContextBundleTool.js +1 -1
- 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 +33 -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
|
};
|
|
@@ -10,7 +10,7 @@ const REQUIRED_SECTION = 'risk';
|
|
|
10
10
|
|
|
11
11
|
const definition = {
|
|
12
12
|
name: 'getRouterContextBundle',
|
|
13
|
-
description: '**Does:** Batched router-context fetch — always evaluates and returns the mandatory `risk` section, plus the requested `landscape` and/or `history` sections. Read-only.\n\n**Required inputs:** none. Optional `include` selects additional sections (`landscape`, `history`; a supplied `risk` value is accepted but redundant), plus optional `landscape_min_priority`, `history_scope`, `history_filter`. Omitting `include` returns all three sections.\n\n**When to call:** after intake at the start of every routing turn, as the single load of the patient\'s clinical context. Do not call it in parallel with `Skill(clinical_routing)`: first read the skill body, then call this bundle. The patient\'s risk disposition cannot be excluded by `include`. Prefer this over calling `getActiveSymptomLandscape`, `getPatientHistory` or `getPatientRiskAssessment` for the same context.\n\n**`turn_focus.recent_escalations` — what you already sent the team.** Every escalation dispatched for this patient in the last 14 days, newest first, with `escalated_at`, `days_since`, `urgency`, `reason` and the `channels` used. Read it before `reportMedicalEscalation`. It records only what was SENT; where the team is with it is in `team_cases`. A complaint unchanged since an escalation a day ago rarely needs a second dispatch; one still uncontrolled days after its escalation needs one at a HIGHER urgency, and `urgency_justification` should say which of the two this is. Empty means nothing was escalated in the window.\n\n**`turn_focus.team_cases` — where the care team is with each case.** Every case the team still holds, and those it closed in the last 14 days, newest first: `status`, `is_open`, `level`, `opened_at`, `days_since`, `doctor_decision` and `case_ids` (the symptom cases it covers).\n- `Por revisar`, `En evaluación`, `Con el médico`, `Indicación por enviar`, `Requiere valoración`: the team has it and has not answered yet. Tell the patient it is with the team, without promising a time. A new escalation is for a new symptom, a higher grade, a new red flag, or a symptom that persists or worsens while it waits (send it as an update).\n- `Esperando paciente`, `Indicaciones dadas`, `En vigilancia`: the team already answered (an indication on its way to the patient, given, or under watch). This comes first: ask how the indication is going before escalating again.\n- `Cerrado`, `No requiere`: the team closed it. A new complaint is a new decision.\n`doctor_decision` is internal (`approved`, `edited_approved`, `monitor`, `needs_intervention` = the doctor asked for an in-person evaluation, `returned`, `direct_chat`): use it to understand the case, never quote it. `team_cases` never says whether the problem resolved; ask the patient. An escalation in `recent_escalations` with no case here has an unknown team status, not an absent one.\n\n**Use `turn_focus.symptom_history` before grading or escalating.** It carries what no other section does: how this patient compares to their own past. `worst_grade` and `worst_reported_at` are the worst they have ever been recorded, `at_personal_worst` says whether they are back at it, and `days_since_last_report` says how stale `last_grade` is. (The current grade and trajectory are already in `landscape.active_cases` — do not expect them repeated here.) Escalate on the comparison, not on severity alone: a patient at their personal worst is a different decision from one below it, and a grade unchanged for weeks is different from the same grade reported yesterday. When you ask about it, anchor the question in the value and the date so the patient can compare. Empty means no graded case on record. \n\n**A case flagged `stale_no_recent_evidence` has had no new evidence for two weeks or more.** `days_since_evidence` says how long. Ask whether the symptom is still there, and close the case with `recordClinicalImpression` `close` when it is not: an open case keeps consuming the landscape and keeps the symptom reading as active on the record.\n\n**How to use risk:** read `risk.turn_focus` first — it is always present. It deterministically keeps every current symptom and maps it to relevant full-profile risks, `checks_now`, and `escalation_threshold`; use the full `risk.profile` only for supporting detail. A highest-severity fallback is labelled explicitly when no direct symptom match exists. When `turn_focus.selection` is `no_current_concerns` the patient reported no current symptom this turn.\n\n**`risk.profile` is a forecast, not history:** `predicted_aes[].minimum_symptom_set`, `disease_risks[].warning_symptoms` and every other profile list name symptoms to watch for, none of which the patient has reported. Never tell the patient a profile symptom appears in their report, and never attach a CTCAE grade to one. Reported symptoms come only from `landscape`, `history` and `turn_focus.current_concerns`.\n\n**Recovery:** verify `sections_returned` contains `risk`. If it does not, inspect `_diagnostics.risk_error` and call `getPatientRiskAssessment` before `submitRoutingDecision`.\n\n**Returns:** `patient_id`, `sections_requested[]`, `sections_returned[]` (only sections that succeeded), mandatory `risk` (always including `turn_focus`), and requested `landscape` / `history`. A section that errors is omitted and its error reported under `_diagnostics`. The attempted risk read and deterministic turn processing are recorded on the turn trace even when no profile exists.',
|
|
13
|
+
description: '**Does:** Batched router-context fetch — always evaluates and returns the mandatory `risk` section, plus the requested `landscape` and/or `history` sections. Read-only.\n\n**Required inputs:** none. Optional `include` selects additional sections (`landscape`, `history`; a supplied `risk` value is accepted but redundant), plus optional `landscape_min_priority`, `history_scope`, `history_filter`. Omitting `include` returns all three sections.\n\n**When to call:** after intake at the start of every routing turn, as the single load of the patient\'s clinical context. Do not call it in parallel with `Skill(clinical_routing)`: first read the skill body, then call this bundle. The patient\'s risk disposition cannot be excluded by `include`. Prefer this over calling `getActiveSymptomLandscape`, `getPatientHistory` or `getPatientRiskAssessment` for the same context.\n\n**`turn_focus.recent_escalations` — what you already sent the team.** Every escalation dispatched for this patient in the last 14 days, newest first, with `escalated_at`, `days_since`, `urgency`, `reason` and the `channels` used. Read it before `reportMedicalEscalation`. It records only what was SENT; where the team is with it is in `team_cases`. A complaint unchanged since an escalation a day ago rarely needs a second dispatch; one still uncontrolled days after its escalation needs one at a HIGHER urgency, and `urgency_justification` should say which of the two this is. Empty means nothing was escalated in the window.\n\n**`turn_focus.team_cases` — where the care team is with each case.** Every case the team still holds, and those it closed in the last 14 days, newest first: `status`, `is_open`, `level`, `opened_at`, `days_since`, `doctor_decision` and `case_ids` (the symptom cases it covers).\n- `Por revisar`, `En evaluación`, `Con el médico`, `Indicación por enviar`, `Requiere valoración`: the team has it and has not answered yet. Tell the patient it is with the team, without promising a time. A new escalation is for a new symptom, a higher grade, a new red flag, or a symptom that persists or worsens while it waits (send it as an update).\n- `Esperando paciente`, `Indicaciones dadas`, `En vigilancia`: the team already answered (an indication on its way to the patient, given, or under watch). This comes first: ask how the indication is going before escalating again.\n- `Cerrado`, `No requiere`: the team closed it. A new complaint is a new decision.\n`doctor_decision` is internal (`approved`, `edited_approved`, `plan_approved` = the doctor gave a structured plan, `monitor`, `needs_intervention` = the doctor asked for an in-person evaluation, `returned`, `direct_chat`): use it to understand the case, never quote it. `team_cases` never says whether the problem resolved; ask the patient. An escalation in `recent_escalations` with no case here has an unknown team status, not an absent one.\n\n**Use `turn_focus.symptom_history` before grading or escalating.** It carries what no other section does: how this patient compares to their own past. `worst_grade` and `worst_reported_at` are the worst they have ever been recorded, `at_personal_worst` says whether they are back at it, and `days_since_last_report` says how stale `last_grade` is. (The current grade and trajectory are already in `landscape.active_cases` — do not expect them repeated here.) Escalate on the comparison, not on severity alone: a patient at their personal worst is a different decision from one below it, and a grade unchanged for weeks is different from the same grade reported yesterday. When you ask about it, anchor the question in the value and the date so the patient can compare. Empty means no graded case on record. \n\n**A case flagged `stale_no_recent_evidence` has had no new evidence for two weeks or more.** `days_since_evidence` says how long. Ask whether the symptom is still there, and close the case with `recordClinicalImpression` `close` when it is not: an open case keeps consuming the landscape and keeps the symptom reading as active on the record.\n\n**How to use risk:** read `risk.turn_focus` first — it is always present. It deterministically keeps every current symptom and maps it to relevant full-profile risks, `checks_now`, and `escalation_threshold`; use the full `risk.profile` only for supporting detail. A highest-severity fallback is labelled explicitly when no direct symptom match exists. When `turn_focus.selection` is `no_current_concerns` the patient reported no current symptom this turn.\n\n**`risk.profile` is a forecast, not history:** `predicted_aes[].minimum_symptom_set`, `disease_risks[].warning_symptoms` and every other profile list name symptoms to watch for, none of which the patient has reported. Never tell the patient a profile symptom appears in their report, and never attach a CTCAE grade to one. Reported symptoms come only from `landscape`, `history` and `turn_focus.current_concerns`.\n\n**Recovery:** verify `sections_returned` contains `risk`. If it does not, inspect `_diagnostics.risk_error` and call `getPatientRiskAssessment` before `submitRoutingDecision`.\n\n**Returns:** `patient_id`, `sections_requested[]`, `sections_returned[]` (only sections that succeeded), mandatory `risk` (always including `turn_focus`), and requested `landscape` / `history`. A section that errors is omitted and its error reported under `_diagnostics`. The attempted risk read and deterministic turn processing are recorded on the turn trace even when no profile exists.',
|
|
14
14
|
strict: false,
|
|
15
15
|
replayScope: 'turn',
|
|
16
16
|
parameters: {
|
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,24 @@ 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
|
+
// Every alarm, not only those since the decision: the plan is always sent after it, so the case's alarm came before.
|
|
2167
|
+
const recordId = [...(review.alarms ?? [])].reverse().find((alarm) => alarm.emergencyRecordId)?.emergencyRecordId;
|
|
2168
|
+
const quoted = await readGroupMessageId(recordId, review.patientCode);
|
|
2169
|
+
const sent = await sendGroupNotice([header, `${patient} (${doctor})`, ...lines, action, link].join('\n'), quoted, [header, ...lines, action].join('\n'));
|
|
2170
|
+
if (!sent.delivered) logger.error('[EscalationReview] The plan check notice did not reach the group', { escalationReviewId: String(review._id), error: sent.error });
|
|
2171
|
+
} catch (error) {
|
|
2172
|
+
logger.error('[EscalationReview] The plan check notice failed', { escalationReviewId: String(review._id), error: redactedError(error) });
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
// 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
2177
|
async function sendPlanThroughAssistant({ claimed, claim, decisionNumber, plan, operator, requestId }) {
|
|
2161
2178
|
const reviewId = claimed._id;
|
|
2162
2179
|
const release = () => releaseReview(reviewId, 'sending', 'approved', claim).catch((releaseError) => {
|
|
@@ -2168,9 +2185,10 @@ async function sendPlanThroughAssistant({ claimed, claim, decisionNumber, plan,
|
|
|
2168
2185
|
const prescriberEmail = needsPrescriber(plan) ? plan.prescriber?.email ?? null : null;
|
|
2169
2186
|
let delivery;
|
|
2170
2187
|
let sendThrew;
|
|
2188
|
+
let reply;
|
|
2171
2189
|
try {
|
|
2172
2190
|
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(
|
|
2191
|
+
({ delivery = null, sendThrew = false, reply = null } = await getMessaging().processSystemMessage(
|
|
2174
2192
|
claimed.patientCode, [assistantInstructionsOf(plan)], 'system', { reply: true, triggeredBy: operator },
|
|
2175
2193
|
) ?? {});
|
|
2176
2194
|
} catch (error) {
|
|
@@ -2193,25 +2211,30 @@ async function sendPlanThroughAssistant({ claimed, claim, decisionNumber, plan,
|
|
|
2193
2211
|
}
|
|
2194
2212
|
if (outcome === 'unknown') {
|
|
2195
2213
|
logger.error('[EscalationReview] The assistant reply could not be confirmed; leaving the review in sending', { escalationReviewId: String(reviewId) });
|
|
2214
|
+
await notifyPlanIssues(claimed, ['no se pudo confirmar que el mensaje salió; el caso sigue en «enviando»']);
|
|
2196
2215
|
throw httpError(502, 'The assistant reply could not be confirmed; check the chat before sending again');
|
|
2197
2216
|
}
|
|
2198
2217
|
|
|
2199
2218
|
// A queued reply goes out once the patient answers the recovery template; the receta, a template itself, goes now.
|
|
2200
2219
|
const queued = outcome === 'queued';
|
|
2201
2220
|
const prescriptionRecordId = prescriberEmail ? await issuePrescription(claimed, plan, prescriberEmail) : null;
|
|
2221
|
+
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'] : [])];
|
|
2202
2224
|
const record = {
|
|
2203
2225
|
decisionNumber, messageId: delivery.messageId || null, status: delivery.status || null, transport: 'assistant', deferred: queued, sentBy: operator,
|
|
2204
2226
|
...(prescriptionRecordId && { prescriptionRecordId }),
|
|
2227
|
+
...(issues.length && { checks: issues }),
|
|
2205
2228
|
};
|
|
2206
2229
|
const recordUpdate = withCaseMirror(claimed, { $set: { patientDelivery: record } }, { recommendation: planSummary(plan) });
|
|
2207
2230
|
const status = queued ? 'indications_queued' : 'indications_sent';
|
|
2208
2231
|
const review = await completeOperation(reviewId, claim, 'sending', recordUpdate, status);
|
|
2209
|
-
const prescriptionFailed = Boolean(prescriberEmail) && !prescriptionRecordId;
|
|
2210
2232
|
await audit(review, operator, requestId, {
|
|
2211
|
-
action: status, decisionNumber, transport: 'assistant', ...(prescriberEmail && { prescription: prescriptionFailed ? 'failed' : 'created' }),
|
|
2233
|
+
action: status, decisionNumber, transport: 'assistant', checks: issues.length, ...(prescriberEmail && { prescription: prescriptionFailed ? 'failed' : 'created' }),
|
|
2212
2234
|
});
|
|
2213
|
-
|
|
2214
|
-
|
|
2235
|
+
if (issues.length) await notifyPlanIssues(review, issues);
|
|
2236
|
+
// Closing waits for a sent reply that kept to the plan and the receta it promised; otherwise the team still has something to do.
|
|
2237
|
+
if (plan.followUp.kind === 'none' && !queued && !issues.length) {
|
|
2215
2238
|
try {
|
|
2216
2239
|
return await closeEscalationReview({ id: reviewId, closedBy: operator, requestId });
|
|
2217
2240
|
} catch (error) {
|