@peopl-health/nexus 5.69.0-dev.12578 → 5.69.0-dev.12621

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.
@@ -230,7 +230,7 @@ class LlmMonitorService {
230
230
  if (!evaluation) return Evaluations.findOne(key);
231
231
 
232
232
  try {
233
- const evidence = await spec.buildEvidence(trace);
233
+ const evidence = spec.buildEvidence(trace);
234
234
  let judge = null;
235
235
  let result = null;
236
236
  let judgePrompt = null;
@@ -248,19 +248,12 @@ class LlmMonitorService {
248
248
  text: { format: { type: 'json_schema', name: `${spec.id}_verdict`, schema: spec.schema, strict: true } },
249
249
  metadata: { source: 'llm_monitor', turn_id: trace.turnId, monitor_id: spec.id, monitor_version: String(spec.version) },
250
250
  });
251
- // A refusal repeats on retry, so a spec that can record it does not pay for it again.
252
- const isFinalRefusal = result?.status === 'refused' && spec.refusalIsFinal === true;
253
- if (result?.status && result.status !== 'completed' && !isFinalRefusal) {
254
- throw new Error(`monitor judge did not complete: ${result.stop_reason || result.status}`);
255
- }
256
- if (!isFinalRefusal) {
257
- judge = parseJudge(result);
258
- spec.validateJudge?.(judge);
259
- }
251
+ judge = parseJudge(result);
252
+ spec.validateJudge?.(judge);
260
253
  }
261
254
  let findings = spec.interpret({ trace, evidence, judge });
262
255
  const evalKey = `${spec.id}:${spec.version}:${trace.turnId}`;
263
- const passSampled = findings.length === 0 && judge !== null && stableFraction(evalKey) < this.passSampleRate;
256
+ const passSampled = findings.length === 0 && stableFraction(evalKey) < this.passSampleRate;
264
257
  if (passSampled) findings = [passFinding(judge)];
265
258
  findings = findings.map((finding, index) => ({
266
259
  ...finding,
@@ -559,7 +552,6 @@ class LlmMonitorService {
559
552
 
560
553
  module.exports = {
561
554
  LlmMonitorService,
562
- stableFraction,
563
555
  EVALUATE_QUEUE,
564
556
  RECONCILE_QUEUE,
565
557
  _test: { stableFraction, findingId, needsSync, syncBacklogFilter, RECONCILE_BATCH_SIZE, MAX_EVALUATION_ATTEMPTS },
@@ -348,27 +348,15 @@ class AnthropicProvider extends BaseLLMProvider {
348
348
  };
349
349
  if (system) apiConfig.system = system;
350
350
 
351
- // Always-thinking models (Opus/Sonnet 5.5) reject a forced tool_choice; presets opt into output_config.format instead.
352
- const nativeFormat = modelConfig.structured_mode === 'output_format';
353
- if (nativeFormat) {
354
- const effort = modelConfig.reasoning?.effort;
355
- if (effort) apiConfig.output_config = { effort: effort === 'minimal' ? 'low' : effort };
356
- if (modelConfig.thinking) apiConfig.thinking = modelConfig.thinking;
357
- }
358
-
359
351
  const spec = text || modelConfig.text;
360
352
  let tool = null;
361
353
  if (spec) {
362
354
  const format = spec.format ? spec.format : spec;
363
355
  if (!format.schema) throw new Error('runStructured: format spec has no schema');
364
- if (nativeFormat) {
365
- apiConfig.output_config = { ...apiConfig.output_config, format: { type: 'json_schema', schema: format.schema } };
366
- } else {
367
- tool = { name: format.name || 'structuredOutput', input_schema: format.schema };
368
- if (format.strict !== undefined) tool.strict = format.strict;
369
- apiConfig.tools = [tool];
370
- apiConfig.tool_choice = { type: 'tool', name: tool.name };
371
- }
356
+ tool = { name: format.name || 'structuredOutput', input_schema: format.schema };
357
+ if (format.strict !== undefined) tool.strict = format.strict;
358
+ apiConfig.tools = [tool];
359
+ apiConfig.tool_choice = { type: 'tool', name: tool.name };
372
360
  }
373
361
 
374
362
  const { result: { result }, durationMs } = await withTiming(() =>
@@ -1,13 +1,14 @@
1
1
  const moment = require('moment-timezone');
2
2
 
3
3
  const airtableService = require('../../services/airtableService');
4
- const { Symptoms_ID } = require('../../config/airtableConfig');
4
+ const { Monitoreo_ID, Symptoms_ID } = require('../../config/airtableConfig');
5
5
  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
9
  const { PATIENT_CONTACT_WINDOW } = require('./contingencyDispatchService');
10
10
  const { hasPendingReminderForPatient } = require('./clinicalAirtableService');
11
+ const { WriteClaimStore } = require('./writeClaimStore');
11
12
 
12
13
  const TABLE = 'weekly_questionnaires';
13
14
  const VIEW = 'seguimiento_72hrs_principal';
@@ -49,6 +50,29 @@ const AUDIT = {
49
50
  turnId: 'seguimiento_turn_id',
50
51
  };
51
52
 
53
+ const WEEKLY_SOURCE = 'weekly';
54
+ const MONITOR_SOURCE = 'message_monitor';
55
+ const SOURCES = [WEEKLY_SOURCE, MONITOR_SOURCE];
56
+
57
+ const MONITOR_TABLE = 'message_monitor';
58
+ const MONITOR_F = {
59
+ whatsapp: 'whatsapp_id',
60
+ lastInbound: 'last_message_patient_time',
61
+ };
62
+
63
+ const PATIENT_TABLE = 'estado_general';
64
+ const PATIENT_F = {
65
+ whatsapp: 'whatsapp_id',
66
+ firstName: 'first_name',
67
+ disableFollowUp: 'desactivar_seguimiento_48hrs',
68
+ };
69
+
70
+ const CANONICAL_CODE = /^whatsapp:\+\d{6,}$/;
71
+
72
+ // The monitor lane has no questionnaire row to stamp, so its weekly claim lives in Mongo, where a
73
+ // unique key makes it atomic and an unavailable store fails closed.
74
+ const sweepClaims = new WriteClaimStore({ namespace: 'outreach_sweep', ttlMs: 14 * 24 * 60 * 60 * 1000 });
75
+
52
76
  const UNDELIVERED_STATUSES = ['failed', 'undelivered'];
53
77
  const CONTACT_SENT = {
54
78
  from_me: true,
@@ -62,6 +86,7 @@ const CONTACT_SENT = {
62
86
  const RECENT_OUTBOUND_HOURS = 24;
63
87
  const COLLIDING_REMINDER_MINUTES = 24 * 60;
64
88
  const INACTIVITY_DAYS = 7;
89
+ const MAX_SILENCE_DAYS = 30;
65
90
  const TRIAGE_PROTECTION_START_MINUTE = (7 * 60) + 30;
66
91
  const TRIAGE_PROTECTION_END_MINUTE = 11 * 60;
67
92
 
@@ -202,7 +227,106 @@ function buildCandidates(rows, { now, cadence = 'daily', mode = 'continuity' })
202
227
  return { candidates, targetDays, weekday, cadence, mode };
203
228
  }
204
229
 
230
+ function airtableInstant(date) {
231
+ return `DATETIME_PARSE("${date.toISOString()}")`;
232
+ }
233
+
234
+ function monitorPrefilter(now) {
235
+ const oldest = moment.tz(now, DEFAULT_TIMEZONE).subtract(MAX_SILENCE_DAYS + 1, 'days').toDate();
236
+ const newest = moment.tz(now, DEFAULT_TIMEZONE).subtract(INACTIVITY_DAYS - 1, 'days').toDate();
237
+ return `AND(IS_AFTER({${MONITOR_F.lastInbound}}, ${airtableInstant(oldest)}), `
238
+ + `IS_BEFORE({${MONITOR_F.lastInbound}}, ${airtableInstant(newest)}))`;
239
+ }
240
+
241
+ // A number can carry several monitor rows; any one of them outside prod, outside the programme or
242
+ // paused rules the number out, so a stale duplicate cannot admit it.
243
+ const MONITOR_DENY = 'OR({tipo} != "prod", NOT({is_program}), {stopped})';
244
+
245
+ /* Patients the weekly lane does not own: anyone in the questionnaire view, or with a questionnaire
246
+ * row this week, belongs to that lane and its stamps. Names come from estado_general because the
247
+ * monitor's own name is a formula that falls back to "Desconocido". A number carrying two names is
248
+ * a shared handset, and a message addressed to one of them is skipped rather than guessed. */
249
+ function buildMonitorCandidates({ monitorRows, patientRows, excludedCodes }) {
250
+ const newestByCode = new Map();
251
+ for (const row of monitorRows) {
252
+ const code = String(first(row[MONITOR_F.whatsapp]) || '').trim();
253
+ if (!CANONICAL_CODE.test(code) || excludedCodes.has(code)) continue;
254
+ const seen = newestByCode.get(code);
255
+ if (!seen || new Date(row[MONITOR_F.lastInbound] || 0) > new Date(seen[MONITOR_F.lastInbound] || 0)) {
256
+ newestByCode.set(code, row);
257
+ }
258
+ }
259
+
260
+ const patientsByCode = new Map();
261
+ for (const row of patientRows) {
262
+ const code = String(first(row[PATIENT_F.whatsapp]) || '').trim();
263
+ if (!newestByCode.has(code)) continue;
264
+ if (!patientsByCode.has(code)) patientsByCode.set(code, { names: new Map(), followUpDisabled: false });
265
+ const patient = patientsByCode.get(code);
266
+ const name = String(first(row[PATIENT_F.firstName]) || '').trim();
267
+ if (name) patient.names.set(name.toLowerCase().replace(/\s+/g, ' '), name);
268
+ if (row[PATIENT_F.disableFollowUp] === true) patient.followUpDisabled = true;
269
+ }
270
+
271
+ const candidates = [];
272
+ for (const [code, row] of newestByCode) {
273
+ const patient = patientsByCode.get(code);
274
+ if (!patient || patient.names.size !== 1) {
275
+ logger.info('[outreachSweep] monitor candidate skipped — no single patient name', {
276
+ recordId: row._recordId, names: patient?.names.size || 0,
277
+ });
278
+ continue;
279
+ }
280
+ candidates.push({
281
+ source: MONITOR_SOURCE,
282
+ patientId: null,
283
+ recordId: row._recordId,
284
+ code,
285
+ name: [...patient.names.values()][0],
286
+ reason: 'no_triage_history',
287
+ followUpDisabled: patient.followUpDisabled,
288
+ rows: [],
289
+ brief: {
290
+ reason: 'no hay un triaje contestado que sirva como ancla clínica',
291
+ lastTriageAt: null,
292
+ maxGrade: null,
293
+ positiveSymptoms: [],
294
+ },
295
+ });
296
+ }
297
+ return candidates;
298
+ }
299
+
300
+ async function loadMonitorCandidates({ now, viewRows }) {
301
+ const weekStart = startOfLocalWeek(now).toDate();
302
+ const [monitorRows, deniedRows, patientRows, weekRows] = await Promise.all([
303
+ airtableService.getRecordByFilter(
304
+ Monitoreo_ID, MONITOR_TABLE, monitorPrefilter(now), null, Object.values(MONITOR_F), true,
305
+ ),
306
+ airtableService.getRecordByFilter(Monitoreo_ID, MONITOR_TABLE, MONITOR_DENY, null, [MONITOR_F.whatsapp]),
307
+ airtableService.getRecordByFilter(Symptoms_ID, PATIENT_TABLE, 'TRUE()', null, Object.values(PATIENT_F)),
308
+ airtableService.getRecordByFilter(
309
+ Symptoms_ID, TABLE, `NOT(IS_BEFORE({${F.createdAt}}, ${airtableInstant(weekStart)}))`, null, [F.whatsapp],
310
+ ),
311
+ ]);
312
+ const excludedCodes = new Set([
313
+ ...[...viewRows, ...(weekRows || [])].map((row) => first(row[F.whatsapp])),
314
+ ...(deniedRows || []).map((row) => first(row[MONITOR_F.whatsapp])),
315
+ ].map((code) => String(code || '').trim()).filter(Boolean));
316
+ return buildMonitorCandidates({ monitorRows: monitorRows || [], patientRows: patientRows || [], excludedCodes });
317
+ }
318
+
319
+ function sweepClaimKey(candidate, now) {
320
+ return `${candidate.code}:${startOfLocalWeek(now).format('YYYY-MM-DD')}`;
321
+ }
322
+
323
+ function sweepReasonFromStamps(stamps) {
324
+ if (stamps.some((stamp) => stamp.endsWith('_en curso'))) return 'stuck_in_progress';
325
+ return stamps.length ? 'already_swept_this_week' : null;
326
+ }
327
+
205
328
  function opsOptedOut(candidate, { now }) {
329
+ if (candidate.followUpDisabled) return 'patient_follow_up_disabled';
206
330
  const weekStart = startOfLocalWeek(now);
207
331
  for (const row of candidate.rows) {
208
332
  if (first(row[F.disableFollowUp]) === true) return 'patient_follow_up_disabled';
@@ -216,9 +340,12 @@ function opsOptedOut(candidate, { now }) {
216
340
  // Patient-scoped, not row-scoped: a patient with two questionnaires this week is one person, and
217
341
  // the old automation's per-row stamp is what let her be contacted twice.
218
342
  function priorSweepReason(candidate) {
219
- const stamps = candidate.rows.map((row) => String(row[F.review] || '').trim()).filter(Boolean);
220
- if (stamps.some((stamp) => stamp.endsWith('_en curso'))) return 'stuck_in_progress';
221
- return stamps.length ? 'already_swept_this_week' : null;
343
+ return sweepReasonFromStamps(candidate.rows.map((row) => String(row[F.review] || '').trim()).filter(Boolean));
344
+ }
345
+
346
+ async function priorMonitorSweepReason(candidate, { now }) {
347
+ const claim = await sweepClaims.get(sweepClaimKey(candidate, now));
348
+ return sweepReasonFromStamps(claim?.stamp ? [claim.stamp] : []);
222
349
  }
223
350
 
224
351
  async function mondayTriageStillPending(candidate) {
@@ -239,7 +366,9 @@ async function rejectionReason(candidate, {
239
366
  if (opsReason) return opsReason;
240
367
  if (await botIsPaused(candidate.code, { label: '[outreachSweep]' })) return 'bot_paused';
241
368
  if (!includePreviouslySwept) {
242
- const sweptReason = priorSweepReason(candidate);
369
+ const sweptReason = candidate.source === MONITOR_SOURCE
370
+ ? await priorMonitorSweepReason(candidate, { now })
371
+ : priorSweepReason(candidate);
243
372
  if (sweptReason) return sweptReason;
244
373
 
245
374
  const since = mode === 'monday_triage_recovery'
@@ -269,11 +398,13 @@ async function rejectionReason(candidate, {
269
398
  if (!await mondayTriageStillPending(candidate)) return 'triage_no_longer_pending';
270
399
  } else if (cadence === 'daily') {
271
400
  const inactiveSince = moment.tz(now, DEFAULT_TIMEZONE).subtract(INACTIVITY_DAYS, 'days').toDate();
272
- const recentInbound = await getMessages(
273
- { numero: candidate.code, from_me: false, createdAt: { $gte: inactiveSince } },
274
- { sort: { createdAt: -1 }, limit: 1 },
401
+ const silentSince = moment.tz(now, DEFAULT_TIMEZONE).subtract(MAX_SILENCE_DAYS, 'days').toDate();
402
+ const [lastInbound] = await getMessages(
403
+ { numero: candidate.code, from_me: false, createdAt: { $gte: silentSince } },
404
+ { select: { createdAt: 1 }, sort: { createdAt: -1 }, limit: 1 },
275
405
  );
276
- if (recentInbound?.length) return 'patient_active_within_inactivity_window';
406
+ if (!lastInbound) return 'patient_silent_beyond_max_window';
407
+ if (new Date(lastInbound.createdAt) >= inactiveSince) return 'patient_active_within_inactivity_window';
277
408
 
278
409
  const lastTriageAt = candidate?.brief?.lastTriageAt;
279
410
  if (lastTriageAt && moment.tz(lastTriageAt, DEFAULT_TIMEZONE).isSameOrAfter(inactiveSince)) {
@@ -356,8 +487,16 @@ async function enrichBrief(candidate) {
356
487
  // so a second overlapping sweep sees this one and stands down. A turn that then dies leaves the
357
488
  // patient uncontacted for the week, which is the safe direction: a free-text send carries no
358
489
  // contentSid and no idempotency key, so nothing downstream would catch a duplicate.
359
- async function claimCandidate({ candidate, weekdayLabel, dryRun }) {
360
- if (dryRun) return true;
490
+ async function claimCandidate({ candidate, weekdayLabel, dryRun, now }) {
491
+ if (dryRun) return null;
492
+ if (candidate.source === MONITOR_SOURCE) {
493
+ const claim = await sweepClaims.acquire(sweepClaimKey(candidate, now), {
494
+ stamp: `${weekdayLabel}_en curso`, recordId: candidate.recordId,
495
+ });
496
+ if (claim === 'acquired') return null;
497
+ logger.info('[outreachSweep] monitor claim not acquired', { recordId: candidate.recordId, claim });
498
+ return claim === 'taken' ? 'claim_lost' : 'claim_unavailable';
499
+ }
361
500
  try {
362
501
  const claimed = await airtableService.updateRecordByFilter(
363
502
  Symptoms_ID,
@@ -368,27 +507,39 @@ async function claimCandidate({ candidate, weekdayLabel, dryRun }) {
368
507
  );
369
508
  if (!claimed?.length) {
370
509
  logger.info('[outreachSweep] candidate already claimed by another run', { recordId: candidate.recordId });
371
- return false;
510
+ return 'claim_lost';
372
511
  }
373
- return true;
512
+ return null;
374
513
  } catch (error) {
375
514
  logger.warn('[outreachSweep] claim failed; skipping rather than risking a double send', {
376
515
  recordId: candidate.recordId, error: error.message,
377
516
  });
378
- return false;
517
+ return 'claim_lost';
379
518
  }
380
519
  }
381
520
 
382
- async function recordOutcome({ candidate, outcome, weekdayLabel, dryRun }) {
521
+ // Three outcomes, not two: a `write` whose delivery never happened is neither "escrito" nor a
522
+ // deliberate hold, and collapsing it into either would hide the failure from the ops queue.
523
+ function outcomeStamp(outcome, weekdayLabel) {
524
+ if (outcome.error) return `${weekdayLabel}_error`;
525
+ if (outcome.delivered) return `${weekdayLabel}_entregado`;
526
+ if (outcome.accepted) return `${weekdayLabel}_enviado`;
527
+ if (outcome.decision === 'write') return `${weekdayLabel}_no entregado`;
528
+ return `${weekdayLabel}_omitido`;
529
+ }
530
+
531
+ async function recordOutcome({ candidate, outcome, weekdayLabel, dryRun, now }) {
383
532
  if (dryRun) return;
384
- // Three outcomes, not two: a `write` whose delivery never happened is neither "escrito" nor a
385
- // deliberate hold, and collapsing it into either would hide the failure from the ops queue.
386
- let stamp;
387
- if (outcome.error) stamp = `${weekdayLabel}_error`;
388
- else if (outcome.delivered) stamp = `${weekdayLabel}_entregado`;
389
- else if (outcome.accepted) stamp = `${weekdayLabel}_enviado`;
390
- else if (outcome.decision === 'write') stamp = `${weekdayLabel}_no entregado`;
391
- else stamp = `${weekdayLabel}_omitido`;
533
+ const stamp = outcomeStamp(outcome, weekdayLabel);
534
+ if (candidate.source === MONITOR_SOURCE) {
535
+ await sweepClaims.set(sweepClaimKey(candidate, now), {
536
+ stamp,
537
+ recordId: candidate.recordId,
538
+ rationale: outcome.rationale || outcome.error || '',
539
+ turnId: outcome.turnId || '',
540
+ });
541
+ return;
542
+ }
392
543
  const where = `RECORD_ID() = "${candidate.recordId}"`;
393
544
  await airtableService.updateRecordByFilter(Symptoms_ID, TABLE, where, {
394
545
  [F.review]: stamp,
@@ -419,6 +570,7 @@ async function runOutreachSweep({
419
570
  includePreviouslySwept = false,
420
571
  cadence = 'daily',
421
572
  mode = 'continuity',
573
+ source = WEEKLY_SOURCE,
422
574
  runOutreachTurn,
423
575
  } = {}) {
424
576
  if (typeof runOutreachTurn !== 'function') {
@@ -440,10 +592,19 @@ async function runOutreachSweep({
440
592
  if (!['continuity', 'monday_triage_recovery'].includes(mode)) {
441
593
  throw new Error('mode must be continuity or monday_triage_recovery');
442
594
  }
595
+ if (!SOURCES.includes(source)) {
596
+ throw new Error('source must be weekly or message_monitor');
597
+ }
598
+ // message_monitor also holds AMI and inactive patients; only the consumer's scope says who is in
599
+ // its programme and active, so this lane never runs unscoped.
600
+ if (source === MONITOR_SOURCE
601
+ && (mode !== 'continuity' || cadence !== 'daily' || !Array.isArray(patientScope))) {
602
+ throw new Error('the message_monitor source requires continuity mode, daily cadence and a patientScope');
603
+ }
443
604
 
444
605
  const localWeekday = moment.tz(now, DEFAULT_TIMEZONE).isoWeekday();
445
606
  if (mode === 'monday_triage_recovery' && localWeekday !== 1) {
446
- return { ran: false, weekday: localWeekday, reason: 'not_monday', counts: {} };
607
+ return { ran: false, weekday: localWeekday, source, reason: 'not_monday', counts: {} };
447
608
  }
448
609
 
449
610
  // Triage owns the morning. This is deliberately enforced below the cron so a manual invocation
@@ -451,23 +612,25 @@ async function runOutreachSweep({
451
612
  if (isWithinTriageProtectionWindow(now)) {
452
613
  const weekday = moment.tz(now, DEFAULT_TIMEZONE).isoWeekday();
453
614
  logger.info('[outreachSweep] triage protection window — nothing sent', { weekday });
454
- return { ran: false, weekday, reason: 'triage_protection_window', counts: {} };
615
+ return { ran: false, weekday, source, reason: 'triage_protection_window', counts: {} };
455
616
  }
456
617
 
457
618
  const rows = await airtableService.getRecordByFilter(
458
619
  Symptoms_ID, TABLE, 'TRUE()', mode === 'monday_triage_recovery' ? null : VIEW, Object.values(F), true,
459
620
  );
460
- const { candidates, targetDays, weekday } = buildCandidates(rows || [], { now, cadence, mode });
621
+ const { candidates, targetDays, weekday } = source === MONITOR_SOURCE
622
+ ? { candidates: await loadMonitorCandidates({ now, viewRows: rows || [] }), targetDays: [], weekday: localWeekday }
623
+ : buildCandidates(rows || [], { now, cadence, mode });
461
624
  if (cadence === 'legacy' && !targetDays.length) {
462
625
  logger.info('[outreachSweep] not a sweep day — nothing to do', { weekday });
463
- return { ran: false, weekday, reason: 'not_a_sweep_day', counts: {} };
626
+ return { ran: false, weekday, source, reason: 'not_a_sweep_day', counts: {} };
464
627
  }
465
628
 
466
629
  // The scheduler is not the guard. An operator running the sweep by hand at 03:00 must not wake
467
630
  // every candidate, so the window is checked here as well as by whatever invoked us.
468
631
  if (!isWithinHourWindow(now, PATIENT_CONTACT_WINDOW)) {
469
632
  logger.info('[outreachSweep] outside patient contact hours — nothing sent', { weekday });
470
- return { ran: false, weekday, reason: 'quiet_hours', counts: {} };
633
+ return { ran: false, weekday, source, reason: 'quiet_hours', counts: {} };
471
634
  }
472
635
 
473
636
  // An explicit empty scope means "nobody", not "scope omitted". This matters for a canary
@@ -495,10 +658,11 @@ async function runOutreachSweep({
495
658
  const weekdayLabel = moment.tz(now, DEFAULT_TIMEZONE).locale('es').format('dddd');
496
659
  const decisions = await mapWithConcurrency(admitted, concurrency, async (candidate) => {
497
660
  const brief = await enrichBrief(candidate);
498
- if (!dryRun && !await claimCandidate({ candidate, weekdayLabel, dryRun })) {
661
+ const claimLost = await claimCandidate({ candidate, weekdayLabel, dryRun, now });
662
+ if (claimLost) {
499
663
  counts.rejected++;
500
- rejectedBy.claim_lost = (rejectedBy.claim_lost || 0) + 1;
501
- return { code: candidate.code, recordId: candidate.recordId, decision: 'skipped', holdReason: 'claim_lost' };
664
+ rejectedBy[claimLost] = (rejectedBy[claimLost] || 0) + 1;
665
+ return { code: candidate.code, recordId: candidate.recordId, decision: 'skipped', holdReason: claimLost };
502
666
  }
503
667
  try {
504
668
  const outcome = await runOutreachTurn(candidate.code, {
@@ -508,7 +672,9 @@ async function runOutreachSweep({
508
672
  dryRun,
509
673
  reminderType: 'seguimiento',
510
674
  reminderPrefix: mode === 'monday_triage_recovery' ? 'recovery' : null,
511
- triggeredBy: `seguimiento:${candidate.recordId}`,
675
+ triggeredBy: candidate.source === MONITOR_SOURCE
676
+ ? `seguimiento:mm:${candidate.recordId}`
677
+ : `seguimiento:${candidate.recordId}`,
512
678
  });
513
679
  counts.run++;
514
680
  if (outcome.decision === 'skipped') {
@@ -518,19 +684,20 @@ async function runOutreachSweep({
518
684
  outcome: { ...outcome, decision: 'hold', rationale: outcome.rationale || outcome.holdReason || 'turn skipped' },
519
685
  weekdayLabel,
520
686
  dryRun,
687
+ now,
521
688
  });
522
689
  return { code: candidate.code, recordId: candidate.recordId, ...outcome };
523
690
  }
524
691
  counts[outcome.decision === 'write' ? 'write' : 'hold']++;
525
692
  if (outcome.accepted) counts.accepted++;
526
693
  if (outcome.delivered) counts.delivered++;
527
- await recordOutcome({ candidate, outcome, weekdayLabel, dryRun });
694
+ await recordOutcome({ candidate, outcome, weekdayLabel, dryRun, now });
528
695
  return { code: candidate.code, recordId: candidate.recordId, dryRun, brief, ...outcome };
529
696
  } catch (error) {
530
697
  counts.error++;
531
698
  logger.error('[outreachSweep] outreach turn failed', { code: candidate.code, error: error.message });
532
699
  try {
533
- await recordOutcome({ candidate, outcome: { error: error.message }, weekdayLabel, dryRun });
700
+ await recordOutcome({ candidate, outcome: { error: error.message }, weekdayLabel, dryRun, now });
534
701
  } catch (auditError) {
535
702
  // An audit-schema outage must be loud, but it must not abort the other patients in this
536
703
  // bounded batch. The pre-send claim remains `_en curso`, which the next sweep surfaces as
@@ -546,12 +713,13 @@ async function runOutreachSweep({
546
713
  });
547
714
 
548
715
  logger.info('[outreachSweep] complete', { dryRun, counts, rejectedBy });
549
- return { ran: true, weekday, cadence, mode, targetDays, dryRun, counts, rejectedBy, decisions };
716
+ return { ran: true, weekday, cadence, mode, source, targetDays, dryRun, counts, rejectedBy, decisions };
550
717
  }
551
718
 
552
719
  module.exports = {
553
720
  runOutreachSweep,
554
721
  buildCandidates,
722
+ buildMonitorCandidates,
555
723
  rejectionReason,
556
724
  priorSweepReason,
557
725
  TARGET_DAYS_BY_WEEKDAY,
@@ -562,5 +730,8 @@ module.exports = {
562
730
  mondayTriageStillPending,
563
731
  VIEW,
564
732
  F,
733
+ MONITOR_F,
734
+ PATIENT_F,
565
735
  AUDIT,
736
+ sweepClaims,
566
737
  };
@@ -77,11 +77,7 @@ const unbatchedRoutes = {
77
77
  function loadDefaultLlmMonitor() {
78
78
  const { LlmMonitorService } = require('../clinical/monitoring/LlmMonitorService');
79
79
  const { contingencyMonitorSpec } = require('../clinical/monitoring/contingencyMonitorSpec');
80
- const { emergencyConsultMonitorSpec } = require('../clinical/monitoring/emergencyConsultMonitorSpec');
81
- const { emergencyConsultMode } = require('../clinical/flags/emergencyConsultFlags');
82
- const specs = [contingencyMonitorSpec];
83
- if (emergencyConsultMode() !== 'off') specs.push(emergencyConsultMonitorSpec);
84
- return { LlmMonitorService, specs };
80
+ return { LlmMonitorService, contingencyMonitorSpec };
85
81
  }
86
82
 
87
83
  function normalizeLlmMonitorConfig(value) {
@@ -92,8 +88,8 @@ function normalizeLlmMonitorConfig(value) {
92
88
  function createDefaultLlmMonitor(options, loader = loadDefaultLlmMonitor) {
93
89
  if (!options.enabled) return null;
94
90
  try {
95
- const { LlmMonitorService, specs } = loader();
96
- return new LlmMonitorService({ ...options, specs });
91
+ const { LlmMonitorService, contingencyMonitorSpec } = loader();
92
+ return new LlmMonitorService({ ...options, specs: [contingencyMonitorSpec] });
97
93
  } catch (error) {
98
94
  logger.warn('[llmMonitor] construction failed; patient messaging remains available', {
99
95
  error: error?.message || String(error),
package/lib/index.d.ts CHANGED
@@ -501,6 +501,7 @@ declare module '@peopl-health/nexus' {
501
501
  ran: boolean;
502
502
  weekday: number;
503
503
  mode?: 'continuity' | 'monday_triage_recovery';
504
+ source?: 'weekly' | 'message_monitor';
504
505
  targetDays?: string[];
505
506
  dryRun?: boolean;
506
507
  counts: Record<string, number>;
@@ -520,6 +521,8 @@ declare module '@peopl-health/nexus' {
520
521
  mode?: 'continuity' | 'monday_triage_recovery';
521
522
  /** daily: patients inactive for 7+ days. legacy: the Thursday/Saturday post-triage cohort (Mon/Tue and Wed/Thu triages). */
522
523
  cadence?: 'daily' | 'legacy';
524
+ /** weekly: the triage questionnaire view. message_monitor: active-program patients outside that view, read from message_monitor; requires continuity, daily and a patientScope. */
525
+ source?: 'weekly' | 'message_monitor';
523
526
  }): Promise<OutreachSweepResult>;
524
527
 
525
528
  export const routes: any;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.69.0-dev.12578",
3
+ "version": "5.69.0-dev.12621",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",
@@ -1,16 +0,0 @@
1
- const runtimeConfig = require('../../config/runtimeConfig');
2
-
3
- const MODES = ['off', 'shadow'];
4
- const DEFAULT_SAMPLE_RATE = 0.1;
5
-
6
- function emergencyConsultMode() {
7
- const value = String(runtimeConfig.get('EMERGENCY_CONSULT_MODE', 'off')).trim().toLowerCase();
8
- return MODES.includes(value) ? value : 'off';
9
- }
10
-
11
- function emergencyConsultSampleRate() {
12
- const value = Number(runtimeConfig.get('EMERGENCY_CONSULT_SAMPLE_RATE', DEFAULT_SAMPLE_RATE));
13
- return Number.isFinite(value) && value >= 0 && value <= 1 ? value : DEFAULT_SAMPLE_RATE;
14
- }
15
-
16
- module.exports = { emergencyConsultMode, emergencyConsultSampleRate };
@@ -1,215 +0,0 @@
1
- const { patientMessageFromTrace } = require('../helpers/evidenceAnchorHelper');
2
- const { buildConsultContext } = require('../services/emergencyConsultService');
3
- const { emergencyConsultSampleRate } = require('../flags/emergencyConsultFlags');
4
- const { URGENCY_RANK } = require('../../models/escalationReviewModel');
5
- const { isPlainObject } = require('../../utils/jsonUtils');
6
-
7
- const { stableFraction } = require('./LlmMonitorService');
8
-
9
- const MONITOR_ID = 'emergency_consult';
10
- const MONITOR_VERSION = '1';
11
- const PRESET_ID = 'sa_emergency_consult';
12
- const LEVELS = ['stat', 'asap', 'urgent', 'routine'];
13
- const IMMEDIATE_LEVELS = ['stat', 'asap'];
14
- const DISPOSITIONS = ['escalate_now', 'ask_first', 'no_escalation'];
15
- const STALE_AFTER_MS = 15 * 60 * 1000;
16
- const UNREADABLE_PATIENT = [400, 404];
17
-
18
- const strings = { type: 'array', items: { type: 'string' } };
19
- const nullableLevel = { anyOf: [{ type: 'string', enum: LEVELS }, { type: 'null' }] };
20
-
21
- const CONSULT_SCHEMA = {
22
- type: 'object',
23
- additionalProperties: false,
24
- required: ['disposition', 'urgency_level', 'confidence', 'clinical_reasoning', 'serious_differential', 'red_flags_present',
25
- 'red_flags_ruled_out', 'missing_information', 'level_if_unanswered', 'context_gaps', 'recommended_action', 'what_to_tell_patient'],
26
- properties: {
27
- disposition: { type: 'string', enum: DISPOSITIONS },
28
- urgency_level: nullableLevel,
29
- confidence: { type: 'string', enum: ['low', 'moderate', 'high'] },
30
- clinical_reasoning: { type: 'string' },
31
- serious_differential: {
32
- type: 'array',
33
- items: {
34
- type: 'object',
35
- additionalProperties: false,
36
- required: ['condition', 'status', 'features'],
37
- properties: {
38
- condition: { type: 'string' },
39
- status: { type: 'string', enum: ['supported', 'not_excluded', 'unlikely'] },
40
- features: strings,
41
- },
42
- },
43
- },
44
- red_flags_present: strings,
45
- red_flags_ruled_out: strings,
46
- missing_information: {
47
- type: 'array',
48
- items: {
49
- type: 'object',
50
- additionalProperties: false,
51
- required: ['question_for_patient', 'why', 'level_if_positive'],
52
- properties: {
53
- question_for_patient: { type: 'string' },
54
- why: { type: 'string' },
55
- level_if_positive: { type: 'string', enum: LEVELS },
56
- },
57
- },
58
- },
59
- level_if_unanswered: nullableLevel,
60
- context_gaps: strings,
61
- recommended_action: {
62
- type: 'object',
63
- additionalProperties: false,
64
- required: ['now', 'notify'],
65
- properties: {
66
- now: { type: 'string' },
67
- notify: { type: 'string', enum: ['none', 'team_review', 'treating_team_today', 'oncologist_now', 'emergency_services'] },
68
- },
69
- },
70
- what_to_tell_patient: { type: 'string' },
71
- },
72
- };
73
-
74
- const asArray = (value) => (Array.isArray(value) ? value : []);
75
-
76
- function triggers(trace) {
77
- const signals = trace?.signals || {};
78
- const found = [];
79
- if (signals.medicalEscalation) found.push('medical_escalation');
80
- if (asArray(signals.safetyFlags).length) found.push('safety_flags');
81
- if (asArray(signals.activatedSkills).includes('emergency')) found.push('emergency_skill');
82
- if (!found.length && asArray(signals.clinicalMentions).length
83
- && stableFraction(`${MONITOR_ID}:${trace.turnId}`) < emergencyConsultSampleRate()) found.push('mention_sample');
84
- return found;
85
- }
86
-
87
- function isEligible(trace) {
88
- return trace?.status === 'completed'
89
- && trace?.shadow !== true
90
- && triggers(trace).length > 0;
91
- }
92
-
93
- function agentDecision(trace) {
94
- const escalation = trace.signals?.medicalEscalation;
95
- if (!escalation) return { escalated: false, level: null, requestedLevel: null, patternFloor: null };
96
- return {
97
- escalated: true,
98
- level: escalation.urgencyLevel || null,
99
- requestedLevel: escalation.requestedUrgencyLevel || null,
100
- patternFloor: escalation.clinicalPatternFloor || null,
101
- };
102
- }
103
-
104
- const mentionFacts = (trace) => asArray(trace.signals?.clinicalMentions)
105
- .map(({ fact }) => {
106
- if (!isPlainObject(fact)) return null;
107
- const { patientId: _patientId, mentionId: _mentionId, ...rest } = fact;
108
- return rest;
109
- })
110
- .filter(Boolean);
111
-
112
- async function buildEvidence(trace, now = Date.now()) {
113
- // The trace is saved once the turn ends, so the turn started predictionTimeMs before createdAt.
114
- const turnStart = new Date(trace.createdAt).getTime() - (trace.timings?.predictionTimeMs || 0);
115
- const base = { triggers: triggers(trace), agent: agentDecision(trace), lag_ms: now - turnStart };
116
- if (base.lag_ms > STALE_AFTER_MS) return { ...base, extraction_succeeded: false, degraded: 'stale' };
117
- try {
118
- const { input, degradedSections, hasCoreContext } = await buildConsultContext({
119
- code: trace.patientCode,
120
- turnStart,
121
- patientMessage: patientMessageFromTrace(trace),
122
- mentions: mentionFacts(trace),
123
- });
124
- if (!hasCoreContext) return { ...base, extraction_succeeded: false, degraded: 'core_context_missing', degraded_sections: degradedSections };
125
- return { ...base, extraction_succeeded: true, input, degraded_sections: degradedSections };
126
- } catch (error) {
127
- if (!UNREADABLE_PATIENT.includes(error?.statusCode)) throw error;
128
- return { ...base, extraction_succeeded: false, degraded: 'patient_unreadable', error: error.message };
129
- }
130
- }
131
-
132
- function buildJudgeInput(evidence) {
133
- return [{ role: 'user', content: evidence.input }];
134
- }
135
-
136
- function validateJudge(judge) {
137
- if (!isPlainObject(judge) || !DISPOSITIONS.includes(judge.disposition)) {
138
- throw new Error('emergency consult returned no valid disposition');
139
- }
140
- }
141
-
142
- function invariantViolations(judge) {
143
- const violations = [];
144
- const askFirst = judge.disposition === 'ask_first';
145
- if (judge.disposition === 'escalate_now' && !judge.urgency_level) violations.push('escalate_now_without_level');
146
- if (askFirst && !asArray(judge.missing_information).length) violations.push('ask_first_without_questions');
147
- if (askFirst && !judge.level_if_unanswered) violations.push('ask_first_without_level_if_unanswered');
148
- if (askFirst && [judge.urgency_level, judge.level_if_unanswered].some((level) => IMMEDIATE_LEVELS.includes(level))) {
149
- violations.push('ask_first_on_immediate_level');
150
- }
151
- if (judge.disposition !== 'escalate_now' && asArray(judge.red_flags_present).length) violations.push('red_flags_without_escalation');
152
- return violations;
153
- }
154
-
155
- const rank = (level) => URGENCY_RANK[level] || 0;
156
-
157
- function consultFinding(fields) {
158
- return {
159
- type: 'emergency_consult',
160
- severity: 'normal',
161
- confidence: 'high',
162
- opsReviewRequired: false,
163
- targetId: null,
164
- observedCallIndex: null,
165
- ...fields,
166
- };
167
- }
168
-
169
- function interpret({ evidence, judge }) {
170
- if (!evidence.extraction_succeeded || !judge) {
171
- return [consultFinding({
172
- expectedAction: null,
173
- observedAction: evidence.agent?.level || null,
174
- criteria: { degraded: evidence.extraction_succeeded ? 'judge_refused' : evidence.degraded },
175
- summary: 'El especialista no evaluó el turno.',
176
- evidence: evidence.error || 'Sin detalle.',
177
- })];
178
- }
179
- const specialistLevel = judge.disposition === 'ask_first' ? judge.level_if_unanswered : judge.urgency_level;
180
- const agentLevel = evidence.agent?.level || null;
181
- return [consultFinding({
182
- expectedAction: specialistLevel || null,
183
- observedAction: agentLevel,
184
- criteria: {
185
- disposition: judge.disposition,
186
- agent_below_specialist: rank(agentLevel) < rank(specialistLevel),
187
- invariant_violations: invariantViolations(judge),
188
- },
189
- summary: `Especialista: ${judge.disposition} ${specialistLevel || 'sin nivel'}; agente: ${agentLevel || 'sin escalamiento'}.`,
190
- evidence: judge.clinical_reasoning || 'Sin razonamiento.',
191
- })];
192
- }
193
-
194
- const emergencyConsultMonitorSpec = {
195
- id: MONITOR_ID,
196
- version: MONITOR_VERSION,
197
- presetId: PRESET_ID,
198
- schema: CONSULT_SCHEMA,
199
- refusalIsFinal: true,
200
- reconcileQuery: {
201
- $or: [
202
- { 'signals.medicalEscalation': { $exists: true } },
203
- { 'signals.safetyFlags.0': { $exists: true } },
204
- { 'signals.activatedSkills': 'emergency' },
205
- { 'signals.clinicalMentions.0': { $exists: true } },
206
- ],
207
- },
208
- isEligible,
209
- buildEvidence,
210
- buildJudgeInput,
211
- validateJudge,
212
- interpret,
213
- };
214
-
215
- module.exports = { emergencyConsultMonitorSpec };
@@ -1,76 +0,0 @@
1
- const { readClinicalProfile } = require('../helpers/clinicalProfileHelper');
2
- const { buildExposureBlock } = require('../helpers/triageCaseBaseHelper');
3
- const { readDispatchedEscalations } = require('../../fhir');
4
- const { getEscalationContext } = require('../../services/escalationContextService');
5
- const { redactDirectIdentifiers } = require('../../utils/sanitizerUtils');
6
- const { assembleRiskProfile } = require('./riskAssessmentService');
7
- const { escalationLookbackSince } = require('./turnRiskBriefService');
8
-
9
- const CONTEXT_SECTIONS = ['agentContext', 'conversation', 'history'];
10
- const MAX_INPUT_CHARS = 120000;
11
-
12
- const optional = (promise) => promise.catch(() => undefined);
13
-
14
- // The conversation ends with this turn's own reply; the consult must not read the decision it is judging.
15
- function conversationBeforeReply(conversation) {
16
- const messages = Array.isArray(conversation) ? [...conversation] : [];
17
- while (messages.length && messages[messages.length - 1].role === 'assistant') messages.pop();
18
- return messages;
19
- }
20
-
21
- function clinicalProfile(profile) {
22
- if (!profile) return undefined;
23
- const { edad = null, sexo = null } = profile.demografia || {};
24
- return { demografia: { edad, sexo }, datos_clinicos: profile.datos_clinicos ?? null };
25
- }
26
-
27
- const section = (title, value) => [`## ${title}`, value === undefined ? 'no disponible' : JSON.stringify(value), ''];
28
-
29
- function fitConversation(head, conversation) {
30
- const messages = [...conversation];
31
- let text = section('Conversación', messages).join('\n');
32
- while (messages.length && head.length + text.length > MAX_INPUT_CHARS) {
33
- messages.shift();
34
- text = section('Conversación', messages).join('\n');
35
- }
36
- return { text, truncated: messages.length < conversation.length };
37
- }
38
-
39
- async function buildConsultContext({ code, turnStart, patientMessage = null, mentions = [] }) {
40
- const asOf = new Date(turnStart);
41
- const [context, profile, risk, escalations] = await Promise.all([
42
- getEscalationContext({ code, sections: CONTEXT_SECTIONS }),
43
- readClinicalProfile(code),
44
- optional(assembleRiskProfile(code)),
45
- optional(readDispatchedEscalations({ patientId: code, since: escalationLookbackSince(asOf.getTime()) })),
46
- ]);
47
- const exposure = buildExposureBlock(profile);
48
- const degradedSections = [...context.degradedSections];
49
- if (!profile) degradedSections.push('clinicalProfile');
50
- if (risk === undefined) degradedSections.push('risk');
51
- if (escalations === undefined) degradedSections.push('escalations');
52
-
53
- const head = [
54
- `Fecha de hoy: ${asOf.toISOString().slice(0, 10)}`,
55
- '',
56
- '## Mensaje del paciente en este turno',
57
- patientMessage || 'no capturado',
58
- '',
59
- ...section('Menciones clínicas extraídas en este turno', mentions),
60
- ...section('Contexto clínico que ve la enfermera', context.agentContext ?? undefined),
61
- '## Exposición antineoplásica y medicación documentadas',
62
- exposure || 'no documentada',
63
- '',
64
- ...section('Perfil clínico', clinicalProfile(profile)),
65
- ...section('Perfil de riesgo', risk?.profile ?? undefined),
66
- ...section('Escalamientos de los últimos 14 días', escalations?.filter((event) => Date.parse(event.escalatedAt) < asOf.getTime())),
67
- ...section('Historia clínica reciente', context.history ?? undefined),
68
- ].join('\n');
69
- const conversation = fitConversation(head, conversationBeforeReply(context.conversation));
70
- if (conversation.truncated) degradedSections.push('conversation_truncated');
71
- const input = `${head}\n${conversation.text}`;
72
-
73
- return { input: redactDirectIdentifiers(input), degradedSections, hasCoreContext: Boolean(context.agentContext || profile) };
74
- }
75
-
76
- module.exports = { buildConsultContext };