@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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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 =
|
|
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
|
|
273
|
-
|
|
274
|
-
{
|
|
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 (
|
|
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 } =
|
|
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;
|