@peopl-health/nexus 5.71.3 → 5.71.5
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.
|
@@ -19,11 +19,13 @@ const CONVERSATION_TURNS = 40;
|
|
|
19
19
|
const TRIAGE_LIMIT = 3;
|
|
20
20
|
const CALL_LOG_LIMIT = 10;
|
|
21
21
|
|
|
22
|
+
const TECHNICAL_TOKEN = /\b(urgentIfPositive|blocksDrug|null)\b|\b[a-z]+_\w+|\\n/i;
|
|
23
|
+
|
|
22
24
|
const trimmed = (value) => String(value ?? '').trim();
|
|
23
25
|
|
|
24
26
|
function withholdReason(sections) {
|
|
25
27
|
const { present } = flagsByStatus(sections);
|
|
26
|
-
const modifiers = (Array.isArray(sections?.treatmentModifiers) ? sections.treatmentModifiers : []).map(trimmed).filter(Boolean);
|
|
28
|
+
const modifiers = (Array.isArray(sections?.treatmentModifiers) ? sections.treatmentModifiers : []).map((modifier) => trimmed(modifier).replace(/_/g, ' ')).filter(Boolean);
|
|
27
29
|
const reasons = [];
|
|
28
30
|
if (present.length) reasons.push(present.length === 1 ? 'hay una bandera roja presente' : 'hay banderas rojas presentes');
|
|
29
31
|
if (modifiers.length) reasons.push(`condición que cambia el algoritmo (${modifiers.join(', ')})`);
|
|
@@ -172,16 +174,20 @@ async function generateEscalationDraft({
|
|
|
172
174
|
return { completed: false, attempted: true, reason: `the treatment proposal broke the formulary rules: ${issues.join('; ')}` };
|
|
173
175
|
}
|
|
174
176
|
|
|
175
|
-
const
|
|
176
|
-
alarm: alarms[alarms.length - 1] ?? null,
|
|
177
|
+
const draft = {
|
|
177
178
|
shared: parsed,
|
|
178
179
|
symptoms: settled.map(({ block, proposal, withheldReason, conduct }) => ({ sections: block, proposal, withheldReason, conduct })),
|
|
179
|
-
}
|
|
180
|
+
};
|
|
181
|
+
const doctorText = composeDoctorBody({ ...draft, alarm: alarms[alarms.length - 1] ?? null });
|
|
180
182
|
|
|
181
183
|
if (!doctorText || !patientMessage) {
|
|
182
184
|
logger.warn('[escalationDraft] generation returned no usable text', { reviewId });
|
|
183
185
|
return { completed: false, attempted: true, reason: 'the model returned no usable text' };
|
|
184
186
|
}
|
|
187
|
+
if (TECHNICAL_TOKEN.test(`${composeDoctorBody({ ...draft, alarm: null })}\n${patientMessage}`)) {
|
|
188
|
+
logger.warn('[escalationDraft] generation leaked a technical token into the text', { reviewId });
|
|
189
|
+
return { completed: false, attempted: true, reason: 'the model wrote a field name or technical token into the doctor or patient text' };
|
|
190
|
+
}
|
|
185
191
|
|
|
186
192
|
return {
|
|
187
193
|
completed: true,
|
|
@@ -33,6 +33,8 @@ const ticketClaims = new WriteClaimStore({ namespace: 'inbound-recovery-ticket',
|
|
|
33
33
|
const coldTicketClaims = new WriteClaimStore({ namespace: 'inbound-recovery-cold-ticket', ttlMs: COLD_TICKET_TTL_MS, maxSize: 2000 });
|
|
34
34
|
|
|
35
35
|
const FAILED_SEND = { 'statusInfo.status': 'failed', message_id: null };
|
|
36
|
+
// A holding bubble sent mid-turn, not an answer: a turn that dies after it leaves the patient waiting.
|
|
37
|
+
const BRIDGE = { 'raw.bridge': true };
|
|
36
38
|
const UNANSWERED_QUESTION = 'El paciente escribió y no recibió respuesta.';
|
|
37
39
|
|
|
38
40
|
async function unansweredThreads(now, { minAgeMs = MIN_AGE_MS, maxAgeMs = MAX_AGE_MS, limit = SCAN_LIMIT } = {}) {
|
|
@@ -47,24 +49,26 @@ async function unansweredThreads(now, { minAgeMs = MIN_AGE_MS, maxAgeMs = MAX_AG
|
|
|
47
49
|
).sort({ lastMessageAt: 1 }).limit(limit).lean();
|
|
48
50
|
}
|
|
49
51
|
|
|
50
|
-
async function
|
|
52
|
+
async function codesWithOutbound(match, now, { maxAgeMs = MAX_AGE_MS, limit = SCAN_LIMIT } = {}) {
|
|
51
53
|
const rows = await aggregateMessages([
|
|
52
|
-
{ $match: { ...
|
|
53
|
-
{ $group: { _id: '$numero',
|
|
54
|
+
{ $match: { ...match, from_me: true, createdAt: { $gte: new Date(now.getTime() - maxAgeMs) } } },
|
|
55
|
+
{ $group: { _id: '$numero', firstAt: { $min: '$createdAt' } } },
|
|
54
56
|
// _id breaks the tie: without it a page of shared timestamps orders differently each pass.
|
|
55
|
-
{ $sort: {
|
|
57
|
+
{ $sort: { firstAt: 1, _id: 1 } },
|
|
56
58
|
{ $limit: limit },
|
|
57
59
|
]);
|
|
58
60
|
return rows.map((row) => row._id);
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
async function candidateCodes(now, { threadBand = null,
|
|
62
|
-
const
|
|
63
|
+
async function candidateCodes(now, { threadBand = null, outboundMaxAgeMs = MAX_AGE_MS, limit = SCAN_LIMIT } = {}) {
|
|
64
|
+
const outbound = { maxAgeMs: outboundMaxAgeMs, limit };
|
|
65
|
+
const [threads, failedSends, bridges] = await Promise.all([
|
|
63
66
|
unansweredThreads(now, { ...(threadBand || {}), limit }),
|
|
64
|
-
|
|
67
|
+
codesWithOutbound(FAILED_SEND, now, outbound),
|
|
68
|
+
codesWithOutbound(BRIDGE, now, outbound),
|
|
65
69
|
]);
|
|
66
|
-
const atCeiling = threads.length >= limit || failedSends.length >= limit;
|
|
67
|
-
const codes = [...new Set([...threads.map((thread) => thread.code), ...failedSends].filter(Boolean))];
|
|
70
|
+
const atCeiling = threads.length >= limit || failedSends.length >= limit || bridges.length >= limit;
|
|
71
|
+
const codes = [...new Set([...threads.map((thread) => thread.code), ...failedSends, ...bridges].filter(Boolean))];
|
|
68
72
|
return { codes, atCeiling };
|
|
69
73
|
}
|
|
70
74
|
|
|
@@ -81,7 +85,7 @@ async function stillUnanswered(code, now = new Date(), { minAgeMs = MIN_AGE_MS,
|
|
|
81
85
|
const ageMs = now.getTime() - new Date(newest.createdAt).getTime();
|
|
82
86
|
if (!Number.isFinite(ageMs) || ageMs < minAgeMs || ageMs > maxAgeMs) return null;
|
|
83
87
|
|
|
84
|
-
const replies = await countMessages({ numero: code, from_me: true, createdAt: { $gt: newest.createdAt }, $nor: [FAILED_SEND] });
|
|
88
|
+
const replies = await countMessages({ numero: code, from_me: true, createdAt: { $gt: newest.createdAt }, $nor: [FAILED_SEND, BRIDGE] });
|
|
85
89
|
return replies === 0 ? newest : null;
|
|
86
90
|
}
|
|
87
91
|
|
|
@@ -235,7 +239,7 @@ async function sweepColdUnansweredInbound({
|
|
|
235
239
|
if (mode === 'off') return { waiting: 0, alerted: 0 };
|
|
236
240
|
|
|
237
241
|
const { codes, atCeiling } = await candidateCodes(now, {
|
|
238
|
-
threadBand: COLD_BAND,
|
|
242
|
+
threadBand: COLD_BAND, outboundMaxAgeMs: COLD_MAX_AGE_MS, limit: COLD_SCAN_LIMIT,
|
|
239
243
|
});
|
|
240
244
|
// Answered conversations are filtered below, after the page is cut, so a full page is a page
|
|
241
245
|
// that may be hiding patients behind it. Say so rather than let the tail go unseen quietly.
|