@peopl-health/nexus 5.71.0 → 5.71.2

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,14 @@ function contextVerifierLive() {
10
10
  return flagOn('CLINICAL_CONTEXT_VERIFIER_LIVE');
11
11
  }
12
12
 
13
+ function profileFromMongo() {
14
+ return flagOn('PATIENT_PROFILE_FROM_MONGO');
15
+ }
16
+
17
+ function profileSyncOn() {
18
+ return flagOn('PATIENT_PROFILE_SYNC');
19
+ }
20
+
13
21
  function contextMirrorLive() {
14
22
  return flagOn('CLINICAL_CONTEXT_MIRROR_LIVE') && contextReadLive();
15
23
  }
@@ -18,4 +26,6 @@ module.exports = {
18
26
  contextReadLive,
19
27
  contextMirrorLive,
20
28
  contextVerifierLive,
29
+ profileFromMongo,
30
+ profileSyncOn,
21
31
  };
@@ -1,5 +1,4 @@
1
- const { resolvePatientIdentity } = require('../services/patientIdentityService');
2
- const { Follow_Up_ID } = require('../../config/airtableConfig');
1
+ const { resolveProfileIdentity } = require('../services/patientProfileService');
3
2
  const { safeParse } = require('../../utils/jsonUtils');
4
3
 
5
4
  const CONTEXT_FIELD = 'clinical-context-json';
@@ -35,7 +34,7 @@ function richestProfile(records) {
35
34
 
36
35
  async function readClinicalProfile(code) {
37
36
  try {
38
- const identity = await resolvePatientIdentity(Follow_Up_ID, code);
37
+ const identity = await resolveProfileIdentity(code);
39
38
  if (identity.kind === 'missing') return null;
40
39
  if (identity.kind !== 'resolved') return undefined;
41
40
  return richestProfile([identity.record]);
@@ -27,6 +27,7 @@ function flagsByStatus(sections) {
27
27
  absent: named(false),
28
28
  unassessed: pending.map((flag) => inlineOf(flag.flag)),
29
29
  drugSafety: pending.filter((flag) => flag.blocksDrug !== false).map((flag) => inlineOf(flag.flag)),
30
+ urgent: pending.filter((flag) => flag.urgentIfPositive === true).map((flag) => inlineOf(flag.flag)),
30
31
  };
31
32
  }
32
33
 
@@ -60,13 +61,16 @@ function proposalLine({ proposal, withheldReason, conduct, sections }) {
60
61
  const declared = [...(proposal.conditions ?? []), ...(proposal.ruledOut ?? []).map((key) => `descartado ${key}`)]
61
62
  .map((key) => inlineOf(key)?.replace(/_/g, ' ')).filter(Boolean);
62
63
  const table = formularyFor(proposal.symptom)?.label;
63
- const { unassessed, drugSafety } = flagsByStatus(sections);
64
- const lead = drugSafety.length
65
- ? `→ Propuesta para su aprobación si se confirma sin ${drugSafety.join(', ')} (tabla ${table})`
64
+ const { unassessed, drugSafety, urgent } = flagsByStatus(sections);
65
+ const gate = unassessed.filter((flag) => drugSafety.includes(flag) || urgent.includes(flag));
66
+ const call = urgent.length ? `Llamar antes de indicar: ${urgent.join(', ')}. ` : '';
67
+ const lead = gate.length
68
+ ? `→ ${call}Propuesta para su aprobación si se confirma sin ${gate.join(', ')} (tabla ${table})`
66
69
  : `→ Propuesta para su aprobación (tabla ${table})`;
70
+ const armedConduct = urgent.length ? inlineOf(conduct) : null;
67
71
  return [
68
72
  `${lead}: ${sentence(drugs.join('; '))}`,
69
- unassessed.length && 'Si algo de lo pendiente es positivo: valoración presencial.',
73
+ armedConduct ? sentence(armedConduct) : unassessed.length && 'Si algo de lo pendiente es positivo: valoración presencial.',
70
74
  declared.length && `Verificar: ${sentence(declared.join(', '))}`,
71
75
  ].filter(Boolean).join(' ');
72
76
  }
@@ -3,7 +3,8 @@ const { handlePendingFunctionCalls: handlePendingFunctionCallsUtil } = require('
3
3
 
4
4
  const { assembleRiskHeadline } = require('../services/riskHeadlineService');
5
5
  const { resolveNutritionPlan } = require('../services/nutritionPlanService');
6
- const { resolvePatientIdentity, identityError } = require('../services/patientIdentityService');
6
+ const { identityError } = require('../services/patientIdentityService');
7
+ const { resolveProfileIdentity } = require('../services/patientProfileService');
7
8
  const { Follow_Up_ID } = require('../../config/airtableConfig');
8
9
  const runtimeConfig = require('../../config/runtimeConfig');
9
10
 
@@ -269,7 +270,7 @@ class DefaultMemoryManager extends MemoryManager {
269
270
 
270
271
  async _getClinicalContext(whatsappId) {
271
272
  try {
272
- const identity = await resolvePatientIdentity(Follow_Up_ID, whatsappId);
273
+ const identity = await resolveProfileIdentity(whatsappId);
273
274
  if (identity.kind === 'ambiguous') throw identityError(identity.kind);
274
275
  const record = identity.record || {};
275
276
  const symptomsRecords = await getRecordByFilter(Follow_Up_ID, 'all_triages_last_month', fieldEquals('whatsapp_id', whatsappId));
@@ -36,14 +36,16 @@ function settleProposal(parsed) {
36
36
  const items = Array.isArray(raw?.items) ? raw.items : [];
37
37
  const stated = trimmed(parsed?.proposalWithheldReason);
38
38
  const conduct = stated && !drugsNamedIn(stated).length ? stated : null;
39
- if (withheld) return { proposal: null, withheldReason: withheld, conduct, strippedItems: items.length };
40
- if (!items.length) return { proposal: null, withheldReason: null, conduct, strippedItems: 0 };
39
+ const { present, urgent } = flagsByStatus(parsed);
40
+ const callFirst = urgent.length && !present.length ? `llamar antes de indicar: ${urgent.join(', ')}` : null;
41
+ if (withheld) return { proposal: null, withheldReason: [withheld, callFirst].filter(Boolean).join('; '), conduct, strippedItems: items.length };
42
+ if (!items.length) return { proposal: null, withheldReason: callFirst, conduct, strippedItems: 0 };
41
43
 
42
44
  const conditions = Array.isArray(raw.conditions) ? raw.conditions : [];
43
45
  const ruledOut = Array.isArray(raw.ruledOut) ? raw.ruledOut : [];
44
46
  const proposal = { symptom: raw.symptom, items, conditions, ruledOut };
45
47
  const { valid, issues } = validateProposal(proposal, { conditions, ruledOut });
46
- return valid ? { proposal, withheldReason: null, strippedItems: 0 } : { invalid: issues };
48
+ return valid ? { proposal, withheldReason: null, conduct, strippedItems: 0 } : { invalid: issues };
47
49
  }
48
50
 
49
51
  const textList = (value) => (Array.isArray(value) ? value.map(trimmed).filter(Boolean) : []);
@@ -155,7 +157,11 @@ async function generateEscalationDraft({
155
157
 
156
158
  const issues = settled.flatMap((entry) => entry.invalid ?? []);
157
159
  if (!issues.length) issues.push(...crossSymptomIssues(proposals));
158
- const confirmed = settled.filter((entry) => entry.proposal && !flagsByStatus(entry.block).drugSafety.length).map((entry) => entry.proposal);
160
+ const isConfirmed = ({ block, proposal }) => {
161
+ const { drugSafety, urgent } = flagsByStatus(block);
162
+ return proposal && !drugSafety.length && !urgent.length;
163
+ };
164
+ const confirmed = settled.filter(isConfirmed).map((entry) => entry.proposal);
159
165
  const patientDrugs = unproposedDrugsIn(patientMessage, confirmed);
160
166
  if (!issues.length && patientDrugs.length) {
161
167
  issues.push(`el mensaje al paciente menciona ${patientDrugs.join(', ')}, que no está en la propuesta de tratamiento`);
@@ -0,0 +1,145 @@
1
+ const { isShadowRun } = require('../helpers/shadowHelper');
2
+ const { profileFromMongo } = require('../flags/clinicalContextFlags');
3
+ const { Estado_General_ID, Follow_Up_ID } = require('../../config/airtableConfig');
4
+ const runtimeConfig = require('../../config/runtimeConfig');
5
+ const { MATCH_ALL } = require('../../helpers/airtableFormulaHelper');
6
+ const { getContextSyncState } = require('../../models/contextSyncStateModel');
7
+ const { getPatientProfile } = require('../../models/patientProfileModel');
8
+ const { getRecordByFilter } = require('../../services/airtableService');
9
+ const { inlineOf } = require('../../utils/coercionUtils');
10
+ const { createIntervalSweeper } = require('../../utils/intervalSweeper');
11
+ const { logger } = require('../../utils/logger');
12
+ const { resolvePatientIdentity } = require('./patientIdentityService');
13
+
14
+ const TABLE = 'estado_general';
15
+ const FIELDS = ['whatsapp_id', 'clinical-context-json', 'last_modified_clinical_context', 'doctor', 'tipo', 'source', 'quimio_context'];
16
+ const JOB = 'patient-profile';
17
+ const MS_PER_MINUTE = 60 * 1000;
18
+ const DEFAULT_INTERVAL_MS = 2 * MS_PER_MINUTE;
19
+ const OVERLAP_MS = 5 * MS_PER_MINUTE;
20
+ const RECONCILE_MS = 24 * 60 * MS_PER_MINUTE;
21
+ const MAX_REMOVAL_SHARE = 0.05;
22
+ const MISSING_TTL_MS = 2 * MS_PER_MINUTE;
23
+ const missingUntil = new Map();
24
+
25
+ const intervalMs = () => Number(runtimeConfig.get('PATIENT_PROFILE_SYNC_INTERVAL_MS', DEFAULT_INTERVAL_MS));
26
+
27
+ function changedSince(watermark) {
28
+ const from = new Date(new Date(watermark).getTime() - OVERLAP_MS).toISOString();
29
+ return `IS_AFTER(LAST_MODIFIED_TIME(), DATETIME_PARSE('${from}'))`;
30
+ }
31
+
32
+ function toProfile(row, syncedAt) {
33
+ const fields = Object.fromEntries(FIELDS.filter((f) => row[f] !== undefined).map((f) => [f, row[f]]));
34
+ return { recordId: row._recordId, whatsappId: inlineOf(row.whatsapp_id) || null, fields, syncedAt };
35
+ }
36
+
37
+ async function readState() {
38
+ return (await getContextSyncState().findOne({ job: JOB }).lean()) || { job: JOB, watermark: null, fullReconciledAt: null };
39
+ }
40
+
41
+ async function saveState({ startedAt, written, reason = null, reconciled = false, advance = true }) {
42
+ await getContextSyncState().updateOne(
43
+ { job: JOB },
44
+ {
45
+ $set: { job: JOB, lastRunAt: startedAt, lastWritten: written, lastReason: reason, ...(reconciled ? { fullReconciledAt: startedAt } : {}) },
46
+ ...(advance ? { $max: { watermark: startedAt } } : {}),
47
+ },
48
+ { upsert: true },
49
+ );
50
+ }
51
+
52
+ async function upsertProfiles(rows, syncedAt) {
53
+ const usable = rows.filter((row) => /^rec[A-Za-z0-9]+$/.test(row._recordId || ''));
54
+ if (!usable.length) return 0;
55
+ await getPatientProfile().bulkWrite(usable.map((row) => {
56
+ const profile = toProfile(row, syncedAt);
57
+ return { updateOne: { filter: { recordId: profile.recordId }, update: { $set: profile }, upsert: true } };
58
+ }), { ordered: false });
59
+ return usable.length;
60
+ }
61
+
62
+ async function removeUnseen(seen, startedAt) {
63
+ const Model = getPatientProfile();
64
+ const unseen = { recordId: { $nin: seen }, syncedAt: { $lt: startedAt } };
65
+ const stale = await Model.countDocuments(unseen);
66
+ const stored = await Model.countDocuments({});
67
+ if (stale > Math.max(1, stored * MAX_REMOVAL_SHARE)) {
68
+ logger.error('[PatientProfileSync] A full read would remove too many profiles; nothing removed', { stale, stored });
69
+ return { removed: 0, reason: 'removal-skipped' };
70
+ }
71
+ return { removed: stale ? (await Model.deleteMany(unseen)).deletedCount || 0 : 0, reason: null };
72
+ }
73
+
74
+ async function syncPatientProfiles({ now = new Date() } = {}) {
75
+ const state = await readState();
76
+ const empty = (await getPatientProfile().countDocuments({})) === 0;
77
+ const reconcile = empty || !state.watermark || !state.fullReconciledAt || now - new Date(state.fullReconciledAt) >= RECONCILE_MS;
78
+ let rows;
79
+ try {
80
+ rows = await getRecordByFilter(Estado_General_ID, TABLE, reconcile ? MATCH_ALL : changedSince(state.watermark), null, FIELDS, true);
81
+ } catch (error) {
82
+ await saveState({ startedAt: now, written: 0, reason: 'airtable-unavailable', advance: false });
83
+ throw error;
84
+ }
85
+ const written = await upsertProfiles(rows, now);
86
+ const { removed, reason } = reconcile ? await removeUnseen(rows.map((row) => row._recordId).filter(Boolean), now) : { removed: 0, reason: null };
87
+ await saveState({ startedAt: now, written, reason, reconciled: reconcile });
88
+ return { ok: true, written, removed, reconciled: reconcile, reason };
89
+ }
90
+
91
+ let storeReady = false;
92
+
93
+ async function profileStoreReady() {
94
+ if (!storeReady) storeReady = Boolean((await readState()).fullReconciledAt);
95
+ return storeReady;
96
+ }
97
+
98
+ async function fromAirtable(code, docs = []) {
99
+ const identity = await resolvePatientIdentity(Estado_General_ID, code);
100
+ if (identity.kind !== 'resolved' || isShadowRun()) return identity;
101
+ const keep = async () => {
102
+ await upsertProfiles([{ ...identity.record, _recordId: identity.recordId }], new Date());
103
+ const gone = docs.map((d) => d.recordId).filter((id) => id !== identity.recordId);
104
+ if (!gone.length) return;
105
+ logger.warn('[PatientProfile] Removing stored profiles Airtable no longer has for this number', { removed: gone, kept: identity.recordId });
106
+ await getPatientProfile().deleteMany({ recordId: { $in: gone } });
107
+ };
108
+ await keep().catch((error) => logger.warn('[PatientProfile] Could not keep a profile read from Airtable', { error: error.message }));
109
+ return identity;
110
+ }
111
+
112
+ async function resolveProfileIdentity(code) {
113
+ if (!profileFromMongo()) return resolvePatientIdentity(Follow_Up_ID, code);
114
+ if (typeof code !== 'string' || !code.trim()) return resolvePatientIdentity(Estado_General_ID, code);
115
+ const key = inlineOf(code);
116
+ if ((missingUntil.get(key) || 0) > Date.now()) return { kind: 'missing' };
117
+ let docs;
118
+ try {
119
+ if (!(await profileStoreReady())) return resolvePatientIdentity(Estado_General_ID, code);
120
+ docs = await getPatientProfile().find({ whatsappId: key }).lean();
121
+ } catch (error) {
122
+ logger.warn('[PatientProfile] Mongo unavailable; reading Airtable', { error: error.message });
123
+ return resolvePatientIdentity(Estado_General_ID, code);
124
+ }
125
+ if (docs.length === 1) return { kind: 'resolved', recordId: docs[0].recordId, record: { ...docs[0].fields } };
126
+ const identity = await fromAirtable(code, docs);
127
+ if (identity.kind === 'missing') missingUntil.set(key, Date.now() + MISSING_TTL_MS);
128
+ return identity;
129
+ }
130
+
131
+ const sweeper = createIntervalSweeper({
132
+ label: '[PatientProfileSync]',
133
+ sweep: () => syncPatientProfiles(),
134
+ defaultIntervalMs: intervalMs,
135
+ });
136
+
137
+ function startPatientProfileSync(options) {
138
+ const timer = sweeper.start(options);
139
+ syncPatientProfiles().catch((error) => logger.error('[PatientProfileSync] First sync failed', { error: error.message }));
140
+ return timer;
141
+ }
142
+
143
+ const stopPatientProfileSync = () => sweeper.stop();
144
+
145
+ module.exports = { resolveProfileIdentity, syncPatientProfiles, startPatientProfileSync, stopPatientProfileSync };
@@ -10,6 +10,7 @@ const { stopReviewDraftSweep } = require('../services/reviewDraftSweepService');
10
10
  const { stopReviewAlertSweep } = require('../services/reviewAlertSweepService');
11
11
  const { clearInteractiveRoutes } = require('../core/interactiveRouteService');
12
12
  const { _resetDefaultInstance } = require('../core/NexusMessaging');
13
+ const { stopPatientProfileSync } = require('../clinical/services/patientProfileService');
13
14
  const { _resetSafetyNetConfig } = require('../clinical/config/safetyNetConfig');
14
15
  const { _resetSafetyNetState } = require('../clinical/services/pausedSafetyNetService');
15
16
  const { stopSafetyNetSweep } = require('../clinical/services/safetyNetPledgeService');
@@ -24,6 +25,7 @@ const SWEEP_STOP_MS = 2000;
24
25
  function stopAllSweeps({ timeoutMs = SWEEP_STOP_MS, label = '[lifecycle]' } = {}) {
25
26
  const stopped = Promise.allSettled([
26
27
  stopSafetyNetSweep(), stopRecommendationRecoverySweep(), stopInboundRecoverySweep(), stopReviewDraftSweep(), stopReviewAlertSweep(),
28
+ stopPatientProfileSync(),
27
29
  ]);
28
30
  return withTimeout(stopped, timeoutMs, 'sweeps.stop')
29
31
  .catch(error => logger.warn(`${label} A sweep was still running`, { error: error.message }));
package/lib/index.js CHANGED
@@ -34,6 +34,8 @@ const { startRecommendationRecoverySweep } = require('./clinical/services/recomm
34
34
  const { startInboundRecoverySweep } = require('./services/inboundRecoveryService');
35
35
  const { startReviewDraftSweep } = require('./services/reviewDraftSweepService');
36
36
  const { startReviewAlertSweep } = require('./services/reviewAlertSweepService');
37
+ const { startPatientProfileSync } = require('./clinical/services/patientProfileService');
38
+ const { profileSyncOn } = require('./clinical/flags/clinicalContextFlags');
37
39
  const { requestIdMiddleware, getRequestId } = require('./middleware/requestId');
38
40
  const { QueueAdapter, LocalQueueAdapter, RedisQueueAdapter, createQueueAdapter, registerQueueAdapter } = require('./queue');
39
41
  const { DrainingError } = require('./core/BatchingManager');
@@ -215,6 +217,15 @@ class Nexus {
215
217
  logger.warn('Warning: failed to arm the clinical review draft sweep:', e?.message || e);
216
218
  }
217
219
 
220
+ try {
221
+ if (profileSyncOn()) {
222
+ startPatientProfileSync();
223
+ logger.info('[Nexus] patient profile sync armed');
224
+ }
225
+ } catch (e) {
226
+ logger.warn('Warning: failed to arm the patient profile sync:', e?.message || e);
227
+ }
228
+
218
229
  try {
219
230
  if (options.reviewAlerts?.enabled === true) {
220
231
  startReviewAlertSweep({ intervalMs: options.reviewAlerts.intervalMs });
@@ -0,0 +1,24 @@
1
+ const mongoose = require('mongoose');
2
+
3
+ const { getDb, getModelDatabase } = require('../config/mongoConfig');
4
+
5
+ const patientProfileSchema = new mongoose.Schema({
6
+ recordId: { type: String, required: true },
7
+ whatsappId: { type: String, default: null },
8
+ fields: { type: mongoose.Schema.Types.Mixed, default: {} },
9
+ syncedAt: { type: Date, default: null },
10
+ }, { timestamps: true, minimize: false });
11
+
12
+ patientProfileSchema.index({ recordId: 1 }, { unique: true });
13
+ patientProfileSchema.index({ whatsappId: 1 });
14
+
15
+ const getPatientProfile = () => {
16
+ const dbName = getModelDatabase('PatientProfile');
17
+ const db = dbName ? getDb(dbName) : mongoose;
18
+ return db.models.PatientProfile || db.model('PatientProfile', patientProfileSchema);
19
+ };
20
+
21
+ module.exports = {
22
+ getPatientProfile,
23
+ patientProfileSchema,
24
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.71.0",
3
+ "version": "5.71.2",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",