@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,177 +0,0 @@
|
|
|
1
|
-
const { recordUnresolvedRequest } = require('../services/clinicalAirtableService');
|
|
2
|
-
const { logger } = require('../../utils/logger');
|
|
3
|
-
|
|
4
|
-
const definition = {
|
|
5
|
-
'name': 'reportUnresolvedRequestv2',
|
|
6
|
-
'description': '**Does:** Escalates ADMINISTRATIVE / OPERATIVE matters — and non-clinical referrals to other services (psico-oncología, paliativos, non-oncology medical) — to the appropriate team queue. Records what was left pending, why it needs the team, operational urgency, and what was already communicated to the patient. It carries NO clinical-escalation semantics for the patient\'s oncology condition — those go to `reportMedicalEscalation`.\n\n**Required inputs:** `unresolved_question`, `category` (operational routing — REQUIRED for the queue), `urgency` (`routine` | `soon` | `urgent`; default `routine`), `escalation_details`, `resolution_attempted` (bool).\n\n**When to call:**\n- Administrative matter (appointments / insurance / authorisations / letters / rescheduling / transport).\n- Patient request that needs human (non-clinical) intervention.\n- Outside the agent\'s scope or knowledge (non-clinical).\n- A routine referral (psico-oncología, paliativos).\n- Callback from a specific team member (`category=contact_team_member`), or enrollment in a program / workshop / support group (`category=program_enrollment`).\nAlways pair with a `DeliverPatientMessage` acknowledging the escalation.\n\n**When NOT to call (use the clinical tools instead):**\n- ANY medical escalation — confirmed emergency, same-day evaluation, OR an asynchronous clinical concern (a symptom persisting >72h, a worrying trend, a treatment concern, stale clinical context) — use `reportMedicalEscalation` (urgency_level stat | asap | urgent | routine).\n- Active mental-health crisis — use `reportCrisis`.\n- Patient SHARED results / values / files — use `reportResultsReceived`.\n- Questions answerable from the CTCAE catalog or skill protocols — answer directly.\n\n**Returns:** an ack ({} on the happy path). Out-of-enum `urgency`/`category` are coerced (to `routine`/`other`) and disclosed via `coerced_urgency`/`coerced_category`; inputs over 500 chars are truncated and disclosed via `truncated_fields`. If the escalation could not be queued to the team it comes back with `queued: false` — do NOT tell the patient the team was notified in that case; acknowledge and say you are still working on it.\n\n**Side effects:** Emits `OUT_OF_SCOPE_LOGGED` to the appropriate team queue.',
|
|
7
|
-
'strict': true,
|
|
8
|
-
'parameters': {
|
|
9
|
-
'type': 'object',
|
|
10
|
-
'properties': {
|
|
11
|
-
'unresolved_question': {
|
|
12
|
-
'type': 'string',
|
|
13
|
-
'description': 'El pendiente ADMINISTRATIVO / OPERATIVO o de DERIVACIÓN que requiere al equipo. Cubre: (1) administrativo — citas, seguros, autorizaciones, cartas, reprogramación, transporte; (2) solicitud que requiere intervención humana no clínica; (3) fuera de tus alcances o conocimientos; (4) derivación/referencia a otro servicio (psico-oncología, paliativos, médico no oncológico) — eso es COORDINACIÓN, no escalación clínica. NO uses esta herramienta para ESCALACIONES CLÍNICAS del cuadro oncológico — un síntoma que persiste, una tendencia preocupante, un treatment_concern o contexto clínico desactualizado van por `reportMedicalEscalation` (urgency_level `routine` para revisión asíncrona del equipo). Resultados ya realizados que compartió el paciente van por `reportResultsReceived`.'
|
|
14
|
-
},
|
|
15
|
-
'escalation_details': {
|
|
16
|
-
'type': 'string',
|
|
17
|
-
'description': 'Por qué requiere al equipo y a quién va: equipo médico, navegadora, área administrativa, etc. Incluye urgencia operativa y qué se le comunicó al paciente.'
|
|
18
|
-
},
|
|
19
|
-
'category': {
|
|
20
|
-
'type': 'string',
|
|
21
|
-
'enum': [
|
|
22
|
-
'scheduling',
|
|
23
|
-
'insurance_authorization',
|
|
24
|
-
'transportation',
|
|
25
|
-
'legal_administrative',
|
|
26
|
-
'non_oncology_medical',
|
|
27
|
-
'document_interpretation',
|
|
28
|
-
'psicooncologia_referral',
|
|
29
|
-
'palliative_referral',
|
|
30
|
-
'contact_team_member',
|
|
31
|
-
'program_enrollment',
|
|
32
|
-
'other'
|
|
33
|
-
],
|
|
34
|
-
'description': 'Categoría operativa/administrativa o de derivación del pendiente para ruteo. `contact_team_member` = el paciente quiere hablar con / pasar un mensaje a / que lo llame de vuelta un miembro del equipo. `program_enrollment` = inscribirse a charlas, talleres, grupos de apoyo o check-ups. Las categorías de ESCALACIÓN CLÍNICA (symptom_persistent, treatment_concern, stale_context) se retiraron — esas escalaciones van por `reportMedicalEscalation`.'
|
|
35
|
-
},
|
|
36
|
-
'resolution_attempted': {
|
|
37
|
-
'anyOf': [
|
|
38
|
-
{
|
|
39
|
-
'type': 'boolean',
|
|
40
|
-
'description': 'Si se intentó resolver en el chat antes de escalar.'
|
|
41
|
-
},
|
|
42
|
-
{
|
|
43
|
-
'type': 'null'
|
|
44
|
-
}
|
|
45
|
-
]
|
|
46
|
-
},
|
|
47
|
-
'urgency': {
|
|
48
|
-
'anyOf': [
|
|
49
|
-
{
|
|
50
|
-
'type': 'string',
|
|
51
|
-
'enum': [
|
|
52
|
-
'routine',
|
|
53
|
-
'soon',
|
|
54
|
-
'urgent'
|
|
55
|
-
],
|
|
56
|
-
'description': 'routine = sin plazo; soon = plazo en la semana; urgent = mismo día (transporte para hoy, conflicto de cita mañana).'
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
'type': 'null'
|
|
60
|
-
}
|
|
61
|
-
]
|
|
62
|
-
},
|
|
63
|
-
'reasoning': {
|
|
64
|
-
'type': 'object',
|
|
65
|
-
'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.',
|
|
66
|
-
'properties': {
|
|
67
|
-
'brief': {
|
|
68
|
-
'type': 'string',
|
|
69
|
-
'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\'.'
|
|
70
|
-
},
|
|
71
|
-
'evidence_quote': {
|
|
72
|
-
'type': 'string',
|
|
73
|
-
'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).'
|
|
74
|
-
},
|
|
75
|
-
'alternatives_considered': {
|
|
76
|
-
'anyOf': [
|
|
77
|
-
{
|
|
78
|
-
'type': 'array',
|
|
79
|
-
'items': {
|
|
80
|
-
'type': 'string'
|
|
81
|
-
},
|
|
82
|
-
'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.'
|
|
83
|
-
},
|
|
84
|
-
{
|
|
85
|
-
'type': 'null'
|
|
86
|
-
}
|
|
87
|
-
]
|
|
88
|
-
}
|
|
89
|
-
},
|
|
90
|
-
'required': [
|
|
91
|
-
'brief',
|
|
92
|
-
'evidence_quote',
|
|
93
|
-
'alternatives_considered'
|
|
94
|
-
],
|
|
95
|
-
'additionalProperties': false
|
|
96
|
-
}
|
|
97
|
-
},
|
|
98
|
-
'required': [
|
|
99
|
-
'unresolved_question',
|
|
100
|
-
'escalation_details',
|
|
101
|
-
'category',
|
|
102
|
-
'resolution_attempted',
|
|
103
|
-
'urgency',
|
|
104
|
-
'reasoning'
|
|
105
|
-
],
|
|
106
|
-
'additionalProperties': false
|
|
107
|
-
}
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
const CATEGORIES = definition.parameters.properties.category.enum;
|
|
111
|
-
const URGENCIES = definition.parameters.properties.urgency.anyOf.find((v) => v.enum).enum;
|
|
112
|
-
const MAX_LEN = 500;
|
|
113
|
-
|
|
114
|
-
async function handler(args = {}, context = {}) {
|
|
115
|
-
try {
|
|
116
|
-
const trace = context?.toolRuntimeContext?.trace || null;
|
|
117
|
-
if (!trace) {
|
|
118
|
-
logger.error('[reportUnresolvedRequestv2] Escalation dropped — no active turn trace', { patientCode: context?.toolRuntimeContext?.patientCode || null });
|
|
119
|
-
return JSON.stringify({ success: false, error: 'reportUnresolvedRequestv2 requires an active turn trace.' });
|
|
120
|
-
}
|
|
121
|
-
const rawUrgency = (args?.urgency || 'routine').toLowerCase();
|
|
122
|
-
const urgency = URGENCIES.includes(rawUrgency) ? rawUrgency : 'routine';
|
|
123
|
-
const rawCategory = (args?.category || 'other').trim();
|
|
124
|
-
const category = CATEGORIES.includes(rawCategory) ? rawCategory : 'other';
|
|
125
|
-
const question = args?.unresolved_question || '';
|
|
126
|
-
const details = args?.escalation_details || '';
|
|
127
|
-
|
|
128
|
-
if (!trace.safetyDecision?.escalationKind) trace.setSafetyDecision({ escalationKind: 'out_of_scope' });
|
|
129
|
-
trace.setSignals({
|
|
130
|
-
unresolvedRequest: {
|
|
131
|
-
category,
|
|
132
|
-
unresolvedQuestion: question.slice(0, MAX_LEN),
|
|
133
|
-
escalationDetails: details.slice(0, MAX_LEN),
|
|
134
|
-
urgency,
|
|
135
|
-
resolutionAttempted: Boolean(args?.resolution_attempted),
|
|
136
|
-
},
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
const patientCode = context?.toolRuntimeContext?.patientCode;
|
|
140
|
-
let queued = false;
|
|
141
|
-
if (patientCode) {
|
|
142
|
-
queued = await recordUnresolvedRequest({
|
|
143
|
-
code: patientCode,
|
|
144
|
-
trace,
|
|
145
|
-
question,
|
|
146
|
-
details,
|
|
147
|
-
resolutionAttempted: Boolean(args?.resolution_attempted),
|
|
148
|
-
category,
|
|
149
|
-
urgency,
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const data = {};
|
|
154
|
-
// auditedWrite swallows a failed write and returns false rather than throwing.
|
|
155
|
-
if (!queued) {
|
|
156
|
-
data.queued = false;
|
|
157
|
-
logger.error('[reportUnresolvedRequestv2] Escalation not queued — the team was not notified', {
|
|
158
|
-
patientCode: patientCode || null,
|
|
159
|
-
reason: patientCode ? 'airtable_write_failed' : 'no_patient_code',
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
if (urgency !== rawUrgency) { data.urgency = urgency; data.coerced_urgency = true; }
|
|
163
|
-
if (category !== rawCategory) { data.category = category; data.coerced_category = true; }
|
|
164
|
-
const truncated = [['unresolved_question', question], ['escalation_details', details]]
|
|
165
|
-
.filter(([, value]) => value.length > MAX_LEN)
|
|
166
|
-
.map(([field]) => field);
|
|
167
|
-
if (truncated.length) data.truncated_fields = truncated;
|
|
168
|
-
return JSON.stringify({ success: true, data });
|
|
169
|
-
} catch (err) {
|
|
170
|
-
return JSON.stringify({ success: false, error: err?.message || 'reportUnresolvedRequestv2 failed' });
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
module.exports = {
|
|
175
|
-
definition,
|
|
176
|
-
handler,
|
|
177
|
-
};
|
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
const moment = require('moment-timezone');
|
|
2
|
-
|
|
3
|
-
const { dispatchReminder } = require('../services/reminderDispatchService');
|
|
4
|
-
const { recordReminder } = require('../services/clinicalAirtableService');
|
|
5
|
-
const { logger } = require('../../utils/logger');
|
|
6
|
-
|
|
7
|
-
const DEFAULT_TIMEZONE = 'America/Mexico_City';
|
|
8
|
-
|
|
9
|
-
const definition = {
|
|
10
|
-
name: 'schedulePatientReminder',
|
|
11
|
-
description: '**Does:** Schedules a proactive WhatsApp message to the patient at a specific future date/time.\n\n**Required inputs:** `reminder_date` (ISO8601 `YYYY-MM-DDTHH:mm`, future, patient-local wall-clock — interpreted in the clinic timezone), `reminder_description` (becomes the WhatsApp message body), `reminder_type` (`medicamento` | `exámen` | `examen` | `cita` | `check_in` | `otros`), `reminder_action_type` (`create` | `update`), `reminder_id` (nullable — REQUIRED to be the id of the reminder to modify when `reminder_action_type=update`; `null` when creating).\n\n**When to call:** appointment reminders, dose reminders, proactive check-ins promised to the patient ("mañana te pregunto cómo amaneciste del dolor"), or any case where the patient was told the system would write at a specific future time. Maximum 2–3 punctual reminders per turn.\n\n**When NOT to call:**\n- A reactive follow-up derived from this turn\'s clinical decision (post-OTC, post-education) — that is a separate care-plan concern.\n- Recurring calendar reminders — out of scope (the medical team owns that surface).\n\n**Returns:** `reminder_id`, `reminder_slug`, `action_type`, `reminder_type`, `fire_at` (patient-local wall-clock), `time_zone`, `channel`. Out-of-enum `reminder_type`/`reminder_action_type` are coerced to `otros`/`create`; a non-ISO or non-future `reminder_date` is rejected — re-issue with `YYYY-MM-DDTHH:mm`. A message body over 200 chars is stored truncated and disclosed via `description_truncated`.\n\n**Side effects:** Records the scheduled-reminder intent (with `timeZone`) on the turn trace.',
|
|
12
|
-
strict: true,
|
|
13
|
-
parameters: {
|
|
14
|
-
type: 'object',
|
|
15
|
-
properties: {
|
|
16
|
-
reminder_date: {
|
|
17
|
-
type: 'string',
|
|
18
|
-
description: 'Target date and time for the reminder in ISO 8601 format (YYYY-MM-DDTHH:mm). Must be in the future. Use the patient\'s local time context.',
|
|
19
|
-
},
|
|
20
|
-
reminder_description: {
|
|
21
|
-
type: 'string',
|
|
22
|
-
description: 'Short description of what the reminder is for (e.g. \'tomar ondansetrón antes del desayuno\', \'preguntarle cómo amaneció del dolor de cabeza\'). This becomes the WhatsApp message body.',
|
|
23
|
-
},
|
|
24
|
-
reminder_type: {
|
|
25
|
-
type: 'string',
|
|
26
|
-
enum: ['medicamento', 'exámen', 'examen', 'cita', 'check_in', 'otros'],
|
|
27
|
-
description: '\'check_in\' = AUNA proactively asks the patient how they are doing. \'medicamento\' = pill/infusion dose nudge. \'cita\'/\'examen\' = clinical appointment or study. \'otros\' = other punctual reminder.',
|
|
28
|
-
},
|
|
29
|
-
reminder_action_type: {
|
|
30
|
-
type: 'string',
|
|
31
|
-
enum: ['create', 'update'],
|
|
32
|
-
description: '\'create\' = new reminder. \'update\' = modify an existing reminder (change date/time/description) — pass its id in `reminder_id`.',
|
|
33
|
-
},
|
|
34
|
-
reminder_id: {
|
|
35
|
-
type: ['string', 'null'],
|
|
36
|
-
description: 'The id of the reminder to modify (from a prior schedulePatientReminder call) when `reminder_action_type=update`; `null` when creating a new reminder.',
|
|
37
|
-
},
|
|
38
|
-
},
|
|
39
|
-
required: ['reminder_date', 'reminder_description', 'reminder_type', 'reminder_action_type', 'reminder_id'],
|
|
40
|
-
additionalProperties: false,
|
|
41
|
-
},
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
const REMINDER_TYPES = definition.parameters.properties.reminder_type.enum;
|
|
45
|
-
const ACTION_TYPES = definition.parameters.properties.reminder_action_type.enum;
|
|
46
|
-
|
|
47
|
-
const ISO_DATETIME = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?$/;
|
|
48
|
-
const FIRE_AT_FORMATS = ['YYYY-MM-DDTHH:mm:ss', 'YYYY-MM-DDTHH:mm', 'YYYY-MM-DD HH:mm:ss', 'YYYY-MM-DD HH:mm'];
|
|
49
|
-
|
|
50
|
-
function parseFireAt(reminderDate, timeZone) {
|
|
51
|
-
if (!ISO_DATETIME.test(reminderDate)) return null;
|
|
52
|
-
const at = moment.tz(reminderDate, FIRE_AT_FORMATS, true, timeZone);
|
|
53
|
-
return at.isValid() ? at : null;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function slugify(value, maxLen = 32) {
|
|
57
|
-
if (!value) return '';
|
|
58
|
-
const stripped = value.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
|
|
59
|
-
const hyphenated = stripped.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase();
|
|
60
|
-
return hyphenated.slice(0, maxLen).replace(/^-+|-+$/g, '');
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
async function handler(args = {}, context = {}) {
|
|
64
|
-
try {
|
|
65
|
-
const runtime = context?.toolRuntimeContext || null;
|
|
66
|
-
const trace = runtime?.trace || null;
|
|
67
|
-
if (!runtime?.turnId || !trace?.setSignals) {
|
|
68
|
-
return JSON.stringify({ success: false, error: 'schedulePatientReminder requires an active turn context (turnId) with a trace.', data: {} });
|
|
69
|
-
}
|
|
70
|
-
const { turnId } = runtime;
|
|
71
|
-
|
|
72
|
-
const reminderDate = (args?.reminder_date || '').trim();
|
|
73
|
-
const description = (args?.reminder_description || '').trim();
|
|
74
|
-
if (!reminderDate || !description) {
|
|
75
|
-
return JSON.stringify({ success: false, error: 'schedulePatientReminder requires `reminder_date` and `reminder_description`.', data: {} });
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const rawType = (args?.reminder_type || 'otros').toLowerCase();
|
|
79
|
-
const reminderType = REMINDER_TYPES.includes(rawType) ? rawType : 'otros';
|
|
80
|
-
const rawAction = (args?.reminder_action_type || 'create').toLowerCase();
|
|
81
|
-
const actionType = ACTION_TYPES.includes(rawAction) ? rawAction : 'create';
|
|
82
|
-
const targetId = typeof args?.reminder_id === 'string' && args.reminder_id.trim() ? args.reminder_id.trim() : null;
|
|
83
|
-
|
|
84
|
-
const timeZone = DEFAULT_TIMEZONE;
|
|
85
|
-
const fireMoment = parseFireAt(reminderDate, timeZone);
|
|
86
|
-
if (!fireMoment) {
|
|
87
|
-
return JSON.stringify({ success: false, error: `reminder_date must be ISO 8601 (YYYY-MM-DDTHH:mm) — got '${reminderDate}'.`, data: {} });
|
|
88
|
-
}
|
|
89
|
-
if (!fireMoment.isAfter(moment.tz(timeZone))) {
|
|
90
|
-
return JSON.stringify({ success: false, error: `reminder_date must be in the future (${timeZone}) — got '${reminderDate}'.`, data: {} });
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const fireAt = fireMoment.format('YYYY-MM-DDTHH:mm:ss');
|
|
94
|
-
const existing = Array.isArray(trace.signals?.reminderIntents) ? trace.signals.reminderIntents : [];
|
|
95
|
-
const reminderId = actionType === 'update' && targetId ? targetId : `rem_${turnId}_${existing.length}`;
|
|
96
|
-
const reminderSlug = [reminderType, fireMoment.format('YYYY-MM-DDTHHmm'), slugify(description, 24)].filter(Boolean).join('-');
|
|
97
|
-
const storedDescription = description.slice(0, 200);
|
|
98
|
-
|
|
99
|
-
const intent = {
|
|
100
|
-
reminderId,
|
|
101
|
-
reminderType,
|
|
102
|
-
actionType,
|
|
103
|
-
fireAt,
|
|
104
|
-
timeZone,
|
|
105
|
-
description: storedDescription,
|
|
106
|
-
channel: 'whatsapp',
|
|
107
|
-
};
|
|
108
|
-
trace.setSignals({ reminderIntents: [...existing, intent] });
|
|
109
|
-
|
|
110
|
-
if (runtime.patientCode) {
|
|
111
|
-
try {
|
|
112
|
-
await dispatchReminder({ intent, patientId: runtime.patientCode });
|
|
113
|
-
} catch (err) {
|
|
114
|
-
logger.warn('[schedulePatientReminder] reminder FHIR projection failed; ack unaffected', { turnId, error: err?.message });
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
if (runtime.patientCode) {
|
|
119
|
-
await recordReminder({
|
|
120
|
-
code: runtime.patientCode,
|
|
121
|
-
trace,
|
|
122
|
-
actionType,
|
|
123
|
-
reminderId,
|
|
124
|
-
reminderDateUtc: fireMoment.toISOString(),
|
|
125
|
-
reminderType,
|
|
126
|
-
description: storedDescription,
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const data = {
|
|
131
|
-
reminder_id: reminderId,
|
|
132
|
-
reminder_slug: reminderSlug,
|
|
133
|
-
action_type: actionType,
|
|
134
|
-
reminder_type: reminderType,
|
|
135
|
-
fire_at: fireAt,
|
|
136
|
-
time_zone: timeZone,
|
|
137
|
-
channel: 'whatsapp',
|
|
138
|
-
};
|
|
139
|
-
if (description.length > 200) data.description_truncated = true;
|
|
140
|
-
return JSON.stringify({ success: true, data });
|
|
141
|
-
} catch (err) {
|
|
142
|
-
return JSON.stringify({ success: false, error: err?.message || 'schedulePatientReminder failed', data: {} });
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
module.exports = {
|
|
147
|
-
definition,
|
|
148
|
-
handler,
|
|
149
|
-
};
|
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
const crypto = require('node:crypto');
|
|
2
|
-
|
|
3
|
-
const { readSymptomCases, readClusters, readContingencies, storeContingency } = require('../../fhir');
|
|
4
|
-
const {
|
|
5
|
-
ContingencySafetyNet,
|
|
6
|
-
ESCALATION_PRIORITIES,
|
|
7
|
-
ACTIVE_CARE_PLAN_STATUSES,
|
|
8
|
-
} = require('../../shared/dtos/ContingencySafetyNet');
|
|
9
|
-
|
|
10
|
-
const OPEN_CASE_STATUSES = ['characterizing', 'monitoring'];
|
|
11
|
-
const STEP_SHAPE = '{recipient, message, priority, action_requested, fires_after_silence_minutes}';
|
|
12
|
-
|
|
13
|
-
const definition = {
|
|
14
|
-
name: 'setContingencyPlan',
|
|
15
|
-
description: `**Does:** Arms a contingency safety net — a CarePlan anchor plus a team escalation that fires after patient silence, and an optional patient follow-up reminder. Guards an open case or cluster against a specific feared trajectory.\n\n**Required inputs:** \`concern\`, \`awaiting_characterization\`, \`why_this_matters\`, \`creation_reasoning\`; at least one of \`related_case_id\` / \`related_cluster_id\`; \`team_escalation\` \`${STEP_SHAPE}\` (message + priority + action_requested + a positive \`fires_after_silence_minutes\`). Optional \`patient_followup_reminder\` (same shape).\n\n**When to call:** when an open case/cluster needs a monitored fallback if the patient goes quiet.\n\n**When NOT to call:** when a target already has an active plan (fails hard) or the target is closed/dissolved.\n\n**Returns:** \`{plan_id}\`. Fails hard with \`case_not_found_or_wrong_patient\` / \`cluster_not_found_or_wrong_patient\`, \`target_not_open\`, or \`active_plan_already_exists_for_case\` / \`active_plan_already_exists_for_cluster\`.\n\n**Side effects:** writes a FHIR CarePlan + team CommunicationRequest (+ optional patient CommunicationRequest) + Provenance.`,
|
|
16
|
-
strict: false,
|
|
17
|
-
parameters: {
|
|
18
|
-
type: 'object',
|
|
19
|
-
properties: {
|
|
20
|
-
concern: {
|
|
21
|
-
type: 'string',
|
|
22
|
-
description: 'The feared trajectory this plan guards against.',
|
|
23
|
-
},
|
|
24
|
-
awaiting_characterization: {
|
|
25
|
-
type: 'string',
|
|
26
|
-
description: 'What the plan is waiting to observe or characterize.',
|
|
27
|
-
},
|
|
28
|
-
why_this_matters: {
|
|
29
|
-
type: 'string',
|
|
30
|
-
description: 'Why this contingency is clinically important.',
|
|
31
|
-
},
|
|
32
|
-
creation_reasoning: {
|
|
33
|
-
type: 'string',
|
|
34
|
-
description: 'Why the plan is being armed now.',
|
|
35
|
-
},
|
|
36
|
-
related_case_id: {
|
|
37
|
-
type: 'string',
|
|
38
|
-
description: 'The open case this plan guards. At least one of related_case_id / related_cluster_id is required.',
|
|
39
|
-
},
|
|
40
|
-
related_cluster_id: {
|
|
41
|
-
type: 'string',
|
|
42
|
-
description: 'The non-dissolved cluster this plan guards. At least one of related_case_id / related_cluster_id is required.',
|
|
43
|
-
},
|
|
44
|
-
team_escalation: {
|
|
45
|
-
type: 'object',
|
|
46
|
-
description: `Required. ${STEP_SHAPE}: message + priority + action_requested (strings) + fires_after_silence_minutes (> 0).`,
|
|
47
|
-
},
|
|
48
|
-
patient_followup_reminder: {
|
|
49
|
-
type: 'object',
|
|
50
|
-
description: `Optional. Same shape as team_escalation ${STEP_SHAPE}.`,
|
|
51
|
-
},
|
|
52
|
-
},
|
|
53
|
-
required: ['concern', 'awaiting_characterization', 'why_this_matters', 'creation_reasoning', 'team_escalation'],
|
|
54
|
-
},
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
function fail(error, data = {}) {
|
|
58
|
-
return JSON.stringify({ success: false, error, data });
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function buildStep(raw, recipient, label) {
|
|
62
|
-
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
63
|
-
return { error: `${label} must be a non-empty object ${STEP_SHAPE}` };
|
|
64
|
-
}
|
|
65
|
-
const silence = raw.fires_after_silence_minutes;
|
|
66
|
-
if (typeof silence !== 'number' || !Number.isInteger(silence) || silence <= 0) {
|
|
67
|
-
return { error: `${label}.fires_after_silence_minutes is required and must be a positive integer` };
|
|
68
|
-
}
|
|
69
|
-
const message = (typeof raw.message === 'string' ? raw.message : '').trim();
|
|
70
|
-
if (!message) return { error: `${label}.message is required` };
|
|
71
|
-
const step = {
|
|
72
|
-
recipient,
|
|
73
|
-
message,
|
|
74
|
-
firesAfterSilenceMinutes: silence,
|
|
75
|
-
};
|
|
76
|
-
if (typeof raw.priority === 'string' && raw.priority.trim()) {
|
|
77
|
-
const priority = raw.priority.trim();
|
|
78
|
-
if (!ESCALATION_PRIORITIES.includes(priority)) {
|
|
79
|
-
return { error: 'invalid_priority', data: { priority, allowed: ESCALATION_PRIORITIES } };
|
|
80
|
-
}
|
|
81
|
-
step.priority = priority;
|
|
82
|
-
}
|
|
83
|
-
if (recipient === 'care_team') {
|
|
84
|
-
const actionRequested = (typeof raw.action_requested === 'string' ? raw.action_requested : '').trim();
|
|
85
|
-
if (!actionRequested) return { error: `${label}.action_requested is required` };
|
|
86
|
-
step.actionRequested = actionRequested;
|
|
87
|
-
} else if (typeof raw.action_requested === 'string' && raw.action_requested.trim()) {
|
|
88
|
-
step.actionRequested = raw.action_requested.trim();
|
|
89
|
-
}
|
|
90
|
-
return { step };
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
async function handler(args = {}, context = {}) {
|
|
94
|
-
try {
|
|
95
|
-
const runtime = context?.toolRuntimeContext || null;
|
|
96
|
-
if (!runtime?.turnId || !runtime?.patientCode) {
|
|
97
|
-
return fail('setContingencyPlan requires an active turn context (turnId, patientCode).');
|
|
98
|
-
}
|
|
99
|
-
const str = (v) => (typeof v === 'string' ? v : '');
|
|
100
|
-
const concern = str(args?.concern).trim();
|
|
101
|
-
const awaiting = str(args?.awaiting_characterization).trim();
|
|
102
|
-
const why = str(args?.why_this_matters).trim();
|
|
103
|
-
const reasoning = str(args?.creation_reasoning).trim();
|
|
104
|
-
const relatedCaseId = str(args?.related_case_id).trim() || null;
|
|
105
|
-
const relatedClusterId = str(args?.related_cluster_id).trim() || null;
|
|
106
|
-
|
|
107
|
-
const missing = [['concern', concern], ['awaiting_characterization', awaiting], ['why_this_matters', why], ['creation_reasoning', reasoning]]
|
|
108
|
-
.filter(([, v]) => !v).map(([n]) => n);
|
|
109
|
-
if (missing.length) return fail(`missing required field(s): ${missing.join(', ')}`);
|
|
110
|
-
if (!relatedCaseId && !relatedClusterId) {
|
|
111
|
-
return fail('at least one of related_case_id or related_cluster_id is required — a plan must link to a target');
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const team = buildStep(args?.team_escalation, 'care_team', 'team_escalation');
|
|
115
|
-
if (team.error) return fail(team.error, team.data);
|
|
116
|
-
let reminder = null;
|
|
117
|
-
if (args?.patient_followup_reminder != null) {
|
|
118
|
-
const built = buildStep(args.patient_followup_reminder, 'patient', 'patient_followup_reminder');
|
|
119
|
-
if (built.error) return fail(built.error, built.data);
|
|
120
|
-
reminder = built.step;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const patientId = runtime.patientCode;
|
|
124
|
-
if (relatedCaseId) {
|
|
125
|
-
const targetCase = (await readSymptomCases({ patientId })).find((c) => c.condition.conditionId === relatedCaseId);
|
|
126
|
-
if (!targetCase) return fail('case_not_found_or_wrong_patient', { related_case_id: relatedCaseId });
|
|
127
|
-
if (!OPEN_CASE_STATUSES.includes(targetCase.condition.clinicalStatus)) {
|
|
128
|
-
return fail('target_not_open — contingency plans require an active case', { related_case_id: relatedCaseId, current_status: targetCase.condition.clinicalStatus });
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
if (relatedClusterId) {
|
|
132
|
-
const targetCluster = (await readClusters({ patientId })).find((c) => c.clusterId === relatedClusterId);
|
|
133
|
-
if (!targetCluster) return fail('cluster_not_found_or_wrong_patient', { related_cluster_id: relatedClusterId });
|
|
134
|
-
if (targetCluster.status === 'dissolved') {
|
|
135
|
-
return fail('target_not_open — contingency plans require a non-dissolved cluster', { related_cluster_id: relatedClusterId, current_status: targetCluster.status });
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
if (relatedCaseId) {
|
|
140
|
-
const active = (await readContingencies({ patientId, caseId: relatedCaseId })).find((n) => ACTIVE_CARE_PLAN_STATUSES.includes(n.carePlan.status));
|
|
141
|
-
if (active) return fail('active_plan_already_exists_for_case', { existing_plan_id: active.carePlan.planId, existing_status: active.carePlan.status });
|
|
142
|
-
}
|
|
143
|
-
if (relatedClusterId) {
|
|
144
|
-
const active = (await readContingencies({ patientId, clusterId: relatedClusterId })).find((n) => ACTIVE_CARE_PLAN_STATUSES.includes(n.carePlan.status));
|
|
145
|
-
if (active) return fail('active_plan_already_exists_for_cluster', { existing_plan_id: active.carePlan.planId, existing_status: active.carePlan.status });
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const now = new Date().toISOString();
|
|
149
|
-
const planId = `plan_${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`;
|
|
150
|
-
const contingency = new ContingencySafetyNet({
|
|
151
|
-
carePlan: {
|
|
152
|
-
planId,
|
|
153
|
-
patientId,
|
|
154
|
-
relatedCaseId,
|
|
155
|
-
relatedClusterId,
|
|
156
|
-
concern,
|
|
157
|
-
awaitingCharacterization: awaiting,
|
|
158
|
-
whyThisMatters: why,
|
|
159
|
-
creationReasoning: reasoning,
|
|
160
|
-
createdAt: now,
|
|
161
|
-
},
|
|
162
|
-
teamEscalation: team.step,
|
|
163
|
-
patientReminder: reminder,
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
await storeContingency({ patientId, contingency });
|
|
167
|
-
return JSON.stringify({ success: true, data: { plan_id: planId } });
|
|
168
|
-
} catch (err) {
|
|
169
|
-
return fail(err?.message || 'setContingencyPlan failed');
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
module.exports = {
|
|
174
|
-
definition,
|
|
175
|
-
handler,
|
|
176
|
-
};
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
const { loadSkillBody } = require('../services/skillService');
|
|
2
|
-
|
|
3
|
-
const definition = {
|
|
4
|
-
name: 'Skill',
|
|
5
|
-
description: '**Does:** Activates an enabled skill by name and returns its `SKILL.md` body inline; the body then governs the rest of the turn.\n\n**Required inputs:** `skill` (enabled skill name, no leading slash, e.g. `emergency`), `args` (context string the skill body can interpret — pass `null` when there is no additional context).\n\n**When to call:** the turn matches a protocol the operator has enabled for this run (e.g. an emergency/red-flag turn) and you need its full body to proceed correctly.\n\n**When NOT to call:** the skill is not enabled for this run; a plain reply covers the turn without a protocol.\n\n**Returns:** `activated_skills[]` — `{name, version, body}`; read `body` and follow it verbatim. A skill not enabled for this run returns the enabled skill names, and an unknown name returns the available skill names, in the error payload for self-correction. Read-only.',
|
|
6
|
-
strict: true,
|
|
7
|
-
parameters: {
|
|
8
|
-
type: 'object',
|
|
9
|
-
properties: {
|
|
10
|
-
skill: {
|
|
11
|
-
type: 'string',
|
|
12
|
-
description: 'Name of the skill protocol to activate.',
|
|
13
|
-
},
|
|
14
|
-
args: {
|
|
15
|
-
type: ['string', 'null'],
|
|
16
|
-
description: 'Context string the skill body can interpret; `null` when there is no additional context.',
|
|
17
|
-
},
|
|
18
|
-
},
|
|
19
|
-
required: ['skill', 'args'],
|
|
20
|
-
additionalProperties: false,
|
|
21
|
-
},
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
async function handler(args, context) {
|
|
25
|
-
try {
|
|
26
|
-
const name = typeof args?.skill === 'string' ? args.skill.trim() : '';
|
|
27
|
-
if (!name) {
|
|
28
|
-
return JSON.stringify({
|
|
29
|
-
success: false,
|
|
30
|
-
error: 'Skill requires `skill` (name of the skill to activate).',
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const enabledSkills = context?.toolRuntimeContext?.enabledSkills;
|
|
35
|
-
if (!Array.isArray(enabledSkills) || !enabledSkills.includes(name)) {
|
|
36
|
-
return JSON.stringify({
|
|
37
|
-
success: false,
|
|
38
|
-
error: `Skill '${name}' is not enabled for this run.`,
|
|
39
|
-
data: {
|
|
40
|
-
requested_skill: name,
|
|
41
|
-
enabled_skills: Array.isArray(enabledSkills) ? [...enabledSkills].sort() : [],
|
|
42
|
-
},
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const { skill, availableSkills } = await loadSkillBody(name);
|
|
47
|
-
if (!skill) {
|
|
48
|
-
return JSON.stringify({
|
|
49
|
-
success: false,
|
|
50
|
-
error: `Skill '${name}' is not registered.`,
|
|
51
|
-
data: { requested_skill: name, available_skills: availableSkills },
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const fragment = { name: skill.name, version: skill.version, body: skill.body };
|
|
56
|
-
if (args?.args) fragment.args_received = args.args;
|
|
57
|
-
|
|
58
|
-
return JSON.stringify({ success: true, data: { activated_skills: [fragment] } });
|
|
59
|
-
} catch (err) {
|
|
60
|
-
return JSON.stringify({ success: false, error: err?.message || 'Skill failed' });
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
module.exports = {
|
|
65
|
-
definition,
|
|
66
|
-
handler,
|
|
67
|
-
};
|