@peopl-health/nexus 5.9.0-dev.921 → 6.0.0-dev.611
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/AssistantProcessor.js +5 -34
- package/lib/clinical/config/divergenceConfig.js +1 -18
- package/lib/clinical/config/llmConfig.js +1 -9
- package/lib/clinical/context/ToolRuntimeContext.js +2 -1
- package/lib/clinical/helpers/divergenceHelper.js +0 -12
- package/lib/clinical/index.js +0 -2
- package/lib/clinical/memory/DefaultMemoryManager.js +9 -19
- package/lib/clinical/models/turnTraceModel.js +5 -18
- package/lib/clinical/providers/AnthropicProvider.js +29 -291
- package/lib/clinical/providers/BaseLLMProvider.js +24 -113
- package/lib/clinical/providers/GatewayProvider.js +3 -3
- package/lib/clinical/providers/OpenAIResponsesProvider.js +4 -46
- package/lib/clinical/services/divergenceService.js +5 -22
- package/lib/clinical/services/promptComposerService.js +15 -46
- package/lib/clinical/services/shadowMessageService.js +12 -28
- package/lib/clinical/services/shadowService.js +3 -12
- package/lib/clinical/stores/CtcaeCatalog.js +0 -20
- package/lib/config/airtableConfig.js +0 -3
- package/lib/core/BatchingManager.js +3 -10
- package/lib/core/NexusMessaging.js +13 -31
- package/lib/eval/EvalProvider.js +2 -3
- package/lib/fhir/config/fhirConfig.js +3 -34
- package/lib/fhir/constants/projectionSlugs.js +1 -41
- package/lib/fhir/helpers/elementHelper.js +0 -21
- package/lib/fhir/helpers/fhirHelper.js +2 -5
- package/lib/fhir/index.js +0 -24
- package/lib/fhir/models/envelopeModel.js +0 -5
- package/lib/fhir/projections/registerProjectors.js +0 -16
- package/lib/fhir/resources/Condition.js +2 -107
- package/lib/fhir/resources/Observation.js +1 -73
- package/lib/fhir/resources/Procedure.js +2 -34
- package/lib/fhir/resources/Provenance.js +2 -93
- package/lib/fhir/services/fhirService.js +2 -2
- package/lib/helpers/twilioHelper.js +1 -9
- package/lib/index.d.ts +0 -16
- package/lib/index.js +0 -9
- package/lib/models/messageModel.js +0 -6
- package/lib/routes/index.js +1 -21
- package/lib/services/airtableService.js +1 -2
- package/lib/shared/dtos/ContingencySafetyNet.js +3 -10
- package/lib/shared/dtos/DispatchedEscalation.js +8 -3
- package/lib/shared/dtos/Reminder.js +2 -2
- package/lib/storage/MongoStorage.js +0 -2
- package/package.json +1 -1
- package/lib/clinical/config/cohortConfig.js +0 -68
- package/lib/clinical/config/composerConfig.js +0 -42
- package/lib/clinical/config/subAgentsConfig.js +0 -44
- package/lib/clinical/flags/bridgeConfig.js +0 -14
- package/lib/clinical/flags/composerFlags.js +0 -14
- package/lib/clinical/helpers/clinicalFactsHelper.js +0 -108
- package/lib/clinical/helpers/clinicalMentionHelper.js +0 -68
- package/lib/clinical/helpers/cohortHelper.js +0 -22
- package/lib/clinical/helpers/gradeEstimateHelper.js +0 -29
- package/lib/clinical/helpers/resultsHelper.js +0 -34
- package/lib/clinical/helpers/safetyFlagsHelper.js +0 -10
- package/lib/clinical/services/bridgeService.js +0 -55
- package/lib/clinical/services/clinicalAirtableService.js +0 -162
- package/lib/clinical/services/clinicalExtractionService.js +0 -112
- package/lib/clinical/services/composerService.js +0 -59
- package/lib/clinical/services/dispatcherService.js +0 -137
- package/lib/clinical/services/reminderDispatchService.js +0 -36
- package/lib/clinical/services/skillService.js +0 -44
- package/lib/clinical/tools/analyzeSymptomPatternsTool.js +0 -279
- package/lib/clinical/tools/deliverPatientMessageTool.js +0 -202
- package/lib/clinical/tools/extractClinicalInfoTool.js +0 -167
- package/lib/clinical/tools/getActiveSymptomLandscapeTool.js +0 -141
- package/lib/clinical/tools/getPatientHistoryTool.js +0 -235
- package/lib/clinical/tools/getPatientRiskProfileTool.js +0 -71
- package/lib/clinical/tools/getRouterContextBundleTool.js +0 -93
- package/lib/clinical/tools/lookupCtcaeEntryTool.js +0 -35
- package/lib/clinical/tools/openClusterHypothesisTool.js +0 -160
- package/lib/clinical/tools/openConditionTool.js +0 -136
- package/lib/clinical/tools/recordClinicalImpressionTool.js +0 -251
- package/lib/clinical/tools/recordInterventionTool.js +0 -140
- package/lib/clinical/tools/registerClinicalTools.js +0 -39
- package/lib/clinical/tools/reportCrisisTool.js +0 -211
- package/lib/clinical/tools/reportMedicalEscalationTool.js +0 -187
- package/lib/clinical/tools/reportResultsReceivedTool.js +0 -153
- package/lib/clinical/tools/reportUnresolvedRequestv2Tool.js +0 -177
- package/lib/clinical/tools/schedulePatientReminderTool.js +0 -149
- package/lib/clinical/tools/setContingencyPlanTool.js +0 -176
- package/lib/clinical/tools/skillTool.js +0 -67
- package/lib/clinical/tools/submitRoutingDecisionTool.js +0 -291
- package/lib/clinical/tools/updateClusterHypothesisTool.js +0 -159
- package/lib/controllers/cohortController.js +0 -16
- package/lib/controllers/traceController.js +0 -26
- package/lib/core/workflowRunner.js +0 -35
- package/lib/fhir/config/fhirTablesConfig.js +0 -9
- package/lib/fhir/constants/severityPriority.js +0 -8
- package/lib/fhir/helpers/compositeHelper.js +0 -88
- package/lib/fhir/helpers/fhirReadHelper.js +0 -42
- package/lib/fhir/helpers/terminologyHelper.js +0 -260
- package/lib/fhir/projections/clusterProjection.js +0 -24
- package/lib/fhir/projections/contingencyProjection.js +0 -36
- package/lib/fhir/projections/dispatchedEscalationProjection.js +0 -25
- package/lib/fhir/projections/interventionProjection.js +0 -42
- package/lib/fhir/projections/proctcaeProjection.js +0 -179
- package/lib/fhir/projections/reminderProjection.js +0 -20
- package/lib/fhir/projections/riskProjection.js +0 -16
- package/lib/fhir/projections/routingProjection.js +0 -48
- package/lib/fhir/projections/symptomCaseProjection.js +0 -65
- package/lib/fhir/resources/CarePlan.js +0 -93
- package/lib/fhir/resources/ClinicalImpression.js +0 -118
- package/lib/fhir/resources/Communication.js +0 -65
- package/lib/fhir/resources/CommunicationRequest.js +0 -182
- package/lib/fhir/resources/Device.js +0 -24
- package/lib/fhir/resources/Patient.js +0 -18
- package/lib/fhir/resources/Questionnaire.js +0 -23
- package/lib/fhir/resources/QuestionnaireResponse.js +0 -29
- package/lib/fhir/resources/RequestOrchestration.js +0 -46
- package/lib/fhir/resources/RiskAssessment.js +0 -99
- package/lib/fhir/resources/Task.js +0 -106
- package/lib/fhir/resources/interventionExtensions.js +0 -28
- package/lib/fhir/services/clusterService.js +0 -77
- package/lib/fhir/services/contingencyService.js +0 -117
- package/lib/fhir/services/interventionService.js +0 -149
- package/lib/fhir/services/resultObservationService.js +0 -52
- package/lib/fhir/services/riskService.js +0 -101
- package/lib/fhir/services/routingService.js +0 -147
- package/lib/fhir/services/snapshotService.js +0 -28
- package/lib/fhir/services/symptomCaseService.js +0 -139
- package/lib/fhir/services/terminologyService.js +0 -58
- package/lib/fhir/services/triageService.js +0 -57
- package/lib/fhir/utils/normalizeUtils.js +0 -33
- package/lib/shared/dtos/GradedSymptom.js +0 -23
- package/lib/shared/dtos/TriageResponse.js +0 -35
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
const { dispatcherService } = require('../services/dispatcherService');
|
|
2
|
-
const { recordEmergency } = require('../services/clinicalAirtableService');
|
|
3
|
-
const { logger } = require('../../utils/logger');
|
|
4
|
-
|
|
5
|
-
const definition = {
|
|
6
|
-
'name': 'reportCrisis',
|
|
7
|
-
'description': '**Does:** Commits a confirmed mental-health crisis — emits `CRISIS_TRIGGERED` to the mental-health responder + SAPTEL coordination. One-way escalation: there is no un-commit. Symmetric with `reportMedicalEscalation` (the medical-event sibling).\n\n**Required inputs:** `crisis_level`, `patient_verbatim` (literal patient text), `action_given`, `signals`, `reasoning`.\n\n**Optional inputs (pass `null` when unknown — do NOT invent):** `with_someone`, `mentioned_method`, `protective_factors_observed`, `risk_trajectory`.\n\n**When to call:** confirmed suicidal ideation (direct or with plan), active or recent self-harm, or profound hopelessness with risk signals. The decision is yours and it is irreversible.\n\n**When NOT to call:**\n- Ambiguous suspicion — an existential phrase like "a veces ya no puedo más" without plan or intent. Instead, deliver `DeliverPatientMessage` with ONE clarifying question ("¿Estás teniendo pensamientos de hacerte daño ahora?") and end the turn; re-enter next turn with the new signal.\n- Sadness, grief, fear of treatment, frustration without ideation — not a crisis; use normal emotional-support flow, not `reportCrisis`.\n- Medical emergency / escalation — use `reportMedicalEscalation`.\n- Patient shared results/values — use `reportResultsReceived`.\n- Routine psycho-oncology referral — use `reportUnresolvedRequestv2` with `category=\'psicooncologia_referral\'`.\n- Already called once this turn — do not repeat.\n\n**After the commit:** deliver `DeliverPatientMessage` per the crisis branch template: ONE line of genuine acknowledgement, ONE line of concrete action (Línea de la Vida 800 911 2000 / SAPTEL 55 5259 8121 / stay where you are / move means of harm away), and the closing line "Ya avisé al equipo médico." No dose, no diagnosis, no jargon. The turn ends there.\n\n**Returns:** an ack ({}). Invalid `crisis_level` values are REJECTED with the valid bucket list — re-issue with the matching bucket.\n\n**Side effects:** Emits `CRISIS_TRIGGERED` to the mental-health responder + SAPTEL coordination.',
|
|
8
|
-
'strict': true,
|
|
9
|
-
'parameters': {
|
|
10
|
-
'type': 'object',
|
|
11
|
-
'properties': {
|
|
12
|
-
'crisis_level': {
|
|
13
|
-
'type': 'string',
|
|
14
|
-
'enum': [
|
|
15
|
-
'ideation',
|
|
16
|
-
'plan',
|
|
17
|
-
'means_accessible',
|
|
18
|
-
'action_taken'
|
|
19
|
-
],
|
|
20
|
-
'description': 'Severity bucket for the crisis. Determines responder tempo. \'ideation\' = pensamientos sin plan ni medios. \'plan\' = mencionó método, tiempo o cómo lo haría, sin acceso inmediato. \'means_accessible\' = plan + acceso a los medios (pastillas a la mano, arma en casa). \'action_taken\' = ya inició la acción (tomó las pastillas, se cortó). El responder escala 911 / SAPTEL vs llamada del navegador según este campo — sé preciso.'
|
|
21
|
-
},
|
|
22
|
-
'patient_verbatim': {
|
|
23
|
-
'type': 'string',
|
|
24
|
-
'description': 'Lo que el paciente escribió, LITERAL, sin parafrasear ni corregir. El responder lee la señal cruda antes de llamar — la elección de palabras importa clínicamente.'
|
|
25
|
-
},
|
|
26
|
-
'action_given': {
|
|
27
|
-
'type': 'string',
|
|
28
|
-
'description': 'La acción concreta que le diste al paciente en `DeliverPatientMessage` este turno (e.g., \'Llamar a SAPTEL 55 5259 8121 ahora y quedarse en la conversación\', \'Alejar las pastillas y avisar a su hermana que está en la casa\'). El responder necesita saber qué dijimos para no contradecir.'
|
|
29
|
-
},
|
|
30
|
-
'signals': {
|
|
31
|
-
'type': 'array',
|
|
32
|
-
'items': {
|
|
33
|
-
'type': 'string'
|
|
34
|
-
},
|
|
35
|
-
'description': 'Señales catalogadas que identificaste (e.g., \'ideacion_suicida_directa\', \'plan_con_medios\', \'autolesion_activa\', \'desesperanza_profunda\'). Matchean contra el crisis_catalog para agregación estadística.'
|
|
36
|
-
},
|
|
37
|
-
'with_someone': {
|
|
38
|
-
'anyOf': [
|
|
39
|
-
{
|
|
40
|
-
'type': 'boolean',
|
|
41
|
-
'description': '¿El paciente mencionó estar acompañado en este momento? True si dijo que hay alguien (familia, pareja, amigo). False si dijo estar solo/a. Omitir (null) si no se sabe — NO inventes.'
|
|
42
|
-
},
|
|
43
|
-
{
|
|
44
|
-
'type': 'null'
|
|
45
|
-
}
|
|
46
|
-
]
|
|
47
|
-
},
|
|
48
|
-
'mentioned_method': {
|
|
49
|
-
'anyOf': [
|
|
50
|
-
{
|
|
51
|
-
'type': 'string',
|
|
52
|
-
'description': 'Si el paciente nombró un método específico (pastillas, cuerda, arma, una altura, un corte), cópialo LITERAL aquí. Omitir si no se nombró — NO asumas.'
|
|
53
|
-
},
|
|
54
|
-
{
|
|
55
|
-
'type': 'null'
|
|
56
|
-
}
|
|
57
|
-
]
|
|
58
|
-
},
|
|
59
|
-
'protective_factors_observed': {
|
|
60
|
-
'anyOf': [
|
|
61
|
-
{
|
|
62
|
-
'type': 'array',
|
|
63
|
-
'items': {
|
|
64
|
-
'type': 'string'
|
|
65
|
-
},
|
|
66
|
-
'description': 'Quotes u observaciones que atenúan el riesgo (red de apoyo mencionada, planes futuros, razones para vivir, disposición a seguir hablando, fe, hijos, responsabilidades activas). Lista vacía si no hay.'
|
|
67
|
-
},
|
|
68
|
-
{
|
|
69
|
-
'type': 'null'
|
|
70
|
-
}
|
|
71
|
-
]
|
|
72
|
-
},
|
|
73
|
-
'risk_trajectory': {
|
|
74
|
-
'anyOf': [
|
|
75
|
-
{
|
|
76
|
-
'type': 'string',
|
|
77
|
-
'enum': [
|
|
78
|
-
'escalating',
|
|
79
|
-
'stable',
|
|
80
|
-
'passive_ideation',
|
|
81
|
-
'active_plan'
|
|
82
|
-
],
|
|
83
|
-
'description': 'Trayectoria observada en este turno vs. turnos previos. Opcional — úsalo cuando el histórico del paciente aporte contexto (e.g. pasiva en turnos anteriores, ahora activa).'
|
|
84
|
-
},
|
|
85
|
-
{
|
|
86
|
-
'type': 'null'
|
|
87
|
-
}
|
|
88
|
-
]
|
|
89
|
-
},
|
|
90
|
-
'reasoning': {
|
|
91
|
-
'type': 'object',
|
|
92
|
-
'description': 'Trazabilidad interna de la decisión (auditoría). NUNCA se muestra al paciente. Úsalo para dejar por qué llamaste esta herramienta AHORA, con evidencia y alternativas descartadas.',
|
|
93
|
-
'properties': {
|
|
94
|
-
'brief': {
|
|
95
|
-
'type': 'string',
|
|
96
|
-
'description': 'UNA oración explicando por qué esta llamada en este turno. Observacional, no justificación genérica. E.g. \'Constelación neuro: visión borrosa + no camina en <2 turnos\'.'
|
|
97
|
-
},
|
|
98
|
-
'evidence_quote': {
|
|
99
|
-
'type': 'string',
|
|
100
|
-
'description': 'Cita textual del paciente (o del contexto inyectado) que soporta la decisión. Vacío si la evidencia es derivada de una tool anterior (menciónala en brief).'
|
|
101
|
-
},
|
|
102
|
-
'alternatives_considered': {
|
|
103
|
-
'anyOf': [
|
|
104
|
-
{
|
|
105
|
-
'type': 'array',
|
|
106
|
-
'items': {
|
|
107
|
-
'type': 'string'
|
|
108
|
-
},
|
|
109
|
-
'description': 'Opciones que sopesaste y descartaste este turno (e.g., \'esperar una caracterización más\', \'ofrecer OTC en vez de non-pharm\'). Lista vacía si no hubo tradeoff real.'
|
|
110
|
-
},
|
|
111
|
-
{
|
|
112
|
-
'type': 'null'
|
|
113
|
-
}
|
|
114
|
-
]
|
|
115
|
-
}
|
|
116
|
-
},
|
|
117
|
-
'required': [
|
|
118
|
-
'brief',
|
|
119
|
-
'evidence_quote',
|
|
120
|
-
'alternatives_considered'
|
|
121
|
-
],
|
|
122
|
-
'additionalProperties': false
|
|
123
|
-
}
|
|
124
|
-
},
|
|
125
|
-
'required': [
|
|
126
|
-
'crisis_level',
|
|
127
|
-
'patient_verbatim',
|
|
128
|
-
'action_given',
|
|
129
|
-
'signals',
|
|
130
|
-
'with_someone',
|
|
131
|
-
'mentioned_method',
|
|
132
|
-
'protective_factors_observed',
|
|
133
|
-
'risk_trajectory',
|
|
134
|
-
'reasoning'
|
|
135
|
-
],
|
|
136
|
-
'additionalProperties': false
|
|
137
|
-
}
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
async function handler(args = {}, context = {}) {
|
|
141
|
-
try {
|
|
142
|
-
const runtime = context?.toolRuntimeContext || null;
|
|
143
|
-
const trace = runtime?.trace || null;
|
|
144
|
-
if (!trace) {
|
|
145
|
-
logger.error('[reportCrisis] Crisis escalation dropped — no active turn trace', { patientCode: runtime?.patientCode || null });
|
|
146
|
-
return JSON.stringify({ success: false, error: 'reportCrisis requires an active turn context (trace).' });
|
|
147
|
-
}
|
|
148
|
-
const levels = definition.parameters.properties.crisis_level.enum;
|
|
149
|
-
const crisisLevel = (args?.crisis_level || '').trim().toLowerCase();
|
|
150
|
-
if (!levels.includes(crisisLevel)) {
|
|
151
|
-
const validLevels = `[${levels.map((v) => `'${v}'`).join(', ')}]`;
|
|
152
|
-
return JSON.stringify({
|
|
153
|
-
success: false,
|
|
154
|
-
data: { valid_crisis_levels: [...levels] },
|
|
155
|
-
error: `invalid crisis_level: '${crisisLevel}'. Must be one of ${validLevels} — re-issue the call with the matching bucket.`,
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
trace.setSafetyDecision({ ...(trace.safetyDecision || {}), escalationKind: 'crisis' });
|
|
160
|
-
trace.setSignals({
|
|
161
|
-
crisis: {
|
|
162
|
-
crisisLevel,
|
|
163
|
-
patientVerbatim: args?.patient_verbatim || '',
|
|
164
|
-
actionGiven: args?.action_given || null,
|
|
165
|
-
signals: args?.signals || [],
|
|
166
|
-
withSomeone: args?.with_someone ?? null,
|
|
167
|
-
mentionedMethod: args?.mentioned_method ?? null,
|
|
168
|
-
protectiveFactorsObserved: args?.protective_factors_observed ?? null,
|
|
169
|
-
riskTrajectory: args?.risk_trajectory ?? null,
|
|
170
|
-
reasoning: args?.reasoning || null,
|
|
171
|
-
},
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
if (runtime.turnId && runtime.patientCode) {
|
|
175
|
-
try {
|
|
176
|
-
await dispatcherService.dispatch({
|
|
177
|
-
trace,
|
|
178
|
-
escalationKind: 'crisis',
|
|
179
|
-
turnId: runtime.turnId,
|
|
180
|
-
patientCode: runtime.patientCode,
|
|
181
|
-
});
|
|
182
|
-
} catch (dispatchErr) {
|
|
183
|
-
logger.warn('reportCrisis dispatch failed', { message: dispatchErr?.message });
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
if (runtime.patientCode) {
|
|
188
|
-
const crisisSignals = Array.isArray(args?.signals) ? args.signals.filter(Boolean) : [];
|
|
189
|
-
await recordEmergency({
|
|
190
|
-
code: runtime.patientCode,
|
|
191
|
-
trace,
|
|
192
|
-
symptom: `Crisis de salud mental (${crisisLevel})`,
|
|
193
|
-
recommendedAction: args?.action_given || '',
|
|
194
|
-
urgencyLevel: 'Alta',
|
|
195
|
-
documentation: [
|
|
196
|
-
args?.patient_verbatim || '',
|
|
197
|
-
crisisSignals.length ? `Señales: ${crisisSignals.join(', ')}` : '',
|
|
198
|
-
].filter(Boolean).join('\n'),
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
return JSON.stringify({ success: true, data: {} });
|
|
203
|
-
} catch (err) {
|
|
204
|
-
return JSON.stringify({ success: false, error: err?.message || 'reportCrisis failed' });
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
module.exports = {
|
|
209
|
-
definition,
|
|
210
|
-
handler,
|
|
211
|
-
};
|
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
const { dispatcherService } = require('../services/dispatcherService');
|
|
2
|
-
const { recordEmergency, recordUnresolvedRequest } = require('../services/clinicalAirtableService');
|
|
3
|
-
const { logger } = require('../../utils/logger');
|
|
4
|
-
|
|
5
|
-
const definition = {
|
|
6
|
-
'name': 'reportMedicalEscalation',
|
|
7
|
-
'description': '**Does:** Escalates a MEDICAL situation to the oncology care team at a chosen urgency, with an explicit justification for the level. Covers the full medical-urgency spectrum — immediate emergencies through asynchronous team review. One-way commit; the payload is INTERNAL — the patient never sees it.\n\n**Required inputs:** `urgency_level` — the FHIR request-priority code (`stat` | `asap` | `urgent` | `routine`), `urgency_justification` (one or two sentences: why this level AND why not the tier above/below — the auditable decision record), `details` (object — `symptom` + `evidence_quote` required; pass `null` when unknown for `category`, `indicators`, `suspected_condition`, `recommended_action`).\n\n**Urgency levels:** FHIR request-priority, a RELATIVE order `routine` < `urgent` < `asap` < `stat` (priority/speed, not a fixed clock). The full per-tier rubric with clinical examples lives on the `urgency_level` input enum — read it there. Out-of-enum values are coerced to `urgent` and disclosed via `coerced_urgency` on the result.\n\n**Neurological constellation override:** TWO OR MORE of — visual change, dizziness/vertigo/loss of balance, inability to walk/stand, focal weakness/numbness, acute confusion, sudden severe headache — reported this turn or across the last 2–3 turns → escalate without a further clarifying question: `asap` by default, `stat` if there is an acute deficit (can\'t walk/stand, sphincter loss, bilateral leg weakness → possible cord compression) or any instability.\n\n**When NOT to call:**\n- Unconfirmed/unspecific symptom — ask ONE characterization question via `DeliverPatientMessage` first.\n- Active mental-health crisis — use `reportCrisis`.\n- Patient shared lab/imaging/values/files — use `reportResultsReceived`.\n- Administrative / operative matter (appointments, insurance, authorizations, letters, transport, scheduling, out-of-scope request) or a non-clinical referral (psico-oncología, paliativos, non-oncology medical) — use `reportUnresolvedRequestv2`.\n- Twice in the same turn — already committed.\n\n**After the commit:** deliver a brief `DeliverPatientMessage` matched to the level — `stat`: firm, no te muevas / 911 if alone or immobile, the team was notified; `asap`: wait 15 min for the team\'s word, then acude a urgencias (ve acompañada); `urgent`: \'es importante que te valoren hoy\' + the 15-min fallback; `routine`: warm acknowledgement that the team will review. Never confirm a diagnosis; avoid alarmist language; say the team was notified.\n\n**Returns:** an ack ({} on the happy path). When your `urgency_level` was out-of-enum it is coerced to `urgent` and disclosed via `{urgency_level, coerced_urgency, requested_urgency_level}`.\n\n**Side effects:** Emits `MEDICAL_ESCALATION_TRIGGERED`; the runtime routes it to the team by level (pages at `stat`/`asap`, ticket at `urgent`/`routine`).',
|
|
8
|
-
'strict': true,
|
|
9
|
-
'parameters': {
|
|
10
|
-
'type': 'object',
|
|
11
|
-
'properties': {
|
|
12
|
-
'urgency_level': {
|
|
13
|
-
'type': 'string',
|
|
14
|
-
'enum': [
|
|
15
|
-
'stat',
|
|
16
|
-
'asap',
|
|
17
|
-
'urgent',
|
|
18
|
-
'routine'
|
|
19
|
-
],
|
|
20
|
-
'description': 'Tier of medical escalation — the FHIR request-priority code (RequestPriority), a RELATIVE urgency ordered routine < urgent < asap < stat. It encodes priority / speed-of-action, NOT a fixed time window — the per-case clock is safety_gate.max_action_time_hours. 1:1 with the gate route. In urgency_justification say why this level and not the one above or below.\n- `stat` (inmediato, gate emergencia) — acción inmediata, prioridad máxima, flujo de emergencia. Inestabilidad o amenaza vital: neutropenia febril CON inestabilidad, disnea severa, alteración del estado mental, sangrado mayor, compresión medular con déficit agudo.\n- `asap` (lo antes posible, gate urgencias) — máxima prioridad NO inmediata; revisión en la primera oportunidad posible porque la demora puede crear riesgo. Complicación seria sospechada pero aún no claramente inestable: dolor que empeora rápido, riesgo de infección / neutropenia febril ESTABLE, síntoma neurológico nuevo sin déficit agudo.\n- `urgent` (urgente, gate equipo_tratante_hoy) — revisión clínica acelerada, por encima de rutina porque la demora podría importar clínicamente. Mismo día, no presencial-ahora: síntoma nuevo moderado, anormalidad de laboratorio preocupante pero estable, toxicidad de tratamiento que necesita input oportuno.\n- `routine` (rutina, sin gate o verdict suavizado) — prioridad normal, vía de atención habitual. Sin riesgo por demora: seguimiento, aclaración de medicación, síntoma >72h sin empeorar, contexto clínico desactualizado.'
|
|
21
|
-
},
|
|
22
|
-
'urgency_justification': {
|
|
23
|
-
'type': 'string',
|
|
24
|
-
'description': 'Por qué ESTE nivel — y explícitamente por qué no el inmediato superior ni el inferior (qué dato objetivo define el tier). Es el registro auditable de la decisión. Una o dos frases. INTERNO; el paciente nunca lo ve.'
|
|
25
|
-
},
|
|
26
|
-
'details': {
|
|
27
|
-
'type': 'object',
|
|
28
|
-
'description': 'Structured clinical payload for the team. All fields are for INTERNAL review — never surfaced to the patient.',
|
|
29
|
-
'properties': {
|
|
30
|
-
'category': {
|
|
31
|
-
'anyOf': [
|
|
32
|
-
{
|
|
33
|
-
'type': 'string',
|
|
34
|
-
'description': 'Rama clínica que motiva la escalación (e.g. \'febrile_neutropenia\', \'neuro_constellation\', \'spinal_cord_compression\', \'treatment_concern\', \'persistent_symptom_72h\', \'stale_clinical_context\').'
|
|
35
|
-
},
|
|
36
|
-
{
|
|
37
|
-
'type': 'null'
|
|
38
|
-
}
|
|
39
|
-
]
|
|
40
|
-
},
|
|
41
|
-
'symptom': {
|
|
42
|
-
'type': 'string',
|
|
43
|
-
'description': 'Síntoma o cluster principal, con temporalidad confirmada.'
|
|
44
|
-
},
|
|
45
|
-
'evidence_quote': {
|
|
46
|
-
'type': 'string',
|
|
47
|
-
'description': 'Cita textual del paciente que sustenta la escalación.'
|
|
48
|
-
},
|
|
49
|
-
'indicators': {
|
|
50
|
-
'anyOf': [
|
|
51
|
-
{
|
|
52
|
-
'type': 'array',
|
|
53
|
-
'items': {
|
|
54
|
-
'type': 'string'
|
|
55
|
-
},
|
|
56
|
-
'description': 'Indicadores del catálogo (e.g. \'fever_on_chemo\', \'neuro_constellation\').'
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
'type': 'null'
|
|
60
|
-
}
|
|
61
|
-
]
|
|
62
|
-
},
|
|
63
|
-
'suspected_condition': {
|
|
64
|
-
'anyOf': [
|
|
65
|
-
{
|
|
66
|
-
'type': 'string',
|
|
67
|
-
'description': 'Sospecha operativa interna para triage. NUNCA se menciona al paciente.'
|
|
68
|
-
},
|
|
69
|
-
{
|
|
70
|
-
'type': 'null'
|
|
71
|
-
}
|
|
72
|
-
]
|
|
73
|
-
},
|
|
74
|
-
'recommended_action': {
|
|
75
|
-
'anyOf': [
|
|
76
|
-
{
|
|
77
|
-
'type': 'string',
|
|
78
|
-
'description': 'Acción recomendada para el equipo.'
|
|
79
|
-
},
|
|
80
|
-
{
|
|
81
|
-
'type': 'null'
|
|
82
|
-
}
|
|
83
|
-
]
|
|
84
|
-
}
|
|
85
|
-
},
|
|
86
|
-
'required': [
|
|
87
|
-
'category',
|
|
88
|
-
'symptom',
|
|
89
|
-
'evidence_quote',
|
|
90
|
-
'indicators',
|
|
91
|
-
'suspected_condition',
|
|
92
|
-
'recommended_action'
|
|
93
|
-
],
|
|
94
|
-
'additionalProperties': false
|
|
95
|
-
}
|
|
96
|
-
},
|
|
97
|
-
'required': [
|
|
98
|
-
'urgency_level',
|
|
99
|
-
'urgency_justification',
|
|
100
|
-
'details'
|
|
101
|
-
],
|
|
102
|
-
'additionalProperties': false
|
|
103
|
-
}
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
async function handler(args, context = {}) {
|
|
107
|
-
try {
|
|
108
|
-
const runtime = context?.toolRuntimeContext || null;
|
|
109
|
-
const trace = runtime?.trace || null;
|
|
110
|
-
if (!trace) {
|
|
111
|
-
logger.error('[reportMedicalEscalation] Medical escalation dropped — no active turn trace', { patientCode: runtime?.patientCode || null });
|
|
112
|
-
return JSON.stringify({ success: false, error: 'reportMedicalEscalation requires an active turn context (trace).' });
|
|
113
|
-
}
|
|
114
|
-
const levels = definition.parameters.properties.urgency_level.enum;
|
|
115
|
-
const requested = (args?.urgency_level || '').trim().toLowerCase();
|
|
116
|
-
const coerced = !levels.includes(requested);
|
|
117
|
-
const urgencyLevel = coerced ? 'urgent' : requested;
|
|
118
|
-
const details = args?.details || {};
|
|
119
|
-
|
|
120
|
-
trace.setSafetyDecision({ ...(trace.safetyDecision || {}), escalationKind: 'medical_urgent' });
|
|
121
|
-
trace.setSignals({
|
|
122
|
-
medicalEscalation: {
|
|
123
|
-
urgencyLevel,
|
|
124
|
-
category: details.category ?? null,
|
|
125
|
-
symptom: details.symptom || null,
|
|
126
|
-
evidenceQuote: details.evidence_quote || null,
|
|
127
|
-
indicators: details.indicators ?? null,
|
|
128
|
-
suspectedCondition: details.suspected_condition ?? null,
|
|
129
|
-
recommendedAction: details.recommended_action ?? null,
|
|
130
|
-
urgencyJustification: args?.urgency_justification || null,
|
|
131
|
-
coercedUrgency: coerced,
|
|
132
|
-
requestedUrgencyLevel: requested,
|
|
133
|
-
},
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
if (runtime.turnId && runtime.patientCode) {
|
|
137
|
-
try {
|
|
138
|
-
await dispatcherService.dispatch({
|
|
139
|
-
trace,
|
|
140
|
-
escalationKind: 'medical_urgent',
|
|
141
|
-
turnId: runtime.turnId,
|
|
142
|
-
patientCode: runtime.patientCode,
|
|
143
|
-
});
|
|
144
|
-
} catch (dispatchErr) {
|
|
145
|
-
logger.warn('reportMedicalEscalation dispatch failed', { message: dispatchErr?.message });
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
if (runtime.patientCode) {
|
|
150
|
-
if (urgencyLevel === 'routine') {
|
|
151
|
-
await recordUnresolvedRequest({
|
|
152
|
-
code: runtime.patientCode,
|
|
153
|
-
trace,
|
|
154
|
-
question: details.symptom || '',
|
|
155
|
-
details: args?.urgency_justification || '',
|
|
156
|
-
category: 'medical_routine',
|
|
157
|
-
urgency: 'routine',
|
|
158
|
-
});
|
|
159
|
-
} else {
|
|
160
|
-
await recordEmergency({
|
|
161
|
-
code: runtime.patientCode,
|
|
162
|
-
trace,
|
|
163
|
-
symptom: details.symptom || '',
|
|
164
|
-
suspectedCondition: details.suspected_condition || '',
|
|
165
|
-
recommendedAction: details.recommended_action || '',
|
|
166
|
-
urgencyFhir: urgencyLevel,
|
|
167
|
-
documentation: [
|
|
168
|
-
args?.urgency_justification || '',
|
|
169
|
-
details.evidence_quote ? `Evidencia: «${details.evidence_quote}»` : '',
|
|
170
|
-
].filter(Boolean).join('\n'),
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const data = coerced
|
|
176
|
-
? { urgency_level: urgencyLevel, coerced_urgency: true, requested_urgency_level: requested }
|
|
177
|
-
: {};
|
|
178
|
-
return JSON.stringify({ success: true, data });
|
|
179
|
-
} catch (err) {
|
|
180
|
-
return JSON.stringify({ success: false, error: err?.message || 'reportMedicalEscalation failed' });
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
module.exports = {
|
|
185
|
-
definition,
|
|
186
|
-
handler,
|
|
187
|
-
};
|
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
const { DEGRADED_NUMERIC_REASON, patientVerbatimHasNumericValues, slugify } = require('../helpers/resultsHelper');
|
|
2
|
-
const { recordUnresolvedRequest } = require('../services/clinicalAirtableService');
|
|
3
|
-
const { storeResultObservations } = require('../../fhir');
|
|
4
|
-
const { logger } = require('../../utils/logger');
|
|
5
|
-
|
|
6
|
-
const definition = {
|
|
7
|
-
'name': 'reportResultsReceived',
|
|
8
|
-
'description': '**Does:** Logs the receipt of patient-shared medical results (labs, imaging, pathology, numeric values, attachments) and records an oncologist-review intent on the turn trace (the team-queue notification is a downstream slice). Emits FHIR `Observation` + `Provenance` resources directly when `observations[]` is passed.\n\n**Required inputs:** `patient_verbatim` (LITERAL copy of the values and words the patient wrote — never interpreted), `result_type` (`lab` | `imaging` | `pathology` | `numeric_values` | `attachment` | `other`); optional `study_name`, `result_date`.\n\n**Required when `patient_verbatim` contains numeric values:** `observations` — flat list of items, NOT FHIR shapes. Each item: `{code: str, value: number|str, unit: str|None (e.g. "g/dL", "mmHg", "°C", "10^9/L"), effective: ISO8601 str|None}`. Without `observations[]` the audit lane stays empty and the system stamps the result `degraded` with reason `observations_should_have_been_passed_text_contains_numbers`.\n\n**Examples:**\n- Patient says *"hemoglobina 8.5 g/dL, neutrofilos 0.7"* → `observations=[{code: "hemoglobin", value: 8.5, unit: "g/dL"}, {code: "neutrophils", value: 0.7, unit: "10^9/L"}]`.\n- Patient says *"me tomaron la presión 140/90 y la temperatura 38.5"* → `observations=[{code: "systolic-blood-pressure", value: 140, unit: "mmHg"}, {code: "diastolic-blood-pressure", value: 90, unit: "mmHg"}, {code: "body-temperature", value: 38.5, unit: "°C"}]`.\n- Patient shares a PDF / image / prose WITHOUT numbers → omit `observations` (workflow notification only).\n\n**When to call:** the patient is SHARING raw data — values, attachments, study names with findings. Trigger phrases: "te mando los resultados", "aquí están mis análisis", "hemoglobina 9.1", "mi TAC salió…", PDF/photo of a study, any clinical number from the patient. Takes precedence over `extractClinicalInfo` when the primary event is "results shared".\n\n**When NOT to call:**\n- Patient is narrating news from the team WITHOUT sharing the data — use `extractClinicalInfo` with the matching `detected_tags` (e.g. `[\'lab_value\']` or `[\'diagnosis\']`).\n- Medical emergency / escalation, including a symptom that persists or worsens — use `reportMedicalEscalation`.\n- Mental-health crisis — use `reportCrisis`.\n- Administrative / operative request — use `reportUnresolvedRequestv2`.\n\n**Hard rule on the subsequent `DeliverPatientMessage`:** NEVER interpret values or findings; NEVER say "está bien / está mal / es normal / está bajo / está alto"; NEVER compare against reference ranges; NEVER repeat the values back to the patient. Confirm receipt only ("recibí tus resultados y quedaron registrados para tu equipo; te avisan en cuanto los revisen", `purpose=\'deliver_escalation\'`).\n\n**Returns:** `result_id` + `result_slug` (`result_type` is returned only when an out-of-enum value was coerced to `other`). With `observations[]`: also `fhir_observation_count` + `observation_ids` (the FHIR bodies live on the audit lane, not on this result), and `observation_rejected_count` when some items were dropped for a missing `code`. On the numeric-miss branch: `degraded=True`, `degraded_reasons` — re-issue with `observations[]`.\n\n**Side effects:** Records the result + oncologist-review intent on the turn trace (the team-queue notification is a downstream slice); writes one FHIR `Observation` per structured observation plus a single linking `Provenance`.',
|
|
9
|
-
'strict': false,
|
|
10
|
-
'parameters': {
|
|
11
|
-
'type': 'object',
|
|
12
|
-
'properties': {
|
|
13
|
-
'result_type': {
|
|
14
|
-
'type': 'string',
|
|
15
|
-
'enum': [
|
|
16
|
-
'lab',
|
|
17
|
-
'imaging',
|
|
18
|
-
'pathology',
|
|
19
|
-
'numeric_values',
|
|
20
|
-
'attachment',
|
|
21
|
-
'other'
|
|
22
|
-
],
|
|
23
|
-
'description': 'What kind of results the patient shared.'
|
|
24
|
-
},
|
|
25
|
-
'study_name': {
|
|
26
|
-
'type': 'string',
|
|
27
|
-
'description': 'Study name if identified (e.g., \'BH\', \'QS\', \'TAC de tórax\', \'RM cerebral\', \'PET\', \'biopsia\', \'marcadores tumorales\'). Empty string if not identifiable.'
|
|
28
|
-
},
|
|
29
|
-
'patient_verbatim': {
|
|
30
|
-
'type': 'string',
|
|
31
|
-
'description': 'Patient\'s own words describing what they sent, verbatim. Include any numeric values they mentioned. DO NOT add interpretation, trends, or grading.'
|
|
32
|
-
},
|
|
33
|
-
'result_date': {
|
|
34
|
-
'type': 'string',
|
|
35
|
-
'description': 'Date of the study if mentioned (ISO or natural language). Empty string if not stated.'
|
|
36
|
-
},
|
|
37
|
-
'observations': {
|
|
38
|
-
'type': 'array',
|
|
39
|
-
'description': '**REQUIRED when the patient text contains ANY numeric values** (lab numbers, vital signs, weights, temperatures, scores). Pass one item per numeric value the patient shared — never leave them only in `patient_verbatim`. Each item has a flat shape: `{code: str (canonical term, e.g. \'hemoglobin\'), value: number|str, unit: str|null (e.g. \'g/dL\', \'10^9/L\', \'mmHg\', \'bpm\', \'°C\'), effective: ISO8601 str|null}`. The tool writes one FHIR `Observation` (category=laboratory) per item plus a single `Provenance` linking them to the audit lane, and returns `fhir_observation_count` + `observation_ids`.\n\n**Examples:**\n- Patient says "hemoglobina 8.5 g/dL, neutrofilos 0.7" → `observations: [{code: "hemoglobin", value: 8.5, unit: "g/dL"}, {code: "neutrophils", value: 0.7, unit: "10^9/L"}]`\n- Patient says "presión 140/90, fiebre 38.5" → `observations: [{code: "systolic-blood-pressure", value: 140, unit: "mmHg"}, {code: "diastolic-blood-pressure", value: 90, unit: "mmHg"}, {code: "body-temperature", value: 38.5, unit: "°C"}]`\n- Patient sends a PDF / image / pure narrative (no numbers) → omit `observations`. The tool still runs as a workflow notification; no FHIR Observation is emitted.',
|
|
40
|
-
'items': {
|
|
41
|
-
'type': 'object',
|
|
42
|
-
'properties': {
|
|
43
|
-
'code': {
|
|
44
|
-
'type': 'string'
|
|
45
|
-
},
|
|
46
|
-
'value': {},
|
|
47
|
-
'unit': {
|
|
48
|
-
'type': [
|
|
49
|
-
'string',
|
|
50
|
-
'null'
|
|
51
|
-
]
|
|
52
|
-
},
|
|
53
|
-
'effective': {
|
|
54
|
-
'type': [
|
|
55
|
-
'string',
|
|
56
|
-
'null'
|
|
57
|
-
]
|
|
58
|
-
}
|
|
59
|
-
},
|
|
60
|
-
'required': [
|
|
61
|
-
'code'
|
|
62
|
-
]
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
},
|
|
66
|
-
'required': [
|
|
67
|
-
'result_type',
|
|
68
|
-
'patient_verbatim'
|
|
69
|
-
]
|
|
70
|
-
}
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
const RESULT_TYPE_ENUM = definition.parameters.properties.result_type.enum;
|
|
74
|
-
|
|
75
|
-
async function handler(args = {}, context = {}) {
|
|
76
|
-
try {
|
|
77
|
-
const runtime = context?.toolRuntimeContext || null;
|
|
78
|
-
if (!runtime?.turnId || !runtime?.patientCode) {
|
|
79
|
-
return JSON.stringify({ success: false, error: 'reportResultsReceived requires an active turn context (turnId, patientCode).', data: {} });
|
|
80
|
-
}
|
|
81
|
-
const trace = runtime.trace || null;
|
|
82
|
-
const str = (v) => (typeof v === 'string' ? v : '');
|
|
83
|
-
|
|
84
|
-
const patientVerbatim = str(args?.patient_verbatim).trim();
|
|
85
|
-
if (!patientVerbatim) {
|
|
86
|
-
return JSON.stringify({ success: false, error: 'reportResultsReceived requires `patient_verbatim`.', data: {} });
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const rawType = (str(args?.result_type) || 'other').toLowerCase();
|
|
90
|
-
const resultType = RESULT_TYPE_ENUM.includes(rawType) ? rawType : 'other';
|
|
91
|
-
const studyName = str(args?.study_name).trim();
|
|
92
|
-
const resultDate = str(args?.result_date).trim();
|
|
93
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
94
|
-
const dateForSlug = /^\d{4}-\d{2}-\d{2}/.test(resultDate) ? resultDate.slice(0, 10) : today;
|
|
95
|
-
|
|
96
|
-
const existing = Array.isArray(trace?.signals?.resultsReceived) ? trace.signals.resultsReceived : [];
|
|
97
|
-
const resultIndex = existing.length;
|
|
98
|
-
const resultId = `rr_${runtime.turnId}_${resultIndex}`;
|
|
99
|
-
const resultSlug = [resultType, studyName ? slugify(studyName, 20) : '', dateForSlug].filter(Boolean).join('-');
|
|
100
|
-
|
|
101
|
-
if (trace?.setSignals) {
|
|
102
|
-
trace.setSignals({
|
|
103
|
-
resultsReceived: [...existing, {
|
|
104
|
-
resultId,
|
|
105
|
-
resultType,
|
|
106
|
-
studyName,
|
|
107
|
-
patientVerbatim: patientVerbatim.slice(0, 500),
|
|
108
|
-
resultDate,
|
|
109
|
-
oncologistEscalation: { category: 'results_review', urgency: 'soon', linkedResultId: resultId },
|
|
110
|
-
}],
|
|
111
|
-
});
|
|
112
|
-
} else {
|
|
113
|
-
logger.warn('[reportResultsReceived] no trace on runtime context; results-received signal not recorded', { turnId: runtime.turnId });
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
await recordUnresolvedRequest({
|
|
117
|
-
code: runtime.patientCode,
|
|
118
|
-
trace,
|
|
119
|
-
question: ['Resultados recibidos:', resultType, studyName].filter(Boolean).join(' '),
|
|
120
|
-
details: patientVerbatim,
|
|
121
|
-
category: 'results_review',
|
|
122
|
-
urgency: 'soon',
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
const data = { result_id: resultId, result_slug: resultSlug };
|
|
126
|
-
if (resultType !== rawType) data.result_type = resultType;
|
|
127
|
-
|
|
128
|
-
const observations = Array.isArray(args?.observations) ? args.observations : [];
|
|
129
|
-
if (observations.length) {
|
|
130
|
-
const { count, ids } = await storeResultObservations({ patientId: runtime.patientCode, turnId: runtime.turnId, idBase: `${runtime.turnId}_${resultIndex}`, observations });
|
|
131
|
-
if (count) {
|
|
132
|
-
data.fhir_observation_count = count;
|
|
133
|
-
data.observation_ids = ids;
|
|
134
|
-
if (count < observations.length) data.observation_rejected_count = observations.length - count;
|
|
135
|
-
return JSON.stringify({ success: true, data });
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
if (patientVerbatimHasNumericValues(patientVerbatim)) {
|
|
140
|
-
data.degraded = true;
|
|
141
|
-
data.degraded_reasons = [DEGRADED_NUMERIC_REASON];
|
|
142
|
-
data.fhir_observation_count = 0;
|
|
143
|
-
}
|
|
144
|
-
return JSON.stringify({ success: true, data });
|
|
145
|
-
} catch (err) {
|
|
146
|
-
return JSON.stringify({ success: false, error: err?.message || 'reportResultsReceived failed' });
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
module.exports = {
|
|
151
|
-
definition,
|
|
152
|
-
handler,
|
|
153
|
-
};
|