@peopl-health/nexus 5.69.0-dev.12623 → 5.69.0-dev.12645

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,47 +203,6 @@ function buildCandidates(rows, { now, cadence = 'daily', mode = 'continuity' })
210
203
  return { candidates, targetDays, weekday, cadence, mode };
211
204
  }
212
205
 
213
- function buildScopeCandidates({ threads, excludedCodes }) {
214
- const candidates = [];
215
- for (const thread of new Map(threads.map((t) => [t.code, t])).values()) {
216
- const name = String(thread.nombre || '').trim().split(/\s+/)[0];
217
- if (excludedCodes.has(thread.code) || !name) continue;
218
- candidates.push({
219
- source: SCOPE_SOURCE,
220
- patientId: thread.patient_id || null,
221
- recordId: String(thread._id),
222
- code: thread.code,
223
- name,
224
- reason: 'no_triage_history',
225
- rows: [],
226
- brief: {
227
- reason: 'no hay un triaje contestado que sirva como ancla clínica',
228
- lastTriageAt: null,
229
- maxGrade: null,
230
- positiveSymptoms: [],
231
- },
232
- });
233
- }
234
- return candidates;
235
- }
236
-
237
- async function loadScopeCandidates({ now, viewRows, patientScope }) {
238
- const weekStart = startOfLocalWeek(now).toISOString();
239
- const [threads, weekRows] = await Promise.all([
240
- Thread.find({ code: { $in: patientScope } }, { code: 1, nombre: 1, patient_id: 1 }).lean(),
241
- airtableService.getRecordByFilter(
242
- Symptoms_ID, TABLE, `NOT(IS_BEFORE({${F.createdAt}}, DATETIME_PARSE("${weekStart}")))`, null, [F.whatsapp],
243
- ),
244
- ]);
245
- const excludedCodes = new Set([...viewRows, ...(weekRows || [])]
246
- .map((row) => String(first(row[F.whatsapp]) || '').trim()));
247
- return buildScopeCandidates({ threads: threads || [], excludedCodes });
248
- }
249
-
250
- function sweepClaimKey(candidate, now) {
251
- return `${candidate.code}:${startOfLocalWeek(now).format('YYYY-MM-DD')}`;
252
- }
253
-
254
206
  function opsOptedOut(candidate, { now }) {
255
207
  const weekStart = startOfLocalWeek(now);
256
208
  for (const row of candidate.rows) {
@@ -286,12 +238,9 @@ async function rejectionReason(candidate, {
286
238
  } = {}) {
287
239
  const opsReason = opsOptedOut(candidate, { now });
288
240
  if (opsReason) return opsReason;
289
- if (await Thread.exists({ code: candidate.code, outreachOptOut: true })) return 'patient_follow_up_disabled';
290
241
  if (await botIsPaused(candidate.code, { label: '[outreachSweep]' })) return 'bot_paused';
291
242
  if (!includePreviouslySwept) {
292
- const sweptReason = candidate.source === SCOPE_SOURCE
293
- ? (await sweepClaims.has(sweepClaimKey(candidate, now)) ? 'already_swept_this_week' : null)
294
- : priorSweepReason(candidate);
243
+ const sweptReason = priorSweepReason(candidate);
295
244
  if (sweptReason) return sweptReason;
296
245
 
297
246
  const since = mode === 'monday_triage_recovery'
@@ -410,11 +359,8 @@ async function enrichBrief(candidate) {
410
359
  // so a second overlapping sweep sees this one and stands down. A turn that then dies leaves the
411
360
  // patient uncontacted for the week, which is the safe direction: a free-text send carries no
412
361
  // contentSid and no idempotency key, so nothing downstream would catch a duplicate.
413
- async function claimCandidate({ candidate, weekdayLabel, dryRun, now }) {
362
+ async function claimCandidate({ candidate, weekdayLabel, dryRun }) {
414
363
  if (dryRun) return true;
415
- if (candidate.source === SCOPE_SOURCE) {
416
- return (await sweepClaims.acquire(sweepClaimKey(candidate, now))) === 'acquired';
417
- }
418
364
  try {
419
365
  const claimed = await airtableService.updateRecordByFilter(
420
366
  Symptoms_ID,
@@ -437,7 +383,7 @@ async function claimCandidate({ candidate, weekdayLabel, dryRun, now }) {
437
383
  }
438
384
 
439
385
  async function recordOutcome({ candidate, outcome, weekdayLabel, dryRun }) {
440
- if (dryRun || candidate.source === SCOPE_SOURCE) return;
386
+ if (dryRun) return;
441
387
  // Three outcomes, not two: a `write` whose delivery never happened is neither "escrito" nor a
442
388
  // deliberate hold, and collapsing it into either would hide the failure from the ops queue.
443
389
  let stamp;
@@ -476,7 +422,6 @@ async function runOutreachSweep({
476
422
  includePreviouslySwept = false,
477
423
  cadence = 'daily',
478
424
  mode = 'continuity',
479
- source = WEEKLY_SOURCE,
480
425
  runOutreachTurn,
481
426
  } = {}) {
482
427
  if (typeof runOutreachTurn !== 'function') {
@@ -498,12 +443,6 @@ async function runOutreachSweep({
498
443
  if (!['continuity', 'monday_triage_recovery'].includes(mode)) {
499
444
  throw new Error('mode must be continuity or monday_triage_recovery');
500
445
  }
501
- if (![WEEKLY_SOURCE, SCOPE_SOURCE].includes(source)) {
502
- throw new Error('source must be weekly or patient_scope');
503
- }
504
- if (source === SCOPE_SOURCE && (mode !== 'continuity' || cadence !== 'daily' || !Array.isArray(patientScope))) {
505
- throw new Error('the patient_scope source requires continuity mode, daily cadence and a patientScope');
506
- }
507
446
 
508
447
  const localWeekday = moment.tz(now, DEFAULT_TIMEZONE).isoWeekday();
509
448
  if (mode === 'monday_triage_recovery' && localWeekday !== 1) {
@@ -521,9 +460,7 @@ async function runOutreachSweep({
521
460
  const rows = await airtableService.getRecordByFilter(
522
461
  Symptoms_ID, TABLE, 'TRUE()', mode === 'monday_triage_recovery' ? null : VIEW, Object.values(F), true,
523
462
  );
524
- const { candidates, targetDays, weekday } = source === SCOPE_SOURCE
525
- ? { candidates: await loadScopeCandidates({ now, viewRows: rows || [], patientScope }), targetDays: [], weekday: localWeekday }
526
- : buildCandidates(rows || [], { now, cadence, mode });
463
+ const { candidates, targetDays, weekday } = buildCandidates(rows || [], { now, cadence, mode });
527
464
  if (cadence === 'legacy' && !targetDays.length) {
528
465
  logger.info('[outreachSweep] not a sweep day — nothing to do', { weekday });
529
466
  return { ran: false, weekday, reason: 'not_a_sweep_day', counts: {} };
@@ -561,7 +498,7 @@ async function runOutreachSweep({
561
498
  const weekdayLabel = moment.tz(now, DEFAULT_TIMEZONE).locale('es').format('dddd');
562
499
  const decisions = await mapWithConcurrency(admitted, concurrency, async (candidate) => {
563
500
  const brief = await enrichBrief(candidate);
564
- if (!dryRun && !await claimCandidate({ candidate, weekdayLabel, dryRun, now })) {
501
+ if (!dryRun && !await claimCandidate({ candidate, weekdayLabel, dryRun })) {
565
502
  counts.rejected++;
566
503
  rejectedBy.claim_lost = (rejectedBy.claim_lost || 0) + 1;
567
504
  return { code: candidate.code, recordId: candidate.recordId, decision: 'skipped', holdReason: 'claim_lost' };
@@ -612,13 +549,12 @@ async function runOutreachSweep({
612
549
  });
613
550
 
614
551
  logger.info('[outreachSweep] complete', { dryRun, counts, rejectedBy });
615
- return { ran: true, weekday, cadence, mode, source, targetDays, dryRun, counts, rejectedBy, decisions };
552
+ return { ran: true, weekday, cadence, mode, targetDays, dryRun, counts, rejectedBy, decisions };
616
553
  }
617
554
 
618
555
  module.exports = {
619
556
  runOutreachSweep,
620
557
  buildCandidates,
621
- buildScopeCandidates,
622
558
  rejectionReason,
623
559
  priorSweepReason,
624
560
  TARGET_DAYS_BY_WEEKDAY,
@@ -630,5 +566,4 @@ module.exports = {
630
566
  VIEW,
631
567
  F,
632
568
  AUDIT,
633
- sweepClaims,
634
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.12623",
3
+ "version": "5.69.0-dev.12645",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",