@peopl-health/nexus 5.68.10 → 5.68.12
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) {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
|
|
1
3
|
const { getEscalationPatternFloor } = require('../config/subAgentsConfig');
|
|
2
4
|
const { requireGatewayProvider } = require('../config/llmConfig');
|
|
3
5
|
const { readClinicalProfile } = require('../helpers/clinicalProfileHelper');
|
|
@@ -9,13 +11,17 @@ const { isPlainObject, safeParse } = require('../../utils/jsonUtils');
|
|
|
9
11
|
const { logger } = require('../../utils/logger');
|
|
10
12
|
const { redactDirectIdentifiers } = require('../../utils/sanitizerUtils');
|
|
11
13
|
const { withTimeout } = require('../../utils/timeoutUtils');
|
|
14
|
+
const { composePrompt } = require('./promptComposerService');
|
|
12
15
|
|
|
13
16
|
const FLOOR_LEVELS = ['asap', 'stat'];
|
|
14
17
|
const PATTERN_NAME = /^[a-z0-9_]{1,64}$/;
|
|
15
18
|
const UNLABELLED_PATTERN = 'sin_etiqueta';
|
|
16
19
|
const MAX_TEXT_LENGTH = 500;
|
|
20
|
+
const MAX_LEVEL_LENGTH = 32;
|
|
21
|
+
const MAX_PATTERN_LENGTH = 64;
|
|
22
|
+
const PROVENANCE_GRACE_MS = 500;
|
|
17
23
|
|
|
18
|
-
const degraded = (reason) => ({ floor: null, degraded: reason });
|
|
24
|
+
const degraded = (reason, check = null) => ({ floor: null, degraded: reason, check });
|
|
19
25
|
|
|
20
26
|
function normalizePattern(raw) {
|
|
21
27
|
const name = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
|
|
@@ -24,6 +30,32 @@ function normalizePattern(raw) {
|
|
|
24
30
|
|
|
25
31
|
const clippedText = (value) => cleanText(value)?.slice(0, MAX_TEXT_LENGTH) || null;
|
|
26
32
|
|
|
33
|
+
// Which prompt judged this escalation. Prompt text is edited in Airtable without bumping the preset
|
|
34
|
+
// version, so the version alone cannot tell two prompts apart; the hash of the composed prompt can.
|
|
35
|
+
async function promptProvenance(presetId, turnId) {
|
|
36
|
+
try {
|
|
37
|
+
// Composed alongside runStructured's own compose, with the same inputs and caches: it names the prompt
|
|
38
|
+
// the model ran with, though an Airtable edit landing between the two reads could make them differ.
|
|
39
|
+
const composed = await composePrompt({ presetId });
|
|
40
|
+
// A missing preset composes to an empty prompt; a hash of nothing would read as real provenance.
|
|
41
|
+
const promptHash = composed.resolvedPrompt
|
|
42
|
+
? crypto.createHash('sha256').update(`${composed.resolvedPrompt}\n${JSON.stringify(composed.modelConfig || null)}`).digest('hex').slice(0, 12)
|
|
43
|
+
: null;
|
|
44
|
+
return { presetId, presetVersion: composed.presetVersion || null, promptHash };
|
|
45
|
+
} catch (error) {
|
|
46
|
+
logger.warn('[clinicalPatternFloor] The prompt provenance could not be read', { turnId, error: error.message });
|
|
47
|
+
return { presetId, presetVersion: null, promptHash: null };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// What the model answered, before validation, so a rejected or empty answer can be audited too.
|
|
52
|
+
const rawOutput = (parsed) => ({
|
|
53
|
+
minLevel: typeof parsed.min_level === 'string' ? parsed.min_level.slice(0, MAX_LEVEL_LENGTH) : null,
|
|
54
|
+
pattern: typeof parsed.pattern === 'string' ? parsed.pattern.slice(0, MAX_PATTERN_LENGTH) : null,
|
|
55
|
+
evidence: clippedText(parsed.evidence),
|
|
56
|
+
reason: clippedText(parsed.reason),
|
|
57
|
+
});
|
|
58
|
+
|
|
27
59
|
function buildPatternInput({ profile, details, justification, today }) {
|
|
28
60
|
const indicators = Array.isArray(details.indicators) ? details.indicators.filter((item) => typeof item === 'string' && item.trim()) : [];
|
|
29
61
|
const exposure = buildExposureBlock(profile);
|
|
@@ -55,32 +87,42 @@ async function evaluatePatternFloor({ code, turnId, details, justification, prov
|
|
|
55
87
|
logger.warn('[clinicalPatternFloor] The clinical profile is missing or unreadable; the check is skipped', { turnId, profile: reason });
|
|
56
88
|
return degraded(reason);
|
|
57
89
|
}
|
|
90
|
+
// Read alongside the model call so the audit never spends the floor's deadline.
|
|
91
|
+
const pendingProvenance = promptProvenance(presetId, turnId);
|
|
58
92
|
const result = await (provider || requireGatewayProvider()).runStructured({
|
|
59
93
|
presetId,
|
|
60
94
|
input: [{ role: 'user', content: buildPatternInput({ profile, details, justification, today: now.toISOString().slice(0, 10) }) }],
|
|
61
95
|
metadata: { subAgent: 'escalation-pattern-floor', turnId },
|
|
62
96
|
});
|
|
97
|
+
// Once the model has answered, provenance gets a short grace and is otherwise dropped: a slow
|
|
98
|
+
// Airtable read must never turn an answer the floor already has into a timeout.
|
|
99
|
+
const provenance = await withTimeout(pendingProvenance, PROVENANCE_GRACE_MS, 'pattern floor provenance').catch((error) => {
|
|
100
|
+
logger.warn('[clinicalPatternFloor] The prompt provenance was not ready; the answer is kept without it', { turnId, error: error.message });
|
|
101
|
+
return { presetId, presetVersion: null, promptHash: null };
|
|
102
|
+
});
|
|
63
103
|
if (result?.status && result.status !== 'completed') {
|
|
64
104
|
logger.warn('[clinicalPatternFloor] The pattern check did not complete', { turnId, status: result.status });
|
|
65
|
-
return degraded('incomplete');
|
|
105
|
+
return degraded('incomplete', { ...provenance, output: null });
|
|
66
106
|
}
|
|
67
107
|
const parsed = safeParse(readStructuredText(result), null);
|
|
68
108
|
if (!isPlainObject(parsed)) {
|
|
69
109
|
logger.warn('[clinicalPatternFloor] The pattern check did not return a JSON object', { turnId });
|
|
70
|
-
return degraded('malformed_output');
|
|
110
|
+
return degraded('malformed_output', { ...provenance, output: null });
|
|
71
111
|
}
|
|
112
|
+
const check = { ...provenance, output: rawOutput(parsed) };
|
|
72
113
|
if (parsed.min_level == null) {
|
|
73
|
-
if (parsed.pattern == null) return { floor: null, degraded: null };
|
|
114
|
+
if (parsed.pattern == null) return { floor: null, degraded: null, check };
|
|
74
115
|
logger.warn('[clinicalPatternFloor] The pattern check named a pattern without a level', { turnId, pattern: parsed.pattern });
|
|
75
|
-
return degraded('missing_level');
|
|
116
|
+
return degraded('missing_level', check);
|
|
76
117
|
}
|
|
77
118
|
if (!FLOOR_LEVELS.includes(parsed.min_level)) {
|
|
78
119
|
logger.warn('[clinicalPatternFloor] The pattern check returned an unsupported level', { turnId, minLevel: parsed.min_level, pattern: parsed.pattern });
|
|
79
|
-
return degraded('unsupported_level');
|
|
120
|
+
return degraded('unsupported_level', check);
|
|
80
121
|
}
|
|
81
122
|
return {
|
|
82
123
|
floor: { level: parsed.min_level, pattern: normalizePattern(parsed.pattern), evidence: clippedText(parsed.evidence), reason: clippedText(parsed.reason) },
|
|
83
124
|
degraded: null,
|
|
125
|
+
check,
|
|
84
126
|
};
|
|
85
127
|
}
|
|
86
128
|
|
|
@@ -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) {
|
|
@@ -349,6 +349,7 @@ async function handler(args, context = {}) {
|
|
|
349
349
|
...(patternFloor ? { clinicalPatternFloor: { ...patternFloor, raised: raisedByPattern } } : {}),
|
|
350
350
|
...(gateCheck?.degraded ? { triageGateFloorDegraded: gateCheck.degraded } : {}),
|
|
351
351
|
...(patternCheck?.degraded ? { clinicalPatternFloorDegraded: patternCheck.degraded } : {}),
|
|
352
|
+
...(patternCheck?.check ? { clinicalPatternFloorCheck: patternCheck.check } : {}),
|
|
352
353
|
},
|
|
353
354
|
});
|
|
354
355
|
|