@peopl-health/nexus 5.71.2 → 5.71.4

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.
@@ -10,6 +10,10 @@ function contextVerifierLive() {
10
10
  return flagOn('CLINICAL_CONTEXT_VERIFIER_LIVE');
11
11
  }
12
12
 
13
+ function chemoContextLive() {
14
+ return flagOn('CHEMO_CONTEXT_LIVE');
15
+ }
16
+
13
17
  function profileFromMongo() {
14
18
  return flagOn('PATIENT_PROFILE_FROM_MONGO');
15
19
  }
@@ -23,6 +27,7 @@ function contextMirrorLive() {
23
27
  }
24
28
 
25
29
  module.exports = {
30
+ chemoContextLive,
26
31
  contextReadLive,
27
32
  contextMirrorLive,
28
33
  contextVerifierLive,
@@ -0,0 +1,165 @@
1
+ const { mexicoDay } = require('../../utils/dateUtils');
2
+ const { foldAccents } = require('../../utils/formatUtils');
3
+ const { safeParse, isPlainObject } = require('../../utils/jsonUtils');
4
+ const { logger } = require('../../utils/logger');
5
+
6
+ const ACTIVE_DAYS = 35;
7
+ const HEADER = 'Quimioterapia (registro de la unidad de quimioterapia; úsalo para contextualizar y priorizar síntomas, nunca para bajar la urgencia; no comuniques dosis ni ajustes de tratamiento; si el paciente dice otra cosa, créele y avisa al equipo)';
8
+ const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
9
+ const NO_ALLERGY = /^(ninguna?s?|no|negadas?|desconocidas?|(niega|no refiere|sin|no tiene|no presenta) alergias?( conocidas| a medicamentos)?)$/;
10
+ const NEGATION = /\b(sin|niega|no|ninguna?)\b/;
11
+
12
+ const isDay = (value) => typeof value === 'string' && ISO_DAY.test(value) && !Number.isNaN(Date.parse(`${value}T00:00:00Z`));
13
+ const dmy = (iso) => `${iso.slice(8, 10)}/${iso.slice(5, 7)}/${iso.slice(0, 4)}`;
14
+ const daysBetween = (from, to) => Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / 86400000);
15
+ const addDays = (iso, days) => new Date(Date.parse(`${iso}T00:00:00Z`) + days * 86400000).toISOString().slice(0, 10);
16
+ const fold = (value) => foldAccents(String(value || '')).toLowerCase().trim();
17
+ const text = (value) => (typeof value === 'string' ? value.trim() : '');
18
+ const list = (value) => (Array.isArray(value) ? value : []);
19
+ const texts = (value) => list(value).map(text).filter(Boolean);
20
+ const dayOr = (value) => (isDay(value) ? value : null);
21
+
22
+ function normalize(raw) {
23
+ const last = isPlainObject(raw.ultimaAplicacion) && isDay(raw.ultimaAplicacion.fecha) ? raw.ultimaAplicacion : null;
24
+ const nadir = Array.isArray(raw.nadirDias) && raw.nadirDias.length === 2 && raw.nadirDias.every(Number.isInteger) ? raw.nadirDias : null;
25
+ const dated = (value, keys) => (isPlainObject(value) && isDay(value.fecha) && keys.every((k) => k in value) ? value : null);
26
+ return {
27
+ ultimaAplicacion: last,
28
+ esquema: list(raw.esquema).filter(isPlainObject).filter((e) => text(e.farmaco)),
29
+ vigilancia: texts(raw.vigilancia),
30
+ nadirDias: nadir,
31
+ premedicacion: texts(raw.premedicacion),
32
+ enCasa: list(raw.enCasa).filter(isPlainObject).filter((m) => text(m.farmaco)),
33
+ inicioEsquema: dated(raw.inicioEsquema, ['anteriorAlRegistro']),
34
+ proximaSesion: dated(raw.proximaSesion, ['tipo']),
35
+ noAplicadas: list(raw.noAplicadas).filter(isDay),
36
+ reaccionInfusion: dated(raw.reaccionInfusion, ['texto']),
37
+ alergias: dated(raw.alergias, ['estado', 'texto']),
38
+ acceso: text(raw.acceso),
39
+ peso: dated(raw.peso, ['kg']),
40
+ signosAnormales: dated(raw.signosAnormales, ['hallazgos']),
41
+ ajusteProximoCiclo: dated(raw.ajusteProximoCiclo, ['valor']),
42
+ esquemaPrevio: isPlainObject(raw.esquemaPrevio) && isDay(raw.esquemaPrevio.hasta) ? { farmacos: texts(raw.esquemaPrevio.farmacos), hasta: raw.esquemaPrevio.hasta } : null,
43
+ visitaSoporte: dated(raw.visitaSoporte, ['farmacos']),
44
+ antecedentes: dated(raw.antecedentes, []),
45
+ };
46
+ }
47
+
48
+ function parseChemoContext(raw) {
49
+ const parsed = safeParse(raw, null);
50
+ return isPlainObject(parsed) && parsed.version === 1 ? normalize(parsed) : null;
51
+ }
52
+
53
+ function chemoStatus(context, today) {
54
+ const last = context.ultimaAplicacion?.fecha;
55
+ if (!last) return { active: false, days: null };
56
+ const days = daysBetween(last, today);
57
+ if (days < 0) return { active: true, days: null };
58
+ const scheduled = context.proximaSesion && context.proximaSesion.fecha >= today;
59
+ return { active: days <= ACTIVE_DAYS || Boolean(scheduled), days };
60
+ }
61
+
62
+ function regimenText(context) {
63
+ return context.esquema.map((e) => [e.farmaco, e.via, e.tiempo].map(text).filter(Boolean).join(' ')).join('; ');
64
+ }
65
+
66
+ function statusLine(last, active, days) {
67
+ const cycle = [last.ciclo ? `ciclo ${last.ciclo}` : '', last.sesion ? `sesión ${last.sesion}${last.total ? `/${last.total}` : ''}` : ''].filter(Boolean).join(', ');
68
+ const when = days === null ? `última aplicación registrada ${dmy(last.fecha)}` : `última aplicación ${dmy(last.fecha)} (hace ${days} días)`;
69
+ return active
70
+ ? `Estado: en quimioterapia activa; ${when}${cycle ? `, ${cycle}` : ''}`
71
+ : `Estado: sin aplicaciones registradas en la unidad desde ${dmy(last.fecha)} (hace ${days} días); confirma con el paciente si sigue en tratamiento`;
72
+ }
73
+
74
+ function chemoLines(context, today) {
75
+ const lines = [];
76
+ const last = context.ultimaAplicacion;
77
+ const { active, days } = chemoStatus(context, today);
78
+ if (last) {
79
+ lines.push(statusLine(last, active, days));
80
+ const regimen = regimenText(context);
81
+ if (regimen) lines.push(`${active ? 'Esquema actual' : 'Último esquema'}: ${regimen}`);
82
+ if (context.vigilancia.length) lines.push(`Vigilar por sus fármacos: ${context.vigilancia.join('; ')}`);
83
+ if (active && days !== null && context.nadirDias && days <= context.nadirDias[1]) {
84
+ const [from, to] = context.nadirDias;
85
+ lines.push(`Mayor riesgo de defensas bajas: días ${from} a ${to} después de la aplicación (${dmy(addDays(last.fecha, from))} al ${dmy(addDays(last.fecha, to))}); hoy es el día ${days}. Fiebre de 38 °C o más es urgente cualquier día`);
86
+ }
87
+ }
88
+ if (context.enCasa.length) {
89
+ lines.push(`Se fue a casa con: ${context.enCasa.map((m) => `${text(m.farmaco)}${text(m.indicacion) ? ` (${text(m.indicacion)})` : ''}${m.esteroide ? ' [esteroide: puede ocultar la fiebre]' : ''}`).join('; ')}`);
90
+ }
91
+ if (context.premedicacion.length) lines.push(`Premedicación en la unidad: ${context.premedicacion.join(', ')}`);
92
+ if (context.inicioEsquema) {
93
+ lines.push(context.inicioEsquema.anteriorAlRegistro
94
+ ? `Inicio de este esquema: antes del ${dmy(context.inicioEsquema.fecha)} (inicio del registro)`
95
+ : `Inicio de este esquema: ${dmy(context.inicioEsquema.fecha)}`);
96
+ }
97
+ if (context.proximaSesion) {
98
+ lines.push(context.proximaSesion.tipo === 'agendada'
99
+ ? `Próxima sesión: ${dmy(context.proximaSesion.fecha)} (agendada)`
100
+ : `Próxima sesión: ${dmy(context.proximaSesion.fecha)} (estimada; no la confirmes como cita)`);
101
+ }
102
+ if (context.noAplicadas.length) lines.push(`Sesiones no aplicadas en los últimos 90 días: ${context.noAplicadas.map(dmy).join(', ')}`);
103
+ if (text(context.reaccionInfusion?.texto)) lines.push(`Reacción durante la infusión registrada: ${text(context.reaccionInfusion.texto)} (${dmy(context.reaccionInfusion.fecha)})`);
104
+ if (context.acceso) lines.push(`Acceso venoso: ${context.acceso}`);
105
+ if (Number.isFinite(context.peso?.kg)) {
106
+ const pct = context.peso.cambioPct;
107
+ const change = Number.isFinite(pct) && isDay(context.peso.desde) ? `; cambio ${pct > 0 ? '+' : ''}${pct}% desde ${dmy(context.peso.desde)}` : '';
108
+ lines.push(`Peso: ${context.peso.kg} kg (${dmy(context.peso.fecha)})${change}`);
109
+ }
110
+ const findings = texts(context.signosAnormales?.hallazgos);
111
+ if (findings.length) lines.push(`Signos fuera de rango en la última sesión (${dmy(context.signosAnormales.fecha)}): ${findings.join(', ')}`);
112
+ if (context.ajusteProximoCiclo?.valor === true) lines.push(`El médico indicó un ajuste para el próximo ciclo (${dmy(context.ajusteProximoCiclo.fecha)}): no lo comuniques, lo explica el médico`);
113
+ if (context.esquemaPrevio?.farmacos.length) lines.push(`Esquema previo: ${context.esquemaPrevio.farmacos.join(', ')}, hasta ${dmy(context.esquemaPrevio.hasta)}`);
114
+ if (context.visitaSoporte) {
115
+ const drugs = texts(context.visitaSoporte.farmacos);
116
+ lines.push(`Última visita a la unidad sin quimioterapia: ${dmy(context.visitaSoporte.fecha)}${drugs.length ? ` (${drugs.join(', ')})` : ''}`);
117
+ }
118
+ const h = context.antecedentes;
119
+ if (h) {
120
+ const parts = [['diabetes', h.diabetes], ['hipertensión', h.hipertension], ['otros', h.otros], ['histología', h.histologia], ['tratamiento previo', h.tratamientoPrevio]]
121
+ .filter(([, value]) => text(value)).map(([label, value]) => `${label}: ${text(value)}`);
122
+ if (parts.length) lines.push(`Antecedentes (historia de primera vez, enfermería, ${dmy(h.fecha)}): ${parts.join('; ')}`);
123
+ }
124
+ return lines;
125
+ }
126
+
127
+ function renderChemoContext(context, today = mexicoDay(new Date())) {
128
+ if (!context) return '';
129
+ try {
130
+ const lines = chemoLines(context, today);
131
+ return lines.length ? `${HEADER}:\n- ${lines.join('\n- ')}` : '';
132
+ } catch (error) {
133
+ logger.warn('[chemoContext] section left out', { error: error.message });
134
+ return '';
135
+ }
136
+ }
137
+
138
+ function listsAllergy(existing, allergy) {
139
+ const held = fold(existing);
140
+ if (NEGATION.test(held)) return false;
141
+ return new RegExp(`(^|[^a-z0-9])${fold(allergy).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`).test(held);
142
+ }
143
+
144
+ function withChemoContext(clinical, context, today = mexicoDay(new Date())) {
145
+ if (!context) return clinical || {};
146
+ try {
147
+ const merged = { ...(clinical || {}) };
148
+ if (!text(merged.tratamiento_actual) && chemoStatus(context, today).active && context.esquema.length) {
149
+ merged.tratamiento_actual = `${regimenText(context)} (según la unidad de quimioterapia)`;
150
+ }
151
+ const allergies = context.alergias;
152
+ const nursing = text(allergies?.texto);
153
+ if (allergies?.estado === 'si' && nursing && !listsAllergy(merged.alergias_medicamentos, nursing)) {
154
+ const recorded = `${nursing} (registrado por enfermería, ${dmy(allergies.fecha)})`;
155
+ const held = text(merged.alergias_medicamentos);
156
+ merged.alergias_medicamentos = !held || NO_ALLERGY.test(fold(held)) ? recorded : `${held}; ${recorded}`;
157
+ }
158
+ return merged;
159
+ } catch (error) {
160
+ logger.warn('[chemoContext] merge skipped', { error: error.message });
161
+ return clinical || {};
162
+ }
163
+ }
164
+
165
+ module.exports = { parseChemoContext, renderChemoContext, withChemoContext };
@@ -1,10 +1,12 @@
1
1
  const { pairTriageSymptoms } = require('../helpers/triageSymptomsHelper');
2
+ const { parseChemoContext, renderChemoContext, withChemoContext } = require('../helpers/chemoContextHelper');
2
3
  const { handlePendingFunctionCalls: handlePendingFunctionCallsUtil } = require('../providers/OpenAIResponsesProviderTools');
3
4
 
4
5
  const { assembleRiskHeadline } = require('../services/riskHeadlineService');
5
6
  const { resolveNutritionPlan } = require('../services/nutritionPlanService');
6
7
  const { identityError } = require('../services/patientIdentityService');
7
8
  const { resolveProfileIdentity } = require('../services/patientProfileService');
9
+ const { chemoContextLive } = require('../flags/clinicalContextFlags');
8
10
  const { Follow_Up_ID } = require('../../config/airtableConfig');
9
11
  const runtimeConfig = require('../../config/runtimeConfig');
10
12
 
@@ -279,13 +281,14 @@ class DefaultMemoryManager extends MemoryManager {
279
281
  ? JSON.parse(record['clinical-context-json'])
280
282
  : null;
281
283
  const treatingPhysician = sanitizeInlineText(record.doctor || '', Infinity);
284
+ const chemo = chemoContextLive() ? parseChemoContext(record.quimio_context) : null;
282
285
 
283
286
  const sortedSymptoms = symptomsRecords?.filter(r => r.Date)
284
287
  .sort((a, b) => new Date(b.Date) - new Date(a.Date)).slice(0, 3) || [];
285
288
 
286
289
  const symptoms = this._parseSymptoms(sortedSymptoms);
287
290
 
288
- return { ...clinicalContext, symptoms, treatingPhysician };
291
+ return { ...clinicalContext, symptoms, treatingPhysician, chemo };
289
292
  } catch (error) {
290
293
  logger.error('[DefaultMemoryManager] Error fetching clinical context', { error: error.message });
291
294
  return null;
@@ -332,7 +335,7 @@ class DefaultMemoryManager extends MemoryManager {
332
335
 
333
336
  const contextParts = [];
334
337
  const d = data.demografia;
335
- const c = data.datos_clinicos;
338
+ const c = withChemoContext(data.datos_clinicos, data.chemo);
336
339
  if (d?.edad) contextParts.push(`Edad: ${d.edad}`);
337
340
  if (d?.sexo) contextParts.push(`Sexo: ${d.sexo}`);
338
341
  if (data.treatingPhysician) contextParts.push(`Médico tratante: ${data.treatingPhysician}`);
@@ -351,7 +354,7 @@ class DefaultMemoryManager extends MemoryManager {
351
354
  }).filter(Boolean);
352
355
 
353
356
  return {
354
- clinicalContext: [contextParts.join('. '), riskHeadline].filter(Boolean).join('\n\n'),
357
+ clinicalContext: [contextParts.join('. '), renderChemoContext(data.chemo), riskHeadline].filter(Boolean).join('\n\n'),
355
358
  lastSymptoms: symptomParts.join('; ') || 'Sin síntomas reportados recientemente',
356
359
  nutritionPlan: await resolveNutritionPlan(data.nutricion),
357
360
  };
@@ -33,6 +33,8 @@ const ticketClaims = new WriteClaimStore({ namespace: 'inbound-recovery-ticket',
33
33
  const coldTicketClaims = new WriteClaimStore({ namespace: 'inbound-recovery-cold-ticket', ttlMs: COLD_TICKET_TTL_MS, maxSize: 2000 });
34
34
 
35
35
  const FAILED_SEND = { 'statusInfo.status': 'failed', message_id: null };
36
+ // A holding bubble sent mid-turn, not an answer: a turn that dies after it leaves the patient waiting.
37
+ const BRIDGE = { 'raw.bridge': true };
36
38
  const UNANSWERED_QUESTION = 'El paciente escribió y no recibió respuesta.';
37
39
 
38
40
  async function unansweredThreads(now, { minAgeMs = MIN_AGE_MS, maxAgeMs = MAX_AGE_MS, limit = SCAN_LIMIT } = {}) {
@@ -47,24 +49,26 @@ async function unansweredThreads(now, { minAgeMs = MIN_AGE_MS, maxAgeMs = MAX_AG
47
49
  ).sort({ lastMessageAt: 1 }).limit(limit).lean();
48
50
  }
49
51
 
50
- async function codesWithAFailedSend(now, { maxAgeMs = MAX_AGE_MS, limit = SCAN_LIMIT } = {}) {
52
+ async function codesWithOutbound(match, now, { maxAgeMs = MAX_AGE_MS, limit = SCAN_LIMIT } = {}) {
51
53
  const rows = await aggregateMessages([
52
- { $match: { ...FAILED_SEND, from_me: true, createdAt: { $gte: new Date(now.getTime() - maxAgeMs) } } },
53
- { $group: { _id: '$numero', firstFailedAt: { $min: '$createdAt' } } },
54
+ { $match: { ...match, from_me: true, createdAt: { $gte: new Date(now.getTime() - maxAgeMs) } } },
55
+ { $group: { _id: '$numero', firstAt: { $min: '$createdAt' } } },
54
56
  // _id breaks the tie: without it a page of shared timestamps orders differently each pass.
55
- { $sort: { firstFailedAt: 1, _id: 1 } },
57
+ { $sort: { firstAt: 1, _id: 1 } },
56
58
  { $limit: limit },
57
59
  ]);
58
60
  return rows.map((row) => row._id);
59
61
  }
60
62
 
61
- async function candidateCodes(now, { threadBand = null, failedMaxAgeMs = MAX_AGE_MS, limit = SCAN_LIMIT } = {}) {
62
- const [threads, failedSends] = await Promise.all([
63
+ async function candidateCodes(now, { threadBand = null, outboundMaxAgeMs = MAX_AGE_MS, limit = SCAN_LIMIT } = {}) {
64
+ const outbound = { maxAgeMs: outboundMaxAgeMs, limit };
65
+ const [threads, failedSends, bridges] = await Promise.all([
63
66
  unansweredThreads(now, { ...(threadBand || {}), limit }),
64
- codesWithAFailedSend(now, { maxAgeMs: failedMaxAgeMs, limit }),
67
+ codesWithOutbound(FAILED_SEND, now, outbound),
68
+ codesWithOutbound(BRIDGE, now, outbound),
65
69
  ]);
66
- const atCeiling = threads.length >= limit || failedSends.length >= limit;
67
- const codes = [...new Set([...threads.map((thread) => thread.code), ...failedSends].filter(Boolean))];
70
+ const atCeiling = threads.length >= limit || failedSends.length >= limit || bridges.length >= limit;
71
+ const codes = [...new Set([...threads.map((thread) => thread.code), ...failedSends, ...bridges].filter(Boolean))];
68
72
  return { codes, atCeiling };
69
73
  }
70
74
 
@@ -81,7 +85,7 @@ async function stillUnanswered(code, now = new Date(), { minAgeMs = MIN_AGE_MS,
81
85
  const ageMs = now.getTime() - new Date(newest.createdAt).getTime();
82
86
  if (!Number.isFinite(ageMs) || ageMs < minAgeMs || ageMs > maxAgeMs) return null;
83
87
 
84
- const replies = await countMessages({ numero: code, from_me: true, createdAt: { $gt: newest.createdAt }, $nor: [FAILED_SEND] });
88
+ const replies = await countMessages({ numero: code, from_me: true, createdAt: { $gt: newest.createdAt }, $nor: [FAILED_SEND, BRIDGE] });
85
89
  return replies === 0 ? newest : null;
86
90
  }
87
91
 
@@ -235,7 +239,7 @@ async function sweepColdUnansweredInbound({
235
239
  if (mode === 'off') return { waiting: 0, alerted: 0 };
236
240
 
237
241
  const { codes, atCeiling } = await candidateCodes(now, {
238
- threadBand: COLD_BAND, failedMaxAgeMs: COLD_MAX_AGE_MS, limit: COLD_SCAN_LIMIT,
242
+ threadBand: COLD_BAND, outboundMaxAgeMs: COLD_MAX_AGE_MS, limit: COLD_SCAN_LIMIT,
239
243
  });
240
244
  // Answered conversations are filtered below, after the page is cut, so a full page is a page
241
245
  // that may be hiding patients behind it. Say so rather than let the tail go unseen quietly.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.71.2",
3
+ "version": "5.71.4",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",