@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.
- package/lib/clinical/monitoring/LlmMonitorService.js +4 -12
- package/lib/clinical/providers/AnthropicProvider.js +4 -16
- package/lib/clinical/services/outreachSweepService.js +206 -35
- package/lib/core/NexusMessaging.js +3 -7
- package/lib/index.d.ts +3 -0
- package/package.json +1 -1
- package/lib/clinical/flags/emergencyConsultFlags.js +0 -16
- package/lib/clinical/monitoring/emergencyConsultMonitorSpec.js +0 -215
- package/lib/clinical/services/emergencyConsultService.js +0 -76
|
@@ -230,7 +230,7 @@ class LlmMonitorService {
|
|
|
230
230
|
if (!evaluation) return Evaluations.findOne(key);
|
|
231
231
|
|
|
232
232
|
try {
|
|
233
|
-
const evidence =
|
|
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
|
-
|
|
252
|
-
|
|
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 &&
|
|
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
|
-
|
|
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(() =>
|
|
@@ -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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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 =
|
|
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
|
|
273
|
-
|
|
274
|
-
{
|
|
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 (
|
|
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
|
|
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
|
|
510
|
+
return 'claim_lost';
|
|
372
511
|
}
|
|
373
|
-
return
|
|
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
|
|
517
|
+
return 'claim_lost';
|
|
379
518
|
}
|
|
380
519
|
}
|
|
381
520
|
|
|
382
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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 } =
|
|
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
|
-
|
|
661
|
+
const claimLost = await claimCandidate({ candidate, weekdayLabel, dryRun, now });
|
|
662
|
+
if (claimLost) {
|
|
499
663
|
counts.rejected++;
|
|
500
|
-
rejectedBy
|
|
501
|
-
return { code: candidate.code, recordId: candidate.recordId, decision: 'skipped', holdReason:
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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,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 };
|