@peopl-health/nexus 5.69.0-dev.12579 → 5.69.0-dev.12622

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.
@@ -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(() =>
@@ -8,6 +8,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,18 @@ const AUDIT = {
49
50
  turnId: 'seguimiento_turn_id',
50
51
  };
51
52
 
53
+ const WEEKLY_SOURCE = 'weekly';
54
+ const SCOPE_SOURCE = 'patient_scope';
55
+
56
+ const PATIENT_TABLE = 'estado_general';
57
+ const PATIENT_F = {
58
+ whatsapp: 'whatsapp_id',
59
+ firstName: 'first_name',
60
+ disableFollowUp: 'desactivar_seguimiento_48hrs',
61
+ };
62
+
63
+ const sweepClaims = new WriteClaimStore({ namespace: 'outreach_sweep', ttlMs: 8 * 24 * 60 * 60 * 1000 });
64
+
52
65
  const UNDELIVERED_STATUSES = ['failed', 'undelivered'];
53
66
  const CONTACT_SENT = {
54
67
  from_me: true,
@@ -62,6 +75,7 @@ const CONTACT_SENT = {
62
75
  const RECENT_OUTBOUND_HOURS = 24;
63
76
  const COLLIDING_REMINDER_MINUTES = 24 * 60;
64
77
  const INACTIVITY_DAYS = 7;
78
+ const MAX_SILENCE_DAYS = 30;
65
79
  const TRIAGE_PROTECTION_START_MINUTE = (7 * 60) + 30;
66
80
  const TRIAGE_PROTECTION_END_MINUTE = 11 * 60;
67
81
 
@@ -202,7 +216,63 @@ function buildCandidates(rows, { now, cadence = 'daily', mode = 'continuity' })
202
216
  return { candidates, targetDays, weekday, cadence, mode };
203
217
  }
204
218
 
219
+ function buildScopeCandidates({ patientScope, patientRows, excludedCodes }) {
220
+ const patients = new Map(patientScope.filter((code) => !excludedCodes.has(code))
221
+ .map((code) => [code, { names: new Map(), recordId: null, followUpDisabled: false }]));
222
+ for (const row of patientRows) {
223
+ const patient = patients.get(String(first(row[PATIENT_F.whatsapp]) || '').trim());
224
+ if (!patient) continue;
225
+ const name = String(first(row[PATIENT_F.firstName]) || '').trim();
226
+ if (name) patient.names.set(name.toLowerCase().replace(/\s+/g, ' '), name);
227
+ patient.recordId = patient.recordId || row._recordId;
228
+ if (row[PATIENT_F.disableFollowUp] === true) patient.followUpDisabled = true;
229
+ }
230
+
231
+ const candidates = [];
232
+ for (const [code, patient] of patients) {
233
+ if (patient.names.size !== 1) {
234
+ logger.info('[outreachSweep] scope candidate skipped — no single patient name', { recordId: patient.recordId });
235
+ continue;
236
+ }
237
+ candidates.push({
238
+ source: SCOPE_SOURCE,
239
+ patientId: null,
240
+ recordId: patient.recordId,
241
+ code,
242
+ name: [...patient.names.values()][0],
243
+ reason: 'no_triage_history',
244
+ followUpDisabled: patient.followUpDisabled,
245
+ rows: [],
246
+ brief: {
247
+ reason: 'no hay un triaje contestado que sirva como ancla clínica',
248
+ lastTriageAt: null,
249
+ maxGrade: null,
250
+ positiveSymptoms: [],
251
+ },
252
+ });
253
+ }
254
+ return candidates;
255
+ }
256
+
257
+ async function loadScopeCandidates({ now, viewRows, patientScope }) {
258
+ const weekStart = startOfLocalWeek(now).toISOString();
259
+ const [patientRows, weekRows] = await Promise.all([
260
+ airtableService.getRecordByFilter(Symptoms_ID, PATIENT_TABLE, 'TRUE()', null, Object.values(PATIENT_F), true),
261
+ airtableService.getRecordByFilter(
262
+ Symptoms_ID, TABLE, `NOT(IS_BEFORE({${F.createdAt}}, DATETIME_PARSE("${weekStart}")))`, null, [F.whatsapp],
263
+ ),
264
+ ]);
265
+ const excludedCodes = new Set([...viewRows, ...(weekRows || [])]
266
+ .map((row) => String(first(row[F.whatsapp]) || '').trim()));
267
+ return buildScopeCandidates({ patientScope, patientRows: patientRows || [], excludedCodes });
268
+ }
269
+
270
+ function sweepClaimKey(candidate, now) {
271
+ return `${candidate.code}:${startOfLocalWeek(now).format('YYYY-MM-DD')}`;
272
+ }
273
+
205
274
  function opsOptedOut(candidate, { now }) {
275
+ if (candidate.followUpDisabled) return 'patient_follow_up_disabled';
206
276
  const weekStart = startOfLocalWeek(now);
207
277
  for (const row of candidate.rows) {
208
278
  if (first(row[F.disableFollowUp]) === true) return 'patient_follow_up_disabled';
@@ -239,7 +309,9 @@ async function rejectionReason(candidate, {
239
309
  if (opsReason) return opsReason;
240
310
  if (await botIsPaused(candidate.code, { label: '[outreachSweep]' })) return 'bot_paused';
241
311
  if (!includePreviouslySwept) {
242
- const sweptReason = priorSweepReason(candidate);
312
+ const sweptReason = candidate.source === SCOPE_SOURCE
313
+ ? (await sweepClaims.has(sweepClaimKey(candidate, now)) ? 'already_swept_this_week' : null)
314
+ : priorSweepReason(candidate);
243
315
  if (sweptReason) return sweptReason;
244
316
 
245
317
  const since = mode === 'monday_triage_recovery'
@@ -269,11 +341,13 @@ async function rejectionReason(candidate, {
269
341
  if (!await mondayTriageStillPending(candidate)) return 'triage_no_longer_pending';
270
342
  } else if (cadence === 'daily') {
271
343
  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 },
344
+ const silentSince = moment.tz(now, DEFAULT_TIMEZONE).subtract(MAX_SILENCE_DAYS, 'days').toDate();
345
+ const [lastInbound] = await getMessages(
346
+ { numero: candidate.code, from_me: false, createdAt: { $gte: silentSince } },
347
+ { select: { createdAt: 1 }, sort: { createdAt: -1 }, limit: 1 },
275
348
  );
276
- if (recentInbound?.length) return 'patient_active_within_inactivity_window';
349
+ if (!lastInbound) return 'patient_silent_beyond_max_window';
350
+ if (new Date(lastInbound.createdAt) >= inactiveSince) return 'patient_active_within_inactivity_window';
277
351
 
278
352
  const lastTriageAt = candidate?.brief?.lastTriageAt;
279
353
  if (lastTriageAt && moment.tz(lastTriageAt, DEFAULT_TIMEZONE).isSameOrAfter(inactiveSince)) {
@@ -356,8 +430,11 @@ async function enrichBrief(candidate) {
356
430
  // so a second overlapping sweep sees this one and stands down. A turn that then dies leaves the
357
431
  // patient uncontacted for the week, which is the safe direction: a free-text send carries no
358
432
  // contentSid and no idempotency key, so nothing downstream would catch a duplicate.
359
- async function claimCandidate({ candidate, weekdayLabel, dryRun }) {
433
+ async function claimCandidate({ candidate, weekdayLabel, dryRun, now }) {
360
434
  if (dryRun) return true;
435
+ if (candidate.source === SCOPE_SOURCE) {
436
+ return (await sweepClaims.acquire(sweepClaimKey(candidate, now))) === 'acquired';
437
+ }
361
438
  try {
362
439
  const claimed = await airtableService.updateRecordByFilter(
363
440
  Symptoms_ID,
@@ -380,7 +457,7 @@ async function claimCandidate({ candidate, weekdayLabel, dryRun }) {
380
457
  }
381
458
 
382
459
  async function recordOutcome({ candidate, outcome, weekdayLabel, dryRun }) {
383
- if (dryRun) return;
460
+ if (dryRun || candidate.source === SCOPE_SOURCE) return;
384
461
  // Three outcomes, not two: a `write` whose delivery never happened is neither "escrito" nor a
385
462
  // deliberate hold, and collapsing it into either would hide the failure from the ops queue.
386
463
  let stamp;
@@ -419,6 +496,7 @@ async function runOutreachSweep({
419
496
  includePreviouslySwept = false,
420
497
  cadence = 'daily',
421
498
  mode = 'continuity',
499
+ source = WEEKLY_SOURCE,
422
500
  runOutreachTurn,
423
501
  } = {}) {
424
502
  if (typeof runOutreachTurn !== 'function') {
@@ -440,6 +518,12 @@ async function runOutreachSweep({
440
518
  if (!['continuity', 'monday_triage_recovery'].includes(mode)) {
441
519
  throw new Error('mode must be continuity or monday_triage_recovery');
442
520
  }
521
+ if (![WEEKLY_SOURCE, SCOPE_SOURCE].includes(source)) {
522
+ throw new Error('source must be weekly or patient_scope');
523
+ }
524
+ if (source === SCOPE_SOURCE && (mode !== 'continuity' || cadence !== 'daily' || !Array.isArray(patientScope))) {
525
+ throw new Error('the patient_scope source requires continuity mode, daily cadence and a patientScope');
526
+ }
443
527
 
444
528
  const localWeekday = moment.tz(now, DEFAULT_TIMEZONE).isoWeekday();
445
529
  if (mode === 'monday_triage_recovery' && localWeekday !== 1) {
@@ -457,7 +541,9 @@ async function runOutreachSweep({
457
541
  const rows = await airtableService.getRecordByFilter(
458
542
  Symptoms_ID, TABLE, 'TRUE()', mode === 'monday_triage_recovery' ? null : VIEW, Object.values(F), true,
459
543
  );
460
- const { candidates, targetDays, weekday } = buildCandidates(rows || [], { now, cadence, mode });
544
+ const { candidates, targetDays, weekday } = source === SCOPE_SOURCE
545
+ ? { candidates: await loadScopeCandidates({ now, viewRows: rows || [], patientScope }), targetDays: [], weekday: localWeekday }
546
+ : buildCandidates(rows || [], { now, cadence, mode });
461
547
  if (cadence === 'legacy' && !targetDays.length) {
462
548
  logger.info('[outreachSweep] not a sweep day — nothing to do', { weekday });
463
549
  return { ran: false, weekday, reason: 'not_a_sweep_day', counts: {} };
@@ -495,7 +581,7 @@ async function runOutreachSweep({
495
581
  const weekdayLabel = moment.tz(now, DEFAULT_TIMEZONE).locale('es').format('dddd');
496
582
  const decisions = await mapWithConcurrency(admitted, concurrency, async (candidate) => {
497
583
  const brief = await enrichBrief(candidate);
498
- if (!dryRun && !await claimCandidate({ candidate, weekdayLabel, dryRun })) {
584
+ if (!dryRun && !await claimCandidate({ candidate, weekdayLabel, dryRun, now })) {
499
585
  counts.rejected++;
500
586
  rejectedBy.claim_lost = (rejectedBy.claim_lost || 0) + 1;
501
587
  return { code: candidate.code, recordId: candidate.recordId, decision: 'skipped', holdReason: 'claim_lost' };
@@ -546,12 +632,13 @@ async function runOutreachSweep({
546
632
  });
547
633
 
548
634
  logger.info('[outreachSweep] complete', { dryRun, counts, rejectedBy });
549
- return { ran: true, weekday, cadence, mode, targetDays, dryRun, counts, rejectedBy, decisions };
635
+ return { ran: true, weekday, cadence, mode, source, targetDays, dryRun, counts, rejectedBy, decisions };
550
636
  }
551
637
 
552
638
  module.exports = {
553
639
  runOutreachSweep,
554
640
  buildCandidates,
641
+ buildScopeCandidates,
555
642
  rejectionReason,
556
643
  priorSweepReason,
557
644
  TARGET_DAYS_BY_WEEKDAY,
@@ -562,5 +649,7 @@ module.exports = {
562
649
  mondayTriageStillPending,
563
650
  VIEW,
564
651
  F,
652
+ PATIENT_F,
565
653
  AUDIT,
654
+ sweepClaims,
566
655
  };
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' | 'patient_scope';
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. patient_scope: the scoped patients outside that view; requires continuity, daily and a patientScope. */
525
+ source?: 'weekly' | 'patient_scope';
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.12579",
3
+ "version": "5.69.0-dev.12622",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",