@peopl-health/nexus 5.67.0-dev.11679 → 5.67.0-dev.11849
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/escalationFlags.js +1 -1
- package/lib/clinical/memory/DefaultMemoryManager.js +3 -1
- package/lib/clinical/memory/EnhancedMemoryManager.js +1 -0
- package/lib/clinical/providers/BaseLLMProvider.js +8 -3
- package/lib/clinical/services/contingencyDispatchService.js +3 -3
- package/lib/clinical/services/escalationDraftService.js +10 -4
- package/lib/clinical/services/nutritionPlanService.js +59 -0
- package/lib/clinical/tools/reportMedicalEscalationTool.js +3 -3
- package/lib/clinical/tools/schedulePatientReminderTool.js +2 -2
- package/lib/clinical/tools/setContingencyPlanTool.js +2 -2
- package/lib/eval/EvalProvider.js +1 -0
- package/lib/fhir/projections/symptomCaseProjection.js +9 -3
- package/lib/fhir/services/symptomCaseService.js +3 -2
- package/lib/index.d.ts +1 -0
- package/package.json +1 -1
|
@@ -3,7 +3,7 @@ const runtimeConfig = require('../../config/runtimeConfig');
|
|
|
3
3
|
const flagOn = (key) => String(runtimeConfig.get(key, 'false')).trim().toLowerCase() === 'true';
|
|
4
4
|
|
|
5
5
|
function escalationUpdateModeLive() {
|
|
6
|
-
return
|
|
6
|
+
return String(runtimeConfig.get('ESCALATION_UPDATE_MODE_LIVE', 'true')).trim().toLowerCase() !== 'false';
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
function escalationAssessmentLive() {
|
|
@@ -2,6 +2,7 @@ const { pairTriageSymptoms } = require('../helpers/triageSymptomsHelper');
|
|
|
2
2
|
const { handlePendingFunctionCalls: handlePendingFunctionCallsUtil } = require('../providers/OpenAIResponsesProviderTools');
|
|
3
3
|
|
|
4
4
|
const { assembleRiskHeadline } = require('../services/riskHeadlineService');
|
|
5
|
+
const { resolveNutritionPlan } = require('../services/nutritionPlanService');
|
|
5
6
|
const { resolvePatientIdentity, identityError } = require('../services/patientIdentityService');
|
|
6
7
|
const { Follow_Up_ID } = require('../../config/airtableConfig');
|
|
7
8
|
const runtimeConfig = require('../../config/runtimeConfig');
|
|
@@ -350,7 +351,8 @@ class DefaultMemoryManager extends MemoryManager {
|
|
|
350
351
|
|
|
351
352
|
return {
|
|
352
353
|
clinicalContext: [contextParts.join('. '), riskHeadline].filter(Boolean).join('\n\n'),
|
|
353
|
-
lastSymptoms: symptomParts.join('; ') || 'Sin síntomas reportados recientemente'
|
|
354
|
+
lastSymptoms: symptomParts.join('; ') || 'Sin síntomas reportados recientemente',
|
|
355
|
+
nutritionPlan: await resolveNutritionPlan(data.nutricion),
|
|
354
356
|
};
|
|
355
357
|
} catch (error) {
|
|
356
358
|
logger.error('[DefaultMemoryManager] Error getting clinical data', { error: error.message });
|
|
@@ -49,6 +49,7 @@ class EnhancedMemoryManager extends DefaultMemoryManager {
|
|
|
49
49
|
return {
|
|
50
50
|
clinicalContext: baseClinicalData?.clinicalContext ?? '',
|
|
51
51
|
lastSymptoms: baseClinicalData?.lastSymptoms ?? '',
|
|
52
|
+
nutritionPlan: baseClinicalData?.nutritionPlan ?? '',
|
|
52
53
|
patientMemories: formattedMemories,
|
|
53
54
|
conversationSummaries: formattedSummaries,
|
|
54
55
|
};
|
|
@@ -305,10 +305,10 @@ class BaseLLMProvider {
|
|
|
305
305
|
role: 'developer',
|
|
306
306
|
content: [
|
|
307
307
|
'## Seguimiento, recordatorio y contingencia — no son intercambiables',
|
|
308
|
-
'- SEGUIMIENTO: tú decides volver a contactar para mantener continuidad cuando ya no falta un dato clínico que pueda cambiar el riesgo. Elige cuándo y redacta la pregunta exacta para esa persona. Usa schedulePatientReminder con reminder_type seguimiento y la utility_basis concreta. No lo programes por
|
|
308
|
+
'- SEGUIMIENTO: tú decides volver a contactar para mantener continuidad cuando ya no falta un dato clínico que pueda cambiar el riesgo. Elige cuándo y redacta la pregunta exacta para esa persona. Usa schedulePatientReminder con reminder_type seguimiento y la utility_basis concreta. Prográmalo cuando indicaste una acción (medicamento de venta libre, autocuidado, educación) cuyo efecto se ve en 24–72 h; cuando la paciente ya dio el dato y quieres saber si el síntoma vuelve o cruza un umbral que nombraste; cuando cerraste un episodio que vale la pena revisar; o cuando tiene un evento próximo (inicio de tratamiento, cita, infusión) cuyo resultado importa. No lo programes por inactividad sola; cualquier actividad posterior de la paciente lo cancela y entonces respondes a lo nuevo.',
|
|
309
309
|
'- RECORDATORIO: sólo existe si la paciente pidió explícitamente que se lo recuerdes o aceptó explícitamente tu oferta de recordárselo. Usa schedulePatientReminder con el tipo concreto (medicamento, cita, examen u otros) y exactamente a la hora que pidió. No lo adelantes para que llegue antes de una cita o tarea salvo que ella haya pedido ese adelanto. No infieras un recordatorio porque mencionó o agendó algo, ni lo uses para preguntar cómo sigue o vigilar incertidumbre clínica.',
|
|
310
310
|
'- CONTINGENCIA: protege una incertidumbre clínica todavía abierta; falta un dato u observación que podría cambiar el grado, la urgencia o la conducta. Usa setContingencyPlan: la contingencia define qué se espera, cuánto silencio tolerar y, si corresponde, la escalada posterior. Nunca la sustituyas por seguimiento ni por recordatorio.',
|
|
311
|
-
'Regla de desempate: si esperas saber “cómo sigue” por continuidad, es seguimiento; si la paciente pidió explícitamente o aceptó que le recuerdes hacer algo, es recordatorio; si esperas un dato porque no tenerlo podría volver insegura la conducta, es contingencia. Una tarea acordada o agendada sin esa petición no es un recordatorio. Una contingencia activa
|
|
311
|
+
'Regla de desempate: si esperas saber “cómo sigue” por continuidad, es seguimiento; si la paciente pidió explícitamente o aceptó que le recuerdes hacer algo, es recordatorio; si esperas un dato porque no tenerlo podría volver insegura la conducta, es contingencia. Una tarea acordada o agendada sin esa petición no es un recordatorio. Una contingencia activa gana sólo mientras siga faltando su dato; cuando la resuelves porque la paciente ya lo dio, la vigilancia que queda es un seguimiento, no una red rearmada.',
|
|
312
312
|
].join('\n'),
|
|
313
313
|
}];
|
|
314
314
|
}
|
|
@@ -402,7 +402,7 @@ class BaseLLMProvider {
|
|
|
402
402
|
}
|
|
403
403
|
for (const net of cancelled) {
|
|
404
404
|
const plan = net?.carePlan || {};
|
|
405
|
-
parts.push(`- (este turno) ${plan.planId} — la red que vigilaba "${sanitizeInlineText(plan.concern)}" se desactivó automáticamente porque el paciente escribió. Si todavía falta un dato de la rúbrica que podría subir el grado, considera rearmarla con setContingencyPlan.`);
|
|
405
|
+
parts.push(`- (este turno) ${plan.planId} — la red que vigilaba "${sanitizeInlineText(plan.concern)}" se desactivó automáticamente porque el paciente escribió. Si todavía falta un dato de la rúbrica que podría subir el grado, considera rearmarla con setContingencyPlan. Si ya no falta ese dato y sólo quieres saber cómo sigue, programa un seguimiento (schedulePatientReminder, reminder_type seguimiento) en lugar de rearmarla.`);
|
|
406
406
|
}
|
|
407
407
|
if (active.length) {
|
|
408
408
|
parts.push('DECISIÓN ESTE TURNO: si el mensaje del paciente responde lo que una red espera observar, llama setContingencyPlan {action: \'resolve\', plan_id: \'<plan>\', resolution_reasoning: \'…\'} o cierra el caso. Si cambió la preocupación o la ventana, usa {action: \'rearm\'}. Si el mensaje trata de otra cosa, no hagas nada con la red.');
|
|
@@ -428,6 +428,10 @@ class BaseLLMProvider {
|
|
|
428
428
|
if (symptoms && !inPrompt.has('last_symptoms')) {
|
|
429
429
|
parts.push(`## Síntomas recientes\n${symptoms}`);
|
|
430
430
|
}
|
|
431
|
+
const nutritionPlan = this._clinicalText(promptVariables?.nutrition_plan, 'nutrition_plan');
|
|
432
|
+
if (nutritionPlan && !inPrompt.has('nutrition_plan')) {
|
|
433
|
+
parts.push(`## Plan de nutrición indicado por el equipo (indicado; no asumas que el paciente ya lo cumple)\n${nutritionPlan}`);
|
|
434
|
+
}
|
|
431
435
|
if (parts.length === 0) return [];
|
|
432
436
|
// Refreshed most turns (symptoms, risks); ahead of the history it rewrote the whole cached
|
|
433
437
|
// conversation. Informational only, so it rides behind it like the clock. Directives stay in system.
|
|
@@ -510,6 +514,7 @@ class BaseLLMProvider {
|
|
|
510
514
|
const promptVariables = {
|
|
511
515
|
clinical_context: clinicalData?.clinicalContext ?? '',
|
|
512
516
|
last_symptoms: clinicalData?.lastSymptoms ?? '',
|
|
517
|
+
nutrition_plan: clinicalData?.nutritionPlan ?? '',
|
|
513
518
|
current_date: buildDateAnchor(),
|
|
514
519
|
patient_memories: clinicalData?.patientMemories ?? '',
|
|
515
520
|
conversation_summaries: clinicalData?.conversationSummaries ?? '',
|
|
@@ -18,8 +18,8 @@ const ESCALATION_SILENCE_MINUTES = { stat: 120, asap: 180, urgent: 240 };
|
|
|
18
18
|
const OPEN_CEILING_SILENCE_MINUTES = 720;
|
|
19
19
|
const OPEN_CEILING_ORIGIN = 'open_ceiling';
|
|
20
20
|
const OPEN_CEILING_CHECK_IN = 'Hola, quería saber cómo sigues. ¿Cómo va lo que me contaste?';
|
|
21
|
-
const ESCALATION_AWAITING = '
|
|
22
|
-
const ESCALATION_CHECK_IN = 'Hola, quería saber cómo sigues. ¿
|
|
21
|
+
const ESCALATION_AWAITING = 'cómo evolucionó el síntoma escalado: si empeoró, mejoró o apareció un signo de alarma';
|
|
22
|
+
const ESCALATION_CHECK_IN = 'Hola, quería saber cómo sigues. ¿Cómo te sientes ahora con lo que me contaste?';
|
|
23
23
|
const FIRED_STATUS_BY_STAGE = { patient: 'fired_reminder', team: 'fired_team' };
|
|
24
24
|
const STATUS_RANK = { armed: 0, fired_reminder: 1, fired_team: 2 };
|
|
25
25
|
const TEAM_WINDOW_FRACTION = 0.5;
|
|
@@ -302,7 +302,7 @@ async function armEscalationNet({ patientCode, urgencyLevel, symptom, suspectedC
|
|
|
302
302
|
recipient: 'patient',
|
|
303
303
|
message: String(checkIn || '').trim() || ESCALATION_CHECK_IN,
|
|
304
304
|
firesAfterSilenceMinutes: silence,
|
|
305
|
-
windowReasoning: `Escalación ${urgencyLevel}: se vuelve a buscar a la paciente si no hay señal suya
|
|
305
|
+
windowReasoning: `Escalación ${urgencyLevel}: se vuelve a buscar a la paciente si no hay señal suya.`,
|
|
306
306
|
},
|
|
307
307
|
});
|
|
308
308
|
|
|
@@ -10,7 +10,7 @@ const { readStructuredText } = require('../helpers/structuredTextHelper');
|
|
|
10
10
|
const { getEscalationContext } = require('../../services/escalationContextService');
|
|
11
11
|
|
|
12
12
|
const { logger } = require('../../utils/logger');
|
|
13
|
-
const { safeParse } = require('../../utils/jsonUtils');
|
|
13
|
+
const { arrayArg, safeParse } = require('../../utils/jsonUtils');
|
|
14
14
|
const { withTiming } = require('../../utils/tracingDecorator');
|
|
15
15
|
|
|
16
16
|
const { crossSymptomIssues, unproposedDrugsIn, validateProposal } = require('./treatmentProposalService');
|
|
@@ -130,9 +130,15 @@ async function generateEscalationDraft({
|
|
|
130
130
|
|
|
131
131
|
const parsed = safeParse(readStructuredText(result), null);
|
|
132
132
|
const patientMessage = trimmed(parsed?.patientMessage);
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
133
|
+
// The schema is not strict, so the model sometimes sends the list JSON-encoded as a string.
|
|
134
|
+
const blocks = parsed?.symptoms === undefined ? [parsed] : arrayArg(parsed.symptoms);
|
|
135
|
+
if (!blocks.length) {
|
|
136
|
+
const shape = parsed.symptoms;
|
|
137
|
+
logger.warn('[escalationDraft] generation returned a malformed symptom list', {
|
|
138
|
+
reviewId,
|
|
139
|
+
symptomsType: Array.isArray(shape) ? 'array' : typeof shape,
|
|
140
|
+
symptomsLength: Array.isArray(shape) || typeof shape === 'string' ? shape.length : null,
|
|
141
|
+
});
|
|
136
142
|
return { completed: false, attempted: true, reason: 'the model returned a malformed symptoms list' };
|
|
137
143
|
}
|
|
138
144
|
if (parsed?.symptoms !== undefined && blocks.some((block) => !trimmed(block?.symptom))) {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const { safeParse, isPlainObject } = require('../../utils/jsonUtils');
|
|
2
|
+
const { logger } = require('../../utils/logger');
|
|
3
|
+
const { sanitizeInlineText } = require('../../utils/sanitizerUtils');
|
|
4
|
+
const { composePrompt } = require('./promptComposerService');
|
|
5
|
+
|
|
6
|
+
const NUTRITION_GUIDE_PROMPT_ID = 'nutrition_plan_guide';
|
|
7
|
+
const PLAN_MAX_CHARS = 1500;
|
|
8
|
+
const GUIDE_MAX_CHARS = 2000;
|
|
9
|
+
const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/;
|
|
10
|
+
|
|
11
|
+
// Each line of a plan or guide is one kind of indication, so the breaks survive and only each line is flattened.
|
|
12
|
+
function keepLines(text, maxChars) {
|
|
13
|
+
return String(text)
|
|
14
|
+
.split('\n')
|
|
15
|
+
.map((line) => sanitizeInlineText(line, Infinity))
|
|
16
|
+
.filter(Boolean)
|
|
17
|
+
.join('\n')
|
|
18
|
+
.slice(0, maxChars);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The section arrives as {plan_vigente, revisar_el}, as a {previous, current} slot, or as either one JSON-encoded.
|
|
22
|
+
function readPlanSection(section) {
|
|
23
|
+
const parsed = typeof section === 'string' ? safeParse(section, section) : section;
|
|
24
|
+
if (typeof parsed === 'string') return { text: parsed, reviewBy: null };
|
|
25
|
+
if (!isPlainObject(parsed)) return { text: '', reviewBy: null };
|
|
26
|
+
const value = parsed.plan_vigente ?? parsed.current ?? parsed.nutrition_context ?? '';
|
|
27
|
+
const inner = typeof value === 'string' ? safeParse(value, null) : value;
|
|
28
|
+
const text = isPlainObject(inner) ? inner.current ?? '' : value;
|
|
29
|
+
const reviewBy = typeof parsed.revisar_el === 'string' && ISO_DATE_RX.test(parsed.revisar_el) ? parsed.revisar_el : null;
|
|
30
|
+
return { text: typeof text === 'string' ? text : '', reviewBy };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function loadNutritionGuide() {
|
|
34
|
+
try {
|
|
35
|
+
const { resolvedPrompt } = await composePrompt({ promptId: NUTRITION_GUIDE_PROMPT_ID });
|
|
36
|
+
return keepLines(resolvedPrompt || '', GUIDE_MAX_CHARS);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
logger.warn('[nutritionPlanService] nutrition guide unavailable; plan sent without it', { error: error.message });
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function resolveNutritionPlan(section, { now = new Date() } = {}) {
|
|
44
|
+
const { text, reviewBy } = readPlanSection(section);
|
|
45
|
+
const plan = keepLines(text, PLAN_MAX_CHARS);
|
|
46
|
+
if (!plan) return '';
|
|
47
|
+
const expired = reviewBy && reviewBy < now.toISOString().slice(0, 10);
|
|
48
|
+
const guide = await loadNutritionGuide();
|
|
49
|
+
return [
|
|
50
|
+
plan,
|
|
51
|
+
expired ? `Fecha de revisión vencida: ${reviewBy}.` : '',
|
|
52
|
+
guide,
|
|
53
|
+
].filter(Boolean).join('\n\n');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = {
|
|
57
|
+
NUTRITION_GUIDE_PROMPT_ID,
|
|
58
|
+
resolveNutritionPlan,
|
|
59
|
+
};
|
|
@@ -19,7 +19,7 @@ const { logger } = require('../../utils/logger');
|
|
|
19
19
|
|
|
20
20
|
const definition = {
|
|
21
21
|
'name': 'reportMedicalEscalation',
|
|
22
|
-
'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:**
|
|
22
|
+
'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 for the same episode — already committed; a later datum belongs to the same open escalation.\n\n**After the commit:** the escalation does not end the conversation.\n- **Patient message** (`DeliverPatientMessage`, calm, ≤60 words): acknowledge what they told you → \'Ya avisé al equipo médico\' → ONE concrete next step → a safety net specific to THIS case (1–2 observable signs and what to do if they appear) → availability. Explain the purpose in everyday words. Reassure with the plan (\'ya le avisé al equipo médico para que lo revise\', \'vamos a darle seguimiento contigo\'), never with promises (\'no es nada\', \'no te preocupes\'). No diagnosis names, no lists of alarming symptoms, no \'grave/peligroso\', no timed fallback (\'si en 15 minutos…\'), and no promised contact time — not even \'te contactan hoy en las próximas horas\'; say what the team was asked to do (\'le pedí al equipo médico que lo revise\'), never that someone is already reviewing it, and not when they will reach out.\n- **By level:** `stat` is the ONLY level that sends the patient now — calm and unambiguous: ve a urgencias ahora, acompañado; 911 (or an ambulance) if they are alone with no one to take them, cannot move safely, or are unstable — being alone counts as unsafe transport, and they never drive themselves. Fever that is `stat`: ≥38.0 °C within 8 weeks of cytotoxic chemotherapy (or a hematologic cancer, transplant, known neutropenia), any fever within 4 weeks of CAR-T or during bispecific step-up dosing, or sepsis signs with or without fever — say it calmly: "ve a urgencias ahora y di que estás en tratamiento oncológico" (CAR-T / bispecific: call the treating center first). If you don\'t know the treatment or the last dose date, ask in the same message; unknown date on chemo counts as within the window. Fever 37.5–37.9 °C in that window with the patient well is `asap`: remeasure in 30–60 min. `asap`: do NOT lead with urgencias — it appears only in the conditional safety net (if [sign] appears or they feel much worse). `urgent`: they can stay home; urgencias only as a worsening-sign safety net. `routine`: acknowledgement, what to watch, when to write again.\n- **Keep characterizing (except `stat`):** in the same message ask the ONE question that most helps the team; in later turns keep going, one question per message, and give high-value guidance that does not prescribe (comfort measures already in their plan or indicated by the team, what to have at hand, how to monitor). Each new datum goes to the SAME open escalation, not a new one: raise the level if it makes something more serious plausible; never lower it. In `stat`, after the instruction only confirm they are on their way and accompanied.\n\n**Returns:** a RECEIPT, not a resolution — `{urgency_level, queued_for_team, safety_net_armed, safety_net_reason, team_has_reviewed, means, follow_up}`. `safety_net_armed` is `true`/`false`/`null`, where **`null` means this urgency tier gets no automatic check-in at all** (`routine`); read `follow_up` and make the fallback explicit to the patient whenever nothing is armed. `queued_for_team` says only that the ticket was written; `team_has_reviewed` is always `false` here because nothing on this path can know otherwise. Treat the escalation as OPEN and still your responsibility for the rest of the conversation: never tell the patient the team already reviewed it, and never close the topic because this call succeeded. If `queued_for_team` is `false` the team was NOT notified — say so instead of implying they were. When your `urgency_level` was out-of-enum it is coerced to `urgent` and disclosed via `{coerced_urgency, requested_urgency_level}`. A `details.category` outside the enum is dropped to `null` and disclosed via `{coerced_category, requested_category}`.\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`).',
|
|
23
23
|
'strict': true,
|
|
24
24
|
'parameters': {
|
|
25
25
|
'type': 'object',
|
|
@@ -32,13 +32,13 @@ const definition = {
|
|
|
32
32
|
'urgent',
|
|
33
33
|
'routine'
|
|
34
34
|
],
|
|
35
|
-
'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;
|
|
35
|
+
'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, fiebre ≥38.0 °C en la ventana de quimioterapia descrita en la regla de fiebre, 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; objetivo interno de valoración en horas (no se le promete al paciente), porque la demora puede crear riesgo; el paciente no va a urgencias salvo que aparezca una señal de la red de seguridad. Complicación seria sospechada pero aún no claramente inestable: dolor que empeora rápido, riesgo de infección sin fiebre ≥38.0 °C en esa ventana (p. ej. 37.5–37.9 °C con el paciente bien), síntoma neurológico nuevo sin déficit agudo.\n- `urgent` (urgente, gate equipo_tratante_hoy) — objetivo interno de revisión en 24 h (no se le promete al paciente), por encima de rutina porque la demora podría importar clínicamente; el paciente puede seguir en casa con una red de seguridad: 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.'
|
|
36
36
|
},
|
|
37
37
|
'patient_check_in': {
|
|
38
38
|
'anyOf': [
|
|
39
39
|
{
|
|
40
40
|
'type': 'string',
|
|
41
|
-
'description': 'Nullable. The check-in this patient receives ONLY IF they go silent for hours after this escalation — the safety net writes to them again so the thread is not lost. Write it now, for delivery later: in their voice and language, short, warm, asking how they are and
|
|
41
|
+
'description': 'Nullable. The check-in this patient receives ONLY IF they go silent for hours after this escalation — the safety net writes to them again so the thread is not lost. Write it now, for delivery later: in their voice and language, short, warm, asking how they are and how the symptom has evolved since. Reference what they told you in their own words. NO diagnosis, NO grades or CTCAE terms, NO internal phrasing — they never see `details`. Always send the key: pass `null` when you have nothing better than the neutral fallback, which is what gets used then — a bad check-in is worse than none.'
|
|
42
42
|
},
|
|
43
43
|
{
|
|
44
44
|
'type': 'null'
|
|
@@ -10,7 +10,7 @@ const { DEFAULT_TIMEZONE } = require('../../utils/scheduleUtils');
|
|
|
10
10
|
|
|
11
11
|
const definition = {
|
|
12
12
|
name: 'schedulePatientReminder',
|
|
13
|
-
description: '**Does:** Creates or updates one agent-authored WhatsApp reminder. An ordinary reminder (medication, appointment, exam, or other task) is allowed only when the patient explicitly asks for or explicitly accepts that reminder. Do not infer a reminder merely because the patient mentioned or scheduled something. Schedule it at the exact date and time the patient requested. Never move it earlier to precede an appointment or task unless the patient explicitly requested that offset. On any patient conversation—not only a triage—or on a proactive review, use reminder_type seguimiento when this turn decides both when to check in again and the exact patient-facing words.
|
|
13
|
+
description: '**Does:** Creates or updates one agent-authored WhatsApp reminder. An ordinary reminder (medication, appointment, exam, or other task) is allowed only when the patient explicitly asks for or explicitly accepts that reminder. Do not infer a reminder merely because the patient mentioned or scheduled something. Schedule it at the exact date and time the patient requested. Never move it earlier to precede an appointment or task unless the patient explicitly requested that offset. On any patient conversation—not only a triage—or on a proactive review, use reminder_type seguimiento when this turn decides both when to check in again and the exact patient-facing words. Schedule one whenever a later contact materially helps continuity and has a concrete utility_basis — typically: you recommended an action (OTC medication, self-care, education) whose effect shows within 24–72h; the patient already gave the deciding datum and you want to know whether the symptom comes back or crosses a threshold you named; you closed a symptom episode worth checking on; or the patient has an upcoming event (starting a treatment, an appointment, an infusion) whose outcome matters. Inactivity alone is not a basis.\\n\\nA seguimiento is a lightweight engagement plan: it is capped at 600 characters, never truncated, expires 24 hours after its chosen fire time, and cannot be created while another seguimiento is active. Any patient activity cancels it.\\n\\n**When NOT to call:** when the reminder guards unresolved clinical uncertainty, use setContingencyPlan instead; that plan owns its silence window and lifecycle. Never update a contingency-owned `plan_…-patient` reminder here.\\n\\nRequired: future patient-local reminder_date, reminder_description, reminder_type, action type, reminder_id for updates, and utility_basis for seguimiento.',
|
|
14
14
|
strict: true,
|
|
15
15
|
parameters: {
|
|
16
16
|
type: 'object',
|
|
@@ -26,7 +26,7 @@ const definition = {
|
|
|
26
26
|
reminder_type: {
|
|
27
27
|
type: 'string',
|
|
28
28
|
enum: ['medicamento', 'exámen', 'examen', 'cita', 'check_in', 'seguimiento', 'otros'],
|
|
29
|
-
description: '\'seguimiento\' = the agent chooses to contact later with an exact continuity/engagement question
|
|
29
|
+
description: '\'seguimiento\' = the agent chooses to contact later with an exact continuity/engagement question once no datum that could raise the grade is still missing; patient activity cancels it. \'medicamento\', \'cita\', \'examen\'/\'exámen\', and \'otros\' require an explicit patient request or acceptance to be reminded. A mention or scheduled event alone is insufficient. \'check_in\' is legacy compatibility; use seguimiento for new agent-owned follow-ups. Never use this tool to guard unresolved clinical uncertainty—use setContingencyPlan.',
|
|
30
30
|
},
|
|
31
31
|
reminder_action_type: {
|
|
32
32
|
type: 'string',
|
|
@@ -12,7 +12,7 @@ const MAX_PATIENT_SILENCE_MIN = 1380;
|
|
|
12
12
|
|
|
13
13
|
const definition = {
|
|
14
14
|
name: 'setContingencyPlan',
|
|
15
|
-
description: `**Does:** Manages the contingency safety net guarding an open case or cluster, selected via \`action\`. A net is a single promise: if the patient goes quiet, you write to them again.\n- \`arm\` (default): arms a new net — a CarePlan anchor plus the follow-up check-in that goes out to the patient after true silence.\n- \`resolve\`: stands an active net down because this turn's conversation addressed the guarded concern, or it is no longer clinically relevant. Requires \`plan_id\` + \`resolution_reasoning\`.\n- \`rearm\`: replaces the target's active net with this new definition (fresh silence window anchored now) — same required fields as \`arm\`. Use when new information changes the concern, window, or message.\n\n**Required inputs:** \`arm\`/\`rearm\`: \`concern\`, \`awaiting_characterization\`; at least one of \`related_case_id\` / \`related_cluster_id\`; \`patient_followup_reminder\` \`${STEP_SHAPE}\` (message + a positive \`fires_after_silence_minutes\` <= ${MAX_PATIENT_SILENCE_MIN}, plus \`window_reasoning\`: one sentence on why that wait). \`resolve\`: \`plan_id\` + \`resolution_reasoning\`.\n\n**When to call:** \`arm\` when, read against the symptom's CTCAE grade scale, a datum is still missing that could put the case at a grade above your current read. If everything needed to settle the severity is already known, do not arm. The rubric is the test, not how alarming the symptom sounds.\n- Missing information could move it from G1 to G2/G3 → arm, and wait for exactly that datum.\n- Everything needed to rule out the higher grades is already known → do not arm.\n- Serious AND fully assessed → escalate. A finished assessment needs a person, not a check-in.\n\nFind the gap in the \`grade_scale\` you graded against: which clause of the next grade up can you neither confirm nor rule out? Name that datum in \`awaiting_characterization\`, concrete enough that the next turn can tell whether their message addressed it. It
|
|
15
|
+
description: `**Does:** Manages the contingency safety net guarding an open case or cluster, selected via \`action\`. A net is a single promise: if the patient goes quiet, you write to them again.\n- \`arm\` (default): arms a new net — a CarePlan anchor plus the follow-up check-in that goes out to the patient after true silence.\n- \`resolve\`: stands an active net down because this turn's conversation addressed the guarded concern, or it is no longer clinically relevant. Requires \`plan_id\` + \`resolution_reasoning\`.\n- \`rearm\`: replaces the target's active net with this new definition (fresh silence window anchored now) — same required fields as \`arm\`. Use when new information changes the concern, window, or message.\n\n**Required inputs:** \`arm\`/\`rearm\`: \`concern\`, \`awaiting_characterization\`; at least one of \`related_case_id\` / \`related_cluster_id\`; \`patient_followup_reminder\` \`${STEP_SHAPE}\` (message + a positive \`fires_after_silence_minutes\` <= ${MAX_PATIENT_SILENCE_MIN}, plus \`window_reasoning\`: one sentence on why that wait). \`resolve\`: \`plan_id\` + \`resolution_reasoning\`.\n\n**When to call:** \`arm\` when, read against the symptom's CTCAE grade scale, a datum is still missing that could put the case at a grade above your current read. If everything needed to settle the severity is already known, do not arm. The rubric is the test, not how alarming the symptom sounds.\n- Missing information could move it from G1 to G2/G3 → arm, and wait for exactly that datum.\n- Everything needed to rule out the higher grades is already known → do not arm.\n- Serious AND fully assessed → escalate. A finished assessment needs a person, not a check-in.\n\nFind the gap in the \`grade_scale\` you graded against: which clause of the next grade up can you neither confirm nor rule out? Name that datum in \`awaiting_characterization\`, concrete enough that the next turn can tell whether their message addressed it. It is an answer the patient still owes about the symptom as it stands now — what qualifies it is that it moves the grade, not the form it takes. A pending detail that cannot change the grade (an administrative confirmation, a name, an appointment) is not a reason to arm. Neither is a watch that starts after the patient already gave that datum — whether the symptom comes back, crosses a threshold you named, or eases with what you recommended: that is continuity, not uncertainty, so schedule a \`seguimiento\` with \`schedulePatientReminder\` instead, and do not re-arm a net you just resolved to cover it. Set \`resolves_on\` to match: \`reply\` for an answer they owe, \`observation\` for an event their next message may not carry.\n\nWaiting on the care team does NOT satisfy this test — a non-routine escalation normally arms its own net, so team follow-up is not this tool's job. A \`routine\` escalation does not — it routes to the team's ticket lane and leaves no net, so on those turns the ceiling test decides on its own merits. Do not condition it on predicting whether the patient will go quiet: that cannot be known, and a net they turn out not to need never reaches them — it steps aside every time they write. \`resolve\` when the patient's message answered what the net was waiting to observe; \`rearm\` when the net's definition went stale.\n\n**When NOT to call:** \`arm\` on a target that already has an active plan (fails hard — use \`resolve\` or \`rearm\`) or whose case/cluster is closed/dissolved; \`resolve\` while the concern is still unobserved (closing the case retires its net automatically). This tool does NOT notify the care team — to reach a human, use the escalation tools.\n\n**Returns:** \`arm\`/\`rearm\` → \`{plan_id}\` (\`rearm\` adds \`superseded_plan_ids\` when it replaced a net). \`resolve\` → \`{plan_id}\`, with \`already_resolved: true\` when the net was retired earlier — an idempotent success, not an error. Fails hard with \`case_not_found_or_wrong_patient\` / \`cluster_not_found_or_wrong_patient\`, \`target_not_open\`, \`active_plan_already_exists_for_case\` / \`_for_cluster\` (arm only), \`plan_not_found_or_wrong_patient\`, \`store_failed_retry_next_turn\`, or \`stand_down_failed_retry_next_turn\`.\n\n**Side effects:** records the action on the turn trace; writes the FHIR CarePlan + patient CommunicationRequest + Provenance; arms or deactivates the dispatch row — the check-in goes out over WhatsApp only if the patient stayed silent through its window, and is pushed back each time they write. Nets also stand down when their case closes or cluster dissolves, and expire on their own after a bounded multiple of the check-in window.`,
|
|
16
16
|
strict: false,
|
|
17
17
|
parameters: {
|
|
18
18
|
type: 'object',
|
|
@@ -41,7 +41,7 @@ const definition = {
|
|
|
41
41
|
},
|
|
42
42
|
awaiting_characterization: {
|
|
43
43
|
type: 'string',
|
|
44
|
-
description: 'The datum from the term\'s grade_scale that would rule out a grade above your current read, concrete enough that the next turn can tell whether the patient\'s message addressed it — an answer they owe
|
|
44
|
+
description: 'The datum from the term\'s grade_scale that would rule out a grade above your current read, concrete enough that the next turn can tell whether the patient\'s message addressed it — an answer they still owe about the current presentation (\'temperature reading\', \'whether liquids also get stuck\'). It must be able to move the grade: an administrative confirmation, a name or an appointment does not qualify, and neither does a process confirmation about the care team or a restatement of the concern. A later recurrence, threshold or response to treatment once that datum is known is a seguimiento (schedulePatientReminder), not a net.',
|
|
45
45
|
},
|
|
46
46
|
resolves_on: {
|
|
47
47
|
type: 'string',
|
package/lib/eval/EvalProvider.js
CHANGED
|
@@ -116,6 +116,7 @@ class EvalProvider {
|
|
|
116
116
|
const promptVariables = {
|
|
117
117
|
clinical_context: clinicalData?.clinicalContext ?? '',
|
|
118
118
|
last_symptoms: clinicalData?.lastSymptoms ?? '',
|
|
119
|
+
nutrition_plan: clinicalData?.nutritionPlan ?? '',
|
|
119
120
|
current_date: buildDateAnchor(),
|
|
120
121
|
patient_memories: clinicalData?.patientMemories ?? '',
|
|
121
122
|
conversation_summaries: clinicalData?.conversationSummaries ?? '',
|
|
@@ -188,6 +188,13 @@ function gradeExtensions(assessment) {
|
|
|
188
188
|
return out;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
// Triage PRO-CTCAE Conditions share the case identifier but never carry the case status.
|
|
192
|
+
function isManagedCaseCondition(resource) {
|
|
193
|
+
return resource.resourceType === 'Condition'
|
|
194
|
+
&& Boolean(identifierValue(resource, SYMPTOM_CASE_SLUG))
|
|
195
|
+
&& SYMPTOM_CASE_EXT.STATUS in extMap(resource);
|
|
196
|
+
}
|
|
197
|
+
|
|
191
198
|
function fromFhir(resources, { patientId }) {
|
|
192
199
|
const byReference = new Map();
|
|
193
200
|
for (const resource of resources) {
|
|
@@ -196,9 +203,7 @@ function fromFhir(resources, { patientId }) {
|
|
|
196
203
|
}
|
|
197
204
|
}
|
|
198
205
|
return resources
|
|
199
|
-
.filter(
|
|
200
|
-
&& identifierValue(resource, SYMPTOM_CASE_SLUG)
|
|
201
|
-
&& SYMPTOM_CASE_EXT.STATUS in extMap(resource))
|
|
206
|
+
.filter(isManagedCaseCondition)
|
|
202
207
|
.map((condition) => reconstruct(condition, byReference, patientId))
|
|
203
208
|
.filter(Boolean);
|
|
204
209
|
}
|
|
@@ -315,4 +320,5 @@ function reconstructCondition(condition, patientId, conditionId) {
|
|
|
315
320
|
module.exports = {
|
|
316
321
|
toFhir,
|
|
317
322
|
fromFhir,
|
|
323
|
+
isManagedCaseCondition,
|
|
318
324
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const { fhirId } = require('../helpers/fhirHelper');
|
|
2
2
|
const { SYMPTOM_CASE_SLUG, SYMPTOM_ASSESSMENT_SLUG, SYMPTOM_GRADE_SLUG } = require('../constants/projectionSlugs');
|
|
3
3
|
const { getFhirStore } = require('../stores/fhirStore');
|
|
4
|
-
const { toFhir, fromFhir } = require('../projections/symptomCaseProjection');
|
|
4
|
+
const { toFhir, fromFhir, isManagedCaseCondition } = require('../projections/symptomCaseProjection');
|
|
5
5
|
const { storeResources } = require('./fhirService');
|
|
6
6
|
|
|
7
7
|
async function storeSymptomCase({ patientId, managedSymptom }) {
|
|
@@ -23,7 +23,8 @@ async function readSymptomCases({ patientId, ctcaeTerm = null, status = null, in
|
|
|
23
23
|
const reconstructed = fromFhir([...conditions, ...impressions, ...grades], { patientId });
|
|
24
24
|
const records = reconstructed.filter((managed) => !status || managed.condition.clinicalStatus === status);
|
|
25
25
|
if (!includeDiagnostics) return records;
|
|
26
|
-
|
|
26
|
+
const managedCases = conditions.filter(isManagedCaseCondition).length;
|
|
27
|
+
return { records, omittedCount: Math.max(0, managedCases - reconstructed.length) };
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
module.exports = {
|
package/lib/index.d.ts
CHANGED