@peopl-health/nexus 5.69.0-dev.12624 → 5.69.0

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.
@@ -21,10 +21,12 @@ function latestAlarm(review) {
21
21
  function flagsByStatus(sections) {
22
22
  const flags = (Array.isArray(sections?.redFlags) ? sections.redFlags : []).filter((flag) => inlineOf(flag?.flag));
23
23
  const named = (present) => flags.filter((flag) => flag.present === present).map((flag) => inlineOf(flag.flag));
24
+ const pending = flags.filter((flag) => typeof flag.present !== 'boolean');
24
25
  return {
25
26
  present: named(true),
26
27
  absent: named(false),
27
- unassessed: flags.filter((flag) => typeof flag.present !== 'boolean').map((flag) => inlineOf(flag.flag)),
28
+ unassessed: pending.map((flag) => inlineOf(flag.flag)),
29
+ drugSafety: pending.filter((flag) => flag.blocksDrug !== false).map((flag) => inlineOf(flag.flag)),
28
30
  };
29
31
  }
30
32
 
@@ -49,13 +51,24 @@ function drugOf(symptom, item) {
49
51
 
50
52
  const proposedDrugs = ({ proposal }) => (proposal?.items ?? []).map((item) => drugOf(proposal.symptom, item)).filter(Boolean);
51
53
 
52
- function proposalLine({ proposal, withheldReason }) {
54
+ function proposalLine({ proposal, withheldReason, conduct, sections }) {
53
55
  const drugs = proposedDrugs({ proposal });
54
- if (!drugs.length) return `→ Sin propuesta de tratamiento: ${sentence(inlineOf(withheldReason) || 'requiere valoración médica')}`;
56
+ if (!drugs.length) {
57
+ const why = [withheldReason, conduct].map(inlineOf).filter(Boolean).map(sentence).join(' ');
58
+ return `→ Sin propuesta de tratamiento: ${why || 'requiere valoración médica.'}`;
59
+ }
55
60
  const declared = [...(proposal.conditions ?? []), ...(proposal.ruledOut ?? []).map((key) => `descartado ${key}`)]
56
61
  .map((key) => inlineOf(key)?.replace(/_/g, ' ')).filter(Boolean);
57
62
  const table = formularyFor(proposal.symptom)?.label;
58
- return [`→ Propuesta para su aprobación (tabla ${table}): ${sentence(drugs.join('; '))}`, declared.length && `Verificar: ${sentence(declared.join(', '))}`].filter(Boolean).join(' ');
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})`
66
+ : `→ Propuesta para su aprobación (tabla ${table})`;
67
+ return [
68
+ `${lead}: ${sentence(drugs.join('; '))}`,
69
+ unassessed.length && 'Si algo de lo pendiente es positivo: valoración presencial.',
70
+ declared.length && `Verificar: ${sentence(declared.join(', '))}`,
71
+ ].filter(Boolean).join(' ');
59
72
  }
60
73
 
61
74
  function symptomParagraph(entry, fallbackSymptom) {
@@ -13,7 +13,7 @@ const { logger } = require('../../utils/logger');
13
13
  const { arrayArg, safeParse } = require('../../utils/jsonUtils');
14
14
  const { withTiming } = require('../../utils/tracingDecorator');
15
15
 
16
- const { crossSymptomIssues, unproposedDrugsIn, validateProposal } = require('./treatmentProposalService');
16
+ const { crossSymptomIssues, drugsNamedIn, unproposedDrugsIn, validateProposal } = require('./treatmentProposalService');
17
17
 
18
18
  const CONVERSATION_TURNS = 40;
19
19
  const TRIAGE_LIMIT = 3;
@@ -22,11 +22,10 @@ const CALL_LOG_LIMIT = 10;
22
22
  const trimmed = (value) => String(value ?? '').trim();
23
23
 
24
24
  function withholdReason(sections) {
25
- const { present, unassessed } = flagsByStatus(sections);
25
+ const { present } = flagsByStatus(sections);
26
26
  const modifiers = (Array.isArray(sections?.treatmentModifiers) ? sections.treatmentModifiers : []).map(trimmed).filter(Boolean);
27
27
  const reasons = [];
28
28
  if (present.length) reasons.push(present.length === 1 ? 'hay una bandera roja presente' : 'hay banderas rojas presentes');
29
- if (unassessed.length) reasons.push(unassessed.length === 1 ? 'hay una bandera roja sin evaluar' : 'hay banderas rojas sin evaluar');
30
29
  if (modifiers.length) reasons.push(`condición que cambia el algoritmo (${modifiers.join(', ')})`);
31
30
  return reasons.join('; ') || null;
32
31
  }
@@ -35,8 +34,10 @@ function settleProposal(parsed) {
35
34
  const withheld = withholdReason(parsed);
36
35
  const raw = parsed?.treatmentProposal;
37
36
  const items = Array.isArray(raw?.items) ? raw.items : [];
38
- if (withheld) return { proposal: null, withheldReason: withheld, strippedItems: items.length };
39
- if (!items.length) return { proposal: null, withheldReason: trimmed(parsed?.proposalWithheldReason) || null, strippedItems: 0 };
37
+ const stated = trimmed(parsed?.proposalWithheldReason);
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 };
40
41
 
41
42
  const conditions = Array.isArray(raw.conditions) ? raw.conditions : [];
42
43
  const ruledOut = Array.isArray(raw.ruledOut) ? raw.ruledOut : [];
@@ -154,7 +155,8 @@ async function generateEscalationDraft({
154
155
 
155
156
  const issues = settled.flatMap((entry) => entry.invalid ?? []);
156
157
  if (!issues.length) issues.push(...crossSymptomIssues(proposals));
157
- const patientDrugs = unproposedDrugsIn(patientMessage, proposals);
158
+ const confirmed = settled.filter((entry) => entry.proposal && !flagsByStatus(entry.block).drugSafety.length).map((entry) => entry.proposal);
159
+ const patientDrugs = unproposedDrugsIn(patientMessage, confirmed);
158
160
  if (!issues.length && patientDrugs.length) {
159
161
  issues.push(`el mensaje al paciente menciona ${patientDrugs.join(', ')}, que no está en la propuesta de tratamiento`);
160
162
  }
@@ -167,7 +169,7 @@ async function generateEscalationDraft({
167
169
  const doctorText = composeDoctorBody({
168
170
  alarm: alarms[alarms.length - 1] ?? null,
169
171
  shared: parsed,
170
- symptoms: settled.map(({ block, proposal, withheldReason }) => ({ sections: block, proposal, withheldReason })),
172
+ symptoms: settled.map(({ block, proposal, withheldReason, conduct }) => ({ sections: block, proposal, withheldReason, conduct })),
171
173
  });
172
174
 
173
175
  if (!doctorText || !patientMessage) {
@@ -6,10 +6,8 @@ const { getMessages } = require('../../services/messageService');
6
6
  const { DEFAULT_TIMEZONE, isWithinHourWindow } = require('../../utils/scheduleUtils');
7
7
  const { logger } = require('../../utils/logger');
8
8
  const { botIsPaused } = require('../../helpers/pausedThreadHelper');
9
- const { Thread } = require('../../models/threadModel');
10
9
  const { PATIENT_CONTACT_WINDOW } = require('./contingencyDispatchService');
11
10
  const { hasPendingReminderForPatient } = require('./clinicalAirtableService');
12
- const { WriteClaimStore } = require('./writeClaimStore');
13
11
 
14
12
  const TABLE = 'weekly_questionnaires';
15
13
  const VIEW = 'seguimiento_72hrs_principal';
@@ -51,11 +49,6 @@ const AUDIT = {
51
49
  turnId: 'seguimiento_turn_id',
52
50
  };
53
51
 
54
- const WEEKLY_SOURCE = 'weekly';
55
- const SCOPE_SOURCE = 'patient_scope';
56
-
57
- const sweepClaims = new WriteClaimStore({ namespace: 'outreach_sweep', ttlMs: 8 * 24 * 60 * 60 * 1000 });
58
-
59
52
  const UNDELIVERED_STATUSES = ['failed', 'undelivered'];
60
53
  const CONTACT_SENT = {
61
54
  from_me: true,
@@ -210,56 +203,6 @@ function buildCandidates(rows, { now, cadence = 'daily', mode = 'continuity' })
210
203
  return { candidates, targetDays, weekday, cadence, mode };
211
204
  }
212
205
 
213
- function firstWord(value) {
214
- return String(value || '').trim().split(/\s+/)[0];
215
- }
216
-
217
- function buildScopeCandidates({ threads, excludedCodes }) {
218
- const threadsByCode = new Map();
219
- for (const thread of threads) threadsByCode.set(thread.code, [...(threadsByCode.get(thread.code) || []), thread]);
220
-
221
- const candidates = [];
222
- for (const [code, docs] of threadsByCode) {
223
- const [thread] = docs.sort((a, b) => String(a._id).localeCompare(String(b._id)));
224
- const name = firstWord(thread.nombre);
225
- const names = new Set(docs.map((doc) => firstWord(doc.nombre).toLowerCase()));
226
- if (excludedCodes.has(code) || !name || names.size !== 1 || !thread.patient_id) continue;
227
- candidates.push({
228
- source: SCOPE_SOURCE,
229
- patientId: thread.patient_id,
230
- recordId: String(thread._id),
231
- code,
232
- name,
233
- reason: 'no_triage_history',
234
- rows: [],
235
- brief: {
236
- reason: 'no hay un triaje contestado que sirva como ancla clínica',
237
- lastTriageAt: null,
238
- maxGrade: null,
239
- positiveSymptoms: [],
240
- },
241
- });
242
- }
243
- return candidates;
244
- }
245
-
246
- async function loadScopeCandidates({ now, viewRows, patientScope }) {
247
- const weekStart = startOfLocalWeek(now).toISOString();
248
- const [threads, weekRows] = await Promise.all([
249
- Thread.find({ code: { $in: patientScope } }, { code: 1, nombre: 1, patient_id: 1 }).lean(),
250
- airtableService.getRecordByFilter(
251
- Symptoms_ID, TABLE, `NOT(IS_BEFORE({${F.createdAt}}, DATETIME_PARSE("${weekStart}")))`, null, [F.whatsapp],
252
- ),
253
- ]);
254
- const excludedCodes = new Set([...viewRows, ...(weekRows || [])]
255
- .map((row) => String(first(row[F.whatsapp]) || '').trim()));
256
- return buildScopeCandidates({ threads: threads || [], excludedCodes });
257
- }
258
-
259
- function sweepClaimKey(candidate, now) {
260
- return `${candidate.code}:${startOfLocalWeek(now).format('YYYY-MM-DD')}`;
261
- }
262
-
263
206
  function opsOptedOut(candidate, { now }) {
264
207
  const weekStart = startOfLocalWeek(now);
265
208
  for (const row of candidate.rows) {
@@ -295,12 +238,9 @@ async function rejectionReason(candidate, {
295
238
  } = {}) {
296
239
  const opsReason = opsOptedOut(candidate, { now });
297
240
  if (opsReason) return opsReason;
298
- if (await Thread.exists({ code: candidate.code, outreachOptOut: true })) return 'outreach_opt_out';
299
241
  if (await botIsPaused(candidate.code, { label: '[outreachSweep]' })) return 'bot_paused';
300
242
  if (!includePreviouslySwept) {
301
- const sweptReason = candidate.source === SCOPE_SOURCE
302
- ? (await sweepClaims.has(sweepClaimKey(candidate, now)) ? 'already_swept_this_week' : null)
303
- : priorSweepReason(candidate);
243
+ const sweptReason = priorSweepReason(candidate);
304
244
  if (sweptReason) return sweptReason;
305
245
 
306
246
  const since = mode === 'monday_triage_recovery'
@@ -419,10 +359,8 @@ async function enrichBrief(candidate) {
419
359
  // so a second overlapping sweep sees this one and stands down. A turn that then dies leaves the
420
360
  // patient uncontacted for the week, which is the safe direction: a free-text send carries no
421
361
  // contentSid and no idempotency key, so nothing downstream would catch a duplicate.
422
- async function claimCandidate({ candidate, weekdayLabel, dryRun, now }) {
362
+ async function claimCandidate({ candidate, weekdayLabel, dryRun }) {
423
363
  if (dryRun) return true;
424
- if ((await sweepClaims.acquire(sweepClaimKey(candidate, now))) !== 'acquired') return false;
425
- if (candidate.source === SCOPE_SOURCE) return true;
426
364
  try {
427
365
  const claimed = await airtableService.updateRecordByFilter(
428
366
  Symptoms_ID,
@@ -445,7 +383,7 @@ async function claimCandidate({ candidate, weekdayLabel, dryRun, now }) {
445
383
  }
446
384
 
447
385
  async function recordOutcome({ candidate, outcome, weekdayLabel, dryRun }) {
448
- if (dryRun || candidate.source === SCOPE_SOURCE) return;
386
+ if (dryRun) return;
449
387
  // Three outcomes, not two: a `write` whose delivery never happened is neither "escrito" nor a
450
388
  // deliberate hold, and collapsing it into either would hide the failure from the ops queue.
451
389
  let stamp;
@@ -484,7 +422,6 @@ async function runOutreachSweep({
484
422
  includePreviouslySwept = false,
485
423
  cadence = 'daily',
486
424
  mode = 'continuity',
487
- source = WEEKLY_SOURCE,
488
425
  runOutreachTurn,
489
426
  } = {}) {
490
427
  if (typeof runOutreachTurn !== 'function') {
@@ -506,12 +443,6 @@ async function runOutreachSweep({
506
443
  if (!['continuity', 'monday_triage_recovery'].includes(mode)) {
507
444
  throw new Error('mode must be continuity or monday_triage_recovery');
508
445
  }
509
- if (![WEEKLY_SOURCE, SCOPE_SOURCE].includes(source)) {
510
- throw new Error('source must be weekly or patient_scope');
511
- }
512
- if (source === SCOPE_SOURCE && (mode !== 'continuity' || cadence !== 'daily' || !Array.isArray(patientScope))) {
513
- throw new Error('the patient_scope source requires continuity mode, daily cadence and a patientScope');
514
- }
515
446
 
516
447
  const localWeekday = moment.tz(now, DEFAULT_TIMEZONE).isoWeekday();
517
448
  if (mode === 'monday_triage_recovery' && localWeekday !== 1) {
@@ -529,9 +460,7 @@ async function runOutreachSweep({
529
460
  const rows = await airtableService.getRecordByFilter(
530
461
  Symptoms_ID, TABLE, 'TRUE()', mode === 'monday_triage_recovery' ? null : VIEW, Object.values(F), true,
531
462
  );
532
- const { candidates, targetDays, weekday } = source === SCOPE_SOURCE
533
- ? { candidates: await loadScopeCandidates({ now, viewRows: rows || [], patientScope }), targetDays: [], weekday: localWeekday }
534
- : buildCandidates(rows || [], { now, cadence, mode });
463
+ const { candidates, targetDays, weekday } = buildCandidates(rows || [], { now, cadence, mode });
535
464
  if (cadence === 'legacy' && !targetDays.length) {
536
465
  logger.info('[outreachSweep] not a sweep day — nothing to do', { weekday });
537
466
  return { ran: false, weekday, reason: 'not_a_sweep_day', counts: {} };
@@ -569,7 +498,7 @@ async function runOutreachSweep({
569
498
  const weekdayLabel = moment.tz(now, DEFAULT_TIMEZONE).locale('es').format('dddd');
570
499
  const decisions = await mapWithConcurrency(admitted, concurrency, async (candidate) => {
571
500
  const brief = await enrichBrief(candidate);
572
- if (!dryRun && !await claimCandidate({ candidate, weekdayLabel, dryRun, now })) {
501
+ if (!dryRun && !await claimCandidate({ candidate, weekdayLabel, dryRun })) {
573
502
  counts.rejected++;
574
503
  rejectedBy.claim_lost = (rejectedBy.claim_lost || 0) + 1;
575
504
  return { code: candidate.code, recordId: candidate.recordId, decision: 'skipped', holdReason: 'claim_lost' };
@@ -620,13 +549,12 @@ async function runOutreachSweep({
620
549
  });
621
550
 
622
551
  logger.info('[outreachSweep] complete', { dryRun, counts, rejectedBy });
623
- return { ran: true, weekday, cadence, mode, source, targetDays, dryRun, counts, rejectedBy, decisions };
552
+ return { ran: true, weekday, cadence, mode, targetDays, dryRun, counts, rejectedBy, decisions };
624
553
  }
625
554
 
626
555
  module.exports = {
627
556
  runOutreachSweep,
628
557
  buildCandidates,
629
- buildScopeCandidates,
630
558
  rejectionReason,
631
559
  priorSweepReason,
632
560
  TARGET_DAYS_BY_WEEKDAY,
@@ -638,5 +566,4 @@ module.exports = {
638
566
  VIEW,
639
567
  F,
640
568
  AUDIT,
641
- sweepClaims,
642
569
  };
package/lib/index.d.ts CHANGED
@@ -501,7 +501,6 @@ declare module '@peopl-health/nexus' {
501
501
  ran: boolean;
502
502
  weekday: number;
503
503
  mode?: 'continuity' | 'monday_triage_recovery';
504
- source?: 'weekly' | 'patient_scope';
505
504
  targetDays?: string[];
506
505
  dryRun?: boolean;
507
506
  counts: Record<string, number>;
@@ -521,8 +520,6 @@ declare module '@peopl-health/nexus' {
521
520
  mode?: 'continuity' | 'monday_triage_recovery';
522
521
  /** daily: patients inactive for 7+ days. legacy: the Thursday/Saturday post-triage cohort (Mon/Tue and Wed/Thu triages). */
523
522
  cadence?: 'daily' | 'legacy';
524
- /** weekly: the triage questionnaire view. patient_scope: the scoped patients outside that view; requires continuity, daily and a patientScope. */
525
- source?: 'weekly' | 'patient_scope';
526
523
  }): Promise<OutreachSweepResult>;
527
524
 
528
525
  export const routes: any;
@@ -10,7 +10,6 @@ const threadSchema = new mongoose.Schema({
10
10
  run_id: { type: String, default: null },
11
11
  nombre: { type: String, default: null },
12
12
  stopped: { type: Boolean, default: false },
13
- outreachOptOut: { type: Boolean, default: false },
14
13
  pausedAt: { type: Date, default: null },
15
14
  doNotAlertThrough: { type: Date, default: null },
16
15
  review: { type: Boolean, default: false },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.69.0-dev.12624",
3
+ "version": "5.69.0",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",