@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 doctorText = composeDoctorBody({
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 codesWithAFailedSend(now, { maxAgeMs = MAX_AGE_MS, limit = SCAN_LIMIT } = {}) {
52
+ async function codesWithOutbound(match, now, { maxAgeMs = MAX_AGE_MS, limit = SCAN_LIMIT } = {}) {
51
53
  const rows = await aggregateMessages([
52
- { $match: { ...FAILED_SEND, from_me: true, createdAt: { $gte: new Date(now.getTime() - maxAgeMs) } } },
53
- { $group: { _id: '$numero', firstFailedAt: { $min: '$createdAt' } } },
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: { firstFailedAt: 1, _id: 1 } },
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, failedMaxAgeMs = MAX_AGE_MS, limit = SCAN_LIMIT } = {}) {
62
- const [threads, failedSends] = await Promise.all([
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
- codesWithAFailedSend(now, { maxAgeMs: failedMaxAgeMs, limit }),
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, failedMaxAgeMs: COLD_MAX_AGE_MS, limit: COLD_SCAN_LIMIT,
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.71.3",
3
+ "version": "5.71.5",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",