@peopl-health/nexus 6.4.0-dev.18675 → 6.4.0-dev.18676

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.
@@ -93,6 +93,9 @@ function planOf(input) {
93
93
  const needsPrescriber = (plan) => plan.items.length > 0 || plan.studies.length > 0;
94
94
 
95
95
  const daysText = (days) => (days === 1 ? '1 día' : `${days} días`);
96
+ const itemLine = (item) => `${[item.drug, item.strength].filter(Boolean).join(' ')}: ${item.dose} ${item.frequency} por ${daysText(item.days)}.`;
97
+ const studyLine = (study) => `${study.name}${study.instructions ? `: ${study.instructions}` : ''}.`;
98
+ const PRESCRIPTION_LINE = 'Justo después de tu mensaje le llega aparte la receta del médico en PDF; dile que la va a recibir.';
96
99
 
97
100
  const FOLLOW_UP_LINES = {
98
101
  check_in: (hours) => `Seguimiento: le escribiremos en unas ${hours} horas para ver cómo va.`,
@@ -103,14 +106,36 @@ const FOLLOW_UP_LINES = {
103
106
  function planInstructions(plan) {
104
107
  const lines = ['Transmítele al paciente lo que indicó el médico, con tus palabras: no cambies dosis, frecuencia ni días, y no agregues otros medicamentos ni estudios.'];
105
108
  if (plan.items.length) {
106
- lines.push('Medicamentos:', ...plan.items.map((item) => `- ${[item.drug, item.strength].filter(Boolean).join(' ')}: ${item.dose} ${item.frequency} por ${daysText(item.days)}.`));
109
+ lines.push('Medicamentos:', ...plan.items.map((item) => `- ${itemLine(item)}`));
107
110
  }
108
111
  if (plan.studies.length) {
109
- lines.push('Estudios:', ...plan.studies.map((study) => `- ${study.name}${study.instructions ? `: ${study.instructions}` : ''}.`));
112
+ lines.push('Estudios:', ...plan.studies.map((study) => `- ${studyLine(study)}`));
110
113
  }
111
114
  if (plan.homeCare.length) lines.push('Cuidados en casa:', ...plan.homeCare.map((entry) => `- ${entry}`));
112
115
  if (plan.note) lines.push(`Nota del médico: «${plan.note}».`);
113
116
  if (plan.followUp.kind !== 'none') lines.push(FOLLOW_UP_LINES[plan.followUp.kind](plan.followUp.hours));
117
+ if (needsPrescriber(plan)) lines.push(PRESCRIPTION_LINE);
118
+ return lines.join('\n');
119
+ }
120
+
121
+ // An edit can drop the receta line; the PDF goes out anyway, so the assistant is still told.
122
+ function assistantInstructionsOf(plan) {
123
+ if (!needsPrescriber(plan) || plan.instructions.includes(PRESCRIPTION_LINE)) return plan.instructions;
124
+ return `${plan.instructions}\n${PRESCRIPTION_LINE}`;
125
+ }
126
+
127
+ // What the team's case shows as the indication, in the doctor's terms rather than the assistant's.
128
+ function planSummary(plan) {
129
+ const lines = [...plan.items.map(itemLine), ...plan.studies.map((study) => `Estudio: ${studyLine(study)}`)];
130
+ if (plan.homeCare.length) lines.push(`Cuidados en casa: ${plan.homeCare.join('; ')}.`);
131
+ if (plan.note) lines.push(`Nota: ${plan.note}`);
132
+ return lines.join('\n');
133
+ }
134
+
135
+ // The receta the Airtable automations turn into the doctor's PDF.
136
+ function prescriptionText(plan) {
137
+ const lines = plan.items.map(itemLine);
138
+ if (plan.studies.length) lines.push('Estudios:', ...plan.studies.map(studyLine));
114
139
  return lines.join('\n');
115
140
  }
116
141
 
@@ -158,8 +183,11 @@ function proposedPlanOf(draft) {
158
183
 
159
184
  module.exports = {
160
185
  approvedInstructions,
186
+ assistantInstructionsOf,
161
187
  needsPrescriber,
162
188
  planInstructions,
163
189
  planOf,
190
+ planSummary,
191
+ prescriptionText,
164
192
  proposedPlanOf,
165
193
  };
@@ -5,7 +5,7 @@ const { logger } = require('../../utils/logger');
5
5
 
6
6
  const definition = {
7
7
  name: 'getPatientRiskAssessment',
8
- description: '**Does:** Returns the patient\'s latest full risk assessment and a deterministic `turn_focus` for the symptoms extracted in this turn. Read-only; no model call.\n\n**Required inputs:** none.\n\n**When to call:** immediately before `submitRoutingDecision` only when `getRouterContextBundle.sections_returned` does not contain `risk`, including a bundle result with `_diagnostics.risk_error`. The routine load is the bundle\'s mandatory `risk` section, which resolves the same assessment through the same code path.\n\n**When NOT to call:** when `getRouterContextBundle` already returned `risk` this turn — it is the same assessment, and a second read cannot change it. Nor again in the same turn after a `not_generated` result — generation runs between turns and the result cannot change mid-turn.\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**How to use:** read `turn_focus.current_concerns`, then each `relevant_risks[].checks_now` and `escalation_threshold`. It preserves concurrent symptoms. Any `selection` containing `fallback` marks a precaution to check, not a confirmed explanation. When `selection` is `no_current_concerns` the patient reported no current symptom this turn. Use `profile` only for supporting detail.\n\n**`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 `turn_focus.current_concerns`.\n\n**Returns:** `status` — `final` when an assessment exists and carries findings, `no_signal` when one exists but the synthesis found nothing usable, or `not_generated`; `turn_focus`, always present; and the full `profile` (`predicted_aes[]`, `disease_risks[]`, `comorbidity_amplifiers[]`, lifecycle and missing-information fields). `profile` is null when not yet generated.',
8
+ description: '**Does:** Returns the patient\'s latest full risk assessment and a deterministic `turn_focus` for the symptoms extracted in this turn. Read-only; no model call.\n\n**Required inputs:** none.\n\n**When to call:** immediately before `submitRoutingDecision` only when `getRouterContextBundle.sections_returned` does not contain `risk`, including a bundle result with `_diagnostics.risk_error`. The routine load is the bundle\'s mandatory `risk` section, which resolves the same assessment through the same code path.\n\n**When NOT to call:** when `getRouterContextBundle` already returned `risk` this turn — it is the same assessment, and a second read cannot change it. Nor again in the same turn after a `not_generated` result — generation runs between turns and the result cannot change mid-turn.\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**How to use:** read `turn_focus.current_concerns`, then each `relevant_risks[].checks_now` and `escalation_threshold`. It preserves concurrent symptoms. Any `selection` containing `fallback` marks a precaution to check, not a confirmed explanation. When `selection` is `no_current_concerns` the patient reported no current symptom this turn. Use `profile` only for supporting detail.\n\n**`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 `turn_focus.current_concerns`.\n\n**Returns:** `status` — `final` when an assessment exists and carries findings, `no_signal` when one exists but the synthesis found nothing usable, or `not_generated`; `turn_focus`, always present; and the full `profile` (`predicted_aes[]`, `disease_risks[]`, `comorbidity_amplifiers[]`, lifecycle and missing-information fields). `profile` is null when not yet generated.',
9
9
  strict: false,
10
10
  replayScope: 'turn',
11
11
  parameters: {
@@ -1,17 +1,11 @@
1
- const { Monitoreo_ID } = require('../config/airtableConfig');
2
-
3
1
  const { logger } = require('../utils/logger');
4
2
 
5
- const { ensureWhatsAppFormat } = require('../helpers/twilioHelper');
6
-
7
- const { addLinkedRecord } = require('../services/airtableService');
3
+ const { createPrescription } = require('../services/prescriptionService');
8
4
 
9
5
  const RECORD_ID_RX = /^rec[A-Za-z0-9]{14}$/;
10
6
 
11
- // physicianId links the receta to a physicians row; the Airtable automation picks the PDF template from that physician's specialty.
12
7
  const createPrescriptionController = async (req, res) => {
13
- const { prescription, triggeredBy, creationSource = 'other', physicianId } = req.body;
14
- let code = req.body.code;
8
+ const { code, prescription, triggeredBy, creationSource = 'other', physicianId } = req.body;
15
9
 
16
10
  if (!code) return res.status(400).json({ success: false, error: 'Code is required' });
17
11
  if (!prescription) return res.status(400).json({ success: false, error: 'Prescription is required' });
@@ -21,28 +15,10 @@ const createPrescriptionController = async (req, res) => {
21
15
  }
22
16
 
23
17
  try {
24
- code = ensureWhatsAppFormat(code);
25
-
26
- const record = await addLinkedRecord(
27
- Monitoreo_ID,
28
- 'patient_prescriptions',
29
- {
30
- prescription,
31
- author_email: triggeredBy,
32
- creation_source: creationSource,
33
- created: new Date().toISOString(),
34
- ...(physicianId && { physician: [physicianId] })
35
- },
36
- {
37
- referenceTable: 'estado_general',
38
- referenceFilter: `{whatsapp_id}='${code}'`,
39
- linkFieldName: 'patient_id'
40
- },
41
- code
42
- );
18
+ const recordId = await createPrescription({ code, prescription, authorEmail: triggeredBy, creationSource, physicianId });
43
19
 
44
20
  logger.info('[PrescriptionController] Prescription created', { code, physicianId });
45
- res.status(201).json({ success: true, message: 'Prescription created', recordId: record?.id || null });
21
+ res.status(201).json({ success: true, message: 'Prescription created', recordId });
46
22
  } catch (error) {
47
23
  logger.error('[PrescriptionController] Error creating prescription', { error: error.message, code });
48
24
  res.status(500).json({ success: false, error: 'Failed to create prescription' });
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<void>;
277
+ processSystemMessage(code: string, messages: string | string[], role?: string, options?: { triggeredBy?: string; reply?: boolean }): Promise<{ delivery: Record<string, unknown> | null; sendThrew: boolean } | 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>;
@@ -239,7 +239,8 @@ const patientDeliverySchema = new mongoose.Schema({
239
239
  decisionNumber: { type: Number, required: true },
240
240
  messageId: { type: String, default: null },
241
241
  status: { type: String, default: null },
242
- transport: { type: String, enum: ['text', 'template_recovery', 'predefined_template'], required: true },
242
+ transport: { type: String, enum: ['text', 'template_recovery', 'predefined_template', 'assistant'], required: true },
243
+ prescriptionRecordId: { type: String, default: null },
243
244
  deferred: { type: Boolean, default: false },
244
245
  confirmedBy: { type: String, default: null },
245
246
  confirmedAt: { type: Date, default: null },
@@ -26,9 +26,9 @@ const ASSISTANT_MESSAGE_FAILURES = {
26
26
  };
27
27
 
28
28
  class AssistantMessageError extends Error {
29
- constructor(reasonCode, acceptedCount = 0) {
29
+ constructor(reasonCode, acceptedCount = 0, cause = undefined) {
30
30
  const [statusCode, message] = ASSISTANT_MESSAGE_FAILURES[reasonCode];
31
- super(message);
31
+ super(message, cause === undefined ? undefined : { cause });
32
32
  this.reasonCode = reasonCode;
33
33
  this.statusCode = statusCode;
34
34
  this.acceptedCount = acceptedCount;
@@ -117,8 +117,10 @@ async function processAssistantSystemMessage(code, messages, role = 'system', op
117
117
  if (!reply) return;
118
118
  recordPatientSend?.({ code, channel: 'assistant_instruction', sentBy: triggeredBy, text: input.messages.join('\n') });
119
119
 
120
+ let turn;
121
+ let settled = null;
120
122
  try {
121
- await executeWithPipeline(code, 'system', 'queue',
123
+ turn = await executeWithPipeline(code, 'system', 'queue',
122
124
  async (preProcessResult, shouldContinue, runId) => {
123
125
  return await assistantProcessor.process({
124
126
  code,
@@ -131,11 +133,15 @@ async function processAssistantSystemMessage(code, messages, role = 'system', op
131
133
  signal: getAbortSignal(runId),
132
134
  }
133
135
  });
134
- }
136
+ },
137
+ { onSendSettled: async (outcome) => { settled = outcome; } },
135
138
  );
136
- } catch {
137
- throw new AssistantMessageError('MESSAGE_REPLY_FAILED', acceptedCount);
139
+ } catch (error) {
140
+ // The cause stays internal: callers tell a drain that refused the turn from one that may have answered.
141
+ throw new AssistantMessageError('MESSAGE_REPLY_FAILED', acceptedCount, error);
138
142
  }
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' };
139
145
  }
140
146
 
141
147
  module.exports = { AssistantMessageError, injectAssistantMessages, processAssistantSystemMessage };
@@ -33,7 +33,9 @@ const { doctorAlertHeadline, doctorAlertOpener, renderDoctorMessage, templateSum
33
33
  const { generateKeyQuestions } = require('../clinical/services/escalationQuestionsService');
34
34
  const { drugsNamedIn } = require('../clinical/services/treatmentProposalService');
35
35
  const { characterizationSeedOf } = require('../clinical/helpers/reviewUpdateHelper');
36
- const { approvedInstructions, needsPrescriber, planInstructions, planOf, proposedPlanOf } = require('../clinical/helpers/doctorPlanHelper');
36
+ const {
37
+ approvedInstructions, assistantInstructionsOf, needsPrescriber, planInstructions, planOf, planSummary, prescriptionText, proposedPlanOf,
38
+ } = require('../clinical/helpers/doctorPlanHelper');
37
39
  const { readNoteFacts, assertionOf } = require('../clinical/services/escalationNoteFactsService');
38
40
  const { humanReplyFilter } = require('../clinical/config/safetyNetConfig');
39
41
  const {
@@ -53,6 +55,7 @@ const { getPatientInformation, logPatientChange } = require('./patientService');
53
55
  const { MAX_NOTE_LENGTH, openCaseIds, readPatientCases, updatePatientTask } = require('./patientTaskService');
54
56
  const { countMessages } = require('./messageService');
55
57
  const { getEscalationApprover, listEscalationApprovers } = require('./physicianService');
58
+ const { createPrescription } = require('./prescriptionService');
56
59
  const { getThreadInfo, switchThreadStoppedStatus } = require('./threadService');
57
60
  const { effectiveUrgencyOf, levelsOf, patientTimerMinutes, responseDeadlineOf, responseDeadlinePolicy } = require('./reviewDeadlineService');
58
61
  const { caseTaskLive, scheduleCaseSync } = require('./escalationCaseTaskService');
@@ -2033,10 +2036,10 @@ async function sendApprovedIndications({ id, outsideWindowRoute = null, sentBy,
2033
2036
  const claim = newOperationClaim();
2034
2037
  const claimed = await updateReview(reviewId, SENDABLE_STATUSES, { $set: { status: 'sending', operationClaim: claim } });
2035
2038
  const decisionNumber = claimed.decisions.length;
2036
- const { finalText, decision } = claimed.decisions[decisionNumber - 1];
2039
+ const { finalText, decision, plan } = claimed.decisions[decisionNumber - 1];
2037
2040
  const claimedFrom = statusAfter(decision);
2038
2041
 
2039
- if (!finalText) {
2042
+ if (!finalText && !plan) {
2040
2043
  await releaseReview(reviewId, 'sending', claimedFrom, claim).catch((releaseError) => {
2041
2044
  logger.error('[EscalationReview] The claim was not released after a decision with no text', {
2042
2045
  escalationReviewId: String(claimed._id), error: releaseError.message,
@@ -2054,6 +2057,7 @@ async function sendApprovedIndications({ id, outsideWindowRoute = null, sentBy,
2054
2057
  });
2055
2058
  throw httpError(409, 'The agent characterized the case after this decision; it went back to documenting for a new draft and decision');
2056
2059
  }
2060
+ if (plan) return sendPlanThroughAssistant({ claimed, claim, decisionNumber, plan, operator, requestId });
2057
2061
 
2058
2062
  // Built outside the try: composing reads Mongo and Airtable, and failing there never sent.
2059
2063
  let message;
@@ -2109,6 +2113,100 @@ async function sendApprovedIndications({ id, outsideWindowRoute = null, sentBy,
2109
2113
  return syncCase(review, operator, requestId);
2110
2114
  }
2111
2115
 
2116
+ const PRESCRIBER_EMAIL_KEY = 'ESCALATION_PRESCRIBER_EMAIL';
2117
+
2118
+ // The PDF goes out from the Airtable automations; a failed receta never undoes the message already sent.
2119
+ async function issuePrescription(review, plan, prescriberEmail) {
2120
+ try {
2121
+ return await createPrescription({
2122
+ code: review.patientCode, prescription: prescriptionText(plan), authorEmail: prescriberEmail, creationSource: 'patient case',
2123
+ });
2124
+ } catch (error) {
2125
+ logger.error('[EscalationReview] The prescription was not created after the plan was sent', {
2126
+ escalationReviewId: String(review._id), error: redactedError(error),
2127
+ });
2128
+ return null;
2129
+ }
2130
+ }
2131
+
2132
+ // How the assistant's reply left: sent, queued for the patient's reply window, not sent, or unknown.
2133
+ function replyOutcome(delivery, sendThrew) {
2134
+ if (sendThrew) return 'unknown';
2135
+ if (!delivery) return 'not_sent';
2136
+ if (delivery.success === true && delivery.deferred === true) return 'queued';
2137
+ if (delivery.success === true && typeof delivery.messageId === 'string' && delivery.messageId) return 'sent';
2138
+ if (delivery.success === false && delivery.deferred !== true && delivery.duplicate !== true) return 'not_sent';
2139
+ return 'unknown';
2140
+ }
2141
+
2142
+ // The assistant writes the patient message from the doctor's instructions; the review closes unless a follow-up was chosen.
2143
+ async function sendPlanThroughAssistant({ claimed, claim, decisionNumber, plan, operator, requestId }) {
2144
+ const reviewId = claimed._id;
2145
+ const release = () => releaseReview(reviewId, 'sending', 'approved', claim).catch((releaseError) => {
2146
+ logger.error('[EscalationReview] The claim was not released after a plan the assistant did not send', {
2147
+ escalationReviewId: String(reviewId), error: releaseError.message,
2148
+ });
2149
+ });
2150
+
2151
+ let prescriberEmail = null;
2152
+ let delivery;
2153
+ let sendThrew;
2154
+ try {
2155
+ if (needsPrescriber(plan)) prescriberEmail = configured(PRESCRIBER_EMAIL_KEY);
2156
+ ({ delivery = null, sendThrew = false } = await getMessaging().processSystemMessage(
2157
+ claimed.patientCode, [assistantInstructionsOf(plan)], 'system', { reply: true, triggeredBy: operator },
2158
+ ) ?? {});
2159
+ } catch (error) {
2160
+ // A turn refused by a deploy's drain never ran; any other failure after the instructions were stored may have answered.
2161
+ if (error?.reasonCode === 'MESSAGE_REPLY_FAILED' && !(error.cause instanceof DrainingError)) {
2162
+ logger.error('[EscalationReview] The assistant turn failed after the instructions were stored; leaving the review in sending', {
2163
+ escalationReviewId: String(reviewId),
2164
+ });
2165
+ throw httpError(502, 'The assistant turn failed after the instructions were stored; check the chat before sending again');
2166
+ }
2167
+ await release();
2168
+ if (error?.cause instanceof DrainingError) throw httpError(503, 'The server is restarting; send the plan again in a minute');
2169
+ throw error;
2170
+ }
2171
+
2172
+ const outcome = replyOutcome(delivery, sendThrew);
2173
+ if (outcome === 'not_sent') {
2174
+ await release();
2175
+ throw httpError(502, 'The assistant did not send a message to the patient; check the chat and send the plan again');
2176
+ }
2177
+ if (outcome === 'unknown') {
2178
+ logger.error('[EscalationReview] The assistant reply could not be confirmed; leaving the review in sending', { escalationReviewId: String(reviewId) });
2179
+ throw httpError(502, 'The assistant reply could not be confirmed; check the chat before sending again');
2180
+ }
2181
+
2182
+ // A queued reply goes out once the patient answers the recovery template; the receta, a template itself, goes now.
2183
+ const queued = outcome === 'queued';
2184
+ const prescriptionRecordId = prescriberEmail ? await issuePrescription(claimed, plan, prescriberEmail) : null;
2185
+ const record = {
2186
+ decisionNumber, messageId: delivery.messageId || null, status: delivery.status || null, transport: 'assistant', deferred: queued, sentBy: operator,
2187
+ ...(prescriptionRecordId && { prescriptionRecordId }),
2188
+ };
2189
+ const recordUpdate = withCaseMirror(claimed, { $set: { patientDelivery: record } }, { recommendation: planSummary(plan) });
2190
+ const status = queued ? 'indications_queued' : 'indications_sent';
2191
+ const review = await completeOperation(reviewId, claim, 'sending', recordUpdate, status);
2192
+ const prescriptionFailed = Boolean(prescriberEmail) && !prescriptionRecordId;
2193
+ await audit(review, operator, requestId, {
2194
+ action: status, decisionNumber, transport: 'assistant', ...(prescriberEmail && { prescription: prescriptionFailed ? 'failed' : 'created' }),
2195
+ });
2196
+ // Closing waits for a sent reply and the receta it promised; otherwise the team still has something to do.
2197
+ if (plan.followUp.kind === 'none' && !queued && !prescriptionFailed) {
2198
+ try {
2199
+ return await closeEscalationReview({ id: reviewId, closedBy: operator, requestId });
2200
+ } catch (error) {
2201
+ logger.error('[EscalationReview] The review was not closed after its plan was sent', {
2202
+ escalationReviewId: String(reviewId), error: redactedError(error),
2203
+ });
2204
+ return syncCase(await loadReview(reviewId), operator, requestId);
2205
+ }
2206
+ }
2207
+ return syncCase(review, operator, requestId);
2208
+ }
2209
+
2112
2210
  const EPISODE_TRUST = 0.7;
2113
2211
  const EPISODE_VARIABLE = 'intercurrent_events_summary';
2114
2212
  const EPISODE_SOURCE = 'doctor';
@@ -0,0 +1,30 @@
1
+ const { Monitoreo_ID } = require('../config/airtableConfig');
2
+
3
+ const { ensureWhatsAppFormat } = require('../helpers/twilioHelper');
4
+
5
+ const { addLinkedRecord } = require('./airtableService');
6
+
7
+ // physicianId links the receta to a physicians row; the Airtable automation picks the PDF template from that physician's specialty.
8
+ async function createPrescription({ code, prescription, authorEmail, creationSource = 'other', physicianId }) {
9
+ const patientCode = ensureWhatsAppFormat(code);
10
+ const record = await addLinkedRecord(
11
+ Monitoreo_ID,
12
+ 'patient_prescriptions',
13
+ {
14
+ prescription,
15
+ author_email: authorEmail,
16
+ creation_source: creationSource,
17
+ created: new Date().toISOString(),
18
+ ...(physicianId && { physician: [physicianId] })
19
+ },
20
+ {
21
+ referenceTable: 'estado_general',
22
+ referenceFilter: `{whatsapp_id}='${patientCode}'`,
23
+ linkFieldName: 'patient_id'
24
+ },
25
+ patientCode
26
+ );
27
+ return record?.id || null;
28
+ }
29
+
30
+ module.exports = { createPrescription };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "6.4.0-dev.18675",
3
+ "version": "6.4.0-dev.18676",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",