@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:
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 } =
|
|
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
|
|
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,
|
|
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 },
|