@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
|
|
52
|
+
async function codesWithOutbound(match, now, { maxAgeMs = MAX_AGE_MS, limit = SCAN_LIMIT } = {}) {
|
|
51
53
|
const rows = await aggregateMessages([
|
|
52
|
-
{ $match: { ...
|
|
53
|
-
{ $group: { _id: '$numero',
|
|
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: {
|
|
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,
|
|
62
|
-
const
|
|
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
|
-
|
|
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,
|
|
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.
|