@peopl-health/nexus 5.69.0-dev.12577 → 5.69.0-dev.12578
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/flags/emergencyConsultFlags.js +16 -0
- package/lib/clinical/monitoring/LlmMonitorService.js +12 -4
- package/lib/clinical/monitoring/emergencyConsultMonitorSpec.js +215 -0
- package/lib/clinical/services/emergencyConsultService.js +76 -0
- package/lib/core/NexusMessaging.js +7 -3
- package/package.json +1 -1
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const runtimeConfig = require('../../config/runtimeConfig');
|
|
2
|
+
|
|
3
|
+
const MODES = ['off', 'shadow'];
|
|
4
|
+
const DEFAULT_SAMPLE_RATE = 0.1;
|
|
5
|
+
|
|
6
|
+
function emergencyConsultMode() {
|
|
7
|
+
const value = String(runtimeConfig.get('EMERGENCY_CONSULT_MODE', 'off')).trim().toLowerCase();
|
|
8
|
+
return MODES.includes(value) ? value : 'off';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function emergencyConsultSampleRate() {
|
|
12
|
+
const value = Number(runtimeConfig.get('EMERGENCY_CONSULT_SAMPLE_RATE', DEFAULT_SAMPLE_RATE));
|
|
13
|
+
return Number.isFinite(value) && value >= 0 && value <= 1 ? value : DEFAULT_SAMPLE_RATE;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
module.exports = { emergencyConsultMode, emergencyConsultSampleRate };
|
|
@@ -230,7 +230,7 @@ class LlmMonitorService {
|
|
|
230
230
|
if (!evaluation) return Evaluations.findOne(key);
|
|
231
231
|
|
|
232
232
|
try {
|
|
233
|
-
const evidence = spec.buildEvidence(trace);
|
|
233
|
+
const evidence = await spec.buildEvidence(trace);
|
|
234
234
|
let judge = null;
|
|
235
235
|
let result = null;
|
|
236
236
|
let judgePrompt = null;
|
|
@@ -248,12 +248,19 @@ class LlmMonitorService {
|
|
|
248
248
|
text: { format: { type: 'json_schema', name: `${spec.id}_verdict`, schema: spec.schema, strict: true } },
|
|
249
249
|
metadata: { source: 'llm_monitor', turn_id: trace.turnId, monitor_id: spec.id, monitor_version: String(spec.version) },
|
|
250
250
|
});
|
|
251
|
-
|
|
252
|
-
spec.
|
|
251
|
+
// A refusal repeats on retry, so a spec that can record it does not pay for it again.
|
|
252
|
+
const isFinalRefusal = result?.status === 'refused' && spec.refusalIsFinal === true;
|
|
253
|
+
if (result?.status && result.status !== 'completed' && !isFinalRefusal) {
|
|
254
|
+
throw new Error(`monitor judge did not complete: ${result.stop_reason || result.status}`);
|
|
255
|
+
}
|
|
256
|
+
if (!isFinalRefusal) {
|
|
257
|
+
judge = parseJudge(result);
|
|
258
|
+
spec.validateJudge?.(judge);
|
|
259
|
+
}
|
|
253
260
|
}
|
|
254
261
|
let findings = spec.interpret({ trace, evidence, judge });
|
|
255
262
|
const evalKey = `${spec.id}:${spec.version}:${trace.turnId}`;
|
|
256
|
-
const passSampled = findings.length === 0 && stableFraction(evalKey) < this.passSampleRate;
|
|
263
|
+
const passSampled = findings.length === 0 && judge !== null && stableFraction(evalKey) < this.passSampleRate;
|
|
257
264
|
if (passSampled) findings = [passFinding(judge)];
|
|
258
265
|
findings = findings.map((finding, index) => ({
|
|
259
266
|
...finding,
|
|
@@ -552,6 +559,7 @@ class LlmMonitorService {
|
|
|
552
559
|
|
|
553
560
|
module.exports = {
|
|
554
561
|
LlmMonitorService,
|
|
562
|
+
stableFraction,
|
|
555
563
|
EVALUATE_QUEUE,
|
|
556
564
|
RECONCILE_QUEUE,
|
|
557
565
|
_test: { stableFraction, findingId, needsSync, syncBacklogFilter, RECONCILE_BATCH_SIZE, MAX_EVALUATION_ATTEMPTS },
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
const { patientMessageFromTrace } = require('../helpers/evidenceAnchorHelper');
|
|
2
|
+
const { buildConsultContext } = require('../services/emergencyConsultService');
|
|
3
|
+
const { emergencyConsultSampleRate } = require('../flags/emergencyConsultFlags');
|
|
4
|
+
const { URGENCY_RANK } = require('../../models/escalationReviewModel');
|
|
5
|
+
const { isPlainObject } = require('../../utils/jsonUtils');
|
|
6
|
+
|
|
7
|
+
const { stableFraction } = require('./LlmMonitorService');
|
|
8
|
+
|
|
9
|
+
const MONITOR_ID = 'emergency_consult';
|
|
10
|
+
const MONITOR_VERSION = '1';
|
|
11
|
+
const PRESET_ID = 'sa_emergency_consult';
|
|
12
|
+
const LEVELS = ['stat', 'asap', 'urgent', 'routine'];
|
|
13
|
+
const IMMEDIATE_LEVELS = ['stat', 'asap'];
|
|
14
|
+
const DISPOSITIONS = ['escalate_now', 'ask_first', 'no_escalation'];
|
|
15
|
+
const STALE_AFTER_MS = 15 * 60 * 1000;
|
|
16
|
+
const UNREADABLE_PATIENT = [400, 404];
|
|
17
|
+
|
|
18
|
+
const strings = { type: 'array', items: { type: 'string' } };
|
|
19
|
+
const nullableLevel = { anyOf: [{ type: 'string', enum: LEVELS }, { type: 'null' }] };
|
|
20
|
+
|
|
21
|
+
const CONSULT_SCHEMA = {
|
|
22
|
+
type: 'object',
|
|
23
|
+
additionalProperties: false,
|
|
24
|
+
required: ['disposition', 'urgency_level', 'confidence', 'clinical_reasoning', 'serious_differential', 'red_flags_present',
|
|
25
|
+
'red_flags_ruled_out', 'missing_information', 'level_if_unanswered', 'context_gaps', 'recommended_action', 'what_to_tell_patient'],
|
|
26
|
+
properties: {
|
|
27
|
+
disposition: { type: 'string', enum: DISPOSITIONS },
|
|
28
|
+
urgency_level: nullableLevel,
|
|
29
|
+
confidence: { type: 'string', enum: ['low', 'moderate', 'high'] },
|
|
30
|
+
clinical_reasoning: { type: 'string' },
|
|
31
|
+
serious_differential: {
|
|
32
|
+
type: 'array',
|
|
33
|
+
items: {
|
|
34
|
+
type: 'object',
|
|
35
|
+
additionalProperties: false,
|
|
36
|
+
required: ['condition', 'status', 'features'],
|
|
37
|
+
properties: {
|
|
38
|
+
condition: { type: 'string' },
|
|
39
|
+
status: { type: 'string', enum: ['supported', 'not_excluded', 'unlikely'] },
|
|
40
|
+
features: strings,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
red_flags_present: strings,
|
|
45
|
+
red_flags_ruled_out: strings,
|
|
46
|
+
missing_information: {
|
|
47
|
+
type: 'array',
|
|
48
|
+
items: {
|
|
49
|
+
type: 'object',
|
|
50
|
+
additionalProperties: false,
|
|
51
|
+
required: ['question_for_patient', 'why', 'level_if_positive'],
|
|
52
|
+
properties: {
|
|
53
|
+
question_for_patient: { type: 'string' },
|
|
54
|
+
why: { type: 'string' },
|
|
55
|
+
level_if_positive: { type: 'string', enum: LEVELS },
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
level_if_unanswered: nullableLevel,
|
|
60
|
+
context_gaps: strings,
|
|
61
|
+
recommended_action: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
additionalProperties: false,
|
|
64
|
+
required: ['now', 'notify'],
|
|
65
|
+
properties: {
|
|
66
|
+
now: { type: 'string' },
|
|
67
|
+
notify: { type: 'string', enum: ['none', 'team_review', 'treating_team_today', 'oncologist_now', 'emergency_services'] },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
what_to_tell_patient: { type: 'string' },
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const asArray = (value) => (Array.isArray(value) ? value : []);
|
|
75
|
+
|
|
76
|
+
function triggers(trace) {
|
|
77
|
+
const signals = trace?.signals || {};
|
|
78
|
+
const found = [];
|
|
79
|
+
if (signals.medicalEscalation) found.push('medical_escalation');
|
|
80
|
+
if (asArray(signals.safetyFlags).length) found.push('safety_flags');
|
|
81
|
+
if (asArray(signals.activatedSkills).includes('emergency')) found.push('emergency_skill');
|
|
82
|
+
if (!found.length && asArray(signals.clinicalMentions).length
|
|
83
|
+
&& stableFraction(`${MONITOR_ID}:${trace.turnId}`) < emergencyConsultSampleRate()) found.push('mention_sample');
|
|
84
|
+
return found;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isEligible(trace) {
|
|
88
|
+
return trace?.status === 'completed'
|
|
89
|
+
&& trace?.shadow !== true
|
|
90
|
+
&& triggers(trace).length > 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function agentDecision(trace) {
|
|
94
|
+
const escalation = trace.signals?.medicalEscalation;
|
|
95
|
+
if (!escalation) return { escalated: false, level: null, requestedLevel: null, patternFloor: null };
|
|
96
|
+
return {
|
|
97
|
+
escalated: true,
|
|
98
|
+
level: escalation.urgencyLevel || null,
|
|
99
|
+
requestedLevel: escalation.requestedUrgencyLevel || null,
|
|
100
|
+
patternFloor: escalation.clinicalPatternFloor || null,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const mentionFacts = (trace) => asArray(trace.signals?.clinicalMentions)
|
|
105
|
+
.map(({ fact }) => {
|
|
106
|
+
if (!isPlainObject(fact)) return null;
|
|
107
|
+
const { patientId: _patientId, mentionId: _mentionId, ...rest } = fact;
|
|
108
|
+
return rest;
|
|
109
|
+
})
|
|
110
|
+
.filter(Boolean);
|
|
111
|
+
|
|
112
|
+
async function buildEvidence(trace, now = Date.now()) {
|
|
113
|
+
// The trace is saved once the turn ends, so the turn started predictionTimeMs before createdAt.
|
|
114
|
+
const turnStart = new Date(trace.createdAt).getTime() - (trace.timings?.predictionTimeMs || 0);
|
|
115
|
+
const base = { triggers: triggers(trace), agent: agentDecision(trace), lag_ms: now - turnStart };
|
|
116
|
+
if (base.lag_ms > STALE_AFTER_MS) return { ...base, extraction_succeeded: false, degraded: 'stale' };
|
|
117
|
+
try {
|
|
118
|
+
const { input, degradedSections, hasCoreContext } = await buildConsultContext({
|
|
119
|
+
code: trace.patientCode,
|
|
120
|
+
turnStart,
|
|
121
|
+
patientMessage: patientMessageFromTrace(trace),
|
|
122
|
+
mentions: mentionFacts(trace),
|
|
123
|
+
});
|
|
124
|
+
if (!hasCoreContext) return { ...base, extraction_succeeded: false, degraded: 'core_context_missing', degraded_sections: degradedSections };
|
|
125
|
+
return { ...base, extraction_succeeded: true, input, degraded_sections: degradedSections };
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (!UNREADABLE_PATIENT.includes(error?.statusCode)) throw error;
|
|
128
|
+
return { ...base, extraction_succeeded: false, degraded: 'patient_unreadable', error: error.message };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function buildJudgeInput(evidence) {
|
|
133
|
+
return [{ role: 'user', content: evidence.input }];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function validateJudge(judge) {
|
|
137
|
+
if (!isPlainObject(judge) || !DISPOSITIONS.includes(judge.disposition)) {
|
|
138
|
+
throw new Error('emergency consult returned no valid disposition');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function invariantViolations(judge) {
|
|
143
|
+
const violations = [];
|
|
144
|
+
const askFirst = judge.disposition === 'ask_first';
|
|
145
|
+
if (judge.disposition === 'escalate_now' && !judge.urgency_level) violations.push('escalate_now_without_level');
|
|
146
|
+
if (askFirst && !asArray(judge.missing_information).length) violations.push('ask_first_without_questions');
|
|
147
|
+
if (askFirst && !judge.level_if_unanswered) violations.push('ask_first_without_level_if_unanswered');
|
|
148
|
+
if (askFirst && [judge.urgency_level, judge.level_if_unanswered].some((level) => IMMEDIATE_LEVELS.includes(level))) {
|
|
149
|
+
violations.push('ask_first_on_immediate_level');
|
|
150
|
+
}
|
|
151
|
+
if (judge.disposition !== 'escalate_now' && asArray(judge.red_flags_present).length) violations.push('red_flags_without_escalation');
|
|
152
|
+
return violations;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const rank = (level) => URGENCY_RANK[level] || 0;
|
|
156
|
+
|
|
157
|
+
function consultFinding(fields) {
|
|
158
|
+
return {
|
|
159
|
+
type: 'emergency_consult',
|
|
160
|
+
severity: 'normal',
|
|
161
|
+
confidence: 'high',
|
|
162
|
+
opsReviewRequired: false,
|
|
163
|
+
targetId: null,
|
|
164
|
+
observedCallIndex: null,
|
|
165
|
+
...fields,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function interpret({ evidence, judge }) {
|
|
170
|
+
if (!evidence.extraction_succeeded || !judge) {
|
|
171
|
+
return [consultFinding({
|
|
172
|
+
expectedAction: null,
|
|
173
|
+
observedAction: evidence.agent?.level || null,
|
|
174
|
+
criteria: { degraded: evidence.extraction_succeeded ? 'judge_refused' : evidence.degraded },
|
|
175
|
+
summary: 'El especialista no evaluó el turno.',
|
|
176
|
+
evidence: evidence.error || 'Sin detalle.',
|
|
177
|
+
})];
|
|
178
|
+
}
|
|
179
|
+
const specialistLevel = judge.disposition === 'ask_first' ? judge.level_if_unanswered : judge.urgency_level;
|
|
180
|
+
const agentLevel = evidence.agent?.level || null;
|
|
181
|
+
return [consultFinding({
|
|
182
|
+
expectedAction: specialistLevel || null,
|
|
183
|
+
observedAction: agentLevel,
|
|
184
|
+
criteria: {
|
|
185
|
+
disposition: judge.disposition,
|
|
186
|
+
agent_below_specialist: rank(agentLevel) < rank(specialistLevel),
|
|
187
|
+
invariant_violations: invariantViolations(judge),
|
|
188
|
+
},
|
|
189
|
+
summary: `Especialista: ${judge.disposition} ${specialistLevel || 'sin nivel'}; agente: ${agentLevel || 'sin escalamiento'}.`,
|
|
190
|
+
evidence: judge.clinical_reasoning || 'Sin razonamiento.',
|
|
191
|
+
})];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const emergencyConsultMonitorSpec = {
|
|
195
|
+
id: MONITOR_ID,
|
|
196
|
+
version: MONITOR_VERSION,
|
|
197
|
+
presetId: PRESET_ID,
|
|
198
|
+
schema: CONSULT_SCHEMA,
|
|
199
|
+
refusalIsFinal: true,
|
|
200
|
+
reconcileQuery: {
|
|
201
|
+
$or: [
|
|
202
|
+
{ 'signals.medicalEscalation': { $exists: true } },
|
|
203
|
+
{ 'signals.safetyFlags.0': { $exists: true } },
|
|
204
|
+
{ 'signals.activatedSkills': 'emergency' },
|
|
205
|
+
{ 'signals.clinicalMentions.0': { $exists: true } },
|
|
206
|
+
],
|
|
207
|
+
},
|
|
208
|
+
isEligible,
|
|
209
|
+
buildEvidence,
|
|
210
|
+
buildJudgeInput,
|
|
211
|
+
validateJudge,
|
|
212
|
+
interpret,
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
module.exports = { emergencyConsultMonitorSpec };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const { readClinicalProfile } = require('../helpers/clinicalProfileHelper');
|
|
2
|
+
const { buildExposureBlock } = require('../helpers/triageCaseBaseHelper');
|
|
3
|
+
const { readDispatchedEscalations } = require('../../fhir');
|
|
4
|
+
const { getEscalationContext } = require('../../services/escalationContextService');
|
|
5
|
+
const { redactDirectIdentifiers } = require('../../utils/sanitizerUtils');
|
|
6
|
+
const { assembleRiskProfile } = require('./riskAssessmentService');
|
|
7
|
+
const { escalationLookbackSince } = require('./turnRiskBriefService');
|
|
8
|
+
|
|
9
|
+
const CONTEXT_SECTIONS = ['agentContext', 'conversation', 'history'];
|
|
10
|
+
const MAX_INPUT_CHARS = 120000;
|
|
11
|
+
|
|
12
|
+
const optional = (promise) => promise.catch(() => undefined);
|
|
13
|
+
|
|
14
|
+
// The conversation ends with this turn's own reply; the consult must not read the decision it is judging.
|
|
15
|
+
function conversationBeforeReply(conversation) {
|
|
16
|
+
const messages = Array.isArray(conversation) ? [...conversation] : [];
|
|
17
|
+
while (messages.length && messages[messages.length - 1].role === 'assistant') messages.pop();
|
|
18
|
+
return messages;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function clinicalProfile(profile) {
|
|
22
|
+
if (!profile) return undefined;
|
|
23
|
+
const { edad = null, sexo = null } = profile.demografia || {};
|
|
24
|
+
return { demografia: { edad, sexo }, datos_clinicos: profile.datos_clinicos ?? null };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const section = (title, value) => [`## ${title}`, value === undefined ? 'no disponible' : JSON.stringify(value), ''];
|
|
28
|
+
|
|
29
|
+
function fitConversation(head, conversation) {
|
|
30
|
+
const messages = [...conversation];
|
|
31
|
+
let text = section('Conversación', messages).join('\n');
|
|
32
|
+
while (messages.length && head.length + text.length > MAX_INPUT_CHARS) {
|
|
33
|
+
messages.shift();
|
|
34
|
+
text = section('Conversación', messages).join('\n');
|
|
35
|
+
}
|
|
36
|
+
return { text, truncated: messages.length < conversation.length };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function buildConsultContext({ code, turnStart, patientMessage = null, mentions = [] }) {
|
|
40
|
+
const asOf = new Date(turnStart);
|
|
41
|
+
const [context, profile, risk, escalations] = await Promise.all([
|
|
42
|
+
getEscalationContext({ code, sections: CONTEXT_SECTIONS }),
|
|
43
|
+
readClinicalProfile(code),
|
|
44
|
+
optional(assembleRiskProfile(code)),
|
|
45
|
+
optional(readDispatchedEscalations({ patientId: code, since: escalationLookbackSince(asOf.getTime()) })),
|
|
46
|
+
]);
|
|
47
|
+
const exposure = buildExposureBlock(profile);
|
|
48
|
+
const degradedSections = [...context.degradedSections];
|
|
49
|
+
if (!profile) degradedSections.push('clinicalProfile');
|
|
50
|
+
if (risk === undefined) degradedSections.push('risk');
|
|
51
|
+
if (escalations === undefined) degradedSections.push('escalations');
|
|
52
|
+
|
|
53
|
+
const head = [
|
|
54
|
+
`Fecha de hoy: ${asOf.toISOString().slice(0, 10)}`,
|
|
55
|
+
'',
|
|
56
|
+
'## Mensaje del paciente en este turno',
|
|
57
|
+
patientMessage || 'no capturado',
|
|
58
|
+
'',
|
|
59
|
+
...section('Menciones clínicas extraídas en este turno', mentions),
|
|
60
|
+
...section('Contexto clínico que ve la enfermera', context.agentContext ?? undefined),
|
|
61
|
+
'## Exposición antineoplásica y medicación documentadas',
|
|
62
|
+
exposure || 'no documentada',
|
|
63
|
+
'',
|
|
64
|
+
...section('Perfil clínico', clinicalProfile(profile)),
|
|
65
|
+
...section('Perfil de riesgo', risk?.profile ?? undefined),
|
|
66
|
+
...section('Escalamientos de los últimos 14 días', escalations?.filter((event) => Date.parse(event.escalatedAt) < asOf.getTime())),
|
|
67
|
+
...section('Historia clínica reciente', context.history ?? undefined),
|
|
68
|
+
].join('\n');
|
|
69
|
+
const conversation = fitConversation(head, conversationBeforeReply(context.conversation));
|
|
70
|
+
if (conversation.truncated) degradedSections.push('conversation_truncated');
|
|
71
|
+
const input = `${head}\n${conversation.text}`;
|
|
72
|
+
|
|
73
|
+
return { input: redactDirectIdentifiers(input), degradedSections, hasCoreContext: Boolean(context.agentContext || profile) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { buildConsultContext };
|
|
@@ -77,7 +77,11 @@ const unbatchedRoutes = {
|
|
|
77
77
|
function loadDefaultLlmMonitor() {
|
|
78
78
|
const { LlmMonitorService } = require('../clinical/monitoring/LlmMonitorService');
|
|
79
79
|
const { contingencyMonitorSpec } = require('../clinical/monitoring/contingencyMonitorSpec');
|
|
80
|
-
|
|
80
|
+
const { emergencyConsultMonitorSpec } = require('../clinical/monitoring/emergencyConsultMonitorSpec');
|
|
81
|
+
const { emergencyConsultMode } = require('../clinical/flags/emergencyConsultFlags');
|
|
82
|
+
const specs = [contingencyMonitorSpec];
|
|
83
|
+
if (emergencyConsultMode() !== 'off') specs.push(emergencyConsultMonitorSpec);
|
|
84
|
+
return { LlmMonitorService, specs };
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
function normalizeLlmMonitorConfig(value) {
|
|
@@ -88,8 +92,8 @@ function normalizeLlmMonitorConfig(value) {
|
|
|
88
92
|
function createDefaultLlmMonitor(options, loader = loadDefaultLlmMonitor) {
|
|
89
93
|
if (!options.enabled) return null;
|
|
90
94
|
try {
|
|
91
|
-
const { LlmMonitorService,
|
|
92
|
-
return new LlmMonitorService({ ...options, specs
|
|
95
|
+
const { LlmMonitorService, specs } = loader();
|
|
96
|
+
return new LlmMonitorService({ ...options, specs });
|
|
93
97
|
} catch (error) {
|
|
94
98
|
logger.warn('[llmMonitor] construction failed; patient messaging remains available', {
|
|
95
99
|
error: error?.message || String(error),
|